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