feat: release OpsXShell 2.1.0 native Tauri edition
This commit is contained in:
1 parent
e9a0d21758
commit
a3dba0c6ed
311 files changed
+31748
-14897
No files matched your search
@@ -0,0 +1,316 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user