Skip to main content

Mountain/Environment/Utility/
EnhanceShellEnvironment.rs

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