1use bytes::Bytes;
6use portable_pty::{native_pty_system, CommandBuilder, PtySize};
7use std::collections::HashMap;
8use std::io::{Read, Write};
9use std::sync::Arc;
10use tokio::sync::{broadcast, mpsc, Mutex};
11
12#[derive(Clone)]
13pub struct PtyHandle {
14 pub id: String,
15 pub output_tx: broadcast::Sender<Bytes>,
16 input_tx: mpsc::UnboundedSender<Bytes>,
17 control_tx: mpsc::UnboundedSender<ControlMsg>,
18 process_info_tx: mpsc::UnboundedSender<ProcessInfoRequest>,
19}
20
21impl Drop for PtyHandle {
22 fn drop(&mut self) {
23 let _ = self.control_tx.send(ControlMsg::Shutdown);
25 }
26}
27
28impl PtyHandle {
29 pub async fn send_input(&self, data: Bytes) -> Result<(), String> {
31 self.input_tx
32 .send(data)
33 .map_err(|_| format!("Terminal {} input channel closed", self.id))
34 }
35
36 pub async fn resize(&self, rows: u16, cols: u16) -> Result<(), String> {
38 if rows == 0 || cols == 0 {
40 return Err(format!("Invalid terminal dimensions: {rows}x{cols}"));
41 }
42 if rows > 1000 || cols > 1000 {
43 return Err(format!("Terminal dimensions too large: {rows}x{cols}"));
44 }
45
46 let size = PtySize {
47 rows,
48 cols,
49 pixel_width: 0,
50 pixel_height: 0,
51 };
52
53 self.control_tx
54 .send(ControlMsg::Resize(size))
55 .map_err(|_| format!("Terminal {} control channel closed", self.id))
56 }
57
58 pub async fn shutdown(&self) -> Result<(), String> {
60 self.control_tx
61 .send(ControlMsg::Shutdown)
62 .map_err(|_| format!("Terminal {} control channel closed", self.id))
63 }
64
65 pub async fn get_process_info(&self) -> Result<ProcessInfo, String> {
67 let (tx, rx) = tokio::sync::oneshot::channel();
68 self.process_info_tx
69 .send(ProcessInfoRequest(tx))
70 .map_err(|_| "Process info channel closed".to_string())?;
71 rx.await
72 .map_err(|_| "Failed to get process info".to_string())
73 }
74}
75
76enum ControlMsg {
77 Resize(PtySize),
78 Shutdown,
79}
80
81struct ProcessInfoRequest(tokio::sync::oneshot::Sender<ProcessInfo>);
82
83#[derive(Debug, Clone, serde::Serialize)]
84pub struct ProcessInfo {
85 pub pid: u32,
86 pub name: String,
87 pub command: String,
88 pub status: ProcessStatus,
89}
90
91#[derive(Debug, Clone, PartialEq, serde::Serialize)]
92pub enum ProcessStatus {
93 Running,
94 Exited(i32),
95 Crashed,
96}
97
98pub struct PtyManager {
99 handles: Arc<Mutex<HashMap<String, broadcast::Sender<Bytes>>>>,
100}
101
102impl Default for PtyManager {
103 fn default() -> Self {
104 Self::new()
105 }
106}
107
108impl PtyManager {
109 pub fn new() -> Self {
110 Self {
111 handles: Arc::new(Mutex::new(HashMap::new())),
112 }
113 }
114
115 pub async fn create_pty(
116 &self,
117 terminal_id: String,
118 shell: Option<String>,
119 rows: u16,
120 cols: u16,
121 ) -> Result<PtyHandle, Box<dyn std::error::Error>> {
122 if rows == 0 || cols == 0 {
124 return Err(format!("Invalid terminal dimensions: {rows}x{cols}").into());
125 }
126 if rows > 1000 || cols > 1000 {
127 return Err(format!("Terminal dimensions too large: {rows}x{cols}").into());
128 }
129 let size = PtySize {
130 rows,
131 cols,
132 pixel_width: 0,
133 pixel_height: 0,
134 };
135
136 let (output_tx, _output_rx) = broadcast::channel(1024);
138 let (input_tx, input_rx) = mpsc::unbounded_channel();
139 let (control_tx, control_rx) = mpsc::unbounded_channel();
140 let (process_info_tx, process_info_rx) = mpsc::unbounded_channel();
141
142 self.handles
144 .lock()
145 .await
146 .insert(terminal_id.clone(), output_tx.clone());
147
148 let terminal_id_clone = terminal_id.clone();
150 let handles_clone = self.handles.clone();
151 let output_tx_clone = output_tx.clone();
152
153 tokio::task::spawn_blocking(move || {
154 run_pty_blocking(
155 terminal_id_clone,
156 shell,
157 size,
158 output_tx_clone,
159 input_rx,
160 control_rx,
161 process_info_rx,
162 handles_clone,
163 )
164 });
165
166 Ok(PtyHandle {
167 id: terminal_id,
168 output_tx,
169 input_tx,
170 control_tx,
171 process_info_tx,
172 })
173 }
174
175 pub async fn close_pty(&self, terminal_id: &str) -> Result<(), Box<dyn std::error::Error>> {
176 self.handles.lock().await.remove(terminal_id);
177 Ok(())
178 }
179}
180
181#[allow(clippy::too_many_arguments)]
182fn run_pty_blocking(
183 terminal_id: String,
184 shell: Option<String>,
185 size: PtySize,
186 output_tx: broadcast::Sender<Bytes>,
187 mut input_rx: mpsc::UnboundedReceiver<Bytes>,
188 mut control_rx: mpsc::UnboundedReceiver<ControlMsg>,
189 mut process_info_rx: mpsc::UnboundedReceiver<ProcessInfoRequest>,
190 handles: Arc<Mutex<HashMap<String, broadcast::Sender<Bytes>>>>,
191) {
192 let pty_system = native_pty_system();
194
195 let pty_pair = match pty_system.openpty(size) {
197 Ok(pair) => pair,
198 Err(e) => {
199 eprintln!("Failed to create PTY: {e}");
200 return;
201 }
202 };
203
204 let mut shell_path = shell.unwrap_or_else(|| {
206 if cfg!(windows) {
207 "powershell.exe".to_string()
208 } else {
209 std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string())
210 }
211 });
212
213 if !std::path::Path::new(&shell_path).exists() {
215 eprintln!("Shell not found: {shell_path}, falling back to default");
216 shell_path = if cfg!(windows) {
218 "cmd.exe".to_string()
219 } else {
220 "/bin/sh".to_string()
221 };
222 }
223
224 let mut cmd = CommandBuilder::new(&shell_path);
226 cmd.cwd(std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("/")));
227
228 let shell_name = std::path::Path::new(&shell_path)
230 .file_name()
231 .unwrap_or_default()
232 .to_string_lossy()
233 .to_string();
234
235 let mut child = match pty_pair.slave.spawn_command(cmd) {
237 Ok(child) => child,
238 Err(e) => {
239 eprintln!("Failed to spawn shell: {e}");
240 return;
241 }
242 };
243
244 let mut reader = match pty_pair.master.try_clone_reader() {
246 Ok(r) => r,
247 Err(e) => {
248 eprintln!("Failed to clone reader: {e}");
249 return;
250 }
251 };
252
253 let mut writer = match pty_pair.master.take_writer() {
254 Ok(w) => w,
255 Err(e) => {
256 eprintln!("Failed to take writer: {e}");
257 return;
258 }
259 };
260
261 let mut buf = vec![0u8; 4096];
264
265 loop {
267 match input_rx.try_recv() {
269 Ok(data) => {
270 if writer.write_all(&data).is_err() {
271 break;
272 }
273 let _ = writer.flush();
274 }
275 Err(mpsc::error::TryRecvError::Empty) => {}
276 Err(mpsc::error::TryRecvError::Disconnected) => break,
277 }
278
279 match control_rx.try_recv() {
281 Ok(ControlMsg::Resize(size)) => {
282 let _ = pty_pair.master.resize(size);
283 }
284 Ok(ControlMsg::Shutdown) => break,
285 Err(mpsc::error::TryRecvError::Empty) => {}
286 Err(mpsc::error::TryRecvError::Disconnected) => break,
287 }
288
289 match process_info_rx.try_recv() {
291 Ok(ProcessInfoRequest(tx)) => {
292 let pid = child.process_id().unwrap_or(0);
293 let info = ProcessInfo {
294 pid,
295 name: shell_name.clone(),
296 command: shell_path.clone(),
297 status: ProcessStatus::Running,
298 };
299 let _ = tx.send(info);
300 }
301 Err(mpsc::error::TryRecvError::Empty) => {}
302 Err(mpsc::error::TryRecvError::Disconnected) => break,
303 }
304
305 match reader.read(&mut buf) {
307 Ok(0) => break, Ok(n) => {
309 let data = Bytes::copy_from_slice(&buf[..n]);
310 if output_tx.send(data).is_err() {
311 }
313 }
314 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
315 std::thread::sleep(std::time::Duration::from_millis(10));
317 }
318 Err(_) => break,
319 }
320
321 match child.try_wait() {
323 Ok(Some(_)) => break, Ok(None) => {} Err(_) => break,
326 }
327 }
328
329 if let Err(e) = child.kill() {
331 eprintln!("Failed to kill child process for terminal {terminal_id}: {e}");
332 }
333 let _ = child.wait();
335
336 let rt = tokio::runtime::Handle::try_current();
338 if let Ok(handle) = rt {
339 handle.spawn(async move {
340 handles.lock().await.remove(&terminal_id);
341 });
342 }
343}