Skip to content

Commit c1d771c

Browse files
ymCopilot
andauthored
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 <[email protected]> --------- Co-authored-by: Copilot <[email protected]>
1 parent 019934d commit c1d771c

File tree

6 files changed

+154
-9
lines changed

6 files changed

+154
-9
lines changed

ui/src/components/InfoBar.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export default function InfoBar() {
3737
}, [rpcDataChannel]);
3838

3939
const keyboardLedState = useHidStore(state => state.keyboardLedState);
40+
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
41+
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
4042

4143
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
4244

@@ -116,6 +118,20 @@ export default function InfoBar() {
116118
Relayed by Cloudflare
117119
</div>
118120
)}
121+
122+
{keyboardLedStateSyncAvailable ? (
123+
<div
124+
className={cx(
125+
"shrink-0 p-1 px-1.5 text-xs",
126+
keyboardLedSync !== "browser"
127+
? "text-black dark:text-white"
128+
: "text-slate-800/20 dark:text-slate-300/20",
129+
)}
130+
title={"Your keyboard LED state is managed by" + (keyboardLedSync === "browser" ? " the browser" : " the host")}
131+
>
132+
{keyboardLedSync === "browser" ? "Browser" : "Host"}
133+
</div>
134+
) : null}
119135
<div
120136
className={cx(
121137
"shrink-0 p-1 px-1.5 text-xs",

ui/src/components/VirtualKeyboard.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useShallow } from "zustand/react/shallow";
22
import { ChevronDownIcon } from "@heroicons/react/16/solid";
33
import { AnimatePresence, motion } from "framer-motion";
4-
import { useCallback, useEffect, useRef, useState } from "react";
4+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
55
import Keyboard from "react-simple-keyboard";
66

77
import Card from "@components/Card";
@@ -13,7 +13,7 @@ import "react-simple-keyboard/build/css/index.css";
1313
import AttachIconRaw from "@/assets/attach-icon.svg";
1414
import DetachIconRaw from "@/assets/detach-icon.svg";
1515
import { cx } from "@/cva.config";
16-
import { useHidStore, useUiStore } from "@/hooks/stores";
16+
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
1717
import useKeyboard from "@/hooks/useKeyboard";
1818
import { keyDisplayMap, keys, modifiers } from "@/keyboardMappings";
1919

@@ -44,6 +44,16 @@ function KeyboardWrapper() {
4444

4545
const isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState?.caps_lock));
4646

47+
// HID related states
48+
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
49+
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
50+
const isKeyboardLedManagedByHost = useMemo(() =>
51+
keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable,
52+
[keyboardLedSync, keyboardLedStateSyncAvailable],
53+
);
54+
55+
const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive);
56+
4757
const startDrag = useCallback((e: MouseEvent | TouchEvent) => {
4858
if (!keyboardRef.current) return;
4959
if (e instanceof TouchEvent && e.touches.length > 1) return;
@@ -158,11 +168,19 @@ function KeyboardWrapper() {
158168
toggleLayout();
159169

160170
if (isCapsLockActive) {
171+
if (!isKeyboardLedManagedByHost) {
172+
setIsCapsLockActive(false);
173+
}
161174
sendKeyboardEvent([keys["CapsLock"]], []);
162175
return;
163176
}
164177
}
165178

179+
// Handle caps lock state change
180+
if (isKeyCaps && !isKeyboardLedManagedByHost) {
181+
setIsCapsLockActive(!isCapsLockActive);
182+
}
183+
166184
// Collect new active keys and modifiers
167185
const newKeys = keys[cleanKey] ? [keys[cleanKey]] : [];
168186
const newModifiers =
@@ -178,7 +196,7 @@ function KeyboardWrapper() {
178196

179197
setTimeout(resetKeyboardState, 100);
180198
},
181-
[isCapsLockActive, sendKeyboardEvent, resetKeyboardState],
199+
[isCapsLockActive, isKeyboardLedManagedByHost, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive],
182200
);
183201

184202
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);

ui/src/components/WebRTCVideo.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,18 @@ export default function WebRTCVideo() {
4747
clientHeight: videoClientHeight,
4848
} = useVideoStore();
4949

50+
// HID related states
51+
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
52+
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
53+
const isKeyboardLedManagedByHost = useMemo(() =>
54+
keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable,
55+
[keyboardLedSync, keyboardLedStateSyncAvailable],
56+
);
57+
58+
const setIsNumLockActive = useHidStore(state => state.setIsNumLockActive);
59+
const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive);
60+
const setIsScrollLockActive = useHidStore(state => state.setIsScrollLockActive);
61+
5062
// RTC related states
5163
const peerConnection = useRTCStore(state => state.peerConnection);
5264

@@ -351,6 +363,12 @@ export default function WebRTCVideo() {
351363

352364
// console.log(document.activeElement);
353365

366+
if (!isKeyboardLedManagedByHost) {
367+
setIsNumLockActive(e.getModifierState("NumLock"));
368+
setIsCapsLockActive(e.getModifierState("CapsLock"));
369+
setIsScrollLockActive(e.getModifierState("ScrollLock"));
370+
}
371+
354372
if (code == "IntlBackslash" && ["`", "~"].includes(key)) {
355373
code = "Backquote";
356374
} else if (code == "Backquote" && ["§", "±"].includes(key)) {
@@ -382,6 +400,10 @@ export default function WebRTCVideo() {
382400
[
383401
handleModifierKeys,
384402
sendKeyboardEvent,
403+
isKeyboardLedManagedByHost,
404+
setIsNumLockActive,
405+
setIsCapsLockActive,
406+
setIsScrollLockActive,
385407
],
386408
);
387409

@@ -390,6 +412,12 @@ export default function WebRTCVideo() {
390412
e.preventDefault();
391413
const prev = useHidStore.getState();
392414

415+
if (!isKeyboardLedManagedByHost) {
416+
setIsNumLockActive(e.getModifierState("NumLock"));
417+
setIsCapsLockActive(e.getModifierState("CapsLock"));
418+
setIsScrollLockActive(e.getModifierState("ScrollLock"));
419+
}
420+
393421
// Filtering out the key that was just released (keys[e.code])
394422
const newKeys = prev.activeKeys.filter(k => k !== keys[e.code]).filter(Boolean);
395423

@@ -404,6 +432,10 @@ export default function WebRTCVideo() {
404432
[
405433
handleModifierKeys,
406434
sendKeyboardEvent,
435+
isKeyboardLedManagedByHost,
436+
setIsNumLockActive,
437+
setIsCapsLockActive,
438+
setIsScrollLockActive,
407439
],
408440
);
409441

ui/src/hooks/stores.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,8 @@ export const useVideoStore = create<VideoState>(set => ({
283283
},
284284
}));
285285

286+
export type KeyboardLedSync = "auto" | "browser" | "host";
287+
286288
interface SettingsState {
287289
isCursorHidden: boolean;
288290
setCursorVisibility: (enabled: boolean) => void;
@@ -305,6 +307,9 @@ interface SettingsState {
305307

306308
keyboardLayout: string;
307309
setKeyboardLayout: (layout: string) => void;
310+
311+
keyboardLedSync: KeyboardLedSync;
312+
setKeyboardLedSync: (sync: KeyboardLedSync) => void;
308313
}
309314

310315
export const useSettingsStore = create(
@@ -336,6 +341,9 @@ export const useSettingsStore = create(
336341

337342
keyboardLayout: "en-US",
338343
setKeyboardLayout: layout => set({ keyboardLayout: layout }),
344+
345+
keyboardLedSync: "auto",
346+
setKeyboardLedSync: sync => set({ keyboardLedSync: sync }),
339347
}),
340348
{
341349
name: "settings",
@@ -411,7 +419,14 @@ export interface KeyboardLedState {
411419
scroll_lock: boolean;
412420
compose: boolean;
413421
kana: boolean;
414-
}
422+
};
423+
const defaultKeyboardLedState: KeyboardLedState = {
424+
num_lock: false,
425+
caps_lock: false,
426+
scroll_lock: false,
427+
compose: false,
428+
kana: false,
429+
};
415430

416431
export interface HidState {
417432
activeKeys: number[];
@@ -433,6 +448,12 @@ export interface HidState {
433448

434449
keyboardLedState?: KeyboardLedState;
435450
setKeyboardLedState: (state: KeyboardLedState) => void;
451+
setIsNumLockActive: (active: boolean) => void;
452+
setIsCapsLockActive: (active: boolean) => void;
453+
setIsScrollLockActive: (active: boolean) => void;
454+
455+
keyboardLedStateSyncAvailable: boolean;
456+
setKeyboardLedStateSyncAvailable: (available: boolean) => void;
436457

437458
isVirtualKeyboardEnabled: boolean;
438459
setVirtualKeyboardEnabled: (enabled: boolean) => void;
@@ -444,7 +465,7 @@ export interface HidState {
444465
setUsbState: (state: HidState["usbState"]) => void;
445466
}
446467

447-
export const useHidStore = create<HidState>(set => ({
468+
export const useHidStore = create<HidState>((set, get) => ({
448469
activeKeys: [],
449470
activeModifiers: [],
450471
updateActiveKeysAndModifiers: ({ keys, modifiers }) => {
@@ -461,6 +482,24 @@ export const useHidStore = create<HidState>(set => ({
461482
setAltGrCtrlTime: time => set({ altGrCtrlTime: time }),
462483

463484
setKeyboardLedState: ledState => set({ keyboardLedState: ledState }),
485+
setIsNumLockActive: active => {
486+
const keyboardLedState = { ...(get().keyboardLedState || defaultKeyboardLedState) };
487+
keyboardLedState.num_lock = active;
488+
set({ keyboardLedState });
489+
},
490+
setIsCapsLockActive: active => {
491+
const keyboardLedState = { ...(get().keyboardLedState || defaultKeyboardLedState) };
492+
keyboardLedState.caps_lock = active;
493+
set({ keyboardLedState });
494+
},
495+
setIsScrollLockActive: active => {
496+
const keyboardLedState = { ...(get().keyboardLedState || defaultKeyboardLedState) };
497+
keyboardLedState.scroll_lock = active;
498+
set({ keyboardLedState });
499+
},
500+
501+
keyboardLedStateSyncAvailable: false,
502+
setKeyboardLedStateSyncAvailable: available => set({ keyboardLedStateSyncAvailable: available }),
464503

465504
isVirtualKeyboardEnabled: false,
466505
setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }),

ui/src/routes/devices.$id.settings.keyboard.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useCallback, useEffect } from "react";
22

3-
import { useSettingsStore } from "@/hooks/stores";
3+
import { KeyboardLedSync, useSettingsStore } from "@/hooks/stores";
44
import { useJsonRpc } from "@/hooks/useJsonRpc";
55
import notifications from "@/notifications";
66
import { SettingsPageHeader } from "@components/SettingsPageheader";
@@ -12,11 +12,20 @@ import { SettingsItem } from "./devices.$id.settings";
1212

1313
export default function SettingsKeyboardRoute() {
1414
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
15+
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
1516
const setKeyboardLayout = useSettingsStore(
1617
state => state.setKeyboardLayout,
1718
);
19+
const setKeyboardLedSync = useSettingsStore(
20+
state => state.setKeyboardLedSync,
21+
);
1822

1923
const layoutOptions = Object.entries(layouts).map(([code, language]) => { return { value: code, label: language } })
24+
const ledSyncOptions = [
25+
{ value: "auto", label: "Automatic" },
26+
{ value: "browser", label: "Browser Only" },
27+
{ value: "host", label: "Host Only" },
28+
];
2029

2130
const [send] = useJsonRpc();
2231

@@ -47,7 +56,7 @@ export default function SettingsKeyboardRoute() {
4756
<div className="space-y-4">
4857
<SettingsPageHeader
4958
title="Keyboard"
50-
description="Configure keyboard layout settings for your device"
59+
description="Configure keyboard settings for your device"
5160
/>
5261

5362
<div className="space-y-4">
@@ -69,6 +78,23 @@ export default function SettingsKeyboardRoute() {
6978
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.
7079
</p>
7180
</div>
81+
82+
<div className="space-y-4">
83+
{ /* this menu item could be renamed to plain "Keyboard layout" in the future, when also the virtual keyboard layout mappings are being implemented */ }
84+
<SettingsItem
85+
title="LED state synchronization"
86+
description="Synchronize the LED state of the keyboard with the target device"
87+
>
88+
<SelectMenuBasic
89+
size="SM"
90+
label=""
91+
fullWidth
92+
value={keyboardLedSync}
93+
onChange={e => setKeyboardLedSync(e.target.value as KeyboardLedSync)}
94+
options={ledSyncOptions}
95+
/>
96+
</SettingsItem>
97+
</div>
7298
</div>
7399
);
74100
}

ui/src/routes/devices.$id.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,8 @@ export default function KvmIdRoute() {
590590
const keyboardLedState = useHidStore(state => state.keyboardLedState);
591591
const setKeyboardLedState = useHidStore(state => state.setKeyboardLedState);
592592

593+
const setKeyboardLedStateSyncAvailable = useHidStore(state => state.setKeyboardLedStateSyncAvailable);
594+
593595
const [hasUpdated, setHasUpdated] = useState(false);
594596
const { navigateTo } = useDeviceUiNavigation();
595597

@@ -615,6 +617,7 @@ export default function KvmIdRoute() {
615617
const ledState = resp.params as KeyboardLedState;
616618
console.log("Setting keyboard led state", ledState);
617619
setKeyboardLedState(ledState);
620+
setKeyboardLedStateSyncAvailable(true);
618621
}
619622

620623
if (resp.method === "otaState") {
@@ -658,12 +661,23 @@ export default function KvmIdRoute() {
658661
if (rpcDataChannel?.readyState !== "open") return;
659662
if (keyboardLedState !== undefined) return;
660663
console.log("Requesting keyboard led state");
664+
661665
send("getKeyboardLedState", {}, resp => {
662-
if ("error" in resp) return;
666+
if ("error" in resp) {
667+
// -32601 means the method is not supported
668+
if (resp.error.code === -32601) {
669+
setKeyboardLedStateSyncAvailable(false);
670+
console.error("Failed to get keyboard led state, disabling sync", resp.error);
671+
} else {
672+
console.error("Failed to get keyboard led state", resp.error);
673+
}
674+
return;
675+
}
663676
console.log("Keyboard led state", resp.result);
664677
setKeyboardLedState(resp.result as KeyboardLedState);
678+
setKeyboardLedStateSyncAvailable(true);
665679
});
666-
}, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState]);
680+
}, [rpcDataChannel?.readyState, send, setKeyboardLedState, setKeyboardLedStateSyncAvailable, keyboardLedState]);
667681

668682
// When the update is successful, we need to refresh the client javascript and show a success modal
669683
useEffect(() => {

0 commit comments

Comments
 (0)