From 0c5c69f2d39681c92741ec8551c4dbf0f82e2709 Mon Sep 17 00:00:00 2001
From: Aveline <352441+ym@users.noreply.github.com>
Date: Fri, 23 May 2025 00:12:18 +0200
Subject: [PATCH 01/11] feat: sync keyboard led status (#502)
---
internal/usbgadget/hid_keyboard.go | 142 ++++++++++++++++++++++++--
internal/usbgadget/usbgadget.go | 39 ++++---
jsonrpc.go | 1 +
ui/src/components/InfoBar.tsx | 20 ++--
ui/src/components/VirtualKeyboard.tsx | 27 ++---
ui/src/components/WebRTCVideo.tsx | 18 ----
ui/src/hooks/stores.ts | 27 ++---
ui/src/routes/devices.$id.tsx | 22 ++++
usb.go | 15 +++
9 files changed, 236 insertions(+), 75 deletions(-)
diff --git a/internal/usbgadget/hid_keyboard.go b/internal/usbgadget/hid_keyboard.go
index de007e4..12b0de9 100644
--- a/internal/usbgadget/hid_keyboard.go
+++ b/internal/usbgadget/hid_keyboard.go
@@ -1,8 +1,11 @@
package usbgadget
import (
+ "context"
"fmt"
"os"
+ "reflect"
+ "time"
)
var keyboardConfig = gadgetConfigItem{
@@ -36,6 +39,7 @@ var keyboardReportDesc = []byte{
0x81, 0x03, /* INPUT (Cnst,Var,Abs) */
0x95, 0x05, /* REPORT_COUNT (5) */
0x75, 0x01, /* REPORT_SIZE (1) */
+
0x05, 0x08, /* USAGE_PAGE (LEDs) */
0x19, 0x01, /* USAGE_MINIMUM (Num Lock) */
0x29, 0x05, /* USAGE_MAXIMUM (Kana) */
@@ -54,13 +58,139 @@ var keyboardReportDesc = []byte{
0xc0, /* END_COLLECTION */
}
-func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
- if u.keyboardHidFile == nil {
- var err error
- u.keyboardHidFile, err = os.OpenFile("/dev/hidg0", os.O_RDWR, 0666)
- if err != nil {
- return fmt.Errorf("failed to open hidg0: %w", err)
+const (
+ hidReadBufferSize = 8
+ // https://www.usb.org/sites/default/files/documents/hid1_11.pdf
+ // https://www.usb.org/sites/default/files/hut1_2.pdf
+ KeyboardLedMaskNumLock = 1 << 0
+ KeyboardLedMaskCapsLock = 1 << 1
+ KeyboardLedMaskScrollLock = 1 << 2
+ KeyboardLedMaskCompose = 1 << 3
+ KeyboardLedMaskKana = 1 << 4
+ ValidKeyboardLedMasks = KeyboardLedMaskNumLock | KeyboardLedMaskCapsLock | KeyboardLedMaskScrollLock | KeyboardLedMaskCompose | KeyboardLedMaskKana
+)
+
+// Synchronization between LED states and CAPS LOCK, NUM LOCK, SCROLL LOCK,
+// COMPOSE, and KANA events is maintained by the host and NOT the keyboard. If
+// using the keyboard descriptor in Appendix B, LED states are set by sending a
+// 5-bit absolute report to the keyboard via a Set_Report(Output) request.
+type KeyboardState struct {
+ NumLock bool `json:"num_lock"`
+ CapsLock bool `json:"caps_lock"`
+ ScrollLock bool `json:"scroll_lock"`
+ Compose bool `json:"compose"`
+ Kana bool `json:"kana"`
+}
+
+func getKeyboardState(b byte) KeyboardState {
+ // should we check if it's the correct usage page?
+ return KeyboardState{
+ NumLock: b&KeyboardLedMaskNumLock != 0,
+ CapsLock: b&KeyboardLedMaskCapsLock != 0,
+ ScrollLock: b&KeyboardLedMaskScrollLock != 0,
+ Compose: b&KeyboardLedMaskCompose != 0,
+ Kana: b&KeyboardLedMaskKana != 0,
+ }
+}
+
+func (u *UsbGadget) updateKeyboardState(b byte) {
+ u.keyboardStateLock.Lock()
+ defer u.keyboardStateLock.Unlock()
+
+ if b&^ValidKeyboardLedMasks != 0 {
+ u.log.Trace().Uint8("b", b).Msg("contains invalid bits, ignoring")
+ return
+ }
+
+ newState := getKeyboardState(b)
+ if reflect.DeepEqual(u.keyboardState, newState) {
+ return
+ }
+ u.log.Info().Interface("old", u.keyboardState).Interface("new", newState).Msg("keyboardState updated")
+ u.keyboardState = newState
+
+ if u.onKeyboardStateChange != nil {
+ (*u.onKeyboardStateChange)(newState)
+ }
+}
+
+func (u *UsbGadget) SetOnKeyboardStateChange(f func(state KeyboardState)) {
+ u.onKeyboardStateChange = &f
+}
+
+func (u *UsbGadget) GetKeyboardState() KeyboardState {
+ u.keyboardStateLock.Lock()
+ defer u.keyboardStateLock.Unlock()
+
+ return u.keyboardState
+}
+
+func (u *UsbGadget) listenKeyboardEvents() {
+ var path string
+ if u.keyboardHidFile != nil {
+ path = u.keyboardHidFile.Name()
+ }
+ l := u.log.With().Str("listener", "keyboardEvents").Str("path", path).Logger()
+ l.Trace().Msg("starting")
+
+ go func() {
+ buf := make([]byte, hidReadBufferSize)
+ for {
+ select {
+ case <-u.keyboardStateCtx.Done():
+ l.Info().Msg("context done")
+ return
+ default:
+ l.Trace().Msg("reading from keyboard")
+ if u.keyboardHidFile == nil {
+ l.Error().Msg("keyboardHidFile is nil")
+ time.Sleep(time.Second)
+ continue
+ }
+ n, err := u.keyboardHidFile.Read(buf)
+ if err != nil {
+ l.Error().Err(err).Msg("failed to read")
+ continue
+ }
+ l.Trace().Int("n", n).Bytes("buf", buf).Msg("got data from keyboard")
+ if n != 1 {
+ l.Trace().Int("n", n).Msg("expected 1 byte, got")
+ continue
+ }
+ u.updateKeyboardState(buf[0])
+ }
}
+ }()
+}
+
+func (u *UsbGadget) openKeyboardHidFile() error {
+ if u.keyboardHidFile != nil {
+ return nil
+ }
+
+ var err error
+ u.keyboardHidFile, err = os.OpenFile("/dev/hidg0", os.O_RDWR, 0666)
+ if err != nil {
+ return fmt.Errorf("failed to open hidg0: %w", err)
+ }
+
+ if u.keyboardStateCancel != nil {
+ u.keyboardStateCancel()
+ }
+
+ u.keyboardStateCtx, u.keyboardStateCancel = context.WithCancel(context.Background())
+ u.listenKeyboardEvents()
+
+ return nil
+}
+
+func (u *UsbGadget) OpenKeyboardHidFile() error {
+ return u.openKeyboardHidFile()
+}
+
+func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
+ if err := u.openKeyboardHidFile(); err != nil {
+ return err
}
_, err := u.keyboardHidFile.Write(data)
diff --git a/internal/usbgadget/usbgadget.go b/internal/usbgadget/usbgadget.go
index f8b2b3e..2eab822 100644
--- a/internal/usbgadget/usbgadget.go
+++ b/internal/usbgadget/usbgadget.go
@@ -3,6 +3,7 @@
package usbgadget
import (
+ "context"
"os"
"path"
"sync"
@@ -59,6 +60,11 @@ type UsbGadget struct {
relMouseHidFile *os.File
relMouseLock sync.Mutex
+ keyboardState KeyboardState
+ keyboardStateLock sync.Mutex
+ keyboardStateCtx context.Context
+ keyboardStateCancel context.CancelFunc
+
enabledDevices Devices
strictMode bool // only intended for testing for now
@@ -70,6 +76,8 @@ type UsbGadget struct {
tx *UsbGadgetTransaction
txLock sync.Mutex
+ onKeyboardStateChange *func(state KeyboardState)
+
log *zerolog.Logger
}
@@ -96,20 +104,25 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev
config = &Config{isEmpty: true}
}
+ keyboardCtx, keyboardCancel := context.WithCancel(context.Background())
+
g := &UsbGadget{
- name: name,
- kvmGadgetPath: path.Join(gadgetPath, name),
- configC1Path: path.Join(gadgetPath, name, "configs/c.1"),
- configMap: configMap,
- customConfig: *config,
- configLock: sync.Mutex{},
- keyboardLock: sync.Mutex{},
- absMouseLock: sync.Mutex{},
- relMouseLock: sync.Mutex{},
- txLock: sync.Mutex{},
- enabledDevices: *enabledDevices,
- lastUserInput: time.Now(),
- log: logger,
+ name: name,
+ kvmGadgetPath: path.Join(gadgetPath, name),
+ configC1Path: path.Join(gadgetPath, name, "configs/c.1"),
+ configMap: configMap,
+ customConfig: *config,
+ configLock: sync.Mutex{},
+ keyboardLock: sync.Mutex{},
+ absMouseLock: sync.Mutex{},
+ relMouseLock: sync.Mutex{},
+ txLock: sync.Mutex{},
+ keyboardStateCtx: keyboardCtx,
+ keyboardStateCancel: keyboardCancel,
+ keyboardState: KeyboardState{},
+ enabledDevices: *enabledDevices,
+ lastUserInput: time.Now(),
+ log: logger,
strictMode: config.strictMode,
diff --git a/jsonrpc.go b/jsonrpc.go
index db25c6d..a32cab2 100644
--- a/jsonrpc.go
+++ b/jsonrpc.go
@@ -1017,6 +1017,7 @@ var rpcHandlers = map[string]RPCHandler{
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
"renewDHCPLease": {Func: rpcRenewDHCPLease},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
+ "getKeyboardLedState": {Func: rpcGetKeyboardLedState},
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
diff --git a/ui/src/components/InfoBar.tsx b/ui/src/components/InfoBar.tsx
index aa00da7..4ee4149 100644
--- a/ui/src/components/InfoBar.tsx
+++ b/ui/src/components/InfoBar.tsx
@@ -36,9 +36,7 @@ export default function InfoBar() {
console.log(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
}, [rpcDataChannel]);
- const isCapsLockActive = useHidStore(state => state.isCapsLockActive);
- const isNumLockActive = useHidStore(state => state.isNumLockActive);
- const isScrollLockActive = useHidStore(state => state.isScrollLockActive);
+ const keyboardLedState = useHidStore(state => state.keyboardLedState);
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
@@ -121,7 +119,7 @@ export default function InfoBar() {
Scroll Lock
+ {keyboardLedState?.compose ? (
+
+ Compose
+
+ ) : null}
+ {keyboardLedState?.kana ? (
+
+ Kana
+
+ ) : null}
diff --git a/ui/src/components/VirtualKeyboard.tsx b/ui/src/components/VirtualKeyboard.tsx
index 03cb331..9566bca 100644
--- a/ui/src/components/VirtualKeyboard.tsx
+++ b/ui/src/components/VirtualKeyboard.tsx
@@ -1,7 +1,8 @@
+import { useShallow } from "zustand/react/shallow";
+import { ChevronDownIcon } from "@heroicons/react/16/solid";
+import { AnimatePresence, motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
import Keyboard from "react-simple-keyboard";
-import { ChevronDownIcon } from "@heroicons/react/16/solid";
-import { motion, AnimatePresence } from "framer-motion";
import Card from "@components/Card";
// eslint-disable-next-line import/order
@@ -9,12 +10,12 @@ import { Button } from "@components/Button";
import "react-simple-keyboard/build/css/index.css";
-import { useHidStore, useUiStore } from "@/hooks/stores";
-import { cx } from "@/cva.config";
-import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
-import useKeyboard from "@/hooks/useKeyboard";
-import DetachIconRaw from "@/assets/detach-icon.svg";
import AttachIconRaw from "@/assets/attach-icon.svg";
+import DetachIconRaw from "@/assets/detach-icon.svg";
+import { cx } from "@/cva.config";
+import { useHidStore, useUiStore } from "@/hooks/stores";
+import useKeyboard from "@/hooks/useKeyboard";
+import { keyDisplayMap, keys, modifiers } from "@/keyboardMappings";
export const DetachIcon = ({ className }: { className?: string }) => {
return ;
@@ -40,8 +41,8 @@ function KeyboardWrapper() {
const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [newPosition, setNewPosition] = useState({ x: 0, y: 0 });
- const isCapsLockActive = useHidStore(state => state.isCapsLockActive);
- const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive);
+
+ const isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState?.caps_lock));
const startDrag = useCallback((e: MouseEvent | TouchEvent) => {
if (!keyboardRef.current) return;
@@ -157,17 +158,11 @@ function KeyboardWrapper() {
toggleLayout();
if (isCapsLockActive) {
- setIsCapsLockActive(false);
sendKeyboardEvent([keys["CapsLock"]], []);
return;
}
}
- // Handle caps lock state change
- if (isKeyCaps) {
- setIsCapsLockActive(!isCapsLockActive);
- }
-
// Collect new active keys and modifiers
const newKeys = keys[cleanKey] ? [keys[cleanKey]] : [];
const newModifiers =
@@ -183,7 +178,7 @@ function KeyboardWrapper() {
setTimeout(resetKeyboardState, 100);
},
- [isCapsLockActive, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive],
+ [isCapsLockActive, sendKeyboardEvent, resetKeyboardState],
);
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx
index 4fd4290..87f2d4e 100644
--- a/ui/src/components/WebRTCVideo.tsx
+++ b/ui/src/components/WebRTCVideo.tsx
@@ -55,10 +55,6 @@ export default function WebRTCVideo() {
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
const isVideoLoading = !isPlaying;
- // Keyboard related states
- const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } =
- useHidStore();
-
// Misc states and hooks
const disableVideoFocusTrap = useUiStore(state => state.disableVideoFocusTrap);
const [send] = useJsonRpc();
@@ -355,10 +351,6 @@ export default function WebRTCVideo() {
// console.log(document.activeElement);
- setIsNumLockActive(e.getModifierState("NumLock"));
- setIsCapsLockActive(e.getModifierState("CapsLock"));
- setIsScrollLockActive(e.getModifierState("ScrollLock"));
-
if (code == "IntlBackslash" && ["`", "~"].includes(key)) {
code = "Backquote";
} else if (code == "Backquote" && ["§", "±"].includes(key)) {
@@ -388,9 +380,6 @@ export default function WebRTCVideo() {
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
},
[
- setIsNumLockActive,
- setIsCapsLockActive,
- setIsScrollLockActive,
handleModifierKeys,
sendKeyboardEvent,
],
@@ -401,10 +390,6 @@ export default function WebRTCVideo() {
e.preventDefault();
const prev = useHidStore.getState();
- setIsNumLockActive(e.getModifierState("NumLock"));
- setIsCapsLockActive(e.getModifierState("CapsLock"));
- setIsScrollLockActive(e.getModifierState("ScrollLock"));
-
// Filtering out the key that was just released (keys[e.code])
const newKeys = prev.activeKeys.filter(k => k !== keys[e.code]).filter(Boolean);
@@ -417,9 +402,6 @@ export default function WebRTCVideo() {
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
},
[
- setIsNumLockActive,
- setIsCapsLockActive,
- setIsScrollLockActive,
handleModifierKeys,
sendKeyboardEvent,
],
diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts
index 01d1257..1754a87 100644
--- a/ui/src/hooks/stores.ts
+++ b/ui/src/hooks/stores.ts
@@ -405,6 +405,14 @@ export const useMountMediaStore = create(set => ({
setErrorMessage: message => set({ errorMessage: message }),
}));
+export interface KeyboardLedState {
+ num_lock: boolean;
+ caps_lock: boolean;
+ scroll_lock: boolean;
+ compose: boolean;
+ kana: boolean;
+}
+
export interface HidState {
activeKeys: number[];
activeModifiers: number[];
@@ -423,18 +431,12 @@ export interface HidState {
altGrCtrlTime: number; // _altGrCtrlTime
setAltGrCtrlTime: (time: number) => void;
- isNumLockActive: boolean;
- setIsNumLockActive: (enabled: boolean) => void;
-
- isScrollLockActive: boolean;
- setIsScrollLockActive: (enabled: boolean) => void;
+ keyboardLedState?: KeyboardLedState;
+ setKeyboardLedState: (state: KeyboardLedState) => void;
isVirtualKeyboardEnabled: boolean;
setVirtualKeyboardEnabled: (enabled: boolean) => void;
- isCapsLockActive: boolean;
- setIsCapsLockActive: (enabled: boolean) => void;
-
isPasteModeEnabled: boolean;
setPasteModeEnabled: (enabled: boolean) => void;
@@ -458,18 +460,11 @@ export const useHidStore = create(set => ({
altGrCtrlTime: 0,
setAltGrCtrlTime: time => set({ altGrCtrlTime: time }),
- isNumLockActive: false,
- setIsNumLockActive: enabled => set({ isNumLockActive: enabled }),
-
- isScrollLockActive: false,
- setIsScrollLockActive: enabled => set({ isScrollLockActive: enabled }),
+ setKeyboardLedState: ledState => set({ keyboardLedState: ledState }),
isVirtualKeyboardEnabled: false,
setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }),
- isCapsLockActive: false,
- setIsCapsLockActive: enabled => set({ isCapsLockActive: enabled }),
-
isPasteModeEnabled: false,
setPasteModeEnabled: enabled => set({ isPasteModeEnabled: enabled }),
diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx
index d35915b..a6be368 100644
--- a/ui/src/routes/devices.$id.tsx
+++ b/ui/src/routes/devices.$id.tsx
@@ -19,6 +19,7 @@ import useWebSocket from "react-use-websocket";
import { cx } from "@/cva.config";
import {
HidState,
+ KeyboardLedState,
NetworkState,
UpdateState,
useDeviceStore,
@@ -586,6 +587,9 @@ export default function KvmIdRoute() {
const setUsbState = useHidStore(state => state.setUsbState);
const setHdmiState = useVideoStore(state => state.setHdmiState);
+ const keyboardLedState = useHidStore(state => state.keyboardLedState);
+ const setKeyboardLedState = useHidStore(state => state.setKeyboardLedState);
+
const [hasUpdated, setHasUpdated] = useState(false);
const { navigateTo } = useDeviceUiNavigation();
@@ -607,6 +611,12 @@ export default function KvmIdRoute() {
setNetworkState(resp.params as NetworkState);
}
+ if (resp.method === "keyboardLedState") {
+ const ledState = resp.params as KeyboardLedState;
+ console.log("Setting keyboard led state", ledState);
+ setKeyboardLedState(ledState);
+ }
+
if (resp.method === "otaState") {
const otaState = resp.params as UpdateState["otaState"];
setOtaState(otaState);
@@ -643,6 +653,18 @@ export default function KvmIdRoute() {
});
}, [rpcDataChannel?.readyState, send, setHdmiState]);
+ // request keyboard led state from the device
+ useEffect(() => {
+ if (rpcDataChannel?.readyState !== "open") return;
+ if (keyboardLedState !== undefined) return;
+ console.log("Requesting keyboard led state");
+ send("getKeyboardLedState", {}, resp => {
+ if ("error" in resp) return;
+ console.log("Keyboard led state", resp.result);
+ setKeyboardLedState(resp.result as KeyboardLedState);
+ });
+ }, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState]);
+
// When the update is successful, we need to refresh the client javascript and show a success modal
useEffect(() => {
if (queryParams.get("updateSuccess")) {
diff --git a/usb.go b/usb.go
index 91674c9..f777f89 100644
--- a/usb.go
+++ b/usb.go
@@ -24,6 +24,17 @@ func initUsbGadget() {
time.Sleep(500 * time.Millisecond)
}
}()
+
+ gadget.SetOnKeyboardStateChange(func(state usbgadget.KeyboardState) {
+ if currentSession != nil {
+ writeJSONRPCEvent("keyboardLedState", state, currentSession)
+ }
+ })
+
+ // open the keyboard hid file to listen for keyboard events
+ if err := gadget.OpenKeyboardHidFile(); err != nil {
+ usbLogger.Error().Err(err).Msg("failed to open keyboard hid file")
+ }
}
func rpcKeyboardReport(modifier uint8, keys []uint8) error {
@@ -42,6 +53,10 @@ func rpcWheelReport(wheelY int8) error {
return gadget.AbsMouseWheelReport(wheelY)
}
+func rpcGetKeyboardLedState() (state usbgadget.KeyboardState) {
+ return gadget.GetKeyboardState()
+}
+
var usbState = "unknown"
func rpcGetUSBState() (state string) {
From 019934d33e074701e9197d514bca3349c8ae477d Mon Sep 17 00:00:00 2001
From: adammkelly
Date: Thu, 22 May 2025 23:56:50 +0100
Subject: [PATCH 02/11] chore(ui): Allow mac address copying (#504) (#506)
---
ui/src/routes/devices.$id.settings.network.tsx | 1 -
1 file changed, 1 deletion(-)
diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx
index 16804b5..0905db5 100644
--- a/ui/src/routes/devices.$id.settings.network.tsx
+++ b/ui/src/routes/devices.$id.settings.network.tsx
@@ -228,7 +228,6 @@ export default function SettingsNetworkRoute() {
size="SM"
value={networkState?.mac_address}
error={""}
- disabled={true}
readOnly={true}
className="dark:!text-opacity-60"
/>
From c1d771cced7e0ca6cbdbe2da0d44b1105f330caf Mon Sep 17 00:00:00 2001
From: Aveline <352441+ym@users.noreply.github.com>
Date: Fri, 23 May 2025 00:59:02 +0200
Subject: [PATCH 03/11] feat: allow user to disable keyboard LED
synchronization (#507)
* feat: allow user to disable keyboard LED synchronization
* Update ui/src/hooks/stores.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
ui/src/components/InfoBar.tsx | 16 +++++++
ui/src/components/VirtualKeyboard.tsx | 24 +++++++++--
ui/src/components/WebRTCVideo.tsx | 32 ++++++++++++++
ui/src/hooks/stores.ts | 43 ++++++++++++++++++-
.../routes/devices.$id.settings.keyboard.tsx | 30 ++++++++++++-
ui/src/routes/devices.$id.tsx | 18 +++++++-
6 files changed, 154 insertions(+), 9 deletions(-)
diff --git a/ui/src/components/InfoBar.tsx b/ui/src/components/InfoBar.tsx
index 4ee4149..b865985 100644
--- a/ui/src/components/InfoBar.tsx
+++ b/ui/src/components/InfoBar.tsx
@@ -37,6 +37,8 @@ export default function InfoBar() {
}, [rpcDataChannel]);
const keyboardLedState = useHidStore(state => state.keyboardLedState);
+ const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
+ const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
@@ -116,6 +118,20 @@ export default function InfoBar() {
Relayed by Cloudflare
)}
+
+ {keyboardLedStateSyncAvailable ? (
+
+ {keyboardLedSync === "browser" ? "Browser" : "Host"}
+
+ ) : null}
state.keyboardLedState?.caps_lock));
+ // HID related states
+ const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
+ const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
+ const isKeyboardLedManagedByHost = useMemo(() =>
+ keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable,
+ [keyboardLedSync, keyboardLedStateSyncAvailable],
+ );
+
+ const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive);
+
const startDrag = useCallback((e: MouseEvent | TouchEvent) => {
if (!keyboardRef.current) return;
if (e instanceof TouchEvent && e.touches.length > 1) return;
@@ -158,11 +168,19 @@ function KeyboardWrapper() {
toggleLayout();
if (isCapsLockActive) {
+ if (!isKeyboardLedManagedByHost) {
+ setIsCapsLockActive(false);
+ }
sendKeyboardEvent([keys["CapsLock"]], []);
return;
}
}
+ // Handle caps lock state change
+ if (isKeyCaps && !isKeyboardLedManagedByHost) {
+ setIsCapsLockActive(!isCapsLockActive);
+ }
+
// Collect new active keys and modifiers
const newKeys = keys[cleanKey] ? [keys[cleanKey]] : [];
const newModifiers =
@@ -178,7 +196,7 @@ function KeyboardWrapper() {
setTimeout(resetKeyboardState, 100);
},
- [isCapsLockActive, sendKeyboardEvent, resetKeyboardState],
+ [isCapsLockActive, isKeyboardLedManagedByHost, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive],
);
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx
index 87f2d4e..ca4db08 100644
--- a/ui/src/components/WebRTCVideo.tsx
+++ b/ui/src/components/WebRTCVideo.tsx
@@ -47,6 +47,18 @@ export default function WebRTCVideo() {
clientHeight: videoClientHeight,
} = useVideoStore();
+ // HID related states
+ const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
+ const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
+ const isKeyboardLedManagedByHost = useMemo(() =>
+ keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable,
+ [keyboardLedSync, keyboardLedStateSyncAvailable],
+ );
+
+ const setIsNumLockActive = useHidStore(state => state.setIsNumLockActive);
+ const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive);
+ const setIsScrollLockActive = useHidStore(state => state.setIsScrollLockActive);
+
// RTC related states
const peerConnection = useRTCStore(state => state.peerConnection);
@@ -351,6 +363,12 @@ export default function WebRTCVideo() {
// console.log(document.activeElement);
+ if (!isKeyboardLedManagedByHost) {
+ setIsNumLockActive(e.getModifierState("NumLock"));
+ setIsCapsLockActive(e.getModifierState("CapsLock"));
+ setIsScrollLockActive(e.getModifierState("ScrollLock"));
+ }
+
if (code == "IntlBackslash" && ["`", "~"].includes(key)) {
code = "Backquote";
} else if (code == "Backquote" && ["§", "±"].includes(key)) {
@@ -382,6 +400,10 @@ export default function WebRTCVideo() {
[
handleModifierKeys,
sendKeyboardEvent,
+ isKeyboardLedManagedByHost,
+ setIsNumLockActive,
+ setIsCapsLockActive,
+ setIsScrollLockActive,
],
);
@@ -390,6 +412,12 @@ export default function WebRTCVideo() {
e.preventDefault();
const prev = useHidStore.getState();
+ if (!isKeyboardLedManagedByHost) {
+ setIsNumLockActive(e.getModifierState("NumLock"));
+ setIsCapsLockActive(e.getModifierState("CapsLock"));
+ setIsScrollLockActive(e.getModifierState("ScrollLock"));
+ }
+
// Filtering out the key that was just released (keys[e.code])
const newKeys = prev.activeKeys.filter(k => k !== keys[e.code]).filter(Boolean);
@@ -404,6 +432,10 @@ export default function WebRTCVideo() {
[
handleModifierKeys,
sendKeyboardEvent,
+ isKeyboardLedManagedByHost,
+ setIsNumLockActive,
+ setIsCapsLockActive,
+ setIsScrollLockActive,
],
);
diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts
index 1754a87..52ef89d 100644
--- a/ui/src/hooks/stores.ts
+++ b/ui/src/hooks/stores.ts
@@ -283,6 +283,8 @@ export const useVideoStore = create
(set => ({
},
}));
+export type KeyboardLedSync = "auto" | "browser" | "host";
+
interface SettingsState {
isCursorHidden: boolean;
setCursorVisibility: (enabled: boolean) => void;
@@ -305,6 +307,9 @@ interface SettingsState {
keyboardLayout: string;
setKeyboardLayout: (layout: string) => void;
+
+ keyboardLedSync: KeyboardLedSync;
+ setKeyboardLedSync: (sync: KeyboardLedSync) => void;
}
export const useSettingsStore = create(
@@ -336,6 +341,9 @@ export const useSettingsStore = create(
keyboardLayout: "en-US",
setKeyboardLayout: layout => set({ keyboardLayout: layout }),
+
+ keyboardLedSync: "auto",
+ setKeyboardLedSync: sync => set({ keyboardLedSync: sync }),
}),
{
name: "settings",
@@ -411,7 +419,14 @@ export interface KeyboardLedState {
scroll_lock: boolean;
compose: boolean;
kana: boolean;
-}
+};
+const defaultKeyboardLedState: KeyboardLedState = {
+ num_lock: false,
+ caps_lock: false,
+ scroll_lock: false,
+ compose: false,
+ kana: false,
+};
export interface HidState {
activeKeys: number[];
@@ -433,6 +448,12 @@ export interface HidState {
keyboardLedState?: KeyboardLedState;
setKeyboardLedState: (state: KeyboardLedState) => void;
+ setIsNumLockActive: (active: boolean) => void;
+ setIsCapsLockActive: (active: boolean) => void;
+ setIsScrollLockActive: (active: boolean) => void;
+
+ keyboardLedStateSyncAvailable: boolean;
+ setKeyboardLedStateSyncAvailable: (available: boolean) => void;
isVirtualKeyboardEnabled: boolean;
setVirtualKeyboardEnabled: (enabled: boolean) => void;
@@ -444,7 +465,7 @@ export interface HidState {
setUsbState: (state: HidState["usbState"]) => void;
}
-export const useHidStore = create(set => ({
+export const useHidStore = create((set, get) => ({
activeKeys: [],
activeModifiers: [],
updateActiveKeysAndModifiers: ({ keys, modifiers }) => {
@@ -461,6 +482,24 @@ export const useHidStore = create(set => ({
setAltGrCtrlTime: time => set({ altGrCtrlTime: time }),
setKeyboardLedState: ledState => set({ keyboardLedState: ledState }),
+ setIsNumLockActive: active => {
+ const keyboardLedState = { ...(get().keyboardLedState || defaultKeyboardLedState) };
+ keyboardLedState.num_lock = active;
+ set({ keyboardLedState });
+ },
+ setIsCapsLockActive: active => {
+ const keyboardLedState = { ...(get().keyboardLedState || defaultKeyboardLedState) };
+ keyboardLedState.caps_lock = active;
+ set({ keyboardLedState });
+ },
+ setIsScrollLockActive: active => {
+ const keyboardLedState = { ...(get().keyboardLedState || defaultKeyboardLedState) };
+ keyboardLedState.scroll_lock = active;
+ set({ keyboardLedState });
+ },
+
+ keyboardLedStateSyncAvailable: false,
+ setKeyboardLedStateSyncAvailable: available => set({ keyboardLedStateSyncAvailable: available }),
isVirtualKeyboardEnabled: false,
setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }),
diff --git a/ui/src/routes/devices.$id.settings.keyboard.tsx b/ui/src/routes/devices.$id.settings.keyboard.tsx
index c311a62..8849e61 100644
--- a/ui/src/routes/devices.$id.settings.keyboard.tsx
+++ b/ui/src/routes/devices.$id.settings.keyboard.tsx
@@ -1,6 +1,6 @@
import { useCallback, useEffect } from "react";
-import { useSettingsStore } from "@/hooks/stores";
+import { KeyboardLedSync, useSettingsStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
import { SettingsPageHeader } from "@components/SettingsPageheader";
@@ -12,11 +12,20 @@ import { SettingsItem } from "./devices.$id.settings";
export default function SettingsKeyboardRoute() {
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
+ const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const setKeyboardLayout = useSettingsStore(
state => state.setKeyboardLayout,
);
+ const setKeyboardLedSync = useSettingsStore(
+ state => state.setKeyboardLedSync,
+ );
const layoutOptions = Object.entries(layouts).map(([code, language]) => { return { value: code, label: language } })
+ const ledSyncOptions = [
+ { value: "auto", label: "Automatic" },
+ { value: "browser", label: "Browser Only" },
+ { value: "host", label: "Host Only" },
+ ];
const [send] = useJsonRpc();
@@ -47,7 +56,7 @@ export default function SettingsKeyboardRoute() {
@@ -69,6 +78,23 @@ export default function SettingsKeyboardRoute() {
Pasting text sends individual key strokes to the target device. The keyboard layout determines which key codes are being sent. Ensure that the keyboard layout in JetKVM matches the settings in the operating system.
+
+
+ { /* this menu item could be renamed to plain "Keyboard layout" in the future, when also the virtual keyboard layout mappings are being implemented */ }
+
+ setKeyboardLedSync(e.target.value as KeyboardLedSync)}
+ options={ledSyncOptions}
+ />
+
+
);
}
diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx
index a6be368..c9aac36 100644
--- a/ui/src/routes/devices.$id.tsx
+++ b/ui/src/routes/devices.$id.tsx
@@ -590,6 +590,8 @@ export default function KvmIdRoute() {
const keyboardLedState = useHidStore(state => state.keyboardLedState);
const setKeyboardLedState = useHidStore(state => state.setKeyboardLedState);
+ const setKeyboardLedStateSyncAvailable = useHidStore(state => state.setKeyboardLedStateSyncAvailable);
+
const [hasUpdated, setHasUpdated] = useState(false);
const { navigateTo } = useDeviceUiNavigation();
@@ -615,6 +617,7 @@ export default function KvmIdRoute() {
const ledState = resp.params as KeyboardLedState;
console.log("Setting keyboard led state", ledState);
setKeyboardLedState(ledState);
+ setKeyboardLedStateSyncAvailable(true);
}
if (resp.method === "otaState") {
@@ -658,12 +661,23 @@ export default function KvmIdRoute() {
if (rpcDataChannel?.readyState !== "open") return;
if (keyboardLedState !== undefined) return;
console.log("Requesting keyboard led state");
+
send("getKeyboardLedState", {}, resp => {
- if ("error" in resp) return;
+ if ("error" in resp) {
+ // -32601 means the method is not supported
+ if (resp.error.code === -32601) {
+ setKeyboardLedStateSyncAvailable(false);
+ console.error("Failed to get keyboard led state, disabling sync", resp.error);
+ } else {
+ console.error("Failed to get keyboard led state", resp.error);
+ }
+ return;
+ }
console.log("Keyboard led state", resp.result);
setKeyboardLedState(resp.result as KeyboardLedState);
+ setKeyboardLedStateSyncAvailable(true);
});
- }, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState]);
+ }, [rpcDataChannel?.readyState, send, setKeyboardLedState, setKeyboardLedStateSyncAvailable, keyboardLedState]);
// When the update is successful, we need to refresh the client javascript and show a success modal
useEffect(() => {
From 1b5062c504bd8db351dc91fe62ad3bbee40cfaa2 Mon Sep 17 00:00:00 2001
From: Marc Brooks
Date: Fri, 23 May 2025 06:21:53 -0500
Subject: [PATCH 04/11] fix(ui): Default the keyboardLayout to en-US if not set
(#512)
The recent fix to PasteModal will silently fail a paste if the keyboardLayout hasn't been selected in the settings yet, then when you look in Settings it looks like it's set to Belgian, but it's really just blank. Set it to default to en-US in both these places so it works like it did previously.
Fixes #492
---
ui/src/components/popovers/PasteModal.tsx | 42 +++++++++++--------
.../routes/devices.$id.settings.keyboard.tsx | 11 ++++-
2 files changed, 33 insertions(+), 20 deletions(-)
diff --git a/ui/src/components/popovers/PasteModal.tsx b/ui/src/components/popovers/PasteModal.tsx
index 26e4b82..70b3067 100644
--- a/ui/src/components/popovers/PasteModal.tsx
+++ b/ui/src/components/popovers/PasteModal.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { LuCornerDownLeft } from "react-icons/lu";
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
import { useClose } from "@headlessui/react";
@@ -39,6 +39,13 @@ export default function PasteModal() {
state => state.setKeyboardLayout,
);
+ // this ensures we always get the original en-US if it hasn't been set yet
+ const safeKeyboardLayout = useMemo(() => {
+ if (keyboardLayout && keyboardLayout.length > 0)
+ return keyboardLayout;
+ return "en-US";
+ }, [keyboardLayout]);
+
useEffect(() => {
send("getKeyboardLayout", {}, resp => {
if ("error" in resp) return;
@@ -56,29 +63,28 @@ export default function PasteModal() {
setPasteMode(false);
setDisableVideoFocusTrap(false);
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
- if (!keyboardLayout) return;
- if (!chars[keyboardLayout]) return;
-
+ if (!safeKeyboardLayout) return;
+ if (!chars[safeKeyboardLayout]) return;
const text = TextAreaRef.current.value;
try {
for (const char of text) {
- const { key, shift, altRight, deadKey, accentKey } = chars[keyboardLayout][char]
+ const { key, shift, altRight, deadKey, accentKey } = chars[safeKeyboardLayout][char]
if (!key) continue;
- const keyz = [ keys[key] ];
- const modz = [ modifierCode(shift, altRight) ];
+ const keyz = [ keys[key] ];
+ const modz = [ modifierCode(shift, altRight) ];
- if (deadKey) {
+ if (deadKey) {
keyz.push(keys["Space"]);
modz.push(noModifier);
- }
- if (accentKey) {
+ }
+ if (accentKey) {
keyz.unshift(keys[accentKey.key])
modz.unshift(modifierCode(accentKey.shift, accentKey.altRight))
- }
+ }
- for (const [index, kei] of keyz.entries()) {
+ for (const [index, kei] of keyz.entries()) {
await new Promise((resolve, reject) => {
send(
"keyboardReport",
@@ -92,13 +98,13 @@ export default function PasteModal() {
},
);
});
- }
+ }
}
} catch (error) {
console.error(error);
notifications.error("Failed to paste text");
}
- }, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, keyboardLayout]);
+ }, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, safeKeyboardLayout]);
useEffect(() => {
if (TextAreaRef.current) {
@@ -148,7 +154,7 @@ export default function PasteModal() {
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
[...new Intl.Segmenter().segment(value)]
.map(x => x.segment)
- .filter(char => !chars[keyboardLayout][char]),
+ .filter(char => !chars[safeKeyboardLayout][char]),
),
];
@@ -167,11 +173,11 @@ export default function PasteModal() {
)}
-
+
- Sending text using keyboard layout: {layouts[keyboardLayout]}
+ Sending text using keyboard layout: {layouts[safeKeyboardLayout]}
-
+
diff --git a/ui/src/routes/devices.$id.settings.keyboard.tsx b/ui/src/routes/devices.$id.settings.keyboard.tsx
index 8849e61..0f266e9 100644
--- a/ui/src/routes/devices.$id.settings.keyboard.tsx
+++ b/ui/src/routes/devices.$id.settings.keyboard.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect } from "react";
+import { useCallback, useEffect, useMemo } from "react";
import { KeyboardLedSync, useSettingsStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc";
@@ -20,6 +20,13 @@ export default function SettingsKeyboardRoute() {
state => state.setKeyboardLedSync,
);
+ // this ensures we always get the original en-US if it hasn't been set yet
+ const safeKeyboardLayout = useMemo(() => {
+ if (keyboardLayout && keyboardLayout.length > 0)
+ return keyboardLayout;
+ return "en-US";
+ }, [keyboardLayout]);
+
const layoutOptions = Object.entries(layouts).map(([code, language]) => { return { value: code, label: language } })
const ledSyncOptions = [
{ value: "auto", label: "Automatic" },
@@ -69,7 +76,7 @@ export default function SettingsKeyboardRoute() {
size="SM"
label=""
fullWidth
- value={keyboardLayout}
+ value={safeKeyboardLayout}
onChange={onKeyboardLayoutChange}
options={layoutOptions}
/>
From 7e64a529f89364a732b9f76c44949bbb7d52bf76 Mon Sep 17 00:00:00 2001
From: Alex Goodkind
Date: Fri, 23 May 2025 05:38:15 -0700
Subject: [PATCH 05/11] chore: add VSCode extensions for improved development
environment (#509)
---
.devcontainer/devcontainer.json | 15 ++++++++++++++-
.vscode/settings.json | 3 +++
2 files changed, 17 insertions(+), 1 deletion(-)
create mode 100644 .vscode/settings.json
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 7da2970..aa803f6 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -9,6 +9,19 @@
},
"mounts": [
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached"
- ]
+ ],
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "bradlc.vscode-tailwindcss",
+ "GitHub.vscode-pull-request-github",
+ "dbaeumer.vscode-eslint",
+ "golang.go",
+ "ms-vscode.makefile-tools",
+ "esbenp.prettier-vscode",
+ "github.vscode-github-actions"
+ ]
+ }
+ }
}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..de91a5d
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "tailwindCSS.classFunctions": ["cva", "cx"]
+}
\ No newline at end of file
From 2ec061b3a8ba2659f55c6b5717b97d2ca5609e23 Mon Sep 17 00:00:00 2001
From: ariedel87 <127566745+ariedel87@users.noreply.github.com>
Date: Sun, 25 May 2025 11:09:48 +0200
Subject: [PATCH 06/11] feat(Keyboard): Hide Pressed Keys (#518)
---
ui/src/components/InfoBar.tsx | 29 ++++++++++---------
ui/src/hooks/stores.ts | 6 ++++
.../routes/devices.$id.settings.keyboard.tsx | 17 +++++++++++
3 files changed, 39 insertions(+), 13 deletions(-)
diff --git a/ui/src/components/InfoBar.tsx b/ui/src/components/InfoBar.tsx
index b865985..7ce67a4 100644
--- a/ui/src/components/InfoBar.tsx
+++ b/ui/src/components/InfoBar.tsx
@@ -28,6 +28,7 @@ export default function InfoBar() {
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const settings = useSettingsStore();
+ const showPressedKeys = useSettingsStore(state => state.showPressedKeys);
useEffect(() => {
if (!rpcDataChannel) return;
@@ -97,19 +98,21 @@ export default function InfoBar() {
)}
-
- Keys:
-
- {[
- ...activeKeys.map(
- x => Object.entries(keys).filter(y => y[1] === x)[0][0],
- ),
- activeModifiers.map(
- x => Object.entries(modifiers).filter(y => y[1] === x)[0][0],
- ),
- ].join(", ")}
-
-
+ {showPressedKeys && (
+
+ Keys:
+
+ {[
+ ...activeKeys.map(
+ x => Object.entries(keys).filter(y => y[1] === x)[0][0],
+ ),
+ activeModifiers.map(
+ x => Object.entries(modifiers).filter(y => y[1] === x)[0][0],
+ ),
+ ].join(", ")}
+
+
+ )}
diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts
index 52ef89d..1c52572 100644
--- a/ui/src/hooks/stores.ts
+++ b/ui/src/hooks/stores.ts
@@ -310,6 +310,9 @@ interface SettingsState {
keyboardLedSync: KeyboardLedSync;
setKeyboardLedSync: (sync: KeyboardLedSync) => void;
+
+ showPressedKeys: boolean;
+ setShowPressedKeys: (show: boolean) => void;
}
export const useSettingsStore = create(
@@ -344,6 +347,9 @@ export const useSettingsStore = create(
keyboardLedSync: "auto",
setKeyboardLedSync: sync => set({ keyboardLedSync: sync }),
+
+ showPressedKeys: true,
+ setShowPressedKeys: show => set({ showPressedKeys: show }),
}),
{
name: "settings",
diff --git a/ui/src/routes/devices.$id.settings.keyboard.tsx b/ui/src/routes/devices.$id.settings.keyboard.tsx
index 0f266e9..12ed6f2 100644
--- a/ui/src/routes/devices.$id.settings.keyboard.tsx
+++ b/ui/src/routes/devices.$id.settings.keyboard.tsx
@@ -5,6 +5,7 @@ import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { layouts } from "@/keyboardLayouts";
+import { Checkbox } from "@/components/Checkbox";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
@@ -13,12 +14,16 @@ import { SettingsItem } from "./devices.$id.settings";
export default function SettingsKeyboardRoute() {
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
+ const showPressedKeys = useSettingsStore(state => state.showPressedKeys);
const setKeyboardLayout = useSettingsStore(
state => state.setKeyboardLayout,
);
const setKeyboardLedSync = useSettingsStore(
state => state.setKeyboardLedSync,
);
+ const setShowPressedKeys = useSettingsStore(
+ state => state.setShowPressedKeys,
+ );
// this ensures we always get the original en-US if it hasn't been set yet
const safeKeyboardLayout = useMemo(() => {
@@ -102,6 +107,18 @@ export default function SettingsKeyboardRoute() {
/>
+
+
+
+ setShowPressedKeys(e.target.checked)}
+ />
+
+
);
}
From a28676cd94d64aad156dee7c7ba779acdca0d3ec Mon Sep 17 00:00:00 2001
From: Aveline <352441+ym@users.noreply.github.com>
Date: Sun, 25 May 2025 11:09:58 +0200
Subject: [PATCH 07/11] feat(websecure): add support for ed25519 certificates
(#513)
---
internal/websecure/ed25519_test.go | 55 ++++++++++++++++++++++++++++++
internal/websecure/utils.go | 7 +++-
2 files changed, 61 insertions(+), 1 deletion(-)
create mode 100644 internal/websecure/ed25519_test.go
diff --git a/internal/websecure/ed25519_test.go b/internal/websecure/ed25519_test.go
new file mode 100644
index 0000000..0753be0
--- /dev/null
+++ b/internal/websecure/ed25519_test.go
@@ -0,0 +1,55 @@
+package websecure
+
+import (
+ "os"
+ "testing"
+)
+
+var (
+ fixtureEd25519Certificate = `-----BEGIN CERTIFICATE-----
+MIIBQDCB86ADAgECAhQdB4qB6dV0/u1lwhJofQgkmjjV1zAFBgMrZXAwLzELMAkG
+A1UEBhMCREUxIDAeBgNVBAMMF2VkMjU1MTktdGVzdC5qZXRrdm0uY29tMB4XDTI1
+MDUyMzEyNTkyN1oXDTI3MDQyMzEyNTkyN1owLzELMAkGA1UEBhMCREUxIDAeBgNV
+BAMMF2VkMjU1MTktdGVzdC5qZXRrdm0uY29tMCowBQYDK2VwAyEA9tLyoulJn7Ev
+bf8kuD1ZGdA092773pCRjFEDKpXHonyjITAfMB0GA1UdDgQWBBRkmrVMfsLY57iy
+r/0POP0S4QxCADAFBgMrZXADQQBfTRvqavLHDYQiKQTgbGod+Yn+fIq2lE584+1U
+C4wh9peIJDFocLBEAYTQpEMKxa4s0AIRxD+a7aCS5oz0e/0I
+-----END CERTIFICATE-----`
+
+ fixtureEd25519PrivateKey = `-----BEGIN PRIVATE KEY-----
+MC4CAQAwBQYDK2VwBCIEIKV08xUsLRHBfMXqZwxVRzIbViOp8G7aQGjPvoRFjujB
+-----END PRIVATE KEY-----`
+
+ certStore *CertStore
+ certSigner *SelfSigner
+)
+
+func TestMain(m *testing.M) {
+ tlsStorePath, err := os.MkdirTemp("", "jktls.*")
+ if err != nil {
+ defaultLogger.Fatal().Err(err).Msg("failed to create temp directory")
+ }
+
+ certStore = NewCertStore(tlsStorePath, nil)
+ certStore.LoadCertificates()
+
+ certSigner = NewSelfSigner(
+ certStore,
+ nil,
+ "ci.jetkvm.com",
+ "JetKVM",
+ "JetKVM",
+ "JetKVM",
+ )
+
+ m.Run()
+
+ os.RemoveAll(tlsStorePath)
+}
+
+func TestSaveEd25519Certificate(t *testing.T) {
+ err, _ := certStore.ValidateAndSaveCertificate("ed25519-test.jetkvm.com", fixtureEd25519Certificate, fixtureEd25519PrivateKey, true)
+ if err != nil {
+ t.Fatalf("failed to save certificate: %v", err)
+ }
+}
diff --git a/internal/websecure/utils.go b/internal/websecure/utils.go
index b0038c0..b333bf9 100644
--- a/internal/websecure/utils.go
+++ b/internal/websecure/utils.go
@@ -2,6 +2,7 @@ package websecure
import (
"crypto/ecdsa"
+ "crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
@@ -37,11 +38,15 @@ func keyToFile(cert *tls.Certificate, filename string) error {
if e != nil {
return fmt.Errorf("failed to marshal EC private key: %v", e)
}
-
keyBlock = pem.Block{
Type: "EC PRIVATE KEY",
Bytes: b,
}
+ case ed25519.PrivateKey:
+ keyBlock = pem.Block{
+ Type: "ED25519 PRIVATE KEY",
+ Bytes: k,
+ }
default:
return fmt.Errorf("unknown private key type: %T", k)
}
From 55d7f22c4749dea0d761a5ea553d0b8bd3cb2360 Mon Sep 17 00:00:00 2001
From: Marc Brooks
Date: Sun, 25 May 2025 07:19:31 -0500
Subject: [PATCH 08/11] chore(ui): Removed unused DeviceSettingState (#496)
Now that we don't do any mouse/trackpad sensitivity settings, this whole interface is unused.
---
ui/src/hooks/stores.ts | 11 -----------
1 file changed, 11 deletions(-)
diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts
index 1c52572..31bf4ce 100644
--- a/ui/src/hooks/stores.ts
+++ b/ui/src/hooks/stores.ts
@@ -358,17 +358,6 @@ export const useSettingsStore = create(
),
);
-export interface DeviceSettingsState {
- trackpadSensitivity: number;
- mouseSensitivity: number;
- clampMin: number;
- clampMax: number;
- blockDelay: number;
- trackpadThreshold: number;
- scrollSensitivity: "low" | "default" | "high";
- setScrollSensitivity: (sensitivity: DeviceSettingsState["scrollSensitivity"]) => void;
-}
-
export interface RemoteVirtualMediaState {
source: "WebRTC" | "HTTP" | "Storage" | null;
mode: "CDROM" | "Disk" | null;
From 1f7c5c94d8c39ae713a31a17645c4f35326ff759 Mon Sep 17 00:00:00 2001
From: Marc Brooks
Date: Sun, 25 May 2025 07:19:42 -0500
Subject: [PATCH 09/11] feat(ui): Add Ctrl+Alt+Del to the action bar (#498)
Since this is the sort of thing we do all the time, make it one-click away
---
ui/src/components/ActionBar.tsx | 24 ++++++++++++--
ui/src/hooks/stores.ts | 6 ++++
.../devices.$id.settings.ctrlaltdel.tsx | 28 ++++++++++++++++
.../routes/devices.$id.settings.hardware.tsx | 32 ++++++++++++++++++-
4 files changed, 87 insertions(+), 3 deletions(-)
create mode 100644 ui/src/routes/devices.$id.settings.ctrlaltdel.tsx
diff --git a/ui/src/components/ActionBar.tsx b/ui/src/components/ActionBar.tsx
index 83ae509..7de4571 100644
--- a/ui/src/components/ActionBar.tsx
+++ b/ui/src/components/ActionBar.tsx
@@ -1,6 +1,6 @@
import { MdOutlineContentPasteGo } from "react-icons/md";
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
-import { FaKeyboard } from "react-icons/fa6";
+import { FaKeyboard, FaLock} from "react-icons/fa6";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { Fragment, useCallback, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid";
@@ -19,6 +19,8 @@ import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
import MountPopopover from "@/components/popovers/MountPopover";
import ExtensionPopover from "@/components/popovers/ExtensionPopover";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
+import useKeyboard from "@/hooks/useKeyboard";
+import { keys, modifiers } from "@/keyboardMappings";
export default function Actionbar({
requestFullscreen,
@@ -56,6 +58,8 @@ export default function Actionbar({
[setDisableFocusTrap],
);
+ const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
+
return (
-
+ {useSettingsStore().actionBarCtrlAltDel && (
+
+ {
+ sendKeyboardEvent(
+ [keys["Delete"]],
+ [modifiers["ControlLeft"], modifiers["AltLeft"]],
+ );
+ setTimeout(resetKeyboardState, 100);
+ }}
+ />
+
+ )}
void;
+ actionBarCtrlAltDel: boolean;
+ setActionBarCtrlAltDel: (enabled: boolean) => void;
+
keyboardLedSync: KeyboardLedSync;
setKeyboardLedSync: (sync: KeyboardLedSync) => void;
@@ -345,6 +348,9 @@ export const useSettingsStore = create(
keyboardLayout: "en-US",
setKeyboardLayout: layout => set({ keyboardLayout: layout }),
+ actionBarCtrlAltDel: false,
+ setActionBarCtrlAltDel: enabled => set({ actionBarCtrlAltDel: enabled }),
+
keyboardLedSync: "auto",
setKeyboardLedSync: sync => set({ keyboardLedSync: sync }),
diff --git a/ui/src/routes/devices.$id.settings.ctrlaltdel.tsx b/ui/src/routes/devices.$id.settings.ctrlaltdel.tsx
new file mode 100644
index 0000000..397dc8b
--- /dev/null
+++ b/ui/src/routes/devices.$id.settings.ctrlaltdel.tsx
@@ -0,0 +1,28 @@
+import { SettingsItem } from "./devices.$id.settings";
+
+import { Checkbox } from "@/components/Checkbox";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+
+import { useSettingsStore } from "@/hooks/stores";
+
+export default function SettingsCtrlAltDelRoute() {
+ const enableCtrlAltDel = useSettingsStore(state => state.actionBarCtrlAltDel);
+ const setEnableCtrlAltDel = useSettingsStore(state => state.setActionBarCtrlAltDel);
+
+ return (
+
+
+
+
+ setEnableCtrlAltDel(e.target.checked)}
+ />
+
+
+
+ );
+}
diff --git a/ui/src/routes/devices.$id.settings.hardware.tsx b/ui/src/routes/devices.$id.settings.hardware.tsx
index 82cc6a1..44a146c 100644
--- a/ui/src/routes/devices.$id.settings.hardware.tsx
+++ b/ui/src/routes/devices.$id.settings.hardware.tsx
@@ -1,9 +1,10 @@
-import { useEffect } from "react";
+import { useCallback, useEffect, useState } from "react";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsItem } from "@routes/devices.$id.settings";
import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc";
+import Checkbox from "@components/Checkbox";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
@@ -11,6 +12,14 @@ import notifications from "../notifications";
import { UsbInfoSetting } from "../components/UsbInfoSetting";
import { FeatureFlag } from "../components/FeatureFlag";
+export interface ActionBarConfig {
+ ctrlAltDel: boolean;
+}
+
+const defaultActionBarConfig: ActionBarConfig = {
+ ctrlAltDel: false,
+};
+
export default function SettingsHardwareRoute() {
const [send] = useJsonRpc();
const settings = useSettingsStore();
@@ -71,6 +80,18 @@ export default function SettingsHardwareRoute() {
});
}, [send, setBacklightSettings]);
+ const [actionBarConfig, setActionBarConfig] = useState(defaultActionBarConfig);
+
+ const onActionBarItemChange = useCallback(
+ (key: keyof ActionBarConfig) => (e: React.ChangeEvent) => {
+ setActionBarConfig(prev => ({
+ ...prev,
+ [key]: e.target.checked,
+ }));
+ },
+ [],
+ );
+
return (
+
+
+
{settings.backlightSettings.max_brightness != 0 && (
<>
Date: Tue, 27 May 2025 08:28:51 -0700
Subject: [PATCH 10/11] feat: add local web server loopback mode configuration
(#511)
* feat: add local web server loopback mode configuration
- Introduced a new configuration option `LocalWebServerLoopbackOnly` to restrict the web server to listen only on the loopback interface.
- Added RPC methods `rpcGetLocalWebServerLoopbackOnly` and `rpcSetLocalWebServerLoopbackOnly` for retrieving and updating this setting.
- Updated the web server startup logic to bind to the appropriate address based on the new configuration.
- Modified the `LocalDevice` struct to include the loopback setting in the response.
* remove extra logs
* chore: add VSCode extensions for improved development environment
* refactor: rename LocalWebServerLoopbackOnly to LocalLoopbackOnly
- Updated the configuration struct and related RPC methods to use the new name `LocalLoopbackOnly` for clarity.
- Adjusted the web server binding logic and device response structure to reflect this change.
* feat: add loopback-only mode functionality to UI
- Implemented a new setting for enabling loopback-only mode, restricting web interface access to localhost.
- Added a confirmation dialog to warn users before enabling this feature.
- Updated the ConfirmDialog component to accept React nodes for the description prop.
- Refactored imports and adjusted component structure for clarity.
* refactor: optimize device settings handlers for better performance
- Refactored the `handleDevChannelChange` and `handleLoopbackOnlyModeChange` functions to use `useCallback` for improved performance and to prevent unnecessary re-renders.
- Consolidated the logic for applying loopback-only mode into a separate `applyLoopbackOnlyMode` function, enhancing code clarity and maintainability.
- Updated the confirmation flow for enabling loopback-only mode to ensure user warnings are displayed appropriately.
---
config.go | 1 +
jsonrpc.go | 21 +++
ui/src/components/ConfirmDialog.tsx | 12 +-
.../routes/devices.$id.settings.advanced.tsx | 125 +++++++++++++++---
web.go | 20 ++-
5 files changed, 148 insertions(+), 31 deletions(-)
diff --git a/config.go b/config.go
index e699ff3..04f2a4e 100644
--- a/config.go
+++ b/config.go
@@ -85,6 +85,7 @@ type Config struct {
HashedPassword string `json:"hashed_password"`
LocalAuthToken string `json:"local_auth_token"`
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
+ LocalLoopbackOnly bool `json:"local_loopback_only"`
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
KeyboardLayout string `json:"keyboard_layout"`
diff --git a/jsonrpc.go b/jsonrpc.go
index a32cab2..258828a 100644
--- a/jsonrpc.go
+++ b/jsonrpc.go
@@ -1006,6 +1006,25 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
return nil, nil
}
+func rpcGetLocalLoopbackOnly() (bool, error) {
+ return config.LocalLoopbackOnly, nil
+}
+
+func rpcSetLocalLoopbackOnly(enabled bool) error {
+ // Check if the setting is actually changing
+ if config.LocalLoopbackOnly == enabled {
+ return nil
+ }
+
+ // Update the setting
+ config.LocalLoopbackOnly = enabled
+ if err := SaveConfig(); err != nil {
+ return fmt.Errorf("failed to save config: %w", err)
+ }
+
+ return nil
+}
+
var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing},
"reboot": {Func: rpcReboot, Params: []string{"force"}},
@@ -1083,4 +1102,6 @@ var rpcHandlers = map[string]RPCHandler{
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}},
"getKeyboardMacros": {Func: getKeyboardMacros},
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
+ "getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
+ "setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
}
diff --git a/ui/src/components/ConfirmDialog.tsx b/ui/src/components/ConfirmDialog.tsx
index 3771096..f6a3923 100644
--- a/ui/src/components/ConfirmDialog.tsx
+++ b/ui/src/components/ConfirmDialog.tsx
@@ -1,12 +1,12 @@
import {
- ExclamationTriangleIcon,
CheckCircleIcon,
+ ExclamationTriangleIcon,
InformationCircleIcon,
} from "@heroicons/react/24/outline";
-import { cx } from "@/cva.config";
import { Button } from "@/components/Button";
import Modal from "@/components/Modal";
+import { cx } from "@/cva.config";
type Variant = "danger" | "success" | "warning" | "info";
@@ -14,7 +14,7 @@ interface ConfirmDialogProps {
open: boolean;
onClose: () => void;
title: string;
- description: string;
+ description: React.ReactNode;
variant?: Variant;
confirmText?: string;
cancelText?: string | null;
@@ -84,8 +84,8 @@ export function ConfirmDialog({
>
-
-
+
+
{title}
@@ -111,4 +111,4 @@ export function ConfirmDialog({
);
-}
+}
\ No newline at end of file
diff --git a/ui/src/routes/devices.$id.settings.advanced.tsx b/ui/src/routes/devices.$id.settings.advanced.tsx
index 927178e..d1dab68 100644
--- a/ui/src/routes/devices.$id.settings.advanced.tsx
+++ b/ui/src/routes/devices.$id.settings.advanced.tsx
@@ -1,17 +1,16 @@
-
-import { useCallback, useState, useEffect } from "react";
+import { useCallback, useEffect, useState } from "react";
import { GridCard } from "@components/Card";
-import { SettingsPageHeader } from "../components/SettingsPageheader";
-import Checkbox from "../components/Checkbox";
-import { useJsonRpc } from "../hooks/useJsonRpc";
-import notifications from "../notifications";
-import { TextAreaWithLabel } from "../components/TextArea";
-import { isOnDevice } from "../main";
import { Button } from "../components/Button";
+import Checkbox from "../components/Checkbox";
+import { ConfirmDialog } from "../components/ConfirmDialog";
+import { SettingsPageHeader } from "../components/SettingsPageheader";
+import { TextAreaWithLabel } from "../components/TextArea";
import { useSettingsStore } from "../hooks/stores";
-
+import { useJsonRpc } from "../hooks/useJsonRpc";
+import { isOnDevice } from "../main";
+import notifications from "../notifications";
import { SettingsItem } from "./devices.$id.settings";
@@ -22,6 +21,8 @@ export default function SettingsAdvancedRoute() {
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
const [devChannel, setDevChannel] = useState(false);
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
+ const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);
+ const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
const settings = useSettingsStore();
@@ -46,6 +47,11 @@ export default function SettingsAdvancedRoute() {
if ("error" in resp) return;
setDevChannel(resp.result as boolean);
});
+
+ send("getLocalLoopbackOnly", {}, resp => {
+ if ("error" in resp) return;
+ setLocalLoopbackOnly(resp.result as boolean);
+ });
}, [send, setDeveloperMode]);
const getUsbEmulationState = useCallback(() => {
@@ -110,17 +116,62 @@ export default function SettingsAdvancedRoute() {
[send, setDeveloperMode],
);
- const handleDevChannelChange = (enabled: boolean) => {
- send("setDevChannelState", { enabled }, resp => {
- if ("error" in resp) {
- notifications.error(
- `Failed to set dev channel state: ${resp.error.data || "Unknown error"}`,
- );
- return;
+ const handleDevChannelChange = useCallback(
+ (enabled: boolean) => {
+ send("setDevChannelState", { enabled }, resp => {
+ if ("error" in resp) {
+ notifications.error(
+ `Failed to set dev channel state: ${resp.error.data || "Unknown error"}`,
+ );
+ return;
+ }
+ setDevChannel(enabled);
+ });
+ },
+ [send, setDevChannel],
+ );
+
+ const applyLoopbackOnlyMode = useCallback(
+ (enabled: boolean) => {
+ send("setLocalLoopbackOnly", { enabled }, resp => {
+ if ("error" in resp) {
+ notifications.error(
+ `Failed to ${enabled ? "enable" : "disable"} loopback-only mode: ${resp.error.data || "Unknown error"}`,
+ );
+ return;
+ }
+ setLocalLoopbackOnly(enabled);
+ if (enabled) {
+ notifications.success(
+ "Loopback-only mode enabled. Restart your device to apply.",
+ );
+ } else {
+ notifications.success(
+ "Loopback-only mode disabled. Restart your device to apply.",
+ );
+ }
+ });
+ },
+ [send, setLocalLoopbackOnly],
+ );
+
+ const handleLoopbackOnlyModeChange = useCallback(
+ (enabled: boolean) => {
+ // If trying to enable loopback-only mode, show warning first
+ if (enabled) {
+ setShowLoopbackWarning(true);
+ } else {
+ // If disabling, just proceed
+ applyLoopbackOnlyMode(false);
}
- setDevChannel(enabled);
- });
- };
+ },
+ [applyLoopbackOnlyMode, setShowLoopbackWarning],
+ );
+
+ const confirmLoopbackModeEnable = useCallback(() => {
+ applyLoopbackOnlyMode(true);
+ setShowLoopbackWarning(false);
+ }, [applyLoopbackOnlyMode, setShowLoopbackWarning]);
return (
@@ -153,7 +204,7 @@ export default function SettingsAdvancedRoute() {
{settings.developerMode && (
-
+
)}
+
+ handleLoopbackOnlyModeChange(e.target.checked)}
+ />
+
+
{isOnDevice && settings.developerMode && (
)}
+
+ {
+ setShowLoopbackWarning(false);
+ }}
+ title="Enable Loopback-Only Mode?"
+ description={
+ <>
+
+ WARNING: This will restrict web interface access to localhost (127.0.0.1)
+ only.
+
+ Before enabling this feature, make sure you have either:
+
+ SSH access configured and tested
+ Cloud access enabled and working
+
+ >
+ }
+ variant="warning"
+ confirmText="I Understand, Enable Anyway"
+ onConfirm={confirmLoopbackModeEnable}
+ />
);
}
diff --git a/web.go b/web.go
index 766eaf5..059915c 100644
--- a/web.go
+++ b/web.go
@@ -52,8 +52,9 @@ type ChangePasswordRequest struct {
}
type LocalDevice struct {
- AuthMode *string `json:"authMode"`
- DeviceID string `json:"deviceId"`
+ AuthMode *string `json:"authMode"`
+ DeviceID string `json:"deviceId"`
+ LoopbackOnly bool `json:"loopbackOnly"`
}
type DeviceStatus struct {
@@ -532,7 +533,15 @@ func basicAuthProtectedMiddleware(requireDeveloperMode bool) gin.HandlerFunc {
func RunWebServer() {
r := setupRouter()
- err := r.Run(":80")
+
+ // Determine the binding address based on the config
+ bindAddress := ":80" // Default to all interfaces
+ if config.LocalLoopbackOnly {
+ bindAddress = "localhost:80" // Loopback only (both IPv4 and IPv6)
+ }
+
+ logger.Info().Str("bindAddress", bindAddress).Bool("loopbackOnly", config.LocalLoopbackOnly).Msg("Starting web server")
+ err := r.Run(bindAddress)
if err != nil {
panic(err)
}
@@ -540,8 +549,9 @@ func RunWebServer() {
func handleDevice(c *gin.Context) {
response := LocalDevice{
- AuthMode: &config.LocalAuthMode,
- DeviceID: GetDeviceID(),
+ AuthMode: &config.LocalAuthMode,
+ DeviceID: GetDeviceID(),
+ LoopbackOnly: config.LocalLoopbackOnly,
}
c.JSON(http.StatusOK, response)
From 8d77d752946a2d56224d89fc43a507898855afdf Mon Sep 17 00:00:00 2001
From: Marc Brooks
Date: Fri, 30 May 2025 08:01:32 -0500
Subject: [PATCH 11/11] chore(ui): Clean up warnings (#536)
---
ui/src/components/Terminal.tsx | 6 ++---
ui/src/components/popovers/PasteModal.tsx | 2 +-
ui/src/routes/devices.$id.settings.tsx | 27 +++++++++--------------
ui/src/routes/devices.$id.tsx | 18 ++++++++++-----
ui/tsconfig.json | 1 +
5 files changed, 28 insertions(+), 26 deletions(-)
diff --git a/ui/src/components/Terminal.tsx b/ui/src/components/Terminal.tsx
index 38927ea..5451afe 100644
--- a/ui/src/components/Terminal.tsx
+++ b/ui/src/components/Terminal.tsx
@@ -61,9 +61,9 @@ function Terminal({
dataChannel,
type,
}: {
- title: string;
- dataChannel: RTCDataChannel;
- type: AvailableTerminalTypes;
+ readonly title: string;
+ readonly dataChannel: RTCDataChannel;
+ readonly type: AvailableTerminalTypes;
}) {
const enableTerminal = useUiStore(state => state.terminalType == type);
const setTerminalType = useUiStore(state => state.setTerminalType);
diff --git a/ui/src/components/popovers/PasteModal.tsx b/ui/src/components/popovers/PasteModal.tsx
index 70b3067..ed642a8 100644
--- a/ui/src/components/popovers/PasteModal.tsx
+++ b/ui/src/components/popovers/PasteModal.tsx
@@ -131,7 +131,7 @@ export default function PasteModal() {
}}
>
-
e.stopPropagation()}>
+
e.stopPropagation()} onKeyDown={e => e.stopPropagation()}>
{
if (!peerConnection) return;
if (!kvmTerminal) {
- // console.log('Creating data channel "terminal"');
setKvmTerminal(peerConnection.createDataChannel("terminal"));
}
if (!serialConsole) {
- // console.log('Creating data channel "serial"');
setSerialConsole(peerConnection.createDataChannel("serial"));
}
}, [kvmTerminal, peerConnection, serialConsole]);
@@ -755,10 +753,10 @@ export default function KvmIdRoute() {
const ConnectionStatusElement = useMemo(() => {
const hasConnectionFailed =
- connectionFailed || ["failed", "closed"].includes(peerConnectionState || "");
+ connectionFailed || ["failed", "closed"].includes(peerConnectionState ?? "");
const isPeerConnectionLoading =
- ["connecting", "new"].includes(peerConnectionState || "") ||
+ ["connecting", "new"].includes(peerConnectionState ?? "") ||
peerConnection === null;
const isDisconnected = peerConnectionState === "disconnected";
@@ -826,7 +824,7 @@ export default function KvmIdRoute() {
isLoggedIn={authMode === "password" || !!user}
userEmail={user?.email}
picture={user?.picture}
- kvmName={deviceName || "JetKVM Device"}
+ kvmName={deviceName ?? "JetKVM Device"}
/>
@@ -846,6 +844,9 @@ export default function KvmIdRoute() {
e.stopPropagation()}
+ onMouseUp={e => e.stopPropagation()}
+ onMouseDown={e => e.stopPropagation()}
onKeyUp={e => e.stopPropagation()}
onKeyDown={e => {
e.stopPropagation();
@@ -869,7 +870,12 @@ export default function KvmIdRoute() {
);
}
-function SidebarContainer({ sidebarView }: { sidebarView: string | null }) {
+interface SidebarContainerProps {
+ readonly sidebarView: string | null;
+}
+
+function SidebarContainer(props: SidebarContainerProps) {
+ const { sidebarView }= props;
return (