Skip to content

Commit 3a5378f

Browse files
committed
feat: Implement launcher supplementary environmental variables, and env var substitution in hooks
1 parent 1fd58e0 commit 3a5378f

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
@@ -564,7 +564,7 @@
564564
"message": "Custom launch hooks"
565565
},
566566
"instance.settings.tabs.hooks.description": {
567-
"message": "Hooks allow advanced users to run certain system commands before and after launching the game."
567+
"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."
568568
},
569569
"instance.settings.tabs.hooks.post-exit": {
570570
"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
@@ -785,15 +785,84 @@ async fn run_credentials(
785785
))
786786
})?;
787787

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

804873
if let Some(command) = cmd.next() {
805-
let full_path = get_full_path(&profile.path).await?;
806874
let result = Command::new(command)
807875
.args(cmd)
808-
.current_dir(&full_path)
876+
.envs(launch_env_args.iter().cloned())
877+
.current_dir(full_path)
809878
.spawn()
810-
.map_err(|e| IOError::with_path(e, &full_path))?
879+
.map_err(|e| IOError::with_path(e, full_path))?
811880
.wait()
812881
.await
813882
.map_err(IOError::from)?;
@@ -822,33 +891,19 @@ async fn run_credentials(
822891
}
823892
}
824893

825-
let java_args = profile
826-
.extra_launch_args
827-
.clone()
828-
.unwrap_or(settings.extra_launch_args);
829-
830-
let wrapper = profile
831-
.hooks
832-
.wrapper
833-
.clone()
834-
.or(settings.hooks.wrapper)
894+
let wrapper = wrapper
895+
.map(|hook| {
896+
hook_environment
897+
.as_ref()
898+
.map_or(hook.clone(), |env| env.expand(&hook))
899+
})
835900
.filter(|hook_command| !hook_command.is_empty());
836-
837-
let memory = profile.memory.unwrap_or(settings.memory);
838-
let resolution =
839-
profile.game_resolution.unwrap_or(settings.game_resolution);
840-
841-
let env_args = profile
842-
.custom_env_vars
843-
.clone()
844-
.unwrap_or(settings.custom_env_vars);
845-
846-
// Post post exit hooks
847-
let post_exit_hook = profile
848-
.hooks
849-
.post_exit
850-
.clone()
851-
.or(settings.hooks.post_exit)
901+
let post_exit_hook = post_exit_hook
902+
.map(|hook| {
903+
hook_environment
904+
.as_ref()
905+
.map_or(hook.clone(), |env| env.expand(&hook))
906+
})
852907
.filter(|hook_command| !hook_command.is_empty());
853908

854909
// Any options.txt settings that we want set, add here
@@ -919,7 +974,7 @@ async fn run_credentials(
919974

920975
crate::launcher::launch_minecraft(
921976
&java_args,
922-
&env_args,
977+
&launch_env_args,
923978
&mc_set_options,
924979
&wrapper,
925980
&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)