Skip to content

Commit bb8a171

Browse files
authored
fix(desktop): restore shell path env for desktop sidecar (#15211)
1 parent 6b02165 commit bb8a171

1 file changed

Lines changed: 184 additions & 1 deletion

File tree

  • packages/desktop/src-tauri/src

packages/desktop/src-tauri/src/cli.rs

Lines changed: 184 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ use process_wrap::tokio::CommandWrap;
44
use process_wrap::tokio::ProcessGroup;
55
#[cfg(windows)]
66
use process_wrap::tokio::{CommandWrapper, JobObject, KillOnDrop};
7+
use std::collections::HashMap;
78
#[cfg(unix)]
89
use std::os::unix::process::ExitStatusExt;
10+
use std::path::Path;
11+
use std::process::Stdio;
912
use std::sync::Arc;
10-
use std::{process::Stdio, time::Duration};
13+
use std::time::{Duration, Instant};
1114
use tauri::{AppHandle, Manager, path::BaseDirectory};
1215
use tauri_specta::Event;
1316
use tokio::{
@@ -39,6 +42,7 @@ impl CommandWrapper for WinCreationFlags {
3942

4043
const CLI_INSTALL_DIR: &str = ".opencode/bin";
4144
const CLI_BINARY_NAME: &str = "opencode";
45+
const SHELL_ENV_TIMEOUT: Duration = Duration::from_secs(5);
4246

4347
#[derive(serde::Deserialize, Debug)]
4448
pub struct ServerConfig {
@@ -232,6 +236,133 @@ fn shell_escape(input: &str) -> String {
232236
escaped
233237
}
234238

239+
fn parse_shell_env(stdout: &[u8]) -> HashMap<String, String> {
240+
String::from_utf8_lossy(stdout)
241+
.split('\0')
242+
.filter_map(|line| {
243+
if line.is_empty() {
244+
return None;
245+
}
246+
247+
let (key, value) = line.split_once('=')?;
248+
if key.is_empty() {
249+
return None;
250+
}
251+
252+
Some((key.to_string(), value.to_string()))
253+
})
254+
.collect()
255+
}
256+
257+
fn command_output_with_timeout(
258+
mut cmd: std::process::Command,
259+
timeout: Duration,
260+
) -> std::io::Result<Option<std::process::Output>> {
261+
let mut child = cmd.spawn()?;
262+
let start = Instant::now();
263+
264+
loop {
265+
if child.try_wait()?.is_some() {
266+
return child.wait_with_output().map(Some);
267+
}
268+
269+
if start.elapsed() >= timeout {
270+
let _ = child.kill();
271+
let _ = child.wait();
272+
return Ok(None);
273+
}
274+
275+
std::thread::sleep(Duration::from_millis(25));
276+
}
277+
}
278+
279+
enum ShellEnvProbe {
280+
Loaded(HashMap<String, String>),
281+
Timeout,
282+
Unavailable,
283+
}
284+
285+
fn probe_shell_env(shell: &str, mode: &str) -> ShellEnvProbe {
286+
let mut cmd = std::process::Command::new(shell);
287+
cmd.args([mode, "-c", "env -0"]);
288+
cmd.stdin(Stdio::null());
289+
cmd.stdout(Stdio::piped());
290+
cmd.stderr(Stdio::null());
291+
let output = match command_output_with_timeout(cmd, SHELL_ENV_TIMEOUT) {
292+
Ok(Some(output)) => output,
293+
Ok(None) => return ShellEnvProbe::Timeout,
294+
Err(error) => {
295+
tracing::debug!(shell, mode, ?error, "Shell env probe failed");
296+
return ShellEnvProbe::Unavailable;
297+
}
298+
};
299+
if !output.status.success() {
300+
tracing::debug!(shell, mode, "Shell env probe exited with non-zero status");
301+
return ShellEnvProbe::Unavailable;
302+
}
303+
let env = parse_shell_env(&output.stdout);
304+
if env.is_empty() {
305+
tracing::debug!(shell, mode, "Shell env probe returned empty env");
306+
return ShellEnvProbe::Unavailable;
307+
}
308+
309+
ShellEnvProbe::Loaded(env)
310+
}
311+
312+
fn is_nushell(shell: &str) -> bool {
313+
let shell_name = Path::new(shell)
314+
.file_name()
315+
.and_then(|name| name.to_str())
316+
.unwrap_or(shell)
317+
.to_ascii_lowercase();
318+
shell_name == "nu" || shell_name == "nu.exe" || shell.to_ascii_lowercase().ends_with("\\nu.exe")
319+
}
320+
fn load_shell_env(shell: &str) -> Option<HashMap<String, String>> {
321+
if is_nushell(shell) {
322+
tracing::debug!(shell, "Skipping shell env probe for nushell");
323+
return None;
324+
}
325+
326+
match probe_shell_env(shell, "-il") {
327+
ShellEnvProbe::Loaded(env) => {
328+
tracing::info!(
329+
shell,
330+
env_count = env.len(),
331+
"Loaded shell environment with -il"
332+
);
333+
return Some(env);
334+
}
335+
ShellEnvProbe::Timeout => {
336+
tracing::warn!(shell, "Interactive shell env probe timed out");
337+
return None;
338+
}
339+
ShellEnvProbe::Unavailable => {}
340+
}
341+
342+
if let ShellEnvProbe::Loaded(env) = probe_shell_env(shell, "-l") {
343+
tracing::info!(
344+
shell,
345+
env_count = env.len(),
346+
"Loaded shell environment with -l"
347+
);
348+
return Some(env);
349+
}
350+
tracing::warn!(shell, "Falling back to app environment");
351+
None
352+
}
353+
354+
fn merge_shell_env(
355+
shell_env: Option<HashMap<String, String>>,
356+
envs: Vec<(String, String)>,
357+
) -> Vec<(String, String)> {
358+
let mut merged = shell_env.unwrap_or_default();
359+
for (key, value) in envs {
360+
merged.insert(key, value);
361+
}
362+
363+
merged.into_iter().collect()
364+
}
365+
235366
pub fn spawn_command(
236367
app: &tauri::AppHandle,
237368
args: &str,
@@ -312,6 +443,7 @@ pub fn spawn_command(
312443
} else {
313444
let sidecar = get_sidecar_path(app);
314445
let shell = get_user_shell();
446+
let envs = merge_shell_env(load_shell_env(&shell), envs);
315447

316448
let line = if shell.ends_with("/nu") {
317449
format!("^\"{}\" {}", sidecar.display(), args)
@@ -556,3 +688,54 @@ async fn read_line<F: Fn(String) -> CommandEvent + Send + Copy + 'static>(
556688
}
557689
}
558690
}
691+
692+
#[cfg(test)]
693+
mod tests {
694+
use super::*;
695+
use std::collections::HashMap;
696+
697+
#[test]
698+
fn parse_shell_env_supports_null_delimited_pairs() {
699+
let env = parse_shell_env(b"PATH=/usr/bin:/bin\0FOO=bar=baz\0\0");
700+
701+
assert_eq!(env.get("PATH"), Some(&"/usr/bin:/bin".to_string()));
702+
assert_eq!(env.get("FOO"), Some(&"bar=baz".to_string()));
703+
}
704+
705+
#[test]
706+
fn parse_shell_env_ignores_invalid_entries() {
707+
let env = parse_shell_env(b"INVALID\0=empty\0OK=1\0");
708+
709+
assert_eq!(env.len(), 1);
710+
assert_eq!(env.get("OK"), Some(&"1".to_string()));
711+
}
712+
713+
#[test]
714+
fn merge_shell_env_keeps_explicit_overrides() {
715+
let mut shell_env = HashMap::new();
716+
shell_env.insert("PATH".to_string(), "/shell/path".to_string());
717+
shell_env.insert("HOME".to_string(), "/tmp/home".to_string());
718+
719+
let merged = merge_shell_env(
720+
Some(shell_env),
721+
vec![
722+
("PATH".to_string(), "/desktop/path".to_string()),
723+
("OPENCODE_CLIENT".to_string(), "desktop".to_string()),
724+
],
725+
)
726+
.into_iter()
727+
.collect::<HashMap<_, _>>();
728+
729+
assert_eq!(merged.get("PATH"), Some(&"/desktop/path".to_string()));
730+
assert_eq!(merged.get("HOME"), Some(&"/tmp/home".to_string()));
731+
assert_eq!(merged.get("OPENCODE_CLIENT"), Some(&"desktop".to_string()));
732+
}
733+
734+
#[test]
735+
fn is_nushell_handles_path_and_binary_name() {
736+
assert!(is_nushell("nu"));
737+
assert!(is_nushell("/opt/homebrew/bin/nu"));
738+
assert!(is_nushell("C:\\Program Files\\nu.exe"));
739+
assert!(!is_nushell("/bin/zsh"));
740+
}
741+
}

0 commit comments

Comments
 (0)