- 1
//! Integrated terminal: one real PTY per pane, streamed to the webview over - 2
//! a Tauri IPC channel. Raw ANSI bytes pass through untouched; xterm.js does - 3
//! the rendering. - 4
- 5
use std::collections::HashMap; - 6
use std::io::{Read, Write}; - 7
use std::sync::Mutex; - 8
- 9
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system}; - 10
use tauri::ipc::Channel; - 11
use tauri::{AppHandle, Emitter, Manager, State}; - 12
- 13
pub struct PtyEntry { - 14
writer: Box<dyn Write + Send>, - 15
master: Box<dyn MasterPty + Send>, - 16
/// A killer cloned off the child *before* the child itself was moved - 17
/// into the reaper thread below — `portable_pty::Child::clone_killer` - 18
/// exists precisely so a shell can be killed from a thread that does - 19
/// not own the `Child` (whose `wait()` the reaper thread is blocked - 20
/// in). Used by `pty_close` so closing a pane actually ends its shell - 21
/// rather than relying on dropping the master to deliver a hangup the - 22
/// shell may or may not honor. - 23
killer: Box<dyn portable_pty::ChildKiller + Send + Sync>, - 24
} - 25
- 26
#[derive(Default)] - 27
pub struct PtyMap(pub Mutex<HashMap<String, PtyEntry>>); - 28
- 29
#[tauri::command] - 30
pub fn spawn_pty( - 31
app: AppHandle, - 32
map: State<'_, PtyMap>, - 33
cwd: String, - 34
cols: u16, - 35
rows: u16, - 36
on_data: Channel<Vec<u8>>, - 37
) -> Result<String, String> { - 38
let id = uuid::Uuid::now_v7().to_string(); - 39
let pty_system = native_pty_system(); - 40
let pair = pty_system - 41
.openpty(PtySize { - 42
rows, - 43
cols, - 44
pixel_width: 0, - 45
pixel_height: 0, - 46
}) - 47
.map_err(|e| format!("openpty failed: {e}"))?; - 48
- 49
// Default prog honors $SHELL / ComSpec like an interactive terminal. - 50
let mut cmd = CommandBuilder::new_default_prog(); - 51
cmd.cwd(&cwd); - 52
cmd.env("TERM", "xterm-256color"); - 53
- 54
let child = pair - 55
.slave - 56
.spawn_command(cmd) - 57
.map_err(|e| format!("shell spawn failed: {e}"))?; - 58
drop(pair.slave); // our copy is not needed once the child holds its side - 59
let killer = child.clone_killer(); - 60
- 61
let mut reader = pair - 62
.master - 63
.try_clone_reader() - 64
.map_err(|e| format!("pty reader failed: {e}"))?; - 65
let writer = pair - 66
.master - 67
.take_writer() - 68
.map_err(|e| format!("pty writer failed: {e}"))?; - 69
- 70
map.0 - 71
.lock() - 72
.unwrap_or_else(std::sync::PoisonError::into_inner) - 73
.insert( - 74
id.clone(), - 75
PtyEntry { - 76
writer, - 77
master: pair.master, - 78
killer, - 79
}, - 80
); - 81
- 82
// Reader thread: forward raw bytes to the webview until EOF. - 83
// - 84
// EOF here means the shell side of the pty is gone — either the user - 85
// typed `exit`, or `pty_close` tore it down. Either way the map entry - 86
// must go too: previously nothing ever removed it, so every pty this - 87
// process ever opened (one per session per terminal-pane mount) stayed - 88
// in `PtyMap` — and its writer/master fds open — for the life of the - 89
// app. - 90
let exit_app = app.clone(); - 91
let exit_id = id.clone(); - 92
std::thread::spawn(move || { - 93
let mut buf = [0u8; 8192]; - 94
loop { - 95
match reader.read(&mut buf) { - 96
Ok(0) => break, - 97
Ok(n) => { - 98
if on_data.send(buf[..n].to_vec()).is_err() { - 99
break; - 100
} - 101
} - 102
Err(_) => break, - 103
} - 104
} - 105
if let Some(map) = exit_app.try_state::<PtyMap>() { - 106
map.0 - 107
.lock() - 108
.unwrap_or_else(std::sync::PoisonError::into_inner) - 109
.remove(&exit_id); - 110
} - 111
let _ = exit_app.emit("pty-exit", &exit_id); - 112
}); - 113
- 114
// Reap the child so no zombie lingers after the pane closes. - 115
std::thread::spawn(move || { - 116
let mut child = child; - 117
let _ = child.wait(); - 118
}); - 119
- 120
Ok(id) - 121
} - 122
- 123
#[tauri::command] - 124
pub fn pty_write(map: State<'_, PtyMap>, id: String, data: Vec<u8>) -> Result<(), String> { - 125
let mut guard = map - 126
.0 - 127
.lock() - 128
.unwrap_or_else(std::sync::PoisonError::into_inner); - 129
let Some(entry) = guard.get_mut(&id) else { - 130
return Err("unknown pty".into()); - 131
}; - 132
entry.writer.write_all(&data).map_err(|e| e.to_string()) - 133
} - 134
- 135
/// End one pty: kill its shell and drop its writer/master (closing the fds - 136
/// and, once the reader thread observes EOF, removing this same entry a - 137
/// second time — a no-op, since `HashMap::remove` on an absent key is - 138
/// harmless). Called when a terminal pane unmounts (session switch, dock - 139
/// close) so a pty's lifetime is scoped to the pane that opened it instead - 140
/// of to the whole app process. - 141
#[tauri::command] - 142
pub fn pty_close(map: State<'_, PtyMap>, id: String) -> Result<(), String> { - 143
let entry = map - 144
.0 - 145
.lock() - 146
.unwrap_or_else(std::sync::PoisonError::into_inner) - 147
.remove(&id); - 148
if let Some(mut entry) = entry { - 149
let _ = entry.killer.kill(); - 150
} - 151
Ok(()) - 152
} - 153
- 154
#[tauri::command] - 155
pub fn pty_resize(map: State<'_, PtyMap>, id: String, cols: u16, rows: u16) -> Result<(), String> { - 156
let guard = map - 157
.0 - 158
.lock() - 159
.unwrap_or_else(std::sync::PoisonError::into_inner); - 160
let Some(entry) = guard.get(&id) else { - 161
return Err("unknown pty".into()); - 162
}; - 163
entry - 164
.master - 165
.resize(PtySize { - 166
rows, - 167
cols, - 168
pixel_width: 0, - 169
pixel_height: 0, - 170
}) - 171
.map_err(|e| e.to_string()) - 172
} - 173
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.