The terminal output path decodes remote bytes with an iconv decoder built
from the user's configured charset (GB18030, etc.), but the input path
serialized keystrokes as UTF-8 unconditionally. On a non-UTF-8 device that
made input and output asymmetric: with GB18030 selected the device's command
completion decoded correctly while manually typed Chinese went out as UTF-8
bytes and showed up garbled (and the reverse with UTF-8 selected).
Make input symmetric with output:
- Add electron/bridges/terminalEncoding.cjs as the single source of truth for
charset normalization plus encodeTerminalInput(), which encodes a keystroke
string with the same iconv charset (returning the string untouched for UTF-8
and for unset/unknown encodings so the transport's native serialization and
the Mosh/local-PTY paths are unchanged). ASCII control bytes and CSI escape
sequences pass through byte-for-byte under GB18030.
- terminalBridge.writeToSession() now encodes outgoing data via
encodeTerminalInput(session.encoding) before writing to the SSH stream,
telnet socket, or serial port. Telnet IAC 0xFF escaping still runs on the
encoded bytes.
- session.encoding (the input charset) is now kept in lock-step with the
output decoder everywhere the decoder is configured:
* Telnet/serial already stored session.encoding; writeToSession now reads
it for input too.
* SSH mirrors session.encoding wherever it sets sessionEncodings: the
GB-variant pre-seed at session start and the runtime setEncoding handler.
The pre-seed stays gated to GB variants to match the renderer's two-value
encoding state, so behavior for other/arbitrary charsets is unchanged —
the renderer still pushes the effective encoding via setEncoding on
attach, and that handler keeps both halves in sync.
- The SSH startup command is encoded with the same charset as interactive
input.
Mosh stays UTF-8 (mosh-client is UTF-8-only and sets LANG accordingly), and
local PTY stays UTF-8 — neither sets session.encoding, so their input is
untouched.
Adds unit tests for the encoding helper (GB18030/UTF-8 round-trips, ASCII
control preservation, symmetry with the output decoder) and integration tests
driving writeToSession over a raw TCP device to assert GB18030 bytes on the
wire and a UTF-8 regression guard.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
191 lines
5.6 KiB
JavaScript
191 lines
5.6 KiB
JavaScript
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const net = require("node:net");
|
|
const iconv = require("iconv-lite");
|
|
|
|
const terminalBridge = require("./terminalBridge.cjs");
|
|
|
|
function listen(server) {
|
|
return new Promise((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
server.off("error", reject);
|
|
resolve(server.address().port);
|
|
});
|
|
});
|
|
}
|
|
|
|
function waitFor(predicate, timeoutMs = 1000) {
|
|
const startedAt = Date.now();
|
|
return new Promise((resolve, reject) => {
|
|
const tick = () => {
|
|
if (predicate()) {
|
|
resolve();
|
|
return;
|
|
}
|
|
if (Date.now() - startedAt > timeoutMs) {
|
|
reject(new Error("Timed out waiting for telnet input bytes"));
|
|
return;
|
|
}
|
|
setTimeout(tick, 10);
|
|
};
|
|
tick();
|
|
});
|
|
}
|
|
|
|
// These tests drive the real terminalBridge.writeToSession path over a raw TCP
|
|
// "device" that never speaks the Telnet protocol (no IAC bytes), so the bytes
|
|
// captured server-side are exactly what the input path serialized — proving
|
|
// the keystroke encoding without IAC-escaping noise. They guard issue #1216:
|
|
// input must use the SAME charset the output decoder uses.
|
|
|
|
function initBridge(sessions) {
|
|
terminalBridge.init({
|
|
sessions,
|
|
electronModule: {
|
|
webContents: {
|
|
fromId: () => ({ send() {} }),
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
test("Telnet input is encoded with the session's GB18030 charset", async () => {
|
|
const chunks = [];
|
|
const sockets = new Set();
|
|
let serverSocket = null;
|
|
const server = net.createServer((socket) => {
|
|
serverSocket = socket;
|
|
sockets.add(socket);
|
|
socket.on("error", () => {});
|
|
socket.on("close", () => sockets.delete(socket));
|
|
socket.on("data", (buf) => chunks.push(buf));
|
|
});
|
|
|
|
const port = await listen(server);
|
|
const sessions = new Map();
|
|
initBridge(sessions);
|
|
|
|
try {
|
|
await terminalBridge.startTelnetSession(
|
|
{ sender: { id: 1 } },
|
|
{
|
|
sessionId: "telnet-gb18030-input",
|
|
hostname: "127.0.0.1",
|
|
port,
|
|
// No saved credentials → auto-login stays idle and does not inject bytes.
|
|
charset: "GB18030",
|
|
},
|
|
);
|
|
await waitFor(() => serverSocket);
|
|
|
|
terminalBridge.writeToSession(
|
|
{},
|
|
{ sessionId: "telnet-gb18030-input", data: "你好\r" },
|
|
);
|
|
|
|
await waitFor(() => Buffer.concat(chunks).length >= 5);
|
|
const received = Buffer.concat(chunks);
|
|
assert.deepEqual([...received], [...iconv.encode("你好\r", "gb18030")]);
|
|
// It must NOT be the UTF-8 serialization that the old code always sent.
|
|
assert.notDeepEqual([...received], [...Buffer.from("你好\r", "utf8")]);
|
|
} finally {
|
|
terminalBridge.cleanupAllSessions();
|
|
for (const socket of sockets) socket.destroy();
|
|
await new Promise((resolve) => server.close(resolve));
|
|
}
|
|
});
|
|
|
|
test("Telnet input stays UTF-8 when no charset is configured", async () => {
|
|
const chunks = [];
|
|
const sockets = new Set();
|
|
let serverSocket = null;
|
|
const server = net.createServer((socket) => {
|
|
serverSocket = socket;
|
|
sockets.add(socket);
|
|
socket.on("error", () => {});
|
|
socket.on("close", () => sockets.delete(socket));
|
|
socket.on("data", (buf) => chunks.push(buf));
|
|
});
|
|
|
|
const port = await listen(server);
|
|
const sessions = new Map();
|
|
initBridge(sessions);
|
|
|
|
try {
|
|
await terminalBridge.startTelnetSession(
|
|
{ sender: { id: 1 } },
|
|
{
|
|
sessionId: "telnet-utf8-input",
|
|
hostname: "127.0.0.1",
|
|
port,
|
|
},
|
|
);
|
|
await waitFor(() => serverSocket);
|
|
|
|
terminalBridge.writeToSession(
|
|
{},
|
|
{ sessionId: "telnet-utf8-input", data: "你好\r" },
|
|
);
|
|
|
|
await waitFor(() => Buffer.concat(chunks).length >= 7);
|
|
const received = Buffer.concat(chunks);
|
|
assert.deepEqual([...received], [...Buffer.from("你好\r", "utf8")]);
|
|
} finally {
|
|
terminalBridge.cleanupAllSessions();
|
|
for (const socket of sockets) socket.destroy();
|
|
await new Promise((resolve) => server.close(resolve));
|
|
}
|
|
});
|
|
|
|
test("setSessionEncoding switches the Telnet input charset at runtime", async () => {
|
|
const chunks = [];
|
|
const sockets = new Set();
|
|
let serverSocket = null;
|
|
const server = net.createServer((socket) => {
|
|
serverSocket = socket;
|
|
sockets.add(socket);
|
|
socket.on("error", () => {});
|
|
socket.on("close", () => sockets.delete(socket));
|
|
socket.on("data", (buf) => chunks.push(buf));
|
|
});
|
|
|
|
const port = await listen(server);
|
|
const sessions = new Map();
|
|
initBridge(sessions);
|
|
|
|
try {
|
|
await terminalBridge.startTelnetSession(
|
|
{ sender: { id: 1 } },
|
|
{
|
|
sessionId: "telnet-switch-input",
|
|
hostname: "127.0.0.1",
|
|
port,
|
|
},
|
|
);
|
|
await waitFor(() => serverSocket);
|
|
|
|
const switchResult = terminalBridge.setSessionEncoding(
|
|
{},
|
|
{ sessionId: "telnet-switch-input", encoding: "gbk" },
|
|
);
|
|
// "gbk" normalizes onto the gb18030 superset and is mirrored to
|
|
// session.encoding so the input path picks it up immediately.
|
|
assert.deepEqual(switchResult, { ok: true, encoding: "gb18030" });
|
|
assert.equal(sessions.get("telnet-switch-input").encoding, "gb18030");
|
|
|
|
terminalBridge.writeToSession(
|
|
{},
|
|
{ sessionId: "telnet-switch-input", data: "测试\r" },
|
|
);
|
|
|
|
await waitFor(() => Buffer.concat(chunks).length >= 5);
|
|
const received = Buffer.concat(chunks);
|
|
assert.deepEqual([...received], [...iconv.encode("测试\r", "gb18030")]);
|
|
} finally {
|
|
terminalBridge.cleanupAllSessions();
|
|
for (const socket of sockets) socket.destroy();
|
|
await new Promise((resolve) => server.close(resolve));
|
|
}
|
|
});
|