Merge branch 'jetkvm:dev' into dev

This commit is contained in:
adammkelly 2025-05-31 21:34:08 +02:00 committed by GitHub
commit a0609a5f86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 787 additions and 175 deletions

View File

@ -9,6 +9,19 @@
}, },
"mounts": [ "mounts": [
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached" "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"
]
}
}
} }

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"tailwindCSS.classFunctions": ["cva", "cx"]
}

View File

@ -85,6 +85,7 @@ type Config struct {
HashedPassword string `json:"hashed_password"` HashedPassword string `json:"hashed_password"`
LocalAuthToken string `json:"local_auth_token"` LocalAuthToken string `json:"local_auth_token"`
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
LocalLoopbackOnly bool `json:"local_loopback_only"`
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"` KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
KeyboardLayout string `json:"keyboard_layout"` KeyboardLayout string `json:"keyboard_layout"`

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

@ -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)
}
}

View File

@ -2,6 +2,7 @@ package websecure
import ( import (
"crypto/ecdsa" "crypto/ecdsa"
"crypto/ed25519"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"crypto/tls" "crypto/tls"
@ -37,11 +38,15 @@ func keyToFile(cert *tls.Certificate, filename string) error {
if e != nil { if e != nil {
return fmt.Errorf("failed to marshal EC private key: %v", e) return fmt.Errorf("failed to marshal EC private key: %v", e)
} }
keyBlock = pem.Block{ keyBlock = pem.Block{
Type: "EC PRIVATE KEY", Type: "EC PRIVATE KEY",
Bytes: b, Bytes: b,
} }
case ed25519.PrivateKey:
keyBlock = pem.Block{
Type: "ED25519 PRIVATE KEY",
Bytes: k,
}
default: default:
return fmt.Errorf("unknown private key type: %T", k) return fmt.Errorf("unknown private key type: %T", k)
} }

View File

@ -1006,6 +1006,25 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
return nil, nil 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{ var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing}, "ping": {Func: rpcPing},
"reboot": {Func: rpcReboot, Params: []string{"force"}}, "reboot": {Func: rpcReboot, Params: []string{"force"}},
@ -1017,6 +1036,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"}},
@ -1082,4 +1102,6 @@ var rpcHandlers = map[string]RPCHandler{
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}}, "setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}},
"getKeyboardMacros": {Func: getKeyboardMacros}, "getKeyboardMacros": {Func: getKeyboardMacros},
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}}, "setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
} }

View File

@ -1,6 +1,6 @@
import { MdOutlineContentPasteGo } from "react-icons/md"; import { MdOutlineContentPasteGo } from "react-icons/md";
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu"; 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 { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { Fragment, useCallback, useRef } from "react"; import { Fragment, useCallback, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid"; 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 MountPopopover from "@/components/popovers/MountPopover";
import ExtensionPopover from "@/components/popovers/ExtensionPopover"; import ExtensionPopover from "@/components/popovers/ExtensionPopover";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import useKeyboard from "@/hooks/useKeyboard";
import { keys, modifiers } from "@/keyboardMappings";
export default function Actionbar({ export default function Actionbar({
requestFullscreen, requestFullscreen,
@ -56,6 +58,8 @@ export default function Actionbar({
[setDisableFocusTrap], [setDisableFocusTrap],
); );
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
return ( return (
<Container className="border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900"> <Container className="border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900">
<div <div
@ -262,7 +266,23 @@ export default function Actionbar({
}} }}
/> />
</div> </div>
{useSettingsStore().actionBarCtrlAltDel && (
<div className="hidden lg:block">
<Button
size="XS"
theme="light"
text="Ctrl + Alt + Del"
LeadingIcon={FaLock}
onClick={() => {
sendKeyboardEvent(
[keys["Delete"]],
[modifiers["ControlLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
}}
/>
</div>
)}
<div> <div>
<Button <Button
size="XS" size="XS"

View File

@ -1,12 +1,12 @@
import { import {
ExclamationTriangleIcon,
CheckCircleIcon, CheckCircleIcon,
ExclamationTriangleIcon,
InformationCircleIcon, InformationCircleIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { cx } from "@/cva.config";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import { cx } from "@/cva.config";
type Variant = "danger" | "success" | "warning" | "info"; type Variant = "danger" | "success" | "warning" | "info";
@ -14,7 +14,7 @@ interface ConfirmDialogProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
title: string; title: string;
description: string; description: React.ReactNode;
variant?: Variant; variant?: Variant;
confirmText?: string; confirmText?: string;
cancelText?: string | null; cancelText?: string | null;
@ -84,8 +84,8 @@ export function ConfirmDialog({
> >
<Icon aria-hidden="true" className={cx("size-6", iconClass)} /> <Icon aria-hidden="true" className={cx("size-6", iconClass)} />
</div> </div>
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left"> <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h2 className="text-lg font-bold leading-tight text-black dark:text-white"> <h2 className="text-lg leading-tight font-bold text-black dark:text-white">
{title} {title}
</h2> </h2>
<div className="mt-2 text-sm leading-snug text-slate-600 dark:text-slate-400"> <div className="mt-2 text-sm leading-snug text-slate-600 dark:text-slate-400">

View File

@ -28,6 +28,7 @@ export default function InfoBar() {
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const settings = useSettingsStore(); const settings = useSettingsStore();
const showPressedKeys = useSettingsStore(state => state.showPressedKeys);
useEffect(() => { useEffect(() => {
if (!rpcDataChannel) return; if (!rpcDataChannel) return;
@ -36,9 +37,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);
@ -97,19 +98,21 @@ export default function InfoBar() {
</div> </div>
)} )}
<div className="flex items-center gap-x-1"> {showPressedKeys && (
<span className="text-xs font-semibold">Keys:</span> <div className="flex items-center gap-x-1">
<h2 className="text-xs"> <span className="text-xs font-semibold">Keys:</span>
{[ <h2 className="text-xs">
...activeKeys.map( {[
x => Object.entries(keys).filter(y => y[1] === x)[0][0], ...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], activeModifiers.map(
), x => Object.entries(modifiers).filter(y => y[1] === x)[0][0],
].join(", ")} ),
</h2> ].join(", ")}
</div> </h2>
</div>
)}
</div> </div>
</div> </div>
<div className="flex items-center divide-x first:divide-l divide-slate-800/20 dark:divide-slate-300/20"> <div className="flex items-center divide-x first:divide-l divide-slate-800/20 dark:divide-slate-300/20">
@ -118,10 +121,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 +148,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 +158,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

@ -61,9 +61,9 @@ function Terminal({
dataChannel, dataChannel,
type, type,
}: { }: {
title: string; readonly title: string;
dataChannel: RTCDataChannel; readonly dataChannel: RTCDataChannel;
type: AvailableTerminalTypes; readonly type: AvailableTerminalTypes;
}) { }) {
const enableTerminal = useUiStore(state => state.terminalType == type); const enableTerminal = useUiStore(state => state.terminalType == type);
const setTerminalType = useUiStore(state => state.setTerminalType); const setTerminalType = useUiStore(state => state.setTerminalType);

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

@ -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 { LuCornerDownLeft } from "react-icons/lu";
import { ExclamationCircleIcon } from "@heroicons/react/16/solid"; import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
import { useClose } from "@headlessui/react"; import { useClose } from "@headlessui/react";
@ -39,6 +39,13 @@ export default function PasteModal() {
state => state.setKeyboardLayout, 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(() => { useEffect(() => {
send("getKeyboardLayout", {}, resp => { send("getKeyboardLayout", {}, resp => {
if ("error" in resp) return; if ("error" in resp) return;
@ -56,29 +63,28 @@ export default function PasteModal() {
setPasteMode(false); setPasteMode(false);
setDisableVideoFocusTrap(false); setDisableVideoFocusTrap(false);
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return; if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
if (!keyboardLayout) return; if (!safeKeyboardLayout) return;
if (!chars[keyboardLayout]) return; if (!chars[safeKeyboardLayout]) return;
const text = TextAreaRef.current.value; const text = TextAreaRef.current.value;
try { try {
for (const char of text) { 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; if (!key) continue;
const keyz = [ keys[key] ]; const keyz = [ keys[key] ];
const modz = [ modifierCode(shift, altRight) ]; const modz = [ modifierCode(shift, altRight) ];
if (deadKey) { if (deadKey) {
keyz.push(keys["Space"]); keyz.push(keys["Space"]);
modz.push(noModifier); modz.push(noModifier);
} }
if (accentKey) { if (accentKey) {
keyz.unshift(keys[accentKey.key]) keyz.unshift(keys[accentKey.key])
modz.unshift(modifierCode(accentKey.shift, accentKey.altRight)) modz.unshift(modifierCode(accentKey.shift, accentKey.altRight))
} }
for (const [index, kei] of keyz.entries()) { for (const [index, kei] of keyz.entries()) {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
send( send(
"keyboardReport", "keyboardReport",
@ -92,13 +98,13 @@ export default function PasteModal() {
}, },
); );
}); });
} }
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
notifications.error("Failed to paste text"); notifications.error("Failed to paste text");
} }
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, keyboardLayout]); }, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, safeKeyboardLayout]);
useEffect(() => { useEffect(() => {
if (TextAreaRef.current) { if (TextAreaRef.current) {
@ -125,7 +131,7 @@ export default function PasteModal() {
}} }}
> >
<div> <div>
<div className="w-full" onKeyUp={e => e.stopPropagation()}> <div className="w-full" onKeyUp={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}>
<TextAreaWithLabel <TextAreaWithLabel
ref={TextAreaRef} ref={TextAreaRef}
label="Paste from host" label="Paste from host"
@ -148,7 +154,7 @@ export default function PasteModal() {
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments // @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
[...new Intl.Segmenter().segment(value)] [...new Intl.Segmenter().segment(value)]
.map(x => x.segment) .map(x => x.segment)
.filter(char => !chars[keyboardLayout][char]), .filter(char => !chars[safeKeyboardLayout][char]),
), ),
]; ];
@ -167,11 +173,11 @@ export default function PasteModal() {
)} )}
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<p className="text-xs text-slate-600 dark:text-slate-400"> <p className="text-xs text-slate-600 dark:text-slate-400">
Sending text using keyboard layout: {layouts[keyboardLayout]} Sending text using keyboard layout: {layouts[safeKeyboardLayout]}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>

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,15 @@ interface SettingsState {
keyboardLayout: string; keyboardLayout: string;
setKeyboardLayout: (layout: string) => void; 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( export const useSettingsStore = create(
@ -336,6 +347,15 @@ export const useSettingsStore = create(
keyboardLayout: "en-US", keyboardLayout: "en-US",
setKeyboardLayout: layout => set({ keyboardLayout: layout }), 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", 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 { export interface RemoteVirtualMediaState {
source: "WebRTC" | "HTTP" | "Storage" | null; source: "WebRTC" | "HTTP" | "Storage" | null;
mode: "CDROM" | "Disk" | null; mode: "CDROM" | "Disk" | null;
@ -405,6 +414,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 +447,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 +466,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 +482,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,17 +1,16 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState, useEffect } from "react";
import { GridCard } from "@components/Card"; 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 { 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 { useSettingsStore } from "../hooks/stores";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { isOnDevice } from "../main";
import notifications from "../notifications";
import { SettingsItem } from "./devices.$id.settings"; import { SettingsItem } from "./devices.$id.settings";
@ -22,6 +21,8 @@ export default function SettingsAdvancedRoute() {
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode); const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
const [devChannel, setDevChannel] = useState(false); const [devChannel, setDevChannel] = useState(false);
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false); const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);
const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
const settings = useSettingsStore(); const settings = useSettingsStore();
@ -46,6 +47,11 @@ export default function SettingsAdvancedRoute() {
if ("error" in resp) return; if ("error" in resp) return;
setDevChannel(resp.result as boolean); setDevChannel(resp.result as boolean);
}); });
send("getLocalLoopbackOnly", {}, resp => {
if ("error" in resp) return;
setLocalLoopbackOnly(resp.result as boolean);
});
}, [send, setDeveloperMode]); }, [send, setDeveloperMode]);
const getUsbEmulationState = useCallback(() => { const getUsbEmulationState = useCallback(() => {
@ -110,17 +116,62 @@ export default function SettingsAdvancedRoute() {
[send, setDeveloperMode], [send, setDeveloperMode],
); );
const handleDevChannelChange = (enabled: boolean) => { const handleDevChannelChange = useCallback(
send("setDevChannelState", { enabled }, resp => { (enabled: boolean) => {
if ("error" in resp) { send("setDevChannelState", { enabled }, resp => {
notifications.error( if ("error" in resp) {
`Failed to set dev channel state: ${resp.error.data || "Unknown error"}`, notifications.error(
); `Failed to set dev channel state: ${resp.error.data || "Unknown error"}`,
return; );
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 ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -153,7 +204,7 @@ export default function SettingsAdvancedRoute() {
{settings.developerMode && ( {settings.developerMode && (
<GridCard> <GridCard>
<div className="flex select-none items-start gap-x-4 p-4"> <div className="flex items-start gap-x-4 p-4 select-none">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -187,6 +238,16 @@ export default function SettingsAdvancedRoute() {
</GridCard> </GridCard>
)} )}
<SettingsItem
title="Loopback-Only Mode"
description="Restrict web interface access to localhost only (127.0.0.1)"
>
<Checkbox
checked={localLoopbackOnly}
onChange={e => handleLoopbackOnlyModeChange(e.target.checked)}
/>
</SettingsItem>
{isOnDevice && settings.developerMode && ( {isOnDevice && settings.developerMode && (
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem <SettingsItem
@ -261,6 +322,30 @@ export default function SettingsAdvancedRoute() {
</> </>
)} )}
</div> </div>
<ConfirmDialog
open={showLoopbackWarning}
onClose={() => {
setShowLoopbackWarning(false);
}}
title="Enable Loopback-Only Mode?"
description={
<>
<p>
WARNING: This will restrict web interface access to localhost (127.0.0.1)
only.
</p>
<p>Before enabling this feature, make sure you have either:</p>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
<li>SSH access configured and tested</li>
<li>Cloud access enabled and working</li>
</ul>
</>
}
variant="warning"
confirmText="I Understand, Enable Anyway"
onConfirm={confirmLoopbackModeEnable}
/>
</div> </div>
); );
} }

View File

@ -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 (
<div className="space-y-4">
<SettingsPageHeader
title="Action Bar"
description="Customize the action bar of your JetKVM interface"
/>
<div className="space-y-4">
<SettingsItem title="Enable Ctrl-Alt-Del" description="Enable the Ctrl-Alt-Del key on the virtual keyboard">
<Checkbox
checked={enableCtrlAltDel}
onChange={e => setEnableCtrlAltDel(e.target.checked)}
/>
</SettingsItem>
</div>
</div>
);
}

View File

@ -1,9 +1,10 @@
import { useEffect } from "react"; import { useCallback, useEffect, useState } from "react";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsItem } from "@routes/devices.$id.settings"; import { SettingsItem } from "@routes/devices.$id.settings";
import { BacklightSettings, useSettingsStore } from "@/hooks/stores"; import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import Checkbox from "@components/Checkbox";
import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting"; import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
@ -11,6 +12,14 @@ import notifications from "../notifications";
import { UsbInfoSetting } from "../components/UsbInfoSetting"; import { UsbInfoSetting } from "../components/UsbInfoSetting";
import { FeatureFlag } from "../components/FeatureFlag"; import { FeatureFlag } from "../components/FeatureFlag";
export interface ActionBarConfig {
ctrlAltDel: boolean;
}
const defaultActionBarConfig: ActionBarConfig = {
ctrlAltDel: false,
};
export default function SettingsHardwareRoute() { export default function SettingsHardwareRoute() {
const [send] = useJsonRpc(); const [send] = useJsonRpc();
const settings = useSettingsStore(); const settings = useSettingsStore();
@ -71,6 +80,18 @@ export default function SettingsHardwareRoute() {
}); });
}, [send, setBacklightSettings]); }, [send, setBacklightSettings]);
const [actionBarConfig, setActionBarConfig] = useState<ActionBarConfig>(defaultActionBarConfig);
const onActionBarItemChange = useCallback(
(key: keyof ActionBarConfig) => (e: React.ChangeEvent<HTMLInputElement>) => {
setActionBarConfig(prev => ({
...prev,
[key]: e.target.checked,
}));
},
[],
);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
@ -116,6 +137,15 @@ export default function SettingsHardwareRoute() {
}} }}
/> />
</SettingsItem> </SettingsItem>
<SettingsItem
title="Enable Ctrl+Alt+Del Action Bar"
description="Enable or disable the action bar action for sending a Ctrl+Alt+Del to the host"
>
<Checkbox
checked={actionBarConfig.ctrlAltDel}
onChange={onActionBarItemChange("ctrlAltDel")}
/>
</SettingsItem>
{settings.backlightSettings.max_brightness != 0 && ( {settings.backlightSettings.max_brightness != 0 && (
<> <>
<SettingsItem <SettingsItem

View File

@ -1,10 +1,11 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect, useMemo } 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";
import { layouts } from "@/keyboardLayouts"; import { layouts } from "@/keyboardLayouts";
import { Checkbox } from "@/components/Checkbox";
import { SelectMenuBasic } from "../components/SelectMenuBasic"; import { SelectMenuBasic } from "../components/SelectMenuBasic";
@ -12,11 +13,31 @@ 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 showPressedKeys = useSettingsStore(state => state.showPressedKeys);
const setKeyboardLayout = useSettingsStore( const setKeyboardLayout = useSettingsStore(
state => state.setKeyboardLayout, 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 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 +68,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">
@ -60,7 +81,7 @@ export default function SettingsKeyboardRoute() {
size="SM" size="SM"
label="" label=""
fullWidth fullWidth
value={keyboardLayout} value={safeKeyboardLayout}
onChange={onKeyboardLayoutChange} onChange={onKeyboardLayoutChange}
options={layoutOptions} 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. 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 className="space-y-4">
<SettingsItem
title="Show Pressed Keys"
description="Display currently pressed keys in the status bar"
>
<Checkbox
checked={showPressedKeys}
onChange={e => setShowPressedKeys(e.target.checked)}
/>
</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

@ -269,22 +269,17 @@ export default function SettingsRoute() {
); );
} }
export function SettingsItem({ interface SettingsItemProps {
title, readonly title: string;
description, readonly description: string | React.ReactNode;
children, readonly badge?: string;
className, readonly className?: string;
loading, readonly loading?: boolean;
badge, readonly children?: React.ReactNode;
}: { }
title: string; export function SettingsItem(props: SettingsItemProps) {
description: string | React.ReactNode; const { title, description, badge, children, className, loading } = props;
children?: React.ReactNode;
className?: string;
name?: string;
loading?: boolean;
badge?: string;
}) {
return ( return (
<label <label
className={cx( className={cx(

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")) {
@ -679,12 +715,10 @@ export default function KvmIdRoute() {
useEffect(() => { useEffect(() => {
if (!peerConnection) return; if (!peerConnection) return;
if (!kvmTerminal) { if (!kvmTerminal) {
// console.log('Creating data channel "terminal"');
setKvmTerminal(peerConnection.createDataChannel("terminal")); setKvmTerminal(peerConnection.createDataChannel("terminal"));
} }
if (!serialConsole) { if (!serialConsole) {
// console.log('Creating data channel "serial"');
setSerialConsole(peerConnection.createDataChannel("serial")); setSerialConsole(peerConnection.createDataChannel("serial"));
} }
}, [kvmTerminal, peerConnection, serialConsole]); }, [kvmTerminal, peerConnection, serialConsole]);
@ -719,10 +753,10 @@ export default function KvmIdRoute() {
const ConnectionStatusElement = useMemo(() => { const ConnectionStatusElement = useMemo(() => {
const hasConnectionFailed = const hasConnectionFailed =
connectionFailed || ["failed", "closed"].includes(peerConnectionState || ""); connectionFailed || ["failed", "closed"].includes(peerConnectionState ?? "");
const isPeerConnectionLoading = const isPeerConnectionLoading =
["connecting", "new"].includes(peerConnectionState || "") || ["connecting", "new"].includes(peerConnectionState ?? "") ||
peerConnection === null; peerConnection === null;
const isDisconnected = peerConnectionState === "disconnected"; const isDisconnected = peerConnectionState === "disconnected";
@ -790,7 +824,7 @@ export default function KvmIdRoute() {
isLoggedIn={authMode === "password" || !!user} isLoggedIn={authMode === "password" || !!user}
userEmail={user?.email} userEmail={user?.email}
picture={user?.picture} picture={user?.picture}
kvmName={deviceName || "JetKVM Device"} kvmName={deviceName ?? "JetKVM Device"}
/> />
<div className="relative flex h-full w-full overflow-hidden"> <div className="relative flex h-full w-full overflow-hidden">
@ -810,6 +844,9 @@ export default function KvmIdRoute() {
<div <div
className="z-50" className="z-50"
onClick={e => e.stopPropagation()}
onMouseUp={e => e.stopPropagation()}
onMouseDown={e => e.stopPropagation()}
onKeyUp={e => e.stopPropagation()} onKeyUp={e => e.stopPropagation()}
onKeyDown={e => { onKeyDown={e => {
e.stopPropagation(); e.stopPropagation();
@ -833,7 +870,12 @@ export default function KvmIdRoute() {
); );
} }
function SidebarContainer({ sidebarView }: { sidebarView: string | null }) { interface SidebarContainerProps {
readonly sidebarView: string | null;
}
function SidebarContainer(props: SidebarContainerProps) {
const { sidebarView }= props;
return ( return (
<div <div
className={cx( className={cx(

View File

@ -2,6 +2,7 @@
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2020",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ES2021", "DOM", "DOM.Iterable"], "lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,

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) {

20
web.go
View File

@ -52,8 +52,9 @@ type ChangePasswordRequest struct {
} }
type LocalDevice struct { type LocalDevice struct {
AuthMode *string `json:"authMode"` AuthMode *string `json:"authMode"`
DeviceID string `json:"deviceId"` DeviceID string `json:"deviceId"`
LoopbackOnly bool `json:"loopbackOnly"`
} }
type DeviceStatus struct { type DeviceStatus struct {
@ -532,7 +533,15 @@ func basicAuthProtectedMiddleware(requireDeveloperMode bool) gin.HandlerFunc {
func RunWebServer() { func RunWebServer() {
r := setupRouter() r := setupRouter()
err := r.Run(":80")
// Determine the binding address based on the config
bindAddress := ":80" // Default to all interfaces
if config.LocalLoopbackOnly {
bindAddress = "localhost:80" // Loopback only (both IPv4 and IPv6)
}
logger.Info().Str("bindAddress", bindAddress).Bool("loopbackOnly", config.LocalLoopbackOnly).Msg("Starting web server")
err := r.Run(bindAddress)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -540,8 +549,9 @@ func RunWebServer() {
func handleDevice(c *gin.Context) { func handleDevice(c *gin.Context) {
response := LocalDevice{ response := LocalDevice{
AuthMode: &config.LocalAuthMode, AuthMode: &config.LocalAuthMode,
DeviceID: GetDeviceID(), DeviceID: GetDeviceID(),
LoopbackOnly: config.LocalLoopbackOnly,
} }
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)