317 lines
9.3 KiB
Rust
317 lines
9.3 KiB
Rust
use std::{
|
|
collections::{HashMap, HashSet},
|
|
env,
|
|
io::{Read, Write},
|
|
path::{Path, PathBuf},
|
|
sync::Mutex,
|
|
thread,
|
|
};
|
|
|
|
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
|
|
use tauri::{AppHandle, Emitter};
|
|
use uuid::Uuid;
|
|
|
|
use crate::domain::local_terminal::{
|
|
AvailableShell, LocalShellCreateResponse, LocalShellDataEvent, LocalShellExitEvent,
|
|
LocalShellOptions,
|
|
};
|
|
|
|
struct LocalShellSession {
|
|
master: Box<dyn MasterPty + Send>,
|
|
writer: Box<dyn Write + Send>,
|
|
child: Box<dyn Child + Send + Sync>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct LocalTerminalService {
|
|
sessions: Mutex<HashMap<String, LocalShellSession>>,
|
|
}
|
|
|
|
impl LocalTerminalService {
|
|
pub fn list_shells(&self) -> Vec<AvailableShell> {
|
|
let default = default_shell();
|
|
let mut candidates = shell_candidates();
|
|
if !candidates.iter().any(|path| path == &default) {
|
|
candidates.insert(0, default.clone());
|
|
}
|
|
|
|
let mut seen_paths = HashSet::new();
|
|
let mut seen_kinds = HashSet::new();
|
|
let mut shells = Vec::new();
|
|
for path in candidates {
|
|
if !seen_paths.insert(path.clone()) || !Path::new(&path).is_file() {
|
|
continue;
|
|
}
|
|
let kind = shell_kind(&path);
|
|
let is_default = path == default;
|
|
if seen_kinds.contains(&kind) && !is_default {
|
|
continue;
|
|
}
|
|
seen_kinds.insert(kind.clone());
|
|
shells.push(AvailableShell {
|
|
path,
|
|
name: shell_display_name(&kind).to_string(),
|
|
kind,
|
|
is_default,
|
|
});
|
|
}
|
|
|
|
if !shells.is_empty() && !shells.iter().any(|shell| shell.is_default) {
|
|
shells[0].is_default = true;
|
|
}
|
|
shells.sort_by_key(|shell| (!shell.is_default, shell_order(&shell.kind)));
|
|
shells
|
|
}
|
|
|
|
pub fn create(
|
|
&self,
|
|
app: AppHandle,
|
|
options: LocalShellOptions,
|
|
preferred_id: Option<String>,
|
|
) -> Result<LocalShellCreateResponse, String> {
|
|
let shell = options.shell.unwrap_or_else(default_shell);
|
|
if !Path::new(&shell).is_file() {
|
|
return Err(format!("shell does not exist: {shell}"));
|
|
}
|
|
let cwd = options
|
|
.cwd
|
|
.map(PathBuf::from)
|
|
.or_else(|| env::var_os("HOME").map(PathBuf::from))
|
|
.or_else(|| env::current_dir().ok())
|
|
.ok_or_else(|| "unable to determine local shell working directory".to_string())?;
|
|
let cols = options.cols.unwrap_or(80).max(2);
|
|
let rows = options.rows.unwrap_or(24).max(1);
|
|
let shell_id = preferred_id.unwrap_or_else(|| format!("local-{}", Uuid::new_v4()));
|
|
|
|
let pair = native_pty_system()
|
|
.openpty(PtySize {
|
|
rows,
|
|
cols,
|
|
pixel_width: 0,
|
|
pixel_height: 0,
|
|
})
|
|
.map_err(|error| format!("failed to open local PTY: {error}"))?;
|
|
let mut command = CommandBuilder::new(&shell);
|
|
command.cwd(cwd);
|
|
command.env("TERM", "xterm-256color");
|
|
command.env("COLORTERM", "truecolor");
|
|
let child = pair
|
|
.slave
|
|
.spawn_command(command)
|
|
.map_err(|error| format!("failed to spawn {shell}: {error}"))?;
|
|
drop(pair.slave);
|
|
|
|
let reader = pair
|
|
.master
|
|
.try_clone_reader()
|
|
.map_err(|error| format!("failed to clone PTY reader: {error}"))?;
|
|
let writer = pair
|
|
.master
|
|
.take_writer()
|
|
.map_err(|error| format!("failed to open PTY writer: {error}"))?;
|
|
self.sessions
|
|
.lock()
|
|
.map_err(|_| "local terminal session lock poisoned".to_string())?
|
|
.insert(
|
|
shell_id.clone(),
|
|
LocalShellSession {
|
|
master: pair.master,
|
|
writer,
|
|
child,
|
|
},
|
|
);
|
|
|
|
spawn_reader(app, shell_id.clone(), reader);
|
|
Ok(LocalShellCreateResponse {
|
|
shell_name: shell_basename(&shell),
|
|
shell_path: shell,
|
|
shell_id,
|
|
})
|
|
}
|
|
|
|
pub fn write(&self, shell_id: &str, data: &[u8]) -> Result<(), String> {
|
|
let mut sessions = self
|
|
.sessions
|
|
.lock()
|
|
.map_err(|_| "local terminal session lock poisoned".to_string())?;
|
|
let session = sessions
|
|
.get_mut(shell_id)
|
|
.ok_or_else(|| format!("local shell not found: {shell_id}"))?;
|
|
session
|
|
.writer
|
|
.write_all(data)
|
|
.map_err(|error| format!("local shell write failed: {error}"))?;
|
|
session
|
|
.writer
|
|
.flush()
|
|
.map_err(|error| format!("local shell flush failed: {error}"))
|
|
}
|
|
|
|
pub fn resize(&self, shell_id: &str, cols: u16, rows: u16) -> Result<(), String> {
|
|
let sessions = self
|
|
.sessions
|
|
.lock()
|
|
.map_err(|_| "local terminal session lock poisoned".to_string())?;
|
|
let session = sessions
|
|
.get(shell_id)
|
|
.ok_or_else(|| format!("local shell not found: {shell_id}"))?;
|
|
session
|
|
.master
|
|
.resize(PtySize {
|
|
rows: rows.max(1),
|
|
cols: cols.max(2),
|
|
pixel_width: 0,
|
|
pixel_height: 0,
|
|
})
|
|
.map_err(|error| format!("local shell resize failed: {error}"))
|
|
}
|
|
|
|
pub fn kill(&self, shell_id: &str) -> Result<(), String> {
|
|
if let Some(mut session) = self
|
|
.sessions
|
|
.lock()
|
|
.map_err(|_| "local terminal session lock poisoned".to_string())?
|
|
.remove(shell_id)
|
|
{
|
|
session
|
|
.child
|
|
.kill()
|
|
.map_err(|error| format!("failed to kill local shell: {error}"))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn spawn_reader(app: AppHandle, shell_id: String, mut reader: Box<dyn Read + Send>) {
|
|
thread::spawn(move || {
|
|
let mut buffer = [0_u8; 8192];
|
|
loop {
|
|
match reader.read(&mut buffer) {
|
|
Ok(0) => break,
|
|
Ok(length) => {
|
|
let _ = app.emit(
|
|
"local:data",
|
|
LocalShellDataEvent {
|
|
shell_id: shell_id.clone(),
|
|
data: buffer[..length].to_vec(),
|
|
},
|
|
);
|
|
}
|
|
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
let _ = app.emit("local:exit", LocalShellExitEvent { shell_id });
|
|
});
|
|
}
|
|
|
|
fn default_shell() -> String {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
env::var("COMSPEC").unwrap_or_else(|_| "C:\\Windows\\System32\\cmd.exe".to_string())
|
|
}
|
|
#[cfg(not(target_os = "windows"))]
|
|
{
|
|
env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string())
|
|
}
|
|
}
|
|
|
|
fn shell_candidates() -> Vec<String> {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
vec![
|
|
"C:\\Windows\\System32\\cmd.exe".to_string(),
|
|
"C:\\Program Files\\PowerShell\\7\\pwsh.exe".to_string(),
|
|
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe".to_string(),
|
|
]
|
|
}
|
|
#[cfg(not(target_os = "windows"))]
|
|
{
|
|
[
|
|
"/bin/zsh",
|
|
"/usr/bin/zsh",
|
|
"/bin/bash",
|
|
"/usr/bin/bash",
|
|
"/usr/local/bin/bash",
|
|
"/opt/homebrew/bin/bash",
|
|
"/usr/bin/fish",
|
|
"/usr/local/bin/fish",
|
|
"/opt/homebrew/bin/fish",
|
|
"/bin/sh",
|
|
]
|
|
.into_iter()
|
|
.map(str::to_string)
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
fn shell_basename(path: &str) -> String {
|
|
Path::new(path)
|
|
.file_name()
|
|
.and_then(|value| value.to_str())
|
|
.unwrap_or(path)
|
|
.to_string()
|
|
}
|
|
|
|
fn shell_kind(path: &str) -> String {
|
|
let name = shell_basename(path).to_ascii_lowercase();
|
|
if name.contains("zsh") {
|
|
"zsh"
|
|
} else if name.contains("bash") {
|
|
"bash"
|
|
} else if name.contains("fish") {
|
|
"fish"
|
|
} else if name.contains("pwsh") || name.contains("powershell") {
|
|
"pwsh"
|
|
} else if name.contains("cmd") {
|
|
"cmd"
|
|
} else if name == "sh" || name == "dash" {
|
|
"sh"
|
|
} else {
|
|
"unknown"
|
|
}
|
|
.to_string()
|
|
}
|
|
|
|
fn shell_display_name(kind: &str) -> &'static str {
|
|
match kind {
|
|
"zsh" => "Zsh",
|
|
"bash" => "Bash",
|
|
"fish" => "Fish",
|
|
"pwsh" => "PowerShell",
|
|
"cmd" => "CMD",
|
|
"sh" => "Sh",
|
|
_ => "Shell",
|
|
}
|
|
}
|
|
|
|
fn shell_order(kind: &str) -> u8 {
|
|
match kind {
|
|
"zsh" => 0,
|
|
"bash" => 1,
|
|
"fish" => 2,
|
|
_ => 3,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn recognizes_common_shell_kinds() {
|
|
assert_eq!(shell_kind("/bin/zsh"), "zsh");
|
|
assert_eq!(shell_kind("/usr/local/bin/bash"), "bash");
|
|
assert_eq!(shell_kind("C:\\Windows\\System32\\cmd.exe"), "cmd");
|
|
}
|
|
|
|
#[test]
|
|
fn lists_default_shell_first_when_available() {
|
|
let shells = LocalTerminalService::default().list_shells();
|
|
if let Some(first) = shells.first() {
|
|
assert!(first.is_default);
|
|
assert!(Path::new(&first.path).is_file());
|
|
}
|
|
}
|
|
}
|