mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): wire terminal sessions to Tauri
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
This commit is contained in:
parent
1a213fe709
commit
ba4ff04fea
Generated
+1
@@ -1106,6 +1106,7 @@ dependencies = [
|
||||
"opus",
|
||||
"plist",
|
||||
"png 0.18.1",
|
||||
"portable-pty",
|
||||
"regex",
|
||||
"reqwest 0.13.4",
|
||||
"rodio",
|
||||
|
||||
@@ -105,6 +105,7 @@ buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" }
|
||||
buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" }
|
||||
buzz_voice_pkg = { package = "buzz-voice", path = "../../crates/buzz-voice" }
|
||||
buzz_terminal = { package = "buzz-terminal", path = "crates/buzz-terminal" }
|
||||
portable-pty = "0.9"
|
||||
iroh = { version = "1.0.2", optional = true }
|
||||
mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true }
|
||||
mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true }
|
||||
|
||||
@@ -80,6 +80,35 @@ impl Listener {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an emulator action that can be answered without renderer state.
|
||||
///
|
||||
/// Color queries deliberately return `None`: named/indexed colors resolve
|
||||
/// against the live theme, which this crate does not own.
|
||||
pub fn reply(
|
||||
action: Action,
|
||||
columns: u16,
|
||||
rows: u16,
|
||||
cell_width: u16,
|
||||
cell_height: u16,
|
||||
) -> Option<String> {
|
||||
match action {
|
||||
Action::PtyWrite(text) => Some(text),
|
||||
Action::SizeReply { format } => Some(format(WindowSize {
|
||||
num_lines: rows,
|
||||
num_cols: columns,
|
||||
cell_width,
|
||||
cell_height,
|
||||
})),
|
||||
// Palette values are renderer-owned. The transport must answer these
|
||||
// only after it has a renderer palette, never invent one here.
|
||||
Action::ColorReply { .. }
|
||||
| Action::Title(_)
|
||||
| Action::ResetTitle
|
||||
| Action::Wakeup
|
||||
| Action::Bell => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl EventListener for Listener {
|
||||
fn send_event(&self, event: Event) {
|
||||
let action = match event {
|
||||
|
||||
@@ -199,6 +199,16 @@ impl SharedTerminal {
|
||||
self.acquire(&self.renderer)
|
||||
}
|
||||
|
||||
/// Modes the renderer/input boundary needs to report alongside frames.
|
||||
pub fn input_modes(&self) -> (bool, bool) {
|
||||
let term = self.acquire(&self.renderer);
|
||||
let mode = term.term().mode();
|
||||
(
|
||||
mode.contains(alacritty_terminal::term::TermMode::BRACKETED_PASTE),
|
||||
mode.contains(alacritty_terminal::term::TermMode::FOCUS_IN_OUT),
|
||||
)
|
||||
}
|
||||
|
||||
fn acquire(&self, meter: &AcquireMeter) -> MutexGuard<'_, Terminal> {
|
||||
let started = Instant::now();
|
||||
let guard = self.term.lock();
|
||||
|
||||
@@ -130,3 +130,9 @@ pub fn login_argv0(shell: &str) -> String {
|
||||
let basename = shell.rsplit('/').next().unwrap_or(shell);
|
||||
format!("-{basename}")
|
||||
}
|
||||
|
||||
/// Resolve the command shell on Windows from `ComSpec`, falling back to cmd.
|
||||
#[cfg(windows)]
|
||||
pub fn resolve_shell(shell_env: Option<&str>) -> String {
|
||||
shell_env.unwrap_or("cmd.exe").to_owned()
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ mod reset;
|
||||
mod secret_store;
|
||||
mod shutdown;
|
||||
mod templates;
|
||||
mod terminal_runtime;
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
mod terminal_transport;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -370,6 +371,7 @@ pub fn run() {
|
||||
.manage(BuilderlabSession::default())
|
||||
.manage(BuilderlabLogin::default())
|
||||
.manage(commands::pairing::PairingHandle::new())
|
||||
.manage(terminal_runtime::TerminalSessions::default())
|
||||
.setup(move |app| {
|
||||
let app_handle = app.handle().clone();
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -665,6 +667,14 @@ pub fn run() {
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
terminal_runtime::terminal_attach,
|
||||
terminal_runtime::terminal_detach,
|
||||
terminal_runtime::terminal_close,
|
||||
terminal_runtime::terminal_input,
|
||||
terminal_runtime::terminal_resize,
|
||||
terminal_runtime::terminal_ack,
|
||||
terminal_runtime::terminal_viewport_ready,
|
||||
terminal_runtime::terminal_focus,
|
||||
take_pending_community_deep_link,
|
||||
acknowledge_pending_community_deep_link,
|
||||
start_builderlab_login,
|
||||
|
||||
@@ -19,6 +19,8 @@ pub(crate) fn shut_down_app(app: &tauri::AppHandle, shutdown_done: &std::sync::a
|
||||
.store(true, Ordering::SeqCst);
|
||||
if !shutdown_done.swap(true, Ordering::SeqCst) {
|
||||
prevent_sleep::release(&app.state::<AppState>().prevent_sleep);
|
||||
app.state::<crate::terminal_runtime::TerminalSessions>()
|
||||
.shutdown_all();
|
||||
if let Err(error) = shutdown_managed_agents(app) {
|
||||
eprintln!("buzz-desktop: failed to stop managed agents: {error}");
|
||||
}
|
||||
@@ -40,6 +42,8 @@ pub(crate) fn install_signal_handler(
|
||||
.shutdown_started
|
||||
.store(true, Ordering::SeqCst);
|
||||
if !shutdown_done.swap(true, Ordering::SeqCst) {
|
||||
app.state::<crate::terminal_runtime::TerminalSessions>()
|
||||
.shutdown_all();
|
||||
let _ = shutdown_managed_agents(&app);
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
shutdown_mesh_runtime(&app);
|
||||
|
||||
@@ -0,0 +1,782 @@
|
||||
//! Rust-owned PTY sessions and the typed Tauri transport for Buzz Substrate.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use buzz_terminal::context::{context_vars, GuiContext};
|
||||
use buzz_terminal::damage::Style;
|
||||
use buzz_terminal::{Fences, SharedTerminal, Size, Terminal, Viewport};
|
||||
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::ipc::Channel;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::terminal_transport::{FramePublisher, Publication, SubscriptionId};
|
||||
|
||||
const MAX_LIVE_SESSIONS: usize = 20;
|
||||
const MAX_INPUT_BYTES: usize = 1024 * 1024;
|
||||
|
||||
type Result<T> = std::result::Result<T, String>;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct AttachRequest {
|
||||
/// Present when a renderer remounts onto an existing PTY-backed tab.
|
||||
session_id: Option<String>,
|
||||
channel_id: String,
|
||||
channel_name: String,
|
||||
thread_id: Option<String>,
|
||||
npub: String,
|
||||
relay_url: String,
|
||||
columns: u16,
|
||||
rows: u16,
|
||||
pixel_width: u16,
|
||||
pixel_height: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct WireViewport {
|
||||
generation: u64,
|
||||
columns: usize,
|
||||
screen_lines: usize,
|
||||
}
|
||||
|
||||
impl From<Viewport> for WireViewport {
|
||||
fn from(value: Viewport) -> Self {
|
||||
Self {
|
||||
generation: value.generation,
|
||||
columns: value.columns,
|
||||
screen_lines: value.screen_lines,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WireViewport> for Viewport {
|
||||
fn from(value: WireViewport) -> Self {
|
||||
Self {
|
||||
generation: value.generation,
|
||||
columns: value.columns,
|
||||
screen_lines: value.screen_lines,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct AttachResponse {
|
||||
session_id: String,
|
||||
subscription_id: String,
|
||||
viewport: WireViewport,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct WireStyle {
|
||||
fg: u32,
|
||||
bg: u32,
|
||||
flags: u16,
|
||||
}
|
||||
|
||||
impl From<Style> for WireStyle {
|
||||
fn from(value: Style) -> Self {
|
||||
Self {
|
||||
fg: value.fg,
|
||||
bg: value.bg,
|
||||
flags: value.flags,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct WireCluster {
|
||||
column: usize,
|
||||
text: String,
|
||||
width: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct WireSpan {
|
||||
style: WireStyle,
|
||||
clusters: Vec<WireCluster>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct WireRow {
|
||||
line: usize,
|
||||
spans: Vec<WireSpan>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct WireCursor {
|
||||
line: usize,
|
||||
column: usize,
|
||||
visible: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct FrameMessage {
|
||||
subscription_id: String,
|
||||
sequence: u64,
|
||||
rows: Vec<WireRow>,
|
||||
cursor: WireCursor,
|
||||
full: bool,
|
||||
viewport: WireViewport,
|
||||
bracketed_paste: bool,
|
||||
focus_reporting: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", content = "payload", rename_all = "camelCase")]
|
||||
pub(crate) enum TerminalMessage {
|
||||
Frame(FrameMessage),
|
||||
Title(String),
|
||||
ResetTitle,
|
||||
Bell,
|
||||
Exit,
|
||||
}
|
||||
|
||||
fn wire_publication(publication: Publication) -> Result<FrameMessage> {
|
||||
let frame = publication.frame;
|
||||
let rows = frame
|
||||
.rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let spans = row
|
||||
.spans
|
||||
.into_iter()
|
||||
.map(|span| {
|
||||
if !span.counts_are_consistent() {
|
||||
return Err(
|
||||
"terminal engine emitted an inconsistent cluster count".to_string()
|
||||
);
|
||||
}
|
||||
let clusters = if span.cluster_count == 1 {
|
||||
vec![WireCluster {
|
||||
column: span.column,
|
||||
text: span.text,
|
||||
width: span.width,
|
||||
}]
|
||||
} else {
|
||||
span.text
|
||||
.chars()
|
||||
.enumerate()
|
||||
.map(|(index, ch)| WireCluster {
|
||||
column: span.column + index * usize::from(span.width),
|
||||
text: ch.to_string(),
|
||||
width: span.width,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
Ok(WireSpan {
|
||||
style: span.style.into(),
|
||||
clusters,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(WireRow {
|
||||
line: row.line,
|
||||
spans,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(FrameMessage {
|
||||
subscription_id: publication.subscription_id.to_string(),
|
||||
sequence: publication.sequence,
|
||||
rows,
|
||||
cursor: WireCursor {
|
||||
line: frame.cursor.line,
|
||||
column: frame.cursor.column,
|
||||
visible: frame.cursor.visible,
|
||||
},
|
||||
full: frame.full,
|
||||
viewport: frame.viewport.into(),
|
||||
bracketed_paste: false,
|
||||
focus_reporting: false,
|
||||
})
|
||||
}
|
||||
|
||||
struct ReaderThread(Option<JoinHandle<()>>);
|
||||
impl buzz_terminal::lifecycle::DrainingReader for ReaderThread {
|
||||
fn join(mut self: Box<Self>) {
|
||||
if let Some(handle) = self.0.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Session {
|
||||
id: Uuid,
|
||||
terminal: Arc<SharedTerminal>,
|
||||
master: Option<Box<dyn MasterPty + Send>>,
|
||||
writer: Arc<Mutex<Box<dyn Write + Send>>>,
|
||||
pty_size: Arc<Mutex<PtySize>>,
|
||||
child: Box<dyn portable_pty::Child + Send + Sync>,
|
||||
reader: Option<Box<dyn buzz_terminal::lifecycle::DrainingReader + Send>>,
|
||||
publisher: Arc<Mutex<FramePublisher>>,
|
||||
channel: Arc<Mutex<Option<Channel<TerminalMessage>>>>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
fn publish(&self, publication: Publication) {
|
||||
let subscription = publication.subscription_id;
|
||||
let sent = wire_publication(publication).and_then(|message| {
|
||||
let (bracketed_paste, focus_reporting) = self.terminal.input_modes();
|
||||
let mut message = message;
|
||||
message.bracketed_paste = bracketed_paste;
|
||||
message.focus_reporting = focus_reporting;
|
||||
self.channel
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.as_ref()
|
||||
.ok_or_else(|| "terminal renderer detached".to_string())?
|
||||
.send(TerminalMessage::Frame(message))
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
if sent.is_err() {
|
||||
if let Ok(mut channel) = self.channel.lock() {
|
||||
*channel = None;
|
||||
}
|
||||
if let Ok(mut publisher) = self.publisher.lock() {
|
||||
publisher.fault(subscription);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown(mut self) {
|
||||
if let Ok(mut channel) = self.channel.lock() {
|
||||
*channel = None;
|
||||
}
|
||||
// The slave closes on child reap; the reader continues draining until then.
|
||||
if let Some(reader) = self.reader.take() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let _ = buzz_terminal::lifecycle::shutdown_draining(&mut self.child, reader);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
reader.join();
|
||||
}
|
||||
}
|
||||
self.master.take();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TerminalSessions(Mutex<Vec<Session>>);
|
||||
|
||||
impl TerminalSessions {
|
||||
fn with_session<T>(&self, id: &str, f: impl FnOnce(&mut Session) -> Result<T>) -> Result<T> {
|
||||
let id = Uuid::parse_str(id).map_err(|_| "invalid terminal session id".to_string())?;
|
||||
let mut sessions = self.0.lock().map_err(|e| e.to_string())?;
|
||||
let session = sessions
|
||||
.iter_mut()
|
||||
.find(|session| session.id == id)
|
||||
.ok_or_else(|| "terminal session not found".to_string())?;
|
||||
f(session)
|
||||
}
|
||||
|
||||
pub(crate) fn shutdown_all(&self) {
|
||||
let sessions = self
|
||||
.0
|
||||
.lock()
|
||||
.map(|mut sessions| std::mem::take(&mut *sessions))
|
||||
.unwrap_or_default();
|
||||
for session in sessions {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn size(columns: u16, rows: u16) -> Result<Size> {
|
||||
if columns == 0 || rows == 0 {
|
||||
return Err("terminal dimensions must be non-zero".to_string());
|
||||
}
|
||||
Ok(Size {
|
||||
columns: usize::from(columns),
|
||||
screen_lines: usize::from(rows),
|
||||
..Size::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn pty_size(columns: u16, rows: u16, pixel_width: u16, pixel_height: u16) -> PtySize {
|
||||
PtySize {
|
||||
rows,
|
||||
cols: columns,
|
||||
pixel_width,
|
||||
pixel_height,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn terminal_attach(
|
||||
request: AttachRequest,
|
||||
on_frame: Channel<TerminalMessage>,
|
||||
state: tauri::State<'_, TerminalSessions>,
|
||||
) -> Result<AttachResponse> {
|
||||
let terminal_size = size(request.columns, request.rows)?;
|
||||
let mut sessions = state.0.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(existing_id) = request.session_id.as_deref() {
|
||||
let existing_id =
|
||||
Uuid::parse_str(existing_id).map_err(|_| "invalid terminal session id".to_string())?;
|
||||
let session = sessions
|
||||
.iter_mut()
|
||||
.find(|session| session.id == existing_id)
|
||||
.ok_or_else(|| "terminal session not found".to_string())?;
|
||||
let subscription = SubscriptionId::new();
|
||||
let mut encoder = buzz_terminal::damage::Encoder::new();
|
||||
let snapshot = session.terminal.snapshot(&mut encoder);
|
||||
let viewport = snapshot.viewport;
|
||||
*session.channel.lock().map_err(|e| e.to_string())? = Some(on_frame);
|
||||
let bootstrap = session
|
||||
.publisher
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.attach(subscription, snapshot)
|
||||
.map_err(|_| "terminal snapshot rejected".to_string())?;
|
||||
session.publish(bootstrap);
|
||||
return Ok(AttachResponse {
|
||||
session_id: existing_id.to_string(),
|
||||
subscription_id: subscription.to_string(),
|
||||
viewport: viewport.into(),
|
||||
});
|
||||
}
|
||||
|
||||
if sessions.len() >= MAX_LIVE_SESSIONS {
|
||||
return Err("too many live terminal sessions".to_string());
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let pair = native_pty_system()
|
||||
.openpty(pty_size(
|
||||
request.columns,
|
||||
request.rows,
|
||||
request.pixel_width,
|
||||
request.pixel_height,
|
||||
))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let shell = {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
buzz_terminal::shell::resolve_shell(std::env::var("SHELL").ok().as_deref())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
buzz_terminal::shell::resolve_shell(std::env::var("ComSpec").ok().as_deref())
|
||||
}
|
||||
};
|
||||
let mut command = CommandBuilder::new_default_prog();
|
||||
buzz_terminal::env_fence::fence_env(
|
||||
&mut command,
|
||||
&buzz_terminal::path::user_shell_path(),
|
||||
&shell,
|
||||
);
|
||||
let context = GuiContext {
|
||||
channel_id: request.channel_id,
|
||||
channel_name: request.channel_name,
|
||||
thread_id: request.thread_id,
|
||||
npub: request.npub,
|
||||
relay_url: request.relay_url,
|
||||
session_id: id.to_string(),
|
||||
};
|
||||
for (key, value) in context_vars(&context) {
|
||||
command.env(key, value);
|
||||
}
|
||||
let child = pair
|
||||
.slave
|
||||
.spawn_command(command)
|
||||
.map_err(|e| e.to_string())?;
|
||||
drop(pair.slave);
|
||||
let mut reader = pair.master.try_clone_reader().map_err(|e| e.to_string())?;
|
||||
let writer = Arc::new(Mutex::new(
|
||||
pair.master.take_writer().map_err(|e| e.to_string())?,
|
||||
));
|
||||
let (terminal, actions) = Terminal::new(terminal_size, Fences::default());
|
||||
let terminal = Arc::new(SharedTerminal::new(terminal));
|
||||
let publisher = Arc::new(Mutex::new(FramePublisher::new(terminal.lock().viewport())));
|
||||
let channel = Arc::new(Mutex::new(Some(on_frame)));
|
||||
let current_pty_size = Arc::new(Mutex::new(pty_size(
|
||||
request.columns,
|
||||
request.rows,
|
||||
request.pixel_width,
|
||||
request.pixel_height,
|
||||
)));
|
||||
|
||||
// Emulator replies (DSR/size) must go back through the PTY even when no
|
||||
// renderer is attached. The action thread ends when the terminal drops.
|
||||
let action_writer = Arc::clone(&writer);
|
||||
let action_size = Arc::clone(¤t_pty_size);
|
||||
let action_channel = Arc::clone(&channel);
|
||||
std::thread::spawn(move || {
|
||||
while let Ok(action) = actions.recv() {
|
||||
let presentation = match &action {
|
||||
buzz_terminal::Action::Title(title) => Some(TerminalMessage::Title(title.clone())),
|
||||
buzz_terminal::Action::ResetTitle => Some(TerminalMessage::ResetTitle),
|
||||
buzz_terminal::Action::Bell => Some(TerminalMessage::Bell),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(message) = presentation {
|
||||
let _ = action_channel
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|channel| channel.as_ref()?.send(message).ok());
|
||||
continue;
|
||||
}
|
||||
let size = action_size.lock().map(|size| *size).unwrap_or_default();
|
||||
if let Some(reply) = buzz_terminal::listener::reply(
|
||||
action,
|
||||
size.cols,
|
||||
size.rows,
|
||||
size.pixel_width,
|
||||
size.pixel_height,
|
||||
) {
|
||||
if action_writer.lock().map_or(true, |mut writer| {
|
||||
writer.write_all(reply.as_bytes()).is_err()
|
||||
}) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let reader_terminal = Arc::clone(&terminal);
|
||||
let reader_publisher = Arc::clone(&publisher);
|
||||
let reader_channel = Arc::clone(&channel);
|
||||
let reader_handle = std::thread::spawn(move || {
|
||||
let mut buffer = [0u8; 16 * 1024];
|
||||
let mut encoder = buzz_terminal::damage::Encoder::new();
|
||||
let mut snapshot_encoder = buzz_terminal::damage::Encoder::new();
|
||||
loop {
|
||||
let count = match reader.read(&mut buffer) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(count) => count,
|
||||
};
|
||||
reader_terminal.feed(&buffer[..count]);
|
||||
let needs_snapshot = reader_publisher
|
||||
.lock()
|
||||
.map(|publisher| publisher.requires_snapshot())
|
||||
.unwrap_or(false);
|
||||
let frame = if needs_snapshot {
|
||||
reader_terminal.snapshot(&mut snapshot_encoder)
|
||||
} else {
|
||||
reader_terminal.render(&mut encoder)
|
||||
};
|
||||
if frame.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let publication = reader_publisher
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut publisher| publisher.offer(frame).ok().flatten());
|
||||
if let Some(publication) = publication {
|
||||
let subscription = publication.subscription_id;
|
||||
let result = wire_publication(publication).and_then(|mut message| {
|
||||
let (bracketed_paste, focus_reporting) = reader_terminal.input_modes();
|
||||
message.bracketed_paste = bracketed_paste;
|
||||
message.focus_reporting = focus_reporting;
|
||||
reader_channel
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.as_ref()
|
||||
.ok_or_else(|| "detached".to_string())?
|
||||
.send(TerminalMessage::Frame(message))
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
if result.is_err() {
|
||||
if let Ok(mut publisher) = reader_publisher.lock() {
|
||||
publisher.fault(subscription);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = reader_channel
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|channel| channel.as_ref()?.send(TerminalMessage::Exit).ok());
|
||||
});
|
||||
|
||||
let subscription = SubscriptionId::new();
|
||||
let mut snapshot_encoder = buzz_terminal::damage::Encoder::new();
|
||||
let viewport = terminal.lock().viewport();
|
||||
let bootstrap = publisher
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.attach(subscription, terminal.snapshot(&mut snapshot_encoder))
|
||||
.map_err(|_| "terminal snapshot rejected".to_string())?;
|
||||
let session = Session {
|
||||
id,
|
||||
terminal,
|
||||
master: Some(pair.master),
|
||||
writer,
|
||||
pty_size: current_pty_size,
|
||||
child,
|
||||
reader: Some(Box::new(ReaderThread(Some(reader_handle)))),
|
||||
publisher,
|
||||
channel,
|
||||
};
|
||||
session.publish(bootstrap);
|
||||
sessions.push(session);
|
||||
Ok(AttachResponse {
|
||||
session_id: id.to_string(),
|
||||
subscription_id: subscription.to_string(),
|
||||
viewport: viewport.into(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn terminal_detach(
|
||||
session_id: String,
|
||||
subscription_id: String,
|
||||
state: tauri::State<'_, TerminalSessions>,
|
||||
) -> Result<()> {
|
||||
state.with_session(&session_id, |session| {
|
||||
let id = SubscriptionId::parse(&subscription_id)?;
|
||||
let detached = session
|
||||
.publisher
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.fault(id);
|
||||
if detached {
|
||||
*session.channel.lock().map_err(|e| e.to_string())? = None;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn terminal_close(
|
||||
session_id: String,
|
||||
state: tauri::State<'_, TerminalSessions>,
|
||||
) -> Result<()> {
|
||||
let id = Uuid::parse_str(&session_id).map_err(|_| "invalid terminal session id".to_string())?;
|
||||
let session = {
|
||||
let mut sessions = state.0.lock().map_err(|e| e.to_string())?;
|
||||
let index = sessions
|
||||
.iter()
|
||||
.position(|session| session.id == id)
|
||||
.ok_or_else(|| "terminal session not found".to_string())?;
|
||||
sessions.remove(index)
|
||||
};
|
||||
session.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn terminal_input(
|
||||
session_id: String,
|
||||
data: String,
|
||||
state: tauri::State<'_, TerminalSessions>,
|
||||
) -> Result<()> {
|
||||
if data.len() > MAX_INPUT_BYTES {
|
||||
return Err("terminal input exceeds 1 MiB".to_string());
|
||||
}
|
||||
state.with_session(&session_id, |session| {
|
||||
session
|
||||
.writer
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.write_all(data.as_bytes())
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn terminal_resize(
|
||||
session_id: String,
|
||||
columns: u16,
|
||||
rows: u16,
|
||||
pixel_width: u16,
|
||||
pixel_height: u16,
|
||||
state: tauri::State<'_, TerminalSessions>,
|
||||
) -> Result<WireViewport> {
|
||||
let terminal_size = size(columns, rows)?;
|
||||
state.with_session(&session_id, |session| {
|
||||
let new_pty_size = pty_size(columns, rows, pixel_width, pixel_height);
|
||||
session
|
||||
.master
|
||||
.as_ref()
|
||||
.ok_or_else(|| "terminal is closing".to_string())?
|
||||
.resize(new_pty_size)
|
||||
.map_err(|e| e.to_string())?;
|
||||
*session.pty_size.lock().map_err(|e| e.to_string())? = new_pty_size;
|
||||
let viewport = session.terminal.resize(terminal_size);
|
||||
let publication = {
|
||||
let mut publisher = session.publisher.lock().map_err(|e| e.to_string())?;
|
||||
publisher.resize_applied(viewport);
|
||||
let mut encoder = buzz_terminal::damage::Encoder::new();
|
||||
publisher
|
||||
.offer(session.terminal.snapshot(&mut encoder))
|
||||
.map_err(|_| "terminal resize snapshot rejected".to_string())?
|
||||
};
|
||||
if let Some(publication) = publication {
|
||||
session.publish(publication);
|
||||
}
|
||||
Ok(viewport.into())
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn terminal_ack(
|
||||
session_id: String,
|
||||
subscription_id: String,
|
||||
sequence: u64,
|
||||
state: tauri::State<'_, TerminalSessions>,
|
||||
) -> Result<()> {
|
||||
state.with_session(&session_id, |session| {
|
||||
let id = SubscriptionId::parse(&subscription_id)?;
|
||||
let publication = session
|
||||
.publisher
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.acknowledge(id, sequence);
|
||||
if let Some(publication) = publication {
|
||||
session.publish(publication);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn terminal_viewport_ready(
|
||||
session_id: String,
|
||||
subscription_id: String,
|
||||
viewport: WireViewport,
|
||||
state: tauri::State<'_, TerminalSessions>,
|
||||
) -> Result<()> {
|
||||
state.with_session(&session_id, |session| {
|
||||
let id = SubscriptionId::parse(&subscription_id)?;
|
||||
let publication = session
|
||||
.publisher
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.viewport_ready(id, viewport.into());
|
||||
if let Some(publication) = publication {
|
||||
session.publish(publication);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn terminal_focus(
|
||||
session_id: String,
|
||||
focused: bool,
|
||||
state: tauri::State<'_, TerminalSessions>,
|
||||
) -> Result<()> {
|
||||
state.with_session(&session_id, |session| {
|
||||
let (_, enabled) = session.terminal.input_modes();
|
||||
if enabled {
|
||||
session
|
||||
.writer
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.write_all(if focused { b"\x1b[I" } else { b"\x1b[O" })
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use buzz_terminal::damage::{CursorFrame, RowFrame, Span};
|
||||
|
||||
fn publication(spans: Vec<Span>) -> Publication {
|
||||
Publication {
|
||||
subscription_id: SubscriptionId::new(),
|
||||
sequence: 7,
|
||||
frame: buzz_terminal::damage::Frame {
|
||||
rows: vec![RowFrame { line: 3, spans }],
|
||||
cursor: CursorFrame {
|
||||
line: 1,
|
||||
column: 2,
|
||||
visible: true,
|
||||
},
|
||||
full: true,
|
||||
viewport: Viewport {
|
||||
generation: 4,
|
||||
columns: 80,
|
||||
screen_lines: 24,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn style() -> Style {
|
||||
Style {
|
||||
fg: 1,
|
||||
bg: 2,
|
||||
flags: 3,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapper_expands_ascii_runs_without_unicode_classification() {
|
||||
let message = wire_publication(publication(vec![Span {
|
||||
column: 4,
|
||||
text: "abc".into(),
|
||||
width: 1,
|
||||
cluster_count: 3,
|
||||
style: style(),
|
||||
}]))
|
||||
.unwrap();
|
||||
let clusters = &message.rows[0].spans[0].clusters;
|
||||
assert_eq!(
|
||||
clusters
|
||||
.iter()
|
||||
.map(|cluster| (cluster.column, cluster.text.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(4, "a"), (5, "b"), (6, "c")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapper_keeps_a_multi_char_cluster_atomic() {
|
||||
let message = wire_publication(publication(vec![Span {
|
||||
column: 9,
|
||||
text: "1\u{fe0f}\u{20e3}".into(),
|
||||
width: 2,
|
||||
cluster_count: 1,
|
||||
style: style(),
|
||||
}]))
|
||||
.unwrap();
|
||||
let clusters = &message.rows[0].spans[0].clusters;
|
||||
assert_eq!(clusters.len(), 1);
|
||||
assert_eq!(clusters[0].column, 9);
|
||||
assert_eq!(clusters[0].text, "1\u{fe0f}\u{20e3}");
|
||||
assert_eq!(clusters[0].width, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapper_rejects_an_inconsistent_engine_span() {
|
||||
let result = wire_publication(publication(vec![Span {
|
||||
column: 0,
|
||||
text: "ab".into(),
|
||||
width: 1,
|
||||
cluster_count: 3,
|
||||
style: style(),
|
||||
}]));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_reject_zero_and_preserve_scrollback_default() {
|
||||
assert!(size(0, 24).is_err());
|
||||
assert!(size(80, 0).is_err());
|
||||
let size = size(100, 40).unwrap();
|
||||
assert_eq!(
|
||||
(size.columns, size.screen_lines, size.scrollback),
|
||||
(100, 40, 10_000)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
//! renderer confirms that it knows the applied [`Viewport`].
|
||||
|
||||
use buzz_terminal::{damage::Frame, Viewport};
|
||||
use std::fmt;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A renderer attachment. Messages from an attachment that has been replaced
|
||||
@@ -18,6 +20,18 @@ impl SubscriptionId {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
|
||||
pub(crate) fn parse(value: &str) -> Result<Self, String> {
|
||||
Uuid::parse_str(value)
|
||||
.map(Self)
|
||||
.map_err(|_| "invalid terminal subscription id".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SubscriptionId {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(formatter)
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame carrying the identity and sequence that its ACK must repeat.
|
||||
|
||||
Reference in New Issue
Block a user