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>
This commit is contained in:
Aveline 2025-05-23 00:59:02 +02:00 committed by GitHub
parent 019934d33e
commit c1d771cced
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 154 additions and 9 deletions

View File

@ -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
</div>
)}
{keyboardLedStateSyncAvailable ? (
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
keyboardLedSync !== "browser"
? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20",
)}
title={"Your keyboard LED state is managed by" + (keyboardLedSync === "browser" ? " the browser" : " the host")}
>
{keyboardLedSync === "browser" ? "Browser" : "Host"}
</div>
) : null}
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",

View File

@ -1,7 +1,7 @@
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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Keyboard from "react-simple-keyboard";
import Card from "@components/Card";
@ -13,7 +13,7 @@ import "react-simple-keyboard/build/css/index.css";
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 { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
import useKeyboard from "@/hooks/useKeyboard";
import { keyDisplayMap, keys, modifiers } from "@/keyboardMappings";
@ -44,6 +44,16 @@ function KeyboardWrapper() {
const isCapsLockActive = useHidStore(useShallow(state => 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);

View File

@ -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,
],
);

View File

@ -283,6 +283,8 @@ export const useVideoStore = create<VideoState>(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<HidState>(set => ({
export const useHidStore = create<HidState>((set, get) => ({
activeKeys: [],
activeModifiers: [],
updateActiveKeysAndModifiers: ({ keys, modifiers }) => {
@ -461,6 +482,24 @@ export const useHidStore = create<HidState>(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 }),

View File

@ -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() {
<div className="space-y-4">
<SettingsPageHeader
title="Keyboard"
description="Configure keyboard layout settings for your device"
description="Configure keyboard settings for your device"
/>
<div className="space-y-4">
@ -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.
</p>
</div>
<div className="space-y-4">
{ /* this menu item could be renamed to plain "Keyboard layout" in the future, when also the virtual keyboard layout mappings are being implemented */ }
<SettingsItem
title="LED state synchronization"
description="Synchronize the LED state of the keyboard with the target device"
>
<SelectMenuBasic
size="SM"
label=""
fullWidth
value={keyboardLedSync}
onChange={e => setKeyboardLedSync(e.target.value as KeyboardLedSync)}
options={ledSyncOptions}
/>
</SettingsItem>
</div>
</div>
);
}

View File

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