Compare commits

...

3 Commits

Author SHA1 Message Date
Aveline c1d771cced
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>
2025-05-23 00:59:02 +02:00
adammkelly 019934d33e
chore(ui): Allow mac address copying (#504) (#506) 2025-05-23 00:56:50 +02:00
Aveline 0c5c69f2d3
feat: sync keyboard led status (#502) 2025-05-23 00:12:18 +02:00
11 changed files with 374 additions and 69 deletions

View File

@ -1,8 +1,11 @@
package usbgadget package usbgadget
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"reflect"
"time"
) )
var keyboardConfig = gadgetConfigItem{ var keyboardConfig = gadgetConfigItem{
@ -36,6 +39,7 @@ var keyboardReportDesc = []byte{
0x81, 0x03, /* INPUT (Cnst,Var,Abs) */ 0x81, 0x03, /* INPUT (Cnst,Var,Abs) */
0x95, 0x05, /* REPORT_COUNT (5) */ 0x95, 0x05, /* REPORT_COUNT (5) */
0x75, 0x01, /* REPORT_SIZE (1) */ 0x75, 0x01, /* REPORT_SIZE (1) */
0x05, 0x08, /* USAGE_PAGE (LEDs) */ 0x05, 0x08, /* USAGE_PAGE (LEDs) */
0x19, 0x01, /* USAGE_MINIMUM (Num Lock) */ 0x19, 0x01, /* USAGE_MINIMUM (Num Lock) */
0x29, 0x05, /* USAGE_MAXIMUM (Kana) */ 0x29, 0x05, /* USAGE_MAXIMUM (Kana) */
@ -54,13 +58,139 @@ var keyboardReportDesc = []byte{
0xc0, /* END_COLLECTION */ 0xc0, /* END_COLLECTION */
} }
func (u *UsbGadget) keyboardWriteHidFile(data []byte) error { const (
if u.keyboardHidFile == nil { hidReadBufferSize = 8
var err error // https://www.usb.org/sites/default/files/documents/hid1_11.pdf
u.keyboardHidFile, err = os.OpenFile("/dev/hidg0", os.O_RDWR, 0666) // https://www.usb.org/sites/default/files/hut1_2.pdf
if err != nil { KeyboardLedMaskNumLock = 1 << 0
return fmt.Errorf("failed to open hidg0: %w", err) 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) _, err := u.keyboardHidFile.Write(data)

View File

@ -3,6 +3,7 @@
package usbgadget package usbgadget
import ( import (
"context"
"os" "os"
"path" "path"
"sync" "sync"
@ -59,6 +60,11 @@ type UsbGadget struct {
relMouseHidFile *os.File relMouseHidFile *os.File
relMouseLock sync.Mutex relMouseLock sync.Mutex
keyboardState KeyboardState
keyboardStateLock sync.Mutex
keyboardStateCtx context.Context
keyboardStateCancel context.CancelFunc
enabledDevices Devices enabledDevices Devices
strictMode bool // only intended for testing for now strictMode bool // only intended for testing for now
@ -70,6 +76,8 @@ type UsbGadget struct {
tx *UsbGadgetTransaction tx *UsbGadgetTransaction
txLock sync.Mutex txLock sync.Mutex
onKeyboardStateChange *func(state KeyboardState)
log *zerolog.Logger log *zerolog.Logger
} }
@ -96,20 +104,25 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev
config = &Config{isEmpty: true} config = &Config{isEmpty: true}
} }
keyboardCtx, keyboardCancel := context.WithCancel(context.Background())
g := &UsbGadget{ g := &UsbGadget{
name: name, name: name,
kvmGadgetPath: path.Join(gadgetPath, name), kvmGadgetPath: path.Join(gadgetPath, name),
configC1Path: path.Join(gadgetPath, name, "configs/c.1"), configC1Path: path.Join(gadgetPath, name, "configs/c.1"),
configMap: configMap, configMap: configMap,
customConfig: *config, customConfig: *config,
configLock: sync.Mutex{}, configLock: sync.Mutex{},
keyboardLock: sync.Mutex{}, keyboardLock: sync.Mutex{},
absMouseLock: sync.Mutex{}, absMouseLock: sync.Mutex{},
relMouseLock: sync.Mutex{}, relMouseLock: sync.Mutex{},
txLock: sync.Mutex{}, txLock: sync.Mutex{},
enabledDevices: *enabledDevices, keyboardStateCtx: keyboardCtx,
lastUserInput: time.Now(), keyboardStateCancel: keyboardCancel,
log: logger, keyboardState: KeyboardState{},
enabledDevices: *enabledDevices,
lastUserInput: time.Now(),
log: logger,
strictMode: config.strictMode, strictMode: config.strictMode,

View File

@ -1017,6 +1017,7 @@ var rpcHandlers = map[string]RPCHandler{
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}}, "setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
"renewDHCPLease": {Func: rpcRenewDHCPLease}, "renewDHCPLease": {Func: rpcRenewDHCPLease},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
"getKeyboardLedState": {Func: rpcGetKeyboardLedState},
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}}, "relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}}, "wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},

View File

@ -36,9 +36,9 @@ export default function InfoBar() {
console.log(`Error on DataChannel '${rpcDataChannel.label}': ${e}`); console.log(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
}, [rpcDataChannel]); }, [rpcDataChannel]);
const isCapsLockActive = useHidStore(state => state.isCapsLockActive); const keyboardLedState = useHidStore(state => state.keyboardLedState);
const isNumLockActive = useHidStore(state => state.isNumLockActive); const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
const isScrollLockActive = useHidStore(state => state.isScrollLockActive); const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse); const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
@ -118,10 +118,24 @@ export default function InfoBar() {
Relayed by Cloudflare Relayed by Cloudflare
</div> </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 <div
className={cx( className={cx(
"shrink-0 p-1 px-1.5 text-xs", "shrink-0 p-1 px-1.5 text-xs",
isCapsLockActive keyboardLedState?.caps_lock
? "text-black dark:text-white" ? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20", : "text-slate-800/20 dark:text-slate-300/20",
)} )}
@ -131,7 +145,7 @@ export default function InfoBar() {
<div <div
className={cx( className={cx(
"shrink-0 p-1 px-1.5 text-xs", "shrink-0 p-1 px-1.5 text-xs",
isNumLockActive keyboardLedState?.num_lock
? "text-black dark:text-white" ? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20", : "text-slate-800/20 dark:text-slate-300/20",
)} )}
@ -141,13 +155,23 @@ export default function InfoBar() {
<div <div
className={cx( className={cx(
"shrink-0 p-1 px-1.5 text-xs", "shrink-0 p-1 px-1.5 text-xs",
isScrollLockActive keyboardLedState?.scroll_lock
? "text-black dark:text-white" ? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20", : "text-slate-800/20 dark:text-slate-300/20",
)} )}
> >
Scroll Lock Scroll Lock
</div> </div>
{keyboardLedState?.compose ? (
<div className="shrink-0 p-1 px-1.5 text-xs">
Compose
</div>
) : null}
{keyboardLedState?.kana ? (
<div className="shrink-0 p-1 px-1.5 text-xs">
Kana
</div>
) : null}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,7 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow";
import Keyboard from "react-simple-keyboard";
import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { motion, AnimatePresence } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Keyboard from "react-simple-keyboard";
import Card from "@components/Card"; import Card from "@components/Card";
// eslint-disable-next-line import/order // eslint-disable-next-line import/order
@ -9,12 +10,12 @@ import { Button } from "@components/Button";
import "react-simple-keyboard/build/css/index.css"; 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 AttachIconRaw from "@/assets/attach-icon.svg";
import DetachIconRaw from "@/assets/detach-icon.svg";
import { cx } from "@/cva.config";
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
import useKeyboard from "@/hooks/useKeyboard";
import { keyDisplayMap, keys, modifiers } from "@/keyboardMappings";
export const DetachIcon = ({ className }: { className?: string }) => { export const DetachIcon = ({ className }: { className?: string }) => {
return <img src={DetachIconRaw} alt="Detach Icon" className={className} />; return <img src={DetachIconRaw} alt="Detach Icon" className={className} />;
@ -40,7 +41,17 @@ function KeyboardWrapper() {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 }); const [position, setPosition] = useState({ x: 0, y: 0 });
const [newPosition, setNewPosition] = useState({ x: 0, y: 0 }); const [newPosition, setNewPosition] = useState({ x: 0, y: 0 });
const isCapsLockActive = useHidStore(state => state.isCapsLockActive);
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 setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive);
const startDrag = useCallback((e: MouseEvent | TouchEvent) => { const startDrag = useCallback((e: MouseEvent | TouchEvent) => {
@ -157,14 +168,16 @@ function KeyboardWrapper() {
toggleLayout(); toggleLayout();
if (isCapsLockActive) { if (isCapsLockActive) {
setIsCapsLockActive(false); if (!isKeyboardLedManagedByHost) {
setIsCapsLockActive(false);
}
sendKeyboardEvent([keys["CapsLock"]], []); sendKeyboardEvent([keys["CapsLock"]], []);
return; return;
} }
} }
// Handle caps lock state change // Handle caps lock state change
if (isKeyCaps) { if (isKeyCaps && !isKeyboardLedManagedByHost) {
setIsCapsLockActive(!isCapsLockActive); setIsCapsLockActive(!isCapsLockActive);
} }
@ -183,7 +196,7 @@ function KeyboardWrapper() {
setTimeout(resetKeyboardState, 100); setTimeout(resetKeyboardState, 100);
}, },
[isCapsLockActive, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive], [isCapsLockActive, isKeyboardLedManagedByHost, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive],
); );
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled); const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);

View File

@ -47,6 +47,18 @@ export default function WebRTCVideo() {
clientHeight: videoClientHeight, clientHeight: videoClientHeight,
} = useVideoStore(); } = 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 // RTC related states
const peerConnection = useRTCStore(state => state.peerConnection); const peerConnection = useRTCStore(state => state.peerConnection);
@ -55,10 +67,6 @@ export default function WebRTCVideo() {
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState); const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
const isVideoLoading = !isPlaying; const isVideoLoading = !isPlaying;
// Keyboard related states
const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } =
useHidStore();
// Misc states and hooks // Misc states and hooks
const disableVideoFocusTrap = useUiStore(state => state.disableVideoFocusTrap); const disableVideoFocusTrap = useUiStore(state => state.disableVideoFocusTrap);
const [send] = useJsonRpc(); const [send] = useJsonRpc();
@ -355,9 +363,11 @@ export default function WebRTCVideo() {
// console.log(document.activeElement); // console.log(document.activeElement);
setIsNumLockActive(e.getModifierState("NumLock")); if (!isKeyboardLedManagedByHost) {
setIsCapsLockActive(e.getModifierState("CapsLock")); setIsNumLockActive(e.getModifierState("NumLock"));
setIsScrollLockActive(e.getModifierState("ScrollLock")); setIsCapsLockActive(e.getModifierState("CapsLock"));
setIsScrollLockActive(e.getModifierState("ScrollLock"));
}
if (code == "IntlBackslash" && ["`", "~"].includes(key)) { if (code == "IntlBackslash" && ["`", "~"].includes(key)) {
code = "Backquote"; code = "Backquote";
@ -388,11 +398,12 @@ export default function WebRTCVideo() {
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]); sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
}, },
[ [
handleModifierKeys,
sendKeyboardEvent,
isKeyboardLedManagedByHost,
setIsNumLockActive, setIsNumLockActive,
setIsCapsLockActive, setIsCapsLockActive,
setIsScrollLockActive, setIsScrollLockActive,
handleModifierKeys,
sendKeyboardEvent,
], ],
); );
@ -401,9 +412,11 @@ export default function WebRTCVideo() {
e.preventDefault(); e.preventDefault();
const prev = useHidStore.getState(); const prev = useHidStore.getState();
setIsNumLockActive(e.getModifierState("NumLock")); if (!isKeyboardLedManagedByHost) {
setIsCapsLockActive(e.getModifierState("CapsLock")); setIsNumLockActive(e.getModifierState("NumLock"));
setIsScrollLockActive(e.getModifierState("ScrollLock")); setIsCapsLockActive(e.getModifierState("CapsLock"));
setIsScrollLockActive(e.getModifierState("ScrollLock"));
}
// Filtering out the key that was just released (keys[e.code]) // Filtering out the key that was just released (keys[e.code])
const newKeys = prev.activeKeys.filter(k => k !== keys[e.code]).filter(Boolean); const newKeys = prev.activeKeys.filter(k => k !== keys[e.code]).filter(Boolean);
@ -417,11 +430,12 @@ export default function WebRTCVideo() {
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]); sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
}, },
[ [
handleModifierKeys,
sendKeyboardEvent,
isKeyboardLedManagedByHost,
setIsNumLockActive, setIsNumLockActive,
setIsCapsLockActive, setIsCapsLockActive,
setIsScrollLockActive, setIsScrollLockActive,
handleModifierKeys,
sendKeyboardEvent,
], ],
); );

View File

@ -283,6 +283,8 @@ export const useVideoStore = create<VideoState>(set => ({
}, },
})); }));
export type KeyboardLedSync = "auto" | "browser" | "host";
interface SettingsState { interface SettingsState {
isCursorHidden: boolean; isCursorHidden: boolean;
setCursorVisibility: (enabled: boolean) => void; setCursorVisibility: (enabled: boolean) => void;
@ -305,6 +307,9 @@ interface SettingsState {
keyboardLayout: string; keyboardLayout: string;
setKeyboardLayout: (layout: string) => void; setKeyboardLayout: (layout: string) => void;
keyboardLedSync: KeyboardLedSync;
setKeyboardLedSync: (sync: KeyboardLedSync) => void;
} }
export const useSettingsStore = create( export const useSettingsStore = create(
@ -336,6 +341,9 @@ export const useSettingsStore = create(
keyboardLayout: "en-US", keyboardLayout: "en-US",
setKeyboardLayout: layout => set({ keyboardLayout: layout }), setKeyboardLayout: layout => set({ keyboardLayout: layout }),
keyboardLedSync: "auto",
setKeyboardLedSync: sync => set({ keyboardLedSync: sync }),
}), }),
{ {
name: "settings", name: "settings",
@ -405,6 +413,21 @@ export const useMountMediaStore = create<MountMediaState>(set => ({
setErrorMessage: message => set({ errorMessage: message }), setErrorMessage: message => set({ errorMessage: message }),
})); }));
export interface KeyboardLedState {
num_lock: boolean;
caps_lock: boolean;
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 { export interface HidState {
activeKeys: number[]; activeKeys: number[];
activeModifiers: number[]; activeModifiers: number[];
@ -423,18 +446,18 @@ export interface HidState {
altGrCtrlTime: number; // _altGrCtrlTime altGrCtrlTime: number; // _altGrCtrlTime
setAltGrCtrlTime: (time: number) => void; setAltGrCtrlTime: (time: number) => void;
isNumLockActive: boolean; keyboardLedState?: KeyboardLedState;
setIsNumLockActive: (enabled: boolean) => void; setKeyboardLedState: (state: KeyboardLedState) => void;
setIsNumLockActive: (active: boolean) => void;
setIsCapsLockActive: (active: boolean) => void;
setIsScrollLockActive: (active: boolean) => void;
isScrollLockActive: boolean; keyboardLedStateSyncAvailable: boolean;
setIsScrollLockActive: (enabled: boolean) => void; setKeyboardLedStateSyncAvailable: (available: boolean) => void;
isVirtualKeyboardEnabled: boolean; isVirtualKeyboardEnabled: boolean;
setVirtualKeyboardEnabled: (enabled: boolean) => void; setVirtualKeyboardEnabled: (enabled: boolean) => void;
isCapsLockActive: boolean;
setIsCapsLockActive: (enabled: boolean) => void;
isPasteModeEnabled: boolean; isPasteModeEnabled: boolean;
setPasteModeEnabled: (enabled: boolean) => void; setPasteModeEnabled: (enabled: boolean) => void;
@ -442,7 +465,7 @@ export interface HidState {
setUsbState: (state: HidState["usbState"]) => void; setUsbState: (state: HidState["usbState"]) => void;
} }
export const useHidStore = create<HidState>(set => ({ export const useHidStore = create<HidState>((set, get) => ({
activeKeys: [], activeKeys: [],
activeModifiers: [], activeModifiers: [],
updateActiveKeysAndModifiers: ({ keys, modifiers }) => { updateActiveKeysAndModifiers: ({ keys, modifiers }) => {
@ -458,18 +481,29 @@ export const useHidStore = create<HidState>(set => ({
altGrCtrlTime: 0, altGrCtrlTime: 0,
setAltGrCtrlTime: time => set({ altGrCtrlTime: time }), setAltGrCtrlTime: time => set({ altGrCtrlTime: time }),
isNumLockActive: false, setKeyboardLedState: ledState => set({ keyboardLedState: ledState }),
setIsNumLockActive: enabled => set({ isNumLockActive: enabled }), 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 });
},
isScrollLockActive: false, keyboardLedStateSyncAvailable: false,
setIsScrollLockActive: enabled => set({ isScrollLockActive: enabled }), setKeyboardLedStateSyncAvailable: available => set({ keyboardLedStateSyncAvailable: available }),
isVirtualKeyboardEnabled: false, isVirtualKeyboardEnabled: false,
setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }), setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }),
isCapsLockActive: false,
setIsCapsLockActive: enabled => set({ isCapsLockActive: enabled }),
isPasteModeEnabled: false, isPasteModeEnabled: false,
setPasteModeEnabled: enabled => set({ isPasteModeEnabled: enabled }), setPasteModeEnabled: enabled => set({ isPasteModeEnabled: enabled }),

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useSettingsStore } from "@/hooks/stores"; import { KeyboardLedSync, useSettingsStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
@ -12,11 +12,20 @@ import { SettingsItem } from "./devices.$id.settings";
export default function SettingsKeyboardRoute() { export default function SettingsKeyboardRoute() {
const keyboardLayout = useSettingsStore(state => state.keyboardLayout); const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const setKeyboardLayout = useSettingsStore( const setKeyboardLayout = useSettingsStore(
state => state.setKeyboardLayout, state => state.setKeyboardLayout,
); );
const setKeyboardLedSync = useSettingsStore(
state => state.setKeyboardLedSync,
);
const layoutOptions = Object.entries(layouts).map(([code, language]) => { return { value: code, label: language } }) 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(); const [send] = useJsonRpc();
@ -47,7 +56,7 @@ export default function SettingsKeyboardRoute() {
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
title="Keyboard" title="Keyboard"
description="Configure keyboard layout settings for your device" description="Configure keyboard settings for your device"
/> />
<div className="space-y-4"> <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. 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> </p>
</div> </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> </div>
); );
} }

View File

@ -228,7 +228,6 @@ export default function SettingsNetworkRoute() {
size="SM" size="SM"
value={networkState?.mac_address} value={networkState?.mac_address}
error={""} error={""}
disabled={true}
readOnly={true} readOnly={true}
className="dark:!text-opacity-60" className="dark:!text-opacity-60"
/> />

View File

@ -19,6 +19,7 @@ import useWebSocket from "react-use-websocket";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { import {
HidState, HidState,
KeyboardLedState,
NetworkState, NetworkState,
UpdateState, UpdateState,
useDeviceStore, useDeviceStore,
@ -586,6 +587,11 @@ export default function KvmIdRoute() {
const setUsbState = useHidStore(state => state.setUsbState); const setUsbState = useHidStore(state => state.setUsbState);
const setHdmiState = useVideoStore(state => state.setHdmiState); const setHdmiState = useVideoStore(state => state.setHdmiState);
const keyboardLedState = useHidStore(state => state.keyboardLedState);
const setKeyboardLedState = useHidStore(state => state.setKeyboardLedState);
const setKeyboardLedStateSyncAvailable = useHidStore(state => state.setKeyboardLedStateSyncAvailable);
const [hasUpdated, setHasUpdated] = useState(false); const [hasUpdated, setHasUpdated] = useState(false);
const { navigateTo } = useDeviceUiNavigation(); const { navigateTo } = useDeviceUiNavigation();
@ -607,6 +613,13 @@ export default function KvmIdRoute() {
setNetworkState(resp.params as NetworkState); setNetworkState(resp.params as NetworkState);
} }
if (resp.method === "keyboardLedState") {
const ledState = resp.params as KeyboardLedState;
console.log("Setting keyboard led state", ledState);
setKeyboardLedState(ledState);
setKeyboardLedStateSyncAvailable(true);
}
if (resp.method === "otaState") { if (resp.method === "otaState") {
const otaState = resp.params as UpdateState["otaState"]; const otaState = resp.params as UpdateState["otaState"];
setOtaState(otaState); setOtaState(otaState);
@ -643,6 +656,29 @@ export default function KvmIdRoute() {
}); });
}, [rpcDataChannel?.readyState, send, setHdmiState]); }, [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) {
// -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, setKeyboardLedStateSyncAvailable, keyboardLedState]);
// When the update is successful, we need to refresh the client javascript and show a success modal // When the update is successful, we need to refresh the client javascript and show a success modal
useEffect(() => { useEffect(() => {
if (queryParams.get("updateSuccess")) { if (queryParams.get("updateSuccess")) {

15
usb.go
View File

@ -24,6 +24,17 @@ func initUsbGadget() {
time.Sleep(500 * time.Millisecond) 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 { func rpcKeyboardReport(modifier uint8, keys []uint8) error {
@ -42,6 +53,10 @@ func rpcWheelReport(wheelY int8) error {
return gadget.AbsMouseWheelReport(wheelY) return gadget.AbsMouseWheelReport(wheelY)
} }
func rpcGetKeyboardLedState() (state usbgadget.KeyboardState) {
return gadget.GetKeyboardState()
}
var usbState = "unknown" var usbState = "unknown"
func rpcGetUSBState() (state string) { func rpcGetUSBState() (state string) {