Files
Netcatty/components/terminal/runtime/createTerminalSessionStarters.ts
T
bincxz a855323912 refactor: use xterm Decoration API for keyword highlighting
Replace ANSI escape sequence-based text processing with xterm.js's
Decoration API for keyword highlighting. This "lazy" approach only
processes visible viewport rows and uses debounced refresh, ensuring
zero impact on scrolling performance.

Key changes:
- New KeywordHighlighter class using IDecoration/IMarker APIs
- Decorations refresh on scroll, write, and resize events
- Skip highlighting in alternate buffer (vim, htop, etc.)
- Add configurable debounce delay (200ms default)
- Remove inline ANSI color injection from data stream

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 11:00:34 +08:00

657 lines
22 KiB
TypeScript

import type { FitAddon } from "@xterm/addon-fit";
import type { SerializeAddon } from "@xterm/addon-serialize";
import type { Terminal as XTerm } from "@xterm/xterm";
import type { Dispatch, RefObject, SetStateAction } from "react";
import { logger } from "../../../lib/logger";
import type { Host, Identity, SerialConfig, SSHKey, TerminalSession, TerminalSettings } from "../../../types";
import { resolveHostAuth } from "../../../domain/sshAuth";
type TerminalBackendApi = {
backendAvailable: () => boolean;
telnetAvailable: () => boolean;
moshAvailable: () => boolean;
localAvailable: () => boolean;
serialAvailable: () => boolean;
execAvailable: () => boolean;
startSSHSession: (options: NetcattySSHOptions) => Promise<string>;
startTelnetSession: (
options: Parameters<NonNullable<NetcattyBridge["startTelnetSession"]>>[0],
) => Promise<string>;
startMoshSession: (
options: Parameters<NonNullable<NetcattyBridge["startMoshSession"]>>[0],
) => Promise<string>;
startLocalSession: (
options: Parameters<NonNullable<NetcattyBridge["startLocalSession"]>>[0],
) => Promise<string>;
startSerialSession: (
options: Parameters<NonNullable<NetcattyBridge["startSerialSession"]>>[0],
) => Promise<string>;
execCommand: (options: Parameters<NetcattyBridge["execCommand"]>[0]) => Promise<{
stdout?: string;
stderr?: string;
}>;
onSessionData: (sessionId: string, cb: (data: string) => void) => () => void;
onSessionExit: (
sessionId: string,
cb: (evt: { exitCode?: number; signal?: number }) => void,
) => () => void;
onChainProgress: (
cb: (hop: number, total: number, label: string, status: string) => void,
) => (() => void) | undefined;
writeToSession: (sessionId: string, data: string) => void;
resizeSession: (sessionId: string, cols: number, rows: number) => void;
};
export type PendingAuth = {
authMethod: "password" | "key" | "certificate";
username: string;
password?: string;
keyId?: string;
passphrase?: string;
} | null;
type ChainProgressState = {
currentHop: number;
totalHops: number;
currentHostLabel: string;
} | null;
export type TerminalSessionStartersContext = {
host: Host;
keys: SSHKey[];
identities?: Identity[];
resolvedChainHosts: Host[];
sessionId: string;
startupCommand?: string;
terminalSettings?: TerminalSettings;
terminalBackend: TerminalBackendApi;
serialConfig?: SerialConfig;
sessionRef: RefObject<string | null>;
hasConnectedRef: RefObject<boolean>;
hasRunStartupCommandRef: RefObject<boolean>;
disposeDataRef: RefObject<(() => void) | null>;
disposeExitRef: RefObject<(() => void) | null>;
fitAddonRef: RefObject<FitAddon | null>;
serializeAddonRef: RefObject<SerializeAddon | null>;
pendingAuthRef: RefObject<PendingAuth>;
updateStatus: (next: TerminalSession["status"]) => void;
setStatus: Dispatch<SetStateAction<TerminalSession["status"]>>;
setError: Dispatch<SetStateAction<string | null>>;
setNeedsAuth: Dispatch<SetStateAction<boolean>>;
setAuthRetryMessage: Dispatch<SetStateAction<string | null>>;
setAuthPassword: Dispatch<SetStateAction<string>>;
setProgressLogs: Dispatch<SetStateAction<string[]>>;
setProgressValue: Dispatch<SetStateAction<number>>;
setChainProgress: Dispatch<SetStateAction<ChainProgressState>>;
onSessionExit?: (sessionId: string) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onOsDetected?: (hostId: string, distro: string) => void;
onCommandExecuted?: (
command: string,
hostId: string,
hostLabel: string,
sessionId: string,
) => void;
};
const buildTermEnv = (host: Host, terminalSettings?: TerminalSettings) => {
const env: Record<string, string> = {
TERM: terminalSettings?.terminalEmulationType ?? "xterm-256color",
};
if (host.environmentVariables) {
for (const { name, value } of host.environmentVariables) {
if (name) env[name] = value;
}
}
return env;
};
const attachSessionToTerminal = (
ctx: TerminalSessionStartersContext,
term: XTerm,
id: string,
opts?: {
onExitMessage?: (evt: { exitCode?: number; signal?: number }) => string;
onConnected?: () => void;
// For serial: convert lone LF to CRLF to avoid "staircase effect"
convertLfToCrlf?: boolean;
},
) => {
ctx.sessionRef.current = id;
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
let data = chunk;
// Convert lone LF (\n) to CRLF (\r\n) for proper terminal display
// This prevents the "staircase effect" common in serial terminals
if (opts?.convertLfToCrlf) {
// Replace \n that is not preceded by \r with \r\n
data = data.replace(/(?<!\r)\n/g, "\r\n");
}
term.write(data);
if (!ctx.hasConnectedRef.current) {
ctx.updateStatus("connected");
opts?.onConnected?.();
setTimeout(() => {
if (!ctx.fitAddonRef.current) return;
try {
ctx.fitAddonRef.current.fit();
if (ctx.sessionRef.current) {
ctx.terminalBackend.resizeSession(ctx.sessionRef.current, term.cols, term.rows);
}
} catch (err) {
logger.warn("Post-connect fit failed", err);
}
}, 100);
}
});
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, (evt) => {
ctx.updateStatus("disconnected");
term.writeln(opts?.onExitMessage?.(evt) ?? "\r\n[session closed]");
if (ctx.onTerminalDataCapture && ctx.serializeAddonRef.current) {
try {
const terminalData = ctx.serializeAddonRef.current.serialize();
ctx.onTerminalDataCapture(ctx.sessionId, terminalData);
} catch (err) {
logger.warn("Failed to serialize terminal data:", err);
}
}
ctx.onSessionExit?.(ctx.sessionId);
});
};
const runDistroDetection = async (
ctx: TerminalSessionStartersContext,
auth: { username: string; password?: string; key?: SSHKey },
) => {
if (!ctx.terminalBackend.execAvailable()) return;
try {
const res = await ctx.terminalBackend.execCommand({
hostname: ctx.host.hostname,
username: auth.username || "root",
port: ctx.host.port || 22,
password: auth.password,
privateKey: auth.key?.privateKey,
command: "cat /etc/os-release 2>/dev/null || uname -a",
timeout: 8000,
});
const data = `${res.stdout || ""}\n${res.stderr || ""}`;
const idMatch = data.match(/ID=([\\w\\-]+)/i);
const distro = idMatch
? idMatch[1].replace(/"/g, "")
: (data.split(/\s+/)[0] || "").toLowerCase();
if (distro) ctx.onOsDetected?.(ctx.host.id, distro);
} catch (err) {
logger.warn("OS probe failed", err);
}
};
export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContext) => {
const startSSH = async (term: XTerm) => {
try {
term.clear?.();
} catch (err) {
logger.warn("Failed to clear terminal before connect", err);
}
if (!ctx.terminalBackend.backendAvailable()) {
ctx.setError("Native SSH bridge unavailable. Launch via Electron app.");
term.writeln(
"\r\n[netcatty SSH bridge unavailable. Please run the desktop build to connect.]",
);
ctx.updateStatus("disconnected");
return;
}
const pendingAuth = ctx.pendingAuthRef.current;
const resolvedAuth = resolveHostAuth({
host: ctx.host,
keys: ctx.keys,
identities: ctx.identities,
override: pendingAuth
? {
authMethod: pendingAuth.authMethod,
username: pendingAuth.username,
password: pendingAuth.password,
keyId: pendingAuth.keyId,
passphrase: pendingAuth.passphrase,
}
: null,
});
const effectiveUsername = resolvedAuth.username || "root";
const effectivePassword = resolvedAuth.password;
const key = resolvedAuth.key;
const effectivePassphrase = resolvedAuth.passphrase;
let usedKey: SSHKey | undefined;
let usedPassword: string | undefined;
const isAuthError = (err: unknown): boolean => {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return (
msg.includes("authentication") ||
msg.includes("auth") ||
msg.includes("password") ||
msg.includes("permission denied")
);
};
const proxyConfig = ctx.host.proxyConfig
? {
type: ctx.host.proxyConfig.type,
host: ctx.host.proxyConfig.host,
port: ctx.host.proxyConfig.port,
username: ctx.host.proxyConfig.username,
password: ctx.host.proxyConfig.password,
}
: undefined;
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys: ctx.keys,
identities: ctx.identities,
});
const jumpKey = jumpAuth.key;
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpAuth.username || "root",
password: jumpAuth.password,
privateKey: jumpKey?.privateKey,
certificate: jumpKey?.certificate,
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
publicKey: jumpKey?.publicKey,
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
};
});
const totalHops = jumpHosts.length + 1;
let unsubscribeChainProgress: (() => void) | undefined;
if (jumpHosts.length > 0) {
ctx.setChainProgress({
currentHop: 1,
totalHops,
currentHostLabel:
jumpHosts[0]?.label || jumpHosts[0]?.hostname || ctx.host.hostname,
});
ctx.setProgressLogs((prev) => [
...prev,
`Starting chain connection (${totalHops} hops)...`,
]);
const unsub = ctx.terminalBackend.onChainProgress((hop, total, label, status) => {
ctx.setChainProgress({
currentHop: hop,
totalHops: total,
currentHostLabel: label,
});
ctx.setProgressLogs((prev) => [
...prev,
`Chain ${hop} of ${total}: ${label} - ${status}`,
]);
const hopProgress = (hop / total) * 80 + 10;
ctx.setProgressValue(Math.min(95, hopProgress));
});
if (unsub) unsubscribeChainProgress = unsub;
}
try {
const termEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
// DEBUG: Log key info for troubleshooting
console.log("[Terminal] Starting SSH session with key info:", {
keyId: key?.id,
keyLabel: key?.label,
keySource: key?.source,
hasPublicKey: !!key?.publicKey,
hasPrivateKey: !!key?.privateKey,
});
const startAttempt = async (attempt: {
password?: string;
key?: SSHKey;
}): Promise<string> => {
return ctx.terminalBackend.startSSHSession({
sessionId: ctx.sessionId,
hostname: ctx.host.hostname,
username: effectiveUsername,
port: ctx.host.port || 22,
password: attempt.password,
privateKey: attempt.key?.privateKey,
certificate: attempt.key?.certificate,
publicKey: attempt.key?.publicKey,
keyId: attempt.key?.id,
keySource: attempt.key?.source,
passphrase: attempt.key ? (effectivePassphrase || attempt.key.passphrase) : undefined,
agentForwarding: ctx.host.agentForwarding,
cols: term.cols,
rows: term.rows,
charset: ctx.host.charset,
env: termEnv,
proxy: proxyConfig,
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
keepaliveInterval: ctx.terminalSettings?.keepaliveInterval,
});
};
let id: string;
// Respect explicit auth method selection - don't use key if password auth was explicitly selected
const authMethod = resolvedAuth.authMethod;
const hasKeyMaterial = !!key?.privateKey && authMethod !== 'password';
const hasPassword = !!effectivePassword;
if (hasKeyMaterial) {
try {
id = await startAttempt({ key });
usedKey = key;
} catch (err) {
if (isAuthError(err) && hasPassword) {
ctx.setProgressLogs((prev) => [
...prev,
"Key auth failed. Trying password...",
]);
id = await startAttempt({ password: effectivePassword });
usedPassword = effectivePassword;
} else {
throw err;
}
}
} else {
id = await startAttempt({ password: effectivePassword });
usedPassword = effectivePassword;
}
if (unsubscribeChainProgress) unsubscribeChainProgress();
attachSessionToTerminal(ctx, term, id, {
onConnected: () => ctx.setChainProgress(null),
onExitMessage: (evt) =>
`\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
});
const commandToRun = ctx.startupCommand || ctx.host.startupCommand;
if (commandToRun && !ctx.hasRunStartupCommandRef.current) {
ctx.hasRunStartupCommandRef.current = true;
setTimeout(() => {
if (!ctx.sessionRef.current) return;
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}\r`);
if (ctx.onCommandExecuted) {
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);
}
}, 600);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const authError = isAuthError(err);
if (authError) {
ctx.setError(null);
ctx.setNeedsAuth(true);
ctx.setAuthRetryMessage(
"Authentication failed. Please check your credentials and try again.",
);
ctx.setAuthPassword("");
ctx.setProgressLogs((prev) => [
...prev,
"Authentication failed. Please try again.",
]);
ctx.setStatus("connecting");
} else {
ctx.setError(message);
term.writeln(`\r\n[Failed to start SSH: ${message}]`);
ctx.updateStatus("disconnected");
}
ctx.setChainProgress(null);
if (unsubscribeChainProgress) unsubscribeChainProgress();
}
setTimeout(
() =>
void runDistroDetection(ctx, {
username: effectiveUsername,
password: usedPassword,
key: usedKey,
}),
600,
);
};
const startTelnet = async (term: XTerm) => {
try {
term.clear?.();
} catch (err) {
logger.warn("Failed to clear terminal before connect", err);
}
if (!ctx.terminalBackend.telnetAvailable()) {
ctx.setError("Telnet bridge unavailable. Please run the desktop build.");
term.writeln("\r\n[Telnet bridge unavailable. Please run the desktop build.]");
ctx.updateStatus("disconnected");
return;
}
try {
const telnetEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
const id = await ctx.terminalBackend.startTelnetSession({
sessionId: ctx.sessionId,
hostname: ctx.host.hostname,
port: ctx.host.telnetPort || ctx.host.port || 23,
cols: term.cols,
rows: term.rows,
charset: ctx.host.charset,
env: telnetEnv,
});
attachSessionToTerminal(ctx, term, id, {
onExitMessage: (evt) =>
`\r\n[Telnet session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);
term.writeln(`\r\n[Failed to start Telnet: ${message}]`);
ctx.updateStatus("disconnected");
}
};
const startMosh = async (term: XTerm) => {
try {
term.clear?.();
} catch (err) {
logger.warn("Failed to clear terminal before connect", err);
}
if (!ctx.terminalBackend.moshAvailable()) {
ctx.setError("Mosh bridge unavailable. Please run the desktop build.");
term.writeln("\r\n[Mosh bridge unavailable. Please run the desktop build.]");
ctx.updateStatus("disconnected");
return;
}
try {
const moshEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
const id = await ctx.terminalBackend.startMoshSession({
sessionId: ctx.sessionId,
hostname: ctx.host.hostname,
username: ctx.host.username || "root",
port: ctx.host.port || 22,
moshServerPath: ctx.host.moshServerPath,
agentForwarding: ctx.host.agentForwarding,
cols: term.cols,
rows: term.rows,
charset: ctx.host.charset,
env: moshEnv,
});
attachSessionToTerminal(ctx, term, id, {
onExitMessage: (evt) =>
`\r\n[Mosh session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
});
const commandToRun = ctx.startupCommand || ctx.host.startupCommand;
if (commandToRun && !ctx.hasRunStartupCommandRef.current) {
ctx.hasRunStartupCommandRef.current = true;
setTimeout(() => {
if (!ctx.sessionRef.current) return;
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}\r`);
if (ctx.onCommandExecuted) {
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);
}
}, 600);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);
term.writeln(`\r\n[Failed to start Mosh: ${message}]`);
ctx.updateStatus("disconnected");
}
};
const startLocal = async (term: XTerm) => {
try {
term.clear?.();
} catch (err) {
logger.warn("Failed to clear terminal before connect", err);
}
if (!ctx.terminalBackend.localAvailable()) {
ctx.setError("Local shell bridge unavailable. Please run the desktop build.");
term.writeln(
"\r\n[Local shell bridge unavailable. Please run the desktop build to spawn a local terminal.]",
);
ctx.updateStatus("disconnected");
return;
}
try {
// Get local shell configuration from terminal settings
const localShell = ctx.terminalSettings?.localShell;
const localStartDir = ctx.terminalSettings?.localStartDir;
const id = await ctx.terminalBackend.startLocalSession({
sessionId: ctx.sessionId,
cols: term.cols,
rows: term.rows,
shell: localShell,
cwd: localStartDir,
env: {
TERM: ctx.terminalSettings?.terminalEmulationType ?? "xterm-256color",
},
});
ctx.sessionRef.current = id;
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
term.write(chunk);
if (!ctx.hasConnectedRef.current) {
ctx.updateStatus("connected");
setTimeout(() => {
if (!ctx.fitAddonRef.current) return;
try {
ctx.fitAddonRef.current.fit();
if (ctx.sessionRef.current) {
ctx.terminalBackend.resizeSession(ctx.sessionRef.current, term.cols, term.rows);
}
} catch (err) {
logger.warn("Post-connect fit failed", err);
}
}, 100);
}
});
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, (evt) => {
ctx.updateStatus("disconnected");
term.writeln(
`\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
);
logger.info("[Terminal] Session exit, capturing data", {
sessionId: ctx.sessionId,
hasCallback: !!ctx.onTerminalDataCapture,
hasSerializeAddon: !!ctx.serializeAddonRef.current,
});
if (ctx.onTerminalDataCapture && ctx.serializeAddonRef.current) {
try {
const terminalData = ctx.serializeAddonRef.current.serialize();
logger.info("[Terminal] Serialized terminal data", {
sessionId: ctx.sessionId,
dataLength: terminalData.length,
});
ctx.onTerminalDataCapture(ctx.sessionId, terminalData);
} catch (err) {
logger.warn("Failed to serialize terminal data:", err);
}
}
ctx.onSessionExit?.(ctx.sessionId);
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);
term.writeln(`\r\n[Failed to start local shell: ${message}]`);
ctx.updateStatus("disconnected");
}
};
// Start Serial session
const startSerial = async (term: XTerm) => {
if (!ctx.serialConfig) {
ctx.setError("No serial configuration provided");
term.writeln("\r\n[Error: No serial configuration provided]");
ctx.updateStatus("disconnected");
return;
}
try {
logger.info("[Serial] Starting serial session", {
port: ctx.serialConfig.path,
baudRate: ctx.serialConfig.baudRate,
});
const id = await ctx.terminalBackend.startSerialSession({
sessionId: ctx.sessionId,
path: ctx.serialConfig.path,
baudRate: ctx.serialConfig.baudRate,
dataBits: ctx.serialConfig.dataBits,
stopBits: ctx.serialConfig.stopBits,
parity: ctx.serialConfig.parity,
flowControl: ctx.serialConfig.flowControl,
});
// Serial connection is established immediately when session starts
// Update status right away since serial ports don't require handshake
ctx.updateStatus("connected");
ctx.setProgressValue(100);
term.writeln(`[Connected to ${ctx.serialConfig.path} at ${ctx.serialConfig.baudRate} baud]`);
attachSessionToTerminal(ctx, term, id, {
onExitMessage: (evt) =>
`\r\n[serial port closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
// Convert lone LF to CRLF to prevent "staircase effect" in serial terminals
convertLfToCrlf: true,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);
term.writeln(`\r\n[Failed to connect to serial port: ${message}]`);
ctx.updateStatus("disconnected");
}
};
return { startSSH, startTelnet, startMosh, startLocal, startSerial };
};