Files
opsxshell/src-tauri/src/services/local_terminal.rs
T

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());
}
}
}