Skip to main content

DevelopmentNodeEnvironment_MicrosoftVSCodeDependency_22NodeVersion_Bundle_Clean_Debug_ElectronProfile_EsbuildCompiler_Mountain/Environment/Utility/
EnhanceShellEnvironment.rs

1//! macOS / Linux GUI launches (Finder double-click, Dock, Spotlight,
2//! `open <bundle>.app`) hand the app a minimal environment:
3//! `PATH=/usr/bin:/bin:/usr/sbin:/sbin`, no `NVM_DIR`, no `HOMEBREW_PREFIX`,
4//! no `JAVA_HOME`, …
5//!
6//! That breaks every child process Mountain or its extensions spawn:
7//! - Cocoon's `node` binary can't find Homebrew installs (`/opt/homebrew/bin`,
8//!   `/usr/local/bin`).
9//! - Language servers (rust-analyzer, gopls, pyright) probe `PATH` and fail to
10//!   launch.
11//! - Git extensions invoking `git` fall back to `/usr/bin/git` (Apple's ancient
12//!   stock copy) instead of the Homebrew one.
13//!
14//! VS Code, Atom, and most other Electron editors solve this by spawning
15//! the user's interactive shell with `-ilc env` once at boot and merging
16//! the result into the process environment. We do the same here.
17//!
18//! Skipped when:
19//! - The launcher is already a TTY (the user invoked from a terminal - PATH is
20//!   already correct).
21//! - `Walk=0` (matches the existing knob users may rely on).
22//! - The shell probe fails or times out (best-effort; never fatal).
23
24use std::time::Duration;
25
26/// Run `$SHELL -ilc env` and merge novel keys into `std::env`. Existing
27/// values win - never clobber an env var the parent process explicitly
28/// set (especially `PATH` if the user passed one). Caller is expected
29/// to invoke this exactly once during boot, before any child process
30/// is spawned.
31pub fn Fn() {
32	// TTY = launched from terminal = already has the user's shell env.
33	if IsTty() {
34		return;
35	}
36
37	let Shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
38
39	// `-i` (interactive) loads `~/.zshrc` / `~/.bashrc` where users
40	// typically extend PATH. `-l` (login) loads `~/.zprofile` /
41	// `~/.bash_profile` where Homebrew, NVM, and similar set their
42	// roots. `-c env` prints every var the shell knows.
43	let Output = std::process::Command::new(&Shell)
44		.args(["-ilc", "env"])
45		.stdin(std::process::Stdio::null())
46		.stdout(std::process::Stdio::piped())
47		.stderr(std::process::Stdio::null())
48		.spawn();
49
50	let mut Child = match Output {
51		Ok(C) => C,
52
53		Err(_) => return,
54	};
55
56	// Hard cap so a misbehaving rc-file (network call in `.zshrc`,
57	// blocking `read`) doesn't stall boot. 2 s is well above the
58	// observed worst-case shells in the wild.
59	let Deadline = std::time::Instant::now() + Duration::from_secs(2);
60
61	loop {
62		match Child.try_wait() {
63			Ok(Some(_)) => break,
64
65			Ok(None) => {
66				if std::time::Instant::now() >= Deadline {
67					let _ = Child.kill();
68
69					let _ = Child.wait();
70
71					return;
72				}
73
74				std::thread::sleep(Duration::from_millis(20));
75			},
76
77			Err(_) => return,
78		}
79	}
80
81	let StdoutBytes = match Child.wait_with_output() {
82		Ok(O) => O.stdout,
83
84		Err(_) => return,
85	};
86
87	let Text = match String::from_utf8(StdoutBytes) {
88		Ok(S) => S,
89
90		Err(_) => return,
91	};
92
93	for Line in Text.lines() {
94		let Some((Key, Value)) = Line.split_once('=') else { continue };
95
96		let Key = Key.trim();
97
98		if Key.is_empty() || !IsPortableEnvName(Key) {
99			continue;
100		}
101
102		// PATH is special: we only reach this point because IsTty() was
103		// false, meaning the process was launched from Finder/Dock/launchd
104		// with PATH=/usr/bin:/bin:/usr/sbin:/sbin.  That minimal value
105		// is NOT the user's intentional PATH - always let the shell
106		// replace it so git, node, language servers, etc. are all found.
107		// For every other var, preserve any explicit value the user set
108		// (e.g. `FOO=bar open /Applications/X.app`).
109		if Key != "PATH" && std::env::var_os(Key).is_some() {
110			continue;
111		}
112
113		// SAFETY: pre-window, single-threaded boot path. set_var is
114		// safe at this point. Mountain's other modules read env
115		// through `std::env::var` snapshots after this returns.
116		unsafe { std::env::set_var(Key, Value) };
117	}
118}
119
120fn IsTty() -> bool {
121	// `IsTerminal` (stable since Rust 1.70) wraps platform isatty
122	// without pulling in libc. Stdin is the right fd to probe -
123	// Mountain redirects stdout/stderr to its own logger, so those
124	// always look "non-tty" even from a real terminal.
125	use std::io::IsTerminal;
126
127	std::io::stdin().is_terminal()
128}
129
130/// Reject keys with characters outside the portable POSIX set so a
131/// hostile rc-file can't sneak shell metacharacters into our env via a
132/// crafted `Key=` line. Standard env-var names are
133/// `[A-Za-z_][A-Za-z0-9_]*`; anything else is dropped silently.
134fn IsPortableEnvName(Name:&str) -> bool {
135	let mut Chars = Name.chars();
136
137	match Chars.next() {
138		Some(C) if C.is_ascii_alphabetic() || C == '_' => {},
139
140		_ => return false,
141	}
142
143	Chars.all(|C| C.is_ascii_alphanumeric() || C == '_')
144}