Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion src/desktop/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
});
Expand Down
26 changes: 25 additions & 1 deletion src/desktop/terminals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ const terminals = new Map<string, Terminal>();

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;
}
Expand All @@ -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);
Expand All @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/sessions/launch.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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' };
}

/**
Expand Down
53 changes: 53 additions & 0 deletions src/sessions/ssh-text.ts
Original file line number Diff line number Diff line change
@@ -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 = /(?<!\\)(?:\\[23][0-7]{2})+/g;
// Fim de pedaço que pode continuar no próximo: escapes completos (bytes de um mesmo caractere UTF-8,
// ou um acento seguido do resto da palavra) e um escape cortado ao meio ("\", "\3", "\34").
const TAIL = /(?<!\\)(?:\\[23][0-7]{2})*(?:\\(?:[23][0-7]?)?)?$/;

export function unescapeSshText(text: string): string {
if (!text.includes('\\')) return text;
return text.replace(ESCAPES, (run) => {
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 !== '';
},
};
}
59 changes: 59 additions & 0 deletions test/ssh-text.test.ts
Original file line number Diff line number Diff line change
@@ -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.' },
);
});
33 changes: 33 additions & 0 deletions test/terminals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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'));
});
Loading