From 62adfed117c0606e2130c3fa464590be18efa808 Mon Sep 17 00:00:00 2001 From: Slackow <32826498+Slackow@users.noreply.github.com> Date: Sun, 26 Apr 2026 02:00:16 -0400 Subject: [PATCH] feat: Implement launcher supplementary environmental variables, and env var substitution in hooks --- .../ui/instance_settings/HooksSettings.vue | 2 +- .../ui/settings/DefaultInstanceSettings.vue | 5 + .../app-frontend/src/locales/en-US/index.json | 2 +- packages/app-lib/src/api/profile/mod.rs | 123 +++++++++---- packages/app-lib/src/launcher/hooks.rs | 165 ++++++++++++++++++ packages/app-lib/src/launcher/mod.rs | 58 +++++- packages/app-lib/src/state/process.rs | 5 +- 7 files changed, 322 insertions(+), 38 deletions(-) create mode 100644 packages/app-lib/src/launcher/hooks.rs diff --git a/apps/app-frontend/src/components/ui/instance_settings/HooksSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/HooksSettings.vue index 51305aa539..3bf59cd06b 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/HooksSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/HooksSettings.vue @@ -54,7 +54,7 @@ const messages = defineMessages({ hooksDescription: { id: 'instance.settings.tabs.hooks.description', defaultMessage: - 'Hooks allow advanced users to run certain system commands before and after launching the game.', + 'Hooks can run commands before launch, as a wrapper, or after exit. Commands support $INST_NAME, $INST_ID, $INST_DIR/$INST_MC_DIR, $INST_JAVA, $INST_JAVA_ARGS.', }, customHooks: { id: 'instance.settings.tabs.hooks.custom-hooks', diff --git a/apps/app-frontend/src/components/ui/settings/DefaultInstanceSettings.vue b/apps/app-frontend/src/components/ui/settings/DefaultInstanceSettings.vue index 252de30984..75ea26d984 100644 --- a/apps/app-frontend/src/components/ui/settings/DefaultInstanceSettings.vue +++ b/apps/app-frontend/src/components/ui/settings/DefaultInstanceSettings.vue @@ -143,6 +143,11 @@ watch(
+

+ Commands support $INST_NAME, $INST_ID, $INST_DIR/$INST_MC_DIR, $INST_JAVA, + $INST_JAVA_ARGS. +

+

Pre launch hook

= + LazyLock::new(|| Regex::new(r"\$(\w+)").expect("valid env var regex")); + +#[derive(Debug, Clone)] +pub(crate) struct HookVariables { + pub instance_name: String, + pub instance_id: String, + pub instance_dir: String, + pub java_path: String, + pub java_args: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct HookEnvironment { + lookup_env: BTreeMap, + injected_env: BTreeMap, +} + +impl HookEnvironment { + pub(crate) fn from_current_env( + custom_env_vars: &[(String, String)], + variables: HookVariables, + ) -> Self { + Self::new( + std::env::vars_os().map(|(key, value)| { + ( + key.to_string_lossy().into_owned(), + value.to_string_lossy().into_owned(), + ) + }), + custom_env_vars, + variables, + ) + } + + fn new( + process_env: impl IntoIterator, + custom_env_vars: &[(String, String)], + variables: HookVariables, + ) -> Self { + let mut lookup_env = + process_env.into_iter().collect::>(); + let mut injected_env = BTreeMap::new(); + + for (key, value) in custom_env_vars { + lookup_env.insert(key.clone(), value.clone()); + injected_env.insert(key.clone(), value.clone()); + } + + let hook_vars = [ + ("INST_NAME", variables.instance_name), + ("INST_ID", variables.instance_id), + ("INST_DIR", variables.instance_dir.clone()), + ("INST_MC_DIR", variables.instance_dir), + ("INST_JAVA", variables.java_path), + ("INST_JAVA_ARGS", variables.java_args), + ]; + + for (key, value) in hook_vars { + let key = key.to_string(); + lookup_env.insert(key.clone(), value.clone()); + injected_env.insert(key, value); + } + + Self { + lookup_env, + injected_env, + } + } + + pub(crate) fn expand(&self, input: &str) -> String { + ENV_VAR_PATTERN + .replace_all(input, |captures: &Captures| { + self.lookup_env + .get(&captures[1]) + .cloned() + .unwrap_or_else(|| captures[0].to_string()) + }) + .into_owned() + } + + pub(crate) fn injected_envs(&self) -> Vec<(String, String)> { + self.injected_env + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect() + } +} + +pub(crate) fn build_hook_java_args( + java_args: &[String], + memory: MemorySettings, + java_version: &JavaVersion, +) -> String { + let mut args = vec![format!("-Xmx{}M", memory.maximum)]; + + args.extend(java_args.iter().filter(|arg| !arg.is_empty()).cloned()); + + if java_version.parsed_version >= 9 { + args.push( + "--add-opens=java.base/java.lang.reflect=ALL-UNNAMED".to_string(), + ); + } + + if java_version.parsed_version >= 25 { + args.push( + "--add-opens=jdk.internal/jdk.internal.misc=ALL-UNNAMED" + .to_string(), + ); + } + + args.join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_variables() -> HookVariables { + HookVariables { + instance_name: "Test Instance".to_string(), + instance_id: "test-instance".to_string(), + instance_dir: "/profiles/test-instance".to_string(), + java_path: "/java/bin/java".to_string(), + java_args: "-Xmx4096M".to_string(), + } + } + + #[test] + fn expands_builtin_and_custom_variables() { + let env = HookEnvironment::new( + [("HOME".to_string(), "/home/alex".to_string())], + &[("CUSTOM_VAR".to_string(), "custom".to_string())], + sample_variables(), + ); + + assert_eq!( + env.expand("$HOME/$INST_ID/$CUSTOM_VAR"), + "/home/alex/test-instance/custom" + ); + } + + #[test] + fn leaves_unknown_variables_untouched() { + let env = HookEnvironment::new([], &[], sample_variables()); + + assert_eq!(env.expand("$UNKNOWN/$INST_NAME"), "$UNKNOWN/Test Instance"); + } + + #[test] + fn expands_empty_variables_to_empty_strings() { + let env = HookEnvironment::new( + [("EMPTY_VAR".to_string(), String::new())], + &[], + sample_variables(), + ); + + assert_eq!(env.expand("prefix$EMPTY_VAR-suffix"), "prefix-suffix"); + } +} diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs index 6dc472bcc7..65572f7df3 100644 --- a/packages/app-lib/src/launcher/mod.rs +++ b/packages/app-lib/src/launcher/mod.rs @@ -28,6 +28,7 @@ use std::path::PathBuf; use tokio::process::Command; mod args; +pub(crate) mod hooks; pub mod download; pub mod quick_play_version; @@ -191,6 +192,60 @@ pub async fn get_loader_version_from_profile( } } +pub(crate) async fn resolve_java_for_launch( + profile: &Profile, +) -> crate::Result { + let state = State::get().await?; + let (minecraft, version_index) = + resolve_minecraft_manifest(&profile.game_version, &state).await?; + let version = &minecraft.versions[version_index]; + + let mut loader_version = get_loader_version_from_profile( + &profile.game_version, + profile.loader, + profile.loader_version.as_deref(), + ) + .await?; + + if profile.loader != ModLoader::Vanilla && loader_version.is_none() { + loader_version = get_loader_version_from_profile( + &profile.game_version, + profile.loader, + Some("stable"), + ) + .await?; + } + + let version_info = download::download_version_info( + &state, + version, + loader_version.as_ref(), + None, + None, + ) + .await?; + + let key = version_info + .java_version + .as_ref() + .map_or(8, |it| it.major_version); + let (java_path, set_java) = if let Some(java_version) = + get_java_version_from_profile(profile, &version_info).await? + { + (PathBuf::from(java_version.path), false) + } else { + (crate::api::jre::auto_install_java(key).await?, true) + }; + + let java_version = crate::api::jre::check_jre(java_path).await?; + + if set_java { + java_version.upsert(&state.pool).await?; + } + + Ok(java_version) +} + /// Resolves the Minecraft version manifest and finds the index for the given /// game version. If the version isn't found in the cache, forces a manifest /// refresh to pick up newly-released versions. @@ -747,7 +802,7 @@ pub async fn launch_minecraft( // Java options should be set in instance options (the existence of _JAVA_OPTIONS overwrites them) command.env_remove("_JAVA_OPTIONS"); - command.envs(env_args); + command.envs(env_args.iter().cloned()); // Overwrites the minecraft options.txt file with the settings from the profile // Uses 'a:b' syntax which is not quite yaml @@ -831,6 +886,7 @@ pub async fn launch_minecraft( &profile.path, command, post_exit_hook, + env_args, state.directories.profile_logs_dir(&profile.path), version_info.logging.is_some(), main_class_keep_alive, diff --git a/packages/app-lib/src/state/process.rs b/packages/app-lib/src/state/process.rs index fb6f0da317..242cc7c5f4 100644 --- a/packages/app-lib/src/state/process.rs +++ b/packages/app-lib/src/state/process.rs @@ -105,6 +105,7 @@ impl ProcessManager { profile_path: &str, mut mc_command: Command, post_exit_command: Option, + post_exit_env_vars: Vec<(String, String)>, logs_folder: PathBuf, xml_logging: bool, main_class_keep_alive: TempDir, @@ -208,6 +209,7 @@ impl ProcessManager { tokio::spawn(Process::sequential_process_manager( profile_path.to_string(), post_exit_command, + post_exit_env_vars, metadata.uuid, )); @@ -737,6 +739,7 @@ impl Process { async fn sequential_process_manager( profile_path: String, post_exit_command: Option, + post_exit_env_vars: Vec<(String, String)>, uuid: Uuid, ) -> crate::Result<()> { async fn update_playtime( @@ -854,7 +857,7 @@ impl Process { if let Some(command) = cmd.next() { let mut command = Command::new(command); - command.args(cmd).current_dir( + command.args(cmd).envs(post_exit_env_vars).current_dir( profile::get_full_path(&profile_path).await?, ); command.spawn().map_err(IOError::from)?;