Skip to content
Open
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
9 changes: 9 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default defineConfig({
entry: resolve(__dirname, 'electron/main/index.ts'),
},
rollupOptions: {
external: ['electron', 'bufferutil', 'utf-8-validate'],
external: ['electron', 'bufferutil', 'utf-8-validate', 'node-pty'],
output: {
format: 'es',
entryFileNames: '[name].mjs',
Expand Down
23 changes: 23 additions & 0 deletions electron/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { getLogFilePath, getFileLogLevel, setFileLogLevel, loadSettings, saveSet
import { isStartupReady } from "./index";
import { channelManager } from "./index";
import { GATEWAY_PORT } from "../../shared/ports";
import { createTerminal, writeTerminal, resizeTerminal, destroyTerminal } from "./services/terminal-manager";

export function registerIpcHandlers(): void {
// ===========================================================================
Expand Down Expand Up @@ -335,6 +336,28 @@ export function registerIpcHandlers(): void {
ipcMain.handle("startup:isReady", async () => {
return isStartupReady();
});

// ===========================================================================
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per the architecture discussion — the terminal service should go through the Gateway WebSocket layer (as new request/notification types like terminal.create, terminal.write, terminal.data) rather than IPC.

Reasoning:

  • node-pty + xterm.js natively supports WebSocket transport (same as VS Code Server / code-server)
  • We already have Gateway infra with auth, reconnect, RPC
  • Going through Gateway gives us Web PTY support later for free
  • The IPC handlers, preload additions, and electron-api.ts wrapper all become unnecessary

The TerminalPanel frontend stays mostly the same — just swap terminalAPI.* calls for gateway.terminal*() methods.

// Terminal
// ===========================================================================

ipcMain.handle("terminal:create", async (event, cwd: string, cols: number, rows: number) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) throw new Error("No window found");
return createTerminal(win, cwd, cols, rows);
});

ipcMain.handle("terminal:write", async (_, id: string, data: string) => {
writeTerminal(id, data);
});

ipcMain.handle("terminal:resize", async (_, id: string, cols: number, rows: number) => {
resizeTerminal(id, cols, rows);
});

ipcMain.handle("terminal:destroy", async (_, id: string) => {
destroyTerminal(id);
});
}

// --- LAN IP helpers ---
Expand Down
85 changes: 85 additions & 0 deletions electron/main/services/terminal-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import * as pty from "node-pty";
import os from "os";
import { BrowserWindow } from "electron";

interface TerminalInstance {
pty: pty.IPty;
windowId: number;
}

const terminals = new Map<string, TerminalInstance>();
let idCounter = 0;

function getDefaultShell(): string {
if (process.platform === "win32") {
return process.env.COMSPEC || "powershell.exe";
}
return process.env.SHELL || "/bin/bash";
}

export function createTerminal(
window: BrowserWindow,
cwd: string,
cols: number,
rows: number,
): string {
const id = `term-${++idCounter}`;
const shell = getDefaultShell();

const env: Record<string, string> = { ...process.env } as Record<string, string>;
if (!env.LANG) env.LANG = "ru_RU.UTF-8";
if (!env.LC_CTYPE) env.LC_CTYPE = env.LANG;
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ru_RU.UTF-8 fallback looks like it leaked from your local env :) Should be en_US.UTF-8 as a safe default — or better, detect via app.getLocale() and map to a POSIX locale.

if (!env.LC_ALL) env.LC_ALL = env.LANG;

const ptyProcess = pty.spawn(shell, [], {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cwd param comes straight from the renderer and gets passed directly to pty.spawn() with no validation.

At minimum we need:

  • path.resolve() to normalize
  • fs.existsSync() + statSync().isDirectory() check
  • Maybe restrict to known project directories (the session already knows its directory)

This is especially important since CodeMux supports remote access — a compromised renderer could spawn shells in arbitrary directories.

name: "xterm-256color",
cols,
rows,
cwd,
env,
});

ptyProcess.onData((data) => {
if (window.isDestroyed()) return;
window.webContents.send("terminal:data", id, data);
});

ptyProcess.onExit(() => {
terminals.delete(id);
if (!window.isDestroyed()) {
window.webContents.send("terminal:exit", id);
}
});

terminals.set(id, { pty: ptyProcess, windowId: window.id });
return id;
}

export function writeTerminal(id: string, data: string): void {
const term = terminals.get(id);
if (term) {
term.pty.write(data);
}
}

export function resizeTerminal(id: string, cols: number, rows: number): void {
const term = terminals.get(id);
if (term) {
term.pty.resize(cols, rows);
}
}

export function destroyTerminal(id: string): void {
const term = terminals.get(id);
if (term) {
term.pty.kill();
terminals.delete(id);
}
}

export function destroyAllTerminals(): void {
for (const [id, term] of terminals) {
term.pty.kill();
terminals.delete(id);
}
}
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

destroyAllTerminals() is exported but never actually called anywhere. It needs to be wired into app.on("will-quit") in electron/main/index.ts, otherwise all PTY child processes become orphans when the app quits.

Also — there's no error handling around pty.spawn(). If the shell doesn't exist or cwd is invalid, this will throw uncaught.

20 changes: 20 additions & 0 deletions electron/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,26 @@ const electronAPI = {
isEnabled: () => ipcRenderer.invoke("autostart:isEnabled") as Promise<boolean>,
setEnabled: (enabled: boolean) => ipcRenderer.invoke("autostart:setEnabled", enabled),
},

// Terminal API
terminal: {
create: (cwd: string, cols: number, rows: number) =>
ipcRenderer.invoke("terminal:create", cwd, cols, rows) as Promise<string>,
write: (id: string, data: string) => ipcRenderer.invoke("terminal:write", id, data),
resize: (id: string, cols: number, rows: number) =>
ipcRenderer.invoke("terminal:resize", id, cols, rows),
destroy: (id: string) => ipcRenderer.invoke("terminal:destroy", id),
onData: (callback: (id: string, data: string) => void) => {
const handler = (_: any, id: string, data: string) => callback(id, data);
ipcRenderer.on("terminal:data", handler);
return () => { ipcRenderer.removeListener("terminal:data", handler); };
},
onExit: (callback: (id: string) => void) => {
const handler = (_: any, id: string) => callback(id);
ipcRenderer.on("terminal:exit", handler);
return () => { ipcRenderer.removeListener("terminal:exit", handler); };
},
},
};

contextBridge.exposeInMainWorld("electronAPI", electronAPI);
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
"@solid-primitives/i18n": "^2.2.1",
"@solidjs/router": "^0.14.10",
"@tanstack/solid-virtual": "^3.13.19",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"diff": "^5.1.0",
"electron-log": "^5.4.3",
"electron-updater": "^6.8.3",
Expand All @@ -54,6 +56,7 @@
"marked": "^11.1.0",
"marked-shiki": "^1.1.2",
"material-icon-theme": "^5.32.0",
"node-pty": "^1.1.0",
"shiki": "^1.22.0",
"solid-js": "^1.8.0",
"vscode-languageserver-types": "^3.17.5",
Expand Down
Loading
Loading