Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,11 @@ watch(
<hr class="my-6 bg-button-border border-none h-[1px]" />

<div class="flex flex-col gap-6">
<p class="m-0 leading-tight">
Commands support $INST_NAME, $INST_ID, $INST_DIR/$INST_MC_DIR, $INST_JAVA,
$INST_JAVA_ARGS.
</p>

<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">Pre launch hook</h3>
<StyledInput
Expand Down
2 changes: 1 addition & 1 deletion apps/app-frontend/src/locales/en-US/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@
"message": "Custom launch hooks"
},
"instance.settings.tabs.hooks.description": {
"message": "Hooks allow advanced users to run certain system commands before and after launching the game."
"message": "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."
},
"instance.settings.tabs.hooks.post-exit": {
"message": "Post-exit"
Expand Down
123 changes: 89 additions & 34 deletions packages/app-lib/src/api/profile/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -781,15 +781,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}",
Expand All @@ -798,12 +867,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)?;
Expand All @@ -818,33 +887,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
Expand Down Expand Up @@ -915,7 +970,7 @@ async fn run_credentials(

crate::launcher::launch_minecraft(
&java_args,
&env_args,
&launch_env_args,
&mc_set_options,
&wrapper,
&memory,
Expand Down
165 changes: 165 additions & 0 deletions packages/app-lib/src/launcher/hooks.rs
Original file line number Diff line number Diff line change
@@ -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<Regex> =
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<String, String>,
injected_env: BTreeMap<String, String>,
}

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<Item = (String, String)>,
custom_env_vars: &[(String, String)],
variables: HookVariables,
) -> Self {
let mut lookup_env =
process_env.into_iter().collect::<BTreeMap<_, _>>();
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");
}
}
Loading
Loading