From 542a65dc95e11805dafd3e5673ef6615310c4776 Mon Sep 17 00:00:00 2001 From: acacio90 <49293808+acacio90@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:20:23 -0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(terminal):=20acentos=20das=20mensagens?= =?UTF-8?q?=20do=20ssh=20no=20lugar=20dos=20c=C3=B3digos=20octais?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O ssh.exe escapa em octal os bytes cp1252 das mensagens do Windows ("n\343o \351"); a saída das abas SSH e a mensagem do agente SSH voltam a ter os acentos. --- CHANGELOG.md | 2 ++ src/desktop/main.ts | 3 ++- src/desktop/terminals.ts | 5 ++++- src/sessions/launch.ts | 3 ++- src/sessions/ssh-text.ts | 26 ++++++++++++++++++++++++++ test/ssh-text.test.ts | 37 +++++++++++++++++++++++++++++++++++++ test/terminals.test.ts | 10 ++++++++++ 7 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 src/sessions/ssh-text.ts create mode 100644 test/ssh-text.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 34d9801..9b60ab9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ Todas as mudanças relevantes do MultTerm. O formato segue o - Colar com o botão direito no agente (Claude Code) não duplica mais o texto: o clique direito cola pelo MultTerm e não chega ao programa como clique do mouse. (#99) +- Mensagens do SSH com acentos (ex.: "Este host não é conhecido.") aparecem com os acentos, e não + como códigos `\343`/`\351`, nas abas SSH e no status do agente SSH. (#100) ## [0.9.0] - 2026-10-21 diff --git a/src/desktop/main.ts b/src/desktop/main.ts index 399002f..37dcd6d 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -32,6 +32,7 @@ import { localShellCommand, type LocalShell } from './local-terminal.ts'; import { wintunDllPath } from './native-vpn.ts'; import { HELPER_FLAG, runVpnHelper } from '../vpn/helper.ts'; import { readImportFile } from '../sessions/import.ts'; +import { unescapeSshText } from '../sessions/ssh-text.ts'; import { exportFileName, loadSessionDefaults, loadSessionFile, mergeSessions, saveSessionFile, sessionsInFolder, validateDefaults, validateSessions, withDefaults, } from '../sessions/store.ts'; @@ -300,7 +301,7 @@ function registerIpc(): void { sshAlgorithms: session.legacySsh ? await sshAlgorithms() : undefined, }); const size = (n: unknown, fallback: number) => (Number.isInteger(n) && (n as number) > 0 ? (n as number) : fallback); - openTerminal(e.sender, id, file, args, { cols: size(cols, 80), rows: size(rows, 24), cwd: homedir() }); + openTerminal(e.sender, id, file, args, { cols: size(cols, 80), rows: size(rows, 24), cwd: homedir() }, unescapeSshText); // Avisos vão para a faixa da aba: o ConPTY limpa a tela ao iniciar e apagaria texto escrito antes. return { warnings }; }); diff --git a/src/desktop/terminals.ts b/src/desktop/terminals.ts index 3c76663..167a0dc 100644 --- a/src/desktop/terminals.ts +++ b/src/desktop/terminals.ts @@ -44,6 +44,8 @@ export function openTerminal( file: string, args: string[], size: { cols: number; rows: number; cwd: string }, + /** Ajuste da saída antes de ir para a aba e para o agente (ex.: acentos escapados pelo ssh). */ + transform?: (data: string) => string, ): void { if (!validId(id)) throw new Error('id de terminal inválido'); const key = keyOf(owner, id); @@ -59,7 +61,8 @@ export function openTerminal( terminals.set(key, entry); if (!outputs.has(key)) outputs.set(key, new OutputBuffer()); // Dono e id lidos na hora: a aba pode ter mudado de janela. - proc.onData((data) => { + proc.onData((raw) => { + const data = transform ? transform(raw) : raw; const k = keyOf(entry.owner, entry.id); outputs.get(k)?.push(data); for (const l of dataListeners) l(k); diff --git a/src/sessions/launch.ts b/src/sessions/launch.ts index 72e35c0..778ab57 100644 --- a/src/sessions/launch.ts +++ b/src/sessions/launch.ts @@ -1,5 +1,6 @@ import { isAbsolute, join, relative } from 'node:path'; import { DEFAULT_PORTS, effectivePort, SessionError, type Session } from './model.ts'; +import { unescapeSshText } from './ssh-text.ts'; export interface LaunchEnv { platform: NodeJS.Platform; @@ -202,7 +203,7 @@ export function parseAgentList(exitCode: number | null, stdout: string, stderr: return { available: true, keys: stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean) }; } if (exitCode === 1) return { available: true, keys: [] }; - return { available: false, message: (stderr || stdout).trim() || 'agente SSH indisponível' }; + return { available: false, message: unescapeSshText((stderr || stdout).trim()) || 'agente SSH indisponível' }; } /** diff --git a/src/sessions/ssh-text.ts b/src/sessions/ssh-text.ts new file mode 100644 index 0000000..e1566a0 --- /dev/null +++ b/src/sessions/ssh-text.ts @@ -0,0 +1,26 @@ +/* + * O OpenSSH escapa em octal (\343) os bytes que não são UTF-8 válido nas próprias mensagens. No + * Windows em português o texto do sistema vem em cp1252: "Este host n\343o \351 conhecido." + * Aqui os escapes voltam a ser os acentos (UTF-8 quando a sequência for UTF-8 válido, senão cp1252). + * Só vira texto o que decodifica em letras: um "\351" que não é acento fica como está. + */ + +const utf8 = new TextDecoder('utf-8', { fatal: true }); +const cp1252 = new TextDecoder('windows-1252'); + +// Sequências de bytes altos (\200-\377) que não vêm depois de outra barra ("\\351" é uma barra literal). +const ESCAPES = /(? { + const bytes = Uint8Array.from(run.slice(1).split('\\'), (octal) => parseInt(octal, 8)); + let decoded: string; + try { + decoded = utf8.decode(bytes); + } catch { + decoded = cp1252.decode(bytes); + } + return /^[\p{L}\p{M}]+$/u.test(decoded) ? decoded : run; + }); +} diff --git a/test/ssh-text.test.ts b/test/ssh-text.test.ts new file mode 100644 index 0000000..e4ab027 --- /dev/null +++ b/test/ssh-text.test.ts @@ -0,0 +1,37 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { unescapeSshText } from '../src/sessions/ssh-text.ts'; +import { parseAgentList } from '../src/sessions/launch.ts'; + +test('mensagens do Windows em cp1252 escapadas pelo ssh voltam a ter acentos', () => { + // Saída real do ssh.exe num Windows em português. + assert.equal( + unescapeSshText('ssh: Could not resolve hostname x.invalid: Este host n\\343o \\351 conhecido.\r\n'), + 'ssh: Could not resolve hostname x.invalid: Este host n\u00e3o \u00e9 conhecido.\r\n', + ); + assert.equal(unescapeSshText('read: Uma conex\\343o existente foi for\\347ada'), 'read: Uma conex\u00e3o existente foi for\u00e7ada'); + assert.equal(unescapeSshText('\\301rea e c\\363digo'), '\u00c1rea e c\u00f3digo'); +}); + +test('sequência UTF-8 escapada vira o caractere UTF-8', () => { + assert.equal(unescapeSshText('caf\\303\\251'), 'caf\u00e9'); +}); + +test('o que não é acento fica como está', () => { + const kept = [ + 'sem escapes', + 'C:\\Users\\Pedro', + 'byte de controle \\200', + 'símbolo \\327', // × não é letra + 'barra literal \\\\351', + 'octal baixo \\101', + ]; + for (const text of kept) assert.equal(unescapeSshText(text), text, text); +}); + +test('mensagem do agente SSH (ssh-add) também volta a ter acentos', () => { + assert.deepEqual( + parseAgentList(2, '', 'Error connecting to agent: O sistema n\\343o pode encontrar o arquivo especificado.\r\n'), + { available: false, message: 'Error connecting to agent: O sistema n\u00e3o pode encontrar o arquivo especificado.' }, + ); +}); diff --git a/test/terminals.test.ts b/test/terminals.test.ts index ae78985..236e6a1 100644 --- a/test/terminals.test.ts +++ b/test/terminals.test.ts @@ -58,3 +58,13 @@ test('moveTerminal passa o processo para outra janela sem reiniciá-lo', async ( closeTerminalsOf(a.wc); assert.equal(terminalRunning(terminalKey(b.wc, 7)), true); }); + +test('transform ajusta a saída antes da aba e do agente (acentos escapados pelo ssh)', async (t) => { + const w = fakeWindow(903); + t.after(() => closeTerminalsOf(w.wc)); + const transform = (s: string) => s.replaceAll('\\343', 'ã'); + openTerminal(w.wc, 1, 'cmd.exe', ['/q', '/k', 'echo n\\343o'], { cols: 80, rows: 24, cwd: process.cwd() }, transform); + await until(() => dataOf(w.sent, 1).includes('não')); + assert.doesNotMatch(dataOf(w.sent, 1), /\\343/); + assert.match(terminalOutput(terminalKey(w.wc, 1))!.tail(5), /não/); +}); From 53442d7a2bb70f459ec6856de7aa6a4d7ab9220a Mon Sep 17 00:00:00 2001 From: acacio90 <49293808+acacio90@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:55:35 -0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(terminal):=20escape=20do=20ssh=20cortad?= =?UTF-8?q?o=20entre=20dois=20peda=C3=A7os=20da=20sa=C3=ADda=20tamb=C3=A9m?= =?UTF-8?q?=20vira=20acento?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A saída do pty chega em pedaços: o fim que pode continuar no próximo fica guardado e sai junto com ele, ou após 20 ms sem saída, ou no fim do processo. --- src/desktop/main.ts | 4 ++-- src/desktop/terminals.ts | 29 +++++++++++++++++++++++++---- src/sessions/ssh-text.ts | 27 +++++++++++++++++++++++++++ test/ssh-text.test.ts | 24 +++++++++++++++++++++++- test/terminals.test.ts | 29 ++++++++++++++++++++++++++--- 5 files changed, 103 insertions(+), 10 deletions(-) diff --git a/src/desktop/main.ts b/src/desktop/main.ts index 37dcd6d..d2f623d 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -32,7 +32,7 @@ import { localShellCommand, type LocalShell } from './local-terminal.ts'; import { wintunDllPath } from './native-vpn.ts'; import { HELPER_FLAG, runVpnHelper } from '../vpn/helper.ts'; import { readImportFile } from '../sessions/import.ts'; -import { unescapeSshText } from '../sessions/ssh-text.ts'; +import { sshTextStream } from '../sessions/ssh-text.ts'; import { exportFileName, loadSessionDefaults, loadSessionFile, mergeSessions, saveSessionFile, sessionsInFolder, validateDefaults, validateSessions, withDefaults, } from '../sessions/store.ts'; @@ -301,7 +301,7 @@ function registerIpc(): void { sshAlgorithms: session.legacySsh ? await sshAlgorithms() : undefined, }); const size = (n: unknown, fallback: number) => (Number.isInteger(n) && (n as number) > 0 ? (n as number) : fallback); - openTerminal(e.sender, id, file, args, { cols: size(cols, 80), rows: size(rows, 24), cwd: homedir() }, unescapeSshText); + openTerminal(e.sender, id, file, args, { cols: size(cols, 80), rows: size(rows, 24), cwd: homedir() }, sshTextStream()); // Avisos vão para a faixa da aba: o ConPTY limpa a tela ao iniciar e apagaria texto escrito antes. return { warnings }; }); diff --git a/src/desktop/terminals.ts b/src/desktop/terminals.ts index 167a0dc..32fe2e2 100644 --- a/src/desktop/terminals.ts +++ b/src/desktop/terminals.ts @@ -26,6 +26,19 @@ const terminals = new Map(); const keyOf = (owner: WebContents, id: number) => `${owner.id}:${id}`; +/** + * Ajuste da saída antes de ir para a aba e para o agente (ex.: acentos escapados pelo ssh). Pode + * guardar o fim de um pedaço para juntar com o próximo; o que ficou guardado sai no `flush`. + */ +export interface OutputFilter { + push(chunk: string): string; + flush(): string; + readonly pending: boolean; +} + +// O que o filtro guardou sai se nada mais vier logo (ex.: uma "\" digitada no shell remoto). +const FILTER_WAIT_MS = 20; + function validId(id: unknown): id is number { return Number.isInteger(id) && (id as number) > 0; } @@ -44,8 +57,7 @@ export function openTerminal( file: string, args: string[], size: { cols: number; rows: number; cwd: string }, - /** Ajuste da saída antes de ir para a aba e para o agente (ex.: acentos escapados pelo ssh). */ - transform?: (data: string) => string, + filter?: OutputFilter, ): void { if (!validId(id)) throw new Error('id de terminal inválido'); const key = keyOf(owner, id); @@ -61,14 +73,23 @@ export function openTerminal( terminals.set(key, entry); if (!outputs.has(key)) outputs.set(key, new OutputBuffer()); // Dono e id lidos na hora: a aba pode ter mudado de janela. - proc.onData((raw) => { - const data = transform ? transform(raw) : raw; + const deliver = (data: string) => { + if (!data) return; const k = keyOf(entry.owner, entry.id); outputs.get(k)?.push(data); for (const l of dataListeners) l(k); if (!entry.owner.isDestroyed()) entry.owner.send('term:data', entry.id, data); + }; + let held: NodeJS.Timeout | undefined; + proc.onData((raw) => { + if (!filter) return deliver(raw); + clearTimeout(held); + deliver(filter.push(raw)); + if (filter.pending) held = setTimeout(() => deliver(filter.flush()), FILTER_WAIT_MS); }); proc.onExit(({ exitCode }) => { + clearTimeout(held); + if (filter) deliver(filter.flush()); const k = keyOf(entry.owner, entry.id); if (terminals.get(k) === entry) terminals.delete(k); if (!entry.owner.isDestroyed()) entry.owner.send('term:exit', entry.id, exitCode); diff --git a/src/sessions/ssh-text.ts b/src/sessions/ssh-text.ts index e1566a0..c5e9754 100644 --- a/src/sessions/ssh-text.ts +++ b/src/sessions/ssh-text.ts @@ -10,6 +10,9 @@ const cp1252 = new TextDecoder('windows-1252'); // Sequências de bytes altos (\200-\377) que não vêm depois de outra barra ("\\351" é uma barra literal). const ESCAPES = /(? { @@ -29,6 +29,28 @@ test('o que não é acento fica como está', () => { for (const text of kept) assert.equal(unescapeSshText(text), text, text); }); +test('escape cortado entre dois pedaços da saída também vira acento', () => { + const message = 'Este host n\\343o \\351 conhecido. caf\\303\\251\r\n'; + // Todo ponto de corte possível, inclusive no meio de "\343" e entre os bytes UTF-8 de "é". + for (let i = 1; i < message.length; i++) { + const stream = sshTextStream(); + const out = stream.push(message.slice(0, i)) + stream.push(message.slice(i)) + stream.flush(); + assert.equal(out, 'Este host n\u00e3o \u00e9 conhecido. caf\u00e9\r\n', `corte em ${i}`); + assert.equal(stream.pending, false); + } +}); + +test('o stream só guarda o que pode continuar no próximo pedaço', () => { + const stream = sshTextStream(); + assert.equal(stream.push('texto comum\r\n'), 'texto comum\r\n'); + assert.equal(stream.pending, false); + assert.equal(stream.push('C:\\Users\\'), 'C:\\Users'); + assert.equal(stream.pending, true); + assert.equal(stream.flush(), '\\'); + assert.equal(stream.push('barra literal \\\\'), 'barra literal \\\\'); + assert.equal(stream.pending, false); +}); + test('mensagem do agente SSH (ssh-add) também volta a ter acentos', () => { assert.deepEqual( parseAgentList(2, '', 'Error connecting to agent: O sistema n\\343o pode encontrar o arquivo especificado.\r\n'), diff --git a/test/terminals.test.ts b/test/terminals.test.ts index 236e6a1..0c6ea83 100644 --- a/test/terminals.test.ts +++ b/test/terminals.test.ts @@ -4,6 +4,7 @@ import type { WebContents } from 'electron'; import { closeTerminalsOf, moveTerminal, onTerminalMoved, openTerminal, terminalKey, terminalOutput, terminalRunning, writeTerminal, } from '../src/desktop/terminals.ts'; +import { sshTextStream } from '../src/sessions/ssh-text.ts'; /** Janela falsa: só o que terminals.ts usa. */ function fakeWindow(id: number) { @@ -59,12 +60,34 @@ test('moveTerminal passa o processo para outra janela sem reiniciá-lo', async ( assert.equal(terminalRunning(terminalKey(b.wc, 7)), true); }); -test('transform ajusta a saída antes da aba e do agente (acentos escapados pelo ssh)', async (t) => { +test('filtro ajusta a saída antes da aba e do agente (acentos escapados pelo ssh)', async (t) => { const w = fakeWindow(903); t.after(() => closeTerminalsOf(w.wc)); - const transform = (s: string) => s.replaceAll('\\343', 'ã'); - openTerminal(w.wc, 1, 'cmd.exe', ['/q', '/k', 'echo n\\343o'], { cols: 80, rows: 24, cwd: process.cwd() }, transform); + openTerminal(w.wc, 1, 'cmd.exe', ['/q', '/k', 'echo n\\343o'], { cols: 80, rows: 24, cwd: process.cwd() }, sshTextStream()); await until(() => dataOf(w.sent, 1).includes('não')); assert.doesNotMatch(dataOf(w.sent, 1), /\\343/); assert.match(terminalOutput(terminalKey(w.wc, 1))!.tail(5), /não/); }); + +test('o que o filtro guarda sai sozinho se nada mais vier', async (t) => { + const w = fakeWindow(904); + t.after(() => closeTerminalsOf(w.wc)); + // Guarda tudo: a saída só chega pelo flush do prazo. + let held = ''; + const filter = { + push(chunk: string) { + held += chunk; + return ''; + }, + flush() { + const text = held; + held = ''; + return text; + }, + get pending() { + return held !== ''; + }, + }; + openTerminal(w.wc, 1, 'cmd.exe', ['/q', '/k', 'echo guardado'], { cols: 80, rows: 24, cwd: process.cwd() }, filter); + await until(() => dataOf(w.sent, 1).includes('guardado')); +});