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..50ea5a6284 100644
--- a/apps/app-frontend/src/components/ui/instance_settings/HooksSettings.vue
+++ b/apps/app-frontend/src/components/ui/instance_settings/HooksSettings.vue
@@ -56,6 +56,35 @@ const messages = defineMessages({
defaultMessage:
'Hooks allow advanced users to run certain system commands before and after launching the game.',
},
+ hookVariablesDescription: {
+ id: 'instance.settings.tabs.hooks.variables.description',
+ defaultMessage:
+ 'Hooks run in the working directory of the instance, with the following variables:',
+ },
+ instanceNameDescription: {
+ id: 'instance.settings.tabs.hooks.variables.inst-name.description',
+ defaultMessage: '$INST_NAME: The name of the instance',
+ },
+ instanceIdDescription: {
+ id: 'instance.settings.tabs.hooks.variables.inst-id.description',
+ defaultMessage: "$INST_ID: The name of the instance's folder",
+ },
+ instanceDirDescription: {
+ id: 'instance.settings.tabs.hooks.variables.inst-dir.description',
+ defaultMessage: "$INST_DIR: The absolute path to the instance's folder",
+ },
+ instanceMcDirDescription: {
+ id: 'instance.settings.tabs.hooks.variables.inst-mc-dir.description',
+ defaultMessage: '$INST_MC_DIR: An alias for $INST_DIR',
+ },
+ instanceJavaDescription: {
+ id: 'instance.settings.tabs.hooks.variables.inst-java.description',
+ defaultMessage: '$INST_JAVA: The absolute path to the java binary',
+ },
+ instanceJavaArgsDescription: {
+ id: 'instance.settings.tabs.hooks.variables.inst-java-args.description',
+ defaultMessage: '$INST_JAVA_ARGS: The JVM Arguments provided to the game',
+ },
customHooks: {
id: 'instance.settings.tabs.hooks.custom-hooks',
defaultMessage: 'Custom launch hooks',
@@ -153,5 +182,17 @@ const messages = defineMessages({
{{ formatMessage(messages.postExitDescription) }}
+
+
+ {{ formatMessage(messages.hookVariablesDescription) }}
+
+
+ - {{ formatMessage(messages.instanceNameDescription) }}
+ - {{ formatMessage(messages.instanceIdDescription) }}
+ - {{ formatMessage(messages.instanceDirDescription) }}
+ - {{ formatMessage(messages.instanceMcDirDescription) }}
+ - {{ formatMessage(messages.instanceJavaDescription) }}
+ - {{ formatMessage(messages.instanceJavaArgsDescription) }}
+
diff --git a/apps/app-frontend/src/components/ui/settings/DefaultInstanceSettings.vue b/apps/app-frontend/src/components/ui/settings/DefaultInstanceSettings.vue
index 252de30984..32a94995f1 100644
--- a/apps/app-frontend/src/components/ui/settings/DefaultInstanceSettings.vue
+++ b/apps/app-frontend/src/components/ui/settings/DefaultInstanceSettings.vue
@@ -181,6 +181,18 @@ watch(
/>
Ran after the game closes.
+
+
+ Hooks run in the working directory of the instance, with the following variables:
+
+ - $INST_NAME: The name of the instance
+ - $INST_ID: The name of the instance's folder
+ - $INST_DIR: The absolute path to the instance's folder
+ - $INST_MC_DIR: An alias for $INST_DIR
+ - $INST_JAVA: The absolute path to the java binary
+ - $INST_JAVA_ARGS: The JVM Arguments provided to the game
+
+
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
index 1d748cc381..e1cf573f73 100644
--- a/apps/app-frontend/src/locales/en-US/index.json
+++ b/apps/app-frontend/src/locales/en-US/index.json
@@ -593,6 +593,27 @@
"instance.settings.tabs.hooks.title": {
"message": "Game launch hooks"
},
+ "instance.settings.tabs.hooks.variables.description": {
+ "message": "Hooks run in the working directory of the instance, with the following variables:"
+ },
+ "instance.settings.tabs.hooks.variables.inst-dir.description": {
+ "message": "$INST_DIR: The absolute path to the instance's folder"
+ },
+ "instance.settings.tabs.hooks.variables.inst-id.description": {
+ "message": "$INST_ID: The name of the instance's folder"
+ },
+ "instance.settings.tabs.hooks.variables.inst-java-args.description": {
+ "message": "$INST_JAVA_ARGS: The JVM Arguments provided to the game"
+ },
+ "instance.settings.tabs.hooks.variables.inst-java.description": {
+ "message": "$INST_JAVA: The absolute path to the java binary"
+ },
+ "instance.settings.tabs.hooks.variables.inst-mc-dir.description": {
+ "message": "$INST_MC_DIR: An alias for $INST_DIR"
+ },
+ "instance.settings.tabs.hooks.variables.inst-name.description": {
+ "message": "$INST_NAME: The name of the instance"
+ },
"instance.settings.tabs.hooks.wrapper": {
"message": "Wrapper"
},
diff --git a/packages/app-lib/src/api/profile/mod.rs b/packages/app-lib/src/api/profile/mod.rs
index 31b3a965df..808521e158 100644
--- a/packages/app-lib/src/api/profile/mod.rs
+++ b/packages/app-lib/src/api/profile/mod.rs
@@ -785,15 +785,84 @@ async fn run_credentials(
))
})?;
- let pre_launch_hooks = profile
+ let pre_launch_hook = profile
.hooks
.pre_launch
.as_ref()
.or(settings.hooks.pre_launch.as_ref())
.filter(|hook_command| !hook_command.is_empty());
- if let Some(hook) = pre_launch_hooks {
- // TODO: hook parameters
- let mut cmd = shlex::split(hook)
+
+ let java_args = profile
+ .extra_launch_args
+ .clone()
+ .unwrap_or(settings.extra_launch_args);
+
+ let wrapper = profile
+ .hooks
+ .wrapper
+ .clone()
+ .or(settings.hooks.wrapper)
+ .filter(|hook_command| !hook_command.is_empty());
+
+ let env_args = profile
+ .custom_env_vars
+ .clone()
+ .unwrap_or(settings.custom_env_vars);
+
+ // Post post exit hooks
+ let post_exit_hook = profile
+ .hooks
+ .post_exit
+ .clone()
+ .or(settings.hooks.post_exit)
+ .filter(|hook_command| !hook_command.is_empty());
+
+ let memory = profile.memory.unwrap_or(settings.memory);
+ let resolution =
+ profile.game_resolution.unwrap_or(settings.game_resolution);
+ let has_hook_commands = pre_launch_hook.is_some()
+ || wrapper.is_some()
+ || post_exit_hook.is_some();
+ let full_path = if has_hook_commands {
+ Some(get_full_path(&profile.path).await?)
+ } else {
+ None
+ };
+ let hook_environment = if has_hook_commands {
+ let full_path = full_path
+ .as_ref()
+ .expect("hooked launches always resolve their instance path");
+ let java_version =
+ crate::launcher::resolve_java_for_launch(&profile).await?;
+
+ Some(crate::launcher::hooks::HookEnvironment::from_current_env(
+ &env_args,
+ crate::launcher::hooks::HookVariables {
+ instance_name: profile.name.clone(),
+ instance_id: profile.path.clone(),
+ instance_dir: full_path.to_string_lossy().to_string(),
+ java_path: java_version.path.clone(),
+ java_args: crate::launcher::hooks::build_hook_java_args(
+ &java_args,
+ memory,
+ &java_version,
+ ),
+ },
+ ))
+ } else {
+ None
+ };
+ let launch_env_args = hook_environment
+ .as_ref()
+ .map_or_else(|| env_args.clone(), |env| env.injected_envs());
+
+ if let (Some(hook), Some(hook_environment), Some(full_path)) = (
+ pre_launch_hook,
+ hook_environment.as_ref(),
+ full_path.as_ref(),
+ ) {
+ let expanded_hook = hook_environment.expand(hook);
+ let mut cmd = shlex::split(&expanded_hook)
.ok_or_else(|| {
crate::ErrorKind::LauncherError(format!(
"Invalid pre-launch command: {hook}",
@@ -802,12 +871,12 @@ async fn run_credentials(
.into_iter();
if let Some(command) = cmd.next() {
- let full_path = get_full_path(&profile.path).await?;
let result = Command::new(command)
.args(cmd)
- .current_dir(&full_path)
+ .envs(launch_env_args.iter().cloned())
+ .current_dir(full_path)
.spawn()
- .map_err(|e| IOError::with_path(e, &full_path))?
+ .map_err(|e| IOError::with_path(e, full_path))?
.wait()
.await
.map_err(IOError::from)?;
@@ -822,33 +891,19 @@ async fn run_credentials(
}
}
- let java_args = profile
- .extra_launch_args
- .clone()
- .unwrap_or(settings.extra_launch_args);
-
- let wrapper = profile
- .hooks
- .wrapper
- .clone()
- .or(settings.hooks.wrapper)
+ let wrapper = wrapper
+ .map(|hook| {
+ hook_environment
+ .as_ref()
+ .map_or(hook.clone(), |env| env.expand(&hook))
+ })
.filter(|hook_command| !hook_command.is_empty());
-
- let memory = profile.memory.unwrap_or(settings.memory);
- let resolution =
- profile.game_resolution.unwrap_or(settings.game_resolution);
-
- let env_args = profile
- .custom_env_vars
- .clone()
- .unwrap_or(settings.custom_env_vars);
-
- // Post post exit hooks
- let post_exit_hook = profile
- .hooks
- .post_exit
- .clone()
- .or(settings.hooks.post_exit)
+ let post_exit_hook = post_exit_hook
+ .map(|hook| {
+ hook_environment
+ .as_ref()
+ .map_or(hook.clone(), |env| env.expand(&hook))
+ })
.filter(|hook_command| !hook_command.is_empty());
// Any options.txt settings that we want set, add here
@@ -919,7 +974,7 @@ async fn run_credentials(
crate::launcher::launch_minecraft(
&java_args,
- &env_args,
+ &launch_env_args,
&mc_set_options,
&wrapper,
&memory,
diff --git a/packages/app-lib/src/launcher/hooks.rs b/packages/app-lib/src/launcher/hooks.rs
new file mode 100644
index 0000000000..942e2d0420
--- /dev/null
+++ b/packages/app-lib/src/launcher/hooks.rs
@@ -0,0 +1,165 @@
+use crate::state::{JavaVersion, MemorySettings};
+use regex::{Captures, Regex};
+use std::collections::BTreeMap;
+use std::sync::LazyLock;
+
+static ENV_VAR_PATTERN: LazyLock =
+ 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)?;