Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/capabilities/linux.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"allow-active-paste-target",
"allow-preview-clipboard-image-paste",
"allow-preview-active-clipboard-image-paste",
"allow-pty-spawn",
"allow-pty-write",
"allow-pty-interrupt",
"allow-pty-resize",
Expand Down
165 changes: 102 additions & 63 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,16 @@ async fn pty_spawn(
rows: u16,
program: Option<String>,
args: Option<Vec<String>>,
) -> Result<u64, String> {
let size = TerminalSize::new(cols, rows).map_err(|error| format!("{error:?}"))?;
let command = resolve_pty_command(program, args);
) -> Result<u64, platform::PlatformError> {
let platform = platform::PlatformServices::detect()?;
let size = TerminalSize::new(cols, rows).map_err(|error| {
platform::PlatformError::native_mechanism(
platform.target(),
format!("invalid terminal size: {error:?}"),
false,
)
})?;
let command = resolve_pty_command(program, args, &platform)?;
let command_args = command.args.iter().map(String::as_str).collect::<Vec<_>>();

// No predecessor `.take()`/close here: sessions are keyed by id and coexist.
Expand Down Expand Up @@ -217,6 +224,28 @@ async fn pty_spawn(
let cleanup_app = app.clone();
let exit_app = app;
let closing_credit = Arc::clone(&credit);
#[cfg(unix)]
let session = PtySession::spawn_with_options_and_close_hook(
&command.program,
&command_args,
splice_pty::PtySpawnOptions {
cwd: None,
env: command.environment,
},
size,
move |id, output| {
let _ = tx.send((id, output));
},
move |id| {
let _ = exit_app.emit(PTY_EXIT_EVENT, id);
let cleanup_app = exit_app.clone();
std::thread::spawn(move || {
clear_and_close_session_by_id(&cleanup_app, id);
});
},
move || closing_credit.close(),
);
#[cfg(windows)]
let session = PtySession::spawn_with_close_hook(
&command.program,
&command_args,
Expand Down Expand Up @@ -251,26 +280,38 @@ async fn pty_spawn(
// reader parked in the blocking `send` above is always released, so a
// never-acking (crashed/closed) webview can never wedge teardown.
move || closing_credit.close(),
)
.map_err(|error| error.to_string())?;
);
let session = session.map_err(|error| {
platform::PlatformError::native_mechanism(
platform.target(),
format!("failed to start PTY: {error}"),
true,
)
})?;

let id = session.id();
// Publish the id so the flusher's stall reporter can attribute a stall event
// to this session (see the flusher thread above).
stall_session_id.store(id, std::sync::atomic::Ordering::SeqCst);

{
let mut guard = state
.sessions
.lock()
.map_err(|_| "PTY state lock poisoned".to_owned())?;
let mut guard = state.sessions.lock().map_err(|_| {
platform::PlatformError::native_mechanism(
platform.target(),
"PTY state lock poisoned",
true,
)
})?;
guard.insert(id, Arc::new(session));
}
{
let mut guard = state
.credits
.lock()
.map_err(|_| "PTY credit lock poisoned".to_owned())?;
let mut guard = state.credits.lock().map_err(|_| {
platform::PlatformError::native_mechanism(
platform.target(),
"PTY credit lock poisoned",
true,
)
})?;
guard.insert(id, credit);
}

Expand All @@ -285,7 +326,10 @@ async fn pty_spawn(
// id-scoped, idempotent `clear_and_close_session_by_id`, so a different
// session is never torn down, and its `close()` runs with the state lock
// released (no thread-join deadlock).
let still_running = clone_pty_session_by_id(state.inner(), id)?
let still_running = clone_pty_session_by_id(state.inner(), id)
.map_err(|message| {
platform::PlatformError::native_mechanism(platform.target(), message, true)
})?
.and_then(|session| session.is_running().ok())
.unwrap_or(false);
if !still_running {
Expand Down Expand Up @@ -372,49 +416,31 @@ fn clear_and_close_session_by_id(app: &tauri::AppHandle, id: u64) {
struct PtyCommand {
program: String,
args: Vec<String>,
environment: Vec<(String, String)>,
}

fn resolve_pty_command(program: Option<String>, args: Option<Vec<String>>) -> PtyCommand {
fn resolve_pty_command(
program: Option<String>,
args: Option<Vec<String>>,
platform: &platform::PlatformServices,
) -> Result<PtyCommand, platform::PlatformError> {
match program {
Some(program) if !program.trim().is_empty() => PtyCommand {
Some(program) if !program.trim().is_empty() => Ok(PtyCommand {
program,
args: args.unwrap_or_default(),
},
_ => PtyCommand {
program: "cmd.exe".to_owned(),
args: default_shell_args(),
},
environment: vec![],
}),
_ => {
let launch = platform.pty_launch();
Ok(PtyCommand {
program: launch.command.program,
args: launch.command.args,
environment: launch.environment,
})
}
}
}

fn default_shell_args() -> Vec<String> {
vec![
"/D".to_owned(),
"/K".to_owned(),
format!("set PATH={};%PATH%", common_cli_path_prefix()),
]
}

fn common_cli_path_prefix() -> String {
let user_profile = std::env::var("USERPROFILE").unwrap_or_default();
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_default();

[
format!("{user_profile}\\.local\\bin"),
format!("{user_profile}\\scoop\\shims"),
format!("{user_profile}\\scoop\\apps\\nodejs\\current\\bin"),
format!("{user_profile}\\scoop\\apps\\nodejs\\current"),
format!("{local_app_data}\\agy\\bin"),
format!("{local_app_data}\\Programs\\OpenCode\\bin"),
format!("{local_app_data}\\Programs\\opencode\\bin"),
format!("{local_app_data}\\OpenAI\\Codex\\bin"),
]
.into_iter()
.filter(|path| !path.starts_with('\\') && !path.is_empty())
.collect::<Vec<_>>()
.join(";")
}

/// Id-scoped write core, split out so its miss path is unit-testable without a
/// Tauri `State`. A miss returns the EXACT string `"PTY session is not
/// running"`, which the frontend's `isClosedPtyInputError` matches verbatim —
Expand Down Expand Up @@ -1072,34 +1098,47 @@ mod tests {

#[test]
fn resolve_pty_command_uses_safe_default_shell() {
let linux = platform::PlatformServices::from_facts(platform::PlatformFacts {
os: "linux".into(),
ubuntu: Some("24.04".into()),
wsl: None,
wslg: false,
path: Some("/usr/local/bin:/usr/bin:/bin".into()),
})
.expect("supported Linux platform");

assert_eq!(
resolve_pty_command(None, None),
resolve_pty_command(None, None, &linux).expect("Linux default command"),
PtyCommand {
program: "cmd.exe".to_owned(),
args: default_shell_args(),
program: "/bin/sh".to_owned(),
args: vec![],
environment: vec![("PATH".to_owned(), "/usr/local/bin:/usr/bin:/bin".to_owned())],
}
);
}

#[test]
fn default_shell_path_includes_common_cli_locations() {
let path_prefix = common_cli_path_prefix();

assert!(path_prefix.contains(".local\\bin"));
assert!(path_prefix.contains("scoop\\shims"));
assert!(path_prefix.contains("agy\\bin"));
}

#[test]
fn resolve_pty_command_accepts_configured_program() {
let linux = platform::PlatformServices::from_facts(platform::PlatformFacts {
os: "linux".into(),
ubuntu: Some("24.04".into()),
wsl: None,
wslg: false,
path: Some("/usr/bin:/bin".into()),
})
.expect("supported Linux platform");

assert_eq!(
resolve_pty_command(
Some("codex.exe".to_owned()),
Some(vec!["--help".to_owned()])
),
Some(vec!["--help".to_owned()]),
&linux,
)
.expect("configured command"),
PtyCommand {
program: "codex.exe".to_owned(),
args: vec!["--help".to_owned()],
environment: vec![],
}
);
}
Expand Down
33 changes: 33 additions & 0 deletions apps/desktop/src-tauri/src/platform/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ impl PlatformError {
retryable,
}
}
pub fn native_mechanism(
target: PlatformTarget,
message: impl Into<String>,
retryable: bool,
) -> Self {
Self::target(
target,
PlatformErrorCode::NativeMechanismFailed,
message,
retryable,
)
}
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
Expand All @@ -73,6 +85,12 @@ impl ShellCommand {
}
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PtyLaunch {
pub command: ShellCommand,
pub environment: Vec<(String, String)>,
}

pub struct PlatformFacts {
pub os: String,
pub ubuntu: Option<String>,
Expand Down Expand Up @@ -148,6 +166,21 @@ impl PlatformServices {
pub fn path(&self) -> &str {
&self.path
}
pub fn pty_launch(&self) -> PtyLaunch {
PtyLaunch {
command: match self.target {
PlatformTarget::Windows => windows::shell(),
PlatformTarget::NativeUbuntu => linux::shell(),
PlatformTarget::Wsl2Wslg => wsl::shell(),
},
environment: match self.target {
PlatformTarget::Windows => vec![],
PlatformTarget::NativeUbuntu | PlatformTarget::Wsl2Wslg => {
vec![("PATH".into(), self.path.clone())]
}
},
}
}
pub fn reveal_command(&self, path: impl AsRef<Path>) -> Result<ShellCommand, PlatformError> {
let path = path.as_ref();
if !path.is_absolute() {
Expand Down
29 changes: 28 additions & 1 deletion apps/desktop/src-tauri/src/platform/windows.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,35 @@
use super::ShellCommand;
use std::path::Path;
pub(super) fn shell() -> ShellCommand {
ShellCommand::new("cmd.exe", ["/D", "/K"])
ShellCommand::new(
"cmd.exe",
[
"/D".to_owned(),
"/K".to_owned(),
format!("set PATH={};%PATH%", common_cli_path_prefix()),
],
)
}
pub(super) fn reveal(path: &Path) -> ShellCommand {
ShellCommand::new("explorer.exe", [format!("/select,{}", path.display())])
}

fn common_cli_path_prefix() -> String {
let user_profile = std::env::var("USERPROFILE").unwrap_or_default();
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_default();

[
format!("{user_profile}\\.local\\bin"),
format!("{user_profile}\\scoop\\shims"),
format!("{user_profile}\\scoop\\apps\\nodejs\\current\\bin"),
format!("{user_profile}\\scoop\\apps\\nodejs\\current"),
format!("{local_app_data}\\agy\\bin"),
format!("{local_app_data}\\Programs\\OpenCode\\bin"),
format!("{local_app_data}\\Programs\\opencode\\bin"),
format!("{local_app_data}\\OpenAI\\Codex\\bin"),
]
.into_iter()
.filter(|path| !path.starts_with('\\') && !path.is_empty())
.collect::<Vec<_>>()
.join(";")
}
17 changes: 5 additions & 12 deletions apps/desktop/src-tauri/tests/authority_manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,22 +121,15 @@ fn target_capabilities_grant_only_the_finite_terminal_authority() {
.expect("target capability must be valid JSON");
assert_eq!(capability["windows"], Value::from(["main"]));
assert_eq!(capability["platforms"], Value::from([platform]));
let expected_permissions: Vec<_> = TERMINAL_PERMISSIONS
.iter()
.copied()
.filter(|permission| platform == "windows" || *permission != "allow-pty-spawn")
.collect();
let expected_permissions: Vec<_> = TERMINAL_PERMISSIONS.to_vec();
assert_eq!(
capability["permissions"],
serde_json::to_value(expected_permissions).expect("permission list must serialize")
);
assert_eq!(
capability["permissions"]
.as_array()
.expect("target permissions must be an array")
.contains(&Value::from("allow-pty-spawn")),
platform == "windows"
);
assert!(capability["permissions"]
.as_array()
.expect("target permissions must be an array")
.contains(&Value::from("allow-pty-spawn")));
}

let default_capability: Value =
Expand Down
Loading
Loading