feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
238
crates/infrastructure/src/pty/mod.rs
Normal file
238
crates/infrastructure/src/pty/mod.rs
Normal file
@ -0,0 +1,238 @@
|
||||
//! [`PortablePtyAdapter`] — local [`PtyPort`] implementation over the
|
||||
//! `portable-pty` crate (ARCHITECTURE §5, L3).
|
||||
//!
|
||||
//! # Design
|
||||
//!
|
||||
//! `portable-pty` is a blocking, thread-oriented API: a master PTY gives a
|
||||
//! `Box<dyn Read>` reader and a `Box<dyn Write>` writer, and the child process is
|
||||
//! waited on a thread. We bridge that to the domain [`PtyPort`] as follows:
|
||||
//!
|
||||
//! - [`PtyHandle`] only carries a [`SessionId`]; the *real* OS handles (master
|
||||
//! PTY, writer, child) live in this adapter's registry keyed by that id. The
|
||||
//! domain never sees an OS handle (ARCHITECTURE §4).
|
||||
//! - On [`spawn`](PtyPort::spawn) we open a PTY pair, spawn the command in the
|
||||
//! slave, then start **one reader thread** that pumps bytes from the master
|
||||
//! into an [`std::sync::mpsc`] channel. [`subscribe_output`](PtyPort::subscribe_output)
|
||||
//! hands back the receiver wrapped as the domain's blocking [`OutputStream`]
|
||||
//! iterator; the presentation layer drains it on its own thread and forwards
|
||||
//! chunks to the per-session Tauri channel (the `PtyBridge`).
|
||||
//! - [`write`](PtyPort::write) / [`resize`](PtyPort::resize) act on the stored
|
||||
//! writer / master. [`kill`](PtyPort::kill) terminates the child, joins the
|
||||
//! reader thread, and returns the [`ExitStatus`].
|
||||
//!
|
||||
//! # Cross-platform note (spike, ARCHITECTURE §13.1)
|
||||
//!
|
||||
//! `portable-pty` abstracts ConPTY on Windows, but exit-code/signal semantics
|
||||
//! differ: a Unix process killed by a signal reports `code: None` here, while
|
||||
//! ConPTY surfaces a numeric code. Resize uses the same `PtySize` everywhere.
|
||||
//! Points to validate on Windows: child `kill()` actually tears down ConPTY, and
|
||||
//! the reader thread observes EOF promptly on exit. The code below avoids any
|
||||
//! Unix-only assumption (no raw fds, no signals) so it should port as-is.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::sync::Mutex;
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySystem};
|
||||
|
||||
use domain::ports::{ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort, SpawnSpec};
|
||||
use domain::terminal::PtySize;
|
||||
use domain::SessionId;
|
||||
|
||||
/// Size of each read buffer pumped from the master PTY.
|
||||
const READ_BUF: usize = 8 * 1024;
|
||||
|
||||
/// A live PTY owned by the adapter.
|
||||
struct LivePty {
|
||||
/// Master side — used for resize.
|
||||
master: Box<dyn MasterPty + Send>,
|
||||
/// Writer into the PTY (child stdin).
|
||||
writer: Box<dyn Write + Send>,
|
||||
/// The spawned child process.
|
||||
child: Box<dyn Child + Send + Sync>,
|
||||
/// Receiver end of the output channel; taken once by `subscribe_output`.
|
||||
output_rx: Option<Receiver<Vec<u8>>>,
|
||||
/// Handle of the reader thread, joined on kill.
|
||||
reader: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
/// Local PTY adapter backed by `portable-pty`'s native PTY system.
|
||||
///
|
||||
/// Thread-safe: the registry of live PTYs is behind a [`Mutex`]; the adapter is
|
||||
/// cloneable-as-`Arc` and injected as `Arc<dyn PtyPort>` at the composition root.
|
||||
#[derive(Default)]
|
||||
pub struct PortablePtyAdapter {
|
||||
sessions: Mutex<HashMap<SessionId, LivePty>>,
|
||||
}
|
||||
|
||||
impl PortablePtyAdapter {
|
||||
/// Creates an empty adapter.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps the domain [`PtySize`] to the `portable-pty` one.
|
||||
fn to_pty_size(size: PtySize) -> portable_pty::PtySize {
|
||||
portable_pty::PtySize {
|
||||
rows: size.rows,
|
||||
cols: size.cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the `portable-pty` command from a domain [`SpawnSpec`].
|
||||
fn to_command(spec: &SpawnSpec) -> CommandBuilder {
|
||||
let mut cmd = CommandBuilder::new(&spec.command);
|
||||
cmd.args(&spec.args);
|
||||
cmd.cwd(spec.cwd.as_str());
|
||||
for (k, v) in &spec.env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PtyPort for PortablePtyAdapter {
|
||||
async fn spawn(&self, spec: SpawnSpec, size: PtySize) -> Result<PtyHandle, PtyError> {
|
||||
let pty_system = NativePtySystem::default();
|
||||
let pair = pty_system
|
||||
.openpty(to_pty_size(size))
|
||||
.map_err(|e| PtyError::Spawn(e.to_string()))?;
|
||||
|
||||
let cmd = to_command(&spec);
|
||||
let child = pair
|
||||
.slave
|
||||
.spawn_command(cmd)
|
||||
.map_err(|e| PtyError::Spawn(e.to_string()))?;
|
||||
// The slave is held by the child; drop our copy so EOF propagates on exit.
|
||||
drop(pair.slave);
|
||||
|
||||
let writer = pair
|
||||
.master
|
||||
.take_writer()
|
||||
.map_err(|e| PtyError::Io(e.to_string()))?;
|
||||
let mut reader = pair
|
||||
.master
|
||||
.try_clone_reader()
|
||||
.map_err(|e| PtyError::Io(e.to_string()))?;
|
||||
|
||||
// One reader thread per PTY pumps bytes into the output channel until EOF.
|
||||
let (tx, rx): (Sender<Vec<u8>>, Receiver<Vec<u8>>) = mpsc::channel();
|
||||
let reader_handle = std::thread::spawn(move || {
|
||||
let mut buf = [0u8; READ_BUF];
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
// Receiver gone (session closed) → stop pumping.
|
||||
if tx.send(buf[..n].to_vec()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// The PTY layer owns the handle identity: it mints a fresh session id and
|
||||
// the caller (the `OpenTerminal` use case) adopts it as the
|
||||
// `TerminalSession.id`. This keeps the OS handle out of the domain while
|
||||
// giving everyone a single, agreed-upon id (ARCHITECTURE §4).
|
||||
let handle = PtyHandle {
|
||||
session_id: SessionId::new_random(),
|
||||
};
|
||||
let live = LivePty {
|
||||
master: pair.master,
|
||||
writer,
|
||||
child,
|
||||
output_rx: Some(rx),
|
||||
reader: Some(reader_handle),
|
||||
};
|
||||
self.sessions
|
||||
.lock()
|
||||
.map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?
|
||||
.insert(handle.session_id, live);
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError> {
|
||||
let mut map = self
|
||||
.sessions
|
||||
.lock()
|
||||
.map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?;
|
||||
let live = map.get_mut(&handle.session_id).ok_or(PtyError::NotFound)?;
|
||||
live.writer
|
||||
.write_all(data)
|
||||
.map_err(|e| PtyError::Io(e.to_string()))?;
|
||||
live.writer
|
||||
.flush()
|
||||
.map_err(|e| PtyError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
fn resize(&self, handle: &PtyHandle, size: PtySize) -> Result<(), PtyError> {
|
||||
let map = self
|
||||
.sessions
|
||||
.lock()
|
||||
.map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?;
|
||||
let live = map.get(&handle.session_id).ok_or(PtyError::NotFound)?;
|
||||
live.master
|
||||
.resize(to_pty_size(size))
|
||||
.map_err(|e| PtyError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
fn subscribe_output(&self, handle: &PtyHandle) -> Result<OutputStream, PtyError> {
|
||||
let mut map = self
|
||||
.sessions
|
||||
.lock()
|
||||
.map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?;
|
||||
let live = map.get_mut(&handle.session_id).ok_or(PtyError::NotFound)?;
|
||||
let rx = live
|
||||
.output_rx
|
||||
.take()
|
||||
.ok_or_else(|| PtyError::Io("output already subscribed".to_owned()))?;
|
||||
Ok(Box::new(rx.into_iter()))
|
||||
}
|
||||
|
||||
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
||||
// Remove from the registry so the writer/master drop and the child is
|
||||
// fully owned here while we tear it down.
|
||||
let mut live = {
|
||||
let mut map = self
|
||||
.sessions
|
||||
.lock()
|
||||
.map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?;
|
||||
map.remove(&handle.session_id).ok_or(PtyError::NotFound)?
|
||||
};
|
||||
|
||||
// Ask the child to terminate, then wait for its real status.
|
||||
let _ = live.child.kill();
|
||||
let status = live
|
||||
.child
|
||||
.wait()
|
||||
.map_err(|e| PtyError::Io(e.to_string()))?;
|
||||
|
||||
// Dropping master/writer closes the PTY; the reader thread then sees EOF.
|
||||
drop(live.output_rx.take());
|
||||
if let Some(reader) = live.reader.take() {
|
||||
let _ = reader.join();
|
||||
}
|
||||
|
||||
Ok(ExitStatus {
|
||||
code: exit_code(&status),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts a portable exit code. `portable-pty`'s `ExitStatus` exposes
|
||||
/// `exit_code(): u32`; `0` is success. We surface it as `i32`.
|
||||
fn exit_code(status: &portable_pty::ExitStatus) -> Option<i32> {
|
||||
Some(status.exit_code() as i32)
|
||||
}
|
||||
Reference in New Issue
Block a user