From 43fca484e8a2b0a845c6af45221eeadcd520195f Mon Sep 17 00:00:00 2001 From: wj-xiao Date: Mon, 2 Feb 2026 17:44:36 +0800 Subject: [PATCH] fix(desktop): handle serial port device disconnection event --- desktop/src/common/ipc-events.ts | 1 + desktop/src/main/device/serial-port.ts | 76 +++++++++++++++---- desktop/src/main/events/serial-port.ts | 24 ++++-- desktop/src/renderer/src/App.tsx | 13 +--- .../index.tsx => device/connect.tsx} | 2 +- .../src/components/device/disconnect.tsx | 33 ++++++++ .../renderer/src/components/device/index.tsx | 20 +++++ .../{device-modal => device}/serial-port.tsx | 0 .../{device-modal => device}/video.tsx | 0 9 files changed, 139 insertions(+), 30 deletions(-) rename desktop/src/renderer/src/components/{device-modal/index.tsx => device/connect.tsx} (93%) create mode 100644 desktop/src/renderer/src/components/device/disconnect.tsx create mode 100644 desktop/src/renderer/src/components/device/index.tsx rename desktop/src/renderer/src/components/{device-modal => device}/serial-port.tsx (100%) rename desktop/src/renderer/src/components/{device-modal => device}/video.tsx (100%) diff --git a/desktop/src/common/ipc-events.ts b/desktop/src/common/ipc-events.ts index 2ad4431..b554167 100644 --- a/desktop/src/common/ipc-events.ts +++ b/desktop/src/common/ipc-events.ts @@ -9,6 +9,7 @@ export enum IpcEvents { OPEN_SERIAL_PORT = 'open-serial-port', OPEN_SERIAL_PORT_RSP = 'open-serial-port-rsp', CLOSE_SERIAL_PORT = 'close-serial-port', + SERIAL_PORT_DISCONNECTED = 'serial-port-disconnected', SEND_KEYBOARD = 'send-keyboard', SEND_MOUSE = 'send-mouse', diff --git a/desktop/src/main/device/serial-port.ts b/desktop/src/main/device/serial-port.ts index e9d2a6e..5c5c5c7 100644 --- a/desktop/src/main/device/serial-port.ts +++ b/desktop/src/main/device/serial-port.ts @@ -1,43 +1,86 @@ import { SerialPort as SP } from 'serialport' +type Options = { + path: string + baudRate?: number + onDisconnect?: () => void +} + export class SerialPort { - port: SP | null - readonly TIMEOUT = 500 // 500ms + readonly SERIAL_BAUD_RATE = 57600 + readonly READ_TIMEOUT = 500 + + private port: SP | null + private onDisconnect?: () => void constructor() { this.port = null } - async init( - path: string, - baudRate: number = 57600, - onOpen: (err: Error | null) => void - ): Promise { + async init(options: Options): Promise { try { if (this.port?.isOpen) { - console.log('Closing existing serial port before opening new one') await this.close() await new Promise((resolve) => setTimeout(resolve, 100)) } - console.log(`Opening serial port: ${path} at ${baudRate} baud`) + const path = options.path + const baudRate = options.baudRate || this.SERIAL_BAUD_RATE + this.port = new SP({ path, baudRate }, (err) => { if (err) { console.error('Error opening port: ', err.message) - } else { - console.log(`Serial port ${path} opened successfully at ${baudRate} baud`) + throw err } - onOpen(err) }) + + if (options.onDisconnect) { + this.onDisconnect = options.onDisconnect + } + + this.port.on('close', () => { + console.warn('Serial port closed event received') + this.handleDisconnect() + }) + + this.port.on('error', (err) => { + console.error('Serial port error:', err) + if (this.isDisconnectError(err)) { + this.handleDisconnect() + } + }) + + this.port.on('data', () => {}) } catch (err) { console.error('Error opening serial port:', err) throw err } } + private handleDisconnect(): void { + if (this.port) { + this.port.removeAllListeners() + this.port = null + } + + if (this.onDisconnect) { + this.onDisconnect() + this.onDisconnect = undefined + } + } + + private isDisconnectError(err: Error): boolean { + const msg = err.message.toLowerCase() + return ( + msg.includes('disconnected') || + msg.includes('device has been lost') || + msg.includes('has been closed') || + msg.includes('no such device') + ) + } + async write(data: number[]): Promise { if (!this.port?.isOpen) { - // throw new Error('Serial port not initialized') return } @@ -54,7 +97,7 @@ export class SerialPort { const startTime = Date.now() while (result.length < minSize) { - if (Date.now() - startTime > this.TIMEOUT) { + if (Date.now() - startTime > this.READ_TIMEOUT) { return [] } @@ -77,7 +120,8 @@ export class SerialPort { async close(): Promise { if (this.port?.isOpen) { try { - console.log('Closing serial port...') + this.port.removeAllListeners() + await new Promise((resolve, reject) => { this.port!.close((err) => { if (err) { @@ -89,6 +133,8 @@ export class SerialPort { } }) }) + + this.port = null } catch (error) { console.error('close-serial-port error', error) throw error diff --git a/desktop/src/main/events/serial-port.ts b/desktop/src/main/events/serial-port.ts index cf7246a..1e1a0b4 100644 --- a/desktop/src/main/events/serial-port.ts +++ b/desktop/src/main/events/serial-port.ts @@ -15,7 +15,16 @@ export function registerSerialPort(): void { async function getSerialPorts(): Promise { try { const ports = await SerialPort.list() - return ports.map((port) => port.path) + const paths = ports.map((port) => port.path) + + return paths.sort((a, b) => { + const aHasUSB = a.toLowerCase().includes('usb') + const bHasUSB = b.toLowerCase().includes('usb') + + if (aHasUSB && !bHasUSB) return -1 + if (!aHasUSB && bHasUSB) return 1 + return a.localeCompare(b) + }) } catch (error) { console.error('Error listing serial ports:', error) return [] @@ -28,13 +37,18 @@ async function openSerialPort( baudRate: number = 57600 ): Promise { try { - await device.serialPort.init(path, baudRate, (err) => { - const msg = err ? err.message : '' - e.sender.send(IpcEvents.OPEN_SERIAL_PORT_RSP, msg) - }) + const onDisconnect = () => { + e.sender.send(IpcEvents.SERIAL_PORT_DISCONNECTED) + } + + await device.serialPort.init({ path, baudRate, onDisconnect }) + + e.sender.send(IpcEvents.OPEN_SERIAL_PORT_RSP, '') return true } catch (error) { console.error('Error opening serial port:', error) + const errorMsg = error instanceof Error ? error.message : 'Unknown error' + e.sender.send(IpcEvents.OPEN_SERIAL_PORT_RSP, errorMsg) return false } } diff --git a/desktop/src/renderer/src/App.tsx b/desktop/src/renderer/src/App.tsx index fe35744..75477da 100644 --- a/desktop/src/renderer/src/App.tsx +++ b/desktop/src/renderer/src/App.tsx @@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next' import { useMediaQuery } from 'react-responsive' import { IpcEvents } from '@common/ipc-events' -import { DeviceModal } from '@renderer/components/device-modal' +import { Device } from '@renderer/components/device' import { Keyboard } from '@renderer/components/keyboard' import { Menu } from '@renderer/components/menu' import { Mouse } from '@renderer/components/mouse' @@ -38,7 +38,6 @@ const App = (): ReactElement => { const setResolution = useSetAtom(resolutionAtom) const [state, setState] = useState('loading') - const [isConnected, setIsConnected] = useState(false) useEffect(() => { const resolution = getVideoResolution() @@ -54,10 +53,6 @@ const App = (): ReactElement => { } }, []) - useEffect(() => { - setIsConnected(videoState === 'connected' && serialPortState === 'connected') - }, [videoState, serialPortState]) - async function requestMediaPermissions(resolution?: Resolution): Promise { try { const platform = await window.electron.ipcRenderer.invoke(IpcEvents.GET_PLATFORM) @@ -116,14 +111,14 @@ const App = (): ReactElement => { return ( <> - {isConnected ? ( + + + {videoState === 'connected' && serialPortState === 'connected' && ( <> {isKeyboardEnable && } - ) : ( - )}