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:
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 {
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user