Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6734b9ef9 | ||
|
|
fb443541aa | ||
|
|
7622c43c38 | ||
|
|
a4a5c703b1 | ||
|
|
2063a5ccfe | ||
|
|
1fcf77ef4d | ||
|
|
8296c2c780 | ||
|
|
d1e6857f76 | ||
|
|
eccb9f2cfc | ||
|
|
74d56cdcb8 | ||
|
|
cd04b0b33c | ||
|
|
a29953f831 | ||
|
|
c941038e68 | ||
|
|
b1ab4d7105 | ||
|
|
08e566adb0 | ||
|
|
df25d6c4b0 | ||
|
|
324301e61a | ||
|
|
2c3a8e7fb8 | ||
|
|
bd2642be74 | ||
|
|
23151c9db8 |
46
App.tsx
46
App.tsx
@@ -20,6 +20,7 @@ import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
|
||||
import { resolveSnippetsShortcutIntent } from './application/state/resolveSnippetsShortcutIntent';
|
||||
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from './application/state/customThemeStore';
|
||||
import type { SyncPayload } from './domain/sync';
|
||||
@@ -880,9 +881,26 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onCheckDirtyEditors) return;
|
||||
const unsub = bridge.onCheckDirtyEditors(() => {
|
||||
const hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
|
||||
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty);
|
||||
// Always report SOMETHING so the main process doesn't time out for
|
||||
// 5 s on an unhandled exception. If we can't determine the state,
|
||||
// fail open — losing unsaved work is bad, but stranding the user
|
||||
// on a slow quit and then quitting anyway after the timeout is
|
||||
// exactly the same outcome.
|
||||
let hasDirty = false;
|
||||
try {
|
||||
hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
|
||||
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
|
||||
} catch (err) {
|
||||
console.error('[App] dirty-editors check failed:', err);
|
||||
}
|
||||
try {
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty);
|
||||
} catch (err) {
|
||||
// Reporting itself shouldn't throw, but if the IPC bridge is in a
|
||||
// bad state we'd rather log than bubble out of the listener and
|
||||
// disable the quit guard for the rest of the session.
|
||||
console.error('[App] reportDirtyEditorsResult failed:', err);
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
}, [t]);
|
||||
@@ -1025,6 +1043,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
addConnectionLogRef.current = addConnectionLog;
|
||||
|
||||
const closeSidePanelRef = useRef<(() => void) | null>(null);
|
||||
const toggleScriptsSidePanelRef = useRef<(() => void) | null>(null);
|
||||
const activeSidePanelTabRef = useRef<string | null>(null);
|
||||
const closeTabInFlightRef = useRef(false);
|
||||
// Populated by UnsavedChangesProvider render-prop below so that the hotkey
|
||||
@@ -1286,9 +1305,23 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
setNavigateToSection('port');
|
||||
break;
|
||||
case 'snippets':
|
||||
// Navigate to vault and open snippets section
|
||||
setActiveTabId('vault');
|
||||
setNavigateToSection('snippets');
|
||||
{
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const intent = resolveSnippetsShortcutIntent({
|
||||
activeTabId: currentId,
|
||||
sessionForTab: sessions.find((s) => s.id === currentId) ?? null,
|
||||
workspaceForTab: workspaces.find((w) => w.id === currentId) ?? null,
|
||||
terminalScriptsToggleAvailable: !!toggleScriptsSidePanelRef.current,
|
||||
});
|
||||
|
||||
if (intent.kind === 'toggleTerminalScripts') {
|
||||
toggleScriptsSidePanelRef.current();
|
||||
break;
|
||||
}
|
||||
|
||||
setActiveTabId('vault');
|
||||
setNavigateToSection('snippets');
|
||||
}
|
||||
break;
|
||||
case 'broadcast': {
|
||||
// Toggle broadcast mode for the active workspace
|
||||
@@ -1926,6 +1959,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessionLogsDir={sessionLogsDir}
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
closeSidePanelRef={closeSidePanelRef}
|
||||
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
||||
activeSidePanelTabRef={activeSidePanelTabRef}
|
||||
/>
|
||||
|
||||
|
||||
45
README.md
45
README.md
@@ -40,7 +40,8 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/main-window-dark.png)
|
||||
<img width="2868" height="1784" alt="netcatty SSH (Window) 2026-04-23 11:19 PM" src="https://github.com/user-attachments/assets/d6df734f-9ebc-452a-8b7d-e8a0fdc9463a" />
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +49,6 @@
|
||||
# 🔥 Catty Agent — Your IT Ops AI Partner
|
||||
|
||||
> 🚀 **Boost your IT ops daily work with AI power.** Catty Agent is the built-in AI assistant that understands your servers, executes commands, and handles complex multi-host operations — all through natural conversation.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/ai-feature.png" alt="Catty Agent Interface" width="800">
|
||||
</p>
|
||||
|
||||
### 🔥 What can Catty Agent do?
|
||||
|
||||
- 🚀 **Natural language server management** — just tell it what you need, no more memorizing commands
|
||||
@@ -68,7 +64,10 @@
|
||||
Ask Catty Agent to check a server's health, and it runs the right commands, analyzes the output, and gives you a clear summary — all in seconds.
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/eecf08f1-80bd-49db-886d-b36e93388865
|
||||
|
||||
https://github.com/user-attachments/assets/f819a1b6-8cba-4910-8017-97dfc080b477
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -78,8 +77,9 @@ https://github.com/user-attachments/assets/eecf08f1-80bd-49db-886d-b36e93388865
|
||||
Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one conversation. It handles the init, token exchange, and node joining — you just tell it what you want.
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/52fd30b8-9f02-43d4-a3b2-142691e8e3ec
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/282027aa-5c9e-4bb1-b2c3-5eea9df2b203
|
||||
|
||||
|
||||
|
||||
@@ -160,21 +160,27 @@ Video previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
|
||||
### Vault views: grid / list / tree
|
||||
Switch between different Vault views to match your workflow: overview in grid, dense scanning in list, and hierarchical navigation in tree.
|
||||
|
||||
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
|
||||
|
||||
https://github.com/user-attachments/assets/1ff1f3f1-e5ae-40ea-b35a-0e5148c3afeb
|
||||
|
||||
|
||||
|
||||
### Split terminals + session management
|
||||
Work in multiple sessions at once with split panes. Keep related tasks side-by-side and reduce context switching.
|
||||
|
||||
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/9c24b519-4b4b-4910-a22a-590d04c9af31
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### SFTP: drag & drop + built-in editor
|
||||
Move files with drag & drop, then edit quickly using the built-in editor without leaving the app.
|
||||
|
||||
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
|
||||
|
||||
https://github.com/user-attachments/assets/f3afdb36-399d-4330-b9f3-4678f178f6db
|
||||
|
||||
|
||||
|
||||
@@ -182,7 +188,11 @@ https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
|
||||
### Drag file upload
|
||||
Drop files into the app to kick off uploads without hunting through dialogs.
|
||||
|
||||
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/e1e26f7a-3489-41cc-975e-8dccba56ea85
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -190,7 +200,10 @@ https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
|
||||
### Custom themes
|
||||
Make Netcatty yours: customize themes and UI appearance.
|
||||
|
||||
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/1a6049aa-9a4c-4d52-a13d-0b007a791b00
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -198,7 +211,11 @@ https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
|
||||
### Keyword highlighting
|
||||
Highlight important terminal output so errors, warnings, and key events stand out at a glance.
|
||||
|
||||
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/1a1db7bd-948b-4f3c-97cd-8fd0cbe7cce7
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -375,6 +375,21 @@ const en: Messages = {
|
||||
'settings.terminal.section.connection': 'Connection',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets to server. Set to 0 to disable.',
|
||||
'settings.terminal.connection.x11Display': 'X11 display',
|
||||
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
|
||||
'settings.terminal.mosh.client': 'Mosh client path',
|
||||
'settings.terminal.mosh.client.desc': 'Absolute path to the local mosh executable. Leave empty to auto-detect on PATH and common install locations (Homebrew, MacPorts, ~/.nix-profile, ~/.cargo, ~/.local).',
|
||||
'settings.terminal.mosh.client.placeholder': 'Auto-detect',
|
||||
'settings.terminal.mosh.client.notFound': 'File not found at that path.',
|
||||
'settings.terminal.mosh.client.isDirectory': 'Path points to a directory, not an executable.',
|
||||
'settings.terminal.mosh.client.notExecutable': 'File exists but is not executable. Run `chmod +x` on it or pick another binary.',
|
||||
'settings.terminal.mosh.client.notAbsolute': 'Path must be absolute. Use Browse… to pick the binary, leave the field empty to auto-detect, or enter a full path.',
|
||||
'settings.terminal.mosh.detect': 'Detect',
|
||||
'settings.terminal.mosh.browse': 'Browse…',
|
||||
'settings.terminal.mosh.autoDetected': 'Auto-detected',
|
||||
'settings.terminal.mosh.detected': 'Detected at',
|
||||
'settings.terminal.mosh.notDetected': 'Mosh not found in:',
|
||||
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
|
||||
'settings.terminal.serverStats.show': 'Show Server Stats',
|
||||
'settings.terminal.serverStats.show.desc': 'Display CPU, memory, and disk usage in the terminal statusbar (Linux servers only).',
|
||||
@@ -1077,6 +1092,9 @@ const en: Messages = {
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not available',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': 'No SSH agent detected. Enable OpenSSH Authentication Agent in Windows Services, or use a compatible agent such as Bitwarden, 1Password, or gpg-agent.',
|
||||
'hostDetails.section.agentForwarding': 'SSH Agent',
|
||||
'hostDetails.x11Forwarding': 'Forward X11 apps',
|
||||
'hostDetails.x11Forwarding.desc': 'Show remote graphical apps on your local desktop when a local X server is running.',
|
||||
'hostDetails.section.x11Forwarding': 'X11 Forwarding',
|
||||
'hostDetails.section.deviceType': 'Device Type',
|
||||
'hostDetails.deviceType': 'Network Device Mode',
|
||||
'hostDetails.deviceType.desc': 'Enable for network equipment (switches, routers, firewalls) connected via SSH. Commands are sent as-is without shell wrapping, compatible with vendor CLIs like Huawei VRP and Cisco IOS.',
|
||||
|
||||
@@ -712,6 +712,9 @@ const zhCN: Messages = {
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 不可用',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': '未检测到 SSH Agent。请启用 Windows OpenSSH Authentication Agent 服务,或使用兼容的 Agent(如 Bitwarden、1Password、gpg-agent)。',
|
||||
'hostDetails.section.agentForwarding': 'SSH 代理',
|
||||
'hostDetails.x11Forwarding': '转发 X11 图形应用',
|
||||
'hostDetails.x11Forwarding.desc': '本机运行 X 服务时,让远程图形程序显示在本地桌面。',
|
||||
'hostDetails.section.x11Forwarding': 'X11 转发',
|
||||
'hostDetails.section.deviceType': '设备类型',
|
||||
'hostDetails.deviceType': '网络设备模式',
|
||||
'hostDetails.deviceType.desc': '适用于通过 SSH 连接的网络设备(交换机、路由器、防火墙)。命令将原样发送,不进行 Shell 包装,兼容华为 VRP、Cisco IOS 等厂商 CLI。',
|
||||
@@ -1456,6 +1459,21 @@ const zhCN: Messages = {
|
||||
'settings.terminal.section.connection': '连接',
|
||||
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 级别保活数据包的频率(秒)。设为 0 表示禁用。',
|
||||
'settings.terminal.connection.x11Display': 'X11 显示地址',
|
||||
'settings.terminal.connection.x11Display.desc': '可选的本机 X11 显示地址。留空则使用系统默认值。',
|
||||
'settings.terminal.connection.x11Display.placeholder': '自动(:0 或 DISPLAY)',
|
||||
'settings.terminal.mosh.client': 'Mosh 客户端路径',
|
||||
'settings.terminal.mosh.client.desc': '本机 mosh 可执行文件的绝对路径。留空则自动从 PATH 与常见安装目录中查找(Homebrew、MacPorts、~/.nix-profile、~/.cargo、~/.local)。',
|
||||
'settings.terminal.mosh.client.placeholder': '自动探测',
|
||||
'settings.terminal.mosh.client.notFound': '该路径下未找到文件。',
|
||||
'settings.terminal.mosh.client.isDirectory': '该路径指向目录而非可执行文件。',
|
||||
'settings.terminal.mosh.client.notExecutable': '文件存在但不可执行。请对其执行 `chmod +x`,或选择其它二进制文件。',
|
||||
'settings.terminal.mosh.client.notAbsolute': '路径必须为绝对路径。请使用 浏览… 选择二进制、留空以自动探测,或输入完整路径。',
|
||||
'settings.terminal.mosh.detect': '探测',
|
||||
'settings.terminal.mosh.browse': '浏览…',
|
||||
'settings.terminal.mosh.autoDetected': '自动检测到',
|
||||
'settings.terminal.mosh.detected': '已找到',
|
||||
'settings.terminal.mosh.notDetected': '在以下位置未找到 mosh:',
|
||||
'settings.terminal.section.serverStats': '服务器状态(Linux)',
|
||||
'settings.terminal.serverStats.show': '显示服务器状态',
|
||||
'settings.terminal.serverStats.show.desc': '在终端状态栏显示 CPU、内存和磁盘使用情况(仅限 Linux 服务器)。',
|
||||
|
||||
64
application/state/resolveSnippetsShortcutIntent.test.ts
Normal file
64
application/state/resolveSnippetsShortcutIntent.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
resolveScriptsSidePanelShortcutIntent,
|
||||
resolveSnippetsShortcutIntent,
|
||||
} from "./resolveSnippetsShortcutIntent.ts";
|
||||
|
||||
test("active single terminal tab toggles the terminal scripts panel", () => {
|
||||
const result = resolveSnippetsShortcutIntent({
|
||||
activeTabId: "s1",
|
||||
sessionForTab: { id: "s1" },
|
||||
workspaceForTab: null,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { kind: "toggleTerminalScripts" });
|
||||
});
|
||||
|
||||
test("active workspace tab toggles the terminal scripts panel", () => {
|
||||
const result = resolveSnippetsShortcutIntent({
|
||||
activeTabId: "w1",
|
||||
sessionForTab: null,
|
||||
workspaceForTab: { id: "w1" },
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { kind: "toggleTerminalScripts" });
|
||||
});
|
||||
|
||||
test("non-terminal tabs navigate to the vault snippets section", () => {
|
||||
for (const activeTabId of ["vault", "sftp", "editor:notes", "log1", null]) {
|
||||
const result = resolveSnippetsShortcutIntent({
|
||||
activeTabId,
|
||||
sessionForTab: null,
|
||||
workspaceForTab: null,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { kind: "openVaultSnippets" });
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal tabs fall back to vault snippets when terminal toggle is unavailable", () => {
|
||||
const result = resolveSnippetsShortcutIntent({
|
||||
activeTabId: "s1",
|
||||
sessionForTab: { id: "s1" },
|
||||
workspaceForTab: null,
|
||||
terminalScriptsToggleAvailable: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { kind: "openVaultSnippets" });
|
||||
});
|
||||
|
||||
test("scripts panel shortcut closes when scripts is already open", () => {
|
||||
const result = resolveScriptsSidePanelShortcutIntent("scripts");
|
||||
|
||||
assert.deepEqual(result, { kind: "closeTerminalSidePanel" });
|
||||
});
|
||||
|
||||
test("scripts panel shortcut opens scripts from closed or other panel states", () => {
|
||||
for (const activePanel of [null, "sftp", "theme", "ai"]) {
|
||||
const result = resolveScriptsSidePanelShortcutIntent(activePanel);
|
||||
|
||||
assert.deepEqual(result, { kind: "openTerminalScripts" });
|
||||
}
|
||||
});
|
||||
42
application/state/resolveSnippetsShortcutIntent.ts
Normal file
42
application/state/resolveSnippetsShortcutIntent.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type SnippetsShortcutIntent =
|
||||
| { kind: 'toggleTerminalScripts' }
|
||||
| { kind: 'openVaultSnippets' };
|
||||
|
||||
export type ScriptsSidePanelShortcutIntent =
|
||||
| { kind: 'closeTerminalSidePanel' }
|
||||
| { kind: 'openTerminalScripts' };
|
||||
|
||||
export interface ResolveSnippetsShortcutIntentInput {
|
||||
activeTabId: string | null;
|
||||
sessionForTab: { id: string } | null;
|
||||
workspaceForTab: { id: string } | null;
|
||||
terminalScriptsToggleAvailable?: boolean;
|
||||
}
|
||||
|
||||
export function resolveSnippetsShortcutIntent(
|
||||
input: ResolveSnippetsShortcutIntentInput,
|
||||
): SnippetsShortcutIntent {
|
||||
const {
|
||||
activeTabId,
|
||||
sessionForTab,
|
||||
workspaceForTab,
|
||||
terminalScriptsToggleAvailable = true,
|
||||
} = input;
|
||||
if (!activeTabId) return { kind: 'openVaultSnippets' };
|
||||
|
||||
if ((sessionForTab || workspaceForTab) && terminalScriptsToggleAvailable) {
|
||||
return { kind: 'toggleTerminalScripts' };
|
||||
}
|
||||
|
||||
return { kind: 'openVaultSnippets' };
|
||||
}
|
||||
|
||||
export function resolveScriptsSidePanelShortcutIntent(
|
||||
activePanel: string | null,
|
||||
): ScriptsSidePanelShortcutIntent {
|
||||
if (activePanel === 'scripts') {
|
||||
return { kind: 'closeTerminalSidePanel' };
|
||||
}
|
||||
|
||||
return { kind: 'openTerminalScripts' };
|
||||
}
|
||||
@@ -550,7 +550,10 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
// Release the transient "connecting" UI once the browser handoff has
|
||||
// happened. The callback session remains active in the background and
|
||||
// will mark the provider connected when the redirect completes.
|
||||
manager.resetProviderStatus(provider);
|
||||
// Do NOT use resetProviderStatus here — it would restore from the
|
||||
// auth snapshot and delete the adapter we just created, making the
|
||||
// eventual completePKCEAuth call fail with "adapter not initialized".
|
||||
manager.clearConnectingStatus(provider);
|
||||
manager.clearProviderError(provider);
|
||||
void completionPromise;
|
||||
return data.url;
|
||||
|
||||
@@ -408,6 +408,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
} else if (preserveLegacyFontSize && cleaned.fontSize == null) {
|
||||
cleaned.fontSize = initialData?.fontSize;
|
||||
}
|
||||
|
||||
if ((cleaned.protocol && cleaned.protocol !== "ssh") || cleaned.moshEnabled) {
|
||||
delete cleaned.x11Forwarding;
|
||||
}
|
||||
onSave(cleaned);
|
||||
};
|
||||
|
||||
@@ -1551,11 +1555,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
enabled={!!form.moshEnabled}
|
||||
onToggle={() => {
|
||||
const enabling = !form.moshEnabled;
|
||||
if (enabling && form.deviceType === 'network') {
|
||||
// Network device mode is incompatible with Mosh — clear it
|
||||
setForm(prev => ({ ...prev, moshEnabled: true, deviceType: undefined }));
|
||||
if (enabling) {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
moshEnabled: true,
|
||||
deviceType: prev.deviceType === 'network' ? undefined : prev.deviceType,
|
||||
x11Forwarding: undefined,
|
||||
}));
|
||||
} else {
|
||||
update("moshEnabled", enabling);
|
||||
update("moshEnabled", false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -1590,6 +1598,24 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* X11 Forwarding */}
|
||||
{(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && (
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<TerminalSquare size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.x11Forwarding")}</p>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.x11Forwarding")}
|
||||
enabled={!!form.x11Forwarding}
|
||||
onToggle={() => update("x11Forwarding", !form.x11Forwarding)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.x11Forwarding.desc")}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Network Device Mode — only for SSH hosts without Mosh (serial already uses raw mode) */}
|
||||
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && (
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
|
||||
@@ -47,6 +47,7 @@ interface SftpSidePanelProps {
|
||||
/** The host to connect to (follows focused terminal) */
|
||||
activeHost: Host | null;
|
||||
initialLocation?: { hostId: string; path: string } | null;
|
||||
onInitialLocationApplied?: (location: { hostId: string; path: string }) => void;
|
||||
showWorkspaceHostHeader?: boolean;
|
||||
isVisible?: boolean;
|
||||
renderOverlays?: boolean;
|
||||
@@ -67,6 +68,7 @@ interface SftpSidePanelProps {
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (value: boolean) => void;
|
||||
onGetTerminalCwd?: () => Promise<string | null>;
|
||||
onRequestTerminalFocus?: () => void;
|
||||
}
|
||||
|
||||
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
@@ -77,6 +79,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
sftpDefaultViewMode,
|
||||
activeHost,
|
||||
initialLocation,
|
||||
onInitialLocationApplied,
|
||||
showWorkspaceHostHeader = false,
|
||||
isVisible = true,
|
||||
renderOverlays = true,
|
||||
@@ -91,6 +94,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
onGetTerminalCwd,
|
||||
onRequestTerminalFocus,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -465,16 +469,18 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
const locationKey = `${connectedKeyRef.current}:${initialLocation.path}`;
|
||||
if (lastAppliedInitialLocationKeyRef.current === locationKey) return;
|
||||
|
||||
lastAppliedInitialLocationKeyRef.current = locationKey;
|
||||
onInitialLocationApplied?.(initialLocation);
|
||||
|
||||
if (connection.currentPath === initialLocation.path) {
|
||||
lastAppliedInitialLocationKeyRef.current = locationKey;
|
||||
return;
|
||||
}
|
||||
|
||||
lastAppliedInitialLocationKeyRef.current = locationKey;
|
||||
sftpRef.current.navigateTo("left", initialLocation.path);
|
||||
}, [
|
||||
activeHost,
|
||||
initialLocation,
|
||||
onInitialLocationApplied,
|
||||
sftp.leftPane,
|
||||
]);
|
||||
|
||||
@@ -723,6 +729,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
handleFileOpenerSelect={handleFileOpenerSelect}
|
||||
handleSelectSystemApp={handleSelectSystemApp}
|
||||
onPromoteToTab={onPromoteToTab}
|
||||
onRequestTerminalFocus={onRequestTerminalFocus}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
@@ -751,6 +758,7 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
|
||||
prev.editorWordWrap === next.editorWordWrap &&
|
||||
prev.setEditorWordWrap === next.setEditorWordWrap &&
|
||||
prev.onGetTerminalCwd === next.onGetTerminalCwd &&
|
||||
prev.onRequestTerminalFocus === next.onRequestTerminalFocus &&
|
||||
prev.initialLocation?.hostId === next.initialLocation?.hostId &&
|
||||
prev.initialLocation?.path === next.initialLocation?.path;
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
|
||||
import { shouldPreserveTerminalFocusOnMouseDown } from "./terminal/toolbarFocus";
|
||||
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
|
||||
@@ -184,6 +185,29 @@ function formatNetSpeed(bytesPerSec: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
type XTermWithPrivateRenderService = XTerm & {
|
||||
_core?: {
|
||||
_renderService?: {
|
||||
_renderRows?: (start: number, end: number) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function forceSyncRenderAfterResize(term: XTerm): void {
|
||||
const renderService = (term as XTermWithPrivateRenderService)._core?._renderService;
|
||||
const renderRows = renderService?._renderRows;
|
||||
if (typeof renderRows !== "function") return;
|
||||
|
||||
const endRow = term.rows - 1;
|
||||
if (endRow < 0) return;
|
||||
|
||||
try {
|
||||
renderRows.call(renderService, 0, endRow);
|
||||
} catch (err) {
|
||||
logger.warn("Sync render after resize failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
host,
|
||||
keys,
|
||||
@@ -982,8 +1006,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const runFit = () => {
|
||||
try {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
|
||||
const dimensions = fitAddon.proposeDimensions();
|
||||
if (!dimensions || Number.isNaN(dimensions.cols) || Number.isNaN(dimensions.rows)) return;
|
||||
|
||||
lastFittedSizeRef.current = { width, height };
|
||||
fitAddon.fit();
|
||||
// addon-fit 0.11 clears the renderer before resizing, which can show
|
||||
// as a one-frame WebGL blink during layout changes. Resize directly
|
||||
// using the proposed dimensions to preserve the existing behavior
|
||||
// without forcing a blank intermediate frame.
|
||||
if (term.cols !== dimensions.cols || term.rows !== dimensions.rows) {
|
||||
term.resize(dimensions.cols, dimensions.rows);
|
||||
forceSyncRenderAfterResize(term);
|
||||
}
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => {
|
||||
autocompleteRepositionRef.current?.();
|
||||
@@ -1025,8 +1062,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current.options.fontFamily = resolvedFontFamily;
|
||||
|
||||
if (terminalSettings) {
|
||||
termRef.current.options.cursorStyle = terminalSettings.cursorShape;
|
||||
termRef.current.options.cursorBlink = terminalSettings.cursorBlink;
|
||||
applyUserCursorPreference(termRef.current, terminalSettings);
|
||||
termRef.current.options.scrollback = terminalSettings.scrollback === 0 ? 999999 : terminalSettings.scrollback;
|
||||
termRef.current.options.fontWeight = effectiveFontWeight as
|
||||
| 100
|
||||
|
||||
@@ -43,6 +43,7 @@ import Terminal from './Terminal';
|
||||
import { SftpSidePanel } from './SftpSidePanel';
|
||||
import { ScriptsSidePanel } from './ScriptsSidePanel';
|
||||
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
|
||||
import { focusTerminalSessionInput } from './terminal/focusTerminalSession';
|
||||
import { AIChatSidePanel } from './AIChatSidePanel';
|
||||
import { useAIState } from '../application/state/useAIState';
|
||||
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
|
||||
@@ -53,6 +54,7 @@ import { Input } from './ui/input';
|
||||
import { RippleButton } from './ui/ripple';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { setupMcpApprovalBridge } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { resolveScriptsSidePanelShortcutIntent } from '../application/state/resolveSnippetsShortcutIntent';
|
||||
|
||||
type SidePanelTab = 'sftp' | 'scripts' | 'theme' | 'ai';
|
||||
|
||||
@@ -436,6 +438,7 @@ interface TerminalLayerProps {
|
||||
sessionLogsDir?: string;
|
||||
sessionLogsFormat?: string;
|
||||
closeSidePanelRef?: React.MutableRefObject<(() => void) | null>;
|
||||
toggleScriptsSidePanelRef?: React.MutableRefObject<(() => void) | null>;
|
||||
activeSidePanelTabRef?: React.MutableRefObject<string | null>;
|
||||
}
|
||||
|
||||
@@ -492,6 +495,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
closeSidePanelRef,
|
||||
toggleScriptsSidePanelRef,
|
||||
activeSidePanelTabRef,
|
||||
}) => {
|
||||
// Subscribe to activeTabId from external store
|
||||
@@ -793,6 +797,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSftpInitialLocationApplied = useCallback((tabId: string, location: { hostId: string; path: string }) => {
|
||||
setSftpInitialLocationForTab(prev => {
|
||||
const current = prev.get(tabId);
|
||||
if (!current || current.hostId !== location.hostId || current.path !== location.path) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.delete(tabId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Focus-mode workspace sidebar resize handler. The sidebar is always
|
||||
// anchored to the left of the workspace area, so a rightward drag grows it.
|
||||
const handleFocusSidebarResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
@@ -1294,9 +1310,26 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
[sidePanelOpenTabs],
|
||||
);
|
||||
|
||||
const getActiveTerminalSessionId = useCallback((): string | null => {
|
||||
if (!activeWorkspace) return activeSession?.id ?? null;
|
||||
|
||||
const workspaceSessionIdSet = new Set(collectSessionIds(activeWorkspace.root));
|
||||
const focusedSessionId = activeWorkspace.focusedSessionId;
|
||||
if (focusedSessionId && workspaceSessionIdSet.has(focusedSessionId) && sessions.some((session) => session.id === focusedSessionId)) {
|
||||
return focusedSessionId;
|
||||
}
|
||||
|
||||
return sessions.find((session) => workspaceSessionIdSet.has(session.id))?.id ?? null;
|
||||
}, [activeWorkspace, activeSession?.id, sessions]);
|
||||
|
||||
const syncWorkspaceFocusIfNeeded = useCallback((sessionId: string | null) => {
|
||||
if (!activeWorkspace || !sessionId || activeWorkspace.focusedSessionId === sessionId) return;
|
||||
onSetWorkspaceFocusedSession?.(activeWorkspace.id, sessionId);
|
||||
}, [activeWorkspace, onSetWorkspaceFocusedSession]);
|
||||
|
||||
// Get the focused terminal's current working directory
|
||||
const getTerminalCwd = useCallback(async (): Promise<string | null> => {
|
||||
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
|
||||
const sessionId = getActiveTerminalSessionId();
|
||||
if (!sessionId) return null;
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionId);
|
||||
@@ -1304,27 +1337,23 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [activeWorkspace?.focusedSessionId, activeSession?.id, terminalBackend]);
|
||||
}, [getActiveTerminalSessionId, terminalBackend]);
|
||||
|
||||
const refocusTerminalSession = useCallback((sessionId?: string | null) => {
|
||||
if (!sessionId) return;
|
||||
|
||||
const focusTarget = () => {
|
||||
const pane = document.querySelector(`[data-session-id="${sessionId}"]`);
|
||||
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
|
||||
textarea?.focus();
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
focusTarget();
|
||||
setTimeout(focusTarget, 50);
|
||||
});
|
||||
focusTerminalSessionInput(sessionId);
|
||||
}, []);
|
||||
|
||||
const refocusActiveTerminalSession = useCallback(() => {
|
||||
const sessionId = getActiveTerminalSessionId();
|
||||
syncWorkspaceFocusIfNeeded(sessionId);
|
||||
refocusTerminalSession(sessionId);
|
||||
}, [getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]);
|
||||
|
||||
// Close the entire side panel for the current tab
|
||||
const handleCloseSidePanel = useCallback(() => {
|
||||
if (!activeTabId) return;
|
||||
const sessionIdToRefocus = activeWorkspace?.focusedSessionId ?? activeSession?.id;
|
||||
const sessionIdToRefocus = getActiveTerminalSessionId();
|
||||
syncWorkspaceFocusIfNeeded(sessionIdToRefocus);
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(activeTabId);
|
||||
@@ -1348,7 +1377,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return next;
|
||||
});
|
||||
refocusTerminalSession(sessionIdToRefocus);
|
||||
}, [activeTabId, activeWorkspace?.focusedSessionId, activeSession?.id, refocusTerminalSession]);
|
||||
}, [activeTabId, getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!closeSidePanelRef) return;
|
||||
@@ -1403,6 +1432,34 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleSwitchSidePanelTab('scripts');
|
||||
}, [handleSwitchSidePanelTab]);
|
||||
|
||||
const handleToggleScriptsSidePanel = useCallback(() => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
if (!tabId) return;
|
||||
|
||||
const intent = resolveScriptsSidePanelShortcutIntent(
|
||||
sidePanelOpenTabsRef.current.get(tabId) ?? null,
|
||||
);
|
||||
|
||||
if (intent.kind === 'closeTerminalSidePanel') {
|
||||
handleCloseSidePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, 'scripts');
|
||||
return next;
|
||||
});
|
||||
}, [handleCloseSidePanel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toggleScriptsSidePanelRef) return;
|
||||
toggleScriptsSidePanelRef.current = handleToggleScriptsSidePanel;
|
||||
return () => {
|
||||
toggleScriptsSidePanelRef.current = null;
|
||||
};
|
||||
}, [toggleScriptsSidePanelRef, handleToggleScriptsSidePanel]);
|
||||
|
||||
// Open theme side panel (called from Terminal toolbar)
|
||||
const handleOpenTheme = useCallback(() => {
|
||||
handleSwitchSidePanelTab('theme');
|
||||
@@ -2271,6 +2328,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
? (sftpInitialLocationForTab.get(tabId) ?? null)
|
||||
: null
|
||||
}
|
||||
onInitialLocationApplied={(location) => handleSftpInitialLocationApplied(tabId, location)}
|
||||
showWorkspaceHostHeader={isVisibleSftpPanel && !!activeWorkspace}
|
||||
isVisible={isVisibleSftpPanel}
|
||||
renderOverlays={isVisibleSftpPanel}
|
||||
@@ -2285,6 +2343,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
onGetTerminalCwd={getTerminalCwd}
|
||||
onRequestTerminalFocus={refocusActiveTerminalSession}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -2599,6 +2658,7 @@ const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProp
|
||||
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
|
||||
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
|
||||
prev.onSplitSession === next.onSplitSession &&
|
||||
prev.toggleScriptsSidePanelRef === next.toggleScriptsSidePanelRef &&
|
||||
prev.identities === next.identities
|
||||
);
|
||||
};
|
||||
|
||||
32
components/VaultView.memo.test.tsx
Normal file
32
components/VaultView.memo.test.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { vaultViewAreEqual } from "./VaultView.tsx";
|
||||
|
||||
test("VaultView re-renders when an external section navigation request changes", () => {
|
||||
const baseProps = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
snippetPackages: [],
|
||||
customGroups: [],
|
||||
knownHosts: [],
|
||||
shellHistory: [],
|
||||
connectionLogs: [],
|
||||
sessions: [],
|
||||
managedSources: [],
|
||||
groupConfigs: {},
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
navigateToSection: null,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
vaultViewAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, navigateToSection: "snippets" } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -3199,7 +3199,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
};
|
||||
|
||||
// Only re-render when data props change - isActive is now managed internally via store subscription
|
||||
const vaultViewAreEqual = (
|
||||
export const vaultViewAreEqual = (
|
||||
prev: VaultViewProps,
|
||||
next: VaultViewProps,
|
||||
): boolean => {
|
||||
@@ -3217,7 +3217,8 @@ const vaultViewAreEqual = (
|
||||
prev.managedSources === next.managedSources &&
|
||||
prev.groupConfigs === next.groupConfigs &&
|
||||
prev.terminalThemeId === next.terminalThemeId &&
|
||||
prev.terminalFontSize === next.terminalFontSize;
|
||||
prev.terminalFontSize === next.terminalFontSize &&
|
||||
prev.navigateToSection === next.navigateToSection;
|
||||
|
||||
return isEqual;
|
||||
};
|
||||
|
||||
@@ -9,27 +9,37 @@ export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: 'user' | 'assistant' | 'system' | 'tool';
|
||||
};
|
||||
|
||||
// Public CSS hooks for user customization (Settings → Appearance → Custom CSS):
|
||||
// .ai-chat-message[data-role="user"] — outer row, user-authored
|
||||
// .ai-chat-message[data-role="assistant"] — outer row, assistant reply
|
||||
// .ai-chat-message-content[data-role=...] — inner bubble / content area
|
||||
// These attributes are part of the UI's stable contract; do not rename
|
||||
// without updating Custom CSS docs.
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex w-full max-w-[95%] flex-col gap-1.5',
|
||||
'ai-chat-message group flex w-full max-w-[95%] flex-col gap-1.5',
|
||||
from === 'user' ? 'is-user ml-auto' : 'is-assistant',
|
||||
className,
|
||||
)}
|
||||
data-role={from}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from?: 'user' | 'assistant' | 'system' | 'tool';
|
||||
};
|
||||
|
||||
export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
|
||||
export const MessageContent = ({ children, className, from, ...props }: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-fit min-w-0 max-w-full flex-col gap-1.5 text-[13px] leading-relaxed',
|
||||
'ai-chat-message-content flex w-fit min-w-0 max-w-full flex-col gap-1.5 text-[13px] leading-relaxed',
|
||||
'group-[.is-user]:ml-auto group-[.is-user]:overflow-hidden group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-2.5 group-[.is-user]:py-2',
|
||||
'group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground/90',
|
||||
className,
|
||||
)}
|
||||
data-role={from}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -196,7 +196,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
|
||||
return (
|
||||
<Message key={message.id} from={message.role}>
|
||||
<MessageContent>
|
||||
<MessageContent from={message.role}>
|
||||
{/* Thinking block */}
|
||||
{!isUser && message.thinking && (
|
||||
<ThinkingBlock
|
||||
|
||||
@@ -300,6 +300,16 @@ export default function SettingsTerminalTab(props: {
|
||||
const [shellValidation, setShellValidation] = useState<{ valid: boolean; message?: string } | null>(null);
|
||||
const [dirValidation, setDirValidation] = useState<{ valid: boolean; message?: string } | null>(null);
|
||||
|
||||
// Mosh settings state
|
||||
const [moshValidation, setMoshValidation] = useState<{ valid: boolean; message?: string } | null>(null);
|
||||
const [moshDetectStatus, setMoshDetectStatus] = useState<
|
||||
| { kind: "idle" }
|
||||
| { kind: "running" }
|
||||
| { kind: "found"; path: string }
|
||||
| { kind: "not-found"; searchedPaths: string[] }
|
||||
>({ kind: "idle" });
|
||||
const [autoDetectedMoshPath, setAutoDetectedMoshPath] = useState<string | null>(null);
|
||||
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
const [showCustomShellInput, setShowCustomShellInput] = useState(() => {
|
||||
if (!terminalSettings.localShell) return false;
|
||||
@@ -455,6 +465,109 @@ export default function SettingsTerminalTab(props: {
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [terminalSettings.localShell, discoveredShells, t]);
|
||||
|
||||
// Validate mosh client path when it changes (debounced)
|
||||
useEffect(() => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
const moshPath = terminalSettings.moshClientPath;
|
||||
if (!moshPath) {
|
||||
setMoshValidation(null);
|
||||
return;
|
||||
}
|
||||
// The shared validatePath bridge resolves bare names through PATH (good
|
||||
// for localShell where "powershell.exe" is a valid choice), but
|
||||
// startMoshSession treats moshClientPath as a literal filesystem path —
|
||||
// so any non-absolute entry would look valid here yet fail at connect
|
||||
// time. Gate on absolute paths first; accept ~ since the main process
|
||||
// will expand it. Tolerant across platforms so e.g. a user pasting a
|
||||
// Windows-style absolute path on macOS still gets a real error
|
||||
// downstream rather than a misleading "not absolute".
|
||||
const looksAbsolute =
|
||||
moshPath.startsWith("/") ||
|
||||
moshPath.startsWith("~") ||
|
||||
/^[a-zA-Z]:[\\/]/.test(moshPath) ||
|
||||
moshPath.startsWith("\\\\");
|
||||
if (!looksAbsolute) {
|
||||
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.notAbsolute") });
|
||||
return;
|
||||
}
|
||||
if (!bridge?.validatePath) {
|
||||
setMoshValidation(null);
|
||||
return;
|
||||
}
|
||||
const timeoutId = setTimeout(() => {
|
||||
bridge.validatePath(moshPath, "file").then((result) => {
|
||||
if (result.exists && result.isFile && !result.isExecutable) {
|
||||
// Stays consistent with startMoshSession's isExecutableFile check —
|
||||
// a regular file without the execute bit can't actually launch.
|
||||
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.notExecutable") });
|
||||
} else if (result.exists && result.isFile) {
|
||||
setMoshValidation({ valid: true });
|
||||
} else if (result.exists && result.isDirectory) {
|
||||
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.isDirectory") });
|
||||
} else {
|
||||
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.notFound") });
|
||||
}
|
||||
}).catch(() => {
|
||||
setMoshValidation(null);
|
||||
});
|
||||
}, 300);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [terminalSettings.moshClientPath, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.detectMoshClient) return;
|
||||
let canceled = false;
|
||||
bridge.detectMoshClient()
|
||||
.then((result) => {
|
||||
if (!canceled) {
|
||||
setAutoDetectedMoshPath(result.found && result.path ? result.path : null);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!canceled) setAutoDetectedMoshPath(null);
|
||||
});
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDetectMosh = useCallback(async () => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.detectMoshClient) return;
|
||||
setMoshDetectStatus({ kind: "running" });
|
||||
try {
|
||||
const result = await bridge.detectMoshClient();
|
||||
if (result.found && result.path) {
|
||||
setMoshDetectStatus({ kind: "found", path: result.path });
|
||||
// Auto-fill the input only when it is empty so we don't override
|
||||
// a value the user is in the middle of editing.
|
||||
if (!terminalSettings.moshClientPath) {
|
||||
updateTerminalSetting("moshClientPath", result.path);
|
||||
}
|
||||
} else {
|
||||
setMoshDetectStatus({ kind: "not-found", searchedPaths: result.searchedPaths });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Settings] detectMoshClient failed:", err);
|
||||
setMoshDetectStatus({ kind: "not-found", searchedPaths: [] });
|
||||
}
|
||||
}, [terminalSettings.moshClientPath, updateTerminalSetting]);
|
||||
|
||||
const handleBrowseMosh = useCallback(async () => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.pickMoshClient) return;
|
||||
try {
|
||||
const result = await bridge.pickMoshClient();
|
||||
if (!result.canceled && result.filePath) {
|
||||
updateTerminalSetting("moshClientPath", result.filePath);
|
||||
setMoshDetectStatus({ kind: "idle" });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Settings] pickMoshClient failed:", err);
|
||||
}
|
||||
}, [updateTerminalSetting]);
|
||||
|
||||
// Validate directory path when it changes
|
||||
useEffect(() => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
@@ -1034,6 +1147,85 @@ export default function SettingsTerminalTab(props: {
|
||||
className="w-24"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.connection.x11Display")}
|
||||
description={t("settings.terminal.connection.x11Display.desc")}
|
||||
>
|
||||
<Input
|
||||
value={terminalSettings.x11Display}
|
||||
onChange={(e) => updateTerminalSetting("x11Display", e.target.value)}
|
||||
placeholder={t("settings.terminal.connection.x11Display.placeholder")}
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.mosh.client")}
|
||||
description={t("settings.terminal.mosh.client.desc")}
|
||||
>
|
||||
<div className="flex max-w-full flex-col gap-1.5" style={{ width: "min(420px, 100%)" }}>
|
||||
<div className="grid grid-cols-[minmax(220px,1fr)_auto_auto] gap-2">
|
||||
<Input
|
||||
value={terminalSettings.moshClientPath}
|
||||
placeholder={t("settings.terminal.mosh.client.placeholder")}
|
||||
onChange={(e) => updateTerminalSetting("moshClientPath", e.target.value)}
|
||||
className={cn(
|
||||
"flex-1",
|
||||
moshValidation && !moshValidation.valid && "border-destructive focus-visible:ring-destructive",
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDetectMosh}
|
||||
disabled={moshDetectStatus.kind === "running"}
|
||||
>
|
||||
{t("settings.terminal.mosh.detect")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBrowseMosh}
|
||||
>
|
||||
{t("settings.terminal.mosh.browse")}
|
||||
</Button>
|
||||
</div>
|
||||
{!terminalSettings.moshClientPath && autoDetectedMoshPath && moshDetectStatus.kind !== "found" && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("settings.terminal.mosh.autoDetected")}: <span className="break-all font-mono">{autoDetectedMoshPath}</span>
|
||||
</span>
|
||||
)}
|
||||
{moshValidation && !moshValidation.valid && moshValidation.message && (
|
||||
<span className="text-xs text-destructive flex items-center gap-1">
|
||||
<AlertCircle size={12} />
|
||||
{moshValidation.message}
|
||||
</span>
|
||||
)}
|
||||
{moshDetectStatus.kind === "found" && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("settings.terminal.mosh.detected")}: <span className="break-all font-mono">{moshDetectStatus.path}</span>
|
||||
</span>
|
||||
)}
|
||||
{moshDetectStatus.kind === "not-found" && (
|
||||
<span className="text-xs text-destructive flex items-start gap-1">
|
||||
<AlertCircle size={12} className="mt-0.5 shrink-0" />
|
||||
<span>
|
||||
{t("settings.terminal.mosh.notDetected")}
|
||||
{moshDetectStatus.searchedPaths.length > 0 && (
|
||||
<>
|
||||
{" "}
|
||||
<span className="text-muted-foreground">
|
||||
({moshDetectStatus.searchedPaths.slice(0, 4).join(", ")}
|
||||
{moshDetectStatus.searchedPaths.length > 4 ? "…" : ""})
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.serverStats")} />
|
||||
|
||||
@@ -46,6 +46,7 @@ interface SftpOverlaysProps {
|
||||
handleFileOpenerSelect: (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => void;
|
||||
handleSelectSystemApp: (systemApp: { path: string; name: string }) => void;
|
||||
onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void;
|
||||
onRequestTerminalFocus?: () => void;
|
||||
}
|
||||
|
||||
export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
@@ -83,6 +84,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
onPromoteToTab,
|
||||
onRequestTerminalFocus,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
@@ -141,6 +143,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
setShowTextEditor(false);
|
||||
setTextEditorTarget(null);
|
||||
setTextEditorContent("");
|
||||
onRequestTerminalFocus?.();
|
||||
}}
|
||||
fileName={textEditorTarget?.file.name || ""}
|
||||
initialContent={textEditorContent}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Terminal as TerminalIcon,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { KeyBinding, RightClickBehavior } from '../../domain/models';
|
||||
import {
|
||||
@@ -59,6 +59,17 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
// Tracks the .workspace-pane whose context menu is currently open so we can
|
||||
// keep its `:focus-within`-driven opacity stable while focus is in the
|
||||
// menu portal (otherwise the pane dims for the menu's lifetime).
|
||||
const markedPaneRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
markedPaneRef.current?.removeAttribute('data-menu-open');
|
||||
markedPaneRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Helper to get shortcut from keyBindings and format for display
|
||||
const getShortcut = (bindingId: string): string => {
|
||||
@@ -91,7 +102,15 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
}
|
||||
|
||||
// Shift+Right-Click or context-menu mode: let Radix open the menu
|
||||
if (e.shiftKey || rightClickBehavior === 'context-menu') return;
|
||||
if (e.shiftKey || rightClickBehavior === 'context-menu') {
|
||||
const pane = (e.target as HTMLElement | null)?.closest<HTMLElement>('.workspace-pane');
|
||||
if (pane) {
|
||||
markedPaneRef.current?.removeAttribute('data-menu-open');
|
||||
pane.setAttribute('data-menu-open', '');
|
||||
markedPaneRef.current = pane;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Paste / select-word: intercept and prevent the context menu
|
||||
e.preventDefault();
|
||||
@@ -107,7 +126,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
// Always use ContextMenu wrapper to maintain consistent React tree structure
|
||||
// This prevents terminal from unmounting when rightClickBehavior changes
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenu onOpenChange={handleOpenChange}>
|
||||
<ContextMenuTrigger
|
||||
asChild
|
||||
onContextMenu={handleRightClick}
|
||||
|
||||
54
components/terminal/TerminalToolbar.test.ts
Normal file
54
components/terminal/TerminalToolbar.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
||||
import type { Host } from "../../types.ts";
|
||||
import { TerminalToolbar } from "./TerminalToolbar.tsx";
|
||||
|
||||
const sshHost: Host = {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
};
|
||||
|
||||
const renderToolbar = (host: Host, status: "connecting" | "connected" | "disconnected" = "connected") =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(TerminalToolbar, {
|
||||
status,
|
||||
host,
|
||||
onOpenSFTP: () => {},
|
||||
onOpenScripts: () => {},
|
||||
onOpenTheme: () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
test("keeps SFTP visible before the terminal overflow menu for SSH sessions", () => {
|
||||
const markup = renderToolbar(sshHost);
|
||||
|
||||
const sftpIndex = markup.indexOf('aria-label="Open SFTP"');
|
||||
const moreIndex = markup.indexOf('aria-label="More actions"');
|
||||
|
||||
assert.notEqual(sftpIndex, -1);
|
||||
assert.notEqual(moreIndex, -1);
|
||||
assert.ok(sftpIndex < moreIndex);
|
||||
});
|
||||
|
||||
test("hides SFTP for local terminal sessions", () => {
|
||||
const markup = renderToolbar({
|
||||
...sshHost,
|
||||
id: "local-1",
|
||||
protocol: "local",
|
||||
});
|
||||
|
||||
assert.equal(markup.includes('aria-label="Open SFTP"'), false);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Terminal Toolbar
|
||||
* Displays SFTP, Scripts, Theme, Highlight, Search buttons and close button in terminal status bar
|
||||
* Displays high-frequency terminal actions and close button in the terminal status bar.
|
||||
*/
|
||||
import { Check, ChevronRight, FolderInput, Languages, MoreVertical, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
@@ -82,6 +82,26 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
buttonClassName={buttonBase}
|
||||
/>
|
||||
|
||||
{!hidesSftp && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={cn(buttonBase, status !== 'connected' && "opacity-50")}
|
||||
aria-label={status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
|
||||
onClick={onOpenSFTP}
|
||||
disabled={status !== 'connected'}
|
||||
>
|
||||
<FolderInput size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -114,9 +134,9 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
<TooltipContent>{t("terminal.toolbar.searchTerminal")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Overflow menu — collapses the four opener-style actions
|
||||
(SFTP / Encoding / Scripts / Terminal Settings) behind a
|
||||
single ⋮ trigger so the toolbar doesn't feel crowded.
|
||||
{/* Overflow menu — keeps lower-frequency opener-style actions
|
||||
(Encoding / Scripts / Terminal Settings) behind a single
|
||||
trigger so the toolbar doesn't feel crowded.
|
||||
Highlight / Compose / Search stay visible because they
|
||||
are toggled mid-session, not just once. */}
|
||||
<Popover
|
||||
@@ -154,21 +174,6 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!hidesSftp && (
|
||||
<PopoverClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(menuItemClass, status !== 'connected' && "opacity-50 pointer-events-none")}
|
||||
onClick={onOpenSFTP}
|
||||
disabled={status !== 'connected'}
|
||||
>
|
||||
<FolderInput size={12} className="shrink-0" />
|
||||
<span className="flex-1 text-left truncate">
|
||||
{status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverClose>
|
||||
)}
|
||||
<PopoverClose asChild>
|
||||
<button type="button" className={menuItemClass} onClick={onOpenScripts}>
|
||||
<Zap size={12} className="shrink-0" />
|
||||
|
||||
61
components/terminal/focusTerminalSession.test.ts
Normal file
61
components/terminal/focusTerminalSession.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { focusTerminalSessionInput } from "./focusTerminalSession";
|
||||
|
||||
test("focusTerminalSessionInput focuses the xterm helper textarea immediately and after scheduled retries", () => {
|
||||
const focusCalls: string[] = [];
|
||||
const textarea = {
|
||||
focus: () => focusCalls.push("focus"),
|
||||
};
|
||||
const pane = {
|
||||
querySelector: (selector: string) => {
|
||||
assert.equal(selector, "textarea.xterm-helper-textarea");
|
||||
return textarea;
|
||||
},
|
||||
};
|
||||
const queriedSelectors: string[] = [];
|
||||
const doc = {
|
||||
querySelector: (selector: string) => {
|
||||
queriedSelectors.push(selector);
|
||||
return pane;
|
||||
},
|
||||
};
|
||||
const timeouts: number[] = [];
|
||||
|
||||
focusTerminalSessionInput("session-1", {
|
||||
document: doc,
|
||||
requestAnimationFrame: (callback) => {
|
||||
callback();
|
||||
return 1;
|
||||
},
|
||||
setTimeout: (callback, delay) => {
|
||||
timeouts.push(delay);
|
||||
callback();
|
||||
return delay;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(queriedSelectors, [
|
||||
'[data-session-id="session-1"]',
|
||||
'[data-session-id="session-1"]',
|
||||
]);
|
||||
assert.deepEqual(timeouts, [50]);
|
||||
assert.deepEqual(focusCalls, ["focus", "focus"]);
|
||||
});
|
||||
|
||||
test("focusTerminalSessionInput ignores empty or unavailable targets", () => {
|
||||
assert.doesNotThrow(() => {
|
||||
focusTerminalSessionInput(null, {
|
||||
document: undefined,
|
||||
requestAnimationFrame: (callback) => {
|
||||
callback();
|
||||
return 1;
|
||||
},
|
||||
setTimeout: (callback, delay) => {
|
||||
callback();
|
||||
return delay;
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
57
components/terminal/focusTerminalSession.ts
Normal file
57
components/terminal/focusTerminalSession.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
type QueryTarget = {
|
||||
querySelector: (selector: string) => QueryTarget | FocusableTarget | null;
|
||||
};
|
||||
|
||||
type FocusableTarget = {
|
||||
focus?: () => void;
|
||||
};
|
||||
|
||||
interface FocusTerminalSessionInputOptions {
|
||||
document?: QueryTarget | null;
|
||||
requestAnimationFrame?: (callback: () => void) => unknown;
|
||||
setTimeout?: (callback: () => void, delay: number) => unknown;
|
||||
retryDelays?: readonly number[];
|
||||
}
|
||||
|
||||
const escapeAttributeValue = (value: string): string =>
|
||||
value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
|
||||
export const focusTerminalSessionInput = (
|
||||
sessionId: string | null | undefined,
|
||||
options: FocusTerminalSessionInputOptions = {},
|
||||
): void => {
|
||||
if (!sessionId) return;
|
||||
|
||||
const doc = options.document ?? (typeof document !== "undefined" ? document : null);
|
||||
if (!doc) return;
|
||||
|
||||
const raf = options.requestAnimationFrame
|
||||
?? (typeof requestAnimationFrame !== "undefined"
|
||||
? requestAnimationFrame
|
||||
: (callback: () => void) => {
|
||||
callback();
|
||||
return undefined;
|
||||
});
|
||||
const scheduleTimeout = options.setTimeout
|
||||
?? (typeof setTimeout !== "undefined"
|
||||
? setTimeout
|
||||
: (callback: () => void) => {
|
||||
callback();
|
||||
return undefined;
|
||||
});
|
||||
const retryDelays = options.retryDelays ?? [50];
|
||||
const paneSelector = `[data-session-id="${escapeAttributeValue(sessionId)}"]`;
|
||||
|
||||
const focusTarget = () => {
|
||||
const pane = doc.querySelector(paneSelector) as QueryTarget | null;
|
||||
const textarea = pane?.querySelector("textarea.xterm-helper-textarea") as FocusableTarget | null;
|
||||
textarea?.focus?.();
|
||||
};
|
||||
|
||||
raf(() => {
|
||||
focusTarget();
|
||||
retryDelays.forEach((delay) => {
|
||||
scheduleTimeout(focusTarget, delay);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -569,6 +569,8 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
? (effectivePassphrase || sanitizeCredentialValue(attempt.key.passphrase))
|
||||
: undefined,
|
||||
agentForwarding: ctx.host.agentForwarding,
|
||||
x11Forwarding: ctx.host.x11Forwarding,
|
||||
x11Display: ctx.terminalSettings?.x11Display,
|
||||
legacyAlgorithms: ctx.host.legacyAlgorithms,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
@@ -759,6 +761,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
username: ctx.host.username || "root",
|
||||
port: ctx.host.port || 22,
|
||||
moshServerPath: ctx.host.moshServerPath,
|
||||
moshClientPath: ctx.terminalSettings?.moshClientPath || undefined,
|
||||
agentForwarding: ctx.host.agentForwarding,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
isEraseScrollbackSequence,
|
||||
preserveTerminalViewportInScrollback,
|
||||
} from "../clearTerminalViewport";
|
||||
import { installUserCursorPreferenceGuard } from "./cursorPreference";
|
||||
import type {
|
||||
Host,
|
||||
KeyBinding,
|
||||
@@ -830,6 +831,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
return true;
|
||||
});
|
||||
|
||||
const cursorPreferenceDisposable = installUserCursorPreferenceGuard(term, ctx.terminalSettingsRef);
|
||||
|
||||
let resizeTimeout: NodeJS.Timeout | null = null;
|
||||
const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
|
||||
term.onResize(({ cols, rows }) => {
|
||||
@@ -857,6 +860,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
eraseScrollbackDisposable.dispose();
|
||||
osc7Disposable.dispose();
|
||||
osc52Disposable.dispose();
|
||||
cursorPreferenceDisposable?.dispose();
|
||||
try {
|
||||
term.dispose();
|
||||
} catch (err) {
|
||||
|
||||
160
components/terminal/runtime/cursorPreference.test.ts
Normal file
160
components/terminal/runtime/cursorPreference.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
applyUserCursorBlinkPreference,
|
||||
applyUserCursorPreference,
|
||||
installUserCursorPreferenceGuard,
|
||||
resolveUserCursorPreference,
|
||||
} from "./cursorPreference";
|
||||
|
||||
test("resolveUserCursorPreference defaults to a blinking block cursor", () => {
|
||||
assert.deepEqual(resolveUserCursorPreference(undefined), {
|
||||
cursorShape: "block",
|
||||
cursorBlink: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("applyUserCursorPreference clears terminal-side cursor overrides before applying user settings", () => {
|
||||
const term = {
|
||||
options: {
|
||||
cursorStyle: "block" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
_core: {
|
||||
coreService: {
|
||||
decPrivateModes: {
|
||||
cursorStyle: "bar" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
applyUserCursorPreference(term, {
|
||||
cursorShape: "underline",
|
||||
cursorBlink: true,
|
||||
});
|
||||
|
||||
assert.equal(term.options.cursorStyle, "underline");
|
||||
assert.equal(term.options.cursorBlink, true);
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorStyle, undefined);
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorBlink, undefined);
|
||||
});
|
||||
|
||||
test("applyUserCursorBlinkPreference keeps remote cursor shape overrides intact", () => {
|
||||
const term = {
|
||||
options: {
|
||||
cursorStyle: "block" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
_core: {
|
||||
coreService: {
|
||||
decPrivateModes: {
|
||||
cursorStyle: "bar" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
applyUserCursorBlinkPreference(term, {
|
||||
cursorShape: "underline",
|
||||
cursorBlink: true,
|
||||
});
|
||||
|
||||
assert.equal(term.options.cursorStyle, "block");
|
||||
assert.equal(term.options.cursorBlink, true);
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorStyle, "bar");
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorBlink, undefined);
|
||||
});
|
||||
|
||||
test("installUserCursorPreferenceGuard restores blink without consuming cursor-style overrides", async () => {
|
||||
const handlers = new Map<string, (params: readonly (number | number[])[]) => boolean>();
|
||||
const parser = {
|
||||
registerCsiHandler(this: typeof parser, id: { prefix?: string; intermediates?: string; final: string }, callback: (params: readonly (number | number[])[]) => boolean) {
|
||||
assert.equal(this, parser);
|
||||
handlers.set(`${id.prefix ?? ""}|${id.intermediates ?? ""}|${id.final}`, callback);
|
||||
return { dispose: () => undefined };
|
||||
},
|
||||
};
|
||||
const term = {
|
||||
options: {
|
||||
cursorStyle: "block" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
parser,
|
||||
_core: {
|
||||
coreService: {
|
||||
decPrivateModes: {
|
||||
cursorStyle: "block" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const settingsRef = {
|
||||
current: {
|
||||
cursorShape: "bar",
|
||||
cursorBlink: true,
|
||||
},
|
||||
};
|
||||
|
||||
installUserCursorPreferenceGuard(term, settingsRef);
|
||||
const handled = handlers.get("| |q")?.([2]);
|
||||
|
||||
assert.equal(handled, false);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
assert.equal(term.options.cursorStyle, "block");
|
||||
assert.equal(term.options.cursorBlink, true);
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorStyle, "block");
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorBlink, undefined);
|
||||
});
|
||||
|
||||
test("installUserCursorPreferenceGuard restores cursor blink after private mode changes", async () => {
|
||||
const handlers = new Map<string, (params: readonly (number | number[])[]) => boolean>();
|
||||
const term = {
|
||||
options: {
|
||||
cursorStyle: "block" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
parser: {
|
||||
registerCsiHandler: (id: { prefix?: string; intermediates?: string; final: string }, callback: (params: readonly (number | number[])[]) => boolean) => {
|
||||
handlers.set(`${id.prefix ?? ""}|${id.intermediates ?? ""}|${id.final}`, callback);
|
||||
return { dispose: () => undefined };
|
||||
},
|
||||
},
|
||||
_core: {
|
||||
coreService: {
|
||||
decPrivateModes: {
|
||||
cursorStyle: "block" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const settingsRef = {
|
||||
current: {
|
||||
cursorShape: "underline",
|
||||
cursorBlink: true,
|
||||
},
|
||||
};
|
||||
|
||||
installUserCursorPreferenceGuard(term, settingsRef);
|
||||
const handled = handlers.get("?||l")?.([12]);
|
||||
|
||||
assert.equal(handled, false);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
assert.equal(term.options.cursorStyle, "block");
|
||||
assert.equal(term.options.cursorBlink, true);
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorStyle, "block");
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorBlink, undefined);
|
||||
});
|
||||
118
components/terminal/runtime/cursorPreference.ts
Normal file
118
components/terminal/runtime/cursorPreference.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { IDisposable, Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { RefObject } from "react";
|
||||
|
||||
import type { TerminalSettings } from "../../../types";
|
||||
|
||||
type CursorPreferenceSettings = Pick<TerminalSettings, "cursorShape" | "cursorBlink">;
|
||||
|
||||
type MutableCursorOptions = {
|
||||
cursorStyle?: "block" | "bar" | "underline";
|
||||
cursorBlink?: boolean;
|
||||
};
|
||||
|
||||
type TerminalLike = {
|
||||
options: MutableCursorOptions;
|
||||
parser?: {
|
||||
registerCsiHandler?: (
|
||||
id: { prefix?: string; intermediates?: string; final: string },
|
||||
callback: (params: readonly (number | number[])[]) => boolean,
|
||||
) => IDisposable;
|
||||
};
|
||||
_core?: {
|
||||
coreService?: {
|
||||
decPrivateModes?: {
|
||||
cursorStyle?: "block" | "bar" | "underline";
|
||||
cursorBlink?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const scheduleAfterDefaultHandler = (callback: () => void): void => {
|
||||
if (typeof queueMicrotask === "function") {
|
||||
queueMicrotask(callback);
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(callback, 0);
|
||||
};
|
||||
|
||||
const hasCursorBlinkPrivateModeParam = (params: readonly (number | number[])[]): boolean => (
|
||||
params.some((param) => (
|
||||
Array.isArray(param)
|
||||
? param.includes(12)
|
||||
: param === 12
|
||||
))
|
||||
);
|
||||
|
||||
export const resolveUserCursorPreference = (
|
||||
settings: Partial<CursorPreferenceSettings> | undefined,
|
||||
): Required<CursorPreferenceSettings> => ({
|
||||
cursorShape: settings?.cursorShape ?? "block",
|
||||
cursorBlink: settings?.cursorBlink ?? true,
|
||||
});
|
||||
|
||||
export const applyUserCursorPreference = (
|
||||
term: TerminalLike,
|
||||
settings: Partial<CursorPreferenceSettings> | undefined,
|
||||
): void => {
|
||||
const preference = resolveUserCursorPreference(settings);
|
||||
const privateModes = term._core?.coreService?.decPrivateModes;
|
||||
if (privateModes) {
|
||||
privateModes.cursorStyle = undefined;
|
||||
privateModes.cursorBlink = undefined;
|
||||
}
|
||||
term.options.cursorStyle = preference.cursorShape;
|
||||
term.options.cursorBlink = preference.cursorBlink;
|
||||
};
|
||||
|
||||
export const applyUserCursorBlinkPreference = (
|
||||
term: TerminalLike,
|
||||
settings: Partial<CursorPreferenceSettings> | undefined,
|
||||
): void => {
|
||||
const preference = resolveUserCursorPreference(settings);
|
||||
const privateModes = term._core?.coreService?.decPrivateModes;
|
||||
if (privateModes) {
|
||||
privateModes.cursorBlink = undefined;
|
||||
}
|
||||
term.options.cursorBlink = preference.cursorBlink;
|
||||
};
|
||||
|
||||
export const installUserCursorPreferenceGuard = (
|
||||
term: XTerm | TerminalLike,
|
||||
terminalSettingsRef: RefObject<TerminalSettings | undefined>,
|
||||
): IDisposable | null => {
|
||||
const terminal = term as TerminalLike;
|
||||
const parser = terminal.parser;
|
||||
if (!parser?.registerCsiHandler) return null;
|
||||
const registerCsiHandler = parser.registerCsiHandler.bind(parser);
|
||||
|
||||
const applyBlinkPreference = () => applyUserCursorBlinkPreference(terminal, terminalSettingsRef.current);
|
||||
|
||||
const cursorStyleDisposable = registerCsiHandler({ intermediates: " ", final: "q" }, () => {
|
||||
scheduleAfterDefaultHandler(applyBlinkPreference);
|
||||
return false;
|
||||
});
|
||||
|
||||
const cursorBlinkSetDisposable = registerCsiHandler({ prefix: "?", final: "h" }, (params) => {
|
||||
if (hasCursorBlinkPrivateModeParam(params)) {
|
||||
scheduleAfterDefaultHandler(applyBlinkPreference);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const cursorBlinkResetDisposable = registerCsiHandler({ prefix: "?", final: "l" }, (params) => {
|
||||
if (hasCursorBlinkPrivateModeParam(params)) {
|
||||
scheduleAfterDefaultHandler(applyBlinkPreference);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
cursorStyleDisposable.dispose();
|
||||
cursorBlinkSetDisposable.dispose();
|
||||
cursorBlinkResetDisposable.dispose();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -78,6 +78,7 @@ export interface Host {
|
||||
savePassword?: boolean; // Whether to save the password (default: true)
|
||||
authMethod?: 'password' | 'key' | 'certificate';
|
||||
agentForwarding?: boolean;
|
||||
x11Forwarding?: boolean;
|
||||
createdAt?: number; // Timestamp when host was created
|
||||
startupCommand?: string;
|
||||
hostChaining?: string; // Deprecated: use hostChain instead
|
||||
@@ -490,6 +491,13 @@ export interface TerminalSettings {
|
||||
|
||||
// SSH Connection
|
||||
keepaliveInterval: number; // Seconds between SSH-level keepalive packets (0 = disabled)
|
||||
x11Display: string; // Optional local X11 DISPLAY override (empty = use system DISPLAY/default)
|
||||
|
||||
// Mosh Connection
|
||||
// Absolute path to the local `mosh` client binary. Empty triggers
|
||||
// auto-discovery (PATH + Homebrew/MacPorts/nix fallbacks). When set,
|
||||
// the value is used as-is and a missing file produces a clear error.
|
||||
moshClientPath: string;
|
||||
|
||||
// Server Stats Display (Linux only)
|
||||
showServerStats: boolean; // Show CPU/Memory/Disk in terminal statusbar
|
||||
@@ -635,6 +643,8 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
localShell: '', // Empty = use system default
|
||||
localStartDir: '', // Empty = use home directory
|
||||
keepaliveInterval: 0, // 0 = disabled (use SSH library defaults)
|
||||
x11Display: '', // Empty = use DISPLAY/default local X server
|
||||
moshClientPath: '', // Empty = auto-detect mosh on PATH / common install dirs
|
||||
showServerStats: true, // Show server stats by default
|
||||
serverStatsRefreshInterval: 5, // Refresh every 5 seconds
|
||||
disableBracketedPaste: false, // Bracketed paste enabled by default
|
||||
|
||||
35
domain/sshConfigSerializer.test.ts
Normal file
35
domain/sshConfigSerializer.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { Host } from "./models.ts";
|
||||
import { serializeHostsToSshConfig } from "./sshConfigSerializer.ts";
|
||||
|
||||
const makeHost = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
label: "X11 Host",
|
||||
hostname: "x11.example.com",
|
||||
username: "root",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
os: "linux",
|
||||
tags: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("serializeHostsToSshConfig writes ForwardX11 for hosts with X11 forwarding enabled", () => {
|
||||
const config = serializeHostsToSshConfig([makeHost({ x11Forwarding: true })]);
|
||||
|
||||
assert.match(config, /ForwardX11 yes/);
|
||||
});
|
||||
|
||||
test("serializeHostsToSshConfig omits ForwardX11 when X11 forwarding is disabled", () => {
|
||||
const config = serializeHostsToSshConfig([makeHost({ x11Forwarding: false })]);
|
||||
|
||||
assert.doesNotMatch(config, /ForwardX11/);
|
||||
});
|
||||
|
||||
test("serializeHostsToSshConfig omits ForwardX11 for mosh hosts", () => {
|
||||
const config = serializeHostsToSshConfig([makeHost({ moshEnabled: true, x11Forwarding: true })]);
|
||||
|
||||
assert.doesNotMatch(config, /ForwardX11/);
|
||||
});
|
||||
@@ -113,6 +113,10 @@ export const serializeHostsToSshConfig = (hosts: Host[], allHosts?: Host[]): str
|
||||
lines.push(` Port ${host.port}`);
|
||||
}
|
||||
|
||||
if (host.x11Forwarding && !host.moshEnabled) {
|
||||
lines.push(" ForwardX11 yes");
|
||||
}
|
||||
|
||||
// Serialize IdentityFile paths
|
||||
if (host.identityFilePaths && host.identityFilePaths.length > 0) {
|
||||
for (const keyPath of host.identityFilePaths) {
|
||||
|
||||
28
domain/vaultImport.test.ts
Normal file
28
domain/vaultImport.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { importVaultHostsFromText } from "./vaultImport.ts";
|
||||
|
||||
test("ssh_config import maps ForwardX11 yes to host X11 forwarding", () => {
|
||||
const result = importVaultHostsFromText("ssh_config", [
|
||||
"Host x11-host",
|
||||
" HostName x11.example.com",
|
||||
" User root",
|
||||
" ForwardX11 yes",
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(result.hosts.length, 1);
|
||||
assert.equal(result.hosts[0].x11Forwarding, true);
|
||||
});
|
||||
|
||||
test("ssh_config import maps ForwardX11 no to disabled host X11 forwarding", () => {
|
||||
const result = importVaultHostsFromText("ssh_config", [
|
||||
"Host no-x11-host",
|
||||
" HostName no-x11.example.com",
|
||||
" User root",
|
||||
" ForwardX11 no",
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(result.hosts.length, 1);
|
||||
assert.equal(result.hosts[0].x11Forwarding, false);
|
||||
});
|
||||
@@ -526,6 +526,7 @@ const importFromSshConfig = (text: string): VaultImportResult => {
|
||||
port?: number;
|
||||
proxyJump?: string;
|
||||
identityFiles?: string[];
|
||||
forwardX11?: boolean;
|
||||
};
|
||||
|
||||
const blocks: Block[] = [];
|
||||
@@ -564,6 +565,7 @@ const importFromSshConfig = (text: string): VaultImportResult => {
|
||||
else if (keyword === "user") current.username = value;
|
||||
else if (keyword === "port") current.port = parsePort(value);
|
||||
else if (keyword === "proxyjump") current.proxyJump = value;
|
||||
else if (keyword === "forwardx11") current.forwardX11 = value.toLowerCase() === "yes";
|
||||
else if (keyword === "identityfile") {
|
||||
if (!current.identityFiles) current.identityFiles = [];
|
||||
// Remove surrounding quotes (ssh_config allows quoted paths with spaces)
|
||||
@@ -614,6 +616,9 @@ const importFromSshConfig = (text: string): VaultImportResult => {
|
||||
if (block.identityFiles && block.identityFiles.length > 0) {
|
||||
host.identityFilePaths = [...block.identityFiles];
|
||||
}
|
||||
if (block.forwardX11 !== undefined) {
|
||||
host.x11Forwarding = block.forwardX11;
|
||||
}
|
||||
|
||||
parsedHosts.push(host);
|
||||
|
||||
@@ -1092,4 +1097,3 @@ export const exportHostsToCsvWithStats = (hosts: Host[]): ExportHostsResult => {
|
||||
skippedCount: skippedHosts.length,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
const crypto = require("crypto");
|
||||
const { StringDecoder } = require("node:string_decoder");
|
||||
const iconv = require("iconv-lite");
|
||||
const { stripAnsi } = require("./shellUtils.cjs");
|
||||
const { stripAnsi, isDefaultPowerShellPromptLine } = require("./shellUtils.cjs");
|
||||
const { classifyLocalShellType } = require("../../../lib/localShell.cjs");
|
||||
|
||||
// Build a stateful decoder for a full exec call. Serial data events can
|
||||
@@ -86,6 +86,54 @@ function escapeCmdForNestedShell(text) {
|
||||
return String(text || "").replace(/"/g, '""').replace(/%/g, "%%");
|
||||
}
|
||||
|
||||
// Matches PowerShell's default prompt only (e.g. `PS C:\Users\alice>`,
|
||||
// `PS>`). Custom prompt functions (oh-my-posh, starship, PSReadLine themes
|
||||
// that emit `❯`/`λ`/etc.) intentionally fall through — we'd rather miss
|
||||
// the override than wrap a fish/zsh prompt as PowerShell. Pattern lives
|
||||
// in shellUtils.cjs so prompt extraction and wrapper selection share one
|
||||
// source of truth.
|
||||
function isPowerShellPrompt(prompt) {
|
||||
// Treat `\r` as a line break too so a PSReadLine/ConPTY redraw like
|
||||
// `PS C:\old>\rPS C:\new>` is matched against the redrawn last line,
|
||||
// not the doubled string.
|
||||
const lastLine = stripAnsi(String(prompt || ""))
|
||||
.replace(/\r/g, "\n")
|
||||
.split("\n")
|
||||
.pop()
|
||||
.replace(/\s+$/, "");
|
||||
return isDefaultPowerShellPromptLine(lastLine);
|
||||
}
|
||||
|
||||
// Prompt-driven override is intentionally narrow: only flip to PowerShell
|
||||
// when the session has no confirmed shell type. This keeps the issue #841
|
||||
// fix working (SSH/Telnet sessions never set shellKind — see
|
||||
// sshBridge.cjs:1265) while preventing a malicious remote process from
|
||||
// spoofing a `PS ...>` line on a real bash/zsh/fish/cmd session to coerce
|
||||
// a single mis-wrapped command.
|
||||
//
|
||||
// Universe of shellKind values (see lib/localShell.cjs:23-33 and
|
||||
// terminalBridge.cjs:368, :932, :1074):
|
||||
// "posix" | "powershell" | "cmd" | "fish" | "unknown" | "raw" | "" | undefined
|
||||
// Excluded on purpose:
|
||||
// - "posix" / "fish" / "cmd": confirmed POSIX-family or cmd.exe — never override.
|
||||
// - "powershell": already correct; no override needed (would be a no-op).
|
||||
// - "raw": serial / network device — execViaRawPty bypasses buildWrappedCommand.
|
||||
const SHELL_KINDS_OPEN_TO_PROMPT_OVERRIDE = new Set([
|
||||
"",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
function resolveEffectiveShellKind(shellKind, expectedPrompt) {
|
||||
const baseKind = shellKind || "";
|
||||
if (
|
||||
SHELL_KINDS_OPEN_TO_PROMPT_OVERRIDE.has(baseKind) &&
|
||||
isPowerShellPrompt(expectedPrompt)
|
||||
) {
|
||||
return "powershell";
|
||||
}
|
||||
return baseKind || "posix";
|
||||
}
|
||||
|
||||
function buildWrappedCommand(command, shellKind, marker) {
|
||||
switch (shellKind) {
|
||||
case "powershell": {
|
||||
@@ -305,7 +353,7 @@ function startPtyJob(ptyStream, command, options) {
|
||||
} = options || {};
|
||||
|
||||
const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
|
||||
const resolvedShellKind = shellKind || "posix";
|
||||
const resolvedShellKind = resolveEffectiveShellKind(shellKind, expectedPrompt);
|
||||
const CANCEL_RETRY_MS = 5000;
|
||||
const CANCEL_WALL_TIMEOUT_MS = 30000;
|
||||
|
||||
@@ -1133,5 +1181,6 @@ module.exports = {
|
||||
execViaChannel,
|
||||
execViaRawPty,
|
||||
detectShellKind,
|
||||
resolveEffectiveShellKind,
|
||||
stripAnsi,
|
||||
};
|
||||
|
||||
109
electron/bridges/ai/ptyExec.test.cjs
Normal file
109
electron/bridges/ai/ptyExec.test.cjs
Normal file
@@ -0,0 +1,109 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
resolveEffectiveShellKind,
|
||||
} = require("./ptyExec.cjs");
|
||||
|
||||
test("uses PowerShell wrapping when a session with no confirmed shell sees a PowerShell prompt", () => {
|
||||
// SSH sessions don't set shellKind (sshBridge never assigns one), which
|
||||
// is exactly the issue #841 case the override targets.
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind(undefined, "PS C:\\Users\\alice>"),
|
||||
"powershell",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses PowerShell wrapping when shellKind is 'unknown'", () => {
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind("unknown", "PS C:\\Users\\alice>"),
|
||||
"powershell",
|
||||
);
|
||||
});
|
||||
|
||||
test("does NOT override an explicit non-PowerShell shell kind even if the prompt looks like PowerShell", () => {
|
||||
// Defends against a malicious remote process spoofing a `PS ...>` line
|
||||
// on a real bash/zsh/cmd/fish/raw session to coerce a single
|
||||
// mis-wrapped command.
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind("posix", "PS C:\\Users\\alice>"),
|
||||
"posix",
|
||||
);
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind("fish", "PS C:\\Users\\alice>"),
|
||||
"fish",
|
||||
);
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind("cmd", "PS C:\\Users\\alice>"),
|
||||
"cmd",
|
||||
);
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind("raw", "PS C:\\Users\\alice>"),
|
||||
"raw",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps powershell wrapping for an explicit powershell session even when nested into a non-PS shell", () => {
|
||||
// After `wsl` or similar, a confirmed PowerShell session may show a
|
||||
// posix prompt. We currently keep PowerShell wrapping (the user's
|
||||
// configured shell is the source of truth). Reverse detection would
|
||||
// be a separate feature; this test locks the current behavior so a
|
||||
// future change is intentional.
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind("powershell", "alice@host:~$"),
|
||||
"powershell",
|
||||
);
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind("powershell", ""),
|
||||
"powershell",
|
||||
);
|
||||
});
|
||||
|
||||
test("recognizes a PowerShell prompt that has trailing whitespace", () => {
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind(undefined, "PS C:\\Users\\alice> "),
|
||||
"powershell",
|
||||
);
|
||||
});
|
||||
|
||||
test("recognizes a bare PowerShell prompt without a working directory", () => {
|
||||
assert.equal(resolveEffectiveShellKind(undefined, "PS>"), "powershell");
|
||||
});
|
||||
|
||||
test("recognizes PowerShell on Linux/macOS prompts (`PS /home/alice>`)", () => {
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind(undefined, "PS /home/alice>"),
|
||||
"powershell",
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores ANSI-coloured PowerShell prompts when detecting the shell", () => {
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind(undefined, "[32mPS C:\\Users\\alice>[0m"),
|
||||
"powershell",
|
||||
);
|
||||
});
|
||||
|
||||
test("treats a CR-redrawn last line as the effective prompt, not the doubled string", () => {
|
||||
// PSReadLine / ConPTY emit `\r` to repaint the current line. Without
|
||||
// CR-as-newline normalization the regex would match a doubled prompt
|
||||
// string that never round-trips through the live PTY tail.
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind(undefined, "PS C:\\old>\rPS C:\\new>"),
|
||||
"powershell",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects spoofed `PS >` (literal space then `>`) — default PowerShell never emits this", () => {
|
||||
assert.equal(resolveEffectiveShellKind(undefined, "PS >"), "posix");
|
||||
});
|
||||
|
||||
test("falls back to posix when neither shell kind nor prompt is informative", () => {
|
||||
assert.equal(resolveEffectiveShellKind(undefined, ""), "posix");
|
||||
assert.equal(resolveEffectiveShellKind(null, undefined), "posix");
|
||||
});
|
||||
|
||||
test("does not misclassify command output that happens to contain 'PS'", () => {
|
||||
assert.equal(resolveEffectiveShellKind(undefined, "PSO>"), "posix");
|
||||
assert.equal(resolveEffectiveShellKind(undefined, "ZIPS>"), "posix");
|
||||
});
|
||||
@@ -24,14 +24,33 @@ function stripAnsi(input) {
|
||||
return String(input || "").replace(ANSI_OSC_REGEX, "").replace(ANSI_ESCAPE_REGEX, "");
|
||||
}
|
||||
|
||||
// Default PowerShell prompt (e.g. `PS C:\Users\alice>`, `PS>`,
|
||||
// `PS /home/alice>`). Anchored so command output that merely starts with
|
||||
// `PS` (e.g. `PSO>`) doesn't match. The `\S` after `\s+` rejects literal
|
||||
// `"PS >"` (which the default prompt never emits) so a script that prints
|
||||
// such a line can't trick prompt-driven shell-kind selection.
|
||||
const POWERSHELL_PROMPT_PATTERN = /^PS(?:\s+\S.*)?>$/;
|
||||
|
||||
function isDefaultPowerShellPromptLine(line) {
|
||||
return POWERSHELL_PROMPT_PATTERN.test(String(line || ""));
|
||||
}
|
||||
|
||||
function extractTrailingIdlePrompt(output) {
|
||||
const normalized = stripAnsi(output).replace(/\r/g, "");
|
||||
// Treat `\r` as a line break, not as a stripped character: PSReadLine /
|
||||
// ConPTY repaints emit bare `\r` to redraw the current line, and we
|
||||
// want only the redrawn line to be considered, not the concatenation
|
||||
// of every overwritten frame.
|
||||
const normalized = stripAnsi(output).replace(/\r/g, "\n");
|
||||
if (!normalized || normalized.endsWith("\n")) return "";
|
||||
|
||||
const lastLine = normalized.split("\n").pop() || "";
|
||||
const rightTrimmed = lastLine.replace(/\s+$/, "");
|
||||
if (!rightTrimmed) return "";
|
||||
|
||||
if (isDefaultPowerShellPromptLine(rightTrimmed)) {
|
||||
return lastLine;
|
||||
}
|
||||
|
||||
if (/^[^\s@]+@[^\s:]+(?::[^\n\r]*)?[#$]$/.test(rightTrimmed)) {
|
||||
return lastLine;
|
||||
}
|
||||
@@ -54,6 +73,32 @@ function trackSessionIdlePrompt(session, chunk) {
|
||||
return prompt;
|
||||
}
|
||||
|
||||
// Return `session.lastIdlePrompt` only if the PTY's recent rolling tail
|
||||
// still ends with it. The cached prompt is updated only when
|
||||
// extractTrailingIdlePrompt recognizes a known shape (PowerShell or
|
||||
// `user@host[:path][#$]`); a remote shell switch into cmd.exe, an
|
||||
// oh-my-posh / starship / custom PS1, or any unrecognized prompt would
|
||||
// otherwise leave a stale value behind, which `resolveEffectiveShellKind`
|
||||
// would then keep using to coerce future commands into a PowerShell
|
||||
// wrapper. By re-checking the live tail we self-correct: if the visible
|
||||
// last line no longer matches the cached prompt, the prompt is treated
|
||||
// as expired and downstream wrapper selection / suffix matching falls
|
||||
// back to `shellKind` alone.
|
||||
function getFreshIdlePrompt(session) {
|
||||
if (!session) return "";
|
||||
const cached = session.lastIdlePrompt;
|
||||
if (!cached) return "";
|
||||
|
||||
const tail = session._promptTrackTail;
|
||||
if (typeof tail !== "string" || !tail) return "";
|
||||
|
||||
const normalizedTail = stripAnsi(tail).replace(/\r/g, "\n");
|
||||
const normalizedCached = stripAnsi(cached).replace(/\r/g, "\n");
|
||||
if (!normalizedCached) return "";
|
||||
|
||||
return normalizedTail.endsWith(normalizedCached) ? cached : "";
|
||||
}
|
||||
|
||||
// ── URL helpers ──
|
||||
|
||||
function isLocalhostHostname(hostname) {
|
||||
@@ -319,6 +364,8 @@ function serializeStreamChunk(chunk) {
|
||||
module.exports = {
|
||||
stripAnsi,
|
||||
extractTrailingIdlePrompt,
|
||||
getFreshIdlePrompt,
|
||||
isDefaultPowerShellPromptLine,
|
||||
trackSessionIdlePrompt,
|
||||
isLocalhostHostname,
|
||||
extractFirstNonLocalhostUrl,
|
||||
|
||||
140
electron/bridges/ai/shellUtils.test.cjs
Normal file
140
electron/bridges/ai/shellUtils.test.cjs
Normal file
@@ -0,0 +1,140 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
extractTrailingIdlePrompt,
|
||||
getFreshIdlePrompt,
|
||||
isDefaultPowerShellPromptLine,
|
||||
trackSessionIdlePrompt,
|
||||
} = require("./shellUtils.cjs");
|
||||
|
||||
test("extracts a trailing PowerShell idle prompt", () => {
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("Microsoft Windows...\r\nPS C:\\Users\\alice>"),
|
||||
"PS C:\\Users\\alice>",
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves trailing whitespace on a captured PowerShell prompt", () => {
|
||||
// The wrapper-selection logic trims this, but the suffix-match logic in
|
||||
// hasExpectedPromptSuffix() compares against raw PTY bytes, so the trailing
|
||||
// space PowerShell emits after `>` must round-trip unchanged.
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("Microsoft Windows...\r\nPS C:\\Users\\alice> "),
|
||||
"PS C:\\Users\\alice> ",
|
||||
);
|
||||
});
|
||||
|
||||
test("extracts a bare PowerShell prompt with no working directory", () => {
|
||||
assert.equal(extractTrailingIdlePrompt("welcome\r\nPS>"), "PS>");
|
||||
});
|
||||
|
||||
test("does not extract content that merely looks PowerShell-ish", () => {
|
||||
// Any non-prompt output ending in `PSO>` or `ZIPS>` would have produced a
|
||||
// trailing newline before the next prompt; this guards against the regex
|
||||
// accidentally matching command output that just happens to contain "PS".
|
||||
assert.equal(extractTrailingIdlePrompt("nope\r\nPSO>"), "");
|
||||
assert.equal(extractTrailingIdlePrompt("nope\r\nZIPS>"), "");
|
||||
});
|
||||
|
||||
test("rejects `PS >` (literal `PS` + space + `>`) so spoofed scripts can't masquerade as a default prompt", () => {
|
||||
// Default PowerShell never emits this shape; rejecting it makes the
|
||||
// override harder to coerce via printed output.
|
||||
assert.equal(extractTrailingIdlePrompt("welcome\r\nPS >"), "");
|
||||
});
|
||||
|
||||
test("treats CR repaints as line breaks so only the redrawn line is captured", () => {
|
||||
// PSReadLine / ConPTY emit bare `\r` to repaint the current line. The
|
||||
// captured prompt must equal the visible last line, not the
|
||||
// concatenation of every overwritten frame, so hasExpectedPromptSuffix
|
||||
// can still match the live PTY tail later.
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("PS C:\\old>\rPS C:\\new>"),
|
||||
"PS C:\\new>",
|
||||
);
|
||||
});
|
||||
|
||||
test("isDefaultPowerShellPromptLine matches default shapes and rejects look-alikes", () => {
|
||||
assert.equal(isDefaultPowerShellPromptLine("PS C:\\Users\\alice>"), true);
|
||||
assert.equal(isDefaultPowerShellPromptLine("PS /home/alice>"), true);
|
||||
assert.equal(isDefaultPowerShellPromptLine("PS>"), true);
|
||||
assert.equal(isDefaultPowerShellPromptLine("PS >"), false);
|
||||
assert.equal(isDefaultPowerShellPromptLine("PSO>"), false);
|
||||
assert.equal(isDefaultPowerShellPromptLine("ZIPS>"), false);
|
||||
assert.equal(isDefaultPowerShellPromptLine(""), false);
|
||||
assert.equal(isDefaultPowerShellPromptLine(null), false);
|
||||
});
|
||||
|
||||
test("tracks PowerShell idle prompt after SSH output", () => {
|
||||
const session = {};
|
||||
|
||||
const prompt = trackSessionIdlePrompt(session, "Last login...\r\nPS C:\\Windows\\System32>");
|
||||
|
||||
assert.equal(prompt, "PS C:\\Windows\\System32>");
|
||||
assert.equal(session.lastIdlePrompt, "PS C:\\Windows\\System32>");
|
||||
assert.equal(typeof session.lastIdlePromptAt, "number");
|
||||
});
|
||||
|
||||
test("getFreshIdlePrompt returns the cached prompt when the live tail still ends with it", () => {
|
||||
const session = {
|
||||
lastIdlePrompt: "PS C:\\Users\\alice>",
|
||||
_promptTrackTail: "Microsoft Windows...\r\nPS C:\\Users\\alice>",
|
||||
};
|
||||
assert.equal(getFreshIdlePrompt(session), "PS C:\\Users\\alice>");
|
||||
});
|
||||
|
||||
test("getFreshIdlePrompt drops a stale prompt when the live tail has moved on (e.g. exited PowerShell)", () => {
|
||||
// Simulates: SSH session entered PowerShell, captured `PS C:\>`, then
|
||||
// user `exit`-ed back into a shell with a custom prompt the regex
|
||||
// doesn't recognize. lastIdlePrompt is still the old PS line, but the
|
||||
// visible tail now shows the new prompt — we must NOT keep handing
|
||||
// the stale value to resolveEffectiveShellKind.
|
||||
const session = {
|
||||
lastIdlePrompt: "PS C:\\Users\\alice>",
|
||||
_promptTrackTail: "PS C:\\Users\\alice>\r\nexit\r\nlogout\r\n❯ ",
|
||||
};
|
||||
assert.equal(getFreshIdlePrompt(session), "");
|
||||
});
|
||||
|
||||
test("getFreshIdlePrompt drops a stale prompt when the live tail switched to cmd.exe", () => {
|
||||
const session = {
|
||||
lastIdlePrompt: "PS C:\\Users\\alice>",
|
||||
_promptTrackTail: "PS C:\\Users\\alice>\r\ncmd\r\nMicrosoft Windows...\r\nC:\\Users\\alice>",
|
||||
};
|
||||
assert.equal(getFreshIdlePrompt(session), "");
|
||||
});
|
||||
|
||||
test("getFreshIdlePrompt tolerates ANSI colour codes that wrap the prompt in either side", () => {
|
||||
const session = {
|
||||
lastIdlePrompt: "PS C:\\Users\\alice>",
|
||||
_promptTrackTail: "stuff\r\n[32mPS C:\\Users\\alice>[0m",
|
||||
};
|
||||
assert.equal(getFreshIdlePrompt(session), "PS C:\\Users\\alice>");
|
||||
});
|
||||
|
||||
test("getFreshIdlePrompt returns empty string when the session has no cached prompt or tail", () => {
|
||||
assert.equal(getFreshIdlePrompt(null), "");
|
||||
assert.equal(getFreshIdlePrompt(undefined), "");
|
||||
assert.equal(getFreshIdlePrompt({}), "");
|
||||
assert.equal(getFreshIdlePrompt({ lastIdlePrompt: "PS C:\\>" }), "");
|
||||
assert.equal(
|
||||
getFreshIdlePrompt({ lastIdlePrompt: "", _promptTrackTail: "anything" }),
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
test("getFreshIdlePrompt and trackSessionIdlePrompt round-trip through a real PTY-like flow", () => {
|
||||
// (1) Remote PowerShell prompt arrives — lastIdlePrompt is captured.
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, "Microsoft Windows...\r\nPS C:\\Users\\alice>");
|
||||
assert.equal(getFreshIdlePrompt(session), "PS C:\\Users\\alice>");
|
||||
|
||||
// (2) User runs `exit` and the shell now shows an unrecognized prompt.
|
||||
// trackSessionIdlePrompt does not update lastIdlePrompt (the new shape
|
||||
// doesn't match POSIX or PowerShell regexes), so the cache is stale.
|
||||
trackSessionIdlePrompt(session, "\r\nexit\r\nlogout\r\n❯ ");
|
||||
assert.equal(session.lastIdlePrompt, "PS C:\\Users\\alice>"); // unchanged
|
||||
// The freshness check rescues us: the visible tail no longer ends
|
||||
// with the cached PS line, so downstream wrapper selection sees "".
|
||||
assert.equal(getFreshIdlePrompt(session), "");
|
||||
});
|
||||
@@ -30,6 +30,7 @@ const {
|
||||
resolveCliFromPath,
|
||||
resolveClaudeAcpBinaryPath,
|
||||
getShellEnv,
|
||||
getFreshIdlePrompt,
|
||||
invalidateShellEnvCache,
|
||||
serializeStreamChunk,
|
||||
toUnpackedAsarPath,
|
||||
@@ -1322,7 +1323,7 @@ function registerHandlers(ipcMain) {
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
chatSessionId,
|
||||
expectedPrompt: session.lastIdlePrompt || "",
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => {
|
||||
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
|
||||
|
||||
@@ -12,7 +12,7 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { existsSync } = require("node:fs");
|
||||
|
||||
const { toUnpackedAsarPath } = require("./ai/shellUtils.cjs");
|
||||
const { toUnpackedAsarPath, getFreshIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
const { execViaPty, startPtyJob, execViaChannel, execViaRawPty } = require("./ai/ptyExec.cjs");
|
||||
const { safeSend } = require("./ipcUtils.cjs");
|
||||
const { getCliDiscoveryFilePath } = require("../cli/discoveryPath.cjs");
|
||||
@@ -1493,7 +1493,7 @@ function handleExec(params) {
|
||||
trackForCancellation: activePtyExecs,
|
||||
timeoutMs: commandTimeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
expectedPrompt: session.lastIdlePrompt || "",
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
|
||||
chatSessionId,
|
||||
@@ -1581,7 +1581,7 @@ function handleJobStart(params) {
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
chatSessionId,
|
||||
expectedPrompt: session.lastIdlePrompt || "",
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
|
||||
maxBufferedChars: MAX_BACKGROUND_JOB_OUTPUT_CHARS,
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { toLocalISOString, stripAnsi, terminalDataToHtml } = require("./sessionLogsBridge.cjs");
|
||||
const {
|
||||
toLocalISOString,
|
||||
wrapTerminalHtmlContent,
|
||||
} = require("./sessionLogsBridge.cjs");
|
||||
const { createTerminalTextRenderer } = require("./terminalLogSanitizer.cjs");
|
||||
|
||||
// Active log streams keyed by sessionId
|
||||
const activeStreams = new Map();
|
||||
@@ -42,34 +46,45 @@ function startStream(sessionId, opts) {
|
||||
|
||||
const date = new Date(startTime || Date.now());
|
||||
const dateStr = toLocalISOString(date);
|
||||
// For html format, write raw data to a temp file during streaming,
|
||||
// then convert on stopStream.
|
||||
// Raw logs are written directly. Txt/html logs keep terminal parser state
|
||||
// in memory and write the rendered file on each flush.
|
||||
const isRaw = format === "raw";
|
||||
const isHtml = format === "html";
|
||||
const ext = isHtml ? "log.tmp" : format === "raw" ? "log" : "txt";
|
||||
const ext = isRaw ? "log" : isHtml ? "html" : "txt";
|
||||
const fileName = `${dateStr}.${ext}`;
|
||||
const filePath = path.join(hostDir, fileName);
|
||||
|
||||
const writeStream = fs.createWriteStream(filePath, { flags: "w", encoding: "utf8" });
|
||||
const writeStream = isRaw
|
||||
? fs.createWriteStream(filePath, { flags: "w", encoding: "utf8" })
|
||||
: null;
|
||||
|
||||
writeStream.on("error", (err) => {
|
||||
console.error(`[SessionLogStream] Write error for ${sessionId}:`, err.message);
|
||||
// Disable this stream on error to avoid cascading failures
|
||||
const entry = activeStreams.get(sessionId);
|
||||
if (entry) {
|
||||
entry.disabled = true;
|
||||
}
|
||||
});
|
||||
if (writeStream) {
|
||||
writeStream.on("error", (err) => {
|
||||
console.error(`[SessionLogStream] Write error for ${sessionId}:`, err.message);
|
||||
// Disable this stream on error to avoid cascading failures
|
||||
const entry = activeStreams.get(sessionId);
|
||||
if (entry) {
|
||||
entry.disabled = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const entry = {
|
||||
writeStream,
|
||||
filePath,
|
||||
hostDir,
|
||||
format,
|
||||
isRaw,
|
||||
isHtml,
|
||||
renderer: isRaw ? null : createTerminalTextRenderer(),
|
||||
hostLabel: hostLabel || hostname || "unknown",
|
||||
startTime: startTime || Date.now(),
|
||||
buffer: "",
|
||||
flushTimer: null,
|
||||
snapshotPromise: null,
|
||||
snapshotRequested: false,
|
||||
snapshotDirty: false,
|
||||
closing: false,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
@@ -96,14 +111,12 @@ function flushBuffer(entry) {
|
||||
const data = entry.buffer;
|
||||
entry.buffer = "";
|
||||
|
||||
if (entry.isHtml) {
|
||||
// For HTML format, write raw data during streaming; convert on close
|
||||
entry.writeStream.write(data);
|
||||
} else if (entry.format === "raw") {
|
||||
if (entry.isRaw) {
|
||||
entry.writeStream.write(data);
|
||||
} else {
|
||||
// txt format: strip ANSI codes
|
||||
entry.writeStream.write(stripAnsi(data));
|
||||
entry.renderer.feed(data);
|
||||
entry.snapshotDirty = true;
|
||||
scheduleSnapshot(entry);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[SessionLogStream] Flush error:", err.message);
|
||||
@@ -111,6 +124,43 @@ function flushBuffer(entry) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderSnapshotContent(entry) {
|
||||
return entry.isHtml
|
||||
? wrapTerminalHtmlContent(entry.renderer.toHtmlContent(), entry.hostLabel, entry.startTime)
|
||||
: entry.renderer.toString();
|
||||
}
|
||||
|
||||
function scheduleSnapshot(entry) {
|
||||
if (!entry || entry.disabled || entry.isRaw || entry.closing) return;
|
||||
if (!entry.snapshotDirty) return;
|
||||
|
||||
if (entry.snapshotPromise) {
|
||||
entry.snapshotRequested = true;
|
||||
return;
|
||||
}
|
||||
|
||||
entry.snapshotDirty = false;
|
||||
entry.snapshotPromise = fs.promises
|
||||
.writeFile(entry.filePath, renderSnapshotContent(entry), "utf8")
|
||||
.catch((err) => {
|
||||
console.error("[SessionLogStream] Snapshot write failed:", err.message);
|
||||
entry.snapshotDirty = true;
|
||||
})
|
||||
.finally(() => {
|
||||
entry.snapshotPromise = null;
|
||||
if ((entry.snapshotRequested || entry.snapshotDirty) && !entry.closing) {
|
||||
entry.snapshotRequested = false;
|
||||
scheduleSnapshot(entry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForSnapshotIdle(entry) {
|
||||
while (entry.snapshotPromise) {
|
||||
await entry.snapshotPromise;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append data to the session's log buffer.
|
||||
* Data is flushed periodically or when the buffer exceeds MAX_BUFFER_SIZE.
|
||||
@@ -139,6 +189,7 @@ async function stopStream(sessionId) {
|
||||
const entry = activeStreams.get(sessionId);
|
||||
if (!entry) return null;
|
||||
activeStreams.delete(sessionId);
|
||||
entry.closing = true;
|
||||
|
||||
// Stop periodic flush
|
||||
if (entry.flushTimer) {
|
||||
@@ -148,34 +199,25 @@ async function stopStream(sessionId) {
|
||||
|
||||
// Flush remaining buffer
|
||||
flushBuffer(entry);
|
||||
await waitForSnapshotIdle(entry);
|
||||
|
||||
// Close the write stream and wait for it to finish
|
||||
await new Promise((resolve) => {
|
||||
entry.writeStream.end(resolve);
|
||||
});
|
||||
|
||||
let finalPath = entry.filePath;
|
||||
|
||||
// For HTML format: read the temp raw file and convert to HTML
|
||||
if (entry.isHtml && !entry.disabled) {
|
||||
// Close the raw write stream and wait for it to finish.
|
||||
if (entry.writeStream) {
|
||||
await new Promise((resolve) => {
|
||||
entry.writeStream.end(resolve);
|
||||
});
|
||||
} else if (!entry.disabled && entry.snapshotDirty) {
|
||||
try {
|
||||
const rawData = await fs.promises.readFile(entry.filePath, "utf8");
|
||||
const htmlContent = terminalDataToHtml(rawData, entry.hostLabel, entry.startTime);
|
||||
const htmlPath = entry.filePath.replace(/\.log\.tmp$/, ".html");
|
||||
await fs.promises.writeFile(htmlPath, htmlContent, "utf8");
|
||||
// Remove temp file
|
||||
try {
|
||||
await fs.promises.unlink(entry.filePath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
finalPath = htmlPath;
|
||||
await fs.promises.writeFile(entry.filePath, renderSnapshotContent(entry), "utf8");
|
||||
entry.snapshotDirty = false;
|
||||
} catch (err) {
|
||||
console.error(`[SessionLogStream] HTML conversion failed for ${sessionId}:`, err.message);
|
||||
// Keep the raw temp file as fallback
|
||||
console.error(`[SessionLogStream] Final snapshot write failed for ${sessionId}:`, err.message);
|
||||
entry.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
const finalPath = entry.filePath;
|
||||
|
||||
console.log(`[SessionLogStream] Stopped stream for ${sessionId} -> ${finalPath}`);
|
||||
return finalPath;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { dialog } = require("electron");
|
||||
const {
|
||||
terminalDataToHtmlContent,
|
||||
terminalDataToPlainText,
|
||||
} = require("./terminalLogSanitizer.cjs");
|
||||
|
||||
/**
|
||||
* Get current Date to a local ISO-like string (YYYY-MM-DDTHH-MM-SS)
|
||||
@@ -23,22 +27,6 @@ function toLocalISOString(date = new Date()) {
|
||||
return `${year}-${month}-${day}T${hours}-${minutes}-${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ANSI escape codes from text
|
||||
* Used for plain text export format
|
||||
*/
|
||||
function stripAnsi(str) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return str
|
||||
// OSC: ESC ] ... BEL or ESC ] ... ESC \
|
||||
.replace(/\x1B\][\s\S]*?(?:\x07|\x1B\\)/g, '')
|
||||
// ANSI CSI / ESC sequences
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "")
|
||||
// Remove remaining control chars except \n \r \t
|
||||
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters to prevent XSS
|
||||
* Must be applied before converting ANSI codes to HTML spans
|
||||
@@ -52,75 +40,12 @@ function escapeHtml(str) {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert terminal data to HTML with colors preserved
|
||||
*/
|
||||
function terminalDataToHtml(terminalData, hostLabel, timestamp) {
|
||||
// Basic ANSI to HTML conversion for common codes
|
||||
const ansiToHtml = (text) => {
|
||||
const colorMap = {
|
||||
"30": "color: #000",
|
||||
"31": "color: #c00",
|
||||
"32": "color: #0c0",
|
||||
"33": "color: #cc0",
|
||||
"34": "color: #00c",
|
||||
"35": "color: #c0c",
|
||||
"36": "color: #0cc",
|
||||
"37": "color: #ccc",
|
||||
"90": "color: #666",
|
||||
"91": "color: #f66",
|
||||
"92": "color: #6f6",
|
||||
"93": "color: #ff6",
|
||||
"94": "color: #66f",
|
||||
"95": "color: #f6f",
|
||||
"96": "color: #6ff",
|
||||
"97": "color: #fff",
|
||||
"40": "background: #000",
|
||||
"41": "background: #c00",
|
||||
"42": "background: #0c0",
|
||||
"43": "background: #cc0",
|
||||
"44": "background: #00c",
|
||||
"45": "background: #c0c",
|
||||
"46": "background: #0cc",
|
||||
"47": "background: #ccc",
|
||||
"1": "font-weight: bold",
|
||||
"3": "font-style: italic",
|
||||
"4": "text-decoration: underline",
|
||||
};
|
||||
function terminalPlainTextToHtml(plainText, hostLabel, timestamp) {
|
||||
const htmlContent = escapeHtml(plainText || "");
|
||||
return wrapTerminalHtmlContent(htmlContent, hostLabel, timestamp);
|
||||
}
|
||||
|
||||
// First, escape HTML in the text content (not the ANSI codes)
|
||||
// We do this by splitting on ANSI sequences, escaping each text part, then rejoining
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ansiRegex = /(\x1B\[[0-9;]*m|\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]))/g;
|
||||
const parts = text.split(ansiRegex);
|
||||
|
||||
let result = parts.map((part) => {
|
||||
// Check if this part is an ANSI sequence
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/^\x1B/.test(part)) {
|
||||
// It's an ANSI sequence, convert to HTML span or remove
|
||||
const match = part.match(/^\x1B\[([0-9;]*)m$/);
|
||||
if (match) {
|
||||
const codes = match[1];
|
||||
if (codes === "0" || codes === "") {
|
||||
return "</span>";
|
||||
}
|
||||
const styles = codes.split(";").map((c) => colorMap[c]).filter(Boolean);
|
||||
if (styles.length > 0) {
|
||||
return `<span style="${styles.join("; ")}">`;
|
||||
}
|
||||
}
|
||||
// Other ANSI sequences are stripped
|
||||
return "";
|
||||
}
|
||||
// It's regular text, escape HTML
|
||||
return escapeHtml(part);
|
||||
}).join("");
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const htmlContent = ansiToHtml(terminalData);
|
||||
function wrapTerminalHtmlContent(htmlContent, hostLabel, timestamp) {
|
||||
const dateStr = new Date(timestamp).toLocaleString();
|
||||
const safeHostLabel = escapeHtml(hostLabel || "Unknown");
|
||||
const safeDateStr = escapeHtml(dateStr);
|
||||
@@ -154,11 +79,19 @@ function terminalDataToHtml(terminalData, hostLabel, timestamp) {
|
||||
Host: ${safeHostLabel}<br>
|
||||
Date: ${safeDateStr}
|
||||
</div>
|
||||
<div class="content">${htmlContent}</div>
|
||||
<div class="content">${htmlContent || ""}</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert terminal data to HTML after applying terminal text controls while
|
||||
* preserving SGR styles such as color, bold, italic, and underline.
|
||||
*/
|
||||
function terminalDataToHtml(terminalData, hostLabel, timestamp) {
|
||||
return wrapTerminalHtmlContent(terminalDataToHtmlContent(terminalData), hostLabel, timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a session log to a file (manual export via save dialog)
|
||||
*/
|
||||
@@ -201,8 +134,8 @@ async function exportSessionLog(event, payload) {
|
||||
// Raw format preserves ANSI codes
|
||||
content = terminalData;
|
||||
} else {
|
||||
// Plain text - strip ANSI codes
|
||||
content = stripAnsi(terminalData);
|
||||
// Plain text - apply terminal text controls and remove escape sequences
|
||||
content = terminalDataToPlainText(terminalData);
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(result.filePath, content, "utf8");
|
||||
@@ -258,7 +191,7 @@ async function autoSaveSessionLog(event, payload) {
|
||||
} else if (format === "raw") {
|
||||
content = terminalData;
|
||||
} else {
|
||||
content = stripAnsi(terminalData);
|
||||
content = terminalDataToPlainText(terminalData);
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(filePath, content, "utf8");
|
||||
@@ -307,7 +240,8 @@ module.exports = {
|
||||
selectSessionLogsDir,
|
||||
autoSaveSessionLog,
|
||||
openSessionLogsDir,
|
||||
stripAnsi,
|
||||
toLocalISOString,
|
||||
terminalDataToHtml,
|
||||
terminalPlainTextToHtml,
|
||||
wrapTerminalHtmlContent,
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
const { attachX11Forwarding } = require("./x11Forwarding.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
@@ -1182,6 +1183,7 @@ async function startSSHSession(event, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const logPrefix = hasJumpHosts ? '[Chain]' : '[SSH]';
|
||||
let settled = false;
|
||||
let detachX11Forwarding = null;
|
||||
|
||||
conn.once("handshake", () => {
|
||||
console.log(`${logPrefix} ${options.hostname} handshake complete`);
|
||||
@@ -1202,26 +1204,57 @@ async function startSSHSession(event, options) {
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'authenticated');
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'shell');
|
||||
|
||||
const sendTerminalMessage = (data) => {
|
||||
safeSend(event.sender, "netcatty:data", { sessionId, data });
|
||||
};
|
||||
|
||||
const x11FakeCookie = options.x11Forwarding
|
||||
? crypto.randomBytes(16).toString("hex")
|
||||
: null;
|
||||
|
||||
if (options.x11Forwarding) {
|
||||
detachX11Forwarding = attachX11Forwarding(conn, {
|
||||
display: options.x11Display,
|
||||
fakeCookie: x11FakeCookie,
|
||||
sendMessage: sendTerminalMessage,
|
||||
});
|
||||
}
|
||||
|
||||
const shellOptions = {
|
||||
env: {
|
||||
LANG: resolveLangFromCharset(options.charset),
|
||||
COLORTERM: "truecolor",
|
||||
...(options.env || {}),
|
||||
},
|
||||
};
|
||||
|
||||
if (options.x11Forwarding) {
|
||||
shellOptions.x11 = {
|
||||
protocol: "MIT-MAGIC-COOKIE-1",
|
||||
cookie: x11FakeCookie,
|
||||
screen: 0,
|
||||
single: false,
|
||||
};
|
||||
}
|
||||
|
||||
conn.shell(
|
||||
{
|
||||
term: "xterm-256color",
|
||||
cols,
|
||||
rows,
|
||||
},
|
||||
{
|
||||
env: {
|
||||
LANG: resolveLangFromCharset(options.charset),
|
||||
COLORTERM: "truecolor",
|
||||
...(options.env || {}),
|
||||
},
|
||||
},
|
||||
shellOptions,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
if (detachX11Forwarding) detachX11Forwarding();
|
||||
settled = true;
|
||||
conn.end();
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
if (options.x11Forwarding && /x11/i.test(err.message || "")) {
|
||||
sendTerminalMessage("\r\n[X11] Could not enable X11 forwarding. Make sure X11 forwarding is allowed on the server and xauth is installed.\r\n");
|
||||
}
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'error', `Failed to open shell: ${err.message}`);
|
||||
reject(err);
|
||||
return;
|
||||
@@ -1349,6 +1382,10 @@ async function startSSHSession(event, options) {
|
||||
}
|
||||
flushBuffer();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
if (detachX11Forwarding) {
|
||||
detachX11Forwarding();
|
||||
detachX11Forwarding = null;
|
||||
}
|
||||
|
||||
// Only send exit if session hasn't already been cleaned up by
|
||||
// conn.once("close") — which fires before stream.on("close")
|
||||
@@ -1431,6 +1468,10 @@ async function startSSHSession(event, options) {
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'error', err.message);
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
if (detachX11Forwarding) {
|
||||
detachX11Forwarding();
|
||||
detachX11Forwarding = null;
|
||||
}
|
||||
sessions.get(sessionId)?.zmodemSentry?.cancel();
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
|
||||
@@ -117,6 +117,102 @@ function createPtyBuffer(sendFn) {
|
||||
return { bufferData, flush };
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate an executable on POSIX systems by name.
|
||||
*
|
||||
* macOS GUI Electron apps inherit launchd's minimal PATH
|
||||
* (`/usr/bin:/bin:/usr/sbin:/sbin`), missing Homebrew and other common
|
||||
* package-manager directories. `pty.spawn(name)` then either fails
|
||||
* synchronously with ENOENT or spawns a child that immediately exits
|
||||
* with no useful error surfaced to the renderer (see issue #842 for the
|
||||
* Mosh case).
|
||||
*
|
||||
* Returns the absolute path on success, or null when the binary cannot
|
||||
* be located anywhere we know to look. Win32 callers should keep using
|
||||
* findExecutable() which handles `where.exe` + Windows-specific paths.
|
||||
*/
|
||||
const POSIX_EXTRA_PATH_DIRS = [
|
||||
"/opt/homebrew/bin",
|
||||
"/opt/homebrew/sbin",
|
||||
"/usr/local/bin",
|
||||
"/usr/local/sbin",
|
||||
"/opt/local/bin",
|
||||
"/opt/local/sbin",
|
||||
"/usr/bin",
|
||||
"/bin",
|
||||
"/usr/sbin",
|
||||
"/sbin",
|
||||
];
|
||||
|
||||
function isExecutableFile(candidate) {
|
||||
try {
|
||||
const st = fs.statSync(candidate);
|
||||
if (!st.isFile()) return false;
|
||||
// Windows has no POSIX execute bit — Node returns mode 0o100666 even for
|
||||
// .exe / .bat / .cmd files, so 0o111 is unreliable there. Treat any
|
||||
// regular file as executable on Win32 and let spawn-time PATHEXT /
|
||||
// extension handling reject non-executables.
|
||||
if (process.platform === "win32") return true;
|
||||
return (st.mode & 0o111) !== 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePosixExecutable(name, opts = {}) {
|
||||
if (process.platform === "win32") return null;
|
||||
if (!name || typeof name !== "string") return null;
|
||||
|
||||
// Already an absolute or relative path: validate as-is.
|
||||
if (name.includes("/")) {
|
||||
return isExecutableFile(name) ? name : null;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9._+-]+$/.test(name)) return null;
|
||||
|
||||
const seen = new Set();
|
||||
const dirs = [];
|
||||
|
||||
// 1. Honor the caller-supplied PATH first so callers that have already
|
||||
// merged a host-level environmentVariables.PATH override don't see the
|
||||
// fallback decline a binary that the spawned process would have found.
|
||||
// Falls back to the main process PATH when no override is provided.
|
||||
const pathOverride = Object.prototype.hasOwnProperty.call(opts, "pathOverride")
|
||||
? opts.pathOverride
|
||||
: process.env.PATH;
|
||||
for (const dir of (pathOverride || "").split(":")) {
|
||||
if (dir && !seen.has(dir)) {
|
||||
seen.add(dir);
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Add directories the GUI launcher's PATH typically misses on macOS/Linux.
|
||||
for (const dir of POSIX_EXTRA_PATH_DIRS) {
|
||||
if (!seen.has(dir)) {
|
||||
seen.add(dir);
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. User-scoped install locations (nix-profile, cargo, ~/.local).
|
||||
const home = process.env.HOME;
|
||||
if (home) {
|
||||
for (const sub of [".nix-profile/bin", ".cargo/bin", ".local/bin"]) {
|
||||
const dir = path.join(home, sub);
|
||||
if (!seen.has(dir)) {
|
||||
seen.add(dir);
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of dirs) {
|
||||
const candidate = path.join(dir, name);
|
||||
if (isExecutableFile(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find executable path on Windows
|
||||
*/
|
||||
@@ -691,13 +787,68 @@ async function startMoshSession(event, options) {
|
||||
const cols = options.cols || 80;
|
||||
const rows = options.rows || 24;
|
||||
|
||||
let moshCmd = 'mosh';
|
||||
if (process.platform === 'win32') {
|
||||
moshCmd = findExecutable('mosh') || 'mosh.exe';
|
||||
// Resolve the mosh client to an absolute path before spawning. Bare names
|
||||
// rely on the spawn-time PATH search, which on macOS GUI apps is reduced to
|
||||
// `/usr/bin:/bin:/usr/sbin:/sbin` and silently fails for Homebrew installs
|
||||
// (see issue #842). On Windows keep the existing behaviour.
|
||||
//
|
||||
// Resolution must consider the same PATH the spawned process will see —
|
||||
// host-level `environmentVariables.PATH` is merged into the child env
|
||||
// below, so the resolver checks that merged value first to avoid
|
||||
// rejecting a binary the child would actually have found.
|
||||
const optionsEnv = options.env || {};
|
||||
const mergedPathForResolution = Object.prototype.hasOwnProperty.call(optionsEnv, "PATH")
|
||||
? optionsEnv.PATH
|
||||
: process.env.PATH;
|
||||
|
||||
let moshCmd;
|
||||
let resolvedMoshDir = null;
|
||||
// 1. Honor user-supplied moshClientPath (Settings → Terminal → Mosh).
|
||||
// Strict failure: a missing/non-executable file produces a clear error
|
||||
// instead of silently falling back, so users notice typos / stale paths.
|
||||
const explicitClient = typeof options.moshClientPath === "string" ? options.moshClientPath.trim() : "";
|
||||
if (explicitClient) {
|
||||
const expanded = expandHomePath(explicitClient);
|
||||
// Reject relative paths up front. validatePath in the renderer is shared
|
||||
// with localShell and resolves bare names through PATH (so "mosh.exe"
|
||||
// would look valid in the UI), but here moshClientPath is taken as a
|
||||
// literal filesystem path and any non-absolute value would be resolved
|
||||
// against the app's cwd and silently fail.
|
||||
if (!path.isAbsolute(expanded)) {
|
||||
throw new Error(
|
||||
`Mosh client path must be absolute: "${explicitClient}". Use Settings → Terminal → Mosh to pick the binary, leave it empty to auto-detect, or enter an absolute path.`,
|
||||
);
|
||||
}
|
||||
if (!isExecutableFile(expanded)) {
|
||||
throw new Error(
|
||||
`Configured Mosh client not usable: ${explicitClient}. Update Settings → Terminal → Mosh, leave it empty to auto-detect, or pick another binary.`,
|
||||
);
|
||||
}
|
||||
moshCmd = path.resolve(expanded);
|
||||
// Always remember the directory so we can extend PATH and locate
|
||||
// mosh-client / ssh helpers regardless of platform — Windows
|
||||
// installs outside %PATH% otherwise can't resolve siblings even
|
||||
// though the wrapper itself runs.
|
||||
resolvedMoshDir = path.dirname(moshCmd);
|
||||
} else if (process.platform === "win32") {
|
||||
moshCmd = findExecutable("mosh") || "mosh.exe";
|
||||
} else {
|
||||
const resolved = resolvePosixExecutable("mosh", { pathOverride: mergedPathForResolution });
|
||||
if (!resolved) {
|
||||
const installHint =
|
||||
process.platform === "darwin"
|
||||
? "macOS: brew install mosh"
|
||||
: "Linux: sudo apt install mosh / sudo dnf install mosh / sudo pacman -S mosh";
|
||||
throw new Error(
|
||||
`Mosh client not found on PATH. Install it (${installHint}) or place the 'mosh' binary somewhere on PATH such as /opt/homebrew/bin or /usr/local/bin. You can also point Settings → Terminal → Mosh at an absolute path.`,
|
||||
);
|
||||
}
|
||||
moshCmd = resolved;
|
||||
resolvedMoshDir = path.dirname(resolved);
|
||||
}
|
||||
|
||||
const args = [];
|
||||
|
||||
|
||||
if (options.port && options.port !== 22) {
|
||||
args.push('--ssh=ssh -p ' + options.port);
|
||||
}
|
||||
@@ -706,7 +857,7 @@ async function startMoshSession(event, options) {
|
||||
args.push('--server=' + options.moshServerPath);
|
||||
}
|
||||
|
||||
const userHost = options.username
|
||||
const userHost = options.username
|
||||
? `${options.username}@${options.hostname}`
|
||||
: options.hostname;
|
||||
args.push(userHost);
|
||||
@@ -722,11 +873,40 @@ async function startMoshSession(event, options) {
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
...(options.env || {}),
|
||||
...optionsEnv,
|
||||
TERM: 'xterm-256color',
|
||||
LANG: resolveLangFromCharset(options.charset),
|
||||
};
|
||||
|
||||
// The mosh wrapper is a Perl script that exec's `mosh-client` (and `ssh`)
|
||||
// by name, so it needs them on PATH. Prepend the resolved mosh's directory
|
||||
// to the env PATH (typical layout: mosh + mosh-client live side by side).
|
||||
// Also point MOSH_CLIENT at the absolute mosh-client when present, so the
|
||||
// wrapper picks it up even if PATH is overridden downstream.
|
||||
if (resolvedMoshDir) {
|
||||
const sep = path.delimiter; // ":" on POSIX, ";" on Win32
|
||||
const existingPath = env.PATH || "";
|
||||
const onPath = existingPath
|
||||
.split(sep)
|
||||
.some((p) => p && path.normalize(p) === path.normalize(resolvedMoshDir));
|
||||
if (!onPath) {
|
||||
env.PATH = existingPath ? `${resolvedMoshDir}${sep}${existingPath}` : resolvedMoshDir;
|
||||
}
|
||||
if (!env.MOSH_CLIENT) {
|
||||
const clientCandidates =
|
||||
process.platform === "win32"
|
||||
? ["mosh-client.exe", "mosh-client.bat", "mosh-client.cmd", "mosh-client"]
|
||||
: ["mosh-client"];
|
||||
for (const name of clientCandidates) {
|
||||
const candidate = path.join(resolvedMoshDir, name);
|
||||
if (isExecutableFile(candidate)) {
|
||||
env.MOSH_CLIENT = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.agentForwarding && process.env.SSH_AUTH_SOCK) {
|
||||
env.SSH_AUTH_SOCK = process.env.SSH_AUTH_SOCK;
|
||||
}
|
||||
@@ -1093,6 +1273,8 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:local:start", startLocalSession);
|
||||
ipcMain.handle("netcatty:telnet:start", startTelnetSession);
|
||||
ipcMain.handle("netcatty:mosh:start", startMoshSession);
|
||||
ipcMain.handle("netcatty:mosh:detectClient", () => detectMoshClient());
|
||||
ipcMain.handle("netcatty:mosh:pickClient", () => pickMoshClient());
|
||||
ipcMain.handle("netcatty:serial:start", startSerialSession);
|
||||
ipcMain.handle("netcatty:serial:list", listSerialPorts);
|
||||
ipcMain.handle("netcatty:local:defaultShell", getDefaultShell);
|
||||
@@ -1115,29 +1297,42 @@ function getDefaultShell() {
|
||||
* Validate a path - check if it exists and whether it's a file or directory
|
||||
* @param {object} event - IPC event
|
||||
* @param {object} payload - Contains { path: string, type?: 'file' | 'directory' | 'any' }
|
||||
* @returns {{ exists: boolean, isFile: boolean, isDirectory: boolean }}
|
||||
* @returns {{ exists: boolean, isFile: boolean, isDirectory: boolean, isExecutable: boolean }}
|
||||
*
|
||||
* `isExecutable` mirrors isExecutableFile(): POSIX requires the file mode
|
||||
* to have an execute bit; Win32 treats any regular file as executable
|
||||
* (NTFS lacks POSIX bits — extension/PATHEXT decides at spawn time).
|
||||
* Existing callers ignore the new field; consumers that need exec
|
||||
* semantics (e.g. Mosh client path) read it explicitly.
|
||||
*/
|
||||
function statIsExecutable(stat) {
|
||||
if (!stat || !stat.isFile()) return false;
|
||||
if (process.platform === "win32") return true;
|
||||
return (stat.mode & 0o111) !== 0;
|
||||
}
|
||||
|
||||
function validatePath(event, payload) {
|
||||
const targetPath = payload?.path;
|
||||
const type = payload?.type || 'any';
|
||||
if (!targetPath) {
|
||||
return { exists: false, isFile: false, isDirectory: false };
|
||||
return { exists: false, isFile: false, isDirectory: false, isExecutable: false };
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Resolve path (handle ~, etc.)
|
||||
let resolvedPath = expandHomePath(targetPath);
|
||||
resolvedPath = path.resolve(resolvedPath);
|
||||
|
||||
|
||||
if (fs.existsSync(resolvedPath)) {
|
||||
const stat = fs.statSync(resolvedPath);
|
||||
return {
|
||||
exists: true,
|
||||
isFile: stat.isFile(),
|
||||
isDirectory: stat.isDirectory(),
|
||||
isExecutable: statIsExecutable(stat),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// If type is 'file' and path doesn't exist, try to resolve via PATH (for executables like cmd.exe, powershell.exe)
|
||||
if (type === 'file') {
|
||||
const resolvedExecutable = findExecutable(targetPath);
|
||||
@@ -1148,6 +1343,7 @@ function validatePath(event, payload) {
|
||||
exists: true,
|
||||
isFile: stat.isFile(),
|
||||
isDirectory: stat.isDirectory(),
|
||||
isExecutable: statIsExecutable(stat),
|
||||
};
|
||||
}
|
||||
// Also try with .exe extension on Windows if not already present
|
||||
@@ -1159,18 +1355,85 @@ function validatePath(event, payload) {
|
||||
exists: true,
|
||||
isFile: stat.isFile(),
|
||||
isDirectory: stat.isDirectory(),
|
||||
isExecutable: statIsExecutable(stat),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { exists: false, isFile: false, isDirectory: false };
|
||||
|
||||
return { exists: false, isFile: false, isDirectory: false, isExecutable: false };
|
||||
} catch (err) {
|
||||
console.warn(`[Terminal] Error validating path "${targetPath}":`, err.message);
|
||||
return { exists: false, isFile: false, isDirectory: false };
|
||||
return { exists: false, isFile: false, isDirectory: false, isExecutable: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the same auto-discovery startMoshSession uses, surfacing the result
|
||||
* (and the search list when nothing was found) to the Settings UI.
|
||||
*/
|
||||
function detectMoshClient() {
|
||||
if (process.platform === "win32") {
|
||||
const resolved = findExecutable("mosh");
|
||||
const found = !!resolved && resolved !== "mosh" && fs.existsSync(resolved);
|
||||
return {
|
||||
platform: "win32",
|
||||
found,
|
||||
path: found ? resolved : null,
|
||||
searchedPaths: [],
|
||||
};
|
||||
}
|
||||
const dirs = [];
|
||||
const seen = new Set();
|
||||
for (const dir of (process.env.PATH || "").split(":")) {
|
||||
if (dir && !seen.has(dir)) { seen.add(dir); dirs.push(dir); }
|
||||
}
|
||||
for (const dir of POSIX_EXTRA_PATH_DIRS) {
|
||||
if (!seen.has(dir)) { seen.add(dir); dirs.push(dir); }
|
||||
}
|
||||
const home = process.env.HOME;
|
||||
if (home) {
|
||||
for (const sub of [".nix-profile/bin", ".cargo/bin", ".local/bin"]) {
|
||||
const dir = path.join(home, sub);
|
||||
if (!seen.has(dir)) { seen.add(dir); dirs.push(dir); }
|
||||
}
|
||||
}
|
||||
const resolved = resolvePosixExecutable("mosh");
|
||||
return {
|
||||
platform: process.platform,
|
||||
found: !!resolved,
|
||||
path: resolved,
|
||||
searchedPaths: dirs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a native file picker so the user can select a Mosh client binary.
|
||||
* Returns { canceled, filePath } so the renderer can decide what to do.
|
||||
*/
|
||||
async function pickMoshClient() {
|
||||
const { dialog, BrowserWindow } = electronModule || {};
|
||||
if (!dialog) {
|
||||
return { canceled: true, filePath: null };
|
||||
}
|
||||
const win = BrowserWindow?.getFocusedWindow?.() || undefined;
|
||||
const isWin = process.platform === "win32";
|
||||
const result = await dialog.showOpenDialog(win, {
|
||||
title: "Select Mosh client",
|
||||
properties: ["openFile", "showHiddenFiles"],
|
||||
filters: isWin
|
||||
? [
|
||||
{ name: "Executables", extensions: ["exe", "bat", "cmd"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
]
|
||||
: [{ name: "All Files", extensions: ["*"] }],
|
||||
});
|
||||
if (result.canceled || !result.filePaths || result.filePaths.length === 0) {
|
||||
return { canceled: true, filePath: null };
|
||||
}
|
||||
return { canceled: false, filePath: result.filePaths[0] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all sessions - call before app quit
|
||||
*/
|
||||
@@ -1220,6 +1483,8 @@ module.exports = {
|
||||
startLocalSession,
|
||||
startTelnetSession,
|
||||
startMoshSession,
|
||||
detectMoshClient,
|
||||
pickMoshClient,
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
writeToSession,
|
||||
|
||||
453
electron/bridges/terminalLogSanitizer.cjs
Normal file
453
electron/bridges/terminalLogSanitizer.cjs
Normal file
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* Terminal log sanitizer.
|
||||
*
|
||||
* This is intentionally stateful: terminal output is a stream of cursor and
|
||||
* erase operations, not plain text with decoration. The renderer below keeps a
|
||||
* small virtual text buffer so plain-text and HTML logs reflect what common
|
||||
* line-editing output actually leaves on screen.
|
||||
*/
|
||||
|
||||
const CSI_FINAL_RE = /[@-~]/;
|
||||
const DEFAULT_FOREGROUND = "#d4d4d4";
|
||||
const DEFAULT_BACKGROUND = "#1e1e1e";
|
||||
const BASIC_COLORS = [
|
||||
"#000000",
|
||||
"#cd3131",
|
||||
"#0dbc79",
|
||||
"#e5e510",
|
||||
"#2472c8",
|
||||
"#bc3fbc",
|
||||
"#11a8cd",
|
||||
"#e5e5e5",
|
||||
];
|
||||
const BRIGHT_COLORS = [
|
||||
"#666666",
|
||||
"#f14c4c",
|
||||
"#23d18b",
|
||||
"#f5f543",
|
||||
"#3b8eea",
|
||||
"#d670d6",
|
||||
"#29b8db",
|
||||
"#ffffff",
|
||||
];
|
||||
|
||||
class TerminalTextRenderer {
|
||||
constructor() {
|
||||
this.lines = [[]];
|
||||
this.row = 0;
|
||||
this.col = 0;
|
||||
this.state = "normal";
|
||||
this.escapeBuffer = "";
|
||||
this.style = createDefaultStyle();
|
||||
}
|
||||
|
||||
feed(input) {
|
||||
if (!input) return;
|
||||
|
||||
for (const ch of input) {
|
||||
this.#consume(ch);
|
||||
}
|
||||
}
|
||||
|
||||
finish() {
|
||||
this.state = "normal";
|
||||
this.escapeBuffer = "";
|
||||
return this.toString();
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.lines
|
||||
.map((line) => line.map((cell) => cell?.ch || " ").join("").replace(/[ \t]+$/g, ""))
|
||||
.join("\n")
|
||||
.replace(/\n+$/g, "");
|
||||
}
|
||||
|
||||
toHtmlContent() {
|
||||
return this.lines
|
||||
.map((line) => renderLineHtml(line))
|
||||
.join("\n")
|
||||
.replace(/\n+$/g, "");
|
||||
}
|
||||
|
||||
#consume(ch) {
|
||||
if (this.state === "esc") {
|
||||
this.#consumeEsc(ch);
|
||||
return;
|
||||
}
|
||||
if (this.state === "csi") {
|
||||
this.escapeBuffer += ch;
|
||||
if (CSI_FINAL_RE.test(ch)) {
|
||||
this.#applyCsi(this.escapeBuffer);
|
||||
this.state = "normal";
|
||||
this.escapeBuffer = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.state === "osc") {
|
||||
if (ch === "\x07") {
|
||||
this.state = "normal";
|
||||
this.escapeBuffer = "";
|
||||
return;
|
||||
}
|
||||
if (ch === "\x1b") {
|
||||
this.state = "oscEsc";
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.state === "oscEsc") {
|
||||
this.state = ch === "\\" ? "normal" : "osc";
|
||||
return;
|
||||
}
|
||||
|
||||
switch (ch) {
|
||||
case "\x1b":
|
||||
this.state = "esc";
|
||||
this.escapeBuffer = "";
|
||||
break;
|
||||
case "\b":
|
||||
this.col = Math.max(0, this.col - 1);
|
||||
break;
|
||||
case "\r":
|
||||
this.col = 0;
|
||||
break;
|
||||
case "\n":
|
||||
this.row += 1;
|
||||
this.col = 0;
|
||||
this.#ensureLine();
|
||||
break;
|
||||
case "\t":
|
||||
this.#writeText(" ".repeat(8 - (this.col % 8)));
|
||||
break;
|
||||
default:
|
||||
if (this.#isPrintable(ch)) this.#writeText(ch);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
#consumeEsc(ch) {
|
||||
if (ch === "[") {
|
||||
this.state = "csi";
|
||||
this.escapeBuffer = "";
|
||||
return;
|
||||
}
|
||||
if (ch === "]") {
|
||||
this.state = "osc";
|
||||
this.escapeBuffer = "";
|
||||
return;
|
||||
}
|
||||
// Single-character ESC sequences are terminal controls. Ignore them for
|
||||
// logs, but consume them so they never leak into txt/html output.
|
||||
this.state = "normal";
|
||||
this.escapeBuffer = "";
|
||||
}
|
||||
|
||||
#applyCsi(sequence) {
|
||||
const final = sequence.at(-1);
|
||||
const params = sequence.slice(0, -1);
|
||||
const values = params
|
||||
.replace(/[?><=]/g, "")
|
||||
.split(";")
|
||||
.map((part) => {
|
||||
if (part === "") return undefined;
|
||||
const n = Number.parseInt(part, 10);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
});
|
||||
const n = values[0] || 1;
|
||||
|
||||
switch (final) {
|
||||
case "A":
|
||||
this.row = Math.max(0, this.row - n);
|
||||
this.#ensureLine();
|
||||
break;
|
||||
case "B":
|
||||
case "E":
|
||||
this.row += n;
|
||||
if (final === "E") this.col = 0;
|
||||
this.#ensureLine();
|
||||
break;
|
||||
case "C":
|
||||
this.col += n;
|
||||
break;
|
||||
case "D":
|
||||
this.col = Math.max(0, this.col - n);
|
||||
break;
|
||||
case "F":
|
||||
this.row = Math.max(0, this.row - n);
|
||||
this.col = 0;
|
||||
this.#ensureLine();
|
||||
break;
|
||||
case "G":
|
||||
this.col = Math.max(0, n - 1);
|
||||
break;
|
||||
case "H":
|
||||
case "f":
|
||||
this.row = Math.max(0, (values[0] || 1) - 1);
|
||||
this.col = Math.max(0, (values[1] || 1) - 1);
|
||||
this.#ensureLine();
|
||||
break;
|
||||
case "J":
|
||||
this.#eraseDisplay(values[0] || 0);
|
||||
break;
|
||||
case "K":
|
||||
this.#eraseLine(values[0] || 0);
|
||||
break;
|
||||
case "m":
|
||||
this.#applySgr(values);
|
||||
break;
|
||||
default:
|
||||
// Unsupported CSI controls are intentionally ignored.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
#applySgr(values) {
|
||||
const codes = values.length > 0 ? values : [0];
|
||||
|
||||
for (let i = 0; i < codes.length; i += 1) {
|
||||
const code = codes[i] ?? 0;
|
||||
|
||||
if (code === 0) {
|
||||
this.style = createDefaultStyle();
|
||||
} else if (code === 1) {
|
||||
this.style.bold = true;
|
||||
} else if (code === 3) {
|
||||
this.style.italic = true;
|
||||
} else if (code === 4) {
|
||||
this.style.underline = true;
|
||||
} else if (code === 7) {
|
||||
this.style.inverse = true;
|
||||
} else if (code === 22) {
|
||||
this.style.bold = false;
|
||||
} else if (code === 23) {
|
||||
this.style.italic = false;
|
||||
} else if (code === 24) {
|
||||
this.style.underline = false;
|
||||
} else if (code === 27) {
|
||||
this.style.inverse = false;
|
||||
} else if (code >= 30 && code <= 37) {
|
||||
this.style.fg = BASIC_COLORS[code - 30];
|
||||
} else if (code === 39) {
|
||||
this.style.fg = null;
|
||||
} else if (code >= 40 && code <= 47) {
|
||||
this.style.bg = BASIC_COLORS[code - 40];
|
||||
} else if (code === 49) {
|
||||
this.style.bg = null;
|
||||
} else if (code >= 90 && code <= 97) {
|
||||
this.style.fg = BRIGHT_COLORS[code - 90];
|
||||
} else if (code >= 100 && code <= 107) {
|
||||
this.style.bg = BRIGHT_COLORS[code - 100];
|
||||
} else if ((code === 38 || code === 48) && codes[i + 1] === 5) {
|
||||
const color = colorFromAnsi256(codes[i + 2]);
|
||||
if (color) {
|
||||
if (code === 38) this.style.fg = color;
|
||||
else this.style.bg = color;
|
||||
}
|
||||
i += 2;
|
||||
} else if ((code === 38 || code === 48) && codes[i + 1] === 2) {
|
||||
const color = colorFromRgb(codes[i + 2], codes[i + 3], codes[i + 4]);
|
||||
if (color) {
|
||||
if (code === 38) this.style.fg = color;
|
||||
else this.style.bg = color;
|
||||
}
|
||||
i += 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#writeText(text) {
|
||||
this.#ensureLine();
|
||||
const line = this.lines[this.row];
|
||||
while (line.length < this.col) line.push(createCell(" ", createDefaultStyle()));
|
||||
for (const ch of text) {
|
||||
line[this.col] = createCell(ch, this.style);
|
||||
this.col += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#eraseLine(mode) {
|
||||
this.#ensureLine();
|
||||
const line = this.lines[this.row];
|
||||
if (mode === 1) {
|
||||
for (let i = 0; i <= this.col && i < line.length; i += 1) {
|
||||
line[i] = createCell(" ", createDefaultStyle());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (mode === 2) {
|
||||
this.lines[this.row] = [];
|
||||
return;
|
||||
}
|
||||
line.length = Math.min(line.length, this.col);
|
||||
}
|
||||
|
||||
#eraseDisplay(mode) {
|
||||
this.#ensureLine();
|
||||
if (mode === 2 || mode === 3) {
|
||||
this.lines = [[]];
|
||||
this.row = 0;
|
||||
this.col = 0;
|
||||
return;
|
||||
}
|
||||
if (mode === 1) {
|
||||
this.lines = this.lines.slice(this.row);
|
||||
this.row = 0;
|
||||
this.#eraseLine(1);
|
||||
return;
|
||||
}
|
||||
this.#eraseLine(0);
|
||||
this.lines.length = this.row + 1;
|
||||
}
|
||||
|
||||
#ensureLine() {
|
||||
while (this.lines.length <= this.row) this.lines.push([]);
|
||||
}
|
||||
|
||||
#isPrintable(ch) {
|
||||
const code = ch.codePointAt(0);
|
||||
if (code === undefined) return false;
|
||||
return code >= 0x20 && code !== 0x7f;
|
||||
}
|
||||
}
|
||||
|
||||
function terminalDataToPlainText(terminalData) {
|
||||
const renderer = new TerminalTextRenderer();
|
||||
renderer.feed(terminalData || "");
|
||||
return renderer.finish();
|
||||
}
|
||||
|
||||
function terminalDataToHtmlContent(terminalData) {
|
||||
const renderer = new TerminalTextRenderer();
|
||||
renderer.feed(terminalData || "");
|
||||
renderer.finish();
|
||||
return renderer.toHtmlContent();
|
||||
}
|
||||
|
||||
function createTerminalTextRenderer() {
|
||||
return new TerminalTextRenderer();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TerminalTextRenderer,
|
||||
createTerminalTextRenderer,
|
||||
terminalDataToHtmlContent,
|
||||
terminalDataToPlainText,
|
||||
};
|
||||
|
||||
function createDefaultStyle() {
|
||||
return {
|
||||
fg: null,
|
||||
bg: null,
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
inverse: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createCell(ch, style) {
|
||||
return {
|
||||
ch,
|
||||
style: { ...style },
|
||||
};
|
||||
}
|
||||
|
||||
function renderLineHtml(line) {
|
||||
let html = "";
|
||||
let runText = "";
|
||||
let runStyle = null;
|
||||
|
||||
const flush = () => {
|
||||
if (!runText) return;
|
||||
const escaped = escapeHtml(runText);
|
||||
const style = styleToCss(runStyle);
|
||||
html += style ? `<span style="${style}">${escaped}</span>` : escaped;
|
||||
runText = "";
|
||||
};
|
||||
|
||||
const trimmedLength = getTrimmedLineLength(line);
|
||||
for (let i = 0; i < trimmedLength; i += 1) {
|
||||
const cell = line[i] || createCell(" ", createDefaultStyle());
|
||||
if (!runStyle || !stylesEqual(runStyle, cell.style)) {
|
||||
flush();
|
||||
runStyle = cell.style;
|
||||
}
|
||||
runText += cell.ch;
|
||||
}
|
||||
flush();
|
||||
return html;
|
||||
}
|
||||
|
||||
function getTrimmedLineLength(line) {
|
||||
let length = line.length;
|
||||
while (length > 0) {
|
||||
const cell = line[length - 1];
|
||||
const ch = cell?.ch || " ";
|
||||
if (ch !== " " && ch !== "\t") break;
|
||||
if (styleToCss(cell?.style)) break;
|
||||
length -= 1;
|
||||
}
|
||||
return length;
|
||||
}
|
||||
|
||||
function styleToCss(style) {
|
||||
if (!style) return "";
|
||||
const declarations = [];
|
||||
const fg = style.inverse ? (style.bg || DEFAULT_BACKGROUND) : style.fg;
|
||||
const bg = style.inverse ? (style.fg || DEFAULT_FOREGROUND) : style.bg;
|
||||
if (fg) declarations.push(`color: ${fg}`);
|
||||
if (bg) declarations.push(`background-color: ${bg}`);
|
||||
if (style.bold) declarations.push("font-weight: 700");
|
||||
if (style.italic) declarations.push("font-style: italic");
|
||||
if (style.underline) declarations.push("text-decoration: underline");
|
||||
return declarations.join("; ");
|
||||
}
|
||||
|
||||
function stylesEqual(a, b) {
|
||||
return (
|
||||
a.fg === b.fg &&
|
||||
a.bg === b.bg &&
|
||||
a.bold === b.bold &&
|
||||
a.italic === b.italic &&
|
||||
a.underline === b.underline &&
|
||||
a.inverse === b.inverse
|
||||
);
|
||||
}
|
||||
|
||||
function colorFromAnsi256(value) {
|
||||
if (!Number.isInteger(value) || value < 0 || value > 255) return null;
|
||||
if (value < 8) return BASIC_COLORS[value];
|
||||
if (value < 16) return BRIGHT_COLORS[value - 8];
|
||||
if (value < 232) {
|
||||
const n = value - 16;
|
||||
const r = Math.floor(n / 36);
|
||||
const g = Math.floor((n % 36) / 6);
|
||||
const b = n % 6;
|
||||
return colorFromRgb(colorCubeValue(r), colorCubeValue(g), colorCubeValue(b));
|
||||
}
|
||||
const level = 8 + (value - 232) * 10;
|
||||
return colorFromRgb(level, level, level);
|
||||
}
|
||||
|
||||
function colorCubeValue(n) {
|
||||
return n === 0 ? 0 : 55 + n * 40;
|
||||
}
|
||||
|
||||
function colorFromRgb(r, g, b) {
|
||||
if (![r, g, b].every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) {
|
||||
return null;
|
||||
}
|
||||
return `#${hex2(r)}${hex2(g)}${hex2(b)}`;
|
||||
}
|
||||
|
||||
function hex2(value) {
|
||||
return value.toString(16).padStart(2, "0");
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
319
electron/bridges/x11Forwarding.cjs
Normal file
319
electron/bridges/x11Forwarding.cjs
Normal file
@@ -0,0 +1,319 @@
|
||||
const net = require("node:net");
|
||||
const fs = require("node:fs");
|
||||
const { Transform } = require("node:stream");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const X11_PORT_BASE = 6000;
|
||||
const MIT_MAGIC_COOKIE_PROTOCOL = "MIT-MAGIC-COOKIE-1";
|
||||
|
||||
function resolveX11DisplaySpec(spec, options = {}) {
|
||||
const platform = options.platform || process.platform;
|
||||
let raw = String(spec || options.envDisplay || process.env.DISPLAY || (platform === "win32" ? "localhost:0" : ":0")).trim();
|
||||
|
||||
if (!raw) {
|
||||
return resolveX11DisplaySpec(undefined, { ...options, envDisplay: platform === "win32" ? "localhost:0" : ":0" });
|
||||
}
|
||||
if (raw === ":") {
|
||||
raw = ":0";
|
||||
}
|
||||
|
||||
if (raw.startsWith("/")) {
|
||||
return { path: raw };
|
||||
}
|
||||
|
||||
const match = raw.match(/^(.*):(\d+)(?:\.(\d+))?$/);
|
||||
if (!match) {
|
||||
return platform === "win32"
|
||||
? { host: raw, port: X11_PORT_BASE }
|
||||
: { path: raw };
|
||||
}
|
||||
|
||||
const host = match[1] || "";
|
||||
const display = Number.parseInt(match[2], 10);
|
||||
const port = display >= 100 ? display : X11_PORT_BASE + display;
|
||||
|
||||
if (host.toLowerCase() === "unix" && platform !== "win32") {
|
||||
return { path: `/tmp/.X11-unix/X${display}` };
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
if (platform === "win32") {
|
||||
return { host: "localhost", port };
|
||||
}
|
||||
return { path: `/tmp/.X11-unix/X${display}` };
|
||||
}
|
||||
|
||||
if (host.startsWith("/")) {
|
||||
return { path: host };
|
||||
}
|
||||
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
function formatDisplayTarget(target) {
|
||||
if (target.path) return target.path;
|
||||
return `${target.host}:${target.port}`;
|
||||
}
|
||||
|
||||
function platformHint(platform) {
|
||||
if (platform === "win32") {
|
||||
return "Install and start VcXsrv or Xming, then try again.";
|
||||
}
|
||||
if (platform === "darwin") {
|
||||
return "Install and start XQuartz, then try again.";
|
||||
}
|
||||
return "Check DISPLAY and make sure Xorg, Xwayland, or your X server is running.";
|
||||
}
|
||||
|
||||
function connectSocket(socket, target) {
|
||||
if (target.path) {
|
||||
return socket.connect(target.path);
|
||||
}
|
||||
return socket.connect(target.port, target.host);
|
||||
}
|
||||
|
||||
function destroyStream(stream) {
|
||||
try {
|
||||
stream.destroy();
|
||||
} catch {
|
||||
// best effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
function pad4(n) {
|
||||
return (n + 3) & ~3;
|
||||
}
|
||||
|
||||
function readUInt16(buf, offset, littleEndian) {
|
||||
return littleEndian ? buf.readUInt16LE(offset) : buf.readUInt16BE(offset);
|
||||
}
|
||||
|
||||
function normalizeCookieBuffer(cookie) {
|
||||
if (!cookie) return null;
|
||||
if (Buffer.isBuffer(cookie)) return cookie;
|
||||
const value = String(cookie).trim();
|
||||
if (/^[0-9a-fA-F]+$/.test(value) && value.length % 2 === 0) {
|
||||
return Buffer.from(value, "hex");
|
||||
}
|
||||
return Buffer.from(value, "binary");
|
||||
}
|
||||
|
||||
function rewriteX11AuthSetupPacket(buffer, options = {}) {
|
||||
const fakeCookie = normalizeCookieBuffer(options.fakeCookie);
|
||||
const realCookie = normalizeCookieBuffer(options.realCookie);
|
||||
if (!realCookie || buffer.length < 12) {
|
||||
return { buffer, complete: buffer.length >= 12, rewritten: false };
|
||||
}
|
||||
|
||||
const byteOrder = buffer[0];
|
||||
const littleEndian = byteOrder === 0x6c; // 'l'
|
||||
if (!littleEndian && byteOrder !== 0x42) { // 'B'
|
||||
return { buffer, complete: true, rewritten: false };
|
||||
}
|
||||
|
||||
const protocolLength = readUInt16(buffer, 6, littleEndian);
|
||||
const dataLength = readUInt16(buffer, 8, littleEndian);
|
||||
const protocolStart = 12;
|
||||
const dataStart = protocolStart + pad4(protocolLength);
|
||||
const totalLength = dataStart + pad4(dataLength);
|
||||
|
||||
if (buffer.length < totalLength) {
|
||||
return { buffer, complete: false, rewritten: false };
|
||||
}
|
||||
|
||||
const protocol = buffer.subarray(protocolStart, protocolStart + protocolLength).toString("ascii");
|
||||
if (protocol !== MIT_MAGIC_COOKIE_PROTOCOL || dataLength !== realCookie.length) {
|
||||
return { buffer, complete: true, rewritten: false };
|
||||
}
|
||||
|
||||
const dataEnd = dataStart + dataLength;
|
||||
const currentCookie = buffer.subarray(dataStart, dataEnd);
|
||||
if (fakeCookie && currentCookie.length === fakeCookie.length && !currentCookie.equals(fakeCookie)) {
|
||||
return { buffer, complete: true, rewritten: false };
|
||||
}
|
||||
|
||||
const next = Buffer.from(buffer);
|
||||
realCookie.copy(next, dataStart);
|
||||
return { buffer: next, complete: true, rewritten: true };
|
||||
}
|
||||
|
||||
function createX11AuthTransform(options = {}) {
|
||||
let pending = Buffer.alloc(0);
|
||||
let done = false;
|
||||
|
||||
return new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
if (done) {
|
||||
callback(null, chunk);
|
||||
return;
|
||||
}
|
||||
|
||||
pending = Buffer.concat([pending, Buffer.from(chunk)]);
|
||||
const result = rewriteX11AuthSetupPacket(pending, options);
|
||||
if (!result.complete) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
done = true;
|
||||
callback(null, result.buffer);
|
||||
},
|
||||
flush(callback) {
|
||||
if (!done && pending.length > 0) {
|
||||
callback(null, pending);
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function resolveXauthCommand(platform) {
|
||||
if (platform === "darwin" && fs.existsSync("/opt/X11/bin/xauth")) {
|
||||
return "/opt/X11/bin/xauth";
|
||||
}
|
||||
return "xauth";
|
||||
}
|
||||
|
||||
function getDisplayNumber(display) {
|
||||
const value = String(display || process.env.DISPLAY || ":0").trim() || ":0";
|
||||
const normalized = value === ":" ? ":0" : value;
|
||||
const match = normalized.match(/:(\d+)(?:\.\d+)?$/) || normalized.match(/\/X(\d+)$/);
|
||||
if (!match) return null;
|
||||
|
||||
const displayNumber = Number.parseInt(match[1], 10);
|
||||
if (!Number.isFinite(displayNumber)) return null;
|
||||
return displayNumber >= X11_PORT_BASE ? displayNumber - X11_PORT_BASE : displayNumber;
|
||||
}
|
||||
|
||||
function parseXauthCookie(output, display) {
|
||||
const requestedDisplay = getDisplayNumber(display);
|
||||
const cookiePattern = new RegExp(`\\b${MIT_MAGIC_COOKIE_PROTOCOL}\\b\\s+([0-9a-fA-F]+)`);
|
||||
|
||||
for (const entry of String(output || "").split(/\r?\n/)) {
|
||||
const match = entry.match(cookiePattern);
|
||||
if (!match) continue;
|
||||
|
||||
const target = entry.trim().split(/\s+/, 1)[0];
|
||||
if (requestedDisplay !== null && getDisplayNumber(target) !== requestedDisplay) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return Buffer.from(match[1], "hex");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readLocalX11AuthCookie(options = {}) {
|
||||
const platform = options.platform || process.platform;
|
||||
const command = options.xauthCommand || resolveXauthCommand(platform);
|
||||
const display = String(options.display || process.env.DISPLAY || ":0").trim() || ":0";
|
||||
try {
|
||||
const normalizedDisplay = display === ":" ? ":0" : display;
|
||||
const output = typeof options.readXauthOutput === "function"
|
||||
? options.readXauthOutput({ command, display: normalizedDisplay })
|
||||
: execFileSync(command, ["list"], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
DISPLAY: normalizedDisplay,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 2000,
|
||||
});
|
||||
return parseXauthCookie(output, normalizedDisplay);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function attachX11Forwarding(conn, options = {}) {
|
||||
const createSocket = options.createSocket || (() => new net.Socket());
|
||||
const sendMessage = typeof options.sendMessage === "function" ? options.sendMessage : () => {};
|
||||
const logger = options.logger || console;
|
||||
const platform = options.platform || process.platform;
|
||||
const display = options.display;
|
||||
const fakeCookie = options.fakeCookie;
|
||||
const fixedLocalAuthCookie = normalizeCookieBuffer(options.localAuthCookie);
|
||||
let localAuthCookie = fixedLocalAuthCookie;
|
||||
let localAuthCookieResolved = Boolean(fixedLocalAuthCookie);
|
||||
|
||||
const resolveLocalAuthCookie = () => {
|
||||
if (localAuthCookieResolved) return localAuthCookie;
|
||||
localAuthCookieResolved = true;
|
||||
const cookie = typeof options.readLocalAuthCookie === "function"
|
||||
? options.readLocalAuthCookie({ display, platform })
|
||||
: readLocalX11AuthCookie({ display, platform });
|
||||
localAuthCookie = normalizeCookieBuffer(cookie);
|
||||
return localAuthCookie;
|
||||
};
|
||||
|
||||
const onX11 = (info, accept, reject) => {
|
||||
const target = resolveX11DisplaySpec(display, { platform });
|
||||
const localSocket = createSocket();
|
||||
let acceptedChannel = null;
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (acceptedChannel) destroyStream(acceptedChannel);
|
||||
destroyStream(localSocket);
|
||||
};
|
||||
|
||||
localSocket.once("connect", () => {
|
||||
if (settled) return;
|
||||
try {
|
||||
acceptedChannel = accept();
|
||||
settled = true;
|
||||
} catch (err) {
|
||||
logger.warn?.("[X11] Failed to accept forwarded channel", err);
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
acceptedChannel.on("error", () => cleanup());
|
||||
localSocket.on("error", () => cleanup());
|
||||
acceptedChannel.on("close", () => destroyStream(localSocket));
|
||||
localSocket.on("close", () => destroyStream(acceptedChannel));
|
||||
const realCookie = resolveLocalAuthCookie();
|
||||
if (realCookie && fakeCookie) {
|
||||
acceptedChannel
|
||||
.pipe(createX11AuthTransform({ fakeCookie, realCookie }))
|
||||
.pipe(localSocket)
|
||||
.pipe(acceptedChannel);
|
||||
} else {
|
||||
acceptedChannel.pipe(localSocket).pipe(acceptedChannel);
|
||||
}
|
||||
});
|
||||
|
||||
localSocket.once("error", (err) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
try { reject(); } catch { /* ignore reject errors */ }
|
||||
sendMessage(`\r\n[X11] Could not connect to the local X11 server: ${err?.message || err}\r\n`);
|
||||
sendMessage(`[X11] Display target: ${formatDisplayTarget(target)}\r\n`);
|
||||
sendMessage(`[X11] ${platformHint(platform)}\r\n`);
|
||||
}
|
||||
destroyStream(localSocket);
|
||||
});
|
||||
|
||||
try {
|
||||
connectSocket(localSocket, target);
|
||||
} catch (err) {
|
||||
localSocket.emit("error", err);
|
||||
}
|
||||
};
|
||||
|
||||
conn.on("x11", onX11);
|
||||
return () => {
|
||||
if (typeof conn.off === "function") conn.off("x11", onX11);
|
||||
else conn.removeListener("x11", onX11);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
attachX11Forwarding,
|
||||
readLocalX11AuthCookie,
|
||||
rewriteX11AuthSetupPacket,
|
||||
resolveX11DisplaySpec,
|
||||
};
|
||||
235
electron/bridges/x11Forwarding.test.cjs
Normal file
235
electron/bridges/x11Forwarding.test.cjs
Normal file
@@ -0,0 +1,235 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Duplex } = require("node:stream");
|
||||
const { EventEmitter } = require("node:events");
|
||||
|
||||
const {
|
||||
attachX11Forwarding,
|
||||
readLocalX11AuthCookie,
|
||||
rewriteX11AuthSetupPacket,
|
||||
resolveX11DisplaySpec,
|
||||
} = require("./x11Forwarding.cjs");
|
||||
|
||||
const buildX11SetupPacket = ({ cookie, endian = "l" }) => {
|
||||
const protocol = Buffer.from("MIT-MAGIC-COOKIE-1", "ascii");
|
||||
const cookieBytes = Buffer.from(cookie, "hex");
|
||||
const protocolPad = (4 - (protocol.length % 4)) % 4;
|
||||
const cookiePad = (4 - (cookieBytes.length % 4)) % 4;
|
||||
const packet = Buffer.alloc(12 + protocol.length + protocolPad + cookieBytes.length + cookiePad);
|
||||
packet[0] = endian.charCodeAt(0);
|
||||
const writeUInt16 = endian === "l"
|
||||
? packet.writeUInt16LE.bind(packet)
|
||||
: packet.writeUInt16BE.bind(packet);
|
||||
writeUInt16(11, 2);
|
||||
writeUInt16(0, 4);
|
||||
writeUInt16(protocol.length, 6);
|
||||
writeUInt16(cookieBytes.length, 8);
|
||||
protocol.copy(packet, 12);
|
||||
cookieBytes.copy(packet, 12 + protocol.length + protocolPad);
|
||||
return packet;
|
||||
};
|
||||
|
||||
test("resolveX11DisplaySpec maps unix display to the X11 socket path", () => {
|
||||
assert.deepEqual(
|
||||
resolveX11DisplaySpec(":2", { platform: "linux" }),
|
||||
{ path: "/tmp/.X11-unix/X2" },
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveX11DisplaySpec treats a bare colon as display zero", () => {
|
||||
assert.deepEqual(
|
||||
resolveX11DisplaySpec(":", { platform: "darwin" }),
|
||||
{ path: "/tmp/.X11-unix/X0" },
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveX11DisplaySpec maps tcp display numbers to X11 ports", () => {
|
||||
assert.deepEqual(
|
||||
resolveX11DisplaySpec("localhost:1", { platform: "win32" }),
|
||||
{ host: "localhost", port: 6001 },
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveX11DisplaySpec accepts explicit unix socket paths", () => {
|
||||
assert.deepEqual(
|
||||
resolveX11DisplaySpec("/private/tmp/com.apple.launchd.test/org.xquartz:0", { platform: "darwin" }),
|
||||
{ path: "/private/tmp/com.apple.launchd.test/org.xquartz:0" },
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveX11DisplaySpec maps unix-prefixed displays to local X11 sockets", () => {
|
||||
assert.deepEqual(
|
||||
resolveX11DisplaySpec("unix:1", { platform: "linux" }),
|
||||
{ path: "/tmp/.X11-unix/X1" },
|
||||
);
|
||||
});
|
||||
|
||||
test("rewriteX11AuthSetupPacket replaces the SSH fake cookie with the local X11 cookie", () => {
|
||||
const fakeCookie = "11111111111111111111111111111111";
|
||||
const realCookie = "22222222222222222222222222222222";
|
||||
const rewritten = rewriteX11AuthSetupPacket(buildX11SetupPacket({ cookie: fakeCookie }), {
|
||||
fakeCookie,
|
||||
realCookie: Buffer.from(realCookie, "hex"),
|
||||
});
|
||||
|
||||
assert.equal(rewritten.complete, true);
|
||||
assert.equal(rewritten.rewritten, true);
|
||||
assert.match(rewritten.buffer.toString("hex"), new RegExp(realCookie));
|
||||
assert.doesNotMatch(rewritten.buffer.toString("hex"), new RegExp(fakeCookie));
|
||||
});
|
||||
|
||||
test("readLocalX11AuthCookie selects the cookie for the requested display", () => {
|
||||
const cookie0 = "00000000000000000000000000000000";
|
||||
const cookie10 = "10101010101010101010101010101010";
|
||||
const cookie = readLocalX11AuthCookie({
|
||||
display: ":0",
|
||||
readXauthOutput: () => [
|
||||
`host/unix:10 MIT-MAGIC-COOKIE-1 ${cookie10}`,
|
||||
`host/unix:0 MIT-MAGIC-COOKIE-1 ${cookie0}`,
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
assert.equal(cookie.toString("hex"), cookie0);
|
||||
});
|
||||
|
||||
test("readLocalX11AuthCookie matches explicit unix socket display paths", () => {
|
||||
const cookie0 = "00000000000000000000000000000000";
|
||||
const cookie1 = "11111111111111111111111111111111";
|
||||
const cookie = readLocalX11AuthCookie({
|
||||
display: "/tmp/.X11-unix/X1",
|
||||
readXauthOutput: () => [
|
||||
`host/unix:0 MIT-MAGIC-COOKIE-1 ${cookie0}`,
|
||||
`host/unix:1 MIT-MAGIC-COOKIE-1 ${cookie1}`,
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
assert.equal(cookie.toString("hex"), cookie1);
|
||||
});
|
||||
|
||||
test("attachX11Forwarding reuses a session-level local X11 cookie", async () => {
|
||||
const conn = new EventEmitter();
|
||||
const localSockets = [];
|
||||
const acceptedChannels = [];
|
||||
const cookieReads = [];
|
||||
|
||||
const makeDuplex = () => new Duplex({
|
||||
read() {},
|
||||
write(_chunk, _encoding, callback) {
|
||||
callback();
|
||||
},
|
||||
});
|
||||
|
||||
attachX11Forwarding(conn, {
|
||||
display: ":0",
|
||||
fakeCookie: "11111111111111111111111111111111",
|
||||
readLocalAuthCookie: () => {
|
||||
cookieReads.push(Date.now());
|
||||
return Buffer.from("22222222222222222222222222222222", "hex");
|
||||
},
|
||||
createSocket: () => {
|
||||
const socket = makeDuplex();
|
||||
socket.connect = () => {
|
||||
queueMicrotask(() => socket.emit("connect"));
|
||||
return socket;
|
||||
};
|
||||
localSockets.push(socket);
|
||||
return socket;
|
||||
},
|
||||
sendMessage: () => {},
|
||||
platform: "linux",
|
||||
});
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
conn.emit("x11", { srcIP: "127.0.0.1", srcPort: 1234 + i }, () => {
|
||||
const channel = makeDuplex();
|
||||
acceptedChannels.push(channel);
|
||||
return channel;
|
||||
}, () => {
|
||||
throw new Error("unexpected reject");
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
assert.equal(localSockets.length, 2);
|
||||
assert.equal(acceptedChannels.length, 2);
|
||||
assert.equal(cookieReads.length, 1);
|
||||
});
|
||||
|
||||
test("attachX11Forwarding pipes accepted X11 channels to the local display socket", async () => {
|
||||
const conn = new EventEmitter();
|
||||
const localSocket = new Duplex({
|
||||
read() {},
|
||||
write(chunk, _encoding, callback) {
|
||||
localSocket.written = Buffer.concat([localSocket.written ?? Buffer.alloc(0), Buffer.from(chunk)]);
|
||||
callback();
|
||||
},
|
||||
});
|
||||
const acceptedChannel = new Duplex({
|
||||
read() {},
|
||||
write(chunk, _encoding, callback) {
|
||||
acceptedChannel.written = Buffer.concat([acceptedChannel.written ?? Buffer.alloc(0), Buffer.from(chunk)]);
|
||||
callback();
|
||||
},
|
||||
});
|
||||
localSocket.connect = () => {
|
||||
queueMicrotask(() => localSocket.emit("connect"));
|
||||
return localSocket;
|
||||
};
|
||||
let accepted = false;
|
||||
const messages = [];
|
||||
|
||||
attachX11Forwarding(conn, {
|
||||
display: ":0",
|
||||
createSocket: () => localSocket,
|
||||
sendMessage: (message) => messages.push(message),
|
||||
platform: "linux",
|
||||
});
|
||||
|
||||
conn.emit("x11", { srcIP: "127.0.0.1", srcPort: 1234 }, () => {
|
||||
accepted = true;
|
||||
return acceptedChannel;
|
||||
}, () => {
|
||||
throw new Error("unexpected reject");
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
acceptedChannel.push("remote");
|
||||
localSocket.push("local");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(accepted, true);
|
||||
assert.equal(localSocket.written.toString(), "remote");
|
||||
assert.equal(acceptedChannel.written.toString(), "local");
|
||||
assert.deepEqual(messages, []);
|
||||
});
|
||||
|
||||
test("attachX11Forwarding rejects the remote channel and explains local display failures", async () => {
|
||||
const conn = new EventEmitter();
|
||||
const localSocket = new EventEmitter();
|
||||
localSocket.connect = () => {
|
||||
queueMicrotask(() => localSocket.emit("error", new Error("ECONNREFUSED")));
|
||||
return localSocket;
|
||||
};
|
||||
localSocket.destroy = () => {};
|
||||
let rejected = false;
|
||||
const messages = [];
|
||||
|
||||
attachX11Forwarding(conn, {
|
||||
display: "localhost:0",
|
||||
createSocket: () => localSocket,
|
||||
sendMessage: (message) => messages.push(message),
|
||||
platform: "win32",
|
||||
});
|
||||
|
||||
conn.emit("x11", { srcIP: "127.0.0.1", srcPort: 1234 }, () => {
|
||||
throw new Error("unexpected accept");
|
||||
}, () => {
|
||||
rejected = true;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(rejected, true);
|
||||
assert.match(messages.join("\n"), /Could not connect to the local X11 server/);
|
||||
assert.match(messages.join("\n"), /VcXsrv/);
|
||||
});
|
||||
@@ -54,7 +54,7 @@ try {
|
||||
electronModule = require("electron");
|
||||
}
|
||||
|
||||
const { app, BrowserWindow, Menu, protocol, shell, clipboard } = electronModule || {};
|
||||
const { app, BrowserWindow, Menu, protocol, shell, clipboard, session } = electronModule || {};
|
||||
if (!app || !BrowserWindow) {
|
||||
throw new Error("Failed to load Electron runtime. Ensure the app is launched with the Electron binary.");
|
||||
}
|
||||
@@ -1078,6 +1078,77 @@ if (!gotLock) {
|
||||
app.whenReady().then(() => {
|
||||
registerAppProtocol();
|
||||
|
||||
// Grant only the Chromium permissions the app actually uses, and only
|
||||
// to the app's own origin. The default session is shared with in-app
|
||||
// OAuth pop-ups (accounts.google.com, login.microsoftonline.com, ...),
|
||||
// so non-app origins are denied outright; for the app itself we keep
|
||||
// an explicit allow-list rather than blanket-approving everything.
|
||||
try {
|
||||
const defaultSession = session?.defaultSession;
|
||||
if (defaultSession) {
|
||||
// app:// is registered as a standard scheme in Chromium
|
||||
// (registerSchemesAsPrivileged above) but Node's WHATWG URL parser
|
||||
// doesn't include it in its special-scheme list, so
|
||||
// `new URL('app://netcatty/...').origin` returns the string "null"
|
||||
// — matching against an `app://netcatty` origin string would
|
||||
// therefore fail in packaged builds. Match by protocol + host
|
||||
// instead, and only fall back to .origin for HTTP-family URLs
|
||||
// (the dev server).
|
||||
const allowedHttpOrigins = new Set();
|
||||
if (effectiveDevServerUrl) {
|
||||
try {
|
||||
allowedHttpOrigins.add(new URL(effectiveDevServerUrl).origin);
|
||||
} catch {
|
||||
// ignore malformed dev server URL
|
||||
}
|
||||
}
|
||||
const isAppOrigin = (rawUrl) => {
|
||||
if (!rawUrl) return false;
|
||||
try {
|
||||
const parsed = new URL(String(rawUrl));
|
||||
if (parsed.protocol === "app:") {
|
||||
return parsed.host === "netcatty";
|
||||
}
|
||||
return allowedHttpOrigins.has(parsed.origin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Permissions the renderer is known to need:
|
||||
// - local-fonts: terminal font picker enumeration (this PR)
|
||||
// - clipboard-read / clipboard-sanitized-write: terminal & SFTP
|
||||
// copy-paste flows (navigator.clipboard.{read,write}Text)
|
||||
const APP_ALLOWED_PERMISSIONS = new Set([
|
||||
"local-fonts",
|
||||
"clipboard-read",
|
||||
"clipboard-sanitized-write",
|
||||
]);
|
||||
|
||||
defaultSession.setPermissionRequestHandler((wc, permission, callback, details) => {
|
||||
const requestingUrl =
|
||||
details?.requestingUrl ||
|
||||
(typeof wc?.getURL === "function" ? wc.getURL() : "");
|
||||
if (!isAppOrigin(requestingUrl)) {
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
callback(APP_ALLOWED_PERMISSIONS.has(permission));
|
||||
});
|
||||
|
||||
defaultSession.setPermissionCheckHandler((wc, permission, requestingOrigin, details) => {
|
||||
const url =
|
||||
requestingOrigin ||
|
||||
details?.requestingUrl ||
|
||||
(typeof wc?.getURL === "function" ? wc.getURL() : "");
|
||||
if (!isAppOrigin(url)) return false;
|
||||
return APP_ALLOWED_PERMISSIONS.has(permission);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Main] Failed to install permission handlers:", err);
|
||||
}
|
||||
|
||||
// Set dock icon on macOS
|
||||
if (isMac && appIcon && app.dock?.setIcon) {
|
||||
try {
|
||||
@@ -1225,35 +1296,92 @@ if (!gotLock) {
|
||||
}
|
||||
|
||||
const { ipcMain: _ipcMain } = electronModule;
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
// No window — nothing to check; commit to quit directly.
|
||||
if (!win || win.isDestroyed?.()) {
|
||||
// Target the main window explicitly. Falling back to
|
||||
// BrowserWindow.getAllWindows()[0] could pick the tray panel or settings
|
||||
// window, whose renderers don't listen for app:query-dirty-editors and
|
||||
// would force the 5s timeout fallback to run on every quit.
|
||||
const win = getWindowManager().getMainWindow();
|
||||
// No main window, or it's hidden (tray-panel "Quit" path) — there's no
|
||||
// visible UI to surface a "save first" toast on, so skip the round-trip
|
||||
// and quit directly. The renderer's dirty-editor check exists to warn the
|
||||
// user; if they can't see the warning, it's just dead 5-second wait.
|
||||
//
|
||||
// A minimized window is *not* hidden: the user has a taskbar/Dock entry
|
||||
// and can restore in one click, so we still want to gate the quit on the
|
||||
// dirty-editor check there. Some platforms report isVisible()=false on a
|
||||
// minimized window (see globalShortcutBridge.cjs:478), so check both.
|
||||
const isReachableByUser =
|
||||
win && !win.isDestroyed?.() &&
|
||||
(win.isVisible?.() || win.isMinimized?.());
|
||||
if (!isReachableByUser) {
|
||||
commitQuit();
|
||||
return;
|
||||
}
|
||||
|
||||
// The renderer needs to be alive for the IPC roundtrip to make sense.
|
||||
// A crashed renderer would silently drop the message and we'd wait
|
||||
// 5 s for nothing — skip straight to quit (we can't ask the user
|
||||
// anyway, the UI is gone).
|
||||
const wc = win.webContents;
|
||||
if (!wc || wc.isDestroyed?.() || wc.isCrashed?.()) {
|
||||
commitQuit();
|
||||
return;
|
||||
}
|
||||
|
||||
quitGuardChannelBusy = true;
|
||||
event.preventDefault();
|
||||
win.webContents.send("app:query-dirty-editors");
|
||||
|
||||
// The response and the timeout race against each other; whichever
|
||||
// one fires first wins. A naive `clearTimeout` is not enough — once
|
||||
// the timer has already been queued for the next tick, clearTimeout
|
||||
// is a no-op and the timeout callback runs even after the response
|
||||
// arrived, which would commit the quit even on a `hasDirty=true`
|
||||
// reply (i.e. silently override the user's "save first" intent).
|
||||
let settled = false;
|
||||
let timeoutId = null;
|
||||
const settle = (decision) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
_ipcMain.removeListener("app:dirty-editors-result", onResult);
|
||||
quitGuardChannelBusy = false;
|
||||
if (decision === "commit") commitQuit();
|
||||
// decision === "stay": renderer showed a toast for dirty editors.
|
||||
// Do not touch isQuitting so tray / close-to-tray gating keeps working.
|
||||
};
|
||||
function onResult(evt, payload) {
|
||||
// Defence in depth: this channel is queried with a specific
|
||||
// webContents in mind. A reply from any other window (e.g. a
|
||||
// misbehaving extension or a future bug that wires the channel
|
||||
// elsewhere) is silently ignored so it can't decide the quit.
|
||||
// We use `.on` (not `.once`) so a rogue reply doesn't consume
|
||||
// the listener slot and let the real reply fall through. Reject
|
||||
// strictly: a missing/falsy sender is anomalous in real IPC and
|
||||
// is treated the same as a wrong-window reply.
|
||||
if (evt?.sender !== wc) return;
|
||||
const hasDirty = payload && payload.hasDirty === true;
|
||||
settle(hasDirty ? "stay" : "commit");
|
||||
}
|
||||
_ipcMain.on("app:dirty-editors-result", onResult);
|
||||
|
||||
// Timeout fallback: if the renderer never replies (crash, unhandled
|
||||
// exception in the listener, etc.) we'd otherwise be stuck with
|
||||
// quitGuardChannelBusy=true and the app un-quittable.
|
||||
const timeoutId = setTimeout(() => {
|
||||
_ipcMain.removeAllListeners("app:dirty-editors-result");
|
||||
quitGuardChannelBusy = false;
|
||||
commitQuit();
|
||||
}, QUIT_GUARD_TIMEOUT_MS);
|
||||
timeoutId = setTimeout(() => settle("commit"), QUIT_GUARD_TIMEOUT_MS);
|
||||
|
||||
_ipcMain.once("app:dirty-editors-result", (_evt, { hasDirty }) => {
|
||||
clearTimeout(timeoutId);
|
||||
quitGuardChannelBusy = false;
|
||||
if (!hasDirty) {
|
||||
commitQuit();
|
||||
}
|
||||
// If hasDirty === true the renderer has shown a toast; stay put. Do not
|
||||
// touch isQuitting so tray/close-to-tray gating keeps working.
|
||||
});
|
||||
try {
|
||||
wc.send("app:query-dirty-editors");
|
||||
} catch (err) {
|
||||
// `webContents.send` can throw if the renderer was destroyed
|
||||
// between our `isCrashed?.()` check and this call (a real race
|
||||
// when the GPU process is dying). Tear the listener/timer down
|
||||
// synchronously so we don't strand quitGuardChannelBusy=true.
|
||||
console.warn("[Main] Failed to query renderer for dirty editors:", err);
|
||||
settle("commit");
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup all PTY sessions and port forwarding tunnels before quitting
|
||||
|
||||
@@ -548,6 +548,12 @@ const api = {
|
||||
const result = await ipcRenderer.invoke("netcatty:mosh:start", options);
|
||||
return result.sessionId;
|
||||
},
|
||||
detectMoshClient: async () => {
|
||||
return ipcRenderer.invoke("netcatty:mosh:detectClient");
|
||||
},
|
||||
pickMoshClient: async () => {
|
||||
return ipcRenderer.invoke("netcatty:mosh:pickClient");
|
||||
},
|
||||
startLocalSession: async (options) => {
|
||||
const result = await ipcRenderer.invoke("netcatty:local:start", options || {});
|
||||
return result.sessionId;
|
||||
|
||||
12
global.d.ts
vendored
12
global.d.ts
vendored
@@ -73,6 +73,8 @@ declare global {
|
||||
keyId?: string;
|
||||
keySource?: 'generated' | 'imported';
|
||||
agentForwarding?: boolean;
|
||||
x11Forwarding?: boolean;
|
||||
x11Display?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
charset?: string;
|
||||
@@ -177,6 +179,7 @@ declare global {
|
||||
username?: string;
|
||||
port?: number;
|
||||
moshServerPath?: string;
|
||||
moshClientPath?: string;
|
||||
agentForwarding?: boolean;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
@@ -184,6 +187,13 @@ declare global {
|
||||
env?: Record<string, string>;
|
||||
sessionLog?: { enabled: boolean; directory: string; format: string };
|
||||
}): Promise<string>;
|
||||
detectMoshClient?(): Promise<{
|
||||
platform: string;
|
||||
found: boolean;
|
||||
path: string | null;
|
||||
searchedPaths: string[];
|
||||
}>;
|
||||
pickMoshClient?(): Promise<{ canceled: boolean; filePath: string | null }>;
|
||||
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; shellArgs?: string[]; cwd?: string; env?: Record<string, string>; sessionLog?: { enabled: boolean; directory: string; format: string } }): Promise<string>;
|
||||
startSerialSession?(options: {
|
||||
sessionId?: string;
|
||||
@@ -206,7 +216,7 @@ declare global {
|
||||
}>>;
|
||||
getDefaultShell?(): Promise<string>;
|
||||
discoverShells?(): Promise<DiscoveredShell[]>;
|
||||
validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean }>;
|
||||
validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean; isExecutable: boolean }>;
|
||||
generateKeyPair?(options: {
|
||||
type: 'RSA' | 'ECDSA' | 'ED25519';
|
||||
bits?: number;
|
||||
|
||||
25
index.css
25
index.css
@@ -1,5 +1,25 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Bundled icon-only fallback so terminals show Nerd Font glyphs (powerline,
|
||||
devicons, etc.) regardless of which base font the user picks. The font is
|
||||
referenced last in the fontFamily fallback chain (see withCjkFallback in
|
||||
infrastructure/config/fonts.ts) — base text comes from the user's chosen
|
||||
font, missing PUA glyphs fall through to this face.
|
||||
|
||||
Source: https://github.com/ryanoasis/nerd-fonts (NerdFontsSymbolsOnly,
|
||||
v3.4.0). License: MIT — see public/fonts/SymbolsNerdFont-LICENSE.txt. */
|
||||
@font-face {
|
||||
font-family: "Symbols Nerd Font Mono";
|
||||
/* Absolute path resolves against the document origin in both dev
|
||||
(http://localhost:5173) and packaged (app://netcatty), regardless of
|
||||
where the bundled CSS file ends up. A relative ./fonts/... would be
|
||||
resolved against dist/assets/index-*.css in production and 404. */
|
||||
src: url("/fonts/SymbolsNerdFontMono-Regular.ttf") format("truetype");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Tailwind CSS v4 Theme Configuration
|
||||
============================================ */
|
||||
@@ -398,6 +418,11 @@ body {
|
||||
.workspace-pane:not(:focus-within) .xterm-screen {
|
||||
opacity: 0.82;
|
||||
}
|
||||
/* Keep current pane fully visible while its context menu / popover is open;
|
||||
focus moves to the menu portal and would otherwise drop :focus-within. */
|
||||
.workspace-pane[data-menu-open] .xterm-screen {
|
||||
opacity: 1;
|
||||
}
|
||||
/* Border-style focus indicator (opt-in via data attribute) */
|
||||
[data-workspace-focus="border"] .workspace-pane:not(:focus-within) .xterm-screen {
|
||||
opacity: 1;
|
||||
|
||||
@@ -28,16 +28,37 @@ const CJK_FALLBACK_FONTS = [
|
||||
'"SimSun"',
|
||||
];
|
||||
|
||||
// Nerd Font symbol-only fallback. Appended after CJK fallbacks so the browser
|
||||
// can locate Private Use Area glyphs (powerline / devicons / etc.) when the
|
||||
// primary font does not ship them — without forcing the user to pick a Nerd
|
||||
// Font variant manually. Mono variants come first to preserve cell width.
|
||||
const NERD_FONT_FALLBACK_FONTS = [
|
||||
'"Symbols Nerd Font Mono"',
|
||||
'"Symbols Nerd Font"',
|
||||
];
|
||||
|
||||
const CJK_FALLBACK_STACK = CJK_FALLBACK_FONTS.join(', ');
|
||||
const NERD_FONT_FALLBACK_STACK = NERD_FONT_FALLBACK_FONTS.join(', ');
|
||||
|
||||
export const withCjkFallback = (family: string) => {
|
||||
const trimmed = family.trim();
|
||||
if (!CJK_FALLBACK_STACK) return trimmed;
|
||||
// Avoid double-appending if a custom stack already includes one of these fonts.
|
||||
if (CJK_FALLBACK_FONTS.some((f) => trimmed.includes(f.replace(/"/g, "")))) {
|
||||
return trimmed;
|
||||
const segments: string[] = [trimmed];
|
||||
|
||||
if (
|
||||
CJK_FALLBACK_STACK &&
|
||||
!CJK_FALLBACK_FONTS.some((f) => trimmed.includes(f.replace(/"/g, '')))
|
||||
) {
|
||||
segments.push(CJK_FALLBACK_STACK);
|
||||
}
|
||||
return `${trimmed}, ${CJK_FALLBACK_STACK}`;
|
||||
|
||||
if (
|
||||
NERD_FONT_FALLBACK_STACK &&
|
||||
!NERD_FONT_FALLBACK_FONTS.some((f) => trimmed.includes(f.replace(/"/g, '')))
|
||||
) {
|
||||
segments.push(NERD_FONT_FALLBACK_STACK);
|
||||
}
|
||||
|
||||
return segments.join(', ');
|
||||
};
|
||||
|
||||
const BASE_TERMINAL_FONTS: TerminalFont[] = [
|
||||
|
||||
@@ -1050,6 +1050,19 @@ export class CloudSyncManager {
|
||||
this.updateProviderStatus(provider, 'error', error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the transient 'connecting' UI state without disturbing the adapter
|
||||
* or the auth restore snapshot. Used by PKCE flows after the browser handoff
|
||||
* has succeeded, so the settings page isn't visually stuck at "connecting"
|
||||
* while we wait for the redirect callback in the background.
|
||||
*/
|
||||
clearConnectingStatus(provider: CloudProvider): void {
|
||||
if (this.state.providers[provider]?.status !== 'connecting') {
|
||||
return;
|
||||
}
|
||||
this.updateProviderStatus(provider, 'disconnected');
|
||||
}
|
||||
|
||||
clearProviderError(provider: CloudProvider): void {
|
||||
const connection = this.state.providers[provider];
|
||||
if (!connection?.error && connection?.status !== 'error') {
|
||||
|
||||
@@ -55,6 +55,8 @@ const KNOWN_MONOSPACE_FONTS = new Set([
|
||||
'sarasa mono',
|
||||
'maple mono',
|
||||
'meslolgs nf',
|
||||
'symbols nerd font mono',
|
||||
'symbols nerd font',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs application/state/*.test.ts components/ai/*.test.ts components/terminal/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts"
|
||||
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs application/state/*.test.ts components/*.test.tsx components/ai/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.58",
|
||||
|
||||
21
public/fonts/SymbolsNerdFont-LICENSE.txt
Normal file
21
public/fonts/SymbolsNerdFont-LICENSE.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014 Ryan L McIntyre
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
BIN
public/fonts/SymbolsNerdFontMono-Regular.ttf
Normal file
BIN
public/fonts/SymbolsNerdFontMono-Regular.ttf
Normal file
Binary file not shown.
Reference in New Issue
Block a user