mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-09 01:20:07 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a356a2b07b | ||
|
|
7a575e692f | ||
|
|
b40b4a7a37 | ||
|
|
bf54a68472 | ||
|
|
0039d98dd0 | ||
|
|
944d1389f2 | ||
|
|
0cf86fe5e5 |
No files matched your search
@@ -82,6 +82,8 @@ on:
|
||||
- "scripts/resolve-et-bin-release.cjs"
|
||||
- "scripts/resolve-mosh-bin-release.cjs"
|
||||
- "scripts/tray-panel-layout.live.test.cjs"
|
||||
- "scripts/serial-named-pipe.live.test.cjs"
|
||||
- "scripts/fixtures/serial/**"
|
||||
- "scripts/verify-linux-*.sh"
|
||||
- "scripts/xterm-macos-column-selection.live.test.cjs"
|
||||
- "skills/**"
|
||||
@@ -251,6 +253,10 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Test Windows serial pipe through native Electron
|
||||
if: matrix.name == 'windows'
|
||||
run: npx electron scripts/serial-named-pipe.live.test.cjs
|
||||
|
||||
- name: Test macOS Option column selection
|
||||
if: matrix.name == 'macos'
|
||||
run: npm run test:xterm-macos-selection
|
||||
|
||||
@@ -91,6 +91,9 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Exercise serial named pipe transport on Windows
|
||||
run: node --test electron/bridges/serialNamedPipe.test.cjs
|
||||
|
||||
- name: Apply production dependency patches
|
||||
run: npx patch-package --error-on-fail
|
||||
|
||||
|
||||
@@ -879,6 +879,7 @@ export const enTerminalMessages: Messages = {
|
||||
'serial.field.flowControl': 'Flow Control',
|
||||
'serial.noPorts': 'No serial ports detected. Connect a device and refresh.',
|
||||
'serial.field.customPort': 'Custom Port Path',
|
||||
'serial.field.namedPipeHint': 'Windows VM console: enter \\\\.\\pipe\\name. The VM must create the pipe as a server. Baud rate and framing are ignored.',
|
||||
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001 or COM1',
|
||||
'serial.type.hardware': 'Hardware',
|
||||
'serial.type.pseudo': 'Pseudo Terminal',
|
||||
|
||||
@@ -841,6 +841,7 @@ export const esTerminalMessages: Messages = {
|
||||
'serial.field.flowControl': 'Control de flujo',
|
||||
'serial.noPorts': 'No se detectaron puertos seriales. Conecta un dispositivo y actualiza.',
|
||||
'serial.field.customPort': 'Ruta de puerto personalizada',
|
||||
'serial.field.namedPipeHint': 'Consola de VM en Windows: introduce \\\\.\\pipe\\name. La VM debe crear la canalización como servidor. Se ignoran la velocidad y la trama.',
|
||||
'serial.field.customPortPlaceholder': 'p. ej., /dev/ttys001 o COM1',
|
||||
'serial.type.hardware': 'Hardware',
|
||||
'serial.type.pseudo': 'Pseudoterminal',
|
||||
|
||||
@@ -886,6 +886,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'serial.field.flowControl': 'Управление потоком',
|
||||
'serial.noPorts': 'Последовательные порты не обнаружены. Подключите устройство и обновите список.',
|
||||
'serial.field.customPort': 'Путь к пользовательскому порту',
|
||||
'serial.field.namedPipeHint': 'Консоль ВМ Windows: введите \\\\.\\pipe\\name. ВМ должна создать канал как сервер. Скорость и параметры кадра игнорируются.',
|
||||
'serial.field.customPortPlaceholder': 'например, /dev/ttys001 или COM1',
|
||||
'serial.type.hardware': 'Аппаратный',
|
||||
'serial.type.pseudo': 'Псевдотерминал',
|
||||
|
||||
@@ -969,6 +969,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'serial.field.flowControl': '流控制',
|
||||
'serial.noPorts': '未检测到串口设备。请连接设备后刷新。',
|
||||
'serial.field.customPort': '自定义串口路径',
|
||||
'serial.field.namedPipeHint': 'Windows 虚拟机控制台:输入 \\\\.\\pipe\\name。虚拟机需作为服务端创建管道,波特率和串口帧设置不生效。',
|
||||
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
|
||||
'serial.type.hardware': '硬件',
|
||||
'serial.type.pseudo': '虚拟终端',
|
||||
|
||||
@@ -969,6 +969,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'serial.field.flowControl': '流量控制',
|
||||
'serial.noPorts': '未偵測到序列埠裝置。請連線裝置後重新整理。',
|
||||
'serial.field.customPort': '自訂序列埠路徑',
|
||||
'serial.field.namedPipeHint': 'Windows 虛擬機主控台:輸入 \\\\.\\pipe\\name。虛擬機需作為伺服器端建立管道,鮑率與串口框架設定不生效。',
|
||||
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
|
||||
'serial.type.hardware': '硬體',
|
||||
'serial.type.pseudo': '虛擬終端',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useTerminalBackend } from '../application/state/useTerminalBackend';
|
||||
import type { Host, SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
|
||||
import { prepareSerialConfigForSavedHost } from '../domain/serialBackspace';
|
||||
import { isSerialEndpointPath, isWindowsNamedPipePath } from '../domain/serialEndpoint';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { Combobox, type ComboboxOption } from './ui/combobox';
|
||||
@@ -111,7 +112,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
if (!selectedPort) return;
|
||||
|
||||
const config: SerialConfig = {
|
||||
path: selectedPort,
|
||||
path: selectedPort.trim(),
|
||||
baudRate,
|
||||
dataBits,
|
||||
stopBits,
|
||||
@@ -129,7 +130,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
const host: Host = {
|
||||
id: `serial-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
|
||||
label: configLabel.trim() || `Serial: ${portName}`,
|
||||
hostname: selectedPort,
|
||||
hostname: selectedPort.trim(),
|
||||
// For serial hosts, port field stores baud rate as a numeric identifier.
|
||||
// The full configuration is stored in serialConfig for actual connection.
|
||||
port: baudRate,
|
||||
@@ -157,18 +158,15 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
}));
|
||||
}, [ports]);
|
||||
|
||||
// Validate: port path must start with /dev/ (Unix/macOS) or COM/\\.\COM (Windows)
|
||||
const trimmedPort = selectedPort.trim();
|
||||
const isPortValid =
|
||||
trimmedPort.startsWith('/dev/') ||
|
||||
/^COM\d+$/i.test(trimmedPort) ||
|
||||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
|
||||
const isNamedPipe = isWindowsNamedPipePath(trimmedPort);
|
||||
const isPortValid = isSerialEndpointPath(trimmedPort);
|
||||
// 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;
|
||||
const isValid = isPortValid && isBaudRateValid;
|
||||
const isValid = isPortValid && (isNamedPipe || isBaudRateValid);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
@@ -212,6 +210,9 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
icon={<Usb size={14} className="text-muted-foreground" />}
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.namedPipeHint')}
|
||||
</p>
|
||||
{!isPortValid && selectedPort && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t('serial.field.customPortPlaceholder')}
|
||||
@@ -219,32 +220,34 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
)}
|
||||
</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>
|
||||
{/* Baud Rate (not used by named pipes) */}
|
||||
{!isNamedPipe && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Advanced Options */}
|
||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
||||
@@ -264,92 +267,98 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
{/* Data Bits */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Hardware-only serial framing is not used by named pipes. */}
|
||||
{!isNamedPipe && (
|
||||
<>
|
||||
{/* 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
|
||||
value={String(dataBits)}
|
||||
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
|
||||
>
|
||||
<SelectTrigger id="data-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATA_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Stop Bits */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
|
||||
<Select
|
||||
value={String(stopBits)}
|
||||
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
|
||||
>
|
||||
<SelectTrigger id="stop-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STOP_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isStopBits15 && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
{t('serial.field.stopBits15Warning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parity */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
|
||||
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
|
||||
<Select
|
||||
value={String(dataBits)}
|
||||
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
|
||||
value={parity}
|
||||
onValueChange={(v) => setParity(v as SerialParity)}
|
||||
>
|
||||
<SelectTrigger id="data-bits">
|
||||
<SelectTrigger id="parity">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATA_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Stop Bits */}
|
||||
{/* Flow Control */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
|
||||
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
|
||||
<Select
|
||||
value={String(stopBits)}
|
||||
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
|
||||
value={flowControl}
|
||||
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
|
||||
>
|
||||
<SelectTrigger id="stop-bits">
|
||||
<SelectTrigger id="flow-control">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STOP_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</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
|
||||
value={parity}
|
||||
onValueChange={(v) => setParity(v as SerialParity)}
|
||||
>
|
||||
<SelectTrigger id="parity">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Flow Control */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
|
||||
<Select
|
||||
value={flowControl}
|
||||
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
|
||||
>
|
||||
<SelectTrigger id="flow-control">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Terminal Options */}
|
||||
<div className="space-y-3 pt-2 border-t border-border/60">
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { I18nProvider } from '../application/i18n/I18nProvider';
|
||||
import type { Host } from '../domain/models';
|
||||
import { SerialHostDetailsPanel } from './SerialHostDetailsPanel';
|
||||
|
||||
function renderPanel(path: string, hostname = path) {
|
||||
const host: Host = {
|
||||
id: 'serial-test', label: 'VM console', hostname, protocol: 'serial',
|
||||
port: 115200, username: '', tags: [], os: 'linux',
|
||||
serialConfig: { path, baudRate: 115200, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none' },
|
||||
};
|
||||
return renderToStaticMarkup(<I18nProvider locale="en"><SerialHostDetailsPanel
|
||||
initialData={host} onSave={() => {}} onCancel={() => {}}
|
||||
/></I18nProvider>);
|
||||
}
|
||||
|
||||
test('saved named-pipe host is editable without hardware baud controls', () => {
|
||||
const html = renderPanel(String.raw`\\.\pipe\vm-console`);
|
||||
assert.match(html, /VM must create the pipe as a server/);
|
||||
assert.doesNotMatch(html, /for="baud-rate"/);
|
||||
assert.doesNotMatch(html, /text-destructive/);
|
||||
});
|
||||
|
||||
test('hardware serial host retains its baud-rate controls', () => {
|
||||
assert.match(renderPanel('COM3'), /for="baud-rate"/);
|
||||
});
|
||||
|
||||
test('editor prefers the stored pipe path over a stale hardware hostname', () => {
|
||||
const html = renderPanel(String.raw`\\.\pipe\VM console`, 'COM3');
|
||||
assert.doesNotMatch(html, /for="baud-rate"/);
|
||||
});
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
resolveSerialBackspaceOverrideOnSave,
|
||||
} from '../domain/serialBackspace';
|
||||
|
||||
import { isSerialEndpointPath, isWindowsNamedPipePath } from '../domain/serialEndpoint';
|
||||
|
||||
import { Button } from './ui/button';
|
||||
import { Combobox, ComboboxOption, MultiCombobox } from './ui/combobox';
|
||||
import { Input } from './ui/input';
|
||||
@@ -91,7 +93,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
|
||||
// input alone cannot restore `password` to undefined — the user needs a
|
||||
// dedicated control to delete the credential entirely.
|
||||
const [passwordCleared, setPasswordCleared] = useState(false);
|
||||
const [selectedPort, setSelectedPort] = useState(initialData.hostname || initialData.serialConfig?.path || '');
|
||||
const [selectedPort, setSelectedPort] = useState(initialData.serialConfig?.path || initialData.hostname || '');
|
||||
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);
|
||||
@@ -131,7 +133,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
|
||||
if (!selectedPort) return;
|
||||
|
||||
const config: SerialConfig = {
|
||||
path: selectedPort,
|
||||
path: selectedPort.trim(),
|
||||
baudRate,
|
||||
dataBits,
|
||||
stopBits,
|
||||
@@ -151,7 +153,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
|
||||
const updatedHost: Host = {
|
||||
...initialData,
|
||||
label: label.trim() || `Serial: ${portName}`,
|
||||
hostname: selectedPort,
|
||||
hostname: selectedPort.trim(),
|
||||
port: baudRate,
|
||||
username: username.trim() || undefined,
|
||||
// An empty password is meaningful for serial auto-login: the detector
|
||||
@@ -206,12 +208,10 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
|
||||
|
||||
// Validation
|
||||
const trimmedPort = selectedPort.trim();
|
||||
const isPortValid =
|
||||
trimmedPort.startsWith('/dev/') ||
|
||||
/^COM\d+$/i.test(trimmedPort) ||
|
||||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
|
||||
const isNamedPipe = isWindowsNamedPipePath(trimmedPort);
|
||||
const isPortValid = isSerialEndpointPath(trimmedPort);
|
||||
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
|
||||
const isValid = isPortValid && isBaudRateValid;
|
||||
const isValid = isPortValid && (isNamedPipe || isBaudRateValid);
|
||||
|
||||
// Check if using 1.5 stop bits (limited Windows support)
|
||||
const isStopBits15 = stopBits === 1.5;
|
||||
@@ -265,6 +265,9 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
|
||||
createText={t('common.use')}
|
||||
icon={<Usb size={14} className="text-muted-foreground" />}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.namedPipeHint')}
|
||||
</p>
|
||||
{!isPortValid && selectedPort && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t('serial.field.customPortPlaceholder')}
|
||||
@@ -272,32 +275,34 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
|
||||
)}
|
||||
</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>
|
||||
{/* Baud Rate (not used by named pipes) */}
|
||||
{!isNamedPipe && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Login credentials (auto-login) */}
|
||||
<div className="space-y-2">
|
||||
@@ -408,92 +413,98 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
{/* Data Bits */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Hardware-only serial framing is not used by named pipes. */}
|
||||
{!isNamedPipe && (
|
||||
<>
|
||||
{/* 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
|
||||
value={String(dataBits)}
|
||||
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
|
||||
>
|
||||
<SelectTrigger id="data-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATA_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Stop Bits */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
|
||||
<Select
|
||||
value={String(stopBits)}
|
||||
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
|
||||
>
|
||||
<SelectTrigger id="stop-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STOP_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isStopBits15 && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
{t('serial.field.stopBits15Warning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parity */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
|
||||
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
|
||||
<Select
|
||||
value={String(dataBits)}
|
||||
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
|
||||
value={parity}
|
||||
onValueChange={(v) => setParity(v as SerialParity)}
|
||||
>
|
||||
<SelectTrigger id="data-bits">
|
||||
<SelectTrigger id="parity">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATA_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Stop Bits */}
|
||||
{/* Flow Control */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
|
||||
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
|
||||
<Select
|
||||
value={String(stopBits)}
|
||||
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
|
||||
value={flowControl}
|
||||
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
|
||||
>
|
||||
<SelectTrigger id="stop-bits">
|
||||
<SelectTrigger id="flow-control">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STOP_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</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
|
||||
value={parity}
|
||||
onValueChange={(v) => setParity(v as SerialParity)}
|
||||
>
|
||||
<SelectTrigger id="parity">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Flow Control */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
|
||||
<Select
|
||||
value={flowControl}
|
||||
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
|
||||
>
|
||||
<SelectTrigger id="flow-control">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Terminal Options */}
|
||||
<div className="space-y-3 pt-2 border-t border-border/60">
|
||||
|
||||
@@ -417,3 +417,19 @@ for (const sleepBeforeCompletion of [true, false]) {
|
||||
assert.deepEqual(writes, ['show version\r']);
|
||||
});
|
||||
}
|
||||
|
||||
test("named pipe sessions preserve the endpoint and omit hardware baud information", async () => {
|
||||
const pipePath = String.raw`\\.\pipe\vm-console`;
|
||||
const output: string[] = [];
|
||||
let capturedPath: unknown;
|
||||
const backend = buildBackend({ startSerialSession: async (options: Record<string, unknown>) => {
|
||||
capturedPath = options.path;
|
||||
return "pipe-session";
|
||||
} });
|
||||
const ctx = buildCtx(backend);
|
||||
ctx.serialConfig.path = pipePath;
|
||||
await createTerminalSessionStarters(ctx as never).startSerial({ ...term, write: (line: string, done?: () => void) => { output.push(line); done?.(); } } as never);
|
||||
assert.equal(capturedPath, pipePath);
|
||||
assert.ok(output.some((line) => line.includes(`[Connected to ${pipePath}]`)));
|
||||
assert.ok(output.every((line) => !line.includes('baud')));
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isWindowsNamedPipePath } from '../../../domain/serialEndpoint';
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { ProviderValidationIssue } from "@netcatty/plugin-contract";
|
||||
import { logger } from "../../../lib/logger";
|
||||
@@ -2172,7 +2173,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
autoLoginAttached = true;
|
||||
ctx.updateStatus("connected");
|
||||
ctx.setProgressValue(100);
|
||||
writeTerminalLine(ctx, term, `[Connected to ${ctx.serialConfig.path} at ${ctx.serialConfig.baudRate} baud]`);
|
||||
writeTerminalLine(ctx, term, isWindowsNamedPipePath(ctx.serialConfig.path)
|
||||
? `[Connected to ${ctx.serialConfig.path}]`
|
||||
: `[Connected to ${ctx.serialConfig.path} at ${ctx.serialConfig.baudRate} baud]`);
|
||||
|
||||
if (waitsForAutoLogin) {
|
||||
if (autoLoginCompletedBeforeAttach) {
|
||||
|
||||
@@ -783,3 +783,13 @@ test("CloudLinker skips the cwd probe without restricting generic Go servers", (
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("sanitizeHost preserves named-pipe spaces and restores the authoritative serial path", () => {
|
||||
const path = String.raw`\\.\pipe\VM console`;
|
||||
const host = makeHost({ protocol: 'serial', hostname: String.raw`\\.\pipe\VM`, serialConfig: {
|
||||
path, baudRate: 115200, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none',
|
||||
} });
|
||||
assert.equal(sanitizeHost(host).hostname, path);
|
||||
assert.equal(sanitizeHost(host).serialConfig?.path, path);
|
||||
assert.equal(sanitizeHost(makeHost({ hostname: 'example.com extra' })).hostname, 'example.com');
|
||||
});
|
||||
+4
-1
@@ -415,7 +415,10 @@ export const hostsEqualForIdentityReuse = (a: Host, b: Host): boolean => {
|
||||
};
|
||||
|
||||
export const sanitizeHost = (host: Host, snippets: Snippet[] = []): Host => {
|
||||
const cleanHostname = (host.hostname || '').trim().split(/\s+/)[0];
|
||||
// Serial endpoints are paths, not network hostnames; pipe names may contain spaces.
|
||||
const cleanHostname = host.protocol === 'serial'
|
||||
? (host.serialConfig?.path || host.hostname || '').trim()
|
||||
: (host.hostname || '').trim().split(/\s+/)[0];
|
||||
const cleanDistro = normalizeDistroId(host.distro);
|
||||
const cleanManualDistro = normalizeDistroId(host.manualDistro);
|
||||
const cleanDistroMode =
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { isSerialEndpointPath, isWindowsNamedPipePath } from './serialEndpoint';
|
||||
const require = createRequire(import.meta.url);
|
||||
const backend = require('../electron/bridges/serialNamedPipe.cjs');
|
||||
|
||||
test('serial endpoint validation keeps hardware ports and accepts local Windows pipes', () => {
|
||||
for (const value of ['/dev/ttyS0', '/dev/ttys001', 'COM1', String.raw`\\.\COM12`, String.raw`\\.\pipe\vm`, String.raw`\\?\pipe\vm`]) {
|
||||
assert.equal(isSerialEndpointPath(value), true, value);
|
||||
}
|
||||
for (const value of ['', 'vm', '\\\\.\\pipe\\', String.raw`\\host\pipe\vm`]) {
|
||||
assert.equal(isSerialEndpointPath(value), false, value);
|
||||
}
|
||||
});
|
||||
|
||||
test('renderer and backend agree on named pipe detection', () => {
|
||||
for (const value of ['', '/dev/ttyS0', 'COM1', String.raw`\\.\COM1`, String.raw`\\.\pipe\vm`, String.raw`\\?\pipe\VM console`, String.raw`\\.\PIPE\vm\console`, String.raw`\\host\pipe\vm`, '\\\\.\\pipe\\', '\\\\.\\pipe\\vm\n', '\\\\.\\pipe\\vm\0']) {
|
||||
assert.equal(isWindowsNamedPipePath(value), backend.isWindowsNamedPipePath(value), value);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
// Keep in sync with electron/bridges/serialNamedPipe.cjs.
|
||||
export function isWindowsNamedPipePath(value: string): boolean {
|
||||
return /^\\\\[.?]\\pipe\\[^\r\n\0]+$/i.test(value);
|
||||
}
|
||||
|
||||
export function isSerialEndpointPath(value: string): boolean {
|
||||
return value.startsWith('/dev/') || /^COM\d+$/i.test(value)
|
||||
|| /^\\\\\.\\COM\d+$/i.test(value) || isWindowsNamedPipePath(value);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
const net = require('node:net');
|
||||
|
||||
// Local Windows IPC only: do not interpret remote UNC paths as serial ports.
|
||||
// Keep in sync with domain/serialEndpoint.ts (covered by the endpoint tests).
|
||||
function isWindowsNamedPipePath(value) {
|
||||
return typeof value === 'string' && /^\\\\[.?]\\pipe\\[^\r\n\0]+$/i.test(value);
|
||||
}
|
||||
|
||||
/** A byte stream with the open/close interface used by serial sessions. */
|
||||
class SerialNamedPipe extends net.Socket {
|
||||
constructor(path) {
|
||||
super();
|
||||
this.path = path;
|
||||
this.finishOpen = null;
|
||||
}
|
||||
|
||||
open(callback) {
|
||||
const onConnect = () => finish(null);
|
||||
const onError = (error) => finish(error);
|
||||
const finish = (error) => {
|
||||
if (this.finishOpen !== finish) return;
|
||||
this.finishOpen = null;
|
||||
this.removeListener('connect', onConnect);
|
||||
this.removeListener('error', onError);
|
||||
callback(error);
|
||||
};
|
||||
this.finishOpen = finish;
|
||||
this.once('connect', onConnect);
|
||||
this.once('error', onError);
|
||||
try {
|
||||
this.connect(this.path);
|
||||
} catch (error) {
|
||||
finish(error);
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
// Cancel even before connect completes; a superseded boot must not linger.
|
||||
this.destroy();
|
||||
// Match asynchronous SerialPort open failures so the boot abort can settle
|
||||
// with its superseded error before this transport-level cancellation.
|
||||
queueMicrotask(() => this.finishOpen?.(new Error('Named pipe connection closed before opening')));
|
||||
}
|
||||
}
|
||||
|
||||
function createSerialTransport(options, SerialPort, platform = process.platform) {
|
||||
const path = options.path?.trim();
|
||||
if (!isWindowsNamedPipePath(path)) return new SerialPort({ ...options, path });
|
||||
if (platform !== 'win32') {
|
||||
throw new Error('Windows named pipes are only supported on Windows');
|
||||
}
|
||||
return new SerialNamedPipe(path);
|
||||
}
|
||||
|
||||
module.exports = { isWindowsNamedPipePath, SerialNamedPipe, createSerialTransport };
|
||||
@@ -0,0 +1,110 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const net = require('node:net');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { once } = require('node:events');
|
||||
const { SerialNamedPipe, isWindowsNamedPipePath, createSerialTransport } = require('./serialNamedPipe.cjs');
|
||||
|
||||
const endpoint = () => process.platform === 'win32'
|
||||
? `\\\\.\\pipe\\netcatty-test-${randomUUID()}`
|
||||
: path.join(os.tmpdir(), `nc-${randomUUID()}.sock`);
|
||||
|
||||
async function echoServer(t) {
|
||||
const address = endpoint();
|
||||
const peers = new Set();
|
||||
const server = net.createServer((socket) => {
|
||||
peers.add(socket);
|
||||
socket.on('close', () => peers.delete(socket));
|
||||
socket.on('error', () => {});
|
||||
socket.on('data', (data) => socket.write(data));
|
||||
});
|
||||
server.listen(address);
|
||||
await once(server, 'listening');
|
||||
t.after(async () => {
|
||||
for (const socket of peers) socket.destroy();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
return { address, peers, server };
|
||||
}
|
||||
|
||||
function open(port) {
|
||||
return new Promise((resolve, reject) => port.open((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
|
||||
test('recognizes only local Windows named pipe paths', () => {
|
||||
for (const value of [String.raw`\\.\pipe\vm`, String.raw`\\?\pipe\VM console`, String.raw`\\.\PIPE\vm\console`]) {
|
||||
assert.equal(isWindowsNamedPipePath(value), true, value);
|
||||
}
|
||||
for (const value of [undefined, '', '\\\\.\\pipe\\', String.raw`\\server\pipe\vm`, 'COM1', '/dev/ttyS0', '\\\\.\\pipe\\vm\n']) {
|
||||
assert.equal(isWindowsNamedPipePath(value), false, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
test('real IPC stream carries binary input/output and can reconnect after close', { timeout: 10000 }, async (t) => {
|
||||
const { address } = await echoServer(t);
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const port = new SerialNamedPipe(address);
|
||||
t.after(() => port.close());
|
||||
await open(port);
|
||||
const payload = Buffer.from([0, 1, 8, 13, 27, 127, 128, 255]);
|
||||
const received = once(port, 'data');
|
||||
await new Promise((resolve, reject) => port.write(payload, (error) => error ? reject(error) : resolve()));
|
||||
assert.deepEqual((await received)[0], payload);
|
||||
const closed = once(port, 'close');
|
||||
port.close();
|
||||
await closed;
|
||||
}
|
||||
});
|
||||
|
||||
test('missing pipe fails opening once without an unhandled error', { timeout: 10000 }, async () => {
|
||||
const port = new SerialNamedPipe(endpoint());
|
||||
let callbacks = 0;
|
||||
const closed = new Promise((resolve) => port.once('close', resolve));
|
||||
await new Promise((resolve) => port.open((error) => {
|
||||
callbacks++;
|
||||
assert.ok(error);
|
||||
resolve();
|
||||
}));
|
||||
await closed;
|
||||
assert.equal(callbacks, 1);
|
||||
});
|
||||
|
||||
test('closing during open cancels the pending connection once', { timeout: 10000 }, async (t) => {
|
||||
const { address } = await echoServer(t);
|
||||
const port = new SerialNamedPipe(address);
|
||||
let callbacks = 0;
|
||||
const closed = new Promise((resolve) => port.once('close', resolve));
|
||||
port.open((error) => {
|
||||
callbacks++;
|
||||
assert.match(error.message, /closed before opening/);
|
||||
});
|
||||
port.close();
|
||||
await closed;
|
||||
assert.equal(callbacks, 1);
|
||||
assert.equal(port.destroyed, true);
|
||||
});
|
||||
|
||||
test('server disconnection closes the transport', { timeout: 10000 }, async (t) => {
|
||||
const { address, peers, server } = await echoServer(t);
|
||||
const port = new SerialNamedPipe(address);
|
||||
t.after(() => port.close());
|
||||
await Promise.all([once(server, 'connection'), open(port)]);
|
||||
const closed = once(port, 'close');
|
||||
for (const peer of peers) peer.end();
|
||||
await closed;
|
||||
assert.equal(port.destroyed, true);
|
||||
});
|
||||
|
||||
test('transport selection preserves hardware settings and trims the path', () => {
|
||||
class HardwarePort { constructor(options) { this.options = options; } }
|
||||
const settings = { path: ' COM12 ', baudRate: 9600, parity: 'even', rtscts: true, autoOpen: false };
|
||||
const hardware = createSerialTransport(settings, HardwarePort, 'win32');
|
||||
assert.ok(hardware instanceof HardwarePort);
|
||||
assert.deepEqual(hardware.options, { ...settings, path: 'COM12' });
|
||||
const pipe = createSerialTransport({ ...settings, path: String.raw`\\.\pipe\vm` }, HardwarePort, 'win32');
|
||||
assert.ok(pipe instanceof SerialNamedPipe);
|
||||
pipe.close();
|
||||
assert.throws(() => createSerialTransport({ path: String.raw`\\.\pipe\vm` }, HardwarePort, 'linux'), /only supported on Windows/);
|
||||
});
|
||||
@@ -21,6 +21,7 @@ const trackedFlowPauseSenders = new WeakSet();
|
||||
|
||||
const pty = require("node-pty");
|
||||
const { SerialPort } = require("serialport");
|
||||
const { createSerialTransport } = require("./serialNamedPipe.cjs");
|
||||
const {
|
||||
configureTerminalSessionDataEmitter,
|
||||
emitTerminalSessionData,
|
||||
@@ -1313,7 +1314,7 @@ async function listSerialPorts() {
|
||||
async function startSerialSession(event, options) {
|
||||
const sessionId = options.sessionId || randomUUID();
|
||||
|
||||
const portPath = options.path;
|
||||
const portPath = options.path?.trim();
|
||||
const baudRate = options.baudRate || 115200;
|
||||
const dataBits = options.dataBits || 8;
|
||||
const stopBits = options.stopBits || 1;
|
||||
@@ -1347,7 +1348,7 @@ async function startSerialSession(event, options) {
|
||||
resolve(value);
|
||||
};
|
||||
try {
|
||||
const serialPort = new SerialPort({
|
||||
const serialPort = createSerialTransport({
|
||||
path: portPath,
|
||||
baudRate: baudRate,
|
||||
dataBits: dataBits,
|
||||
@@ -1357,7 +1358,7 @@ async function startSerialSession(event, options) {
|
||||
xon: flowControl === 'xon/xoff',
|
||||
xoff: flowControl === 'xon/xoff',
|
||||
autoOpen: false,
|
||||
});
|
||||
}, SerialPort);
|
||||
|
||||
const abortPendingOpen = () => {
|
||||
try { serialPort.close(); } catch { /* ignore */ }
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
|
||||
// A VM-like server with exactly one client slot. Keep the same server handle
|
||||
// alive between clients so reconnect tests do not race pipe creation/removal.
|
||||
class SingleInstancePipe {
|
||||
static void Main(string[] args) {
|
||||
using (var pipe = new NamedPipeServerStream(args[0], PipeDirection.InOut, 1,
|
||||
PipeTransmissionMode.Byte, PipeOptions.Asynchronous)) {
|
||||
Console.WriteLine("READY");
|
||||
Console.Out.Flush();
|
||||
var buffer = new byte[8192];
|
||||
while (true) {
|
||||
try {
|
||||
pipe.WaitForConnection();
|
||||
int count;
|
||||
while ((count = pipe.Read(buffer, 0, buffer.Length)) > 0) {
|
||||
pipe.Write(buffer, 0, count);
|
||||
pipe.Flush();
|
||||
}
|
||||
} catch (IOException error) {
|
||||
// Accept can also see an already-closed late client (ERROR_NO_DATA).
|
||||
int code = error.HResult & 0xffff;
|
||||
if (code != 109 && code != 232 && code != 233) throw;
|
||||
} finally {
|
||||
try { pipe.Disconnect(); }
|
||||
catch (InvalidOperationException) { /* Accept failed before connection. */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
'use strict';
|
||||
|
||||
// Native Electron integration, not a hypervisor fixture: the disposable server
|
||||
// implements the same local Windows pipe byte-stream contract as a VM console.
|
||||
if (!process.versions.electron) {
|
||||
require('node:test')('serial named pipe Electron integration', {
|
||||
skip: 'run electron scripts/serial-named-pipe.live.test.cjs',
|
||||
}, () => {});
|
||||
} else {
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const net = require('node:net');
|
||||
const { spawn, execFileSync } = require('node:child_process');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const electron = require('electron');
|
||||
const { app, BrowserWindow, ipcMain, protocol } = electron;
|
||||
protocol.registerSchemesAsPrivileged([{ scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true } }]);
|
||||
const sessions = new Map();
|
||||
const peers = new Set();
|
||||
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-pipe-electron-'));
|
||||
app.setPath('userData', profile);
|
||||
app.on('window-all-closed', () => {});
|
||||
let win;
|
||||
let server;
|
||||
let bridge;
|
||||
let busyServer;
|
||||
let busyOwner;
|
||||
let finishing = false;
|
||||
const watchdog = setTimeout(() => finish(1, new Error('serial pipe Electron test timed out')), 45000);
|
||||
function finish(code, error) {
|
||||
if (finishing) return;
|
||||
finishing = true;
|
||||
clearTimeout(watchdog);
|
||||
if (error) console.error(error);
|
||||
bridge?.cleanupAllSessions();
|
||||
for (const peer of peers) peer.destroy();
|
||||
server?.close();
|
||||
busyOwner?.destroy();
|
||||
busyServer?.kill();
|
||||
if (win && !win.isDestroyed()) win.destroy();
|
||||
try { fs.rmSync(profile, { recursive: true, force: true }); } catch { /* Windows may hold cache files until exit. */ }
|
||||
app.exit(code);
|
||||
}
|
||||
const js = (source) => win.webContents.executeJavaScript(source);
|
||||
const subscribe = (sessionId) => js(`window.pipeDataOff?.(); window.pipeExitOff?.(); window.pipeOutput = ''; window.pipeExits = []; window.pipeDataOff = window.netcatty.onSessionData(${JSON.stringify(sessionId)}, data => { window.pipeOutput += data; document.getElementById('output').textContent = window.pipeOutput; window.netcatty.ackSessionFlow(${JSON.stringify(sessionId)}, data.length); }); window.pipeExitOff = window.netcatty.onSessionExit(${JSON.stringify(sessionId)}, event => window.pipeExits.push(event)); void 0;`);
|
||||
async function waitFor(source, timeout = 5000) {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
if (await js(source)) return;
|
||||
await new Promise(resolve => setTimeout(resolve, 25));
|
||||
}
|
||||
throw new Error(`Timed out: ${source}`);
|
||||
}
|
||||
async function main() {
|
||||
bridge = require('../electron/bridges/terminalBridge.cjs');
|
||||
bridge.init({ sessions, electronModule: electron });
|
||||
bridge.registerHandlers(ipcMain);
|
||||
win = new BrowserWindow({ show: false, webPreferences: {
|
||||
preload: path.join(__dirname, '../electron/preload.cjs'),
|
||||
contextIsolation: true, nodeIntegration: false,
|
||||
// Match the production main-window preload configuration.
|
||||
sandbox: false,
|
||||
} });
|
||||
protocol.handle('app', () => new Response('<title>Serial pipe integration</title><pre id="output"></pre>', { headers: { 'content-type': 'text/html' } }));
|
||||
await win.loadURL('app://netcatty/serial-pipe-test');
|
||||
assert.equal(await js('typeof window.netcatty.startSerialSession'), 'function');
|
||||
const endpoint = String.raw`\\.\pipe\netcatty VM console ${randomUUID()}`;
|
||||
const busyName = `netcatty-busy-${randomUUID()}`;
|
||||
const busyPath = '\\\\.\\pipe\\' + busyName;
|
||||
assert.ok(busyPath.endsWith(busyName) && !busyPath.includes('${'));
|
||||
if (process.platform !== 'win32') {
|
||||
// Exercise callback registration too; functions must never be returned
|
||||
// from executeJavaScript across Electron's structured-clone boundary.
|
||||
await subscribe('unsupported');
|
||||
await assert.rejects(js(`window.netcatty.startSerialSession(${JSON.stringify({ sessionId: 'unsupported', path: endpoint })})`), /only supported on Windows/);
|
||||
assert.equal(sessions.size, 0);
|
||||
console.log('SERIAL_PIPE_ELECTRON_PASS: native preload/IPC rejects Windows pipes on non-Windows');
|
||||
return;
|
||||
}
|
||||
const received = [];
|
||||
let slowReader = false;
|
||||
server = net.createServer(peer => {
|
||||
peers.add(peer);
|
||||
if (slowReader) peer.pause();
|
||||
peer.on('error', () => {});
|
||||
peer.on('close', () => peers.delete(peer));
|
||||
peer.on('data', data => { received.push(data); peer.write(data); });
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(endpoint, resolve);
|
||||
});
|
||||
const sessionId = 'native-pipe-session';
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
assert.equal(await js(`window.netcatty.startSerialSession(${JSON.stringify({ sessionId, path: endpoint, charset: 'utf-8' })})`), sessionId);
|
||||
assert.equal(sessions.get(sessionId).type, 'serial');
|
||||
await subscribe(sessionId);
|
||||
const payload = `round ${attempt}: café 中文\r\n`;
|
||||
await js(`window.netcatty.writeToSession(${JSON.stringify(sessionId)}, ${JSON.stringify(payload)})`);
|
||||
await waitFor(`window.pipeOutput === ${JSON.stringify(payload)}`);
|
||||
assert.ok(Buffer.concat(received).toString('utf8').includes(payload), 'renderer input reaches the real Windows pipe server');
|
||||
assert.equal(await js("document.getElementById('output').textContent"), payload);
|
||||
if (attempt === 0) {
|
||||
await js(`window.netcatty.closeSession(${JSON.stringify(sessionId)})`);
|
||||
assert.equal(sessions.has(sessionId), false);
|
||||
} else {
|
||||
for (const peer of peers) peer.destroy();
|
||||
await waitFor('window.pipeExits.length === 1');
|
||||
assert.equal(sessions.has(sessionId), false, 'broken pipe removes backend session');
|
||||
await js(`window.netcatty.closeSession(${JSON.stringify(sessionId)})`);
|
||||
}
|
||||
}
|
||||
// Bound stress to 1 MiB: pause the server reader, then verify exact bytes
|
||||
// through both directions without introducing a different flow-control path.
|
||||
slowReader = true;
|
||||
received.length = 0;
|
||||
await js(`window.netcatty.startSerialSession(${JSON.stringify({ sessionId: 'slow-reader', path: endpoint })})`);
|
||||
await subscribe('slow-reader');
|
||||
const largePayload = '0123456789abcdef'.repeat(65536);
|
||||
await js(`window.netcatty.writeToSession('slow-reader', ${JSON.stringify(largePayload)})`);
|
||||
const queueDeadline = Date.now() + 5000;
|
||||
while (sessions.get('slow-reader').serialPort.writableLength === 0 && Date.now() < queueDeadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
const queuedBytes = sessions.get('slow-reader').serialPort.writableLength;
|
||||
assert.ok(queuedBytes > 0, 'paused reader must leave a pending transport write');
|
||||
assert.ok(queuedBytes <= Buffer.byteLength(largePayload), 'bounded write queue');
|
||||
for (const peer of peers) peer.resume();
|
||||
await waitFor(`window.pipeOutput.length === ${largePayload.length}`, 15000);
|
||||
assert.equal(Buffer.concat(received).toString('utf8'), largePayload);
|
||||
assert.equal(await js('window.pipeOutput'), largePayload);
|
||||
assert.equal(sessions.get('slow-reader').serialPort.writableLength, 0);
|
||||
await js("window.netcatty.closeSession('slow-reader')");
|
||||
console.log(`SERIAL_PIPE_SLOW_READER_PASS: 1048576 exact bytes, observed queued bytes=${queuedBytes}, drained=0`);
|
||||
|
||||
// Node net.Server permits multiple simultaneous clients. Use a tiny .NET
|
||||
// server to reproduce a real single-instance pipe's ERROR_PIPE_BUSY path.
|
||||
const fixtureExe = path.join(profile, 'single-instance-pipe.exe');
|
||||
const compiler = path.join(process.env.WINDIR, 'Microsoft.NET/Framework64/v4.0.30319/csc.exe');
|
||||
execFileSync(compiler, ['/nologo', '/target:exe', `/out:${fixtureExe}`,
|
||||
path.join(__dirname, 'fixtures/serial/single-instance-pipe.cs')]);
|
||||
busyServer = spawn(fixtureExe, [busyName], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let fixtureErrors = '';
|
||||
busyServer.stderr.on('data', chunk => { fixtureErrors += chunk; });
|
||||
busyServer.on('exit', code => { if (!finishing) finish(1, new Error(`Pipe fixture exited ${code}: ${fixtureErrors}`)); });
|
||||
await new Promise((resolve, reject) => {
|
||||
let output = '';
|
||||
busyServer.once('error', reject);
|
||||
busyServer.once('exit', code => reject(new Error(`Pipe fixture exited ${code}`)));
|
||||
busyServer.stdout.on('data', chunk => { output += chunk; if (output.includes('READY')) resolve(); });
|
||||
});
|
||||
busyOwner = net.connect(busyPath);
|
||||
await new Promise((resolve, reject) => { busyOwner.once('connect', resolve); busyOwner.once('error', reject); });
|
||||
const { SerialNamedPipe } = require('../electron/bridges/serialNamedPipe.cjs');
|
||||
const originalOpen = SerialNamedPipe.prototype.open;
|
||||
let pendingTransport;
|
||||
let pendingTransportClosed;
|
||||
// Observe the real transport, without replacing its connect/close behavior.
|
||||
SerialNamedPipe.prototype.open = function (callback) {
|
||||
if (this.path === busyPath) {
|
||||
SerialNamedPipe.prototype.open = originalOpen;
|
||||
pendingTransport = this;
|
||||
pendingTransportClosed = new Promise(resolve => this.once('close', resolve));
|
||||
}
|
||||
return originalOpen.call(this, callback);
|
||||
};
|
||||
await js(`window.busyResult = 'pending'; window.busyPromise = window.netcatty.startSerialSession(${JSON.stringify({ sessionId: 'busy', path: busyPath })}).then(() => { window.busyResult = 'connected'; }, error => { window.busyResult = error.message; }); void 0;`);
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
assert.equal(await js('window.busyResult'), 'pending', 'single-instance pipe must be busy');
|
||||
await js("window.netcatty.closeSession('busy')");
|
||||
await waitFor("window.busyResult !== 'pending'");
|
||||
assert.match(await js('window.busyResult'), /superseded|closed before opening/i);
|
||||
assert.equal(sessions.has('busy'), false);
|
||||
busyOwner.destroy();
|
||||
assert.ok(pendingTransportClosed, 'observe the pending real transport');
|
||||
await pendingTransportClosed;
|
||||
assert.equal(pendingTransport.destroyed, true);
|
||||
// Reconnect after releasing the occupied pipe. This also proves a cancelled
|
||||
// late connect cannot keep the one server instance occupied indefinitely.
|
||||
assert.equal(await js(`window.netcatty.startSerialSession(${JSON.stringify({ sessionId: 'busy-reconnect', path: busyPath })})`), 'busy-reconnect');
|
||||
await subscribe('busy-reconnect');
|
||||
await js("window.netcatty.writeToSession('busy-reconnect', 'after cancellation')");
|
||||
await waitFor("window.pipeOutput === 'after cancellation'");
|
||||
await js("window.netcatty.closeSession('busy-reconnect')");
|
||||
assert.equal(sessions.has('busy'), false, 'cancelled late connect must not create a session');
|
||||
console.log('SERIAL_PIPE_BUSY_PASS: pending cancel, late-connect cleanup, manual reconnect');
|
||||
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
server = null;
|
||||
await assert.rejects(js(`window.netcatty.startSerialSession(${JSON.stringify({ sessionId: 'missing', path: endpoint })})`), /Failed to open serial port/);
|
||||
// A deliberately nonexistent hardware port still takes the SerialPort path,
|
||||
// not the pipe adapter. This does not claim testing physical COM hardware.
|
||||
await assert.rejects(js(`window.netcatty.startSerialSession({sessionId:'missing-com',path:'COM999999',baudRate:9600})`), /Failed to open serial port/);
|
||||
assert.equal(sessions.size, 0);
|
||||
console.log('SERIAL_PIPE_ELECTRON_PASS: real Windows pipe, renderer/preload/IPC, UTF-8 I/O, reconnect, disconnect, missing pipe and COM error');
|
||||
}
|
||||
app.whenReady().then(main).then(() => finish(0), error => finish(1, error));
|
||||
}
|
||||
Reference in new issue
Block a user