feat(terminal): add binary input handling to XTerminal and session management (#444)
- Implemented `sendSessionBinaryInput` function to send binary data to terminal sessions. - Enhanced `XTerminal` component to handle binary input events, ensuring proper data transmission. - Updated session commands to support raw data writing, allowing for better handling of binary inputs. - Added tests for binary input functionality to ensure accurate data processing without text bookkeeping.
This commit is contained in:
1 parent
f63dd15f2f
commit
280272aaaf
15 files changed
+112
-23
No files matched your search
@@ -919,6 +919,7 @@ pub async fn write_to_session(
|
||||
&session_id,
|
||||
SessionCommand::Write {
|
||||
data: data.into_bytes(),
|
||||
raw: false,
|
||||
automated,
|
||||
origin,
|
||||
sensitivity,
|
||||
@@ -955,6 +956,26 @@ pub async fn write_to_session(
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn write_bytes_to_session(
|
||||
state: tauri::State<'_, Arc<SessionManager>>,
|
||||
session_id: String,
|
||||
data: Vec<u8>,
|
||||
) -> AppResult<()> {
|
||||
state
|
||||
.send_command(
|
||||
&session_id,
|
||||
SessionCommand::Write {
|
||||
data,
|
||||
raw: true,
|
||||
automated: false,
|
||||
origin: InputOrigin::Keyboard,
|
||||
sensitivity: InputSensitivity::Normal,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_session_output_paused(
|
||||
state: tauri::State<'_, Arc<SessionManager>>,
|
||||
|
||||
@@ -116,6 +116,7 @@ pub async fn execute_terminal_command(
|
||||
&request.session_id,
|
||||
SessionCommand::Write {
|
||||
data,
|
||||
raw: false,
|
||||
automated: true,
|
||||
origin: InputOrigin::AiAgent,
|
||||
sensitivity: InputSensitivity::Normal,
|
||||
|
||||
@@ -245,6 +245,7 @@ pub enum SessionCommand {
|
||||
/// Input to send to the terminal.
|
||||
Write {
|
||||
data: Vec<u8>,
|
||||
raw: bool,
|
||||
automated: bool,
|
||||
origin: InputOrigin,
|
||||
sensitivity: InputSensitivity,
|
||||
@@ -1474,6 +1475,7 @@ mod tests {
|
||||
"sftp-only",
|
||||
SessionCommand::Write {
|
||||
data: b"ignored".to_vec(),
|
||||
raw: false,
|
||||
automated: false,
|
||||
origin: InputOrigin::Keyboard,
|
||||
sensitivity: crate::core::InputSensitivity::Normal,
|
||||
@@ -1567,6 +1569,7 @@ mod tests {
|
||||
"local-startup",
|
||||
SessionCommand::Write {
|
||||
data: b"x".to_vec(),
|
||||
raw: false,
|
||||
automated: false,
|
||||
origin: InputOrigin::Keyboard,
|
||||
sensitivity: super::InputSensitivity::Normal,
|
||||
|
||||
@@ -3,11 +3,12 @@ use super::client::{
|
||||
};
|
||||
use crate::config::SftpCwdFollowMode;
|
||||
use crate::core::capture::OutputCaptureProcessor;
|
||||
use crate::core::input::remap_del_to_bs;
|
||||
use crate::core::monitoring::stats::RemoteStatsSampler;
|
||||
use crate::core::ssh::osc::{self, OscStripper, ShellKind};
|
||||
use crate::core::terminal_session::local::split_startup_passthrough;
|
||||
use crate::core::terminal_session::{TerminalOutputDecoder, encode_terminal_input};
|
||||
use crate::core::terminal_session::{
|
||||
TerminalOutputDecoder, encode_terminal_input, prepare_terminal_write_input,
|
||||
};
|
||||
use crate::core::zmodem::{
|
||||
ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemDirection, ZmodemDownloadOoDrain,
|
||||
ZmodemEvent, ZmodemTransfer, ZmodemUploadDrain, start_zmodem_transfer,
|
||||
@@ -1328,7 +1329,7 @@ pub(super) async fn ssh_io_loop(
|
||||
Some(SessionCommand::DetachRenderer) => {
|
||||
output.detach();
|
||||
}
|
||||
Some(SessionCommand::Write { mut data, origin, .. }) => {
|
||||
Some(SessionCommand::Write { data, raw, origin, .. }) => {
|
||||
if zmodem_transfer.is_some()
|
||||
|| zmodem_upload_drain.should_suppress(std::time::Instant::now())
|
||||
{
|
||||
@@ -1344,10 +1345,7 @@ pub(super) async fn ssh_io_loop(
|
||||
&mut post_login_deadline,
|
||||
)
|
||||
.await;
|
||||
if backspace_as_bs {
|
||||
remap_del_to_bs(&mut data);
|
||||
}
|
||||
let send_data = encode_terminal_input(&data, &encoding);
|
||||
let send_data = prepare_terminal_write_input(data, &encoding, raw, backspace_as_bs);
|
||||
if phase == IoPhase::Suppressing {
|
||||
suppression_diagnostics.record_pre_ready_write(send_data.len());
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ use crate::core::session::{
|
||||
StartupInjectionAttempt, StartupInputBarrier, session_command_channel,
|
||||
};
|
||||
use crate::core::ssh::osc::{CwdPayloadEvent, OscStripper, build_ready_marker};
|
||||
use crate::core::terminal_session::{TerminalOutputDecoder, encode_terminal_input};
|
||||
use crate::core::terminal_session::{
|
||||
TerminalOutputDecoder, encode_terminal_input, prepare_terminal_write_input,
|
||||
};
|
||||
use crate::core::zmodem::{
|
||||
ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemDirection, ZmodemEvent, ZmodemTransfer,
|
||||
start_zmodem_transfer,
|
||||
|
||||
@@ -1327,7 +1327,7 @@ fn pty_session_thread(
|
||||
SessionCommand::DetachRenderer => {
|
||||
output.detach();
|
||||
}
|
||||
SessionCommand::Write { data, origin, .. } => {
|
||||
SessionCommand::Write { data, raw, origin, .. } => {
|
||||
if input_cancels_startup_injection(origin)
|
||||
&& startup_input_barrier
|
||||
.as_ref()
|
||||
@@ -1353,7 +1353,7 @@ fn pty_session_thread(
|
||||
if zmodem_input_blocked.load(Ordering::Acquire) {
|
||||
continue;
|
||||
}
|
||||
let send_data = encode_terminal_input(&data, &encoding);
|
||||
let send_data = prepare_terminal_write_input(data, &encoding, raw, false);
|
||||
let write_started_at = Instant::now();
|
||||
match write_to_pty(&mut *writer, &send_data) {
|
||||
Ok(()) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ pub(crate) mod local;
|
||||
pub(crate) mod serial;
|
||||
pub(crate) mod telnet;
|
||||
|
||||
use crate::core::input::remap_del_to_bs;
|
||||
use encoding_rs::{CoderResult, Decoder, Encoding, UTF_8};
|
||||
|
||||
pub(crate) fn terminal_encoding(label: &str) -> &'static Encoding {
|
||||
@@ -88,6 +89,21 @@ pub(crate) fn encode_terminal_input(data: &[u8], encoding: &str) -> Vec<u8> {
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_terminal_write_input(
|
||||
mut data: Vec<u8>,
|
||||
encoding: &str,
|
||||
raw: bool,
|
||||
backspace_as_bs: bool,
|
||||
) -> Vec<u8> {
|
||||
if raw {
|
||||
return data;
|
||||
}
|
||||
if backspace_as_bs {
|
||||
remap_del_to_bs(&mut data);
|
||||
}
|
||||
encode_terminal_input(&data, encoding)
|
||||
}
|
||||
|
||||
fn ansi_sequence_end(data: &[u8], start: usize) -> usize {
|
||||
let mut i = start + 1;
|
||||
if i >= data.len() {
|
||||
@@ -111,7 +127,20 @@ fn ansi_sequence_end(data: &[u8], start: usize) -> usize {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{TerminalOutputDecoder, encode_terminal_input};
|
||||
use super::{TerminalOutputDecoder, encode_terminal_input, prepare_terminal_write_input};
|
||||
|
||||
#[test]
|
||||
fn write_input_encodes_text_but_preserves_raw_bytes_and_del() {
|
||||
assert_eq!(
|
||||
prepare_terminal_write_input("测\u{7f}".as_bytes().to_vec(), "GBK", false, true),
|
||||
vec![0xB2, 0xE2, 0x08]
|
||||
);
|
||||
let raw = vec![0x1b, b'[', b'M', b' ', 0x80, 0xff, 0x7f];
|
||||
assert_eq!(
|
||||
prepare_terminal_write_input(raw.clone(), "GBK", true, true),
|
||||
raw
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gbk_decoder_preserves_split_multibyte_character() {
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
use crate::config::AiExecutionProfile;
|
||||
use crate::core::capture::OutputCaptureProcessor;
|
||||
use crate::core::input::remap_del_to_bs;
|
||||
use crate::core::session::{
|
||||
DynamicTitleCapabilities, SessionCommand, SessionCommandReceiver, SessionCommandSender,
|
||||
SessionHandle, SessionInfo, SessionManager, SessionReadyHook, SessionType, SharedCwd,
|
||||
session_command_channel,
|
||||
};
|
||||
use crate::core::terminal_session::{TerminalOutputDecoder, encode_terminal_input};
|
||||
use crate::core::terminal_session::{
|
||||
TerminalOutputDecoder, encode_terminal_input, prepare_terminal_write_input,
|
||||
};
|
||||
use crate::core::zmodem::{
|
||||
ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemDirection, ZmodemEvent, ZmodemTransfer,
|
||||
start_zmodem_transfer,
|
||||
|
||||
@@ -304,15 +304,12 @@ fn serial_session_thread(
|
||||
SessionCommand::DetachRenderer => {
|
||||
output.detach();
|
||||
}
|
||||
SessionCommand::Write { mut data, .. } => {
|
||||
SessionCommand::Write { data, raw, .. } => {
|
||||
if zmodem_state.lock().unwrap().is_some() || xymodem_state.lock().unwrap().is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if backspace_as_bs {
|
||||
remap_del_to_bs(&mut data);
|
||||
}
|
||||
let send_data = encode_terminal_input(&data, &encoding);
|
||||
let send_data = prepare_terminal_write_input(data, &encoding, raw, backspace_as_bs);
|
||||
let mut p = port_writer.lock().unwrap();
|
||||
let _ = p.write_all(&send_data);
|
||||
let _ = p.flush();
|
||||
|
||||
@@ -8,7 +8,9 @@ use crate::core::session::{
|
||||
SessionHandle, SessionInfo, SessionManager, SessionReadyHook, SessionType, SharedCwd,
|
||||
session_command_channel,
|
||||
};
|
||||
use crate::core::terminal_session::{TerminalOutputDecoder, encode_terminal_input};
|
||||
use crate::core::terminal_session::{
|
||||
TerminalOutputDecoder, encode_terminal_input, prepare_terminal_write_input,
|
||||
};
|
||||
use crate::core::zmodem::{
|
||||
ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemDirection, ZmodemDownloadOoDrain,
|
||||
ZmodemEvent, ZmodemTransfer, ZmodemUploadDrain, start_zmodem_transfer,
|
||||
|
||||
@@ -375,7 +375,7 @@ async fn telnet_session_task(
|
||||
Some(SessionCommand::DetachRenderer) => {
|
||||
output.detach();
|
||||
}
|
||||
Some(SessionCommand::Write { mut data, automated, .. }) => {
|
||||
Some(SessionCommand::Write { mut data, raw, automated, .. }) => {
|
||||
if !automated {
|
||||
let mut auto = auto_login.lock().await;
|
||||
if let Some(auto) = auto.as_mut() {
|
||||
@@ -398,7 +398,12 @@ async fn telnet_session_task(
|
||||
}
|
||||
|
||||
let mut write_failed = None;
|
||||
if line_edit_active {
|
||||
if raw {
|
||||
let send_data = prepare_terminal_write_input(data, &encoding, true, false);
|
||||
if let Err(e) = writer.write_all(&send_data).await {
|
||||
write_failed = Some(e);
|
||||
}
|
||||
} else if line_edit_active {
|
||||
let edit_result = line_editor.process(&data, config.enter_mode);
|
||||
if !edit_result.display.is_empty() {
|
||||
output.push_owned(edit_result.display);
|
||||
@@ -422,7 +427,7 @@ async fn telnet_session_task(
|
||||
output.push_owned(echoed);
|
||||
}
|
||||
}
|
||||
let send_data = encode_terminal_input(&data, &encoding);
|
||||
let send_data = prepare_terminal_write_input(data, &encoding, false, false);
|
||||
for chunk in split_write_chunks(&send_data, config.force_character_at_a_time) {
|
||||
if let Err(e) = writer.write_all(&chunk).await {
|
||||
write_failed = Some(e);
|
||||
|
||||
@@ -236,6 +236,7 @@ pub fn run() {
|
||||
cmd::session::cancel_session_creation,
|
||||
cmd::session::list_serial_ports,
|
||||
cmd::session::write_to_session,
|
||||
cmd::session::write_bytes_to_session,
|
||||
cmd::session::set_session_output_paused,
|
||||
cmd::session::ack_session_output,
|
||||
cmd::session::resize_session,
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
normalizeTerminalCommandInput,
|
||||
type SendSessionInputOptions,
|
||||
type SessionInputPreview,
|
||||
sendSessionBinaryInput,
|
||||
sendSessionInput,
|
||||
sendSessionInputWithSync,
|
||||
} from "@/lib/sessionInput";
|
||||
@@ -2354,6 +2355,16 @@ export default function XTerminal({
|
||||
sendRawInput(data, data === "\r" && command ? command : null);
|
||||
});
|
||||
|
||||
const binaryDisposable = terminal.onBinary((data) => {
|
||||
if (shouldBlockXTerminalData(appLockedRef.current, "keyboard")) return;
|
||||
if (aiCapturingRef.current) return;
|
||||
if (hibernationPhaseRef.current !== "idle") {
|
||||
requestWake("input");
|
||||
}
|
||||
if (disconnectedRef.current) return;
|
||||
void sendSessionBinaryInput(sessionIdRef.current, data).catch(() => {});
|
||||
});
|
||||
|
||||
const resizeDisposable = terminal.onResize(({ cols, rows }) => {
|
||||
sendBackendResize(cols, rows, "xterm.onResize");
|
||||
refreshGutter();
|
||||
@@ -2499,6 +2510,7 @@ export default function XTerminal({
|
||||
clipboardOscDisposable.dispose();
|
||||
writeParsedDisposable.dispose();
|
||||
dataDisposable.dispose();
|
||||
binaryDisposable.dispose();
|
||||
resizeDisposable.dispose();
|
||||
scrollDisposable.dispose();
|
||||
for (const disposable of searchLifecycleDisposables) {
|
||||
|
||||
@@ -5,7 +5,7 @@ const mocks = vi.hoisted(() => ({
|
||||
}));
|
||||
vi.mock("./invoke", () => ({ invoke: mocks.invoke }));
|
||||
|
||||
import { sendSessionInput, sendSessionInputWithSync } from "./sessionInput";
|
||||
import { sendSessionBinaryInput, sendSessionInput, sendSessionInputWithSync } from "./sessionInput";
|
||||
|
||||
describe("sendSessionInputWithSync command confirmation", () => {
|
||||
beforeEach(() => {
|
||||
@@ -27,6 +27,16 @@ describe("sendSessionInputWithSync command confirmation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sends binary input as exact low-byte values without text bookkeeping", async () => {
|
||||
await sendSessionBinaryInput("primary", "\x1b[M \x80\xff");
|
||||
|
||||
expect(mocks.invoke).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("write_bytes_to_session", {
|
||||
sessionId: "primary",
|
||||
data: [0x1b, 0x5b, 0x4d, 0x20, 0x80, 0xff],
|
||||
});
|
||||
});
|
||||
|
||||
it("registers each peer candidate before synchronized command input", async () => {
|
||||
await sendSessionInputWithSync(
|
||||
"primary",
|
||||
|
||||
@@ -175,6 +175,13 @@ export async function sendSessionInput(
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendSessionBinaryInput(sessionId: string, data: string): Promise<void> {
|
||||
await invoke("write_bytes_to_session", {
|
||||
sessionId,
|
||||
data: Array.from(data, (char) => char.charCodeAt(0) & 0xff),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send input to a session and broadcast to all sync-group peers.
|
||||
* Peers do not emit frontend preview/history UI, but an executable command
|
||||
|
||||
Reference in new issue
Block a user