Compare commits

...

13 Commits

Author SHA1 Message Date
Aveline 91aab6074f
Merge 0bb3a6f3c2 into 37b1a8bf34 2025-09-12 11:11:36 +02:00
Aveline 37b1a8bf34
docs: update pprof section of DEVELOPMENT.md (#802) 2025-09-12 11:11:28 +02:00
Siyuan Miao 0bb3a6f3c2 chore: update comment for KEEPALIVE_INTERVAL 2025-09-12 10:41:40 +02:00
Siyuan Miao 67ea3c0432 chore: update logging for kbdAutoReleaseLock 2025-09-12 10:41:23 +02:00
Marc Brooks ca8b06f4cf
chore: enhance the gzip and cacheable handling of static files
Add SVG and ICO to cacheable files.
Emit robots.txt directly.
Recognize WOFF2 (font) files as assets (so the get the immutable treatment)
Pre-gzip the entire /static/ directory (not just /static/assets/) and include SVG, ICO, and HTML files
Ensure fonts.css is processed by vite/rollup so that the preload and css reference the same immutable files (which get long-cached with hashes)
Add CircularXXWeb-Black to the preload list as it is used in the hot-path.
Handle system-driven color-scheme changes from dark to light correctly.
2025-09-12 08:41:41 +02:00
Aveline 33e099f258
update netboot.xyz-multiarch.iso to 2.0.88 (#799)
* chore: update netboot.xyz-multiarch.iso to 2.0.88

* feat: add script to update netboot.xyz iso
2025-09-12 08:41:17 +02:00
Siyuan Miao cbf1c7fba6 chore: adjust auto release key interval 2025-09-11 17:46:50 +02:00
Siyuan Miao 3901ac91e0 chore: use ordered unreliable channel for pointer events 2025-09-11 17:32:43 +02:00
Siyuan Miao 8e9554f4d6 chore: use unreliable channel to send keepalive events 2025-09-11 16:16:47 +02:00
Siyuan Miao 46aee61565 clean up logging 2025-09-11 15:04:35 +02:00
Siyuan Miao 8b07903388 remove logging 2025-09-11 15:00:03 +02:00
Siyuan Miao 4b42c7e7e3 send keepalive when pressing the key 2025-09-11 14:10:18 +02:00
Siyuan Miao 2f0aa18d1d feat: release keyPress automatically 2025-09-11 13:05:20 +02:00
20 changed files with 499 additions and 99 deletions

View File

@ -301,13 +301,14 @@ export JETKVM_PROXY_URL="ws://<IP>"
### Performance Profiling ### Performance Profiling
```bash 1. Enable `Developer Mode` on your JetKVM device
# Enable profiling 2. Add a password on the `Access` tab
go build -o bin/jetkvm_app -ldflags="-X main.enableProfiling=true" cmd/main.go
```bash
# Access profiling # Access profiling
curl http://<IP>:6060/debug/pprof/ curl http://api:$JETKVM_PASSWORD@YOUR_DEVICE_IP/developer/pprof/
``` ```
### Advanced Environment Variables ### Advanced Environment Variables
```bash ```bash

View File

@ -63,14 +63,17 @@ build_dev_test: build_test2json build_gotestsum
frontend: frontend:
cd ui && npm ci && npm run build:device && \ cd ui && npm ci && npm run build:device && \
find ../static/assets \ find ../static/ \
-type f \ -type f \
\( -name '*.js' \ \( -name '*.js' \
-o -name '*.css' \ -o -name '*.css' \
-o -name '*.html' \
-o -name '*.ico' \
-o -name '*.png' \ -o -name '*.png' \
-o -name '*.jpg' \ -o -name '*.jpg' \
-o -name '*.jpeg' \ -o -name '*.jpeg' \
-o -name '*.gif' \ -o -name '*.gif' \
-o -name '*.svg' \
-o -name '*.webp' \ -o -name '*.webp' \
-o -name '*.woff2' \ -o -name '*.woff2' \
\) \ \) \

View File

@ -29,6 +29,8 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
session.reportHidRPCKeysDownState(*keysDownState) session.reportHidRPCKeysDownState(*keysDownState)
} }
rpcErr = err rpcErr = err
case hidrpc.TypeKeypressKeepAliveReport:
gadget.DelayAutoRelease()
case hidrpc.TypePointerReport: case hidrpc.TypePointerReport:
pointerReport, err := message.PointerReport() pointerReport, err := message.PointerReport()
if err != nil { if err != nil {
@ -52,8 +54,13 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
} }
} }
func onHidMessage(data []byte, session *Session) { func onHidMessage(msg hidQueueMessage, session *Session) {
scopedLogger := hidRPCLogger.With().Bytes("data", data).Logger() data := msg.Data
scopedLogger := hidRPCLogger.With().
Str("channel", msg.channel).
Bytes("data", data).
Logger()
scopedLogger.Debug().Msg("HID RPC message received") scopedLogger.Debug().Msg("HID RPC message received")
if len(data) < 1 { if len(data) < 1 {

View File

@ -10,14 +10,15 @@ import (
type MessageType byte type MessageType byte
const ( const (
TypeHandshake MessageType = 0x01 TypeHandshake MessageType = 0x01
TypeKeyboardReport MessageType = 0x02 TypeKeyboardReport MessageType = 0x02
TypePointerReport MessageType = 0x03 TypePointerReport MessageType = 0x03
TypeWheelReport MessageType = 0x04 TypeWheelReport MessageType = 0x04
TypeKeypressReport MessageType = 0x05 TypeKeypressReport MessageType = 0x05
TypeMouseReport MessageType = 0x06 TypeKeypressKeepAliveReport MessageType = 0x09
TypeKeyboardLedState MessageType = 0x32 TypeMouseReport MessageType = 0x06
TypeKeydownState MessageType = 0x33 TypeKeyboardLedState MessageType = 0x32
TypeKeydownState MessageType = 0x33
) )
const ( const (
@ -98,3 +99,11 @@ func NewKeydownStateMessage(state usbgadget.KeysDownState) *Message {
d: data, d: data,
} }
} }
// NewKeypressKeepAliveMessage creates a new keypress keep alive message.
func NewKeypressKeepAliveMessage() *Message {
return &Message{
t: TypeKeypressKeepAliveReport,
d: []byte{},
}
}

View File

@ -43,6 +43,8 @@ func (m *Message) String() string {
return fmt.Sprintf("MouseReport{Malformed: %v}", m.d) return fmt.Sprintf("MouseReport{Malformed: %v}", m.d)
} }
return fmt.Sprintf("MouseReport{DX: %d, DY: %d, Button: %d}", m.d[0], m.d[1], m.d[2]) return fmt.Sprintf("MouseReport{DX: %d, DY: %d, Button: %d}", m.d[0], m.d[1], m.d[2])
case TypeKeypressKeepAliveReport:
return "KeypressKeepAliveReport"
default: default:
return fmt.Sprintf("Unknown{Type: %d, Data: %v}", m.t, m.d) return fmt.Sprintf("Unknown{Type: %d, Data: %v}", m.t, m.d)
} }

View File

@ -22,6 +22,11 @@ var keyboardConfig = gadgetConfigItem{
reportDesc: keyboardReportDesc, reportDesc: keyboardReportDesc,
} }
// macOS default: 15 * 15 = 225ms https://discussions.apple.com/thread/1316947?sortBy=rank
// Linux default: 250ms https://man.archlinux.org/man/kbdrate.8.en
// Windows default: 1ms `HKEY_CURRENT_USER\Control Panel\Accessibility\Keyboard Response\AutoRepeatDelay`
const autoReleaseKeyboardInterval = time.Millisecond * 225
// Source: https://www.kernel.org/doc/Documentation/usb/gadget_hid.txt // Source: https://www.kernel.org/doc/Documentation/usb/gadget_hid.txt
var keyboardReportDesc = []byte{ var keyboardReportDesc = []byte{
0x05, 0x01, /* USAGE_PAGE (Generic Desktop) */ 0x05, 0x01, /* USAGE_PAGE (Generic Desktop) */
@ -173,6 +178,66 @@ func (u *UsbGadget) SetOnKeysDownChange(f func(state KeysDownState)) {
u.onKeysDownChange = &f u.onKeysDownChange = &f
} }
func (u *UsbGadget) scheduleAutoRelease(key byte) {
u.log.Trace().Msg("scheduling autoRelease")
u.kbdAutoReleaseLock.Lock()
defer unlockWithLog(&u.kbdAutoReleaseLock, u.log, "autoRelease scheduled")
if u.kbdAutoReleaseTimer != nil {
u.kbdAutoReleaseTimer.Stop()
}
u.kbdAutoReleaseTimer = time.AfterFunc(autoReleaseKeyboardInterval, func() {
u.performAutoRelease(key)
})
}
func (u *UsbGadget) cancelAutoRelease() {
u.log.Trace().Msg("cancelling autoRelease")
u.kbdAutoReleaseLock.Lock()
defer unlockWithLog(&u.kbdAutoReleaseLock, u.log, "autoRelease cancelled")
if u.kbdAutoReleaseTimer != nil {
u.kbdAutoReleaseTimer.Stop()
}
}
func (u *UsbGadget) DelayAutoRelease() {
u.log.Trace().Msg("delaying autoRelease")
u.kbdAutoReleaseLock.Lock()
defer unlockWithLog(&u.kbdAutoReleaseLock, u.log, "autoRelease delayed")
if u.kbdAutoReleaseTimer == nil {
return
}
u.kbdAutoReleaseTimer.Reset(autoReleaseKeyboardInterval)
u.log.Trace().Msg("auto-release timer reset")
}
func (u *UsbGadget) performAutoRelease(key byte) {
u.log.Trace().Msg("performing autoRelease")
u.kbdAutoReleaseLock.Lock()
defer unlockWithLog(&u.kbdAutoReleaseLock, u.log, "autoRelease performed")
select {
case <-u.keyboardStateCtx.Done():
return
default:
}
// we just reset the keyboard state to 0 no matter what
_, err := u.keypressReport(0, false, false)
if err != nil {
u.log.Warn().Uint8("key", key).Msg("failed to auto-release keyboard key")
}
u.kbdAutoReleaseTimer = nil
u.log.Trace().Uint8("key", key).Msg("auto release performed")
}
func (u *UsbGadget) listenKeyboardEvents() { func (u *UsbGadget) listenKeyboardEvents() {
var path string var path string
if u.keyboardHidFile != nil { if u.keyboardHidFile != nil {
@ -331,9 +396,10 @@ var KeyCodeToMaskMap = map[byte]byte{
RightSuper: ModifierMaskRightSuper, RightSuper: ModifierMaskRightSuper,
} }
func (u *UsbGadget) KeypressReport(key byte, press bool) (KeysDownState, error) { func (u *UsbGadget) keypressReport(key byte, press bool, autoRelease bool) (KeysDownState, error) {
u.keyboardLock.Lock() u.keyboardLock.Lock()
defer u.keyboardLock.Unlock() defer u.keyboardLock.Unlock()
defer u.resetUserInputTime() defer u.resetUserInputTime()
// IMPORTANT: This code parallels the logic in the kernel's hid-gadget driver // IMPORTANT: This code parallels the logic in the kernel's hid-gadget driver
@ -398,5 +464,26 @@ func (u *UsbGadget) KeypressReport(key byte, press bool) (KeysDownState, error)
u.log.Warn().Uint8("modifier", modifier).Uints8("keys", keys).Msg("Could not write keypress report to hidg0") u.log.Warn().Uint8("modifier", modifier).Uints8("keys", keys).Msg("Could not write keypress report to hidg0")
} }
if press {
{
u.log.Trace().Msg("acquiring kbdAutoReleaseLock to update last key")
u.kbdAutoReleaseLock.Lock()
u.kbdAutoReleaseLastKey = key
unlockWithLog(&u.kbdAutoReleaseLock, u.log, "last key updated")
}
if autoRelease {
u.scheduleAutoRelease(key)
}
} else {
if autoRelease {
u.cancelAutoRelease()
}
}
return u.UpdateKeysDown(modifier, keys), err return u.UpdateKeysDown(modifier, keys), err
} }
func (u *UsbGadget) KeypressReport(key byte, press bool) (KeysDownState, error) {
return u.keypressReport(key, press, true)
}

View File

@ -68,6 +68,10 @@ type UsbGadget struct {
keyboardState byte // keyboard latched state (NumLock, CapsLock, ScrollLock, Compose, Kana) keyboardState byte // keyboard latched state (NumLock, CapsLock, ScrollLock, Compose, Kana)
keysDownState KeysDownState // keyboard dynamic state (modifier keys and pressed keys) keysDownState KeysDownState // keyboard dynamic state (modifier keys and pressed keys)
kbdAutoReleaseLock sync.Mutex
kbdAutoReleaseTimer *time.Timer
kbdAutoReleaseLastKey byte
keyboardStateLock sync.Mutex keyboardStateLock sync.Mutex
keyboardStateCtx context.Context keyboardStateCtx context.Context
keyboardStateCancel context.CancelFunc keyboardStateCancel context.CancelFunc
@ -149,3 +153,35 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev
return g return g
} }
// Close cleans up resources used by the USB gadget
func (u *UsbGadget) Close() error {
// Cancel keyboard state context
if u.keyboardStateCancel != nil {
u.keyboardStateCancel()
}
// Stop auto-release timer
u.kbdAutoReleaseLock.Lock()
if u.kbdAutoReleaseTimer != nil {
u.kbdAutoReleaseTimer.Stop()
u.kbdAutoReleaseTimer = nil
}
u.kbdAutoReleaseLock.Unlock()
// Close HID files
if u.keyboardHidFile != nil {
u.keyboardHidFile.Close()
u.keyboardHidFile = nil
}
if u.absMouseHidFile != nil {
u.absMouseHidFile.Close()
u.absMouseHidFile = nil
}
if u.relMouseHidFile != nil {
u.relMouseHidFile.Close()
u.relMouseHidFile = nil
}
return nil
}

View File

@ -9,6 +9,7 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/rs/zerolog" "github.com/rs/zerolog"
@ -164,3 +165,8 @@ func (u *UsbGadget) resetLogSuppressionCounter(counterName string) {
u.logSuppressionCounter[counterName] = 0 u.logSuppressionCounter[counterName] = 0
} }
} }
func unlockWithLog(lock *sync.Mutex, logger *zerolog.Logger, msg string, args ...any) {
logger.Trace().Msgf(msg, args...)
lock.Unlock()
}

Binary file not shown.

77
scripts/update_netboot_xyz.sh Executable file
View File

@ -0,0 +1,77 @@
#!/usr/bin/env bash
#
# Exit immediately if a command exits with a non-zero status
set -e
C_RST="$(tput sgr0)"
C_ERR="$(tput setaf 1)"
C_OK="$(tput setaf 2)"
C_WARN="$(tput setaf 3)"
C_INFO="$(tput setaf 5)"
msg() { printf '%s%s%s\n' $2 "$1" $C_RST; }
msg_info() { msg "$1" $C_INFO; }
msg_ok() { msg "$1" $C_OK; }
msg_err() { msg "$1" $C_ERR; }
msg_warn() { msg "$1" $C_WARN; }
# Get the latest release information
msg_info "Getting latest release information ..."
LATEST_RELEASE=$(curl -s \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/netbootxyz/netboot.xyz/releases | jq '
[.[] | select(.prerelease == false and .draft == false and .assets != null and (.assets | length > 0))] |
sort_by(.created_at) |
.[-1]')
# Extract version, download URL, and digest
VERSION=$(echo "$LATEST_RELEASE" | jq -r '.tag_name')
ISO_URL=$(echo "$LATEST_RELEASE" | jq -r '.assets[] | select(.name == "netboot.xyz-multiarch.iso") | .browser_download_url')
EXPECTED_CHECKSUM=$(echo "$LATEST_RELEASE" | jq -r '.assets[] | select(.name == "netboot.xyz-multiarch.iso") | .digest' | sed 's/sha256://')
msg_ok "Latest version: $VERSION"
msg_ok "ISO URL: $ISO_URL"
msg_ok "Expected SHA256: $EXPECTED_CHECKSUM"
# Check if we already have the same version
if [ -f "resource/netboot.xyz-multiarch.iso" ]; then
msg_info "Checking current resource file ..."
# First check by checksum (fastest)
CURRENT_CHECKSUM=$(shasum -a 256 resource/netboot.xyz-multiarch.iso | awk '{print $1}')
if [ "$CURRENT_CHECKSUM" = "$EXPECTED_CHECKSUM" ]; then
msg_ok "Resource file is already up to date (version $VERSION). No update needed."
exit 0
else
msg_info "Checksums differ, proceeding with download ..."
fi
fi
# Download ISO file
TMP_ISO=$(mktemp -t netbootxyziso)
msg_info "Downloading ISO file ..."
curl -L -o "$TMP_ISO" "$ISO_URL"
# Verify SHA256 checksum
msg_info "Verifying SHA256 checksum ..."
ACTUAL_CHECKSUM=$(shasum -a 256 "$TMP_ISO" | awk '{print $1}')
if [ "$EXPECTED_CHECKSUM" = "$ACTUAL_CHECKSUM" ]; then
msg_ok "Verified SHA256 checksum."
mv -f "$TMP_ISO" "resource/netboot.xyz-multiarch.iso"
msg_ok "Updated ISO file."
git add "resource/netboot.xyz-multiarch.iso"
git commit -m "chore: update netboot.xyz-multiarch.iso to $VERSION"
msg_ok "Committed changes."
msg_ok "You can now push the changes to the remote repository."
exit 0
else
msg_err "Inconsistent SHA256 checksum."
msg_err "Expected: $EXPECTED_CHECKSUM"
msg_err "Actual: $ACTUAL_CHECKSUM"
exit 1
fi

View File

@ -6,27 +6,34 @@
<!-- These are the fonts used in the app --> <!-- These are the fonts used in the app -->
<link <link
rel="preload" rel="preload"
href="/fonts/CircularXXWeb-Medium.woff2" href="./public/fonts/CircularXXWeb-Medium.woff2"
as="font" as="font"
type="font/woff2" type="font/woff2"
crossorigin crossorigin
/> />
<link <link
rel="preload" rel="preload"
href="/fonts/CircularXXWeb-Book.woff2" href="./public/fonts/CircularXXWeb-Book.woff2"
as="font" as="font"
type="font/woff2" type="font/woff2"
crossorigin crossorigin
/> />
<link <link
rel="preload" rel="preload"
href="/fonts/CircularXXWeb-Regular.woff2" href="./public/fonts/CircularXXWeb-Regular.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="./public/fonts/CircularXXWeb-Black.woff2"
as="font" as="font"
type="font/woff2" type="font/woff2"
crossorigin crossorigin
/> />
<title>JetKVM</title> <title>JetKVM</title>
<link rel="stylesheet" href="/fonts/fonts.css" /> <link rel="stylesheet" href="./public/fonts/fonts.css" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" /> <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" /> <link rel="shortcut icon" href="/favicon.ico" />
@ -36,23 +43,21 @@
<meta name="theme-color" content="#051946" /> <meta name="theme-color" content="#051946" />
<meta name="description" content="A web-based KVM console for managing remote servers." /> <meta name="description" content="A web-based KVM console for managing remote servers." />
<script> <script>
// Initial theme setup function applyThemeFromPreference() {
document.documentElement.classList.toggle( // dark theme setup
"dark", var darkDesired = localStorage.theme === "dark" ||
localStorage.theme === "dark" ||
(!("theme" in localStorage) && (!("theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches), window.matchMedia("(prefers-color-scheme: dark)").matches)
);
document.documentElement.classList.toggle("dark", darkDesired)
}
// initial theme application
applyThemeFromPreference();
// Listen for system theme changes // Listen for system theme changes
window window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", applyThemeFromPreference);
.matchMedia("(prefers-color-scheme: dark)") window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", applyThemeFromPreference);
.addEventListener("change", ({ matches }) => {
if (!("theme" in localStorage)) {
// Only auto-switch if user hasn't manually set a theme
document.documentElement.classList.toggle("dark", matches);
}
});
</script> </script>
</head> </head>
<body <body

View File

@ -1,2 +0,0 @@
User-agent: *
Disallow: /

View File

@ -6,6 +6,7 @@ export const HID_RPC_MESSAGE_TYPES = {
PointerReport: 0x03, PointerReport: 0x03,
WheelReport: 0x04, WheelReport: 0x04,
KeypressReport: 0x05, KeypressReport: 0x05,
KeypressKeepAliveReport: 0x09,
MouseReport: 0x06, MouseReport: 0x06,
KeyboardLedState: 0x32, KeyboardLedState: 0x32,
KeysDownState: 0x33, KeysDownState: 0x33,
@ -278,12 +279,23 @@ export class MouseReportMessage extends RpcMessage {
} }
} }
export class KeypressKeepAliveMessage extends RpcMessage {
constructor() {
super(HID_RPC_MESSAGE_TYPES.KeypressKeepAliveReport);
}
marshal(): Uint8Array {
return new Uint8Array([this.messageType]);
}
}
export const messageRegistry = { export const messageRegistry = {
[HID_RPC_MESSAGE_TYPES.Handshake]: HandshakeMessage, [HID_RPC_MESSAGE_TYPES.Handshake]: HandshakeMessage,
[HID_RPC_MESSAGE_TYPES.KeysDownState]: KeysDownStateMessage, [HID_RPC_MESSAGE_TYPES.KeysDownState]: KeysDownStateMessage,
[HID_RPC_MESSAGE_TYPES.KeyboardLedState]: KeyboardLedStateMessage, [HID_RPC_MESSAGE_TYPES.KeyboardLedState]: KeyboardLedStateMessage,
[HID_RPC_MESSAGE_TYPES.KeyboardReport]: KeyboardReportMessage, [HID_RPC_MESSAGE_TYPES.KeyboardReport]: KeyboardReportMessage,
[HID_RPC_MESSAGE_TYPES.KeypressReport]: KeypressReportMessage, [HID_RPC_MESSAGE_TYPES.KeypressReport]: KeypressReportMessage,
[HID_RPC_MESSAGE_TYPES.KeypressKeepAliveReport]: KeypressKeepAliveMessage,
} }
export const unmarshalHidRpcMessage = (data: Uint8Array): RpcMessage | undefined => { export const unmarshalHidRpcMessage = (data: Uint8Array): RpcMessage | undefined => {

View File

@ -111,6 +111,12 @@ export interface RTCState {
rpcHidChannel: RTCDataChannel | null; rpcHidChannel: RTCDataChannel | null;
setRpcHidChannel: (channel: RTCDataChannel) => void; setRpcHidChannel: (channel: RTCDataChannel) => void;
rpcHidUnreliableChannel: RTCDataChannel | null;
setRpcHidUnreliableChannel: (channel: RTCDataChannel) => void;
rpcHidUnreliableNonOrderedChannel: RTCDataChannel | null;
setRpcHidUnreliableNonOrderedChannel: (channel: RTCDataChannel) => void;
peerConnectionState: RTCPeerConnectionState | null; peerConnectionState: RTCPeerConnectionState | null;
setPeerConnectionState: (state: RTCPeerConnectionState) => void; setPeerConnectionState: (state: RTCPeerConnectionState) => void;
@ -163,6 +169,12 @@ export const useRTCStore = create<RTCState>(set => ({
rpcHidChannel: null, rpcHidChannel: null,
setRpcHidChannel: (channel: RTCDataChannel) => set({ rpcHidChannel: channel }), setRpcHidChannel: (channel: RTCDataChannel) => set({ rpcHidChannel: channel }),
rpcHidUnreliableChannel: null,
setRpcHidUnreliableChannel: (channel: RTCDataChannel) => set({ rpcHidUnreliableChannel: channel }),
rpcHidUnreliableNonOrderedChannel: null,
setRpcHidUnreliableNonOrderedChannel: (channel: RTCDataChannel) => set({ rpcHidUnreliableNonOrderedChannel: channel }),
transceiver: null, transceiver: null,
setTransceiver: (transceiver: RTCRtpTransceiver) => set({ transceiver }), setTransceiver: (transceiver: RTCRtpTransceiver) => set({ transceiver }),

View File

@ -6,6 +6,7 @@ import {
HID_RPC_VERSION, HID_RPC_VERSION,
HandshakeMessage, HandshakeMessage,
KeyboardReportMessage, KeyboardReportMessage,
KeypressKeepAliveMessage,
KeypressReportMessage, KeypressReportMessage,
MouseReportMessage, MouseReportMessage,
PointerReportMessage, PointerReportMessage,
@ -13,20 +14,43 @@ import {
unmarshalHidRpcMessage, unmarshalHidRpcMessage,
} from "./hidRpc"; } from "./hidRpc";
const KEEPALIVE_MESSAGE = new KeypressKeepAliveMessage();
interface sendMessageParams {
ignoreHandshakeState?: boolean;
useUnreliableChannel?: boolean;
requireOrdered?: boolean;
}
export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) { export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
const { rpcHidChannel, setRpcHidProtocolVersion, rpcHidProtocolVersion } = useRTCStore(); const {
rpcHidChannel,
rpcHidUnreliableChannel,
rpcHidUnreliableNonOrderedChannel,
setRpcHidProtocolVersion,
rpcHidProtocolVersion,
} = useRTCStore();
const rpcHidReady = useMemo(() => { const rpcHidReady = useMemo(() => {
return rpcHidChannel?.readyState === "open" && rpcHidProtocolVersion !== null; return rpcHidChannel?.readyState === "open" && rpcHidProtocolVersion !== null;
}, [rpcHidChannel, rpcHidProtocolVersion]); }, [rpcHidChannel, rpcHidProtocolVersion]);
const rpcHidUnreliableReady = useMemo(() => {
return rpcHidUnreliableChannel?.readyState === "open" && rpcHidProtocolVersion !== null;
}, [rpcHidUnreliableChannel, rpcHidProtocolVersion]);
const rpcHidUnreliableNonOrderedReady = useMemo(() => {
return rpcHidUnreliableNonOrderedChannel?.readyState === "open" && rpcHidProtocolVersion !== null;
}, [rpcHidUnreliableNonOrderedChannel, rpcHidProtocolVersion]);
const rpcHidStatus = useMemo(() => { const rpcHidStatus = useMemo(() => {
if (!rpcHidChannel) return "N/A"; if (!rpcHidChannel) return "N/A";
if (rpcHidChannel.readyState !== "open") return rpcHidChannel.readyState; if (rpcHidChannel.readyState !== "open") return rpcHidChannel.readyState;
if (!rpcHidProtocolVersion) return "handshaking"; if (!rpcHidProtocolVersion) return "handshaking";
return `ready (v${rpcHidProtocolVersion})`; return `ready (v${rpcHidProtocolVersion}${rpcHidUnreliableReady ? "+u" : ""})`;
}, [rpcHidChannel, rpcHidProtocolVersion]); }, [rpcHidChannel, rpcHidUnreliableReady, rpcHidProtocolVersion]);
const sendMessage = useCallback((message: RpcMessage, ignoreHandshakeState = false) => { const sendMessage = useCallback((message: RpcMessage, { ignoreHandshakeState, useUnreliableChannel, requireOrdered = true }: sendMessageParams = {}) => {
if (rpcHidChannel?.readyState !== "open") return; if (rpcHidChannel?.readyState !== "open") return;
if (!rpcHidReady && !ignoreHandshakeState) return; if (!rpcHidReady && !ignoreHandshakeState) return;
@ -38,8 +62,24 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
} }
if (!data) return; if (!data) return;
if (useUnreliableChannel) {
if (requireOrdered && rpcHidUnreliableReady) {
rpcHidUnreliableChannel?.send(data as unknown as ArrayBuffer);
} else if (!requireOrdered && rpcHidUnreliableNonOrderedReady) {
rpcHidUnreliableNonOrderedChannel?.send(data as unknown as ArrayBuffer);
}
return;
}
rpcHidChannel?.send(data as unknown as ArrayBuffer); rpcHidChannel?.send(data as unknown as ArrayBuffer);
}, [rpcHidChannel, rpcHidReady]); }, [
rpcHidChannel,
rpcHidUnreliableChannel,
rpcHidUnreliableNonOrderedChannel,
rpcHidReady,
rpcHidUnreliableReady,
rpcHidUnreliableNonOrderedReady,
]);
const reportKeyboardEvent = useCallback( const reportKeyboardEvent = useCallback(
(keys: number[], modifier: number) => { (keys: number[], modifier: number) => {
@ -56,7 +96,7 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
const reportAbsMouseEvent = useCallback( const reportAbsMouseEvent = useCallback(
(x: number, y: number, buttons: number) => { (x: number, y: number, buttons: number) => {
sendMessage(new PointerReportMessage(x, y, buttons)); sendMessage(new PointerReportMessage(x, y, buttons), { useUnreliableChannel: true });
}, },
[sendMessage], [sendMessage],
); );
@ -68,11 +108,15 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
[sendMessage], [sendMessage],
); );
const reportKeypressKeepAlive = useCallback(() => {
sendMessage(KEEPALIVE_MESSAGE, { useUnreliableChannel: true, requireOrdered: false });
}, [sendMessage]);
const sendHandshake = useCallback(() => { const sendHandshake = useCallback(() => {
if (rpcHidProtocolVersion) return; if (rpcHidProtocolVersion) return;
if (!rpcHidChannel) return; if (!rpcHidChannel) return;
sendMessage(new HandshakeMessage(HID_RPC_VERSION), true); sendMessage(new HandshakeMessage(HID_RPC_VERSION), { ignoreHandshakeState: true });
}, [rpcHidChannel, rpcHidProtocolVersion, sendMessage]); }, [rpcHidChannel, rpcHidProtocolVersion, sendMessage]);
const handleHandshake = useCallback((message: HandshakeMessage) => { const handleHandshake = useCallback((message: HandshakeMessage) => {
@ -143,6 +187,7 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
reportKeypressEvent, reportKeypressEvent,
reportAbsMouseEvent, reportAbsMouseEvent,
reportRelMouseEvent, reportRelMouseEvent,
reportKeypressKeepAlive,
rpcHidProtocolVersion, rpcHidProtocolVersion,
rpcHidReady, rpcHidReady,
rpcHidStatus, rpcHidStatus,

View File

@ -1,4 +1,4 @@
import { useCallback } from "react"; import { useCallback, useRef } from "react";
import { hidErrorRollOver, hidKeyBufferSize, KeysDownState, useHidStore, useRTCStore } from "@/hooks/stores"; import { hidErrorRollOver, hidKeyBufferSize, KeysDownState, useHidStore, useRTCStore } from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
@ -11,6 +11,9 @@ export default function useKeyboard() {
const { rpcDataChannel } = useRTCStore(); const { rpcDataChannel } = useRTCStore();
const { keysDownState, setKeysDownState, setKeyboardLedState } = useHidStore(); const { keysDownState, setKeysDownState, setKeyboardLedState } = useHidStore();
// Keepalive timer management
const keepAliveTimerRef = useRef<number | null>(null);
// INTRODUCTION: The earlier version of the JetKVM device shipped with all keyboard state // INTRODUCTION: The earlier version of the JetKVM device shipped with all keyboard state
// being tracked on the browser/client-side. When adding the keyPressReport API to the // being tracked on the browser/client-side. When adding the keyPressReport API to the
// device-side code, we have to still support the situation where the browser/client-side code // device-side code, we have to still support the situation where the browser/client-side code
@ -26,6 +29,7 @@ export default function useKeyboard() {
const { const {
reportKeyboardEvent: sendKeyboardEventHidRpc, reportKeyboardEvent: sendKeyboardEventHidRpc,
reportKeypressEvent: sendKeypressEventHidRpc, reportKeypressEvent: sendKeypressEventHidRpc,
reportKeypressKeepAlive: sendKeypressKeepAliveHidRpc,
rpcHidReady, rpcHidReady,
} = useHidRpc((message) => { } = useHidRpc((message) => {
switch (message.constructor) { switch (message.constructor) {
@ -70,17 +74,6 @@ export default function useKeyboard() {
], ],
); );
// resetKeyboardState is used to reset the keyboard state to no keys pressed and no modifiers.
// This is useful for macros and when the browser loses focus to ensure that the keyboard state
// is clean.
const resetKeyboardState = useCallback(
async () => {
// Reset the keys buffer to zeros and the modifier state to zero
keysDownState.keys.length = hidKeyBufferSize;
keysDownState.keys.fill(0);
keysDownState.modifier = 0;
sendKeyboardEvent(keysDownState);
}, [keysDownState, sendKeyboardEvent]);
// executeMacro is used to execute a macro consisting of multiple steps. // executeMacro is used to execute a macro consisting of multiple steps.
// Each step can have multiple keys, multiple modifiers and a delay. // Each step can have multiple keys, multiple modifiers and a delay.
@ -111,12 +104,61 @@ export default function useKeyboard() {
} }
}; };
const KEEPALIVE_INTERVAL = 75; // TODO: use an adaptive interval based on RTT later
const cancelKeepAlive = useCallback(() => {
if (keepAliveTimerRef.current) {
clearInterval(keepAliveTimerRef.current);
keepAliveTimerRef.current = null;
}
}, []);
const scheduleKeepAlive = useCallback(() => {
// Clear existing timer if it exists
if (keepAliveTimerRef.current) {
clearInterval(keepAliveTimerRef.current);
}
// Create new interval timer
keepAliveTimerRef.current = setInterval(() => {
sendKeypressKeepAliveHidRpc();
}, KEEPALIVE_INTERVAL);
}, [sendKeypressKeepAliveHidRpc]);
// resetKeyboardState is used to reset the keyboard state to no keys pressed and no modifiers.
// This is useful for macros and when the browser loses focus to ensure that the keyboard state
// is clean.
const resetKeyboardState = useCallback(async () => {
// Cancel keepalive since we're resetting the keyboard state
cancelKeepAlive();
// Reset the keys buffer to zeros and the modifier state to zero
keysDownState.keys.length = hidKeyBufferSize;
keysDownState.keys.fill(0);
keysDownState.modifier = 0;
sendKeyboardEvent(keysDownState);
}, [keysDownState, sendKeyboardEvent, cancelKeepAlive]);
// handleKeyPress is used to handle a key press or release event. // handleKeyPress is used to handle a key press or release event.
// This function handle both key press and key release events. // This function handle both key press and key release events.
// It checks if the keyPressReport API is available and sends the key press event. // It checks if the keyPressReport API is available and sends the key press event.
// If the keyPressReport API is not available, it simulates the device-side key // If the keyPressReport API is not available, it simulates the device-side key
// handling for legacy devices and updates the keysDownState accordingly. // handling for legacy devices and updates the keysDownState accordingly.
// It then sends the full keyboard state to the device. // It then sends the full keyboard state to the device.
const sendKeypress = useCallback(
(key: number, press: boolean) => {
cancelKeepAlive();
sendKeypressEventHidRpc(key, press);
if (press) {
scheduleKeepAlive();
}
},
[sendKeypressEventHidRpc, scheduleKeepAlive, cancelKeepAlive],
);
const handleKeyPress = useCallback( const handleKeyPress = useCallback(
async (key: number, press: boolean) => { async (key: number, press: boolean) => {
if (rpcDataChannel?.readyState !== "open" && !rpcHidReady) return; if (rpcDataChannel?.readyState !== "open" && !rpcHidReady) return;
@ -129,7 +171,7 @@ export default function useKeyboard() {
// Older device version doesn't support this API, so we will switch to local key handling // Older device version doesn't support this API, so we will switch to local key handling
// In that case we will switch to local key handling and update the keysDownState // In that case we will switch to local key handling and update the keysDownState
// in client/browser-side code using simulateDeviceSideKeyHandlingForLegacyDevices. // in client/browser-side code using simulateDeviceSideKeyHandlingForLegacyDevices.
sendKeypressEventHidRpc(key, press); sendKeypress(key, press);
} else { } else {
// if the keyPress api is not available, we need to handle the key locally // if the keyPress api is not available, we need to handle the key locally
const downState = simulateDeviceSideKeyHandlingForLegacyDevices(keysDownState, key, press); const downState = simulateDeviceSideKeyHandlingForLegacyDevices(keysDownState, key, press);
@ -147,7 +189,7 @@ export default function useKeyboard() {
resetKeyboardState, resetKeyboardState,
rpcDataChannel?.readyState, rpcDataChannel?.readyState,
sendKeyboardEvent, sendKeyboardEvent,
sendKeypressEventHidRpc, sendKeypress,
], ],
); );
@ -210,5 +252,10 @@ export default function useKeyboard() {
return { modifier: modifiers, keys }; return { modifier: modifiers, keys };
} }
return { handleKeyPress, resetKeyboardState, executeMacro }; // Cleanup function to cancel keepalive timer
const cleanup = useCallback(() => {
cancelKeepAlive();
}, [cancelKeepAlive]);
return { handleKeyPress, resetKeyboardState, executeMacro, cleanup };
} }

View File

@ -136,6 +136,8 @@ export default function KvmIdRoute() {
rpcDataChannel, rpcDataChannel,
setTransceiver, setTransceiver,
setRpcHidChannel, setRpcHidChannel,
setRpcHidUnreliableNonOrderedChannel,
setRpcHidUnreliableChannel,
} = useRTCStore(); } = useRTCStore();
const location = useLocation(); const location = useLocation();
@ -488,6 +490,24 @@ export default function KvmIdRoute() {
setRpcHidChannel(rpcHidChannel); setRpcHidChannel(rpcHidChannel);
}; };
const rpcHidUnreliableChannel = pc.createDataChannel("hidrpc-unreliable-ordered", {
ordered: true,
maxRetransmits: 0,
});
rpcHidUnreliableChannel.binaryType = "arraybuffer";
rpcHidUnreliableChannel.onopen = () => {
setRpcHidUnreliableChannel(rpcHidUnreliableChannel);
};
const rpcHidUnreliableNonOrderedChannel = pc.createDataChannel("hidrpc-unreliable-nonordered", {
ordered: false,
maxRetransmits: 0,
});
rpcHidUnreliableNonOrderedChannel.binaryType = "arraybuffer";
rpcHidUnreliableNonOrderedChannel.onopen = () => {
setRpcHidUnreliableNonOrderedChannel(rpcHidUnreliableNonOrderedChannel);
};
setPeerConnection(pc); setPeerConnection(pc);
}, [ }, [
cleanupAndStopReconnecting, cleanupAndStopReconnecting,
@ -499,6 +519,8 @@ export default function KvmIdRoute() {
setPeerConnectionState, setPeerConnectionState,
setRpcDataChannel, setRpcDataChannel,
setRpcHidChannel, setRpcHidChannel,
setRpcHidUnreliableNonOrderedChannel,
setRpcHidUnreliableChannel,
setTransceiver, setTransceiver,
]); ]);

View File

@ -31,6 +31,7 @@ export default defineConfig(({ mode, command }) => {
esbuild: { esbuild: {
pure: ["console.debug"], pure: ["console.debug"],
}, },
assetsInclude: ["**/*.woff2"],
build: { build: {
outDir: isCloud ? "dist" : "../static", outDir: isCloud ? "dist" : "../static",
rollupOptions: { rollupOptions: {

16
web.go
View File

@ -69,8 +69,7 @@ type SetupRequest struct {
} }
var cachableFileExtensions = []string{ var cachableFileExtensions = []string{
".jpg", ".jpeg", ".png", ".gif", ".webp", ".woff2", ".jpg", ".jpeg", ".png", ".svg", ".gif", ".webp", ".ico", ".woff2",
".ico",
} }
func setupRouter() *gin.Engine { func setupRouter() *gin.Engine {
@ -83,7 +82,10 @@ func setupRouter() *gin.Engine {
}), }),
)) ))
staticFS, _ := fs.Sub(staticFiles, "static") staticFS, err := fs.Sub(staticFiles, "static")
if err != nil {
logger.Fatal().Err(err).Msg("failed to get rooted static files subdirectory")
}
staticFileServer := http.StripPrefix("/static", statigz.FileServer( staticFileServer := http.StripPrefix("/static", statigz.FileServer(
staticFS.(fs.ReadDirFS), staticFS.(fs.ReadDirFS),
)) ))
@ -109,9 +111,17 @@ func setupRouter() *gin.Engine {
c.Next() c.Next()
}) })
r.GET("/robots.txt", func(c *gin.Context) {
c.Header("Content-Type", "text/plain")
c.Header("Cache-Control", "public, max-age=31536000, immutable") // Cache for 1 year
c.String(http.StatusOK, "User-agent: *\nDisallow: /")
})
r.Any("/static/*w", func(c *gin.Context) { r.Any("/static/*w", func(c *gin.Context) {
staticFileServer.ServeHTTP(c.Writer, c.Request) staticFileServer.ServeHTTP(c.Writer, c.Request)
}) })
// Public routes (no authentication required)
r.POST("/auth/login-local", handleLogin) r.POST("/auth/login-local", handleLogin)
// We use this to determine if the device is setup // We use this to determine if the device is setup

View File

@ -29,7 +29,12 @@ type Session struct {
hidRPCAvailable bool hidRPCAvailable bool
hidQueueLock sync.Mutex hidQueueLock sync.Mutex
hidQueue []chan webrtc.DataChannelMessage hidQueue []chan hidQueueMessage
}
type hidQueueMessage struct {
webrtc.DataChannelMessage
channel string
} }
type SessionConfig struct { type SessionConfig struct {
@ -78,16 +83,59 @@ func (s *Session) initQueues() {
s.hidQueueLock.Lock() s.hidQueueLock.Lock()
defer s.hidQueueLock.Unlock() defer s.hidQueueLock.Unlock()
s.hidQueue = make([]chan webrtc.DataChannelMessage, 0) s.hidQueue = make([]chan hidQueueMessage, 0)
for i := 0; i < 4; i++ { for i := 0; i < 4; i++ {
q := make(chan webrtc.DataChannelMessage, 256) q := make(chan hidQueueMessage, 256)
s.hidQueue = append(s.hidQueue, q) s.hidQueue = append(s.hidQueue, q)
} }
} }
func (s *Session) handleQueues(index int) { func (s *Session) handleQueues(index int) {
for msg := range s.hidQueue[index] { for msg := range s.hidQueue[index] {
onHidMessage(msg.Data, s) onHidMessage(msg, s)
}
}
func getOnHidMessageHandler(session *Session, scopedLogger *zerolog.Logger, channel string) func(msg webrtc.DataChannelMessage) {
return func(msg webrtc.DataChannelMessage) {
l := scopedLogger.With().
Str("channel", channel).
Int("length", len(msg.Data)).
Logger()
// only log data if the log level is debug or lower
if scopedLogger.GetLevel() > zerolog.DebugLevel {
l = l.With().Str("data", string(msg.Data)).Logger()
}
if msg.IsString {
l.Warn().Msg("received string data in HID RPC message handler")
return
}
if len(msg.Data) < 1 {
l.Warn().Msg("received empty data in HID RPC message handler")
return
}
l.Trace().Msg("received data in HID RPC message handler")
// Enqueue to ensure ordered processing
queueIndex := hidrpc.GetQueueIndex(hidrpc.MessageType(msg.Data[0]))
if queueIndex >= len(session.hidQueue) || queueIndex < 0 {
l.Warn().Int("queueIndex", queueIndex).Msg("received data in HID RPC message handler, but queue index not found")
queueIndex = 3
}
queue := session.hidQueue[queueIndex]
if queue != nil {
queue <- hidQueueMessage{
DataChannelMessage: msg,
channel: channel,
}
} else {
l.Warn().Int("queueIndex", queueIndex).Msg("received data in HID RPC message handler, but queue is nil")
return
}
} }
} }
@ -157,40 +205,12 @@ func newSession(config SessionConfig) (*Session, error) {
switch d.Label() { switch d.Label() {
case "hidrpc": case "hidrpc":
session.HidChannel = d session.HidChannel = d
d.OnMessage(func(msg webrtc.DataChannelMessage) { d.OnMessage(getOnHidMessageHandler(session, scopedLogger, "hidrpc"))
l := scopedLogger.With().Int("length", len(msg.Data)).Logger() // we won't send anything over the unreliable channels
// only log data if the log level is debug or lower case "hidrpc-unreliable-ordered":
if scopedLogger.GetLevel() > zerolog.DebugLevel { d.OnMessage(getOnHidMessageHandler(session, scopedLogger, "hidrpc-unreliable-ordered"))
l = l.With().Str("data", string(msg.Data)).Logger() case "hidrpc-unreliable-nonordered":
} d.OnMessage(getOnHidMessageHandler(session, scopedLogger, "hidrpc-unreliable-nonordered"))
if msg.IsString {
l.Warn().Msg("received string data in HID RPC message handler")
return
}
if len(msg.Data) < 1 {
l.Warn().Msg("received empty data in HID RPC message handler")
return
}
l.Trace().Msg("received data in HID RPC message handler")
// Enqueue to ensure ordered processing
queueIndex := hidrpc.GetQueueIndex(hidrpc.MessageType(msg.Data[0]))
if queueIndex >= len(session.hidQueue) || queueIndex < 0 {
l.Warn().Int("queueIndex", queueIndex).Msg("received data in HID RPC message handler, but queue index not found")
queueIndex = 3
}
queue := session.hidQueue[queueIndex]
if queue != nil {
queue <- msg
} else {
l.Warn().Int("queueIndex", queueIndex).Msg("received data in HID RPC message handler, but queue is nil")
return
}
})
case "rpc": case "rpc":
session.RPCChannel = d session.RPCChannel = d
d.OnMessage(func(msg webrtc.DataChannelMessage) { d.OnMessage(func(msg webrtc.DataChannelMessage) {