Compare commits

...

11 Commits

Author SHA1 Message Date
bincxz
2fecbb94fb Migrate electron-builder config to JS, drop JSON
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Removes the legacy `electron-builder.json` file and updates all packaging scripts to reference the new `electron-builder.config.cjs` module. This centralizes the build configuration in a JavaScript file, enabling richer logic and easier maintenance while eliminating the now‑unused JSON config.
2026-01-14 01:20:31 +08:00
bincxz
6bd3968e04 Reformats electron‑builder config and fixes script path
Improves readability by expanding target architecture arrays and UI element definitions onto separate lines.
Updates the FixQuarantine script reference to use the ${projectDir} variable, ensuring the correct absolute path during builds.
These changes make the configuration easier to maintain and avoid path resolution issues.
2026-01-14 00:49:23 +08:00
bincxz
528cda1f70 Updates dependencies to latest versions
Bumps a wide range of packages to their newest releases, including AWS SDK v3.967 and related @smithy modules, Electron builder libraries, Rollup 4.55.1, Babel 7.28.x, and @npmcli tooling. Removes deprecated packages (e.g., @tootallnate/once, is-ci) and adds missing utilities such as @isaacs/fs-minipass and ci-info. These updates improve compatibility with newer Node versions, address security fixes, and enhance build stability.
2026-01-13 16:15:51 +08:00
bincxz
29bde31989 Adjust peer flags in package-lock.json
Adds missing `"peer": true` entries for several development packages and removes incorrectly set peer flags from optional dependencies. This aligns the lockfile metadata with the actual peer‑dependency relationships, improving package resolution and consistency.
2026-01-13 16:12:44 +08:00
陈大猫
b12a2171e7 Merge pull request #75 from binaricat/copilot/add-custom-baud-rate-support
Add custom baud rate support, serial config persistence, and connection logging
2026-01-13 14:43:20 +08:00
bincxz
ffcd94e216 Add host tag/group i18n and clean imports
Introduce new localization entries for host tags and groups (add, select, create) in English and Chinese resource files, enabling UI support for tagging and grouping hosts.

Remove unused `X` icon import and the `cn` utility from the SerialHostDetailsPanel component to eliminate dead code and streamline imports.
2026-01-13 14:42:45 +08:00
copilot-swe-agent[bot]
9b77fc9e3b Add dedicated SerialHostDetailsPanel for editing serial hosts
- Create SerialHostDetailsPanel with serial-specific fields (port, baud rate, data bits, stop bits, parity, flow control, etc.)
- Update VaultView to show SerialHostDetailsPanel when editing serial hosts instead of the SSH-focused HostDetailsPanel
- Add i18n translations for serial edit panel (en, zh-CN)
- Fix ESLint exhaustive-deps warning

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-13 06:36:38 +00:00
copilot-swe-agent[bot]
9e57f2eb90 Address code review: fix substr deprecation and improve comments
- Replace deprecated substr() with substring()
- Add clearer documentation about port field usage for serial hosts

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-13 06:10:53 +00:00
copilot-swe-agent[bot]
8cf6e9243d Support connecting saved serial hosts and add visual indicators
- Add serialConfig field to Host interface for storing full serial configuration
- Update connectToHost to handle serial hosts and create proper serial sessions
- Update handleConnectToHost to log serial connections properly
- Update DistroAvatar to show USB icon for serial hosts

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-13 06:09:31 +00:00
copilot-swe-agent[bot]
7a32aa0743 Add custom baud rate support, serial config saving, and connection logging
- Changed baud rate input from fixed select to combobox with preset options + custom input
- Added "Save Configuration" checkbox to save serial port settings as a host entry
- Added connection logging for serial connections (similar to SSH/local terminals)
- Updated ConnectionLog type to include 'serial' protocol
- Updated ConnectionLogsManager to display serial connections with USB icon
- Added i18n translations for new UI elements (en, zh-CN)

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-13 06:06:43 +00:00
copilot-swe-agent[bot]
a1d0ce02fe Initial plan 2026-01-13 05:57:40 +00:00
14 changed files with 1824 additions and 1295 deletions

41
App.tsx
View File

@@ -20,7 +20,7 @@ import { Label } from './components/ui/label';
import { ToastProvider, toast } from './components/ui/toast';
import { VaultView, VaultSection } from './components/VaultView';
import { cn } from './lib/utils';
import { ConnectionLog, Host, HostProtocol, TerminalTheme } from './types';
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
import { LogView as LogViewType } from './application/state/useSessionState';
import type { SftpView as SftpViewComponent } from './components/SftpView';
import type { TerminalLayer as TerminalLayerComponent } from './components/TerminalLayer';
@@ -619,6 +619,25 @@ function App({ settings }: { settings: SettingsState }) {
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host) => {
const { username, hostname: localHost } = systemInfoRef.current;
// Handle serial hosts separately
if (host.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
addConnectionLog({
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: host.hostname,
username: username,
protocol: 'serial',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
connectToHost(host);
return;
}
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host, keys, identities });
addConnectionLog({
@@ -635,6 +654,24 @@ function App({ settings }: { settings: SettingsState }) {
connectToHost(host);
}, [addConnectionLog, connectToHost, identities, keys]);
// Wrapper to create serial session with logging
const handleConnectSerial = useCallback((config: SerialConfig) => {
const { username, hostname } = systemInfoRef.current;
const portName = config.path.split('/').pop() || config.path;
addConnectionLog({
hostId: '',
hostLabel: `Serial: ${portName}`,
hostname: config.path,
username: username,
protocol: 'serial',
startTime: Date.now(),
localUsername: username,
localHostname: hostname,
saved: false,
});
createSerialSession(config);
}, [addConnectionLog, createSerialSession]);
// Handle terminal data capture when session exits
const handleTerminalDataCapture = useCallback((sessionId: string, data: string) => {
if (IS_DEV) console.log('[handleTerminalDataCapture] Called', { sessionId, dataLength: data.length });
@@ -776,7 +813,7 @@ function App({ settings }: { settings: SettingsState }) {
onOpenSettings={handleOpenSettings}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onCreateLocalTerminal={handleCreateLocalTerminal}
onConnectSerial={createSerialSession}
onConnectSerial={handleConnectSerial}
onDeleteHost={handleDeleteHost}
onConnect={handleConnectToHost}
onUpdateHosts={updateHosts}

View File

@@ -525,7 +525,7 @@ const en: Messages = {
'settings.sftpFileAssociations.noAssociations': 'No file associations configured',
'settings.sftpFileAssociations.remove': 'Remove',
'settings.sftpFileAssociations.removeConfirm': 'Remove association for .{ext}?',
// Settings > SFTP Behavior
'settings.sftp.doubleClickBehavior': 'Double-click behavior',
'settings.sftp.doubleClickBehavior.desc': 'Choose the action when double-clicking a file in SFTP View',
@@ -533,7 +533,7 @@ const en: Messages = {
'settings.sftp.doubleClickBehavior.transfer': 'Transfer to other pane',
'settings.sftp.doubleClickBehavior.openDesc': 'Open the file in the default application',
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': 'Auto-sync to remote',
'settings.sftp.autoSync.desc': 'Automatically sync file changes back to the remote server when opening files with external applications',
@@ -654,6 +654,12 @@ const en: Messages = {
'hostDetails.telnet.password': 'Telnet Password',
'hostDetails.charset.placeholder': 'Charset (e.g. UTF-8)',
'hostDetails.telnet.add': 'Add Telnet Protocol',
'hostDetails.tags': 'Tags',
'hostDetails.group': 'Group',
'hostDetails.selectGroup': 'Select Group',
'hostDetails.addTag': 'Add a tag...',
'hostDetails.createTag': 'Create tag',
'hostDetails.createGroup': 'Create group',
// Host form (legacy modal)
'hostForm.title.edit': 'Edit Host',
@@ -1089,6 +1095,15 @@ const en: Messages = {
'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',
'serial.field.baudRatePlaceholder': 'Select or enter baud rate...',
'serial.field.baudRateEmpty': 'Enter a custom baud rate',
'serial.field.customBaudRate': 'Using custom baud rate',
'serial.field.saveConfig': 'Save Configuration',
'serial.field.saveConfigDesc': 'Save this serial configuration to hosts for quick access',
'serial.field.configLabel': 'Configuration Name',
'serial.field.configLabelPlaceholder': 'e.g. Arduino Uno',
'serial.connectAndSave': 'Connect & Save',
'serial.edit.title': 'Serial Port Settings',
};
export default en;

View File

@@ -401,6 +401,12 @@ const zhCN: Messages = {
'hostDetails.telnet.password': 'Telnet 密码',
'hostDetails.charset.placeholder': '字符集(例如 UTF-8',
'hostDetails.telnet.add': '添加 Telnet 协议',
'hostDetails.tags': '标签',
'hostDetails.group': '分组',
'hostDetails.selectGroup': '选择分组',
'hostDetails.addTag': '添加标签...',
'hostDetails.createTag': '创建标签',
'hostDetails.createGroup': '创建分组',
// Host form (legacy modal)
'hostForm.title.edit': '编辑主机',
@@ -757,7 +763,7 @@ const zhCN: Messages = {
'settings.sftpFileAssociations.noAssociations': '未配置文件关联',
'settings.sftpFileAssociations.remove': '移除',
'settings.sftpFileAssociations.removeConfirm': '确定移除 .{ext} 的关联吗?',
// Settings > SFTP Behavior
'settings.sftp.doubleClickBehavior': '双击行为',
'settings.sftp.doubleClickBehavior.desc': '选择在 SFTP 视图中双击文件时的操作',
@@ -765,7 +771,7 @@ const zhCN: Messages = {
'settings.sftp.doubleClickBehavior.transfer': '传输到另一侧',
'settings.sftp.doubleClickBehavior.openDesc': '使用默认应用程序打开文件',
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': '自动同步到远程',
'settings.sftp.autoSync.desc': '使用外部应用程序打开文件时,自动将文件更改同步回远程服务器',
@@ -1078,6 +1084,15 @@ const zhCN: Messages = {
'serial.field.lineMode': '行模式',
'serial.field.lineModeDesc': '缓冲输入,按回车后发送(而不是逐字符发送)',
'serial.connectionError': '连接串口失败',
'serial.field.baudRatePlaceholder': '选择或输入波特率...',
'serial.field.baudRateEmpty': '输入自定义波特率',
'serial.field.customBaudRate': '使用自定义波特率',
'serial.field.saveConfig': '保存配置',
'serial.field.saveConfigDesc': '将此串口配置保存到主机列表以便快速访问',
'serial.field.configLabel': '配置名称',
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
'serial.connectAndSave': '连接并保存',
'serial.edit.title': '串口设置',
};
export default zhCN;

View File

@@ -72,6 +72,37 @@ export const useSessionState = () => {
}, [setActiveTabId]);
const connectToHost = useCallback((host: Host) => {
// Handle serial hosts specially - use createSerialSession for them
if (host.protocol === 'serial') {
// Use stored serialConfig or construct from host data
const serialConfig: SerialConfig = host.serialConfig || {
path: host.hostname,
baudRate: host.port || 115200,
dataBits: 8,
stopBits: 1,
parity: 'none',
flowControl: 'none',
localEcho: false,
lineMode: false,
};
const sessionId = crypto.randomUUID();
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
const newSession: TerminalSession = {
id: sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: serialConfig.path,
username: '',
status: 'connecting',
protocol: 'serial',
serialConfig: serialConfig,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
return;
}
const newSession: TerminalSession = {
id: crypto.randomUUID(),
hostId: host.id,

View File

@@ -4,6 +4,7 @@ import {
Server,
Terminal,
Trash2,
Usb,
User,
} from "lucide-react";
import React, { memo, useCallback, useMemo } from "react";
@@ -63,6 +64,7 @@ interface LogItemProps {
const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) => {
const { t, resolvedLocale } = useI18n();
const isLocal = log.protocol === "local" || log.hostname === "localhost";
const isSerial = log.protocol === "serial";
return (
<div
@@ -92,14 +94,14 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center shrink-0",
isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
isSerial ? "bg-amber-500/10 text-amber-500" : isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
)}>
{isLocal ? <Terminal size={14} /> : <Server size={14} />}
{isSerial ? <Usb size={14} /> : isLocal ? <Terminal size={14} /> : <Server size={14} />}
</div>
<div className="min-w-0">
<div className="text-sm font-medium truncate">{isLocal ? t("logs.localTerminal") : log.hostLabel}</div>
<div className="text-xs text-muted-foreground truncate">
{isLocal ? "local" : `${log.protocol}, ${log.username}`}
{isLocal ? "local" : isSerial ? `serial, ${log.hostname}` : `${log.protocol}, ${log.username}`}
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { Server } from "lucide-react";
import { Server, Usb } from "lucide-react";
import React, { memo } from "react";
import { normalizeDistroId } from "../domain/host";
import { cn } from "../lib/utils";
@@ -69,6 +69,21 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
const containerClass = sizeClasses[size];
const iconSize = iconSizes[size];
// Show USB icon for serial hosts
if (host.protocol === 'serial') {
return (
<div
className={cn(
containerClass,
"flex items-center justify-center bg-amber-500/15 text-amber-500",
className,
)}
>
<Usb className={iconSize} />
</div>
);
}
if (logo && !errored) {
return (
<div

View File

@@ -2,11 +2,11 @@
* Serial Port Connect Modal
* Allows users to configure and connect to a serial port
*/
import { ChevronDown, ChevronUp, Cpu, RefreshCw, Usb } from 'lucide-react';
import { ChevronDown, ChevronUp, Cpu, RefreshCw, Save, 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 type { Host, SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
import { Combobox, type ComboboxOption } from './ui/combobox';
@@ -18,6 +18,7 @@ import {
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
@@ -35,6 +36,7 @@ interface SerialConnectModalProps {
open: boolean;
onClose: () => void;
onConnect: (config: SerialConfig) => void;
onSaveHost?: (host: Host) => void;
}
const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
@@ -47,6 +49,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
open,
onClose,
onConnect,
onSaveHost,
}) => {
const { t } = useI18n();
const [ports, setPorts] = useState<SerialPort[]>([]);
@@ -63,6 +66,10 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
const [localEcho, setLocalEcho] = useState(false);
const [lineMode, setLineMode] = useState(false);
// Save configuration state
const [saveConfig, setSaveConfig] = useState(false);
const [configLabel, setConfigLabel] = useState('');
const terminalBackend = useTerminalBackend();
const loadPorts = useCallback(async () => {
@@ -87,6 +94,14 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
}
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
// Generate a default label when port is selected
useEffect(() => {
if (selectedPort && !configLabel) {
const portName = selectedPort.split('/').pop() || selectedPort;
setConfigLabel(`Serial: ${portName}`);
}
}, [selectedPort, configLabel]);
const handleConnect = () => {
if (!selectedPort) return;
@@ -101,6 +116,26 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
lineMode,
};
// Save as host if checkbox is checked and onSaveHost is provided
if (saveConfig && onSaveHost) {
const portName = selectedPort.split('/').pop() || selectedPort;
const host: Host = {
id: `serial-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
label: configLabel.trim() || `Serial: ${portName}`,
hostname: selectedPort,
// For serial hosts, port field stores baud rate as a numeric identifier.
// The full configuration is stored in serialConfig for actual connection.
port: baudRate,
username: '',
os: 'linux',
tags: ['serial'],
protocol: 'serial',
createdAt: Date.now(),
serialConfig: config, // Store full serial configuration for connection
};
onSaveHost(host);
}
onConnect(config);
onClose();
};
@@ -120,7 +155,8 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
trimmedPort.startsWith('/dev/') ||
/^COM\d+$/i.test(trimmedPort) ||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
const isBaudRateValid = BAUD_RATES.includes(baudRate);
// Allow custom baud rates as long as they are positive integers
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
// Check if using 1.5 stop bits (limited Windows support)
const isStopBits15 = stopBits === 1.5;
@@ -178,18 +214,28 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
{/* 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>
<Combobox
options={BAUD_RATES.map((rate) => ({
value: String(rate),
label: String(rate),
}))}
value={String(baudRate)}
onValueChange={(val) => {
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0) {
setBaudRate(parsed);
}
}}
placeholder={t('serial.field.baudRatePlaceholder')}
emptyText={t('serial.field.baudRateEmpty')}
allowCreate
createText={t('common.use')}
/>
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
<p className="text-xs text-muted-foreground">
{t('serial.field.customBaudRate')}
</p>
)}
</div>
{/* Advanced Options */}
@@ -325,6 +371,40 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
</div>
</CollapsibleContent>
</Collapsible>
{/* Save Configuration */}
{onSaveHost && (
<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="save-config" className="text-sm font-medium cursor-pointer">
{t('serial.field.saveConfig')}
</Label>
<p className="text-xs text-muted-foreground">
{t('serial.field.saveConfigDesc')}
</p>
</div>
<input
type="checkbox"
id="save-config"
checked={saveConfig}
onChange={(e) => setSaveConfig(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
</div>
{saveConfig && (
<div className="space-y-2">
<Label htmlFor="config-label">{t('serial.field.configLabel')}</Label>
<Input
id="config-label"
value={configLabel}
onChange={(e) => setConfigLabel(e.target.value)}
placeholder={t('serial.field.configLabelPlaceholder')}
/>
</div>
)}
</div>
)}
</div>
<DialogFooter>
@@ -332,8 +412,12 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
{t('common.cancel')}
</Button>
<Button onClick={handleConnect} disabled={!isValid}>
<Cpu size={14} className="mr-2" />
{t('common.connect')}
{saveConfig ? (
<Save size={14} className="mr-2" />
) : (
<Cpu size={14} className="mr-2" />
)}
{saveConfig ? t('serial.connectAndSave') : t('common.connect')}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -0,0 +1,415 @@
/**
* Serial Host Details Panel
* A dedicated editor for serial port hosts (distinct from SSH HostDetailsPanel)
*/
import { ChevronDown, ChevronUp, Save, Tag, 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 { Host, SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
import { Button } from './ui/button';
import { Combobox, ComboboxOption, MultiCombobox } from './ui/combobox';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import {
AsidePanel,
AsidePanelContent,
AsidePanelFooter,
} from './ui/aside-panel';
interface SerialPort {
path: string;
manufacturer: string;
serialNumber: string;
vendorId: string;
productId: string;
pnpId: string;
type?: 'hardware' | 'pseudo' | 'custom';
}
interface SerialHostDetailsPanelProps {
initialData: Host;
allTags?: string[];
groups?: string[];
onSave: (host: Host) => void;
onCancel: () => 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 SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
initialData,
allTags = [],
groups = [],
onSave,
onCancel,
}) => {
const { t } = useI18n();
const terminalBackend = useTerminalBackend();
const [ports, setPorts] = useState<SerialPort[]>([]);
const [isLoadingPorts, setIsLoadingPorts] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
// Form state
const [label, setLabel] = useState(initialData.label);
const [selectedPort, setSelectedPort] = useState(initialData.hostname || initialData.serialConfig?.path || '');
const [baudRate, setBaudRate] = useState(initialData.serialConfig?.baudRate || initialData.port || 115200);
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(initialData.serialConfig?.dataBits || 8);
const [stopBits, setStopBits] = useState<1 | 1.5 | 2>(initialData.serialConfig?.stopBits || 1);
const [parity, setParity] = useState<SerialParity>(initialData.serialConfig?.parity || 'none');
const [flowControl, setFlowControl] = useState<SerialFlowControl>(initialData.serialConfig?.flowControl || 'none');
const [localEcho, setLocalEcho] = useState(initialData.serialConfig?.localEcho || false);
const [lineMode, setLineMode] = useState(initialData.serialConfig?.lineMode || false);
const [tags, setTags] = useState<string[]>(initialData.tags || []);
const [group, setGroup] = useState(initialData.group || '');
const loadPorts = useCallback(async () => {
setIsLoadingPorts(true);
try {
const result = await terminalBackend.listSerialPorts();
setPorts(result);
} catch (err) {
console.error('[Serial] Failed to list ports:', err);
} finally {
setIsLoadingPorts(false);
}
}, [terminalBackend]);
useEffect(() => {
loadPorts();
}, [loadPorts]);
const handleSave = () => {
if (!selectedPort) return;
const config: SerialConfig = {
path: selectedPort,
baudRate,
dataBits,
stopBits,
parity,
flowControl,
localEcho,
lineMode,
};
const portName = selectedPort.split('/').pop() || selectedPort;
const updatedHost: Host = {
...initialData,
label: label.trim() || `Serial: ${portName}`,
hostname: selectedPort,
port: baudRate,
tags,
group,
serialConfig: config,
};
onSave(updatedHost);
};
// Convert ports to Combobox options
const portOptions: ComboboxOption[] = useMemo(() => {
return ports.map((port) => ({
value: port.path,
label: port.path,
sublabel: port.manufacturer || undefined,
}));
}, [ports]);
// Tag options for MultiCombobox
const tagOptions: ComboboxOption[] = useMemo(() => {
const allUniqueTags = new Set([...allTags, ...tags]);
return Array.from(allUniqueTags).map((tag) => ({
value: tag,
label: tag,
}));
}, [allTags, tags]);
// Group options for Combobox
const groupOptions: ComboboxOption[] = useMemo(() => {
const allGroups = new Set(groups);
if (group && !allGroups.has(group)) {
allGroups.add(group);
}
return Array.from(allGroups).map((g) => ({
value: g,
label: g,
}));
}, [groups, group]);
// Validation
const trimmedPort = selectedPort.trim();
const isPortValid =
trimmedPort.startsWith('/dev/') ||
/^COM\d+$/i.test(trimmedPort) ||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
const isValid = isPortValid && isBaudRateValid;
// Check if using 1.5 stop bits (limited Windows support)
const isStopBits15 = stopBits === 1.5;
return (
<AsidePanel
open={true}
onClose={onCancel}
title={t('serial.edit.title')}
subtitle={initialData.label}
className="z-40"
>
<AsidePanelContent>
{/* Label */}
<div className="space-y-2">
<Label htmlFor="serial-label">{t('serial.field.configLabel')}</Label>
<Input
id="serial-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t('serial.field.configLabelPlaceholder')}
/>
</div>
{/* Serial Port */}
<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"
>
{t('common.refresh')}
</Button>
</div>
<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>
<Combobox
options={BAUD_RATES.map((rate) => ({
value: String(rate),
label: String(rate),
}))}
value={String(baudRate)}
onValueChange={(val) => {
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0) {
setBaudRate(parsed);
}
}}
placeholder={t('serial.field.baudRatePlaceholder')}
emptyText={t('serial.field.baudRateEmpty')}
allowCreate
createText={t('common.use')}
/>
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
<p className="text-xs text-muted-foreground">
{t('serial.field.customBaudRate')}
</p>
)}
</div>
{/* Tags */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Tag size={14} />
{t('hostDetails.tags')}
</Label>
<MultiCombobox
options={tagOptions}
values={tags}
onValuesChange={setTags}
placeholder={t('hostDetails.addTag')}
allowCreate
createText={t('hostDetails.createTag')}
/>
</div>
{/* Group */}
<div className="space-y-2">
<Label>{t('hostDetails.group')}</Label>
<Combobox
options={groupOptions}
value={group}
onValueChange={setGroup}
placeholder={t('hostDetails.selectGroup')}
allowCreate
createText={t('hostDetails.createGroup')}
/>
</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>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
</p>
)}
</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>
</AsidePanelContent>
<AsidePanelFooter>
<div className="flex gap-2">
<Button variant="ghost" onClick={onCancel} className="flex-1">
{t('common.cancel')}
</Button>
<Button onClick={handleSave} disabled={!isValid} className="flex-1">
<Save size={14} className="mr-2" />
{t('common.save')}
</Button>
</div>
</AsidePanelFooter>
</AsidePanel>
);
};
export default SerialHostDetailsPanel;

View File

@@ -50,6 +50,7 @@ import PortForwarding from "./PortForwardingNew";
import QuickConnectWizard from "./QuickConnectWizard";
import { isQuickConnectInput, parseQuickConnectInputWithWarnings } from "../domain/quickConnect";
import SerialConnectModal from "./SerialConnectModal";
import SerialHostDetailsPanel from "./SerialHostDetailsPanel";
import SnippetsManager from "./SnippetsManager";
import { ImportVaultDialog } from "./vault/ImportVaultDialog";
import { Button } from "./ui/button";
@@ -1361,7 +1362,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</div>
{/* Host Details Panel - positioned at VaultView root level for correct top alignment */}
{currentSection === "hosts" && isHostPanelOpen && (
{currentSection === "hosts" && isHostPanelOpen && editingHost?.protocol !== 'serial' && (
<HostDetailsPanel
initialData={editingHost}
availableKeys={keys}
@@ -1396,6 +1397,31 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
/>
)}
{/* Serial Host Details Panel - for editing serial port hosts */}
{currentSection === "hosts" && isHostPanelOpen && editingHost?.protocol === 'serial' && (
<SerialHostDetailsPanel
initialData={editingHost}
allTags={allTags}
groups={Array.from(
new Set([
...customGroups,
...hosts.map((h) => h.group || "General"),
]),
)}
onSave={(host) => {
onUpdateHosts(
hosts.map((h) => (h.id === host.id ? host : h)),
);
setIsHostPanelOpen(false);
setEditingHost(null);
}}
onCancel={() => {
setIsHostPanelOpen(false);
setEditingHost(null);
}}
/>
)}
<Dialog open={isNewFolderOpen} onOpenChange={(open) => {
setIsNewFolderOpen(open);
if (!open) {
@@ -1532,6 +1558,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onConnectSerial(config);
}
}}
onSaveHost={(host) => {
onUpdateHosts([...hosts, host]);
}}
/>
</div>
);

View File

@@ -88,6 +88,8 @@ export interface Host {
telnetEnabled?: boolean; // Is Telnet enabled for this host
telnetUsername?: string; // Telnet-specific username
telnetPassword?: string; // Telnet-specific password
// Serial-specific configuration (for protocol='serial' hosts)
serialConfig?: SerialConfig;
}
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
@@ -617,7 +619,7 @@ export interface ConnectionLog {
hostLabel: string; // Display label (e.g., 'Local Terminal' or host label)
hostname: string; // Target hostname or 'localhost'
username: string; // SSH username or system username
protocol: 'ssh' | 'telnet' | 'local' | 'mosh';
protocol: 'ssh' | 'telnet' | 'local' | 'mosh' | 'serial';
startTime: number; // Connection start timestamp
endTime?: number; // Connection end timestamp (undefined if still active)
localUsername: string; // System username of the local user

109
electron-builder.config.cjs Normal file
View File

@@ -0,0 +1,109 @@
const path = require('path');
/**
* @type {import('electron-builder').Configuration}
*/
module.exports = {
appId: 'com.netcatty.app',
productName: 'Netcatty',
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
icon: 'public/icon.png',
directories: {
buildResources: 'build',
output: 'release'
},
files: [
'dist/**/*',
'electron/**/*',
'!electron/.dev-config.json',
'public/**/*',
'node_modules/**/*'
],
asarUnpack: [
'node_modules/node-pty/**/*',
'node_modules/ssh2/**/*',
'node_modules/cpu-features/**/*'
],
mac: {
target: [
{
target: 'dmg',
arch: ['arm64', 'x64']
},
{
target: 'zip',
arch: ['arm64', 'x64']
}
],
category: 'public.app-category.developer-tools',
hardenedRuntime: false,
gatekeeperAssess: false,
entitlements: 'electron/entitlements.mac.plist',
entitlementsInherit: 'electron/entitlements.mac.plist',
extendInfo: {
NSCameraUsageDescription: 'Netcatty may use the camera for video calls',
NSMicrophoneUsageDescription: 'Netcatty may use the microphone for audio',
NSLocalNetworkUsageDescription: 'Netcatty needs local network access for SSH connections'
}
},
dmg: {
title: '${productName}',
background: 'public/dmg-background.jpg',
iconSize: 100,
iconTextSize: 12,
window: {
width: 672,
height: 500
},
contents: [
{ x: 150, y: 158 },
{ x: 550, y: 158, type: 'link', path: '/Applications' },
{
x: 350,
y: 330,
type: 'file',
// Use absolute path resolved at build time
path: path.resolve(__dirname, 'scripts/FixQuarantine.app'),
name: '已损坏修复.app'
}
]
},
win: {
target: [
{
target: 'nsis',
arch: ['x64']
},
{
target: 'dir',
arch: ['x64']
}
]
},
nsis: {
oneClick: false,
perMachine: false,
allowElevation: true,
allowToChangeInstallationDirectory: true,
createDesktopShortcut: true,
createStartMenuShortcut: true,
shortcutName: 'Netcatty'
},
linux: {
target: [
{
target: 'AppImage',
arch: ['x64', 'arm64']
},
{
target: 'deb',
arch: ['x64', 'arm64']
},
{
target: 'rpm',
arch: ['x64', 'arm64']
}
],
category: 'Development'
}
};

View File

@@ -1,104 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
"appId": "com.netcatty.app",
"productName": "Netcatty",
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"icon": "public/icon.png",
"directories": {
"buildResources": "build",
"output": "release"
},
"files": [
"dist/**/*",
"electron/**/*",
"!electron/.dev-config.json",
"public/**/*",
"node_modules/**/*"
],
"asarUnpack": [
"node_modules/node-pty/**/*",
"node_modules/ssh2/**/*",
"node_modules/cpu-features/**/*"
],
"mac": {
"target": [
{
"target": "dmg",
"arch": ["arm64", "x64"]
},
{
"target": "zip",
"arch": ["arm64", "x64"]
}
],
"category": "public.app-category.developer-tools",
"hardenedRuntime": false,
"gatekeeperAssess": false,
"entitlements": "electron/entitlements.mac.plist",
"entitlementsInherit": "electron/entitlements.mac.plist",
"extendInfo": {
"NSCameraUsageDescription": "Netcatty may use the camera for video calls",
"NSMicrophoneUsageDescription": "Netcatty may use the microphone for audio",
"NSLocalNetworkUsageDescription": "Netcatty needs local network access for SSH connections"
}
},
"dmg": {
"title": "${productName}",
"background": "public/dmg-background.jpg",
"iconSize": 100,
"iconTextSize": 12,
"window": {
"width": 672,
"height": 500
},
"contents": [
{ "x": 150, "y": 158 },
{ "x": 550, "y": 158, "type": "link", "path": "/Applications" },
{
"x": 350,
"y": 330,
"type": "file",
"path": "scripts/FixQuarantine.app",
"name": "已损坏修复.app"
}
]
},
"win": {
"target": [
{
"target": "nsis",
"arch": ["x64"]
},
{
"target": "dir",
"arch": ["x64"]
}
]
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowElevation": true,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "Netcatty"
},
"linux": {
"target": [
{
"target": "AppImage",
"arch": ["x64", "arm64"]
},
{
"target": "deb",
"arch": ["x64", "arm64"]
},
{
"target": "rpm",
"arch": ["x64", "arm64"]
}
],
"category": "Development"
}
}

2191
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,11 +15,11 @@
"build": "vite build",
"preview": "vite preview",
"start": "node electron/launch.cjs",
"pack": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --publish=never",
"pack:dir": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --dir --publish=never",
"pack:win": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --win --publish=never",
"pack:mac": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --mac --publish=never",
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --linux --publish=never",
"pack": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --publish=never",
"pack:dir": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --dir --publish=never",
"pack:win": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --win --publish=never",
"pack:mac": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --mac --publish=never",
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never",
"postinstall": "electron-builder install-app-deps",
"rebuild": "electron-builder install-app-deps",
"lint": "eslint .",
@@ -77,4 +77,4 @@
"vite": "^7.2.7",
"wait-on": "^9.0.3"
}
}
}