feat(terminal): implement synchronized binary input handling for XTerminal

- Added `sendSessionBinaryInputWithSync` function to broadcast binary input to sync peers.
- Updated `XTerminal` component to utilize the new function for handling binary input with peer synchronization.
- Enhanced tests to verify the correct broadcasting of binary input to multiple peers without text bookkeeping.
- Introduced `escape_telnet_application_data` function to handle IAC byte escaping for telnet transport.
- Added tests for the new escaping functionality to ensure proper data handling in telnet sessions.
This commit is contained in:
Kang committed 2026-09-26 23:59:13 +08:00
1 parent 280272aaaf
commit 334817dbff
6 files changed
+69 -3

No files matched your search

@@ -58,6 +58,21 @@ fn unescape_iac_iac(data: &[u8]) -> Vec<u8> {
visible visible
} }
fn escape_telnet_application_data(data: &[u8], raw_tcp_cli: bool) -> Vec<u8> {
if raw_tcp_cli {
return data.to_vec();
}
let mut escaped = Vec::with_capacity(data.len());
for &byte in data {
escaped.push(byte);
if byte == IAC {
escaped.push(IAC);
}
}
escaped
}
/// Strip IAC sequences from raw data, returning only user-visible bytes. /// Strip IAC sequences from raw data, returning only user-visible bytes.
/// Calls `on_negotiate` for each IAC command/option pair encountered. /// Calls `on_negotiate` for each IAC command/option pair encountered.
fn strip_telnet_commands(data: &[u8], on_negotiate: &mut impl FnMut(u8, u8)) -> Vec<u8> { fn strip_telnet_commands(data: &[u8], on_negotiate: &mut impl FnMut(u8, u8)) -> Vec<u8> {
@@ -400,6 +400,7 @@ async fn telnet_session_task(
let mut write_failed = None; let mut write_failed = None;
if raw { if raw {
let send_data = prepare_terminal_write_input(data, &encoding, true, false); let send_data = prepare_terminal_write_input(data, &encoding, true, false);
let send_data = escape_telnet_application_data(&send_data, config.raw_tcp_cli);
if let Err(e) = writer.write_all(&send_data).await { if let Err(e) = writer.write_all(&send_data).await {
write_failed = Some(e); write_failed = Some(e);
} }
@@ -4,7 +4,7 @@ mod tests {
DO, IAC, OPT_NAWS, OPT_SUPPRESS_GO_AHEAD, TelnetEnterMode, TelnetLineEditor, DO, IAC, OPT_NAWS, OPT_SUPPRESS_GO_AHEAD, TelnetEnterMode, TelnetLineEditor,
TelnetAutoLogin, TelnetAutoLoginAction, TelnetAutoLoginConfig, TelnetAutoLogin, TelnetAutoLoginAction, TelnetAutoLoginConfig,
TelnetAutoLoginCredentials, TelnetSessionConfig, WILL, maybe_build_naws, TelnetAutoLoginCredentials, TelnetSessionConfig, WILL, maybe_build_naws,
await_telnet_connection, negotiate_response, normalize_enter_bytes, split_write_chunks, await_telnet_connection, escape_telnet_application_data, negotiate_response, normalize_enter_bytes, split_write_chunks,
strip_telnet_commands, strip_telnet_commands,
}; };
use crate::error::AppError; use crate::error::AppError;
@@ -84,6 +84,19 @@ mod tests {
assert!(responses.is_empty()); assert!(responses.is_empty());
} }
#[test]
fn binary_input_escapes_iac_only_for_telnet_transport() {
let mouse_report = b"\x1b[M \xff\x80";
assert_eq!(
escape_telnet_application_data(mouse_report, false),
b"\x1b[M \xff\xff\x80"
);
assert_eq!(
escape_telnet_application_data(mouse_report, true),
mouse_report
);
}
#[test] #[test]
fn send_naws_false_prevents_naws_resize_payload() { fn send_naws_false_prevents_naws_resize_payload() {
let config = TelnetSessionConfig { let config = TelnetSessionConfig {
+7 -1
View File
@@ -52,6 +52,7 @@ import {
type SendSessionInputOptions, type SendSessionInputOptions,
type SessionInputPreview, type SessionInputPreview,
sendSessionBinaryInput, sendSessionBinaryInput,
sendSessionBinaryInputWithSync,
sendSessionInput, sendSessionInput,
sendSessionInputWithSync, sendSessionInputWithSync,
} from "@/lib/sessionInput"; } from "@/lib/sessionInput";
@@ -2362,7 +2363,12 @@ export default function XTerminal({
requestWake("input"); requestWake("input");
} }
if (disconnectedRef.current) return; if (disconnectedRef.current) return;
void sendSessionBinaryInput(sessionIdRef.current, data).catch(() => {}); const peers = syncPeerSessionIdsRef.current;
void (
peers?.length
? sendSessionBinaryInputWithSync(sessionIdRef.current, data, peers)
: sendSessionBinaryInput(sessionIdRef.current, data)
).catch(() => {});
}); });
const resizeDisposable = terminal.onResize(({ cols, rows }) => { const resizeDisposable = terminal.onResize(({ cols, rows }) => {
+21 -1
View File
@@ -5,7 +5,12 @@ const mocks = vi.hoisted(() => ({
})); }));
vi.mock("./invoke", () => ({ invoke: mocks.invoke })); vi.mock("./invoke", () => ({ invoke: mocks.invoke }));
import { sendSessionBinaryInput, sendSessionInput, sendSessionInputWithSync } from "./sessionInput"; import {
sendSessionBinaryInput,
sendSessionBinaryInputWithSync,
sendSessionInput,
sendSessionInputWithSync,
} from "./sessionInput";
describe("sendSessionInputWithSync command confirmation", () => { describe("sendSessionInputWithSync command confirmation", () => {
beforeEach(() => { beforeEach(() => {
@@ -37,6 +42,21 @@ describe("sendSessionInputWithSync command confirmation", () => {
}); });
}); });
it("broadcasts binary input to sync peers without text bookkeeping", async () => {
await sendSessionBinaryInputWithSync("primary", "\x1b[M \xff", [
"peer-a",
"peer-b",
]);
expect(mocks.invoke).toHaveBeenCalledTimes(3);
for (const sessionId of ["primary", "peer-a", "peer-b"]) {
expect(mocks.invoke).toHaveBeenCalledWith("write_bytes_to_session", {
sessionId,
data: [0x1b, 0x5b, 0x4d, 0x20, 0xff],
});
}
});
it("registers each peer candidate before synchronized command input", async () => { it("registers each peer candidate before synchronized command input", async () => {
await sendSessionInputWithSync( await sendSessionInputWithSync(
"primary", "primary",
+11
View File
@@ -182,6 +182,17 @@ export async function sendSessionBinaryInput(sessionId: string, data: string): P
}); });
} }
export async function sendSessionBinaryInputWithSync(
sessionId: string,
data: string,
peerSessionIds: string[],
): Promise<void> {
await sendSessionBinaryInput(sessionId, data);
await Promise.allSettled(
peerSessionIds.map((sid) => sendSessionBinaryInput(sid, data)),
);
}
/** /**
* Send input to a session and broadcast to all sync-group peers. * Send input to a session and broadcast to all sync-group peers.
* Peers do not emit frontend preview/history UI, but an executable command * Peers do not emit frontend preview/history UI, but an executable command