From 83db66254d5f8e894db02ce339368fe9caa063be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl?= Date: Tue, 7 Apr 2026 22:33:16 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20option=20to=20create=20symlin?= =?UTF-8?q?k=20before=20invoking=20weidu=20to=20install?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modda-lib/Cargo.toml | 1 + modda-lib/src/lib.rs | 1 + modda-lib/src/module/location/location.rs | 4 + modda-lib/src/module/location/mod.rs | 1 + .../module/location/post_install_action.rs | 127 ++++++++++++++++++ modda-lib/src/obtain/get_module.rs | 23 +++- modda-lib/src/process_weidu_mod.rs | 6 +- modda-lib/src/run_post_install_action.rs | 67 +++++++++ modda-lib/src/timeline.rs | 19 ++- 9 files changed, 237 insertions(+), 12 deletions(-) create mode 100644 modda-lib/src/module/location/post_install_action.rs create mode 100644 modda-lib/src/run_post_install_action.rs diff --git a/modda-lib/Cargo.toml b/modda-lib/Cargo.toml index 81ad805..5bc3b74 100644 --- a/modda-lib/Cargo.toml +++ b/modda-lib/Cargo.toml @@ -52,6 +52,7 @@ url = "2.5.8" void = "1.0.2" walkdir = "2.5.0" zip = "8.1.0" +symlink = "0.1.0" [dev-dependencies] diff --git a/modda-lib/src/lib.rs b/modda-lib/src/lib.rs index 8163a76..4a51800 100644 --- a/modda-lib/src/lib.rs +++ b/modda-lib/src/lib.rs @@ -21,6 +21,7 @@ pub mod patch_source; pub mod post_install; pub mod process_weidu_mod; pub mod progname; +pub mod run_post_install_action; pub mod run_result; pub mod run_weidu; pub mod config; diff --git a/modda-lib/src/module/location/location.rs b/modda-lib/src/module/location/location.rs index 92c0b4e..cba093a 100644 --- a/modda-lib/src/module/location/location.rs +++ b/modda-lib/src/module/location/location.rs @@ -10,6 +10,7 @@ use serde_with::skip_serializing_none; use void::Void; use crate::lowercase::{LwcString, lwc}; +use crate::module::location::post_install_action::PostPopulateAction; use crate::module::pre_copy_command::PrecopyCommand; use crate::{archive_layout::Layout, patch_source::PatchDesc}; @@ -64,6 +65,9 @@ pub struct ConcreteLocation { /// regex-based search and replace, runs after patch. pub replace: Option>, pub precopy: Option, + /// Things that will be done after installing the mod inside the game directory + #[serde(default)] + pub post_populate: Vec, } pub fn location_deser<'de, D>(deserializer: D) -> Result diff --git a/modda-lib/src/module/location/mod.rs b/modda-lib/src/module/location/mod.rs index 2c2ac70..49c515d 100644 --- a/modda-lib/src/module/location/mod.rs +++ b/modda-lib/src/module/location/mod.rs @@ -1,6 +1,7 @@ pub mod github; pub mod http; pub mod location; +pub mod post_install_action; pub mod replace; pub mod size_hint; pub mod source; diff --git a/modda-lib/src/module/location/post_install_action.rs b/modda-lib/src/module/location/post_install_action.rs new file mode 100644 index 0000000..0f169e9 --- /dev/null +++ b/modda-lib/src/module/location/post_install_action.rs @@ -0,0 +1,127 @@ +use serde::{Deserialize, Serialize}; +use serde_with::serde_as; +use serde_with::OneOrMany; +use serde_with::formats::PreferOne; + + +#[derive(Deserialize, Serialize, Debug, PartialEq, Clone)] +#[serde(untagged)] +pub enum PostPopulateAction { + Symlink(SymlinkAction), +} + +#[serde_as] +#[derive(Deserialize, Serialize, Debug, PartialEq, Clone)] +pub struct SymlinkAction { + // Paths of the symlinks to create, from game dir + #[serde_as(as = "OneOrMany<_, PreferOne>")] + pub symlinks: Vec, + // Path of the target of the symlinks, from game dir + pub symlink_target: String, +} + +#[cfg(test)] +mod tests { + use crate::module::location::github::{Github, GithubDescriptor}; + use crate::module::location::location::{ConcreteLocation, Location}; + use crate::module::location::post_install_action::{PostPopulateAction, SymlinkAction}; + use crate::module::location::source::Source; + + + #[test] + fn deserialize_source_github_with_symlink_post_action() { + let yaml = r#" + github_user: my_user + repository: my_repo + tag: v1.0 + post_populate: + - symlinks: [a, b] + symlink_target: target + "#; + let location: Location = serde_yaml::from_str(yaml).unwrap(); + assert_eq!( + location, + Location::Concrete { + concrete: ConcreteLocation { + source: Source::Github(Github { + github_user: "my_user".to_string(), + repository: "my_repo".to_string(), + descriptor: GithubDescriptor::Tag { + tag: "v1.0".to_string(), + }, + ..Default::default() + }), + post_populate: vec![ + PostPopulateAction::Symlink( + SymlinkAction{ + symlinks: vec!["a".to_string(), "b".to_string()], + symlink_target: "target".to_string(), + }, + ), + ], + ..Default::default() + }, + } + ); + } + + #[test] + fn deserialize_source_github_with_symlink_post_action_single() { + let yaml = r#" + github_user: my_user + repository: my_repo + tag: v1.0 + post_populate: + - symlinks: a + symlink_target: target + "#; + let location: Location = serde_yaml::from_str(yaml).unwrap(); + assert_eq!( + location, + Location::Concrete { + concrete: ConcreteLocation { + source: Source::Github(Github { + github_user: "my_user".to_string(), + repository: "my_repo".to_string(), + descriptor: GithubDescriptor::Tag { + tag: "v1.0".to_string(), + }, + ..Default::default() + }), + post_populate: vec![ + PostPopulateAction::Symlink( + SymlinkAction{ symlinks: vec!["a".to_string()], symlink_target: "target".to_string() }, + ), + ], + ..Default::default() + }, + } + ); + } + #[test] + fn deserialize_source_path_with_symlink_post_action() { + let yaml = r#" + path: /somewhere/else/mod.zip + post_populate: + - symlinks: [a] + symlink_target: target + "#; + let location: Location = serde_yaml::from_str(yaml).unwrap(); + assert_eq!( + location, + Location::Concrete { + concrete: ConcreteLocation { + source: Source::Absolute { + path: "/somewhere/else/mod.zip".to_string(), + }, + post_populate: vec![ + PostPopulateAction::Symlink( + SymlinkAction{ symlinks: vec!["a".to_string()], symlink_target: "target".to_string() }, + ), + ], + ..Default::default() + }, + } + ); + } +} diff --git a/modda-lib/src/obtain/get_module.rs b/modda-lib/src/obtain/get_module.rs index e94e6f7..966b0c8 100644 --- a/modda-lib/src/obtain/get_module.rs +++ b/modda-lib/src/obtain/get_module.rs @@ -17,6 +17,7 @@ use crate::module::global_locations::GlobalLocations; use crate::module::location::location::{ConcreteLocation, Location}; use crate::module::location::replace::ReplaceSpec; use crate::module::location::source::Source; +use crate::run_post_install_action::run_post_populate_action; use crate::timeline::SetupTimeline; use crate::lowercase::LwcString; use crate::module::weidu_mod::WeiduMod; @@ -53,9 +54,10 @@ impl <'a> ModuleDownload<'a> { // at some point, I'd like to have a pool of downloads with installations done // concurrently as soon as modules are there #[tokio::main] - pub async fn get_module(&self, module: &WeiduMod, get_options: &GetOptions) -> Result { + pub async fn get_module(&self, module: &WeiduMod, + get_options: &GetOptions, game_dir: &CanonPath) -> Result { let concrete_location = self.get_module_location(module)?; - self.get_mod_from_concrete_location(concrete_location, &module.name, get_options).await + self.get_mod_from_concrete_location(concrete_location, &module.name, get_options, game_dir).await } pub fn get_module_location(&'a self, module: &'a WeiduMod) -> Result<&'a ConcreteLocation> { @@ -80,9 +82,11 @@ impl <'a> ModuleDownload<'a> { /// 3. run `precopy` command if any -> the mod content is modified in-place (temp location) /// 4. move content (whole or part, according to `layout`) to the game directory -> the mod content is in the game directory /// 5. apply `patch` in-place (on mod data in game directory) - /// 5. apply `replace` in-place (on mod data in game directory) - async fn get_mod_from_concrete_location(&self, location: &ConcreteLocation, - mod_name: &LwcString, get_options: &GetOptions) -> Result { + /// 6. apply `replace` in-place (on mod data in game directory) + /// 7. execute post-install actions + async fn get_mod_from_concrete_location(&self, + location: &ConcreteLocation, mod_name: &LwcString, + get_options: &GetOptions, game_dir: &CanonPath) -> Result { let start = Local::now(); let archive = match self.retrieve_location(&location, &mod_name).await { Ok(archive) => archive, @@ -114,7 +118,14 @@ impl <'a> ModuleDownload<'a> { replace_module(&dest, &mod_name , &location.replace, get_options)?; let replaced = Some(Local::now()); - Ok(SetupTimeline { start, downloaded, copied, patched, replaced, configured: None }) + // Execute post-install actions + location.post_populate.iter().enumerate().try_for_each(|(index, action)| { + info!("[{mod_name}] running post-action {index}: {action:?}"); + run_post_populate_action(action, game_dir, &mod_name) + .and_then(|_| { info!("[{mod_name}] post-action {index} successful"); Ok(()) }) + })?; + let post_install_done = Some(Local::now()); + Ok(SetupTimeline { start, downloaded, copied, patched, replaced, configured: None, post_install_done }) } pub async fn retrieve_location(&self, loc: &ConcreteLocation, mod_name: &LwcString) -> Result { diff --git a/modda-lib/src/process_weidu_mod.rs b/modda-lib/src/process_weidu_mod.rs index 5df1a7e..c19564b 100644 --- a/modda-lib/src/process_weidu_mod.rs +++ b/modda-lib/src/process_weidu_mod.rs @@ -54,7 +54,11 @@ pub fn process_weidu_mod(weidu_mod: &WeiduMod, modda_context: &ModdaContext, man } ) }; - let setup_log = match module_downloader.get_module(&weidu_mod, &get_options) { + let setup_log = match module_downloader.get_module( + &weidu_mod, + &get_options, + modda_context.current_dir, + ) { Err(error) => { let message = format!("module {name} (index={idx}/{len}) download/installation failed, stopping.", name = weidu_mod.name, idx = real_index, len = mod_count); diff --git a/modda-lib/src/run_post_install_action.rs b/modda-lib/src/run_post_install_action.rs new file mode 100644 index 0000000..350f548 --- /dev/null +++ b/modda-lib/src/run_post_install_action.rs @@ -0,0 +1,67 @@ + +use std::path::PathBuf; + +use anyhow::{Result, bail}; + +use crate::canon_path::CanonPath; +use crate::lowercase::LwcString; +use crate::module::location::post_install_action::{PostPopulateAction, SymlinkAction}; + + +pub fn run_post_populate_action(action: &PostPopulateAction, game_dir: &CanonPath, mod_name:&LwcString) -> Result<()> { + let _ = mod_name; //unused ATM + match action { + PostPopulateAction::Symlink(symlink_action) => symlink(symlink_action,game_dir), + } +} + +/// Create symlinks, paths need to be relative to game dir +/// Checks the target exists and the symlink names doesn't exist already +fn symlink(symlink_action: &SymlinkAction, game_dir: &CanonPath) -> Result<()> { + let target_path = PathBuf::from(&symlink_action.symlink_target); + if target_path.is_absolute() { + bail!("post_install action - symlink target can't be absolute"); + } + let target_path = CanonPath::new(target_path)?; + if !target_path.path().starts_with(game_dir.path()) { + bail!("post_install action - symlink target {target_path:?} must be inside game directory {game_dir:?}"); + } + let target_meta = match target_path.path().metadata() { + Ok(metadata) => metadata, + Err(err) => bail!("Couldn't get metadata for {target_path:?}\n {err:?}"), + }; + + let symlinks= symlink_action.symlinks.iter().map(|symlink|{ + let path = PathBuf::from(&symlink); + if path.is_absolute() { + bail!("post_install action - symlink path can't be absolute"); + } + let path = CanonPath::new(path)?; + if !path.path().starts_with(game_dir.path()) { + bail!("post_install action - symlink path {path:?} must be inside game directory {game_dir:?}"); + } + Ok(path) + }).collect::>>()?; + symlinks.iter().try_for_each(|sym| + match sym.path().exists() { + true => bail!("symlink {sym:?} can't be created, a file with that name already exists"), + false => Ok(()), + } + )?; + symlinks.iter().try_for_each(|symlink| { + let filetype = target_meta.file_type(); + if filetype.is_dir() { + if let Err(err) = symlink::symlink_dir(symlink.path(), target_path.path()) { + bail!("Couldn't create symlink {symlink:?} to {target_path:?}\n {err:?}") + } else {Ok(())} + } else if filetype.is_file() { + if let Err(err) = symlink::symlink_file(symlink.path(), target_path.path()) { + bail!("Couldn't create symlink {symlink:?} to {target_path:?}\n {err:?}") + } else{ Ok(())} + } else { + bail!("Unsupported file type for symlink target {target_path:?} (allowed: file or dir)") + } + })?; + Ok(()) +} + diff --git a/modda-lib/src/timeline.rs b/modda-lib/src/timeline.rs index 9b6f398..494e7ca 100644 --- a/modda-lib/src/timeline.rs +++ b/modda-lib/src/timeline.rs @@ -15,6 +15,7 @@ pub struct InstallTimeline { pub configured: Option>, pub start_install: Option>, pub installed: Option>, + pub post_populate_done: Option>, } impl InstallTimeline { @@ -32,29 +33,36 @@ impl InstallTimeline { self.copied = setup.copied; self.patched = setup.patched; self.configured = setup.configured; + self.post_populate_done = setup.post_install_done; } pub fn short(&self) -> String { let mut result = format!("({}) {}" , self.start.format("%H:%M:%S"), self.name); - result +=" download: "; + result +=" download: "; if let (Some(start), Some(end)) = (self.start_download, self.downloaded) { - result+= &format_duration(end - start).to_string(); + result += &format_duration(end - start).to_string(); } else { result += "-" } - result +=" extract: "; + result +=" extract: "; if let (Some(downloaded), Some(copied)) = (self.downloaded, self.copied) { result+= &format_duration(copied - downloaded).to_string(); } else { result += "-" } - result +=" prepare: "; + result +=" prepare: "; if let (Some(copied), Some(configured)) = (self.copied, self.configured) { result+= &format_duration(configured - copied).to_string(); } else { result += "-" } - result +=" install: "; + result +=" post-populate: "; + if let (Some(post_action), Some(copied)) = (self.post_populate_done, self.copied) { + result+= &format_duration(post_action - copied).to_string(); + } else { + result += "-" + } + result +=" install: "; if let (Some(start_install), Some(installed)) = (self.start_install, self.installed) { result+= &format_duration(installed - start_install).to_string(); } else { @@ -77,4 +85,5 @@ pub struct SetupTimeline { pub patched: Option>, pub replaced: Option>, pub configured: Option>, + pub post_install_done: Option>, }