import { useEffect, useRef, useCallback, forwardRef, useImperativeHandle, } from "react"; import { useXTerm } from "react-xtermjs"; import { FitAddon } from "@xterm/addon-fit"; import { useTranslation } from "react-i18next"; import { TriangleAlert } from "lucide-react"; import { isElectron } from "@/lib/electron"; import { useTheme } from "@/components/theme-provider"; import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme"; import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes"; import { ensureTerminalFontsLoaded } from "@/features/terminal/terminal-global-styles"; import type { SerialConfig } from "@/types/ui-types"; import type { SerialHandle } from "./serial-types"; type WebSerialPort = { open(options: { baudRate: number; dataBits?: number; stopBits?: number; parity?: string; }): Promise; close(): Promise; readable: ReadableStream | null; writable: WritableStream | null; }; interface SerialProps { config: SerialConfig; isVisible: boolean; instanceId: string; } export const Serial = forwardRef(function Serial( { config, isVisible, instanceId }, ref, ) { const { t } = useTranslation(); const { theme: appTheme } = useTheme(); const { instance: terminal, ref: xtermRef } = useXTerm(); const fitAddonRef = useRef(null); const wsRef = useRef(null); const connectedRef = useRef(false); const webSerialReaderRef = useRef(null); const webSerialWriterRef = useRef(null); const webSerialPortRef = useRef(null); const containerRef = useRef(null); const useWebSerial = !isElectron(); const write = useCallback( (text: string) => { terminal?.write(text); }, [terminal], ); // ── Theme sync ───────────────────────────────────────────────────────── useEffect(() => { if (!terminal) return; const themeColors = resolveTermixThemeColors("termix", appTheme); const fontConfig = TERMINAL_FONTS.find( (f) => f.value === DEFAULT_TERMINAL_CONFIG.fontFamily, ); ensureTerminalFontsLoaded(fontConfig?.value ?? TERMINAL_FONTS[0].value); terminal.options.theme = { background: themeColors.background, foreground: themeColors.foreground, cursor: themeColors.cursor, cursorAccent: themeColors.cursorAccent, selectionBackground: themeColors.selectionBackground, selectionForeground: themeColors.selectionForeground, black: themeColors.black, red: themeColors.red, green: themeColors.green, yellow: themeColors.yellow, blue: themeColors.blue, magenta: themeColors.magenta, cyan: themeColors.cyan, white: themeColors.white, brightBlack: themeColors.brightBlack, brightRed: themeColors.brightRed, brightGreen: themeColors.brightGreen, brightYellow: themeColors.brightYellow, brightBlue: themeColors.brightBlue, brightMagenta: themeColors.brightMagenta, brightCyan: themeColors.brightCyan, brightWhite: themeColors.brightWhite, }; terminal.options.fontFamily = fontConfig?.fallback ?? TERMINAL_FONTS[0].fallback; terminal.options.fontSize = DEFAULT_TERMINAL_CONFIG.fontSize; }, [terminal, appTheme]); // ── WebSocket (Electron) path ────────────────────────────────────────── const buildWsUrl = useCallback(() => { // Serial is always local -- the device is physically attached to this // desktop machine, so it never routes through a remote server. const token = localStorage.getItem("jwt"); const base = "ws://127.0.0.1:30011"; return token ? `${base}?token=${encodeURIComponent(token)}` : base; }, []); const disconnectWs = useCallback(() => { if (wsRef.current) { wsRef.current.onclose = null; wsRef.current.close(); wsRef.current = null; } connectedRef.current = false; }, []); const connectWs = useCallback(() => { disconnectWs(); const url = buildWsUrl(); if (!url) { write(`\r\n\x1b[31m${t("serial.errorNoServerUrl")}\x1b[0m\r\n`); return; } const ws = new WebSocket(url); wsRef.current = ws; ws.onopen = () => { ws.send(JSON.stringify({ type: "connect", data: config })); }; ws.onmessage = (ev) => { try { const msg = JSON.parse(ev.data as string) as { type: string; data?: unknown; }; switch (msg.type) { case "connected": connectedRef.current = true; write( `\x1b[32m${t("serial.connected", { path: config.path, baud: config.baudRate })}\x1b[0m\r\n`, ); break; case "data": if (typeof msg.data === "string") write(msg.data); break; case "disconnected": connectedRef.current = false; write(`\r\n\x1b[33m${t("serial.disconnected")}\x1b[0m\r\n`); break; case "error": write( `\r\n\x1b[31m${t("serial.connectionError")}: ${msg.data as string}\x1b[0m\r\n`, ); break; } } catch { // ignore malformed messages } }; ws.onclose = () => { connectedRef.current = false; }; ws.onerror = () => { write(`\r\n\x1b[31m${t("serial.wsError")}\x1b[0m\r\n`); }; }, [buildWsUrl, config, disconnectWs, t, write]); // ── Web Serial API path ──────────────────────────────────────────────── const disconnectWebSerial = useCallback(async () => { try { webSerialReaderRef.current?.cancel(); webSerialWriterRef.current?.releaseLock(); await webSerialPortRef.current?.close(); } catch { // best-effort } webSerialReaderRef.current = null; webSerialWriterRef.current = null; webSerialPortRef.current = null; connectedRef.current = false; }, []); const connectWebSerial = useCallback(async () => { await disconnectWebSerial(); if (!("serial" in navigator)) { write(`\r\n\x1b[31m${t("serial.notSupported")}\x1b[0m\r\n`); return; } try { const serial = navigator.serial as { requestPort(): Promise; }; const port = await serial.requestPort(); await port.open({ baudRate: config.baudRate, dataBits: config.dataBits, stopBits: config.stopBits, parity: config.parity === "none" ? "none" : config.parity, }); webSerialPortRef.current = port; connectedRef.current = true; write( `\x1b[32m${t("serial.connected", { path: config.path || "serial", baud: config.baudRate })}\x1b[0m\r\n`, ); const reader = port.readable!.getReader(); webSerialReaderRef.current = reader; const decoder = new TextDecoder(); (async () => { try { for (;;) { const { value, done } = await reader.read(); if (done) break; write(decoder.decode(value)); } } catch { // port closed } finally { reader.releaseLock(); connectedRef.current = false; write(`\r\n\x1b[33m${t("serial.disconnected")}\x1b[0m\r\n`); } })(); const writer = port.writable!.getWriter(); webSerialWriterRef.current = writer; } catch (err) { if (err instanceof Error && err.name !== "NotFoundError") { write( `\r\n\x1b[31m${t("serial.connectionError")}: ${err.message}\x1b[0m\r\n`, ); } } }, [config, disconnectWebSerial, t, write]); // ── Unified connect/disconnect ───────────────────────────────────────── const connect = useCallback(() => { if (useWebSerial) { connectWebSerial(); } else { connectWs(); } }, [useWebSerial, connectWebSerial, connectWs]); const disconnect = useCallback(() => { if (useWebSerial) { disconnectWebSerial(); } else { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: "disconnect" })); } disconnectWs(); } }, [useWebSerial, disconnectWebSerial, disconnectWs]); useImperativeHandle(ref, () => ({ connect, disconnect, isConnected: () => connectedRef.current, sendInput: (data: string) => { if (useWebSerial) { const encoder = new TextEncoder(); webSerialWriterRef.current?.write(encoder.encode(data)).catch(() => {}); } else if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: "input", data })); } }, })); // ── Terminal setup ───────────────────────────────────────────────────── useEffect(() => { if (!terminal) return; const fitAddon = new FitAddon(); fitAddonRef.current = fitAddon; terminal.loadAddon(fitAddon); terminal.options.cursorBlink = DEFAULT_TERMINAL_CONFIG.cursorBlink; terminal.options.scrollback = DEFAULT_TERMINAL_CONFIG.scrollback; terminal.onData((data) => { if (!connectedRef.current) return; if (useWebSerial) { const encoder = new TextEncoder(); webSerialWriterRef.current?.write(encoder.encode(data)).catch(() => {}); } else if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: "input", data })); } }); connect(); return () => { disconnect(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [terminal, instanceId]); // ── Fit on visibility change ─────────────────────────────────────────── useEffect(() => { if (!isVisible || !fitAddonRef.current) return; try { fitAddonRef.current.fit(); } catch { // ignore } }, [isVisible]); // ── ResizeObserver ───────────────────────────────────────────────────── useEffect(() => { if (!containerRef.current || !terminal) return; const observer = new ResizeObserver(() => { try { fitAddonRef.current?.fit(); } catch { // ignore } }); observer.observe(containerRef.current); return () => observer.disconnect(); }, [terminal]); // ── Not supported (Firefox etc.) ────────────────────────────────────── if (useWebSerial && !("serial" in navigator)) { return (
{t("serial.notSupportedTitle")} {t("serial.notSupported")}
); } return (
); });