electron/main/terminal.ts
Rama main · réplica de lectura del repositorio original.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { ipcMain, type WebContents } from 'electron';
import log from 'electron-log';
import type { IPty } from 'node-pty';
import fs from 'node:fs';
import os from 'node:os';
import kill from 'tree-kill';
import { TerminalProcessTree } from './terminalProcessTree';
/**
* Interactive shell sessions for the session page's terminal tabs. Each
* session is a real PTY running the user's default shell, keyed by an id the
* renderer generates. Sessions outlive the xterm UI (tab switches unmount the
* renderer side); they die on explicit dispose or app quit.
*/
interface TerminalCreateResult {
success: boolean;
existing?: boolean;
error?: string;
}
interface TerminalSession {
/**
* The renderer currently attached to this shell. This must remain mutable:
* restored tabs reuse their persisted shell id from a new WebContents.
*/
sender: WebContents;
/** Set after node-pty loads and the synchronous spawn completes. */
pty: IPty | null;
/** Prevent a pending lazy load from spawning after disposal/app shutdown. */
disposed: boolean;
/** Shared by concurrent creates so only one PTY can be spawned per id. */
creation: Promise<TerminalCreateResult>;
stop?: Promise<{ success: boolean; error?: string }>;
disposal?: Promise<{ success: boolean; error?: string }>;
stopRequested?: boolean;
processTree?: TerminalProcessTree;
exitCode?: number;
}
const sessions = new Map<string, TerminalSession>();
let shuttingDown = false;
function publishTerminalExit(id: string, session: TerminalSession) {
if (sessions.get(id) !== session || session.exitCode === undefined) return;
sessions.delete(id);
if (!session.disposed && !session.sender.isDestroyed()) {
session.sender.send('terminal-exit', { id, exitCode: session.exitCode });
}
}
/**
* node-pty is a native module; load it lazily so a missing/broken binary
* degrades to an error in the terminal tab instead of crashing main startup.
*/
async function loadNodePty(): Promise<typeof import('node-pty') | null> {
try {
return await import('node-pty');
} catch (error) {
log.error('[TERMINAL] Failed to load node-pty:', error);
return null;
}
}
function defaultShell(): { file: string; args: string[] } {
if (process.platform === 'win32') {
return { file: 'powershell.exe', args: [] };
}
const shell = process.env.SHELL || '/bin/zsh';
// Login shell so the user's profile (PATH, prompt, aliases) is loaded —
// the tab should feel exactly like opening the desktop terminal.
return { file: shell, args: ['-l'] };
}
/**
* Do not leak credentials held by the Electron main process into an
* interactive shell where a plain `env` would print them. Keep ordinary
* desktop environment variables so the shell still inherits PATH, locale,
* proxy configuration, and toolchain settings.
*/
const SENSITIVE_ENV_NAME =
/(?:^|_)(?:API_KEY|TOKEN|PASSWORD|PASSWD|SECRET|PRIVATE_KEY|CAPABILITY)(?:$|_)/i;
export function terminalEnvironment(
environment: NodeJS.ProcessEnv = process.env
): Record<string, string> {
return Object.fromEntries(
Object.entries(environment).filter(
(entry): entry is [string, string] =>
entry[1] !== undefined && !SENSITIVE_ENV_NAME.test(entry[0])
)
);
}
export interface TerminalCreateOptions {
id: string;
cwd?: string;
cols?: number;
rows?: number;
}
async function createTerminalSession(
session: TerminalSession,
options: TerminalCreateOptions
): Promise<TerminalCreateResult> {
const { id, cwd, cols, rows } = options;
const pty = await loadNodePty();
if (!pty) {
if (sessions.get(id) === session) sessions.delete(id);
return { success: false, error: 'Terminal backend unavailable' };
}
// terminal-dispose and app shutdown may run while node-pty is loading.
if (session.disposed || sessions.get(id) !== session) {
return { success: false, error: 'Terminal creation was cancelled' };
}
const { file, args } = defaultShell();
const workingDir = cwd && fs.existsSync(cwd) ? cwd : os.homedir();
try {
const terminal = pty.spawn(file, args, {
name: 'xterm-256color',
cols: cols || 80,
rows: rows || 24,
cwd: workingDir,
env: terminalEnvironment(),
});
session.pty = terminal;
if (process.platform !== 'win32') {
try {
session.processTree = new TerminalProcessTree(terminal.pid);
} catch {
// Shell use remains available, but Stop must fail closed if ownership
// could not be established at spawn (never guess from a later PID).
log.warn('[TERMINAL] Could not capture shell process identity');
}
}
terminal.onData((data) => {
if (session.disposed || sessions.get(id) !== session) return;
const sender = session.sender;
if (!sender.isDestroyed()) {
sender.send('terminal-data', { id, data });
}
});
terminal.onExit(({ exitCode }) => {
session.exitCode = exitCode;
// A disposed/restarted PTY can report its exit after a replacement with
// the same id is already live. Do not mark that replacement as exited.
if (sessions.get(id) !== session) return;
// Stop owns completion until the captured descendants have also exited.
// Keep failed cleanup addressable even if the PTY parent is gone.
if (!session.stopRequested) publishTerminalExit(id, session);
});
log.info(`[TERMINAL] Created session ${id} (${file}) in ${workingDir}`);
return { success: true };
} catch (error) {
if (sessions.get(id) === session) sessions.delete(id);
log.error(`[TERMINAL] Failed to spawn shell for ${id}:`, error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to spawn shell',
};
}
}
async function stopTerminalSession(id: string, session: TerminalSession) {
if (session.stop) return session.stop;
session.stopRequested = true;
const stopping = (async () => {
await session.creation;
if (sessions.get(id) !== session || !session.pty) return { success: true };
const terminal = session.pty;
const deadline = Date.now() + 5000;
// Disposal does not need the interactive Stop grace period. An ongoing
// Stop also observes disposed on its next poll and escalates immediately.
let forced = session.disposed;
try {
if (process.platform === 'win32') {
// tree-kill already uses taskkill /T /F on Windows. Wait for that
// tree operation, not only the PTY exit, and never retry a dead PID.
if (session.exitCode !== undefined) throw new Error('Parent exited');
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error('Stop timed out')),
5000
);
kill(terminal.pid, 'SIGTERM', (error) => {
clearTimeout(timer);
if (error) reject(error);
else resolve();
});
});
} else {
if (!session.processTree) throw new Error('Missing process identity');
await session.processTree.signal(forced ? 'SIGKILL' : 'SIGTERM');
}
const forceAt = Date.now() + 1500;
while (Date.now() < deadline) {
const running = session.processTree
? await session.processTree.isRunning()
: session.exitCode === undefined;
if (!running && session.exitCode !== undefined) {
publishTerminalExit(id, session);
return { success: true };
}
if (!forced && (session.disposed || Date.now() >= forceAt)) {
forced = true;
await session.processTree?.signal('SIGKILL');
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
return {
success: false,
error: 'Terminal did not exit. Try stopping it again.',
};
} catch {
return { success: false, error: 'Could not stop terminal. Try again.' };
}
})();
session.stop = stopping;
const result = await stopping;
if (!result.success && session.stop === stopping) session.stop = undefined;
return result;
}
async function disposeTerminalSession(id: string, session: TerminalSession) {
if (session.disposal) return session.disposal;
session.disposed = true;
const pendingStop = session.stop;
const disposal = (async () => {
let result = await stopTerminalSession(id, session);
// A Stop already in flight may fail while disposal is joining it. Retry
// once through the same captured identities; never fall back to pty.kill.
if (!result.success && pendingStop)
result = await stopTerminalSession(id, session);
if (result.success) {
if (sessions.get(id) === session) sessions.delete(id);
} else {
// Retain ownership for another dispose or app-quit cleanup attempt.
log.warn(`[TERMINAL] Cleanup failed for ${id}: ${result.error}`);
}
return result;
})();
session.disposal = disposal;
const result = await disposal;
if (!result.success && session.disposal === disposal)
session.disposal = undefined;
return result;
}
export function registerTerminalIpcHandlers() {
ipcMain.handle(
'terminal-create',
async (event, options: TerminalCreateOptions) => {
const { id, cwd, cols, rows } = options ?? {};
if (!id) return { success: false, error: 'Missing terminal id' };
if (shuttingDown)
return { success: false, error: 'Terminal creation was cancelled' };
const existing = sessions.get(id);
if (existing) {
if (existing.disposed)
return { success: false, error: 'Terminal cleanup is incomplete' };
// A restored renderer becomes the owner of all subsequent output.
existing.sender = event.sender;
const result = await existing.creation;
return result.success ? { ...result, existing: true } : result;
}
// Reserve the id before the lazy node-pty import yields. React dev
// double-mounts and other concurrent callers now share this promise.
const session: TerminalSession = {
sender: event.sender,
pty: null,
disposed: false,
creation: Promise.resolve({
success: false,
error: 'Terminal creation has not started',
}),
};
sessions.set(id, session);
session.creation = createTerminalSession(session, {
id,
cwd,
cols,
rows,
});
return session.creation;
}
);
ipcMain.on(
'terminal-input',
(_event, payload: { id: string; data: string }) => {
const session = sessions.get(payload?.id);
if (!session?.disposed) session?.pty?.write(payload.data);
}
);
ipcMain.on(
'terminal-resize',
(_event, payload: { id: string; cols: number; rows: number }) => {
const session = sessions.get(payload?.id);
const terminal = session?.disposed ? null : session?.pty;
if (!terminal) return;
const cols = Math.max(2, Math.floor(payload.cols || 0));
const rows = Math.max(1, Math.floor(payload.rows || 0));
try {
terminal.resize(cols, rows);
} catch (error) {
log.warn(`[TERMINAL] Resize failed for ${payload.id}:`, error);
}
}
);
ipcMain.handle('terminal-stop', async (event, id: string) => {
const session = sessions.get(id);
if (!session) return { success: true };
if (session.sender !== event.sender)
return { success: false, error: 'Terminal owner mismatch' };
return stopTerminalSession(id, session);
});
ipcMain.handle('terminal-dispose', (_event, id: string) => {
const session = sessions.get(id);
if (!session) return { success: true };
return disposeTerminalSession(id, session);
});
}
/** Kill every live shell (app quit). */
export async function disposeAllTerminals() {
shuttingDown = true;
const results = await Promise.all(
[...sessions].map(([id, session]) => disposeTerminalSession(id, session))
);
return { success: results.every((result) => result.success) };
}