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 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/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/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) } diff --git a/jsonrpc.go b/jsonrpc.go index db25c6d..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"}}, @@ -1017,6 +1036,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"}}, @@ -1082,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/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 && ( +
+
+ )}
-
-

+
+

{title}

@@ -111,4 +111,4 @@ export function ConfirmDialog({
); -} +} \ No newline at end of file diff --git a/ui/src/components/InfoBar.tsx b/ui/src/components/InfoBar.tsx index aa00da7..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; @@ -36,9 +37,9 @@ 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 keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable); + const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync); const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse); @@ -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(", ")} +

+
+ )}

@@ -118,10 +121,24 @@ export default function InfoBar() { Relayed by Cloudflare
)} + + {keyboardLedStateSyncAvailable ? ( +
+ {keyboardLedSync === "browser" ? "Browser" : "Host"} +
+ ) : null}
Scroll Lock
+ {keyboardLedState?.compose ? ( +
+ Compose +
+ ) : null} + {keyboardLedState?.kana ? ( +
+ Kana +
+ ) : null} 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/VirtualKeyboard.tsx b/ui/src/components/VirtualKeyboard.tsx index 03cb331..4ff04a9 100644 --- a/ui/src/components/VirtualKeyboard.tsx +++ b/ui/src/components/VirtualKeyboard.tsx @@ -1,7 +1,8 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import Keyboard from "react-simple-keyboard"; +import { useShallow } from "zustand/react/shallow"; 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"; // 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, useSettingsStore, useUiStore } from "@/hooks/stores"; +import useKeyboard from "@/hooks/useKeyboard"; +import { keyDisplayMap, keys, modifiers } from "@/keyboardMappings"; export const DetachIcon = ({ className }: { className?: string }) => { return Detach Icon; @@ -40,7 +41,17 @@ 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 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) => { @@ -157,14 +168,16 @@ function KeyboardWrapper() { toggleLayout(); if (isCapsLockActive) { - setIsCapsLockActive(false); + if (!isKeyboardLedManagedByHost) { + setIsCapsLockActive(false); + } sendKeyboardEvent([keys["CapsLock"]], []); return; } } // Handle caps lock state change - if (isKeyCaps) { + if (isKeyCaps && !isKeyboardLedManagedByHost) { setIsCapsLockActive(!isCapsLockActive); } @@ -183,7 +196,7 @@ function KeyboardWrapper() { setTimeout(resetKeyboardState, 100); }, - [isCapsLockActive, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive], + [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 4fd4290..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); @@ -55,10 +67,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,9 +363,11 @@ export default function WebRTCVideo() { // console.log(document.activeElement); - setIsNumLockActive(e.getModifierState("NumLock")); - setIsCapsLockActive(e.getModifierState("CapsLock")); - setIsScrollLockActive(e.getModifierState("ScrollLock")); + if (!isKeyboardLedManagedByHost) { + setIsNumLockActive(e.getModifierState("NumLock")); + setIsCapsLockActive(e.getModifierState("CapsLock")); + setIsScrollLockActive(e.getModifierState("ScrollLock")); + } if (code == "IntlBackslash" && ["`", "~"].includes(key)) { code = "Backquote"; @@ -388,11 +398,12 @@ export default function WebRTCVideo() { sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]); }, [ + handleModifierKeys, + sendKeyboardEvent, + isKeyboardLedManagedByHost, setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive, - handleModifierKeys, - sendKeyboardEvent, ], ); @@ -401,9 +412,11 @@ export default function WebRTCVideo() { e.preventDefault(); const prev = useHidStore.getState(); - setIsNumLockActive(e.getModifierState("NumLock")); - setIsCapsLockActive(e.getModifierState("CapsLock")); - setIsScrollLockActive(e.getModifierState("ScrollLock")); + 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); @@ -417,11 +430,12 @@ export default function WebRTCVideo() { sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]); }, [ + handleModifierKeys, + sendKeyboardEvent, + isKeyboardLedManagedByHost, setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive, - handleModifierKeys, - sendKeyboardEvent, ], ); diff --git a/ui/src/components/popovers/PasteModal.tsx b/ui/src/components/popovers/PasteModal.tsx index 26e4b82..ed642a8 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) { @@ -125,7 +131,7 @@ export default function PasteModal() { }} >
-
e.stopPropagation()}> +
e.stopPropagation()} onKeyDown={e => e.stopPropagation()}> 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/hooks/stores.ts b/ui/src/hooks/stores.ts index 01d1257..43b9273 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,15 @@ interface SettingsState { keyboardLayout: string; setKeyboardLayout: (layout: string) => void; + + actionBarCtrlAltDel: boolean; + setActionBarCtrlAltDel: (enabled: boolean) => void; + + keyboardLedSync: KeyboardLedSync; + setKeyboardLedSync: (sync: KeyboardLedSync) => void; + + showPressedKeys: boolean; + setShowPressedKeys: (show: boolean) => void; } export const useSettingsStore = create( @@ -336,6 +347,15 @@ 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 }), + + showPressedKeys: true, + setShowPressedKeys: show => set({ showPressedKeys: show }), }), { name: "settings", @@ -344,17 +364,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; @@ -405,6 +414,21 @@ 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; +}; +const defaultKeyboardLedState: KeyboardLedState = { + num_lock: false, + caps_lock: false, + scroll_lock: false, + compose: false, + kana: false, +}; + export interface HidState { activeKeys: number[]; activeModifiers: number[]; @@ -423,18 +447,18 @@ export interface HidState { altGrCtrlTime: number; // _altGrCtrlTime setAltGrCtrlTime: (time: number) => void; - isNumLockActive: boolean; - setIsNumLockActive: (enabled: boolean) => void; + keyboardLedState?: KeyboardLedState; + setKeyboardLedState: (state: KeyboardLedState) => void; + setIsNumLockActive: (active: boolean) => void; + setIsCapsLockActive: (active: boolean) => void; + setIsScrollLockActive: (active: boolean) => void; - isScrollLockActive: boolean; - setIsScrollLockActive: (enabled: boolean) => void; + keyboardLedStateSyncAvailable: boolean; + setKeyboardLedStateSyncAvailable: (available: boolean) => void; isVirtualKeyboardEnabled: boolean; setVirtualKeyboardEnabled: (enabled: boolean) => void; - isCapsLockActive: boolean; - setIsCapsLockActive: (enabled: boolean) => void; - isPasteModeEnabled: boolean; setPasteModeEnabled: (enabled: boolean) => void; @@ -442,7 +466,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 }) => { @@ -458,18 +482,29 @@ export const useHidStore = create(set => ({ altGrCtrlTime: 0, setAltGrCtrlTime: time => set({ altGrCtrlTime: time }), - isNumLockActive: false, - setIsNumLockActive: enabled => set({ isNumLockActive: enabled }), + 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 }); + }, - isScrollLockActive: false, - setIsScrollLockActive: enabled => set({ isScrollLockActive: enabled }), + keyboardLedStateSyncAvailable: false, + setKeyboardLedStateSyncAvailable: available => set({ keyboardLedStateSyncAvailable: available }), 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.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/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 && ( <> 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(() => { + 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" }, + { value: "browser", label: "Browser Only" }, + { value: "host", label: "Host Only" }, + ]; const [send] = useJsonRpc(); @@ -47,7 +68,7 @@ export default function SettingsKeyboardRoute() {
@@ -60,7 +81,7 @@ export default function SettingsKeyboardRoute() { size="SM" label="" fullWidth - value={keyboardLayout} + value={safeKeyboardLayout} onChange={onKeyboardLayoutChange} options={layoutOptions} /> @@ -69,6 +90,35 @@ 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} + /> + +
+ +
+ + setShowPressedKeys(e.target.checked)} + /> + +
); } 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" /> diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx index 5b277a1..1e888f6 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -269,22 +269,17 @@ export default function SettingsRoute() { ); } -export function SettingsItem({ - title, - description, - children, - className, - loading, - badge, -}: { - title: string; - description: string | React.ReactNode; - children?: React.ReactNode; - className?: string; - name?: string; - loading?: boolean; - badge?: string; -}) { +interface SettingsItemProps { + readonly title: string; + readonly description: string | React.ReactNode; + readonly badge?: string; + readonly className?: string; + readonly loading?: boolean; + readonly children?: React.ReactNode; +} +export function SettingsItem(props: SettingsItemProps) { + const { title, description, badge, children, className, loading } = props; + return (