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] 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(() => {