mirror of
https://github.com/jetkvm/kvm.git
synced 2026-06-20 05:24:32 +00:00
* fix: add USB serial console toggle to hardware settings (#726) * fix: add USB CDC-ACM serial console gadget function (#726) Add serial_console.go with acm.usb0 gadget config item following the mass_storage pattern. Add SerialConsole bool to Devices struct and wire it through config.go enable check and jsonrpc.go setUsbDeviceState. The existing UI toggle in UsbDeviceSetting.tsx (with localization messages) now calls through to the backend correctly. When enabled, the KVM device creates /dev/ttyGS0 and the target host sees a CDC-ACM serial device (/dev/ttyACM*). When disabled, the symlink is removed from the USB gadget config and the host no longer enumerates the ACM interface. * fix: add CDC-ACM Console terminal UI for USB serial gadget (#726) * fix: merge terminal buttons into split button and rename CDC-ACM to USB Serial Console (#726) Combine KVM Terminal and USB Serial Console into a split button when both are present, make USB serial console state reactive via zustand store so the action bar updates without a page refresh, and fix the split button chevron not respecting the disabled state.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
package kvm
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
const cdcACMDevicePath = "/dev/ttyGS0"
|
||||
|
||||
func handleCDCACMChannel(d *webrtc.DataChannel) {
|
||||
scopedLogger := cdcACMLogger.With().
|
||||
Uint16("data_channel_id", *d.ID()).Logger()
|
||||
|
||||
var f *os.File
|
||||
d.OnOpen(func() {
|
||||
var err error
|
||||
f, err = os.OpenFile(cdcACMDevicePath, os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
scopedLogger.Warn().Err(err).Str("path", cdcACMDevicePath).Msg("Failed to open CDC-ACM device")
|
||||
d.Close()
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 1024)
|
||||
for {
|
||||
n, err := f.Read(buf)
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
scopedLogger.Warn().Err(err).Msg("Failed to read from CDC-ACM device")
|
||||
}
|
||||
break
|
||||
}
|
||||
if err := d.Send(buf[:n]); err != nil {
|
||||
scopedLogger.Warn().Err(err).Msg("Failed to send CDC-ACM output")
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
scopedLogger.Info().Msg("CDC-ACM console channel opened")
|
||||
})
|
||||
|
||||
d.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
if f == nil {
|
||||
return
|
||||
}
|
||||
if _, err := f.Write(msg.Data); err != nil {
|
||||
scopedLogger.Warn().Err(err).Msg("Failed to write to CDC-ACM device")
|
||||
}
|
||||
})
|
||||
|
||||
d.OnClose(func() {
|
||||
if f != nil {
|
||||
f.Close()
|
||||
}
|
||||
scopedLogger.Info().Msg("CDC-ACM console channel closed")
|
||||
})
|
||||
|
||||
d.OnError(func(err error) {
|
||||
scopedLogger.Warn().Err(err).Msg("CDC-ACM console channel error")
|
||||
})
|
||||
}
|
||||
@@ -59,6 +59,8 @@ var defaultGadgetConfig = map[string]gadgetConfigItem{
|
||||
// mass storage
|
||||
"mass_storage_base": massStorageBaseConfig,
|
||||
"mass_storage_lun0": massStorageLun0Config,
|
||||
// serial console (CDC-ACM)
|
||||
"serial_console": serialConsoleConfig,
|
||||
}
|
||||
|
||||
func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool {
|
||||
@@ -73,6 +75,8 @@ func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool {
|
||||
return u.enabledDevices.MassStorage
|
||||
case "mass_storage_lun0":
|
||||
return u.enabledDevices.MassStorage
|
||||
case "serial_console":
|
||||
return u.enabledDevices.SerialConsole
|
||||
default:
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package usbgadget
|
||||
|
||||
var serialConsoleConfig = gadgetConfigItem{
|
||||
order: 4000,
|
||||
device: "acm.usb0",
|
||||
path: []string{"functions", "acm.usb0"},
|
||||
configPath: []string{"acm.usb0"},
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type Devices struct {
|
||||
RelativeMouse bool `json:"relative_mouse"`
|
||||
Keyboard bool `json:"keyboard"`
|
||||
MassStorage bool `json:"mass_storage"`
|
||||
SerialConsole bool `json:"serial_console"`
|
||||
}
|
||||
|
||||
// Config is a struct that represents the customizations for a USB gadget.
|
||||
|
||||
@@ -907,6 +907,8 @@ func rpcSetUsbDeviceState(device string, enabled bool) error {
|
||||
config.UsbDevices.Keyboard = enabled
|
||||
case "massStorage":
|
||||
config.UsbDevices.MassStorage = enabled
|
||||
case "serialConsole":
|
||||
config.UsbDevices.SerialConsole = enabled
|
||||
default:
|
||||
return fmt.Errorf("invalid device: %s", device)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ var (
|
||||
otaLogger = logging.GetSubsystemLogger("ota")
|
||||
serialLogger = logging.GetSubsystemLogger("serial")
|
||||
terminalLogger = logging.GetSubsystemLogger("terminal")
|
||||
cdcACMLogger = logging.GetSubsystemLogger("cdcacm")
|
||||
displayLogger = logging.GetSubsystemLogger("display")
|
||||
wolLogger = logging.GetSubsystemLogger("wol")
|
||||
usbLogger = logging.GetSubsystemLogger("usb")
|
||||
|
||||
@@ -1101,6 +1101,149 @@ test.describe("Remote Host Agent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// USB SERIAL CONSOLE (CDC-ACM)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
test("usb: serial console CDC-ACM toggle creates and removes ttyACM on host", async () => {
|
||||
test.setTimeout(30_000);
|
||||
|
||||
const remoteHost = process.env.JETKVM_REMOTE_HOST;
|
||||
test.skip(!remoteHost, "JETKVM_REMOTE_HOST not set");
|
||||
|
||||
const sshTarget = remoteHost!.includes("@") ? remoteHost! : `tony@${remoteHost}`;
|
||||
const sshOpts = "-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o ConnectTimeout=10";
|
||||
const { execSync } = await import("child_process");
|
||||
|
||||
const remoteExec = (cmd: string) =>
|
||||
execSync(`ssh ${sshOpts} ${sshTarget} "${cmd}"`, { encoding: "utf8" }).trim();
|
||||
|
||||
// Ensure serial console is off initially
|
||||
await callJsonRpc(sharedPage, "setUsbDevices", {
|
||||
devices: { ...USB_DEVICES_DEFAULT, serial_console: false },
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
// Verify the host does NOT see a ttyACM device
|
||||
const beforeACM = remoteExec("ls /dev/ttyACM* 2>/dev/null || echo MISSING");
|
||||
expect(beforeACM).toBe("MISSING");
|
||||
|
||||
// Enable serial console
|
||||
await callJsonRpc(sharedPage, "setUsbDevices", {
|
||||
devices: { ...USB_DEVICES_DEFAULT, serial_console: true },
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
// Verify the host now sees a ttyACM device
|
||||
const afterACM = remoteExec("ls /dev/ttyACM* 2>/dev/null || echo MISSING");
|
||||
expect(afterACM).not.toBe("MISSING");
|
||||
|
||||
// Verify /dev/ttyGS0 exists on the KVM device
|
||||
const afterGS0 = (await sshExec("ls /dev/ttyGS0 2>/dev/null || echo MISSING", true)).trim();
|
||||
expect(afterGS0).toBe("/dev/ttyGS0");
|
||||
|
||||
// Disable serial console
|
||||
await callJsonRpc(sharedPage, "setUsbDevices", {
|
||||
devices: { ...USB_DEVICES_DEFAULT, serial_console: false },
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
// Verify the host no longer sees a ttyACM device
|
||||
const removedACM = remoteExec("ls /dev/ttyACM* 2>/dev/null || echo MISSING");
|
||||
expect(removedACM).toBe("MISSING");
|
||||
|
||||
// Verify other USB functions still work (keyboard, mouse)
|
||||
await agent!.waitForInputDevices(
|
||||
["keyboard", "absolute_mouse", "relative_mouse"],
|
||||
10000,
|
||||
);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// USB SERIAL CONSOLE UI
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
test("usb: USB Serial Console terminal sends and receives data via ttyGS0", async () => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const remoteHost = process.env.JETKVM_REMOTE_HOST;
|
||||
test.skip(!remoteHost, "JETKVM_REMOTE_HOST not set");
|
||||
|
||||
const sshTarget = remoteHost!.includes("@") ? remoteHost! : `tony@${remoteHost}`;
|
||||
const sshOpts = "-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o ConnectTimeout=10";
|
||||
const { execSync } = await import("child_process");
|
||||
|
||||
// Use single-quoted SSH commands to avoid nested quoting issues
|
||||
const remoteExec = (cmd: string) =>
|
||||
execSync(`ssh ${sshOpts} ${sshTarget} '${cmd}'`, { encoding: "utf8", timeout: 15_000 }).trim();
|
||||
|
||||
// Enable serial console
|
||||
await callJsonRpc(sharedPage, "setUsbDevices", {
|
||||
devices: { ...USB_DEVICES_DEFAULT, serial_console: true },
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
// Find the ttyACM device on the remote host
|
||||
const ttyACM = remoteExec("ls /dev/ttyACM* 2>/dev/null | head -1");
|
||||
expect(ttyACM).toContain("ttyACM");
|
||||
|
||||
// Reload the page so the action bar picks up serial_console enabled state
|
||||
await sharedPage.reload({ waitUntil: "networkidle" });
|
||||
await waitForWebRTCReady(sharedPage);
|
||||
|
||||
// Verify the USB Serial Console button is visible
|
||||
const cdcButton = sharedPage.getByRole("button", { name: "USB Serial Console" });
|
||||
await expect(cdcButton).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click the button to open the terminal
|
||||
await cdcButton.click();
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// Configure the remote serial port and start a background reader
|
||||
const testString = `e2e_test_${Date.now()}`;
|
||||
remoteExec(`sudo stty -F ${ttyACM} 9600 raw -echo`);
|
||||
remoteExec(`sudo bash -c "nohup cat ${ttyACM} > /tmp/cdcacm_rx.txt 2>/dev/null &"`);
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
|
||||
// Type a string into the USB Serial Console terminal
|
||||
// The terminal is focused after opening, so we type directly
|
||||
await sharedPage.keyboard.type(testString, { delay: 50 });
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
// Read what the remote host received
|
||||
const received = remoteExec("sudo cat /tmp/cdcacm_rx.txt 2>/dev/null || echo EMPTY");
|
||||
expect(received).toContain(testString);
|
||||
|
||||
// Test receiving data: send from remote host to ttyACM
|
||||
const replyString = `reply_${Date.now()}`;
|
||||
remoteExec(`sudo bash -c "echo ${replyString} > ${ttyACM}"`);
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
// Take a screenshot for visual review
|
||||
await sharedPage.screenshot({ path: `${process.cwd()}/screenshot.png` });
|
||||
|
||||
// Clean up: kill background cat, remove temp file
|
||||
remoteExec("sudo pkill -f cat.*/dev/ttyACM || true");
|
||||
remoteExec("sudo rm -f /tmp/cdcacm_rx.txt");
|
||||
|
||||
// Close the terminal
|
||||
await sharedPage.keyboard.press("Escape");
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
|
||||
// Disable serial console to clean up
|
||||
await callJsonRpc(sharedPage, "setUsbDevices", {
|
||||
devices: { ...USB_DEVICES_DEFAULT, serial_console: false },
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
// Verify button is gone after disabling
|
||||
await sharedPage.reload({ waitUntil: "networkidle" });
|
||||
await waitForWebRTCReady(sharedPage);
|
||||
await expect(
|
||||
sharedPage.getByRole("button", { name: "USB Serial Console" }),
|
||||
).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// USB RECOVERY
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
@@ -953,6 +953,8 @@
|
||||
"usb_device_enable_mass_storage_title": "Enable USB Mass Storage",
|
||||
"usb_device_enable_relative_mouse_description": "Enable Relative Mouse",
|
||||
"usb_device_enable_relative_mouse_title": "Enable Relative Mouse",
|
||||
"usb_device_enable_serial_console_description": "Exposes a USB serial (CDC-ACM) device to the target host",
|
||||
"usb_device_enable_serial_console_title": "Enable USB Serial Console",
|
||||
"usb_device_failed_load": "Failed to load USB devices: {error}",
|
||||
"usb_device_failed_set": "Failed to set USB devices: {error}",
|
||||
"usb_device_keyboard_mouse_and_mass_storage": "Keyboard, Mouse and Mass Storage",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useCallback, useRef } from "react";
|
||||
import { Fragment, useCallback, useEffect, useRef } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { MdOutlineContentPasteGo } from "react-icons/md";
|
||||
import {
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
LuScanText,
|
||||
LuSettings,
|
||||
LuSignal,
|
||||
LuTerminal,
|
||||
LuX,
|
||||
} from "react-icons/lu";
|
||||
import { FaKeyboard } from "react-icons/fa6";
|
||||
@@ -33,6 +34,7 @@ import PasteModal from "@components/popovers/PasteModal";
|
||||
import WakeOnLanModal from "@components/popovers/WakeOnLan/Index";
|
||||
import MountPopopover from "@components/popovers/MountPopover";
|
||||
import ExtensionPopover from "@components/popovers/ExtensionPopover";
|
||||
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
|
||||
import { m } from "@localizations/messages.js";
|
||||
|
||||
export default function Actionbar({
|
||||
@@ -53,11 +55,22 @@ export default function Actionbar({
|
||||
toggleSidebarView,
|
||||
isOcrMode,
|
||||
setOcrMode,
|
||||
usbSerialConsoleEnabled,
|
||||
setUsbSerialConsoleEnabled,
|
||||
} = useUiStore();
|
||||
const { remoteVirtualMediaState } = useMountMediaStore();
|
||||
const { width: videoWidth, height: videoHeight } = useVideoStore();
|
||||
const { developerMode } = useSettingsStore();
|
||||
const { openDetachedWindow } = useDetachedWindow();
|
||||
const { send } = useJsonRpc();
|
||||
|
||||
useEffect(() => {
|
||||
send("getUsbDevices", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) return;
|
||||
const devices = resp.result as { serial_console?: boolean };
|
||||
setUsbSerialConsoleEnabled(devices.serial_console === true);
|
||||
});
|
||||
}, [send, setUsbSerialConsoleEnabled]);
|
||||
|
||||
// This is the only way to get a reliable state change for the popover
|
||||
// at time of writing this there is no mount, or unmount event for the popover
|
||||
@@ -85,7 +98,25 @@ export default function Actionbar({
|
||||
className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-1.5"
|
||||
>
|
||||
<div className="relative flex flex-wrap items-center gap-x-2 gap-y-2">
|
||||
{developerMode && (
|
||||
{developerMode && usbSerialConsoleEnabled ? (
|
||||
<SplitButtonGroup>
|
||||
<SplitButtonPrimary
|
||||
icon={({ className }) => <CommandLineIcon className={className} />}
|
||||
label={m.kvm_terminal()}
|
||||
onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")}
|
||||
/>
|
||||
<SplitButtonCaret
|
||||
menuItems={[
|
||||
{
|
||||
label: "USB Serial Console",
|
||||
icon: LuTerminal,
|
||||
onClick: () => setTerminalType(terminalType === "cdcacm" ? "none" : "cdcacm"),
|
||||
active: terminalType === "cdcacm",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SplitButtonGroup>
|
||||
) : developerMode ? (
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
@@ -93,7 +124,15 @@ export default function Actionbar({
|
||||
LeadingIcon={({ className }) => <CommandLineIcon className={className} />}
|
||||
onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")}
|
||||
/>
|
||||
)}
|
||||
) : usbSerialConsoleEnabled ? (
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="USB Serial Console"
|
||||
LeadingIcon={LuTerminal}
|
||||
onClick={() => setTerminalType(terminalType === "cdcacm" ? "none" : "cdcacm")}
|
||||
/>
|
||||
) : null}
|
||||
<Popover>
|
||||
<SplitButtonGroup>
|
||||
<PopoverButton
|
||||
|
||||
@@ -74,6 +74,7 @@ export function SplitButtonCaret({ menuItems }: { menuItems: SplitButtonMenuItem
|
||||
lightHover,
|
||||
"inline-flex cursor-pointer items-center rounded-r-sm px-1",
|
||||
"border-l-slate-800/15 dark:border-l-slate-300/10",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<ChevronDownIcon className="size-3.5 text-black dark:text-white" />
|
||||
|
||||
@@ -144,7 +144,7 @@ function Terminal({
|
||||
);
|
||||
|
||||
const onDataHandler = instance.onData(data => {
|
||||
if (type === "kvm") {
|
||||
if (type === "kvm" || type === "cdcacm") {
|
||||
dataChannel.send(data);
|
||||
} else {
|
||||
if (data === "\r") {
|
||||
@@ -170,8 +170,8 @@ function Terminal({
|
||||
}
|
||||
});
|
||||
|
||||
// Send initial terminal size
|
||||
if (dataChannel.readyState === "open") {
|
||||
// Send initial terminal size (not applicable for CDC-ACM raw serial)
|
||||
if (dataChannel.readyState === "open" && type !== "cdcacm") {
|
||||
if (type === "kvm") {
|
||||
dataChannel.send(JSON.stringify({ rows: instance.rows, cols: instance.cols }));
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
|
||||
import { useUiStore } from "@hooks/stores";
|
||||
import { m } from "@localizations/messages.js";
|
||||
import { SettingsItem } from "@components/SettingsItem";
|
||||
import Checkbox from "@components/Checkbox";
|
||||
@@ -24,6 +25,7 @@ export interface UsbDeviceConfig {
|
||||
absolute_mouse: boolean;
|
||||
relative_mouse: boolean;
|
||||
mass_storage: boolean;
|
||||
serial_console: boolean;
|
||||
}
|
||||
|
||||
const defaultUsbDeviceConfig: UsbDeviceConfig = {
|
||||
@@ -31,6 +33,7 @@ const defaultUsbDeviceConfig: UsbDeviceConfig = {
|
||||
absolute_mouse: true,
|
||||
relative_mouse: true,
|
||||
mass_storage: true,
|
||||
serial_console: false,
|
||||
};
|
||||
|
||||
const usbPresets = [
|
||||
@@ -42,6 +45,7 @@ const usbPresets = [
|
||||
absolute_mouse: true,
|
||||
relative_mouse: true,
|
||||
mass_storage: true,
|
||||
serial_console: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -52,6 +56,7 @@ const usbPresets = [
|
||||
absolute_mouse: false,
|
||||
relative_mouse: false,
|
||||
mass_storage: false,
|
||||
serial_console: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -63,6 +68,7 @@ const usbPresets = [
|
||||
export function UsbDeviceSetting() {
|
||||
const { send } = useJsonRpc();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { setUsbSerialConsoleEnabled } = useUiStore();
|
||||
|
||||
const [usbDeviceConfig, setUsbDeviceConfig] = useState<UsbDeviceConfig>(defaultUsbDeviceConfig);
|
||||
const [selectedPreset, setSelectedPreset] = useState<string>("default");
|
||||
@@ -77,6 +83,7 @@ export function UsbDeviceSetting() {
|
||||
} else {
|
||||
const usbConfigState = resp.result as UsbDeviceConfig;
|
||||
setUsbDeviceConfig(usbConfigState);
|
||||
setUsbSerialConsoleEnabled(usbConfigState.serial_console);
|
||||
|
||||
// Set the appropriate preset based on current config
|
||||
const matchingPreset = usbPresets.find(
|
||||
@@ -93,7 +100,7 @@ export function UsbDeviceSetting() {
|
||||
setSelectedPreset(matchingPreset ? matchingPreset.value : "custom");
|
||||
}
|
||||
});
|
||||
}, [send]);
|
||||
}, [send, setUsbSerialConsoleEnabled]);
|
||||
|
||||
const handleUsbConfigChange = useCallback(
|
||||
(devices: UsbDeviceConfig) => {
|
||||
@@ -219,6 +226,17 @@ export function UsbDeviceSetting() {
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title={m.usb_device_enable_serial_console_title()}
|
||||
description={m.usb_device_enable_serial_console_description()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={usbDeviceConfig.serial_console}
|
||||
onChange={onUsbConfigItemChange("serial_console")}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex gap-x-2">
|
||||
<Button
|
||||
|
||||
@@ -40,7 +40,7 @@ const appendStatToMap = <T extends { timestamp: number }>(
|
||||
|
||||
// Constants and types
|
||||
export type AvailableSidebarViews = "connection-stats";
|
||||
export type AvailableTerminalTypes = "kvm" | "serial" | "none";
|
||||
export type AvailableTerminalTypes = "kvm" | "serial" | "cdcacm" | "none";
|
||||
|
||||
export interface User {
|
||||
sub: string;
|
||||
@@ -78,6 +78,9 @@ export interface UIState {
|
||||
|
||||
isOcrMode: boolean;
|
||||
setOcrMode: (enabled: boolean) => void;
|
||||
|
||||
usbSerialConsoleEnabled: boolean;
|
||||
setUsbSerialConsoleEnabled: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export const useUiStore = create<UIState>(set => ({
|
||||
@@ -111,6 +114,9 @@ export const useUiStore = create<UIState>(set => ({
|
||||
|
||||
isOcrMode: false,
|
||||
setOcrMode: (enabled: boolean) => set({ isOcrMode: enabled }),
|
||||
|
||||
usbSerialConsoleEnabled: false,
|
||||
setUsbSerialConsoleEnabled: (enabled: boolean) => set({ usbSerialConsoleEnabled: enabled }),
|
||||
}));
|
||||
|
||||
export interface RTCState {
|
||||
|
||||
@@ -868,6 +868,16 @@ export default function KvmIdRoute() {
|
||||
}
|
||||
}, [peerConnection, serialConsole]);
|
||||
|
||||
// CDC-ACM console data channel
|
||||
const [cdcACMConsole, setCdcACMConsole] = useState<RTCDataChannel | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!peerConnection) return;
|
||||
if (!cdcACMConsole) {
|
||||
setCdcACMConsole(peerConnection.createDataChannel("cdcacm"));
|
||||
}
|
||||
}, [peerConnection, cdcACMConsole]);
|
||||
|
||||
// Register E2E test hooks
|
||||
useEffect(() => {
|
||||
registerTestHandlers({
|
||||
@@ -1060,6 +1070,10 @@ export default function KvmIdRoute() {
|
||||
{serialConsole && (
|
||||
<Terminal type="serial" dataChannel={serialConsole} title={m.serial_console()} />
|
||||
)}
|
||||
|
||||
{cdcACMConsole && (
|
||||
<Terminal type="cdcacm" dataChannel={cdcACMConsole} title="USB Serial Console" />
|
||||
)}
|
||||
</FeatureFlagProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user