Skip to content

Commit 62adfed

Browse files
committed
feat: Implement launcher supplementary environmental variables, and env var substitution in hooks
1 parent faf593b commit 62adfed

7 files changed

Lines changed: 322 additions & 38 deletions

File tree

apps/app-frontend/src/components/ui/instance_settings/HooksSettings.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ const messages = defineMessages({
5454
hooksDescription: {
5555
id: 'instance.settings.tabs.hooks.description',
5656
defaultMessage:
57-
'Hooks allow advanced users to run certain system commands before and after launching the game.',
57+
'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.',
5858
},
5959
customHooks: {
6060
id: 'instance.settings.tabs.hooks.custom-hooks',

apps/app-frontend/src/components/ui/settings/DefaultInstanceSettings.vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,11 @@ watch(
143143
<hr class="my-6 bg-button-border border-none h-[1px]" />
144144

145145
<div class="flex flex-col gap-6">
146+
<p class="m-0 leading-tight">
147+
Commands support $INST_NAME, $INST_ID, $INST_DIR/$INST_MC_DIR, $INST_JAVA,
148+
$INST_JAVA_ARGS.
149+
</p>
150+
146151
<div class="flex flex-col gap-2.5">
147152
<h3 class="m-0 text-lg font-semibold text-contrast">Pre launch hook</h3>
148153
<StyledInput

apps/app-frontend/src/locales/en-US/index.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,7 @@
447447
"message": "Custom launch hooks"
448448
},
449449
"instance.settings.tabs.hooks.description": {
450-
"message": "Hooks allow advanced users to run certain system commands before and after launching the game."
450+
"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."
451451
},
452452
"instance.settings.tabs.hooks.post-exit": {
453453
"message": "Post-exit"

packages/app-lib/src/api/profile/mod.rs

Lines changed: 89 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -781,15 +781,84 @@ async fn run_credentials(
781781
))
782782
})?;
783783

784-
let pre_launch_hooks = profile
784+
let pre_launch_hook = profile
785785
.hooks
786786
.pre_launch
787787
.as_ref()
788788
.or(settings.hooks.pre_launch.as_ref())
789789
.filter(|hook_command| !hook_command.is_empty());
790-
if let Some(hook) = pre_launch_hooks {
791-
// TODO: hook parameters
792-
let mut cmd = shlex::split(hook)
790+
791+
let java_args = profile
792+
.extra_launch_args
793+
.clone()
794+
.unwrap_or(settings.extra_launch_args);
795+
796+
let wrapper = profile
797+
.hooks
798+
.wrapper
799+
.clone()
800+
.or(settings.hooks.wrapper)
801+
.filter(|hook_command| !hook_command.is_empty());
802+
803+
let env_args = profile
804+
.custom_env_vars
805+
.clone()
806+
.unwrap_or(settings.custom_env_vars);
807+
808+
// Post post exit hooks
809+
let post_exit_hook = profile
810+
.hooks
811+
.post_exit
812+
.clone()
813+
.or(settings.hooks.post_exit)
814+
.filter(|hook_command| !hook_command.is_empty());
815+
816+
let memory = profile.memory.unwrap_or(settings.memory);
817+
let resolution =
818+
profile.game_resolution.unwrap_or(settings.game_resolution);
819+
let has_hook_commands = pre_launch_hook.is_some()
820+
|| wrapper.is_some()
821+
|| post_exit_hook.is_some();
822+
let full_path = if has_hook_commands {
823+
Some(get_full_path(&profile.path).await?)
824+
} else {
825+
None
826+
};
827+
let hook_environment = if has_hook_commands {
828+
let full_path = full_path
829+
.as_ref()
830+
.expect("hooked launches always resolve their instance path");
831+
let java_version =
832+
crate::launcher::resolve_java_for_launch(&profile).await?;
833+
834+
Some(crate::launcher::hooks::HookEnvironment::from_current_env(
835+
&env_args,
836+
crate::launcher::hooks::HookVariables {
837+
instance_name: profile.name.clone(),
838+
instance_id: profile.path.clone(),
839+
instance_dir: full_path.to_string_lossy().to_string(),
840+
java_path: java_version.path.clone(),
841+
java_args: crate::launcher::hooks::build_hook_java_args(
842+
&java_args,
843+
memory,
844+
&java_version,
845+
),
846+
},
847+
))
848+
} else {
849+
None
850+
};
851+
let launch_env_args = hook_environment
852+
.as_ref()
853+
.map_or_else(|| env_args.clone(), |env| env.injected_envs());
854+
855+
if let (Some(hook), Some(hook_environment), Some(full_path)) = (
856+
pre_launch_hook,
857+
hook_environment.as_ref(),
858+
full_path.as_ref(),
859+
) {
860+
let expanded_hook = hook_environment.expand(hook);
861+
let mut cmd = shlex::split(&expanded_hook)
793862
.ok_or_else(|| {
794863
crate::ErrorKind::LauncherError(format!(
795864
"Invalid pre-launch command: {hook}",
@@ -798,12 +867,12 @@ async fn run_credentials(
798867
.into_iter();
799868

800869
if let Some(command) = cmd.next() {
801-
let full_path = get_full_path(&profile.path).await?;
802870
let result = Command::new(command)
803871
.args(cmd)
804-
.current_dir(&full_path)
872+
.envs(launch_env_args.iter().cloned())
873+
.current_dir(full_path)
805874
.spawn()
806-
.map_err(|e| IOError::with_path(e, &full_path))?
875+
.map_err(|e| IOError::with_path(e, full_path))?
807876
.wait()
808877
.await
809878
.map_err(IOError::from)?;
@@ -818,33 +887,19 @@ async fn run_credentials(
818887
}
819888
}
820889

821-
let java_args = profile
822-
.extra_launch_args
823-
.clone()
824-
.unwrap_or(settings.extra_launch_args);
825-
826-
let wrapper = profile
827-
.hooks
828-
.wrapper
829-
.clone()
830-
.or(settings.hooks.wrapper)
890+
let wrapper = wrapper
891+
.map(|hook| {
892+
hook_environment
893+
.as_ref()
894+
.map_or(hook.clone(), |env| env.expand(&hook))
895+
})
831896
.filter(|hook_command| !hook_command.is_empty());
832-
833-
let memory = profile.memory.unwrap_or(settings.memory);
834-
let resolution =
835-
profile.game_resolution.unwrap_or(settings.game_resolution);
836-
837-
let env_args = profile
838-
.custom_env_vars
839-
.clone()
840-
.unwrap_or(settings.custom_env_vars);
841-
842-
// Post post exit hooks
843-
let post_exit_hook = profile
844-
.hooks
845-
.post_exit
846-
.clone()
847-
.or(settings.hooks.post_exit)
897+
let post_exit_hook = post_exit_hook
898+
.map(|hook| {
899+
hook_environment
900+
.as_ref()
901+
.map_or(hook.clone(), |env| env.expand(&hook))
902+
})
848903
.filter(|hook_command| !hook_command.is_empty());
849904

850905
// Any options.txt settings that we want set, add here
@@ -915,7 +970,7 @@ async fn run_credentials(
915970

916971
crate::launcher::launch_minecraft(
917972
&java_args,
918-
&env_args,
973+
&launch_env_args,
919974
&mc_set_options,
920975
&wrapper,
921976
&memory,
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
use crate::state::{JavaVersion, MemorySettings};
2+
use regex::{Captures, Regex};
3+
use std::collections::BTreeMap;
4+
use std::sync::LazyLock;
5+
6+
static ENV_VAR_PATTERN: LazyLock<Regex> =
7+
LazyLock::new(|| Regex::new(r"\$(\w+)").expect("valid env var regex"));
8+
9+
#[derive(Debug, Clone)]
10+
pub(crate) struct HookVariables {
11+
pub instance_name: String,
12+
pub instance_id: String,
13+
pub instance_dir: String,
14+
pub java_path: String,
15+
pub java_args: String,
16+
}
17+
18+
#[derive(Debug, Clone)]
19+
pub(crate) struct HookEnvironment {
20+
lookup_env: BTreeMap<String, String>,
21+
injected_env: BTreeMap<String, String>,
22+
}
23+
24+
impl HookEnvironment {
25+
pub(crate) fn from_current_env(
26+
custom_env_vars: &[(String, String)],
27+
variables: HookVariables,
28+
) -> Self {
29+
Self::new(
30+
std::env::vars_os().map(|(key, value)| {
31+
(
32+
key.to_string_lossy().into_owned(),
33+
value.to_string_lossy().into_owned(),
34+
)
35+
}),
36+
custom_env_vars,
37+
variables,
38+
)
39+
}
40+
41+
fn new(
42+
process_env: impl IntoIterator<Item = (String, String)>,
43+
custom_env_vars: &[(String, String)],
44+
variables: HookVariables,
45+
) -> Self {
46+
let mut lookup_env =
47+
process_env.into_iter().collect::<BTreeMap<_, _>>();
48+
let mut injected_env = BTreeMap::new();
49+
50+
for (key, value) in custom_env_vars {
51+
lookup_env.insert(key.clone(), value.clone());
52+
injected_env.insert(key.clone(), value.clone());
53+
}
54+
55+
let hook_vars = [
56+
("INST_NAME", variables.instance_name),
57+
("INST_ID", variables.instance_id),
58+
("INST_DIR", variables.instance_dir.clone()),
59+
("INST_MC_DIR", variables.instance_dir),
60+
("INST_JAVA", variables.java_path),
61+
("INST_JAVA_ARGS", variables.java_args),
62+
];
63+
64+
for (key, value) in hook_vars {
65+
let key = key.to_string();
66+
lookup_env.insert(key.clone(), value.clone());
67+
injected_env.insert(key, value);
68+
}
69+
70+
Self {
71+
lookup_env,
72+
injected_env,
73+
}
74+
}
75+
76+
pub(crate) fn expand(&self, input: &str) -> String {
77+
ENV_VAR_PATTERN
78+
.replace_all(input, |captures: &Captures| {
79+
self.lookup_env
80+
.get(&captures[1])
81+
.cloned()
82+
.unwrap_or_else(|| captures[0].to_string())
83+
})
84+
.into_owned()
85+
}
86+
87+
pub(crate) fn injected_envs(&self) -> Vec<(String, String)> {
88+
self.injected_env
89+
.iter()
90+
.map(|(key, value)| (key.clone(), value.clone()))
91+
.collect()
92+
}
93+
}
94+
95+
pub(crate) fn build_hook_java_args(
96+
java_args: &[String],
97+
memory: MemorySettings,
98+
java_version: &JavaVersion,
99+
) -> String {
100+
let mut args = vec![format!("-Xmx{}M", memory.maximum)];
101+
102+
args.extend(java_args.iter().filter(|arg| !arg.is_empty()).cloned());
103+
104+
if java_version.parsed_version >= 9 {
105+
args.push(
106+
"--add-opens=java.base/java.lang.reflect=ALL-UNNAMED".to_string(),
107+
);
108+
}
109+
110+
if java_version.parsed_version >= 25 {
111+
args.push(
112+
"--add-opens=jdk.internal/jdk.internal.misc=ALL-UNNAMED"
113+
.to_string(),
114+
);
115+
}
116+
117+
args.join(" ")
118+
}
119+
120+
#[cfg(test)]
121+
mod tests {
122+
use super::*;
123+
124+
fn sample_variables() -> HookVariables {
125+
HookVariables {
126+
instance_name: "Test Instance".to_string(),
127+
instance_id: "test-instance".to_string(),
128+
instance_dir: "/profiles/test-instance".to_string(),
129+
java_path: "/java/bin/java".to_string(),
130+
java_args: "-Xmx4096M".to_string(),
131+
}
132+
}
133+
134+
#[test]
135+
fn expands_builtin_and_custom_variables() {
136+
let env = HookEnvironment::new(
137+
[("HOME".to_string(), "/home/alex".to_string())],
138+
&[("CUSTOM_VAR".to_string(), "custom".to_string())],
139+
sample_variables(),
140+
);
141+
142+
assert_eq!(
143+
env.expand("$HOME/$INST_ID/$CUSTOM_VAR"),
144+
"/home/alex/test-instance/custom"
145+
);
146+
}
147+
148+
#[test]
149+
fn leaves_unknown_variables_untouched() {
150+
let env = HookEnvironment::new([], &[], sample_variables());
151+
152+
assert_eq!(env.expand("$UNKNOWN/$INST_NAME"), "$UNKNOWN/Test Instance");
153+
}
154+
155+
#[test]
156+
fn expands_empty_variables_to_empty_strings() {
157+
let env = HookEnvironment::new(
158+
[("EMPTY_VAR".to_string(), String::new())],
159+
&[],
160+
sample_variables(),
161+
);
162+
163+
assert_eq!(env.expand("prefix$EMPTY_VAR-suffix"), "prefix-suffix");
164+
}
165+
}

0 commit comments

Comments
 (0)