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..d2f623d 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 { sshTextStream } 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() }, 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 3c76663..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,6 +57,7 @@ export function openTerminal( file: string, args: string[], size: { cols: number; rows: number; cwd: string }, + filter?: OutputFilter, ): void { if (!validId(id)) throw new Error('id de terminal inválido'); const key = keyOf(owner, id); @@ -59,13 +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((data) => { + 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/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..c5e9754 --- /dev/null +++ b/src/sessions/ssh-text.ts @@ -0,0 +1,53 @@ +/* + * 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; + }); +} + +/** + * Para a saída do terminal, que chega em pedaços: o fim de um pedaço que pode continuar no próximo + * fica guardado e sai junto com ele (ou no `flush`), para um "\343" cortado ao meio não escapar. + */ +export function sshTextStream(): { push(chunk: string): string; flush(): string; readonly pending: boolean } { + let held = ''; + return { + push(chunk) { + const text = held + chunk; + // Sem casamento quando o texto termina numa barra literal ("\\"): nada a guardar. + held = TAIL.exec(text)?.[0] ?? ''; + return unescapeSshText(text.slice(0, text.length - held.length)); + }, + flush() { + const text = held; + held = ''; + return unescapeSshText(text); + }, + get pending() { + return held !== ''; + }, + }; +} diff --git a/test/ssh-text.test.ts b/test/ssh-text.test.ts new file mode 100644 index 0000000..60c666c --- /dev/null +++ b/test/ssh-text.test.ts @@ -0,0 +1,59 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { sshTextStream, 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('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'), + { 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..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) { @@ -58,3 +59,35 @@ test('moveTerminal passa o processo para outra janela sem reiniciá-lo', async ( closeTerminalsOf(a.wc); assert.equal(terminalRunning(terminalKey(b.wc, 7)), true); }); + +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)); + 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')); +});