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, writer: Box, child: Box, } #[derive(Default)] pub struct LocalTerminalService { sessions: Mutex>, } impl LocalTerminalService { pub fn list_shells(&self) -> Vec { 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, ) -> Result { 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) { 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 { #[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()); } } }