feat: convert serial console into the serial plugin

This commit is contained in:
LukeGus committed 2026-09-23 23:06:37 -05:00
1 parent 708eedb861
commit 791d8656a1
121 files changed
+2216 -1425

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { createSerialSession } from "./session.js";
export async function activate(ctx: PluginContext): Promise<void> {
ctx.ws.route("/console", createSerialSession(ctx));
ctx.log.info("Serial console mounted at /plugin-ws/serial/console");
}
export async function deactivate(): Promise<void> {
// The WS route and every open socket are torn down by core: see
// src/backend/plugins/ws.ts. Nothing here outlives activate().
}
+169
View File
@@ -0,0 +1,169 @@
import type { WebSocket } from "ws";
import { SerialPort } from "serialport";
import type {
PluginContext,
PluginWebSocketConnection,
} from "@termix/plugin-sdk/backend";
import { parseWsMessage } from "./ws-message.js";
interface SerialConnectData {
path: string;
baudRate: number;
dataBits?: 5 | 6 | 7 | 8;
stopBits?: 1 | 2;
parity?: "none" | "even" | "odd";
}
/**
* Handles one /plugin-ws/serial/console connection. Auth already ran (core's
* ctx.ws.route authenticates before this is called), so every message here
* only has to decide whether to touch the actual hardware.
*/
export function createSerialSession(ctx: PluginContext) {
return async (connection: PluginWebSocketConnection): Promise<void> => {
const ws = connection.socket as WebSocket;
let port: SerialPort | null = null;
const send = (msg: object) => {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify(msg));
}
};
const cleanup = () => {
if (port?.isOpen) {
port.close();
}
port = null;
};
if (!connection.isDataUnlocked()) {
send({ type: "error", data: "Data locked" });
ws.close(1008, "Data access required");
return;
}
ws.on("message", async (raw) => {
let type: string;
let data: unknown;
try {
({ type, data } = parseWsMessage(raw));
} catch {
return;
}
if (!connection.isDataUnlocked()) {
send({ type: "error", data: "Data access expired" });
ws.close(1008, "Data access expired");
return;
}
try {
switch (type) {
case "list_ports": {
await ctx.capabilities.require("device:serial");
try {
const ports = await SerialPort.list();
send({ type: "ports_list", data: ports });
} catch (err) {
send({
type: "error",
data: errorMessage(err, "Failed to list ports"),
});
}
break;
}
case "connect": {
await ctx.capabilities.require("device:serial");
if (port?.isOpen) {
port.close();
port = null;
}
const cfg = data as SerialConnectData;
if (!cfg?.path || !cfg?.baudRate) {
send({ type: "error", data: "Missing port path or baud rate" });
break;
}
try {
const opened = new SerialPort({
path: cfg.path,
baudRate: cfg.baudRate,
dataBits: cfg.dataBits ?? 8,
stopBits: cfg.stopBits ?? 1,
parity: cfg.parity ?? "none",
autoOpen: false,
});
port = opened;
opened.open((err) => {
if (err) {
ctx.log.error(`Serial port open failed for ${cfg.path}`, err);
send({ type: "error", data: err.message });
port = null;
return;
}
ctx.log.info(
`Serial port opened: ${cfg.path} at ${cfg.baudRate} baud`,
);
send({ type: "connected" });
});
opened.on("data", (chunk: Buffer) => {
send({ type: "data", data: chunk.toString("binary") });
});
opened.on("error", (err) => {
send({ type: "error", data: err.message });
});
opened.on("close", () => {
send({ type: "disconnected" });
port = null;
});
} catch (err) {
send({
type: "error",
data: errorMessage(err, "Failed to open serial port"),
});
}
break;
}
case "input": {
if (!port?.isOpen) break;
const input = typeof data === "string" ? data : "";
if (!input) break;
port.write(Buffer.from(input, "binary"), (err) => {
if (err) send({ type: "error", data: err.message });
});
break;
}
case "disconnect": {
cleanup();
send({ type: "disconnected" });
break;
}
}
} catch (err) {
ctx.log.error("Error handling serial WebSocket message", err);
send({
type: "error",
data:
err instanceof Error ? err.message : "Failed to process message",
});
}
});
ws.on("close", cleanup);
ws.on("error", cleanup);
};
}
function errorMessage(err: unknown, fallback: string): string {
return err instanceof Error ? err.message : fallback;
}
+38
View File
@@ -0,0 +1,38 @@
import type { RawData } from "ws";
// Control messages here are tiny (a path, a baud rate) or raw terminal input,
// bounded by what a user can type or paste, so anything larger is abuse.
const MAX_MESSAGE_BYTES = 1024 * 1024;
function rawByteLength(raw: RawData): number {
if (Buffer.isBuffer(raw)) return raw.length;
if (Array.isArray(raw))
return raw.reduce((sum, part) => sum + part.length, 0);
if (raw instanceof ArrayBuffer) return raw.byteLength;
return 0;
}
/** Parses a WebSocket frame into `{ type, data }`, throwing on anything malformed. */
export function parseWsMessage(raw: RawData): { type: string; data: unknown } {
if (rawByteLength(raw) > MAX_MESSAGE_BYTES) {
throw new Error("Message too large");
}
let parsed: unknown;
try {
parsed = JSON.parse(raw.toString());
} catch {
throw new Error("Invalid JSON");
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Message must be a JSON object");
}
const { type, data } = parsed as { type?: unknown; data?: unknown };
if (typeof type !== "string") {
throw new Error("Message type must be a string");
}
return { type, data };
}
+339
View File
@@ -0,0 +1,339 @@
import {
useEffect,
useRef,
useCallback,
forwardRef,
useImperativeHandle,
} from "react";
import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
import { TriangleAlert } from "lucide-react";
import { useTranslation, useTheme } from "@termix/plugin-sdk/frontend";
import type { SerialConfig, SerialHandle } from "./types.js";
import { isElectron } from "./electron.js";
import { resolveSerialWsUrl } from "./transport.js";
const XTERM_THEME: Record<"light" | "dark", Record<string, string>> = {
dark: {
background: "#0b0e14",
foreground: "#d4d7de",
cursor: "#d4d7de",
},
light: {
background: "#ffffff",
foreground: "#1a1a1a",
cursor: "#1a1a1a",
},
};
type WebSerialPort = {
open(options: {
baudRate: number;
dataBits?: number;
stopBits?: number;
parity?: string;
}): Promise<void>;
close(): Promise<void>;
readable: ReadableStream<Uint8Array> | null;
writable: WritableStream<Uint8Array> | null;
};
interface SerialProps {
config: SerialConfig;
isVisible: boolean;
instanceId: string;
}
export const Serial = forwardRef<SerialHandle, SerialProps>(function Serial(
{ config, isVisible, instanceId },
ref,
) {
const { t } = useTranslation();
const { theme: appTheme } = useTheme();
const { instance: terminal, ref: xtermRef } = useXTerm();
const fitAddonRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const connectedRef = useRef(false);
const webSerialReaderRef = useRef<ReadableStreamDefaultReader | null>(null);
const webSerialWriterRef = useRef<WritableStreamDefaultWriter | null>(null);
const webSerialPortRef = useRef<WebSerialPort | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const useWebSerial = !isElectron();
const write = useCallback(
(text: string) => {
terminal?.write(text);
},
[terminal],
);
useEffect(() => {
if (!terminal) return;
terminal.options.theme = XTERM_THEME[appTheme];
terminal.options.fontFamily =
'"SF Mono", Consolas, "Liberation Mono", monospace';
terminal.options.fontSize = 14;
}, [terminal, appTheme]);
// ── WebSocket (Electron) path ──────────────────────────────────────────
const disconnectWs = useCallback(() => {
if (wsRef.current) {
wsRef.current.onclose = null;
wsRef.current.close();
wsRef.current = null;
}
connectedRef.current = false;
}, []);
const connectWs = useCallback(async () => {
disconnectWs();
const target = await resolveSerialWsUrl();
if (!target) {
write(`\r\n\x1b[31m${t("serial.errorNoServerUrl")}\x1b[0m\r\n`);
return;
}
const ws = new WebSocket(target.url, target.protocols);
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`);
};
}, [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<WebSerialPort>;
};
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 {
void 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]);
const reconnect = useCallback(() => {
disconnect();
connect();
}, [disconnect, connect]);
useImperativeHandle(ref, () => ({
connect,
disconnect,
reconnect,
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 = true;
terminal.options.scrollback = 10000;
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 (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
<TriangleAlert className="size-5 text-muted-foreground/50" />
</div>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-foreground">
{t("serial.notSupportedTitle")}
</span>
<span className="text-xs text-muted-foreground max-w-xs">
{t("serial.notSupported")}
</span>
</div>
</div>
);
}
return (
<div ref={containerRef} className="flex h-full w-full">
<div ref={xtermRef} className="flex-1 min-h-0" />
</div>
);
});
+325
View File
@@ -0,0 +1,325 @@
import { useState, useEffect, useCallback } from "react";
import { RefreshCw, Usb, TriangleAlert } from "lucide-react";
import { Input, Select2 } from "@termix/plugin-sdk/ui";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { SerialConfig } from "./types.js";
import { isElectron } from "./electron.js";
import { resolveSerialWsUrl } from "./transport.js";
const BAUD_RATES = [
300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800,
921600,
];
interface SerialPanelProps {
onConnect: (config: SerialConfig) => void;
}
// In Chrome/Edge the Web Serial API shows its own native port picker on requestPort(),
// so getPorts() only returns previously-granted ports. We never need the user to type
// a path — the browser handles selection at connect time.
const useWebSerial = !isElectron() && "serial" in navigator;
const serialUnsupported = !isElectron() && !("serial" in navigator);
export function SerialPanel({ onConnect }: SerialPanelProps) {
const { t } = useTranslation();
const [path, setPath] = useState("");
const [baudRate, setBaudRate] = useState(115200);
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(8);
const [stopBits, setStopBits] = useState<1 | 2>(1);
const [parity, setParity] = useState<"none" | "even" | "odd">("none");
// Electron only: ports discovered from the backend
const [availablePorts, setAvailablePorts] = useState<string[]>([]);
const [loadingPorts, setLoadingPorts] = useState(false);
const refreshPorts = useCallback(async () => {
if (!isElectron()) return;
setLoadingPorts(true);
const target = await resolveSerialWsUrl();
if (!target) {
setLoadingPorts(false);
return;
}
const ws = new WebSocket(target.url, target.protocols);
ws.onopen = () => ws.send(JSON.stringify({ type: "list_ports" }));
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data as string) as {
type: string;
data?: { path: string }[];
};
if (msg.type === "ports_list" && Array.isArray(msg.data)) {
const paths = msg.data.map((p) => p.path);
setAvailablePorts(paths);
setPath((current) => current || (paths[0] ?? current));
}
} catch {
// ignore
} finally {
ws.close();
setLoadingPorts(false);
}
};
ws.onerror = () => setLoadingPorts(false);
}, []);
useEffect(() => {
if (isElectron()) void refreshPorts();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const connect = () => {
// For Web Serial, path is irrelevant — the browser picker handles it.
// We pass an empty string; Serial.tsx calls requestPort() itself.
if (useWebSerial) {
onConnect({ path: "", baudRate, dataBits, stopBits, parity });
return;
}
const finalPath = path.trim();
if (!finalPath) return;
onConnect({ path: finalPath, baudRate, dataBits, stopBits, parity });
};
// ── Not supported (Firefox, Safari, etc.) ─────────────────────────────
if (serialUnsupported) {
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col gap-3 p-3">
<div className="flex flex-col gap-2 rounded border border-destructive/30 bg-destructive/5 p-3">
<div className="flex items-center gap-2">
<TriangleAlert className="size-3.5 text-destructive shrink-0" />
<span className="text-xs font-semibold text-destructive">
{t("serial.notSupportedTitle")}
</span>
</div>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t("serial.notSupported")}
</p>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t("serial.hideHint")}
</p>
</div>
</div>
</div>
);
}
// ── Web Serial (Chrome / Edge) ─────────────────────────────────────────
if (useWebSerial) {
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col gap-3 p-3">
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t("serial.browserPickerHint")}
</p>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.baudRateLabel")}
</label>
<Select2
value={baudRate}
onChange={(e) => setBaudRate(Number(e.target.value))}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{BAUD_RATES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</Select2>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.dataBitsLabel")}
</label>
<Select2
value={dataBits}
onChange={(e) =>
setDataBits(Number(e.target.value) as 5 | 6 | 7 | 8)
}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{([5, 6, 7, 8] as const).map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.stopBitsLabel")}
</label>
<Select2
value={stopBits}
onChange={(e) => setStopBits(Number(e.target.value) as 1 | 2)}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value={1}>1</option>
<option value={2}>2</option>
</Select2>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.parityLabel")}
</label>
<Select2
value={parity}
onChange={(e) =>
setParity(e.target.value as "none" | "even" | "odd")
}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="none">{t("serial.parityNone")}</option>
<option value="even">{t("serial.parityEven")}</option>
<option value="odd">{t("serial.parityOdd")}</option>
</Select2>
</div>
</div>
<button
onClick={connect}
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors mt-1"
>
<Usb className="size-3.5" />
{t("serial.connect")}
</button>
</div>
</div>
);
}
// ── Electron (backend serialport) ─────────────────────────────────────
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col gap-3 p-3">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.portLabel")}
</label>
<button
onClick={() => void refreshPorts()}
className="text-muted-foreground hover:text-foreground transition-colors"
title={t("serial.refreshPorts")}
>
<RefreshCw
className={`size-3 ${loadingPorts ? "animate-spin" : ""}`}
/>
</button>
</div>
{availablePorts.length > 0 && (
<Select2
value={path}
onChange={(e) => setPath(e.target.value)}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("serial.portPlaceholder")}</option>
{availablePorts.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</Select2>
)}
<Input
placeholder={t("serial.portPlaceholder")}
value={path}
onChange={(e) => setPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") connect();
}}
className="h-7 text-xs font-mono"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.baudRateLabel")}
</label>
<Select2
value={baudRate}
onChange={(e) => setBaudRate(Number(e.target.value))}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{BAUD_RATES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</Select2>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.dataBitsLabel")}
</label>
<Select2
value={dataBits}
onChange={(e) =>
setDataBits(Number(e.target.value) as 5 | 6 | 7 | 8)
}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{([5, 6, 7, 8] as const).map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.stopBitsLabel")}
</label>
<Select2
value={stopBits}
onChange={(e) => setStopBits(Number(e.target.value) as 1 | 2)}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value={1}>1</option>
<option value={2}>2</option>
</Select2>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.parityLabel")}
</label>
<Select2
value={parity}
onChange={(e) =>
setParity(e.target.value as "none" | "even" | "odd")
}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="none">{t("serial.parityNone")}</option>
<option value="even">{t("serial.parityEven")}</option>
<option value="odd">{t("serial.parityOdd")}</option>
</Select2>
</div>
</div>
<button
onClick={connect}
disabled={!path.trim()}
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed mt-1"
>
<Usb className="size-3.5" />
{t("serial.connect")}
</button>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
type ElectronWindow = Window &
typeof globalThis & {
IS_ELECTRON?: boolean;
electronAPI?: { isElectron?: boolean };
};
/** Serial is always local: the device is physically attached to this desktop machine. */
export function isElectron(): boolean {
if (typeof window === "undefined") return false;
const win = window as ElectronWindow;
return (
win.IS_ELECTRON === true ||
!!win.electronAPI ||
win.electronAPI?.isElectron === true
);
}
+85
View File
@@ -0,0 +1,85 @@
import type { Ref } from "react";
import { Usb } from "lucide-react";
import type {
PanelProps,
PluginHostRecord,
TabProps,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import { Serial } from "./Serial.js";
import { SerialPanel } from "./SerialPanel.js";
import { setSerialWsUrl } from "./transport.js";
import type { SerialConfig, SerialHandle } from "./types.js";
/**
* A per-connection synthetic host, never a real DB host. The shell only
* opens a genuinely hostless (host: null) tab as a singleton, and a serial
* console is not one: a user can have several open at once, each with its
* own device and settings. A distinct id per connection is what keeps them
* separate tabs.
*/
function buildPseudoHost(config: SerialConfig): PluginHostRecord {
return {
id: `serial-${Date.now()}`,
name: config.path
? `${config.path} (${config.baudRate})`
: `Serial (${config.baudRate})`,
ip: "",
port: 0,
};
}
function SerialPanelView({ shell }: PanelProps) {
return (
<SerialPanel
onConnect={(config) => {
shell.openTab(buildPseudoHost(config), "serial", {
forceNewTab: true,
data: { serialConfig: config },
});
}}
/>
);
}
function SerialTabView({ tab, isVisible, handleRef }: TabProps) {
const config = tab.data?.serialConfig as SerialConfig | undefined;
if (!config) return null;
return (
<Serial
ref={handleRef as Ref<SerialHandle>}
config={config}
isVisible={isVisible}
instanceId={tab.id}
/>
);
}
export function activate(app: TermixApp): void {
setSerialWsUrl(app.wsUrl);
app.onDispose(() => setSerialWsUrl(null));
app.registerRailItem({
id: "serial",
icon: Usb,
titleKey: "nav.serial",
after: "quick-connect",
separatorAfter: true,
});
app.registerPanel("serial", SerialPanelView);
app.registerTab("serial", SerialTabView, {
icon: Usb,
titleKey: "nav.serial",
session: true,
// A physical device attached to this machine, not a remote connection
// another user could join.
shareable: false,
// Reconnecting to a COM port automatically after a restart is not a
// workflow anyone asked for, and there's no host to restore against
// anyway, so a serial tab is a local, ephemeral session like the
// desktop app's local terminal rather than something workspaces saves.
inLayouts: false,
});
}
+23
View File
@@ -0,0 +1,23 @@
import type { TermixApp } from "@termix/plugin-sdk/frontend";
/**
* The live app.wsUrl, for components that are defined at module scope rather
* than inside activate(). Set on activate, cleared on dispose, the same
* pattern the tunnels plugin uses for app.api.
*/
let wsUrl: TermixApp["wsUrl"] | null = null;
export function setSerialWsUrl(next: TermixApp["wsUrl"] | null): void {
wsUrl = next;
}
/**
* The console's WebSocket URL and auth subprotocols. No origin option: a
* serial device is physically attached to this desktop machine, so it
* always dials the embedded local backend rather than a connected remote
* sync server (app.wsUrl's default when no origin is given).
*/
export async function resolveSerialWsUrl() {
if (!wsUrl) return null;
return wsUrl("/console");
}
+15
View File
@@ -0,0 +1,15 @@
export type SerialConfig = {
path: string;
baudRate: number;
dataBits: 5 | 6 | 7 | 8;
stopBits: 1 | 2;
parity: "none" | "even" | "odd";
};
export interface SerialHandle {
connect: () => void;
disconnect: () => void;
reconnect: () => void;
isConnected: () => boolean;
sendInput: (data: string) => void;
}