diff --git a/package-lock.json b/package-lock.json index 26c21f4..75114d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-opener": "^2.5.3", "lucide-svelte": "^0.474.0" }, "devDependencies": { @@ -1552,6 +1553,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", + "integrity": "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tsconfig/svelte": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", diff --git a/package.json b/package.json index 776e158..e750f1e 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-opener": "^2.5.3", "lucide-svelte": "^0.474.0" }, "devDependencies": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f3d1f21..4ad1fb2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -19,9 +19,11 @@ dependencies = [ "tauri-plugin-log", "tauri-plugin-opener", "tempfile", + "thiserror 1.0.69", "tokio", "unrar", "urlencoding", + "walkdir", "winreg", "zip", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f29cd28..ddb8eb7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -33,6 +33,8 @@ zip = "2" sevenz-rust = "0.6" unrar = "0.5.8" urlencoding = "2" +walkdir = "2.5.0" +thiserror = "1" [dev-dependencies] tempfile = "3" diff --git a/src-tauri/src/commands/browse.rs b/src-tauri/src/commands/browse.rs index 9be67b5..ca2ff6a 100644 --- a/src-tauri/src/commands/browse.rs +++ b/src-tauri/src/commands/browse.rs @@ -55,7 +55,7 @@ pub async fn browse_install_mod( author: mod_author, }; - let result = crate::services::installer::install_with_metadata(&archive_path, &dir, &metadata); + let result = crate::services::installer::install(&archive_path, &dir, Some(&metadata)); let _ = std::fs::remove_file(&archive_path); result } diff --git a/src-tauri/src/commands/logs.rs b/src-tauri/src/commands/logs.rs index 64ee527..3d1b9db 100644 --- a/src-tauri/src/commands/logs.rs +++ b/src-tauri/src/commands/logs.rs @@ -1,6 +1,6 @@ use tauri::State; -use crate::errors::AppError; +use crate::errors::{AppError, Context}; use crate::state::AppState; #[tauri::command] @@ -17,10 +17,7 @@ pub async fn read_log_file(state: State<'_, AppState>) -> Result Ok(String::from_utf8_lossy(&bytes).into_owned()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), - Err(e) => Err(AppError::from(format!( - "Failed to read {}: {e}", - log_path.display() - ))), + Err(e) => Err(e).with_context(|| format!("failed to read {}", log_path.display())), } } @@ -37,5 +34,5 @@ pub async fn watch_log_file( .ok_or_else(|| AppError::from("Game directory not set"))?; let jeode_dir = dir.join("jeode"); - crate::services::log_watcher::start(app, jeode_dir) + crate::services::jeodewatcher::start(app, jeode_dir) } diff --git a/src-tauri/src/commands/mods.rs b/src-tauri/src/commands/mods.rs index 3bea1ec..01a0b29 100644 --- a/src-tauri/src/commands/mods.rs +++ b/src-tauri/src/commands/mods.rs @@ -1,7 +1,7 @@ use tauri::State; use tauri_plugin_dialog::DialogExt; -use crate::errors::AppError; +use crate::errors::{AppError, Context}; use crate::services::mods::{self, ModInfo}; use crate::state::AppState; @@ -36,8 +36,8 @@ pub async fn remove_mod(id: String, state: State<'_, AppState>) -> Result<(), Ap pub async fn open_mod_folder(id: String, state: State<'_, AppState>) -> Result<(), AppError> { let dir = game_dir(&state)?; let mod_path = mods::mod_folder_path(&dir, &id)?; - tauri_plugin_opener::reveal_item_in_dir(&mod_path) - .map_err(|e| AppError::from(format!("Failed to open mod folder: {e}"))) + tauri_plugin_opener::reveal_item_in_dir(&mod_path).context("failed to open mod folder")?; + Ok(()) } #[tauri::command] @@ -71,7 +71,7 @@ pub async fn install_mod( let path = file_path .into_path() .map_err(|_| AppError::from("Invalid file path selected"))?; - let result = crate::services::installer::install(&path, &dir)?; + let result = crate::services::installer::install(&path, &dir, None)?; Ok(Some(result)) } None => Ok(None), diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 452ef9b..a9e5963 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use tauri::State; use tauri_plugin_dialog::DialogExt; -use crate::errors::AppError; +use crate::errors::{AppError, Context}; use crate::services::{game, jeode}; use crate::state::AppState; @@ -85,5 +85,6 @@ const STEAM_LAUNCH_URL: &str = "steam://run/1419170"; #[tauri::command] pub async fn launch_game() -> Result<(), AppError> { tauri_plugin_opener::open_url(STEAM_LAUNCH_URL, None::<&str>) - .map_err(|e| AppError::from(format!("Failed to launch game: {e}"))) + .context("failed to launch game")?; + Ok(()) } diff --git a/src-tauri/src/errors.rs b/src-tauri/src/errors.rs index f60d165..111afdc 100644 --- a/src-tauri/src/errors.rs +++ b/src-tauri/src/errors.rs @@ -1,54 +1,101 @@ -use serde::Serialize; +use serde::{ser::SerializeStruct, Serialize, Serializer}; +use thiserror::Error; -#[derive(Debug, Serialize, Clone)] -pub struct AppError { - pub message: String, -} +#[derive(Debug, Error)] +pub enum AppError { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), -impl std::fmt::Display for AppError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.message) - } -} + #[error("network error: {0}")] + Network(#[from] reqwest::Error), -impl std::error::Error for AppError {} + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), -impl From for AppError { - fn from(err: std::io::Error) -> Self { - Self { - message: err.to_string(), - } - } + #[error("{message}")] + Other { + message: String, + #[source] + source: Option>, + }, } -impl From for AppError { - fn from(err: reqwest::Error) -> Self { - Self { - message: err.to_string(), +pub type AppResult = Result; + +impl AppError { + pub fn msg(message: impl Into) -> Self { + Self::Other { + message: message.into(), + source: None, } } -} -impl From for AppError { - fn from(err: serde_json::Error) -> Self { - Self { - message: err.to_string(), + fn kind(&self) -> &'static str { + match self { + Self::Io(_) => "io", + Self::Network(_) => "network", + Self::Json(_) => "json", + Self::Other { .. } => "other", } } } impl From for AppError { fn from(message: String) -> Self { - Self { message } + Self::msg(message) } } impl From<&str> for AppError { fn from(message: &str) -> Self { - Self { - message: message.to_string(), + Self::msg(message) + } +} + +impl Serialize for AppError { + fn serialize(&self, serializer: S) -> Result { + let mut message = self.to_string(); + let mut src = std::error::Error::source(self); + while let Some(e) = src { + message.push_str(": "); + message.push_str(&e.to_string()); + src = e.source(); } + + let mut s = serializer.serialize_struct("AppError", 2)?; + s.serialize_field("kind", self.kind())?; + s.serialize_field("message", &message)?; + s.end() } } -pub type AppResult = Result; +pub trait Context { + fn context(self, msg: impl Into) -> AppResult; + fn with_context(self, f: F) -> AppResult + where + F: FnOnce() -> S, + S: Into; +} + +impl Context for Result +where + E: std::error::Error + Send + Sync + 'static, +{ + fn context(self, msg: impl Into) -> AppResult { + self.map_err(|e| AppError::Other { + message: msg.into(), + source: Some(Box::new(e)), + }) + } + + fn with_context(self, f: F) -> AppResult + where + F: FnOnce() -> S, + S: Into, + { + self.map_err(|e| AppError::Other { + message: f().into(), + source: Some(Box::new(e)), + }) + } +} diff --git a/src-tauri/src/services/gamebanana.rs b/src-tauri/src/services/gamebanana.rs index a41f33b..603094c 100644 --- a/src-tauri/src/services/gamebanana.rs +++ b/src-tauri/src/services/gamebanana.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::errors::{AppError, AppResult}; +use crate::errors::{AppError, AppResult, Context}; const API_BASE: &str = "https://gamebanana.com/apiv11"; const MSM_GAME_ID: u32 = 9640; @@ -323,14 +323,14 @@ pub async fn download_to_temp(file_id: u64, file_name: &str) -> AppResult, } -enum ArchiveKind { +enum Archive { Zip, SevenZ, Rar, } -impl ArchiveKind { +impl Archive { fn from_path(path: &Path) -> AppResult { match path .extension() @@ -40,73 +41,68 @@ impl ArchiveKind { None => Err("No file extension ???".into()), } } -} - -fn staging_dir() -> AppResult { - let base = std::env::temp_dir().join("cantus/staging"); - std::fs::create_dir_all(&base)?; - Ok(base) -} - -fn unique_staging_path() -> AppResult { - let ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0); - - let path = staging_dir()?.join(format!("mod_{ts}")); - std::fs::create_dir_all(&path)?; - Ok(path) -} -fn extract(archive: &Path, dest: &Path) -> AppResult<()> { - match ArchiveKind::from_path(archive)? { - ArchiveKind::Zip => extract_zip(archive, dest), - ArchiveKind::SevenZ => extract_7z(archive, dest), - ArchiveKind::Rar => extract_rar(archive, dest), + fn extract(self, archive: &Path, dest: &Path) -> AppResult<()> { + match self { + Self::Zip => { + let file = std::fs::File::open(archive)?; + let mut ar = zip::ZipArchive::new(file).context("failed to open zip")?; + ar.extract(dest).context("failed to extract zip")?; + } + Self::SevenZ => { + sevenz_rust::decompress_file(archive, dest).context("7z extraction failed")?; + } + Self::Rar => { + let mut ar = unrar::Archive::new(archive) + .open_for_processing() + .context("failed to open rar")?; + while let Some(header) = ar.read_header().context("corrupt rar header")? { + ar = header + .extract_with_base(dest) + .context("rar extraction failed")?; + } + } + } + Ok(()) } } -fn extract_zip(path: &Path, dest: &Path) -> AppResult<()> { - let file = std::fs::File::open(path)?; - let mut ar = zip::ZipArchive::new(file) - .map_err(|e| AppError::from(format!("Failed to open zip: {e}")))?; - ar.extract(dest) - .map_err(|e| AppError::from(format!("Failed to extract zip: {e}")))?; - Ok(()) -} +struct StagingDir(PathBuf); + +impl StagingDir { + fn new() -> AppResult { + let base = std::env::temp_dir().join("cantus/staging"); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + let path = base.join(format!("mod_{ts}")); + std::fs::create_dir_all(&path)?; + Ok(Self(path)) + } -fn extract_7z(path: &Path, dest: &Path) -> AppResult<()> { - sevenz_rust::decompress_file(path, dest) - .map_err(|e| AppError::from(format!("7z extraction failed: {e}")))?; - Ok(()) + fn path(&self) -> &Path { + &self.0 + } } -fn extract_rar(path: &Path, dest: &Path) -> AppResult<()> { - let mut ar = unrar::Archive::new(path) - .open_for_processing() - .map_err(|e| AppError::from(format!("Failed to open rar: {e}")))?; - - while let Some(header) = ar - .read_header() - .map_err(|e| AppError::from(format!("Corrupt rar header: {e}")))? - { - ar = header - .extract_with_base(dest) - .map_err(|e| AppError::from(format!("Rar extraction failed: {e}")))?; +impl Drop for StagingDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); } - Ok(()) } fn copy_dir_recursive(src: &Path, dest: &Path) -> AppResult<()> { - std::fs::create_dir_all(dest)?; - for entry in std::fs::read_dir(src)?.flatten() { - let target = dest.join(entry.file_name()); - if entry.path().is_dir() { - copy_dir_recursive(&entry.path(), &target)?; - continue; + for entry in WalkDir::new(src) { + let entry = entry.context("walk failed")?; + let rel = entry.path().strip_prefix(src).unwrap(); + let target = dest.join(rel); + + if entry.file_type().is_dir() { + std::fs::create_dir_all(&target)?; + } else { + std::fs::copy(entry.path(), &target)?; } - std::fs::copy(entry.path(), target)?; } Ok(()) } @@ -120,24 +116,15 @@ fn copy_file_creating_parents(src: &Path, dest: &Path) -> AppResult<()> { } fn collect_files(dir: &Path) -> AppResult> { - let mut out = Vec::new(); - collect_files_walk(dir, &mut out)?; - Ok(out) -} - -fn collect_files_walk(dir: &Path, out: &mut Vec) -> AppResult<()> { if !dir.is_dir() { - return Ok(()); + return Ok(Vec::new()); } - for entry in std::fs::read_dir(dir)?.flatten() { - let p = entry.path(); - if p.is_dir() { - collect_files_walk(&p, out)?; - continue; - } - out.push(p); - } - Ok(()) + Ok(WalkDir::new(dir) + .into_iter() + .filter_map(Result::ok) + .filter(|e| e.file_type().is_file()) + .map(|e| e.into_path()) + .collect()) } fn build_file_index(data_dir: &Path) -> AppResult>> { @@ -155,65 +142,46 @@ fn build_file_index(data_dir: &Path) -> AppResult>> Ok(index) } -fn find_mod_roots(extracted: &Path) -> Vec { - let subdirs: Vec<_> = std::fs::read_dir(extracted) - .ok() +fn find_mod_roots(extracted: &Path) -> AppResult> { + let direct: Vec = WalkDir::new(extracted) + .min_depth(1) + .max_depth(1) .into_iter() - .flatten() - .flatten() - .filter(|e| e.path().is_dir()) - .collect(); - - let roots: Vec = subdirs - .iter() - .filter(|e| e.path().join("manifest.json").exists()) - .map(|e| e.path()) + .filter_map(Result::ok) + .filter(|e| e.file_type().is_dir() && e.path().join("manifest.json").exists()) + .map(|e| e.into_path()) .collect(); - if roots.len() > 1 { - return roots; + if direct.len() > 1 { + return Ok(direct); } - vec![find_mod_root(extracted)] -} - -fn find_mod_root(extracted: &Path) -> PathBuf { if let Some(manifest) = find_manifest(extracted) { - return manifest.parent().unwrap_or(extracted).to_path_buf(); + return Ok(vec![manifest.parent().unwrap_or(extracted).to_path_buf()]); } - let entries: Vec<_> = std::fs::read_dir(extracted) - .ok() + let top: Vec = WalkDir::new(extracted) + .min_depth(1) + .max_depth(1) .into_iter() - .flatten() - .flatten() + .filter_map(Result::ok) + .filter(|e| e.file_type().is_dir()) + .map(|e| e.into_path()) .collect(); - match entries.as_slice() { - [only] if only.path().is_dir() => only.path(), + Ok(vec![match top.as_slice() { + [only] => only.clone(), _ => extracted.to_path_buf(), - } + }]) } fn find_manifest(dir: &Path) -> Option { - let candidate = dir.join("manifest.json"); - if candidate.exists() { - return Some(candidate); - } - let mut children: Vec<_> = std::fs::read_dir(dir).ok()?.flatten().collect(); - children.sort_by_key(|e| e.file_name()); - - children - .iter() - .filter_map(|e| { - let p = e.path(); - if p.is_dir() { - find_manifest(&p) - } else { - None - } - }) - .next() + WalkDir::new(dir) + .sort_by_file_name() + .into_iter() + .filter_map(Result::ok) + .map(|e| e.into_path()) + .find(|p| p.file_name().and_then(|n| n.to_str()) == Some("manifest.json")) } fn mod_name_from_archive(path: &Path) -> String { @@ -223,23 +191,6 @@ fn mod_name_from_archive(path: &Path) -> String { .to_string() } -// native as in already a jeode mod not DLL mods -fn install_native(root: &Path, mods_dir: &Path) -> AppResult { - let raw = std::fs::read_to_string(root.join("manifest.json"))?; - let manifest: mods::Manifest = serde_json::from_str(&raw) - .map_err(|e| AppError::from(format!("Invalid manifest.json: {e}")))?; - - let id = mods::sanitize_id(&manifest.id); - let dest = mods_dir.join(&id); - - if dest.exists() { - std::fs::remove_dir_all(&dest)?; - } - copy_dir_recursive(root, &dest)?; - - Ok(manifest.name) -} - // no data directory but has gfx etc fn looks_like_bare_data(mod_root: &Path, game_dir: &Path) -> bool { let game_data = game_dir.join("data"); @@ -247,12 +198,12 @@ fn looks_like_bare_data(mod_root: &Path, game_dir: &Path) -> bool { return false; } - let subdirs: Vec<_> = std::fs::read_dir(mod_root) - .ok() + let subdirs: Vec<_> = WalkDir::new(mod_root) + .min_depth(1) + .max_depth(1) .into_iter() - .flatten() - .flatten() - .filter(|e| e.path().is_dir()) + .filter_map(Result::ok) + .filter(|e| e.file_type().is_dir()) .collect(); if subdirs.is_empty() { @@ -267,52 +218,6 @@ fn looks_like_bare_data(mod_root: &Path, game_dir: &Path) -> bool { hits > 0 && hits * 2 >= subdirs.len() } -fn install_rebuilt( - archive_path: &Path, - root: &Path, - game_dir: &Path, - mods_dir: &Path, - metadata: Option<&ModMetadata>, -) -> AppResult { - let name = metadata - .map(|m| m.name.clone()) - .unwrap_or_else(|| mod_name_from_archive(archive_path)); - let id = metadata - .map(|m| mods::sanitize_id(&m.id)) - .unwrap_or_else(|| mods::sanitize_id(&name)); - let author = metadata - .map(|m| m.author.clone()) - .unwrap_or_else(|| "Unknown".into()); - let mod_dir = mods_dir.join(&id); - - if mod_dir.exists() { - std::fs::remove_dir_all(&mod_dir)?; - } - std::fs::create_dir_all(&mod_dir)?; - - if root.join("data").is_dir() { - copy_dir_recursive(root, &mod_dir)?; - } else if looks_like_bare_data(root, game_dir) { - copy_dir_recursive(root, &mod_dir.join("data"))?; - } else { - place_files_by_name(root, game_dir, &mod_dir)?; - } - - let manifest = mods::Manifest { - id, - name: name.clone(), - author, - assets: mods::ManifestAssets { - auto_override: true, - ..Default::default() - }, - ..mods::default_manifest() - }; - mods::write_manifest(&mod_dir.join("manifest.json"), &manifest)?; - - Ok(name) -} - fn place_files_by_name(root: &Path, game_dir: &Path, mod_dir: &Path) -> AppResult<()> { let game_data = game_dir.join("data"); let index = build_file_index(&game_data)?; @@ -359,68 +264,100 @@ fn place_files_by_name(root: &Path, game_dir: &Path, mod_dir: &Path) -> AppResul Ok(()) } -fn install_single( +fn install_rebuilt( archive_path: &Path, root: &Path, game_dir: &Path, mods_dir: &Path, metadata: Option<&ModMetadata>, ) -> AppResult { - if root.join("manifest.json").exists() { - return install_native(root, mods_dir); + let name = metadata + .map(|m| m.name.clone()) + .unwrap_or_else(|| mod_name_from_archive(archive_path)); + let id = metadata + .map(|m| mods::sanitize_id(&m.id)) + .unwrap_or_else(|| mods::sanitize_id(&name)); + let author = metadata + .map(|m| m.author.clone()) + .unwrap_or_else(|| "Unknown".into()); + let mod_dir = mods_dir.join(&id); + + if mod_dir.exists() { + std::fs::remove_dir_all(&mod_dir)?; + } + std::fs::create_dir_all(&mod_dir)?; + + if root.join("data").is_dir() { + copy_dir_recursive(root, &mod_dir)?; + } else if looks_like_bare_data(root, game_dir) { + copy_dir_recursive(root, &mod_dir.join("data"))?; + } else { + place_files_by_name(root, game_dir, &mod_dir)?; + } + + let manifest = mods::Manifest { + id, + name: name.clone(), + author, + assets: mods::ManifestAssets { + auto_override: true, + ..Default::default() + }, + ..mods::default_manifest() + }; + mods::write_manifest(&mod_dir.join("manifest.json"), &manifest)?; + + Ok(name) +} + +// native as in already a jeode mod not DLL mods +fn install_native(root: &Path, mods_dir: &Path) -> AppResult { + let raw = std::fs::read_to_string(root.join("manifest.json"))?; + let manifest: mods::Manifest = serde_json::from_str(&raw).context("invalid manifest.json")?; + + let id = mods::sanitize_id(&manifest.id); + let dest = mods_dir.join(&id); + + if dest.exists() { + std::fs::remove_dir_all(&dest)?; } - install_rebuilt(archive_path, root, game_dir, mods_dir, metadata) + copy_dir_recursive(root, &dest)?; + + Ok(manifest.name) } -fn do_install( +pub fn install( archive_path: &Path, game_dir: &Path, - staging: &Path, metadata: Option<&ModMetadata>, ) -> AppResult { - extract(archive_path, staging)?; + let staging = StagingDir::new()?; + Archive::from_path(archive_path)?.extract(archive_path, staging.path())?; + let mods_dir = game_dir.join("mods"); std::fs::create_dir_all(&mods_dir)?; - let roots = find_mod_roots(staging); + let roots = find_mod_roots(staging.path())?; let total = roots.len(); let mut installed = Vec::new(); let mut errors = Vec::new(); for root in &roots { - match install_single(archive_path, root, game_dir, &mods_dir, metadata) { + let result = if root.join("manifest.json").exists() { + install_native(root, &mods_dir) + } else { + install_rebuilt(archive_path, root, game_dir, &mods_dir, metadata) + }; + match result { Ok(name) => installed.push(name), Err(e) => errors.push(e.to_string()), } } - let error = if errors.is_empty() { - None - } else { - Some(errors.join("; ")) - }; - + let error = (!errors.is_empty()).then(|| errors.join("; ")); Ok(InstallResult { installed, total, error, }) } - -pub fn install(archive_path: &Path, game_dir: &Path) -> AppResult { - let staging = unique_staging_path()?; - let result = do_install(archive_path, game_dir, &staging, None); - let _ = std::fs::remove_dir_all(&staging); - result -} - -pub fn install_with_metadata( - archive_path: &Path, - game_dir: &Path, - metadata: &ModMetadata, -) -> AppResult { - let staging = unique_staging_path()?; - let result = do_install(archive_path, game_dir, &staging, Some(metadata)); - let _ = std::fs::remove_dir_all(&staging); - result -} diff --git a/src-tauri/src/services/jeode.rs b/src-tauri/src/services/jeode.rs index 83046ba..a8fdc16 100644 --- a/src-tauri/src/services/jeode.rs +++ b/src-tauri/src/services/jeode.rs @@ -2,7 +2,7 @@ use std::path::Path; use sha2::{Digest, Sha256}; -use crate::errors::{AppError, AppResult}; +use crate::errors::{AppError, AppResult, Context}; const GITHUB_LATEST: &str = "https://api.github.com/repos/Paficent/jeode/releases/latest"; const USER_AGENT: &str = "cantus/0.1.0"; @@ -48,7 +48,7 @@ pub async fn install(game_dir: &Path) -> AppResult<()> { .send() .await? .error_for_status() - .map_err(|e| AppError::from(format!("GitHub API request failed: {e}")))? + .context("GitHub API request failed")? .json() .await?; @@ -64,7 +64,7 @@ pub async fn install(game_dir: &Path) -> AppResult<()> { .send() .await? .error_for_status() - .map_err(|e| AppError::from(format!("Download failed for {}: {e}", target.asset_name)))? + .with_context(|| format!("download failed for {}", target.asset_name))? .bytes() .await?; diff --git a/src-tauri/src/services/log_watcher.rs b/src-tauri/src/services/jeodewatcher.rs similarity index 88% rename from src-tauri/src/services/log_watcher.rs rename to src-tauri/src/services/jeodewatcher.rs index 5e3c6c4..8157aed 100644 --- a/src-tauri/src/services/log_watcher.rs +++ b/src-tauri/src/services/jeodewatcher.rs @@ -1,5 +1,3 @@ -//TODO: rename this to just jeode watcher - use std::ffi::OsStr; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; @@ -54,10 +52,13 @@ pub fn start(app: tauri::AppHandle, jeode_dir: PathBuf) -> AppResult<()> { let now = Instant::now(); let touches_log = event.paths.iter().any(|p| p.file_name() == Some(log_name)); - let touches_settings = - event.paths.iter().any(|p| p.file_name() == Some(settings_name)); + let touches_settings = event + .paths + .iter() + .any(|p| p.file_name() == Some(settings_name)); - if touches_log && now.duration_since(last_log_emit) >= Duration::from_millis(DEBOUNCE_MS) + if touches_log + && now.duration_since(last_log_emit) >= Duration::from_millis(DEBOUNCE_MS) { last_log_emit = now; let _ = app.emit("log-changed", ()); diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index a994497..cab5e03 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -2,7 +2,7 @@ pub mod game; pub mod gamebanana; pub mod installer; pub mod jeode; -pub mod log_watcher; +pub mod jeodewatcher; pub mod mods; pub mod settings; pub mod watcher; diff --git a/src-tauri/src/services/mods.rs b/src-tauri/src/services/mods.rs index c198d16..dd7bc20 100644 --- a/src-tauri/src/services/mods.rs +++ b/src-tauri/src/services/mods.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; -use crate::errors::{AppError, AppResult}; +use crate::errors::{AppError, AppResult, Context}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Manifest { @@ -111,7 +111,7 @@ fn read_manifest(mod_path: &Path, dir_name: &str) -> AppResult { } let content = std::fs::read_to_string(&manifest_path)?; let mut manifest: Manifest = serde_json::from_str(&content) - .map_err(|e| AppError::from(format!("Failed to parse manifest in {dir_name}: {e}")))?; + .with_context(|| format!("failed to parse manifest in {}", dir_name))?; manifest.id = sanitize_id(&manifest.id); Ok(manifest) } @@ -227,160 +227,3 @@ pub fn write_manifest(path: &Path, manifest: &Manifest) -> AppResult<()> { std::fs::write(path, content + "\n")?; Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - - fn setup_mod( - tmp: &Path, - dir_name: &str, - manifest_json: Option<&str>, - create_entry: bool, - ) -> PathBuf { - let mods_dir = tmp.join("mods"); - let mod_dir = mods_dir.join(dir_name); - fs::create_dir_all(&mod_dir).unwrap(); - if let Some(json) = manifest_json { - fs::write(mod_dir.join("manifest.json"), json).unwrap(); - } - if create_entry { - fs::write(mod_dir.join("init.lua"), "-- entry").unwrap(); - } - mod_dir - } - - #[test] - fn sanitize_id_strips_invalid_chars() { - assert_eq!(sanitize_id("My Cool Mod!"), "mycoolmod"); - assert_eq!(sanitize_id("test-mod_123"), "test-mod_123"); - assert_eq!(sanitize_id("!!!"), "unknown"); - assert_eq!(sanitize_id(""), "unknown"); - } - - #[test] - fn detect_type_native_when_native_entry_set() { - let tmp = tempfile::tempdir().unwrap(); - let mod_path = tmp.path().join("test-mod"); - fs::create_dir_all(&mod_path).unwrap(); - let manifest = Manifest { - native_entry: "mod.dll".into(), - ..default_manifest() - }; - assert_eq!(detect_type(&manifest, &mod_path), "native"); - } - - #[test] - fn detect_type_lua_when_entry_exists() { - let tmp = tempfile::tempdir().unwrap(); - let mod_path = tmp.path().join("test-mod"); - fs::create_dir_all(&mod_path).unwrap(); - fs::write(mod_path.join("init.lua"), "").unwrap(); - let manifest = Manifest { - native_entry: String::new(), - entry: "init.lua".into(), - ..default_manifest() - }; - assert_eq!(detect_type(&manifest, &mod_path), "lua"); - } - - #[test] - fn detect_type_asset_when_no_entry() { - let tmp = tempfile::tempdir().unwrap(); - let mod_path = tmp.path().join("test-mod"); - fs::create_dir_all(&mod_path).unwrap(); - let manifest = Manifest { - native_entry: String::new(), - entry: "init.lua".into(), - ..default_manifest() - }; - assert_eq!(detect_type(&manifest, &mod_path), "asset"); - } - - #[test] - fn list_reads_mods_from_directory() { - let tmp = tempfile::tempdir().unwrap(); - setup_mod( - tmp.path(), - "my-mod", - Some( - r#"{"id":"my-mod","name":"My Mod","author":"Tester","version":"1.0.0","enabled":true}"#, - ), - true, - ); - setup_mod( - tmp.path(), - "asset-pack", - Some( - r#"{"id":"asset-pack","name":"Asset Pack","author":"Artist","version":"2.0.0","enabled":false}"#, - ), - false, - ); - - let mods = list(tmp.path()).unwrap(); - assert_eq!(mods.len(), 2); - - let asset = mods.iter().find(|m| m.id == "asset-pack").unwrap(); - assert_eq!(asset.mod_type, "asset"); - assert!(!asset.enabled); - - let cool = mods.iter().find(|m| m.id == "my-mod").unwrap(); - assert_eq!(cool.mod_type, "lua"); - assert!(cool.enabled); - } - - #[test] - fn list_returns_empty_when_no_mods_dir() { - let tmp = tempfile::tempdir().unwrap(); - let mods = list(tmp.path()).unwrap(); - assert!(mods.is_empty()); - } - - #[test] - fn list_generates_manifest_for_dir_without_one() { - let tmp = tempfile::tempdir().unwrap(); - setup_mod(tmp.path(), "bare-mod", None, false); - - let mods = list(tmp.path()).unwrap(); - assert_eq!(mods.len(), 1); - assert_eq!(mods[0].id, "bare-mod"); - assert_eq!(mods[0].name, "bare-mod"); - assert_eq!(mods[0].mod_type, "asset"); - } - - #[test] - fn toggle_flips_enabled_in_manifest() { - let tmp = tempfile::tempdir().unwrap(); - setup_mod( - tmp.path(), - "toggle-test", - Some(r#"{"id":"toggle-test","name":"Toggle Test","enabled":true}"#), - false, - ); - - let new_state = toggle(tmp.path(), "toggle-test").unwrap(); - assert!(!new_state); - - let mods = list(tmp.path()).unwrap(); - assert!(!mods[0].enabled); - - let new_state = toggle(tmp.path(), "toggle-test").unwrap(); - assert!(new_state); - } - - #[test] - fn remove_deletes_mod_directory() { - let tmp = tempfile::tempdir().unwrap(); - setup_mod( - tmp.path(), - "remove-me", - Some(r#"{"id":"remove-me","name":"test"}"#), - true, - ); - - assert!(tmp.path().join("mods/remove-me").exists()); - remove(tmp.path(), "remove-me").unwrap(); - assert!(!tmp.path().join("mods/remove-me").exists()); - } -} diff --git a/src-tauri/src/services/settings.rs b/src-tauri/src/services/settings.rs index 895ab27..d08f0f6 100644 --- a/src-tauri/src/services/settings.rs +++ b/src-tauri/src/services/settings.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use tauri::Manager; -use crate::errors::{AppError, AppResult}; +use crate::errors::{AppResult, Context}; const SETTINGS_FILE: &str = "config.json"; @@ -41,7 +41,7 @@ fn settings_path(app: &tauri::AppHandle) -> AppResult { let dir = app .path() .app_config_dir() - .map_err(|e| AppError::from(format!("Failed to resolve config directory: {e}")))?; + .context("failed to resolve config directory")?; Ok(dir.join(SETTINGS_FILE)) } @@ -52,11 +52,8 @@ pub fn load(app: &tauri::AppHandle) -> AppResult { return Ok(Settings::default()); } - let content = std::fs::read_to_string(&path) - .map_err(|e| AppError::from(format!("Failed to read settings: {e}")))?; - - serde_json::from_str(&content) - .map_err(|e| AppError::from(format!("Failed to parse settings: {e}"))) + let content = std::fs::read_to_string(&path).context("failed to read settings")?; + serde_json::from_str(&content).context("failed to parse settings") } pub fn save(app: &tauri::AppHandle, settings: &Settings) -> AppResult<()> { @@ -125,11 +122,9 @@ pub fn load_jeode(game_dir: &Path) -> AppResult { return Ok(JeodeSettings::default()); } - let content = std::fs::read_to_string(&path) - .map_err(|e| AppError::from(format!("Failed to read jeode settings: {e}")))?; + let content = std::fs::read_to_string(&path).context("failed to read jeode settings")?; - serde_json::from_str(&content) - .map_err(|e| AppError::from(format!("Failed to parse jeode settings: {e}"))) + serde_json::from_str(&content).context("failed to parse jeode settings") } pub fn save_jeode(game_dir: &Path, settings: &JeodeSettings) -> AppResult<()> { diff --git a/src-tauri/src/services/watcher.rs b/src-tauri/src/services/watcher.rs index e60e605..d427823 100644 --- a/src-tauri/src/services/watcher.rs +++ b/src-tauri/src/services/watcher.rs @@ -5,7 +5,7 @@ use std::time::Duration; use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher}; use tauri::Emitter; -use crate::errors::AppResult; +use crate::errors::{AppResult, Context}; static WATCHING: AtomicBool = AtomicBool::new(false); @@ -22,11 +22,11 @@ pub fn start(app: tauri::AppHandle, mods_dir: PathBuf) -> AppResult<()> { tx, notify::Config::default().with_poll_interval(Duration::from_secs(2)), ) - .map_err(|e| crate::errors::AppError::from(format!("Failed to create watcher: {e}")))?; + .context("failed to create watcher")?; watcher .watch(&mods_dir, RecursiveMode::Recursive) - .map_err(|e| crate::errors::AppError::from(format!("Failed to watch mods folder: {e}")))?; + .context("failed to watch mods folder")?; std::thread::spawn(move || { let _watcher = watcher; diff --git a/src.zip b/src.zip new file mode 100644 index 0000000..09d758d Binary files /dev/null and b/src.zip differ diff --git a/src/lib/components/browse/BrowseCard.svelte b/src/lib/components/browse/BrowseCard.svelte index e60d365..6c9c38a 100644 --- a/src/lib/components/browse/BrowseCard.svelte +++ b/src/lib/components/browse/BrowseCard.svelte @@ -11,7 +11,7 @@ RefreshCw, } from "lucide-svelte"; import type { BrowseMod } from "$lib/types/browse"; - import { open } from "@tauri-apps/api/shell"; + import { openUrl } from "@tauri-apps/plugin-opener"; interface Props { mod: BrowseMod; @@ -26,8 +26,8 @@ return n.toString(); } - function openMod() { - open(`https://gamebanana.com/mods/${mod.id}`); + async function openMod() { + await openUrl(`https://gamebanana.com/mods/${mod.id}`); } function timeAgo(ts: number): string { @@ -48,14 +48,19 @@ -
- {mod.name} +
+
- {mod.name} + openMod()} + >{mod.name} {mod.author}