Files
infinite/src-tauri/src/pty.rs
Haapy c3552d08b9 new additions : What's new
 Rust PTY backend (portable-pty)
     - pty_spawn / pty_write / pty_resize / pty_kill commands
     - Output streamed via pty:data Tauri events (base64-encoded bytes)
     - Spawns /bin/bash (falls back to /bin/bash), starts in /home/code
   xterm.js terminal card (full ANSI + true color)
   Toolbar (top-left) with + Note / + Terminal
   Shared useDragHandle hook (notes + terminals)
   Terminal font-size scales with canvas zoom → crisp at any zoom
   Terminal auto-refits when card resizes or zoom changes
2026-05-14 22:22:18 +00:00

165 lines
4.1 KiB
Rust

use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::sync::Mutex;
use tauri::{AppHandle, Emitter, State};
use uuid::Uuid;
pub struct PtyHandle {
master: Box<dyn MasterPty + Send>,
writer: Box<dyn Write + Send>,
}
#[derive(Default)]
pub struct PtyState {
handles: Mutex<HashMap<String, PtyHandle>>,
}
#[derive(Deserialize)]
pub struct SpawnOpts {
pub cols: u16,
pub rows: u16,
pub shell: Option<String>,
pub cwd: Option<String>,
}
#[derive(Serialize, Clone)]
struct DataPayload {
id: String,
data: String,
}
#[derive(Serialize, Clone)]
struct ExitPayload {
id: String,
status: Option<i32>,
}
#[tauri::command]
pub async fn pty_spawn(
app: AppHandle,
state: State<'_, PtyState>,
opts: SpawnOpts,
) -> Result<String, String> {
let id = Uuid::new_v4().to_string();
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows: opts.rows.max(1),
cols: opts.cols.max(1),
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| e.to_string())?;
let shell = opts
.shell
.or_else(|| std::env::var("SHELL").ok())
.unwrap_or_else(|| "/bin/bash".into());
let mut cmd = CommandBuilder::new(&shell);
if let Some(cwd) = opts.cwd {
cmd.cwd(cwd);
} else if let Ok(home) = std::env::var("HOME") {
cmd.cwd(home);
}
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
let mut child = pair
.slave
.spawn_command(cmd)
.map_err(|e| e.to_string())?;
drop(pair.slave);
let reader = pair
.master
.try_clone_reader()
.map_err(|e| e.to_string())?;
let writer = pair
.master
.take_writer()
.map_err(|e| e.to_string())?;
let app_for_thread = app.clone();
let id_for_thread = id.clone();
std::thread::spawn(move || {
let mut reader = reader;
let mut buf = vec![0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let payload = DataPayload {
id: id_for_thread.clone(),
data: B64.encode(&buf[..n]),
};
let _ = app_for_thread.emit("pty:data", payload);
}
Err(_) => break,
}
}
let status = child.wait().ok().map(|s| s.exit_code() as i32);
let _ = app_for_thread.emit(
"pty:exit",
ExitPayload {
id: id_for_thread,
status,
},
);
});
state
.handles
.lock()
.unwrap()
.insert(id.clone(), PtyHandle { master: pair.master, writer });
Ok(id)
}
#[tauri::command]
pub fn pty_write(
state: State<'_, PtyState>,
id: String,
data: String,
) -> Result<(), String> {
let bytes = B64.decode(&data).map_err(|e| e.to_string())?;
let mut handles = state.handles.lock().unwrap();
let handle = handles.get_mut(&id).ok_or("pty not found")?;
handle.writer.write_all(&bytes).map_err(|e| e.to_string())?;
handle.writer.flush().map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub fn pty_resize(
state: State<'_, PtyState>,
id: String,
cols: u16,
rows: u16,
) -> Result<(), String> {
let handles = state.handles.lock().unwrap();
let handle = handles.get(&id).ok_or("pty not found")?;
handle
.master
.resize(PtySize {
cols: cols.max(1),
rows: rows.max(1),
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub fn pty_kill(state: State<'_, PtyState>, id: String) -> Result<(), String> {
// Dropping the handle closes the master fd; child receives SIGHUP and exits.
state.handles.lock().unwrap().remove(&id);
Ok(())
}