Compare commits

...

35 Commits

Author SHA1 Message Date
bincxz
cac621413c Updates application icon asset
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Replaces the existing icon with a new version to refresh
visual branding and improve recognition in user interfaces.
2026-01-05 22:53:10 +08:00
bincxz
897ddaddbf Refactors identity and credential combobox formatting
Improves readability and consistency by adjusting indentation
and formatting of identity filtering and credential selection
comboboxes. No functional changes; aids maintainability
and reduces confusion in complex conditional UI rendering.
2026-01-05 22:46:15 +08:00
bincxz
d51c0f526c Prefills group for new hosts based on navigation
Improves user experience by automatically setting the group for new hosts to match the current navigation context.
Reduces manual input and helps maintain organizational consistency.
2026-01-05 22:46:10 +08:00
bincxz
7acd9b3b8d Improves formatting and indentation in UI components
Refactors whitespace and indentation for better code readability and consistency in modal and terminal components. No logic or functional changes are introduced.
2026-01-05 22:40:09 +08:00
bincxz
05345d1ac7 Adds serial local echo and line mode terminal options
Enhances serial terminal usability by introducing configurable options
for forced local echo and line mode (buffer input, send on Enter).
Improves cross-platform compatibility by handling newlines to avoid
display artifacts, and updates UI to allow manual port entry and
clearer feedback for serial connections.
2026-01-05 22:39:53 +08:00
陈大猫
1f1ec8f7a6 Merge pull request #24 from binaricat/copilot/add-serial-port-support
Add serial port connection support
2026-01-05 20:39:12 +08:00
copilot-swe-agent[bot]
8abba4bc7d Improve serial port validation based on code review
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-05 12:19:46 +00:00
copilot-swe-agent[bot]
ccf707df5a Add serial port connection support
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-05 12:13:54 +00:00
copilot-swe-agent[bot]
48d7a63d2e Initial plan 2026-01-05 11:52:43 +00:00
LAPTOP-O016UC3M\Qi Chen
ad7f523ec2 Removes extraneous whitespace in effect hooks
Cleans up unnecessary blank lines within effect hooks to improve code readability and maintain consistency.
2026-01-05 18:24:52 +08:00
LAPTOP-O016UC3M\Qi Chen
a905b3e092 Improves shell path validation for executables
Enhances path validation logic to better detect shell executables
by checking the system PATH and handling .exe extensions on Windows.
Improves user experience when specifying shell paths that are not
absolute or lack file extensions.
2026-01-05 18:24:26 +08:00
陈大猫
23148e88b1 Merge pull request #23 from binaricat/copilot/feature-remember-window-size-position
feat: remember window size and position on restart
2026-01-05 18:13:36 +08:00
copilot-swe-agent[bot]
23c6c55968 refactor: remove duplicate code in window state persistence
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-05 10:00:57 +00:00
copilot-swe-agent[bot]
a53264013c feat: remember window size and position on restart
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-05 09:59:21 +00:00
copilot-swe-agent[bot]
7f58e039a2 Initial plan for window state persistence
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-05 09:56:17 +00:00
copilot-swe-agent[bot]
4999a6884b Initial plan 2026-01-05 09:53:21 +00:00
陈大猫
eb8b565a77 Merge pull request #22 from binaricat/copilot/add-configurable-terminal-shell
Add configurable shell and starting directory for local terminal
2026-01-05 17:50:37 +08:00
copilot-swe-agent[bot]
cf103d7421 Fix tilde expansion logic for path validation
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-05 09:48:23 +00:00
copilot-swe-agent[bot]
88b8cfb4da Add default shell detection and path validation for local shell settings
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-05 09:45:22 +00:00
copilot-swe-agent[bot]
24f7a5a805 Address code review feedback: simplify code and add cwd path validation
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-05 09:32:25 +00:00
copilot-swe-agent[bot]
37d289be50 Add configurable shell and starting directory for local terminal
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-05 09:30:33 +00:00
copilot-swe-agent[bot]
74f99e65d9 Initial plan 2026-01-05 09:14:43 +00:00
陈大猫
937608e7f3 Merge pull request #21 from binaricat/copilot/add-jump-host-support
Add jump host support for SFTP connections
2026-01-05 17:13:29 +08:00
copilot-swe-agent[bot]
3e1b72b869 Address code review feedback - add logging to catch blocks
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-05 09:08:54 +00:00
copilot-swe-agent[bot]
9d04ae86f4 Add jump host support for SFTP connections
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-05 09:06:17 +00:00
copilot-swe-agent[bot]
7beb9c1444 Initial plan 2026-01-05 08:46:53 +00:00
陈大猫
dd2f23b672 Merge pull request #18 from Weihong-Liu/revert-14-feature/auto_check_update 2026-01-05 15:33:15 +08:00
Puppet
eac1007764 Revert "feat: add auto check update" 2026-01-05 15:30:57 +08:00
陈大猫
62625214a0 Merge pull request #14 from Weihong-Liu/feature/auto_check_update
feat: add auto check update
2026-01-05 15:27:24 +08:00
Puppet
a6ae160932 Update electron/bridges/updateBridge.cjs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-05 15:06:31 +08:00
Puppet
6f1431e623 fix: guard update download and clarify install 2026-01-05 15:05:18 +08:00
Puppet
bebd161a98 fix: disable update badge while downloading 2026-01-05 15:05:18 +08:00
Puppet
3eaac53515 fix: dedupe update available toast 2026-01-05 15:05:18 +08:00
Puppet
3a6949862d Update application/state/useUpdateCheck.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-05 14:43:32 +08:00
Puppet
3c843c448a feat: add auto check update 2026-01-05 11:20:45 +08:00
25 changed files with 3772 additions and 2110 deletions

View File

@@ -208,6 +208,7 @@ function App({ settings }: { settings: SettingsState }) {
submitWorkspaceRename,
resetWorkspaceRename,
createLocalTerminal,
createSerialSession,
connectToHost,
closeSession,
closeWorkspace,
@@ -764,6 +765,7 @@ function App({ settings }: { settings: SettingsState }) {
onOpenSettings={handleOpenSettings}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onCreateLocalTerminal={handleCreateLocalTerminal}
onConnectSerial={createSerialSession}
onDeleteHost={handleDeleteHost}
onConnect={handleConnectToHost}
onUpdateHosts={updateHosts}

View File

@@ -28,6 +28,7 @@ const en: Messages = {
'common.noResultsFound': 'No results found',
'common.back': 'Back',
'common.apply': 'Apply',
'common.use': 'Use',
'common.saveChanges': 'Save Changes',
'common.advanced': 'Advanced',
'common.left': 'Left',
@@ -166,6 +167,18 @@ const en: Messages = {
'settings.terminal.scrollback.rows': 'Number of rows *',
'settings.terminal.keywordHighlight.title': 'Keyword highlighting',
'settings.terminal.keywordHighlight.resetColors': 'Reset to default colors',
'settings.terminal.section.localShell': 'Local Shell',
'settings.terminal.localShell.shell': 'Shell executable',
'settings.terminal.localShell.shell.desc': 'Path to the shell executable (e.g., /bin/zsh, pwsh.exe). Leave empty for system default.',
'settings.terminal.localShell.shell.placeholder': 'System default',
'settings.terminal.localShell.shell.detected': 'Detected',
'settings.terminal.localShell.shell.notFound': 'Shell executable not found',
'settings.terminal.localShell.shell.isDirectory': 'Path is a directory, not an executable',
'settings.terminal.localShell.startDir': 'Starting directory',
'settings.terminal.localShell.startDir.desc': 'Directory to start in when opening a local terminal. Leave empty for home directory.',
'settings.terminal.localShell.startDir.placeholder': 'Home directory',
'settings.terminal.localShell.startDir.notFound': 'Directory not found',
'settings.terminal.localShell.startDir.isFile': 'Path is a file, not a directory',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': 'Hotkey Scheme',
@@ -943,6 +956,37 @@ const en: Messages = {
'snippets.packageDialog.root': 'Root',
'snippets.packageDialog.placeholder': 'e.g. ops/maintenance',
'snippets.packageDialog.hint': 'Use "/" to create nested packages.',
// Serial Port
'serial.button': 'Serial',
'serial.modal.title': 'Connect to Serial Port',
'serial.modal.desc': 'Configure serial port connection settings',
'serial.field.port': 'Serial Port',
'serial.field.selectPort': 'Select a port...',
'serial.field.baudRate': 'Baud Rate',
'serial.field.dataBits': 'Data Bits',
'serial.field.stopBits': 'Stop Bits',
'serial.field.parity': 'Parity',
'serial.field.flowControl': 'Flow Control',
'serial.noPorts': 'No serial ports detected. Connect a device and refresh.',
'serial.field.customPort': 'Custom Port Path',
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001',
'serial.type.hardware': 'Hardware',
'serial.type.pseudo': 'Pseudo Terminal',
'serial.type.custom': 'Custom',
'serial.parity.none': 'None',
'serial.parity.even': 'Even',
'serial.parity.odd': 'Odd',
'serial.parity.mark': 'Mark',
'serial.parity.space': 'Space',
'serial.flowControl.none': 'None',
'serial.flowControl.xon/xoff': 'XON/XOFF (Software)',
'serial.flowControl.rts/cts': 'RTS/CTS (Hardware)',
'serial.field.localEcho': 'Force Local Echo',
'serial.field.localEchoDesc': 'Echo typed characters locally (for devices without remote echo)',
'serial.field.lineMode': 'Line Mode',
'serial.field.lineModeDesc': 'Buffer input and send on Enter (instead of character-by-character)',
'serial.connectionError': 'Failed to connect to serial port',
};
export default en;

View File

@@ -19,6 +19,7 @@ const zhCN: Messages = {
'common.noResultsFound': '没有匹配结果',
'common.back': '返回',
'common.apply': '应用',
'common.use': '使用',
'common.left': '左侧',
'common.right': '右侧',
'common.selectAHost': '选择主机',
@@ -741,6 +742,18 @@ const zhCN: Messages = {
'settings.terminal.scrollback.rows': '行数 *',
'settings.terminal.keywordHighlight.title': '关键字高亮',
'settings.terminal.keywordHighlight.resetColors': '重置为默认颜色',
'settings.terminal.section.localShell': '本地 Shell',
'settings.terminal.localShell.shell': 'Shell 可执行文件',
'settings.terminal.localShell.shell.desc': 'Shell 可执行文件的路径(例如 /bin/zsh、pwsh.exe。留空使用系统默认。',
'settings.terminal.localShell.shell.placeholder': '系统默认',
'settings.terminal.localShell.shell.detected': '检测到',
'settings.terminal.localShell.shell.notFound': '未找到 Shell 可执行文件',
'settings.terminal.localShell.shell.isDirectory': '路径是目录,不是可执行文件',
'settings.terminal.localShell.startDir': '起始目录',
'settings.terminal.localShell.startDir.desc': '打开本地终端时的起始目录。留空使用用户主目录。',
'settings.terminal.localShell.startDir.placeholder': '用户主目录',
'settings.terminal.localShell.startDir.notFound': '目录不存在',
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': '快捷键方案',
@@ -932,6 +945,37 @@ const zhCN: Messages = {
'snippets.packageDialog.root': '根目录',
'snippets.packageDialog.placeholder': '例如ops/maintenance',
'snippets.packageDialog.hint': '使用 "/" 创建嵌套代码包。',
// Serial Port
'serial.button': '串口',
'serial.modal.title': '连接串口',
'serial.modal.desc': '配置串口连接参数',
'serial.field.port': '串口',
'serial.field.selectPort': '选择串口...',
'serial.field.baudRate': '波特率',
'serial.field.dataBits': '数据位',
'serial.field.stopBits': '停止位',
'serial.field.parity': '校验位',
'serial.field.flowControl': '流控制',
'serial.noPorts': '未检测到串口设备。请连接设备后刷新。',
'serial.field.customPort': '自定义串口路径',
'serial.field.customPortPlaceholder': '例如 /dev/ttys001',
'serial.type.hardware': '硬件',
'serial.type.pseudo': '虚拟终端',
'serial.type.custom': '自定义',
'serial.parity.none': '无',
'serial.parity.even': '偶校验',
'serial.parity.odd': '奇校验',
'serial.parity.mark': 'Mark',
'serial.parity.space': 'Space',
'serial.flowControl.none': '无',
'serial.flowControl.xon/xoff': 'XON/XOFF (软件)',
'serial.flowControl.rts/cts': 'RTS/CTS (硬件)',
'serial.field.localEcho': '强制本地回显',
'serial.field.localEchoDesc': '本地回显输入字符(用于没有远程回显的设备)',
'serial.field.lineMode': '行模式',
'serial.field.lineModeDesc': '缓冲输入,按回车后发送(而不是逐字符发送)',
'serial.connectionError': '连接串口失败',
};
export default zhCN;

View File

@@ -1,5 +1,5 @@
import { MouseEvent,useCallback,useMemo,useState } from 'react';
import { ConnectionLog,Host,Snippet,TerminalSession,Workspace,WorkspaceViewMode } from '../../domain/models';
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceViewMode } from '../../domain/models';
import {
collectSessionIds,
createWorkspaceFromSessions as createWorkspaceEntity,
@@ -53,6 +53,24 @@ export const useSessionState = () => {
setActiveTabId(sessionId);
}, [setActiveTabId]);
const createSerialSession = useCallback((config: SerialConfig) => {
const sessionId = crypto.randomUUID();
const serialHostId = `serial-${sessionId}`;
const portName = config.path.split('/').pop() || config.path;
const newSession: TerminalSession = {
id: sessionId,
hostId: serialHostId,
hostLabel: `Serial: ${portName}`,
hostname: config.path,
username: '',
status: 'connecting',
protocol: 'serial',
serialConfig: config,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
}, [setActiveTabId]);
const connectToHost = useCallback((host: Host) => {
const newSession: TerminalSession = {
id: crypto.randomUUID(),
@@ -590,6 +608,7 @@ export const useSessionState = () => {
submitWorkspaceRename,
resetWorkspaceRename,
createLocalTerminal,
createSerialSession,
connectToHost,
closeSession,
closeWorkspace,

View File

@@ -293,6 +293,47 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
(host: Host): NetcattySSHOptions => {
const resolved = resolveHostAuth({ host, keys, identities });
const key = resolved.key || null;
// Build proxy config if present
const proxyConfig = host.proxyConfig
? {
type: host.proxyConfig.type,
host: host.proxyConfig.host,
port: host.proxyConfig.port,
username: host.proxyConfig.username,
password: host.proxyConfig.password,
}
: undefined;
// Build jump hosts array if host chain is configured
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
jumpHosts = host.hostChain.hostIds
.map((hostId) => hosts.find((h) => h.id === hostId))
.filter((h): h is Host => !!h)
.map((jumpHost) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys,
identities,
});
const jumpKey = jumpAuth.key;
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpAuth.username || "root",
password: jumpAuth.password,
privateKey: jumpKey?.privateKey,
certificate: jumpKey?.certificate,
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
publicKey: jumpKey?.publicKey,
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
};
});
}
return {
hostname: host.hostname,
username: resolved.username,
@@ -303,9 +344,11 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
publicKey: key?.publicKey,
keyId: resolved.keyId,
keySource: key?.source,
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
};
},
[identities, keys],
[hosts, identities, keys],
);
const getMockLocalFiles = useCallback((path: string): SftpFileEntry[] => {

View File

@@ -17,6 +17,11 @@ export const useTerminalBackend = () => {
return !!bridge?.startLocalSession;
}, []);
const serialAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.startSerialSession;
}, []);
const execAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.execCommand;
@@ -46,6 +51,12 @@ export const useTerminalBackend = () => {
return bridge.startLocalSession(options);
}, []);
const startSerialSession = useCallback(async (options: Parameters<NonNullable<NetcattyBridge["startSerialSession"]>>[0]) => {
const bridge = netcattyBridge.get();
if (!bridge?.startSerialSession) throw new Error("startSerialSession unavailable");
return bridge.startSerialSession(options);
}, []);
const execCommand = useCallback(async (options: Parameters<NetcattyBridge["execCommand"]>[0]) => {
const bridge = netcattyBridge.get();
if (!bridge?.execCommand) throw new Error("execCommand unavailable");
@@ -99,17 +110,26 @@ export const useTerminalBackend = () => {
return !!bridge?.startSSHSession;
}, []);
const listSerialPorts = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.listSerialPorts) return [];
return bridge.listSerialPorts();
}, []);
return {
backendAvailable,
telnetAvailable,
moshAvailable,
localAvailable,
serialAvailable,
execAvailable,
openExternalAvailable,
startSSHSession,
startTelnetSession,
startMoshSession,
startLocalSession,
startSerialSession,
listSerialPorts,
execCommand,
writeToSession,
resizeSession,

View File

@@ -59,6 +59,7 @@ interface HostDetailsPanelProps {
groups: string[];
allTags?: string[]; // All available tags for autocomplete
allHosts?: Host[]; // All hosts for chain selection
defaultGroup?: string | null; // Default group for new hosts (from current navigation)
onSave: (host: Host) => void;
onCancel: () => void;
onCreateGroup?: (groupPath: string) => void; // Callback to create a new group
@@ -72,6 +73,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
groups,
allTags = [],
allHosts = [],
defaultGroup,
onSave,
onCancel,
onCreateGroup,
@@ -95,6 +97,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
charset: "UTF-8",
theme: "Flexoki Dark",
createdAt: Date.now(),
group: defaultGroup || undefined, // Pre-fill with current navigation group
} as Host),
);
@@ -286,10 +289,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
const base = identities;
const filtered = q
? base.filter(
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
: base;
return filtered.slice(0, 6);
}, [form.username, identities, selectedIdentity]);
@@ -639,10 +642,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
const q = next.toLowerCase().trim();
const matches = q
? identities.filter(
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
: identities;
setIdentitySuggestionsOpen(matches.length > 0);
}}
@@ -650,10 +653,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
const q = (form.username || "").toLowerCase().trim();
const matches = q
? identities.filter(
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
: identities;
setIdentitySuggestionsOpen(matches.length > 0);
}}
@@ -670,10 +673,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
.trim();
const matches = q
? identities.filter(
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
: identities;
return matches.length > 0;
});
@@ -702,8 +705,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{filteredIdentitySuggestions.map((identity) => {
const keyLabel = identity.keyId
? availableKeys.find(
(k) => k.id === identity.keyId,
)?.label
(k) => k.id === identity.keyId,
)?.label
: undefined;
const methodLabel =
identity.authMethod === "certificate"
@@ -850,42 +853,42 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{!selectedIdentity &&
selectedCredentialType === "key" &&
!form.identityFileId && (
<div className="flex items-center gap-1">
<Combobox
options={keysByCategory.key.map((k) => ({
value: k.id,
label: k.label,
sublabel: `${k.type}${k.keySize ? ` ${k.keySize}` : ""}`,
icon: <Key size={14} className="text-muted-foreground" />,
}))}
value={form.identityFileId}
onValueChange={(val) => {
update("identityFileId", val);
update("authMethod", "key");
setSelectedCredentialType(null);
}}
placeholder={t("hostDetails.keys.search")}
emptyText={t("hostDetails.keys.empty")}
icon={<Key size={14} className="text-muted-foreground" />}
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setSelectedCredentialType(null)}
>
<X size={14} />
</Button>
</div>
)}
<div className="flex items-center gap-1">
<Combobox
options={keysByCategory.key.map((k) => ({
value: k.id,
label: k.label,
sublabel: `${k.type}${k.keySize ? ` ${k.keySize}` : ""}`,
icon: <Key size={14} className="text-muted-foreground" />,
}))}
value={form.identityFileId}
onValueChange={(val) => {
update("identityFileId", val);
update("authMethod", "key");
setSelectedCredentialType(null);
}}
placeholder={t("hostDetails.keys.search")}
emptyText={t("hostDetails.keys.empty")}
icon={<Key size={14} className="text-muted-foreground" />}
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setSelectedCredentialType(null)}
>
<X size={14} />
</Button>
</div>
)}
{/* Certificate selection combobox - appears after selecting "Certificate" type */}
{!selectedIdentity &&
selectedCredentialType === "certificate" &&
!form.identityFileId && (
<div className="flex items-center gap-1">
<Combobox
{!selectedIdentity &&
selectedCredentialType === "certificate" &&
!form.identityFileId && (
<div className="flex items-center gap-1">
<Combobox
options={keysByCategory.certificate.map((k) => ({
value: k.id,
label: k.label,
@@ -913,11 +916,11 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onClick={() => setSelectedCredentialType(null)}
>
<X size={14} />
</Button>
</div>
)}
</div>
</Card>
</Button>
</div>
)}
</div>
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<p className="text-xs font-semibold">

View File

@@ -233,6 +233,8 @@ interface SFTPModalProps {
publicKey?: string;
keyId?: string;
keySource?: 'generated' | 'imported';
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
};
open: boolean;
onClose: () => void;
@@ -445,6 +447,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
publicKey: credentials.publicKey,
keyId: credentials.keyId,
keySource: credentials.keySource,
proxy: credentials.proxy,
jumpHosts: credentials.jumpHosts,
});
sftpIdRef.current = sftpId;
return sftpId;
@@ -461,6 +465,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
credentials.publicKey,
credentials.keyId,
credentials.keySource,
credentials.proxy,
credentials.jumpHosts,
openSftp,
]);

View File

@@ -0,0 +1,332 @@
/**
* Serial Port Connect Modal
* Allows users to configure and connect to a serial port
*/
import { ChevronDown, ChevronUp, Cpu, RefreshCw, Usb } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useTerminalBackend } from '../application/state/useTerminalBackend';
import type { SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
import { Combobox, type ComboboxOption } from './ui/combobox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Label } from './ui/label';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
interface SerialPort {
path: string;
manufacturer: string;
serialNumber: string;
vendorId: string;
productId: string;
pnpId: string;
type?: 'hardware' | 'pseudo' | 'custom';
}
interface SerialConnectModalProps {
open: boolean;
onClose: () => void;
onConnect: (config: SerialConfig) => void;
}
const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
const DATA_BITS: Array<5 | 6 | 7 | 8> = [5, 6, 7, 8];
const STOP_BITS: Array<1 | 1.5 | 2> = [1, 1.5, 2];
const PARITY_OPTIONS: SerialParity[] = ['none', 'even', 'odd', 'mark', 'space'];
const FLOW_CONTROL_OPTIONS: SerialFlowControl[] = ['none', 'xon/xoff', 'rts/cts'];
export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
open,
onClose,
onConnect,
}) => {
const { t } = useI18n();
const [ports, setPorts] = useState<SerialPort[]>([]);
const [isLoadingPorts, setIsLoadingPorts] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
// Form state
const [selectedPort, setSelectedPort] = useState('');
const [baudRate, setBaudRate] = useState(115200);
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(8);
const [stopBits, setStopBits] = useState<1 | 1.5 | 2>(1);
const [parity, setParity] = useState<SerialParity>('none');
const [flowControl, setFlowControl] = useState<SerialFlowControl>('none');
const [localEcho, setLocalEcho] = useState(false);
const [lineMode, setLineMode] = useState(false);
const terminalBackend = useTerminalBackend();
const loadPorts = useCallback(async () => {
setIsLoadingPorts(true);
try {
const result = await terminalBackend.listSerialPorts();
setPorts(result);
// Auto-select first port if available and no port is selected
if (result.length > 0) {
setSelectedPort((prev) => prev || result[0].path);
}
} catch (err) {
console.error('[Serial] Failed to list ports:', err);
} finally {
setIsLoadingPorts(false);
}
}, [terminalBackend]);
useEffect(() => {
if (open) {
loadPorts();
}
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
const handleConnect = () => {
if (!selectedPort) return;
const config: SerialConfig = {
path: selectedPort,
baudRate,
dataBits,
stopBits,
parity,
flowControl,
localEcho,
lineMode,
};
onConnect(config);
onClose();
};
// Convert ports to Combobox options
const portOptions: ComboboxOption[] = useMemo(() => {
return ports.map((port) => ({
value: port.path,
label: port.path,
sublabel: port.manufacturer || undefined,
}));
}, [ports]);
// Validate: port path must start with /dev/
const isPortValid = selectedPort.trim().startsWith('/dev/');
const isBaudRateValid = BAUD_RATES.includes(baudRate);
const isValid = isPortValid && isBaudRateValid;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Usb size={18} />
{t('serial.modal.title')}
</DialogTitle>
<DialogDescription>
{t('serial.modal.desc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Serial Port Selection */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="serial-port">{t('serial.field.port')}</Label>
<Button
variant="ghost"
size="sm"
onClick={loadPorts}
disabled={isLoadingPorts}
className="h-6 px-2 text-xs"
>
<RefreshCw size={12} className={cn("mr-1", isLoadingPorts && "animate-spin")} />
{t('common.refresh')}
</Button>
</div>
{/* Combobox for port selection with manual input support */}
<Combobox
options={portOptions}
value={selectedPort}
onValueChange={setSelectedPort}
placeholder={t('serial.field.selectPort')}
emptyText={t('serial.noPorts')}
allowCreate
createText={t('common.use')}
icon={<Usb size={14} className="text-muted-foreground" />}
/>
{!isPortValid && selectedPort && (
<p className="text-xs text-destructive">
{t('serial.field.customPortPlaceholder')}
</p>
)}
</div>
{/* Baud Rate */}
<div className="space-y-2">
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
<select
id="baud-rate"
value={baudRate}
onChange={(e) => setBaudRate(parseInt(e.target.value, 10))}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{BAUD_RATES.map((rate) => (
<option key={rate} value={rate}>
{rate}
</option>
))}
</select>
</div>
{/* Advanced Options */}
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="w-full justify-between h-9 px-0 hover:bg-transparent"
>
<span className="text-sm font-medium text-muted-foreground">
{t('common.advanced')}
</span>
{showAdvanced ? (
<ChevronUp size={14} className="text-muted-foreground" />
) : (
<ChevronDown size={14} className="text-muted-foreground" />
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{/* Data Bits */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<select
id="data-bits"
value={dataBits}
onChange={(e) => setDataBits(parseInt(e.target.value, 10) as 5 | 6 | 7 | 8)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{DATA_BITS.map((bits) => (
<option key={bits} value={bits}>
{bits}
</option>
))}
</select>
</div>
{/* Stop Bits */}
<div className="space-y-2">
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<select
id="stop-bits"
value={stopBits}
onChange={(e) => setStopBits(parseFloat(e.target.value) as 1 | 1.5 | 2)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{STOP_BITS.map((bits) => (
<option key={bits} value={bits}>
{bits}
</option>
))}
</select>
</div>
</div>
{/* Parity */}
<div className="space-y-2">
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
<select
id="parity"
value={parity}
onChange={(e) => setParity(e.target.value as SerialParity)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{PARITY_OPTIONS.map((option) => (
<option key={option} value={option}>
{t(`serial.parity.${option}`)}
</option>
))}
</select>
</div>
{/* Flow Control */}
<div className="space-y-2">
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<select
id="flow-control"
value={flowControl}
onChange={(e) => setFlowControl(e.target.value as SerialFlowControl)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{FLOW_CONTROL_OPTIONS.map((option) => (
<option key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</option>
))}
</select>
</div>
{/* Terminal Options */}
<div className="space-y-3 pt-2 border-t border-border/60">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="local-echo" className="text-sm font-medium cursor-pointer">
{t('serial.field.localEcho')}
</Label>
<p className="text-xs text-muted-foreground">
{t('serial.field.localEchoDesc')}
</p>
</div>
<input
type="checkbox"
id="local-echo"
checked={localEcho}
onChange={(e) => setLocalEcho(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="line-mode" className="text-sm font-medium cursor-pointer">
{t('serial.field.lineMode')}
</Label>
<p className="text-xs text-muted-foreground">
{t('serial.field.lineModeDesc')}
</p>
</div>
<input
type="checkbox"
id="line-mode"
checked={lineMode}
onChange={(e) => setLineMode(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button onClick={handleConnect} disabled={!isValid}>
<Cpu size={14} className="mr-2" />
{t('common.connect')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default SerialConnectModal;

View File

@@ -12,6 +12,7 @@ import {
Host,
Identity,
KnownHost,
SerialConfig,
SSHKey,
Snippet,
TerminalSession,
@@ -58,6 +59,7 @@ interface TerminalProps {
terminalSettings?: TerminalSettings;
sessionId: string;
startupCommand?: string;
serialConfig?: SerialConfig;
onUpdateTerminalThemeId?: (themeId: string) => void;
onUpdateTerminalFontFamilyId?: (fontFamilyId: string) => void;
onUpdateTerminalFontSize?: (fontSize: number) => void;
@@ -103,6 +105,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalSettings,
sessionId,
startupCommand,
serialConfig,
onUpdateTerminalThemeId,
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
@@ -138,6 +141,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const hasConnectedRef = useRef(false);
const hasRunStartupCommandRef = useRef(false);
const commandBufferRef = useRef<string>("");
const serialLineBufferRef = useRef<string>("");
const terminalSettingsRef = useRef(terminalSettings);
terminalSettingsRef.current = terminalSettings;
@@ -280,6 +284,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
startupCommand,
terminalSettings,
terminalBackend,
serialConfig,
sessionRef,
hasConnectedRef,
hasRunStartupCommandRef,
@@ -336,6 +341,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onCommandExecuted,
commandBufferRef,
setIsSearchOpen,
// Serial-specific options
serialLocalEcho: serialConfig?.localEcho,
serialLineMode: serialConfig?.lineMode,
serialLineBufferRef,
});
xtermRuntimeRef.current = runtime;
@@ -346,7 +355,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const term = runtime.term;
if (host.protocol === "local" || host.hostname === "localhost") {
if (host.protocol === "serial") {
setStatus("connecting");
setProgressLogs(["Initializing serial connection..."]);
await sessionStarters.startSerial(term);
} else if (host.protocol === "local" || host.hostname === "localhost") {
setStatus("connecting");
setProgressLogs(["Initializing local shell..."]);
await sessionStarters.startLocal(term);
@@ -407,21 +420,28 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Connection timeline and timeout visuals
useEffect(() => {
if (status !== "connecting" || auth.needsAuth) return;
const scripted = [
"Resolving host and keys...",
"Negotiating ciphers...",
"Exchanging keys...",
"Authenticating user...",
"Waiting for server greeting...",
];
let idx = 0;
const stepTimer = setInterval(() => {
setProgressLogs((prev) => {
if (idx >= scripted.length) return prev;
const next = scripted[idx++];
return prev.includes(next) ? prev : [...prev, next];
});
}, 900);
// Only show SSH-specific scripted logs for SSH connections
const isSSH = host.protocol !== "serial" && host.protocol !== "local" && host.protocol !== "telnet" && host.hostname !== "localhost";
let stepTimer: ReturnType<typeof setInterval> | undefined;
if (isSSH) {
const scripted = [
"Resolving host and keys...",
"Negotiating ciphers...",
"Exchanging keys...",
"Authenticating user...",
"Waiting for server greeting...",
];
let idx = 0;
stepTimer = setInterval(() => {
setProgressLogs((prev) => {
if (idx >= scripted.length) return prev;
const next = scripted[idx++];
return prev.includes(next) ? prev : [...prev, next];
});
}, 900);
}
setTimeLeft(CONNECTION_TIMEOUT / 1000);
const countdown = setInterval(() => {
@@ -445,13 +465,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, 200);
return () => {
clearInterval(stepTimer);
if (stepTimer) clearInterval(stepTimer);
clearInterval(countdown);
clearTimeout(timeout);
clearInterval(prog);
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateStatus is a stable internal helper
}, [status, auth.needsAuth]);
}, [status, auth.needsAuth, host.protocol, host.hostname]);
const safeFit = () => {
const fitAddon = fitAddonRef.current;
@@ -822,11 +842,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
>
<div className="relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]">
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
<div
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0 border-b-[0.5px]"
style={{
backgroundColor: effectiveTheme.colors.background,
color: effectiveTheme.colors.foreground,
<div
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0 border-b-[0.5px]"
style={{
backgroundColor: effectiveTheme.colors.background,
color: effectiveTheme.colors.foreground,
borderColor: `color-mix(in srgb, ${effectiveTheme.colors.foreground} 8%, ${effectiveTheme.colors.background} 92%)`,
['--terminal-toolbar-fg' as never]: effectiveTheme.colors.foreground,
['--terminal-toolbar-bg' as never]: effectiveTheme.colors.background,
@@ -847,14 +867,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<div className="flex-1" />
<div className="flex items-center gap-0.5 flex-shrink-0">
{inWorkspace && onToggleBroadcast && (
<Button
variant="secondary"
size="icon"
className={cn(
"h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)]",
"bg-transparent hover:bg-transparent",
isBroadcastEnabled && "text-green-500",
)}
<Button
variant="secondary"
size="icon"
className={cn(
"h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)]",
"bg-transparent hover:bg-transparent",
isBroadcastEnabled && "text-green-500",
)}
onClick={onToggleBroadcast}
title={
isBroadcastEnabled
@@ -866,22 +886,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
? t("terminal.toolbar.broadcastDisable")
: t("terminal.toolbar.broadcastEnable")
}
>
<Radio size={12} />
</Button>
)}
{inWorkspace && !isFocusMode && onExpandToFocus && (
<Button
variant="secondary"
size="icon"
className="h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent"
onClick={onExpandToFocus}
title={t("terminal.toolbar.focusMode")}
aria-label={t("terminal.toolbar.focusMode")}
>
<Maximize2 size={12} />
</Button>
)}
>
<Radio size={12} />
</Button>
)}
{inWorkspace && !isFocusMode && onExpandToFocus && (
<Button
variant="secondary"
size="icon"
className="h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent"
onClick={onExpandToFocus}
title={t("terminal.toolbar.focusMode")}
aria-label={t("terminal.toolbar.focusMode")}
>
<Maximize2 size={12} />
</Button>
)}
{renderControls({ showClose: inWorkspace })}
</div>
</div>
@@ -972,6 +992,47 @@ const TerminalComponent: React.FC<TerminalProps> = ({
host={host}
credentials={(() => {
const resolvedAuth = resolveHostAuth({ host, keys, identities });
// Build proxy config if present
const proxyConfig = host.proxyConfig
? {
type: host.proxyConfig.type,
host: host.proxyConfig.host,
port: host.proxyConfig.port,
username: host.proxyConfig.username,
password: host.proxyConfig.password,
}
: undefined;
// Build jump hosts array if host chain is configured
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
jumpHosts = host.hostChain.hostIds
.map((hostId) => allHosts.find((h) => h.id === hostId))
.filter((h): h is Host => !!h)
.map((jumpHost) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys,
identities,
});
const jumpKey = jumpAuth.key;
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpAuth.username || "root",
password: jumpAuth.password,
privateKey: jumpKey?.privateKey,
certificate: jumpKey?.certificate,
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
publicKey: jumpKey?.publicKey,
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
};
});
}
return {
username: resolvedAuth.username,
hostname: host.hostname,
@@ -983,6 +1044,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
publicKey: resolvedAuth.key?.publicKey,
keyId: resolvedAuth.keyId,
keySource: resolvedAuth.key?.source,
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
};
})()}
open={showSFTP && status === "connected"}

View File

@@ -683,6 +683,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
terminalSettings={terminalSettings}
sessionId={session.id}
startupCommand={session.startupCommand}
serialConfig={session.serialConfig}
onUpdateTerminalThemeId={onUpdateTerminalThemeId}
onUpdateTerminalFontFamilyId={onUpdateTerminalFontFamilyId}
onUpdateTerminalFontSize={onUpdateTerminalFontSize}

View File

@@ -16,6 +16,7 @@ import {
TerminalSquare,
Trash2,
Upload,
Usb,
Zap,
} from "lucide-react";
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useState } from "react";
@@ -33,6 +34,7 @@ import {
HostProtocol,
Identity,
KnownHost,
SerialConfig,
SSHKey,
ShellHistoryEntry,
Snippet,
@@ -47,6 +49,7 @@ import KnownHostsManager from "./KnownHostsManager";
import PortForwarding from "./PortForwardingNew";
import QuickConnectWizard from "./QuickConnectWizard";
import { isQuickConnectInput, parseQuickConnectInputWithWarnings } from "../domain/quickConnect";
import SerialConnectModal from "./SerialConnectModal";
import SnippetsManager from "./SnippetsManager";
import { ImportVaultDialog } from "./vault/ImportVaultDialog";
import { Button } from "./ui/button";
@@ -90,6 +93,7 @@ interface VaultViewProps {
onOpenSettings: () => void;
onOpenQuickSwitcher: () => void;
onCreateLocalTerminal: () => void;
onConnectSerial?: (config: SerialConfig) => void;
onDeleteHost: (id: string) => void;
onConnect: (host: Host) => void;
onUpdateHosts: (hosts: Host[]) => void;
@@ -124,6 +128,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onOpenSettings,
onOpenQuickSwitcher,
onCreateLocalTerminal,
onConnectSerial,
onDeleteHost,
onConnect,
onUpdateHosts,
@@ -156,6 +161,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const [renameGroupName, setRenameGroupName] = useState("");
const [renameGroupError, setRenameGroupError] = useState<string | null>(null);
const [isImportOpen, setIsImportOpen] = useState(false);
const [isSerialModalOpen, setIsSerialModalOpen] = useState(false);
// Handle external navigation requests
useEffect(() => {
@@ -960,6 +966,18 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
>
<TerminalSquare size={14} className="mr-2" /> {t("common.terminal")}
</Button>
<Button
size="sm"
variant="secondary"
className={cn(
"h-10 px-3 app-no-drag",
currentSection === "hosts" &&
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
)}
onClick={() => setIsSerialModalOpen(true)}
>
<Usb size={14} className="mr-2" /> {t("serial.button")}
</Button>
</div>
</header>
)}
@@ -1356,6 +1374,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
)}
allTags={allTags}
allHosts={hosts}
defaultGroup={editingHost ? undefined : selectedGroupPath}
onSave={(host) => {
onUpdateHosts(
editingHost
@@ -1503,6 +1522,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
/>
</Suspense>
)}
{/* Serial Connect Modal */}
<SerialConnectModal
open={isSerialModalOpen}
onClose={() => setIsSerialModalOpen(false)}
onConnect={(config) => {
if (onConnectSerial) {
onConnectSerial(config);
}
}}
/>
</div>
);
};

View File

@@ -1,5 +1,5 @@
import React, { useCallback } from "react";
import { Check, Minus, Plus, RotateCcw } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { AlertCircle, Check, Minus, Plus, RotateCcw } from "lucide-react";
import type {
CursorShape,
LinkModifier,
@@ -93,6 +93,87 @@ export default function SettingsTerminalTab(props: {
} = props;
const { t } = useI18n();
// Local shell settings state
const [defaultShell, setDefaultShell] = useState<string>("");
const [shellValidation, setShellValidation] = useState<{ valid: boolean; message?: string } | null>(null);
const [dirValidation, setDirValidation] = useState<{ valid: boolean; message?: string } | null>(null);
// Fetch default shell on mount
useEffect(() => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (bridge?.getDefaultShell) {
bridge.getDefaultShell().then((shell) => {
setDefaultShell(shell);
}).catch(() => {
// Ignore errors - might not be in Electron
});
}
}, []);
// Validate shell path when it changes
useEffect(() => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
const shellPath = terminalSettings.localShell;
if (!shellPath) {
setShellValidation(null);
return;
}
if (!bridge?.validatePath) {
setShellValidation(null);
return;
}
const timeoutId = setTimeout(() => {
bridge.validatePath(shellPath, 'file').then((result) => {
if (result.exists && result.isFile) {
setShellValidation({ valid: true });
} else if (result.exists && result.isDirectory) {
setShellValidation({ valid: false, message: t("settings.terminal.localShell.shell.isDirectory") });
} else {
setShellValidation({ valid: false, message: t("settings.terminal.localShell.shell.notFound") });
}
}).catch(() => {
setShellValidation(null);
});
}, 300);
return () => clearTimeout(timeoutId);
}, [terminalSettings.localShell, t]);
// Validate directory path when it changes
useEffect(() => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
const dirPath = terminalSettings.localStartDir;
if (!dirPath) {
setDirValidation(null);
return;
}
if (!bridge?.validatePath) {
setDirValidation(null);
return;
}
const timeoutId = setTimeout(() => {
bridge.validatePath(dirPath, 'directory').then((result) => {
if (result.exists && result.isDirectory) {
setDirValidation({ valid: true });
} else if (result.exists && result.isFile) {
setDirValidation({ valid: false, message: t("settings.terminal.localShell.startDir.isFile") });
} else {
setDirValidation({ valid: false, message: t("settings.terminal.localShell.startDir.notFound") });
}
}).catch(() => {
setDirValidation(null);
});
}, 300);
return () => clearTimeout(timeoutId);
}, [terminalSettings.localStartDir, t]);
const clampFontSize = useCallback((next: number) => {
const safe = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, next));
setTerminalFontSize(safe);
@@ -443,6 +524,60 @@ export default function SettingsTerminalTab(props: {
</div>
)}
</div>
<SectionHeader title={t("settings.terminal.section.localShell")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.terminal.localShell.shell")}
description={t("settings.terminal.localShell.shell.desc")}
>
<div className="flex flex-col gap-1 items-end">
<Input
value={terminalSettings.localShell}
placeholder={t("settings.terminal.localShell.shell.placeholder")}
onChange={(e) => updateTerminalSetting("localShell", e.target.value)}
className={cn(
"w-48",
shellValidation && !shellValidation.valid && "border-destructive focus-visible:ring-destructive"
)}
/>
{defaultShell && !terminalSettings.localShell && (
<span className="text-xs text-muted-foreground">
{t("settings.terminal.localShell.shell.detected")}: {defaultShell}
</span>
)}
{shellValidation && !shellValidation.valid && shellValidation.message && (
<span className="text-xs text-destructive flex items-center gap-1">
<AlertCircle size={12} />
{shellValidation.message}
</span>
)}
</div>
</SettingRow>
<SettingRow
label={t("settings.terminal.localShell.startDir")}
description={t("settings.terminal.localShell.startDir.desc")}
>
<div className="flex flex-col gap-1">
<Input
value={terminalSettings.localStartDir}
placeholder={t("settings.terminal.localShell.startDir.placeholder")}
onChange={(e) => updateTerminalSetting("localStartDir", e.target.value)}
className={cn(
"w-48",
dirValidation && !dirValidation.valid && "border-destructive focus-visible:ring-destructive"
)}
/>
{dirValidation && !dirValidation.valid && dirValidation.message && (
<span className="text-xs text-destructive flex items-center gap-1">
<AlertCircle size={12} />
{dirValidation.message}
</span>
)}
</div>
</SettingRow>
</div>
</SettingsTabContent>
);
}

View File

@@ -58,6 +58,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
const buttonBase = "h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent";
const isLocalTerminal = host?.protocol === 'local' || host?.id?.startsWith('local-');
const isSerialTerminal = host?.protocol === 'serial' || host?.id?.startsWith('serial-');
const hidesSftp = isLocalTerminal || isSerialTerminal;
const currentThemeId = host?.theme || defaultThemeId;
const currentFontFamilyId = host?.fontFamily || defaultFontFamilyId;
@@ -95,17 +97,19 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
return (
<>
<Button
variant="secondary"
size="icon"
className={buttonBase}
disabled={status !== 'connected'}
title={status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
aria-label={t("terminal.toolbar.openSftp")}
onClick={onOpenSFTP}
>
<FolderInput size={12} />
</Button>
{!hidesSftp && (
<Button
variant="secondary"
size="icon"
className={buttonBase}
disabled={status !== 'connected'}
title={status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
aria-label={t("terminal.toolbar.openSftp")}
onClick={onOpenSFTP}
>
<FolderInput size={12} />
</Button>
)}
<Popover open={isScriptsOpen} onOpenChange={setIsScriptsOpen}>
<PopoverTrigger asChild>

View File

@@ -3,7 +3,7 @@ import type { SerializeAddon } from "@xterm/addon-serialize";
import type { Terminal as XTerm } from "@xterm/xterm";
import type { Dispatch, RefObject, SetStateAction } from "react";
import { logger } from "../../../lib/logger";
import type { Host, Identity, SSHKey, TerminalSession, TerminalSettings } from "../../../types";
import type { Host, Identity, SerialConfig, SSHKey, TerminalSession, TerminalSettings } from "../../../types";
import { resolveHostAuth } from "../../../domain/sshAuth";
type TerminalBackendApi = {
@@ -11,6 +11,7 @@ type TerminalBackendApi = {
telnetAvailable: () => boolean;
moshAvailable: () => boolean;
localAvailable: () => boolean;
serialAvailable: () => boolean;
execAvailable: () => boolean;
startSSHSession: (options: NetcattySSHOptions) => Promise<string>;
startTelnetSession: (
@@ -22,6 +23,9 @@ type TerminalBackendApi = {
startLocalSession: (
options: Parameters<NonNullable<NetcattyBridge["startLocalSession"]>>[0],
) => Promise<string>;
startSerialSession: (
options: Parameters<NonNullable<NetcattyBridge["startSerialSession"]>>[0],
) => Promise<string>;
execCommand: (options: Parameters<NetcattyBridge["execCommand"]>[0]) => Promise<{
stdout?: string;
stderr?: string;
@@ -61,6 +65,7 @@ export type TerminalSessionStartersContext = {
startupCommand?: string;
terminalSettings?: TerminalSettings;
terminalBackend: TerminalBackendApi;
serialConfig?: SerialConfig;
sessionRef: RefObject<string | null>;
hasConnectedRef: RefObject<boolean>;
@@ -114,12 +119,21 @@ const attachSessionToTerminal = (
opts?: {
onExitMessage?: (evt: { exitCode?: number; signal?: number }) => string;
onConnected?: () => void;
// For serial: convert lone LF to CRLF to avoid "staircase effect"
convertLfToCrlf?: boolean;
},
) => {
ctx.sessionRef.current = id;
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
term.write(ctx.highlightProcessorRef.current(chunk));
let data = chunk;
// Convert lone LF (\n) to CRLF (\r\n) for proper terminal display
// This prevents the "staircase effect" common in serial terminals
if (opts?.convertLfToCrlf) {
// Replace \n that is not preceded by \r with \r\n
data = data.replace(/(?<!\r)\n/g, "\r\n");
}
term.write(ctx.highlightProcessorRef.current(data));
if (!ctx.hasConnectedRef.current) {
ctx.updateStatus("connected");
opts?.onConnected?.();
@@ -521,10 +535,16 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
try {
// Get local shell configuration from terminal settings
const localShell = ctx.terminalSettings?.localShell;
const localStartDir = ctx.terminalSettings?.localStartDir;
const id = await ctx.terminalBackend.startLocalSession({
sessionId: ctx.sessionId,
cols: term.cols,
rows: term.rows,
shell: localShell,
cwd: localStartDir,
env: {
TERM: ctx.terminalSettings?.terminalEmulationType ?? "xterm-256color",
},
@@ -584,5 +604,50 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
};
return { startSSH, startTelnet, startMosh, startLocal };
// Start Serial session
const startSerial = async (term: XTerm) => {
if (!ctx.serialConfig) {
ctx.setError("No serial configuration provided");
term.writeln("\r\n[Error: No serial configuration provided]");
ctx.updateStatus("disconnected");
return;
}
try {
logger.info("[Serial] Starting serial session", {
port: ctx.serialConfig.path,
baudRate: ctx.serialConfig.baudRate,
});
const id = await ctx.terminalBackend.startSerialSession({
sessionId: ctx.sessionId,
path: ctx.serialConfig.path,
baudRate: ctx.serialConfig.baudRate,
dataBits: ctx.serialConfig.dataBits,
stopBits: ctx.serialConfig.stopBits,
parity: ctx.serialConfig.parity,
flowControl: ctx.serialConfig.flowControl,
});
// Serial connection is established immediately when session starts
// Update status right away since serial ports don't require handshake
ctx.updateStatus("connected");
ctx.setProgressValue(100);
term.writeln(`[Connected to ${ctx.serialConfig.path} at ${ctx.serialConfig.baudRate} baud]`);
attachSessionToTerminal(ctx, term, id, {
onExitMessage: (evt) =>
`\r\n[serial port closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
// Convert lone LF to CRLF to prevent "staircase effect" in serial terminals
convertLfToCrlf: true,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);
term.writeln(`\r\n[Failed to connect to serial port: ${message}]`);
ctx.updateStatus("disconnected");
}
};
return { startSSH, startTelnet, startMosh, startLocal, startSerial };
};

View File

@@ -71,6 +71,11 @@ export type CreateXTermRuntimeContext = {
) => void;
commandBufferRef: RefObject<string>;
setIsSearchOpen: Dispatch<SetStateAction<boolean>>;
// Serial-specific options
serialLocalEcho?: boolean;
serialLineMode?: boolean;
serialLineBufferRef?: RefObject<string>;
};
const detectPlatform = (): XTermPlatform => {
@@ -397,7 +402,64 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
term.onData((data) => {
const id = ctx.sessionRef.current;
if (id) {
ctx.terminalBackend.writeToSession(id, data);
// Serial line mode: buffer input and send on Enter
if (ctx.host.protocol === "serial" && ctx.serialLineMode && ctx.serialLineBufferRef) {
if (data === "\r") {
// Enter key: send buffered line + CR
const line = ctx.serialLineBufferRef.current + "\r";
ctx.terminalBackend.writeToSession(id, line);
ctx.serialLineBufferRef.current = "";
// Local echo newline if enabled
if (ctx.serialLocalEcho) {
term.write("\r\n");
}
} else if (data === "\x7f" || data === "\b") {
// Backspace: remove last character from buffer
if (ctx.serialLineBufferRef.current.length > 0) {
ctx.serialLineBufferRef.current = ctx.serialLineBufferRef.current.slice(0, -1);
if (ctx.serialLocalEcho) {
term.write("\b \b");
}
}
} else if (data === "\x03") {
// Ctrl+C: clear buffer and send Ctrl+C
ctx.serialLineBufferRef.current = "";
ctx.terminalBackend.writeToSession(id, data);
if (ctx.serialLocalEcho) {
term.write("^C\r\n");
}
} else if (data === "\x15") {
// Ctrl+U: clear line buffer
if (ctx.serialLocalEcho && ctx.serialLineBufferRef.current.length > 0) {
// Erase the displayed line
const len = ctx.serialLineBufferRef.current.length;
term.write("\b \b".repeat(len));
}
ctx.serialLineBufferRef.current = "";
} else if (data.charCodeAt(0) >= 32 || data.length > 1) {
// Regular characters: add to buffer
ctx.serialLineBufferRef.current += data;
if (ctx.serialLocalEcho) {
term.write(data);
}
}
} else {
// Character mode (default): send immediately
ctx.terminalBackend.writeToSession(id, data);
// Local echo for serial connections only when explicitly enabled
if (ctx.host.protocol === "serial" && ctx.serialLocalEcho) {
if (data === "\r") {
term.write("\r\n");
} else if (data === "\x7f" || data === "\b") {
term.write("\b \b");
} else if (data === "\x03") {
term.write("^C");
} else if (data.charCodeAt(0) >= 32 || data.length > 1) {
term.write(data);
}
}
}
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
ctx.onBroadcastInputRef.current(data, ctx.sessionId);

View File

@@ -23,7 +23,22 @@ export interface EnvVar {
}
// Protocol type for connections
export type HostProtocol = 'ssh' | 'telnet' | 'mosh' | 'local';
export type HostProtocol = 'ssh' | 'telnet' | 'mosh' | 'local' | 'serial';
// Serial port configuration
export type SerialParity = 'none' | 'even' | 'odd' | 'mark' | 'space';
export type SerialFlowControl = 'none' | 'xon/xoff' | 'rts/cts';
export interface SerialConfig {
path: string; // Serial port path (e.g., /dev/ttyUSB0, COM1)
baudRate: number; // Baud rate (e.g., 9600, 115200)
dataBits?: 5 | 6 | 7 | 8; // Data bits (default: 8)
stopBits?: 1 | 1.5 | 2; // Stop bits (default: 1)
parity?: SerialParity; // Parity (default: 'none')
flowControl?: SerialFlowControl; // Flow control (default: 'none')
localEcho?: boolean; // Force local echo (default: false, rely on remote echo)
lineMode?: boolean; // Line mode - buffer input and send on Enter (default: false)
}
// Per-protocol configuration
export interface ProtocolConfig {
@@ -359,6 +374,10 @@ export interface TerminalSettings {
// Keyword Highlighting
keywordHighlightEnabled: boolean;
keywordHighlightRules: KeywordHighlightRule[];
// Local Shell Configuration
localShell: string; // Path to shell executable (empty = system default)
localStartDir: string; // Starting directory for local terminal (empty = home directory)
}
export const DEFAULT_KEYWORD_HIGHLIGHT_RULES: KeywordHighlightRule[] = [
@@ -394,6 +413,8 @@ export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
linkModifier: 'none',
keywordHighlightEnabled: true,
keywordHighlightRules: DEFAULT_KEYWORD_HIGHLIGHT_RULES,
localShell: '', // Empty = use system default
localStartDir: '', // Empty = use home directory
};
export interface TerminalTheme {
@@ -434,9 +455,11 @@ export interface TerminalSession {
workspaceId?: string;
startupCommand?: string; // Command to run after connection (for snippet runner)
// Connection-time protocol overrides (used instead of looking up from hosts)
protocol?: 'ssh' | 'telnet' | 'local';
protocol?: 'ssh' | 'telnet' | 'local' | 'serial';
port?: number;
moshEnabled?: boolean;
// Serial-specific connection settings
serialConfig?: SerialConfig;
}
export interface RemoteFile {

View File

@@ -6,13 +6,18 @@
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const net = require("node:net");
const SftpClient = require("ssh2-sftp-client");
const { Client: SSHClient } = require("ssh2");
const { NetcattyAgent } = require("./netcattyAgent.cjs");
// SFTP clients storage - shared reference passed from main
let sftpClients = null;
let electronModule = null;
// Storage for jump host connections that need to be cleaned up
const jumpConnectionsMap = new Map(); // connId -> { connections: SSHClient[], socket: stream }
/**
* Initialize the SFTP bridge with dependencies
*/
@@ -21,18 +26,312 @@ function init(deps) {
electronModule = deps.electronModule;
}
/**
* Create a socket through a proxy (HTTP CONNECT or SOCKS5)
* Reused from sshBridge.cjs
*/
function createProxySocket(proxy, targetHost, targetPort) {
return new Promise((resolve, reject) => {
if (proxy.type === 'http') {
// HTTP CONNECT proxy
const socket = net.connect(proxy.port, proxy.host, () => {
let authHeader = '';
if (proxy.username && proxy.password) {
const auth = Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64');
authHeader = `Proxy-Authorization: Basic ${auth}\r\n`;
}
const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n${authHeader}\r\n`;
socket.write(connectRequest);
let response = '';
const onData = (data) => {
response += data.toString();
if (response.includes('\r\n\r\n')) {
socket.removeListener('data', onData);
if (response.startsWith('HTTP/1.1 200') || response.startsWith('HTTP/1.0 200')) {
resolve(socket);
} else {
socket.destroy();
reject(new Error(`HTTP proxy error: ${response.split('\r\n')[0]}`));
}
}
};
socket.on('data', onData);
});
socket.on('error', reject);
} else if (proxy.type === 'socks5') {
// SOCKS5 proxy
const socket = net.connect(proxy.port, proxy.host, () => {
// SOCKS5 greeting
const authMethods = proxy.username && proxy.password ? [0x00, 0x02] : [0x00];
socket.write(Buffer.from([0x05, authMethods.length, ...authMethods]));
let step = 'greeting';
const onData = (data) => {
if (step === 'greeting') {
if (data[0] !== 0x05) {
socket.destroy();
reject(new Error('Invalid SOCKS5 response'));
return;
}
const method = data[1];
if (method === 0x02 && proxy.username && proxy.password) {
// Username/password auth
step = 'auth';
const userBuf = Buffer.from(proxy.username);
const passBuf = Buffer.from(proxy.password);
socket.write(Buffer.concat([
Buffer.from([0x01, userBuf.length]),
userBuf,
Buffer.from([passBuf.length]),
passBuf
]));
} else if (method === 0x00) {
// No auth, proceed to connect
step = 'connect';
sendConnectRequest();
} else {
socket.destroy();
reject(new Error('SOCKS5 authentication method not supported'));
}
} else if (step === 'auth') {
if (data[1] !== 0x00) {
socket.destroy();
reject(new Error('SOCKS5 authentication failed'));
return;
}
step = 'connect';
sendConnectRequest();
} else if (step === 'connect') {
socket.removeListener('data', onData);
if (data[1] === 0x00) {
resolve(socket);
} else {
const errors = {
0x01: 'General failure',
0x02: 'Connection not allowed',
0x03: 'Network unreachable',
0x04: 'Host unreachable',
0x05: 'Connection refused',
0x06: 'TTL expired',
0x07: 'Command not supported',
0x08: 'Address type not supported',
};
socket.destroy();
reject(new Error(`SOCKS5 error: ${errors[data[1]] || 'Unknown'}`));
}
}
};
const sendConnectRequest = () => {
// SOCKS5 connect request
const hostBuf = Buffer.from(targetHost);
const request = Buffer.concat([
Buffer.from([0x05, 0x01, 0x00, 0x03, hostBuf.length]),
hostBuf,
Buffer.from([(targetPort >> 8) & 0xff, targetPort & 0xff])
]);
socket.write(request);
};
socket.on('data', onData);
});
socket.on('error', reject);
} else {
reject(new Error(`Unknown proxy type: ${proxy.type}`));
}
});
}
/**
* Connect through a chain of jump hosts for SFTP
*/
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort) {
const connections = [];
let currentSocket = null;
try {
// Connect through each jump host
for (let i = 0; i < jumpHosts.length; i++) {
const jump = jumpHosts[i];
const isFirst = i === 0;
const isLast = i === jumpHosts.length - 1;
const hopLabel = jump.label || `${jump.hostname}:${jump.port || 22}`;
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Connecting to ${hopLabel}...`);
const conn = new SSHClient();
// Build connection options
const connOpts = {
host: jump.hostname,
port: jump.port || 22,
username: jump.username || 'root',
readyTimeout: 20000,
keepaliveInterval: 10000,
keepaliveCountMax: 3,
algorithms: {
cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'],
kex: ['curve25519-sha256', 'curve25519-sha256@libssh.org', 'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'diffie-hellman-group14-sha256'],
compress: ['none'],
},
};
// Auth - support agent (certificate), key, and password fallback
const hasCertificate =
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
let authAgent = null;
if (hasCertificate) {
authAgent = new NetcattyAgent({
mode: "certificate",
webContents: event.sender,
meta: {
label: jump.keyId || jump.username || "",
certificate: jump.certificate,
privateKey: jump.privateKey,
passphrase: jump.passphrase,
},
});
connOpts.agent = authAgent;
} else if (jump.privateKey) {
connOpts.privateKey = jump.privateKey;
if (jump.passphrase) connOpts.passphrase = jump.passphrase;
}
if (jump.password) connOpts.password = jump.password;
if (authAgent) {
const order = ["agent"];
if (connOpts.password) order.push("password");
connOpts.authHandler = order;
}
// If first hop and proxy is configured, connect through proxy
if (isFirst && options.proxy) {
currentSocket = await createProxySocket(options.proxy, jump.hostname, jump.port || 22);
connOpts.sock = currentSocket;
delete connOpts.host;
delete connOpts.port;
} else if (!isFirst && currentSocket) {
// Tunnel through previous hop
connOpts.sock = currentSocket;
delete connOpts.host;
delete connOpts.port;
}
// Connect this hop
await new Promise((resolve, reject) => {
conn.on('ready', () => {
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} connected`);
resolve();
});
conn.on('error', (err) => {
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} error:`, err.message);
reject(err);
});
conn.on('timeout', () => {
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} timeout`);
reject(new Error(`Connection timeout to ${hopLabel}`));
});
conn.connect(connOpts);
});
connections.push(conn);
// Determine next target
let nextHost, nextPort;
if (isLast) {
// Last jump host, forward to final target
nextHost = targetHost;
nextPort = targetPort;
} else {
// Forward to next jump host
const nextJump = jumpHosts[i + 1];
nextHost = nextJump.hostname;
nextPort = nextJump.port || 22;
}
// Create forward stream to next hop
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Forwarding to ${nextHost}:${nextPort}...`);
currentSocket = await new Promise((resolve, reject) => {
conn.forwardOut('127.0.0.1', 0, nextHost, nextPort, (err, stream) => {
if (err) {
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: forwardOut failed:`, err.message);
reject(err);
return;
}
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: forwardOut success`);
resolve(stream);
});
});
}
// Return the final forwarded stream and all connections for cleanup
return {
socket: currentSocket,
connections
};
} catch (err) {
// Cleanup on error
for (const conn of connections) {
try { conn.end(); } catch (cleanupErr) { console.warn('[SFTP Chain] Cleanup error:', cleanupErr.message); }
}
throw err;
}
}
/**
* Open a new SFTP connection
* Supports jump host connections when options.jumpHosts is provided
*/
async function openSftp(event, options) {
const client = new SftpClient();
const connId = options.sessionId || `${Date.now()}-sftp-${Math.random().toString(16).slice(2)}`;
// Check if we need to connect through jump hosts
const jumpHosts = options.jumpHosts || [];
const hasJumpHosts = jumpHosts.length > 0;
const hasProxy = !!options.proxy;
let chainConnections = [];
let connectionSocket = null;
// Handle chain/proxy connections
if (hasJumpHosts) {
console.log(`[SFTP] Opening connection through ${jumpHosts.length} jump host(s) to ${options.hostname}:${options.port || 22}`);
const chainResult = await connectThroughChainForSftp(
event,
options,
jumpHosts,
options.hostname,
options.port || 22
);
connectionSocket = chainResult.socket;
chainConnections = chainResult.connections;
} else if (hasProxy) {
console.log(`[SFTP] Opening connection through proxy to ${options.hostname}:${options.port || 22}`);
connectionSocket = await createProxySocket(
options.proxy,
options.hostname,
options.port || 22
);
}
const connectOpts = {
host: options.hostname,
port: options.port || 22,
username: options.username || "root",
};
// Use the tunneled socket if we have one
if (connectionSocket) {
connectOpts.sock = connectionSocket;
// When using sock, we should not set host/port as the connection is already established
delete connectOpts.host;
delete connectOpts.port;
}
const hasCertificate = typeof options.certificate === "string" && options.certificate.trim().length > 0;
let authAgent = null;
@@ -61,9 +360,27 @@ async function openSftp(event, options) {
connectOpts.authHandler = order;
}
await client.connect(connectOpts);
sftpClients.set(connId, client);
return { sftpId: connId };
try {
await client.connect(connectOpts);
sftpClients.set(connId, client);
// Store jump connections for cleanup when SFTP is closed
if (chainConnections.length > 0) {
jumpConnectionsMap.set(connId, {
connections: chainConnections,
socket: connectionSocket
});
}
console.log(`[SFTP] Connection established: ${connId}`);
return { sftpId: connId };
} catch (err) {
// Cleanup jump connections on error
for (const conn of chainConnections) {
try { conn.end(); } catch (cleanupErr) { console.warn('[SFTP] Cleanup error on connect failure:', cleanupErr.message); }
}
throw err;
}
}
/**
@@ -167,6 +484,7 @@ async function writeSftpBinaryWithProgress(event, payload) {
/**
* Close an SFTP connection
* Also cleans up any jump host connections if present
*/
async function closeSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
@@ -178,6 +496,16 @@ async function closeSftp(event, payload) {
console.warn("SFTP close failed", err);
}
sftpClients.delete(payload.sftpId);
// Clean up jump connections if any
const jumpData = jumpConnectionsMap.get(payload.sftpId);
if (jumpData) {
for (const conn of jumpData.connections) {
try { conn.end(); } catch (cleanupErr) { console.warn('[SFTP] Cleanup error on close:', cleanupErr.message); }
}
jumpConnectionsMap.delete(payload.sftpId);
console.log(`[SFTP] Cleaned up ${jumpData.connections.length} jump connection(s) for ${payload.sftpId}`);
}
}
/**

View File

@@ -1,5 +1,5 @@
/**
* Terminal Bridge - Handles local shell and telnet/mosh sessions
* Terminal Bridge - Handles local shell, telnet/mosh, and serial port sessions
* Extracted from main.cjs for single responsibility
*/
@@ -8,6 +8,7 @@ const fs = require("node:fs");
const net = require("node:net");
const path = require("node:path");
const pty = require("node-pty");
const { SerialPort } = require("serialport");
// Shared references
let sessions = null;
@@ -107,10 +108,30 @@ function startLocalSession(event, payload) {
COLORTERM: "truecolor",
});
// Determine the starting directory
// Default to home directory if not specified or if specified path is invalid
const defaultCwd = os.homedir();
let cwd = defaultCwd;
if (payload?.cwd) {
try {
// Resolve to absolute path and check if it exists and is a directory
const resolvedPath = path.resolve(payload.cwd);
if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory()) {
cwd = resolvedPath;
} else {
console.warn(`[Terminal] Specified cwd "${payload.cwd}" is not a valid directory, using home directory`);
}
} catch (err) {
console.warn(`[Terminal] Error validating cwd "${payload.cwd}":`, err.message);
}
}
const proc = pty.spawn(shell, shellArgs, {
cols: payload?.cols || 80,
rows: payload?.rows || 24,
env,
cwd,
});
const session = {
@@ -423,6 +444,103 @@ async function startMoshSession(event, options) {
}
}
/**
* List available serial ports (hardware only)
*/
async function listSerialPorts() {
try {
const ports = await SerialPort.list();
return ports.map(port => ({
path: port.path,
manufacturer: port.manufacturer || '',
serialNumber: port.serialNumber || '',
vendorId: port.vendorId || '',
productId: port.productId || '',
pnpId: port.pnpId || '',
type: 'hardware',
}));
} catch (err) {
console.error("[Serial] Failed to list ports:", err.message);
return [];
}
}
/**
* Start a serial port session (supports both hardware serial ports and PTY devices)
* Note: SerialPort library can open PTY devices directly, they just won't appear in list()
*/
async function startSerialSession(event, options) {
const sessionId =
options.sessionId ||
`serial-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const portPath = options.path;
const baudRate = options.baudRate || 115200;
const dataBits = options.dataBits || 8;
const stopBits = options.stopBits || 1;
const parity = options.parity || 'none';
const flowControl = options.flowControl || 'none';
console.log(`[Serial] Starting connection to ${portPath} at ${baudRate} baud`);
return new Promise((resolve, reject) => {
try {
const serialPort = new SerialPort({
path: portPath,
baudRate: baudRate,
dataBits: dataBits,
stopBits: stopBits,
parity: parity,
rtscts: flowControl === 'rts/cts',
xon: flowControl === 'xon/xoff',
xoff: flowControl === 'xon/xoff',
autoOpen: false,
});
serialPort.open((err) => {
if (err) {
console.error(`[Serial] Failed to open port ${portPath}:`, err.message);
reject(new Error(`Failed to open serial port: ${err.message}`));
return;
}
console.log(`[Serial] Connected to ${portPath}`);
const session = {
serialPort,
type: 'serial',
webContentsId: event.sender.id,
};
sessions.set(sessionId, session);
serialPort.on('data', (data) => {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data: data.toString('binary') });
});
serialPort.on('error', (err) => {
console.error(`[Serial] Port error: ${err.message}`);
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message });
sessions.delete(sessionId);
});
serialPort.on('close', () => {
console.log(`[Serial] Port closed`);
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: 0 });
sessions.delete(sessionId);
});
resolve({ sessionId });
});
} catch (err) {
console.error("[Serial] Failed to start serial session:", err.message);
reject(err);
}
});
}
/**
* Write data to a session
*/
@@ -437,6 +555,8 @@ function writeToSession(event, payload) {
session.proc.write(payload.data);
} else if (session.socket) {
session.socket.write(payload.data);
} else if (session.serialPort) {
session.serialPort.write(payload.data);
}
} catch (err) {
if (err.code !== 'EPIPE' && err.code !== 'ERR_STREAM_DESTROYED') {
@@ -491,6 +611,8 @@ function closeSession(event, payload) {
session.proc.kill();
} else if (session.socket) {
session.socket.destroy();
} else if (session.serialPort) {
session.serialPort.close();
}
if (session.chainConnections) {
for (const c of session.chainConnections) {
@@ -510,11 +632,90 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:local:start", startLocalSession);
ipcMain.handle("netcatty:telnet:start", startTelnetSession);
ipcMain.handle("netcatty:mosh:start", startMoshSession);
ipcMain.handle("netcatty:serial:start", startSerialSession);
ipcMain.handle("netcatty:serial:list", listSerialPorts);
ipcMain.handle("netcatty:local:defaultShell", getDefaultShell);
ipcMain.handle("netcatty:local:validatePath", validatePath);
ipcMain.on("netcatty:write", writeToSession);
ipcMain.on("netcatty:resize", resizeSession);
ipcMain.on("netcatty:close", closeSession);
}
/**
* Get the default shell for the current platform
*/
function getDefaultShell() {
if (process.platform === "win32") {
return findExecutable("powershell") || "powershell.exe";
}
return process.env.SHELL || "/bin/bash";
}
/**
* 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 }}
*/
function validatePath(event, payload) {
const targetPath = payload?.path;
const type = payload?.type || 'any';
if (!targetPath) {
return { exists: false, isFile: false, isDirectory: false };
}
try {
// Resolve path (handle ~, etc.)
let resolvedPath = targetPath;
if (resolvedPath === "~") {
resolvedPath = os.homedir();
} else if (resolvedPath.startsWith("~/")) {
resolvedPath = path.join(os.homedir(), resolvedPath.slice(2));
}
resolvedPath = path.resolve(resolvedPath);
if (fs.existsSync(resolvedPath)) {
const stat = fs.statSync(resolvedPath);
return {
exists: true,
isFile: stat.isFile(),
isDirectory: stat.isDirectory(),
};
}
// 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);
// findExecutable returns the original name if not found, so check if it actually resolves to a real path
if (resolvedExecutable !== targetPath && fs.existsSync(resolvedExecutable)) {
const stat = fs.statSync(resolvedExecutable);
return {
exists: true,
isFile: stat.isFile(),
isDirectory: stat.isDirectory(),
};
}
// Also try with .exe extension on Windows if not already present
if (process.platform === 'win32' && !targetPath.toLowerCase().endsWith('.exe')) {
const withExe = findExecutable(targetPath + '.exe');
if (withExe !== targetPath + '.exe' && fs.existsSync(withExe)) {
const stat = fs.statSync(withExe);
return {
exists: true,
isFile: stat.isFile(),
isDirectory: stat.isDirectory(),
};
}
}
}
return { exists: false, isFile: false, isDirectory: false };
} catch (err) {
console.warn(`[Terminal] Error validating path "${targetPath}":`, err.message);
return { exists: false, isFile: false, isDirectory: false };
}
}
/**
* Cleanup all sessions - call before app quit
*/
@@ -534,6 +735,12 @@ function cleanupAllSessions() {
}
} else if (session.socket) {
session.socket.destroy();
} else if (session.serialPort) {
try {
session.serialPort.close();
} catch (e) {
// Ignore errors during cleanup
}
}
if (session.chainConnections) {
for (const c of session.chainConnections) {
@@ -554,8 +761,12 @@ module.exports = {
startLocalSession,
startTelnetSession,
startMoshSession,
startSerialSession,
listSerialPorts,
writeToSession,
resizeSession,
closeSession,
cleanupAllSessions,
getDefaultShell,
validatePath,
};

View File

@@ -27,11 +27,15 @@ let currentTheme = "light";
let currentLanguage = "en";
let handlersRegistered = false; // Prevent duplicate IPC handler registration
let menuDeps = null;
let electronApp = null; // Reference to Electron app for userData path
const rendererReadyCallbacksByWebContentsId = new Map();
const DEBUG_WINDOWS = process.env.NETCATTY_DEBUG_WINDOWS === "1";
const OAUTH_DEFAULT_WIDTH = 600;
const OAUTH_DEFAULT_HEIGHT = 700;
const OAUTH_OVERLAY_ID = "__netcatty_oauth_loading__";
const WINDOW_STATE_FILE = "window-state.json";
const DEFAULT_WINDOW_WIDTH = 1400;
const DEFAULT_WINDOW_HEIGHT = 900;
function debugLog(...args) {
if (!DEBUG_WINDOWS) return;
@@ -43,6 +47,78 @@ function debugLog(...args) {
}
}
/**
* Get the path to the window state file
*/
function getWindowStatePath() {
try {
if (!electronApp) return null;
return path.join(electronApp.getPath("userData"), WINDOW_STATE_FILE);
} catch {
return null;
}
}
/**
* Load saved window state from disk
*/
function loadWindowState() {
try {
const statePath = getWindowStatePath();
if (!statePath || !fs.existsSync(statePath)) {
return null;
}
const data = fs.readFileSync(statePath, "utf8");
const state = JSON.parse(data);
// Validate the loaded state has required properties
if (
typeof state.width === "number" &&
typeof state.height === "number" &&
state.width > 0 &&
state.height > 0
) {
return state;
}
return null;
} catch (err) {
debugLog("Failed to load window state:", err?.message || err);
return null;
}
}
/**
* Save window state to disk
*/
function saveWindowState(state) {
try {
const statePath = getWindowStatePath();
if (!statePath) return false;
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), { mode: 0o600 });
return true;
} catch (err) {
debugLog("Failed to save window state:", err?.message || err);
return false;
}
}
/**
* Get the current window bounds state for saving
* @param {BrowserWindow} win - The window to get bounds from
* @param {Object} overrideBounds - Optional bounds to use instead of current window bounds (for normal bounds tracking)
*/
function getWindowBoundsState(win, overrideBounds) {
if (!win || win.isDestroyed()) return null;
const bounds = overrideBounds || win.getBounds();
return {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
isMaximized: win.isMaximized(),
isFullScreen: win.isFullScreen(),
};
}
const MENU_LABELS = {
en: { edit: "Edit", view: "View", window: "Window" },
"zh-CN": { edit: "编辑", view: "视图", window: "窗口" },
@@ -420,18 +496,58 @@ function setupDeferredShow(win, { timeoutMs = 3000, waitForRendererReady = true
* Create the main application window
*/
async function createWindow(electronModule, options) {
const { BrowserWindow, nativeTheme } = electronModule;
const { BrowserWindow, nativeTheme, app, screen } = electronModule;
const { preload, devServerUrl, isDev, appIcon, isMac, onRegisterBridge, electronDir } = options;
// Store app reference for window state persistence
electronApp = app;
const osTheme = nativeTheme?.shouldUseDarkColors ? "dark" : "light";
const effectiveTheme = currentTheme === "dark" || currentTheme === "light" ? currentTheme : osTheme;
const frontendBackground = resolveFrontendBackgroundColor(electronDir || __dirname, effectiveTheme);
const backgroundColor = frontendBackground || "#1a1a1a";
const themeConfig = THEME_COLORS[effectiveTheme] || THEME_COLORS.light;
// Load saved window state
const savedState = loadWindowState();
let windowBounds = {
width: DEFAULT_WINDOW_WIDTH,
height: DEFAULT_WINDOW_HEIGHT,
};
if (savedState) {
// Use saved dimensions
windowBounds.width = savedState.width;
windowBounds.height = savedState.height;
// Only use saved position if the screen is available at that location
if (typeof savedState.x === "number" && typeof savedState.y === "number") {
try {
// Check if the saved position is within any available display
const displays = screen?.getAllDisplays?.() || [];
const isPositionVisible = displays.some((display) => {
const { x, y, width, height } = display.bounds;
// Check if at least part of the window would be visible on this display
return (
savedState.x < x + width &&
savedState.x + savedState.width > x &&
savedState.y < y + height &&
savedState.y + savedState.height > y
);
});
if (isPositionVisible) {
windowBounds.x = savedState.x;
windowBounds.y = savedState.y;
}
} catch {
// Ignore screen check errors, just don't set position
}
}
}
const win = new BrowserWindow({
width: 1400,
height: 900,
...windowBounds,
backgroundColor,
icon: appIcon,
show: false,
@@ -448,12 +564,68 @@ async function createWindow(electronModule, options) {
mainWindow = win;
// Restore maximized state if it was saved
if (savedState?.isMaximized && !savedState?.isFullScreen) {
win.once("ready-to-show", () => {
try {
win.maximize();
} catch {
// ignore
}
});
}
// Track window bounds for saving (use last non-maximized/non-fullscreen bounds)
let lastNormalBounds = null;
let saveStateTimer = null;
const updateNormalBounds = () => {
if (!win.isDestroyed() && !win.isMaximized() && !win.isFullScreen()) {
lastNormalBounds = win.getBounds();
}
};
const scheduleSaveState = () => {
if (saveStateTimer) clearTimeout(saveStateTimer);
saveStateTimer = setTimeout(() => {
const state = getWindowBoundsState(win, lastNormalBounds);
if (state) saveWindowState(state);
}, 500);
};
// Update normal bounds on resize/move when not maximized/fullscreen
win.on("resize", () => {
updateNormalBounds();
scheduleSaveState();
});
win.on("move", () => {
updateNormalBounds();
scheduleSaveState();
});
win.on("maximize", scheduleSaveState);
win.on("unmaximize", () => {
updateNormalBounds();
scheduleSaveState();
});
// Save state when window is about to close
win.on("close", () => {
if (saveStateTimer) clearTimeout(saveStateTimer);
const state = getWindowBoundsState(win, lastNormalBounds);
if (state) saveWindowState(state);
});
win.on("enter-full-screen", () => {
win.webContents?.send("netcatty:window:fullscreen-changed", true);
scheduleSaveState();
});
win.on("leave-full-screen", () => {
win.webContents?.send("netcatty:window:fullscreen-changed", false);
updateNormalBounds();
scheduleSaveState();
});
// Ensure native background matches frontend background, even before first paint.

View File

@@ -215,6 +215,19 @@ const api = {
const result = await ipcRenderer.invoke("netcatty:local:start", options || {});
return result.sessionId;
},
startSerialSession: async (options) => {
const result = await ipcRenderer.invoke("netcatty:serial:start", options);
return result.sessionId;
},
listSerialPorts: async () => {
return ipcRenderer.invoke("netcatty:serial:list");
},
getDefaultShell: async () => {
return ipcRenderer.invoke("netcatty:local:defaultShell");
},
validatePath: async (path, type) => {
return ipcRenderer.invoke("netcatty:local:validatePath", { path, type });
},
writeToSession: (sessionId, data) => {
ipcRenderer.send("netcatty:write", { sessionId, data });
},

21
global.d.ts vendored
View File

@@ -134,7 +134,26 @@ interface NetcattyBridge {
charset?: string;
env?: Record<string, string>;
}): Promise<string>;
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; env?: Record<string, string> }): Promise<string>;
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; cwd?: string; env?: Record<string, string> }): Promise<string>;
startSerialSession?(options: {
sessionId?: string;
path: string;
baudRate?: number;
dataBits?: 5 | 6 | 7 | 8;
stopBits?: 1 | 1.5 | 2;
parity?: 'none' | 'even' | 'odd' | 'mark' | 'space';
flowControl?: 'none' | 'xon/xoff' | 'rts/cts';
}): Promise<string>;
listSerialPorts?(): Promise<Array<{
path: string;
manufacturer: string;
serialNumber: string;
vendorId: string;
productId: string;
pnpId: string;
}>>;
getDefaultShell?(): Promise<string>;
validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean }>;
generateKeyPair?(options: {
type: 'RSA' | 'ECDSA' | 'ED25519';
bits?: number;

3976
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -47,6 +47,7 @@
"node-pty": "1.1.0-beta19",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"serialport": "^13.0.0",
"ssh2-sftp-client": "^12.0.1",
"tailwind-merge": "3.4.0",
"uuid": "^13.0.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 26 KiB