mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: convert serial console into the serial plugin
This commit is contained in:
1 parent
708eedb861
commit
791d8656a1
121 files changed
+2216
-1425
No files matched your search
@@ -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().
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user