Compare commits

...

14 Commits

Author SHA1 Message Date
Aveline 4c06f33c07
Merge 1e2cee7060 into 37b1a8bf34 2025-09-12 11:35:56 +02:00
Aveline 37b1a8bf34
docs: update pprof section of DEVELOPMENT.md (#802) 2025-09-12 11:11:28 +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 1e2cee7060 fix: performance issue of Uint8Array concat 2025-09-11 02:56:47 +02:00
Siyuan Miao a86b516f9a fix: jsonrpc.go:1142:21: Error return value is not checked (errcheck) 2025-09-11 02:16:45 +02:00
Siyuan Miao 137d22b0b3 fix: use currentSession.reportHidRPCKeyboardMacroState 2025-09-11 02:15:01 +02:00
Siyuan Miao d6de9668bd chore: clear keysDownState 2025-09-11 02:12:30 +02:00
Siyuan Miao a667aefc96 allow user to override delay 2025-09-10 23:48:26 +02:00
Siyuan Miao 7014560b41 feat: allow paste progress to be cancelled 2025-09-10 23:35:24 +02:00
Siyuan Miao d7c8abbb11 add delay 2025-09-10 17:21:44 +02:00
Siyuan Miao 024cbb8fb1 wip: send macro using hidRPC channel 2025-09-10 16:57:16 +02:00
Siyuan Miao f58e5476bf feat: cancel paste mode 2025-09-10 00:13:10 +02:00
Siyuan Miao 4b0818502c feat: send all paste keystrokes to backend 2025-09-09 23:56:23 +02:00
19 changed files with 676 additions and 136 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

@ -475,6 +475,10 @@ func handleSessionRequest(
cloudLogger.Info().Interface("session", session).Msg("new session accepted") cloudLogger.Info().Interface("session", session).Msg("new session accepted")
cloudLogger.Trace().Interface("session", session).Msg("new session accepted") cloudLogger.Trace().Interface("session", session).Msg("new session accepted")
// Cancel any ongoing keyboard report multi when session changes
cancelKeyboardMacro()
currentSession = session currentSession = session
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd}) _ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
return nil return nil

View File

@ -1,7 +1,9 @@
package kvm package kvm
import ( import (
"errors"
"fmt" "fmt"
"io"
"time" "time"
"github.com/jetkvm/kvm/internal/hidrpc" "github.com/jetkvm/kvm/internal/hidrpc"
@ -29,6 +31,16 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
session.reportHidRPCKeysDownState(*keysDownState) session.reportHidRPCKeysDownState(*keysDownState)
} }
rpcErr = err rpcErr = err
case hidrpc.TypeKeyboardMacroReport:
keyboardMacroReport, err := message.KeyboardMacroReport()
if err != nil {
logger.Warn().Err(err).Msg("failed to get keyboard macro report")
return
}
_, rpcErr = rpcExecuteKeyboardMacro(keyboardMacroReport.Macro)
case hidrpc.TypeCancelKeyboardMacroReport:
rpcCancelKeyboardMacro()
return
case hidrpc.TypePointerReport: case hidrpc.TypePointerReport:
pointerReport, err := message.PointerReport() pointerReport, err := message.PointerReport()
if err != nil { if err != nil {
@ -128,6 +140,8 @@ func reportHidRPC(params any, session *Session) {
message, err = hidrpc.NewKeyboardLedMessage(params).Marshal() message, err = hidrpc.NewKeyboardLedMessage(params).Marshal()
case usbgadget.KeysDownState: case usbgadget.KeysDownState:
message, err = hidrpc.NewKeydownStateMessage(params).Marshal() message, err = hidrpc.NewKeydownStateMessage(params).Marshal()
case hidrpc.KeyboardMacroStateReport:
message, err = hidrpc.NewKeyboardMacroStateMessage(params.State, params.IsPaste).Marshal()
default: default:
err = fmt.Errorf("unknown HID RPC message type: %T", params) err = fmt.Errorf("unknown HID RPC message type: %T", params)
} }
@ -143,6 +157,10 @@ func reportHidRPC(params any, session *Session) {
} }
if err := session.HidChannel.Send(message); err != nil { if err := session.HidChannel.Send(message); err != nil {
if errors.Is(err, io.ErrClosedPipe) {
logger.Debug().Err(err).Msg("HID RPC channel closed, skipping reportHidRPC")
return
}
logger.Warn().Err(err).Msg("failed to send HID RPC message") logger.Warn().Err(err).Msg("failed to send HID RPC message")
} }
} }
@ -160,3 +178,10 @@ func (s *Session) reportHidRPCKeysDownState(state usbgadget.KeysDownState) {
} }
reportHidRPC(state, s) reportHidRPC(state, s)
} }
func (s *Session) reportHidRPCKeyboardMacroState(state hidrpc.KeyboardMacroStateReport) {
if !s.hidRPCAvailable {
writeJSONRPCEvent("keyboardMacroState", state, s)
}
reportHidRPC(state, s)
}

View File

@ -10,14 +10,17 @@ 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 TypeMouseReport MessageType = 0x06
TypeKeyboardLedState MessageType = 0x32 TypeKeyboardMacroReport MessageType = 0x07
TypeKeydownState MessageType = 0x33 TypeCancelKeyboardMacroReport MessageType = 0x08
TypeKeyboardLedState MessageType = 0x32
TypeKeydownState MessageType = 0x33
TypeKeyboardMacroStateReport MessageType = 0x34
) )
const ( const (
@ -29,10 +32,13 @@ func GetQueueIndex(messageType MessageType) int {
switch messageType { switch messageType {
case TypeHandshake: case TypeHandshake:
return 0 return 0
case TypeKeyboardReport, TypeKeypressReport, TypeKeyboardLedState, TypeKeydownState: case TypeKeyboardReport, TypeKeypressReport, TypeKeyboardMacroReport, TypeKeyboardLedState, TypeKeydownState, TypeKeyboardMacroStateReport:
return 1 return 1
case TypePointerReport, TypeMouseReport, TypeWheelReport: case TypePointerReport, TypeMouseReport, TypeWheelReport:
return 2 return 2
// we don't want to block the queue for this message
case TypeCancelKeyboardMacroReport:
return 3
default: default:
return 3 return 3
} }
@ -98,3 +104,19 @@ func NewKeydownStateMessage(state usbgadget.KeysDownState) *Message {
d: data, d: data,
} }
} }
// NewKeyboardMacroStateMessage creates a new keyboard macro state message.
func NewKeyboardMacroStateMessage(state bool, isPaste bool) *Message {
data := make([]byte, 2)
if state {
data[0] = 1
}
if isPaste {
data[1] = 1
}
return &Message{
t: TypeKeyboardMacroStateReport,
d: data,
}
}

View File

@ -1,6 +1,7 @@
package hidrpc package hidrpc
import ( import (
"encoding/binary"
"fmt" "fmt"
) )
@ -43,6 +44,11 @@ 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 TypeKeyboardMacroReport:
if len(m.d) < 5 {
return fmt.Sprintf("KeyboardMacroReport{Malformed: %v}", m.d)
}
return fmt.Sprintf("KeyboardMacroReport{IsPaste: %v, Length: %d}", m.d[0] == uint8(1), binary.BigEndian.Uint32(m.d[1:5]))
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)
} }
@ -84,6 +90,51 @@ func (m *Message) KeyboardReport() (KeyboardReport, error) {
}, nil }, nil
} }
// Macro ..
type KeyboardMacro struct {
Modifier byte // 1 byte
Keys []byte // 6 bytes, to make things easier, the keys length is fixed to 6
Delay uint16 // 2 bytes
}
type KeyboardMacroReport struct {
IsPaste bool
Length uint32
Macro []KeyboardMacro
}
// KeyboardMacroReport returns the keyboard macro report from the message.
func (m *Message) KeyboardMacroReport() (KeyboardMacroReport, error) {
if m.t != TypeKeyboardMacroReport {
return KeyboardMacroReport{}, fmt.Errorf("invalid message type: %d", m.t)
}
isPaste := m.d[0] == uint8(1)
length := binary.BigEndian.Uint32(m.d[1:5])
// check total length
expectedLength := int(length)*9 + 5
if len(m.d) != expectedLength {
return KeyboardMacroReport{}, fmt.Errorf("invalid length: %d, expected: %d", len(m.d), expectedLength)
}
macro := make([]KeyboardMacro, 0, int(length))
for i := 0; i < int(length); i++ {
offset := 5 + i*9
macro = append(macro, KeyboardMacro{
Modifier: m.d[offset],
Keys: m.d[offset+1 : offset+7],
Delay: binary.BigEndian.Uint16(m.d[offset+7 : offset+9]),
})
}
return KeyboardMacroReport{
IsPaste: isPaste,
Macro: macro,
Length: length,
}, nil
}
// PointerReport .. // PointerReport ..
type PointerReport struct { type PointerReport struct {
X int X int
@ -131,3 +182,20 @@ func (m *Message) MouseReport() (MouseReport, error) {
Button: uint8(m.d[2]), Button: uint8(m.d[2]),
}, nil }, nil
} }
type KeyboardMacroStateReport struct {
State bool
IsPaste bool
}
// KeyboardMacroStateReport returns the keyboard macro state report from the message.
func (m *Message) KeyboardMacroStateReport() (KeyboardMacroStateReport, error) {
if m.t != TypeKeyboardMacroStateReport {
return KeyboardMacroStateReport{}, fmt.Errorf("invalid message type: %d", m.t)
}
return KeyboardMacroStateReport{
State: m.d[0] == uint8(1),
IsPaste: m.d[1] == uint8(1),
}, nil
}

View File

@ -1,6 +1,7 @@
package kvm package kvm
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@ -10,12 +11,14 @@ import (
"path/filepath" "path/filepath"
"reflect" "reflect"
"strconv" "strconv"
"sync"
"time" "time"
"github.com/pion/webrtc/v4" "github.com/pion/webrtc/v4"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.bug.st/serial" "go.bug.st/serial"
"github.com/jetkvm/kvm/internal/hidrpc"
"github.com/jetkvm/kvm/internal/usbgadget" "github.com/jetkvm/kvm/internal/usbgadget"
"github.com/jetkvm/kvm/internal/utils" "github.com/jetkvm/kvm/internal/utils"
) )
@ -1056,6 +1059,107 @@ func rpcSetLocalLoopbackOnly(enabled bool) error {
return nil return nil
} }
var (
keyboardMacroCancel context.CancelFunc
keyboardMacroLock sync.Mutex
)
// cancelKeyboardMacro cancels any ongoing keyboard macro execution
func cancelKeyboardMacro() {
keyboardMacroLock.Lock()
defer keyboardMacroLock.Unlock()
if keyboardMacroCancel != nil {
keyboardMacroCancel()
logger.Info().Msg("canceled keyboard macro")
keyboardMacroCancel = nil
}
}
func setKeyboardMacroCancel(cancel context.CancelFunc) {
keyboardMacroLock.Lock()
defer keyboardMacroLock.Unlock()
keyboardMacroCancel = cancel
}
func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacro) (usbgadget.KeysDownState, error) {
cancelKeyboardMacro()
ctx, cancel := context.WithCancel(context.Background())
setKeyboardMacroCancel(cancel)
s := hidrpc.KeyboardMacroStateReport{
State: true,
IsPaste: true,
}
if currentSession != nil {
currentSession.reportHidRPCKeyboardMacroState(s)
}
result, err := rpcDoExecuteKeyboardMacro(ctx, macro)
setKeyboardMacroCancel(nil)
s.State = false
if currentSession != nil {
currentSession.reportHidRPCKeyboardMacroState(s)
}
return result, err
}
func rpcCancelKeyboardMacro() {
cancelKeyboardMacro()
}
var keyboardClearStateKeys = make([]byte, 6)
func isClearKeyStep(step hidrpc.KeyboardMacro) bool {
return step.Modifier == 0 && bytes.Equal(step.Keys, keyboardClearStateKeys)
}
func rpcDoExecuteKeyboardMacro(ctx context.Context, macro []hidrpc.KeyboardMacro) (usbgadget.KeysDownState, error) {
var last usbgadget.KeysDownState
var err error
logger.Debug().Interface("macro", macro).Msg("Executing keyboard macro")
for i, step := range macro {
delay := time.Duration(step.Delay) * time.Millisecond
last, err = rpcKeyboardReport(step.Modifier, step.Keys)
if err != nil {
logger.Warn().Err(err).Msg("failed to execute keyboard macro")
return last, err
}
// notify the device that the keyboard state is being cleared
if isClearKeyStep(step) {
gadget.UpdateKeysDown(0, keyboardClearStateKeys)
}
// Use context-aware sleep that can be cancelled
select {
case <-time.After(delay):
// Sleep completed normally
case <-ctx.Done():
// make sure keyboard state is reset
_, err := rpcKeyboardReport(0, keyboardClearStateKeys)
if err != nil {
logger.Warn().Err(err).Msg("failed to reset keyboard state")
}
gadget.UpdateKeysDown(0, keyboardClearStateKeys)
logger.Debug().Int("step", i).Msg("Keyboard macro cancelled during sleep")
return last, ctx.Err()
}
}
return last, 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"}},

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

@ -27,6 +27,7 @@ export default function InfoBar() {
const { rpcDataChannel } = useRTCStore(); const { rpcDataChannel } = useRTCStore();
const { debugMode, mouseMode, showPressedKeys } = useSettingsStore(); const { debugMode, mouseMode, showPressedKeys } = useSettingsStore();
const { isPasteModeEnabled } = useHidStore();
useEffect(() => { useEffect(() => {
if (!rpcDataChannel) return; if (!rpcDataChannel) return;
@ -108,7 +109,12 @@ export default function InfoBar() {
<span className="text-xs">{rpcHidStatus}</span> <span className="text-xs">{rpcHidStatus}</span>
</div> </div>
)} )}
{isPasteModeEnabled && (
<div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">Paste Mode:</span>
<span className="text-xs">Enabled</span>
</div>
)}
{showPressedKeys && ( {showPressedKeys && (
<div className="flex items-center gap-x-1"> <div className="flex items-center gap-x-1">
<span className="text-xs font-semibold">Keys:</span> <span className="text-xs font-semibold">Keys:</span>

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";
@ -8,35 +8,32 @@ import { GridCard } from "@components/Card";
import { TextAreaWithLabel } from "@components/TextArea"; import { TextAreaWithLabel } from "@components/TextArea";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { useHidStore, useRTCStore, useUiStore, useSettingsStore } from "@/hooks/stores"; import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings"; import useKeyboard from "@/hooks/useKeyboard";
import { KeyStroke } from "@/keyboardLayouts";
import useKeyboardLayout from "@/hooks/useKeyboardLayout"; import useKeyboardLayout from "@/hooks/useKeyboardLayout";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { InputFieldWithLabel } from "@components/InputField";
const hidKeyboardPayload = (modifier: number, keys: number[]) => {
return { modifier, keys };
};
const modifierCode = (shift?: boolean, altRight?: boolean) => {
return (shift ? modifiers.ShiftLeft : 0)
| (altRight ? modifiers.AltRight : 0)
}
const noModifier = 0
export default function PasteModal() { export default function PasteModal() {
const TextAreaRef = useRef<HTMLTextAreaElement>(null); const TextAreaRef = useRef<HTMLTextAreaElement>(null);
const { setPasteModeEnabled } = useHidStore(); const { isPasteModeEnabled } = useHidStore();
const { setDisableVideoFocusTrap } = useUiStore(); const { setDisableVideoFocusTrap } = useUiStore();
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { rpcDataChannel } = useRTCStore(); const { executeMacro, cancelExecuteMacro } = useKeyboard();
const [invalidChars, setInvalidChars] = useState<string[]>([]); const [invalidChars, setInvalidChars] = useState<string[]>([]);
const [delayValue, setDelayValue] = useState(100);
const delay = useMemo(() => {
if (delayValue < 50 || delayValue > 65534) {
return 100;
}
return delayValue;
}, [delayValue]);
const close = useClose(); const close = useClose();
const { setKeyboardLayout } = useSettingsStore(); const { setKeyboardLayout } = useSettingsStore();
const { selectedKeyboard } = useKeyboardLayout(); const { selectedKeyboard } = useKeyboardLayout();
useEffect(() => { useEffect(() => {
send("getKeyboardLayout", {}, (resp: JsonRpcResponse) => { send("getKeyboardLayout", {}, (resp: JsonRpcResponse) => {
@ -46,21 +43,23 @@ export default function PasteModal() {
}, [send, setKeyboardLayout]); }, [send, setKeyboardLayout]);
const onCancelPasteMode = useCallback(() => { const onCancelPasteMode = useCallback(() => {
setPasteModeEnabled(false); cancelExecuteMacro();
setDisableVideoFocusTrap(false); setDisableVideoFocusTrap(false);
setInvalidChars([]); setInvalidChars([]);
}, [setDisableVideoFocusTrap, setPasteModeEnabled]); }, [setDisableVideoFocusTrap, cancelExecuteMacro]);
const onConfirmPaste = useCallback(async () => { const onConfirmPaste = useCallback(async () => {
setPasteModeEnabled(false); if (!TextAreaRef.current || !selectedKeyboard) return;
setDisableVideoFocusTrap(false);
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
if (!selectedKeyboard) return;
const text = TextAreaRef.current.value; const text = TextAreaRef.current.value;
try { try {
const macroSteps: {
keys: string[] | null;
modifiers: string[] | null;
delay: number;
}[] = [];
for (const char of text) { for (const char of text) {
const keyprops = selectedKeyboard.chars[char]; const keyprops = selectedKeyboard.chars[char];
if (!keyprops) continue; if (!keyprops) continue;
@ -70,39 +69,41 @@ export default function PasteModal() {
// if this is an accented character, we need to send that accent FIRST // if this is an accented character, we need to send that accent FIRST
if (accentKey) { if (accentKey) {
await sendKeystroke({modifier: modifierCode(accentKey.shift, accentKey.altRight), keys: [ keys[accentKey.key] ] }) const accentModifiers: string[] = [];
if (accentKey.shift) accentModifiers.push("ShiftLeft");
if (accentKey.altRight) accentModifiers.push("AltRight");
macroSteps.push({
keys: [String(accentKey.key)],
modifiers: accentModifiers.length > 0 ? accentModifiers : null,
delay,
});
} }
// now send the actual key // now send the actual key
await sendKeystroke({ modifier: modifierCode(shift, altRight), keys: [ keys[key] ]}); const modifiers: string[] = [];
if (shift) modifiers.push("ShiftLeft");
if (altRight) modifiers.push("AltRight");
macroSteps.push({
keys: [String(key)],
modifiers: modifiers.length > 0 ? modifiers : null,
delay
});
// if what was requested was a dead key, we need to send an unmodified space to emit // if what was requested was a dead key, we need to send an unmodified space to emit
// just the accent character // just the accent character
if (deadKey) { if (deadKey) macroSteps.push({ keys: ["Space"], modifiers: null, delay });
await sendKeystroke({ modifier: noModifier, keys: [ keys["Space"] ] }); }
}
// now send a message with no keys down to "release" the keys if (macroSteps.length > 0) {
await sendKeystroke({ modifier: 0, keys: [] }); await executeMacro(macroSteps);
} }
} catch (error) { } catch (error) {
console.error("Failed to paste text:", error); console.error("Failed to paste text:", error);
notifications.error("Failed to paste text"); notifications.error("Failed to paste text");
} }
}, [selectedKeyboard, executeMacro, delay]);
async function sendKeystroke(stroke: KeyStroke) {
await new Promise<void>((resolve, reject) => {
send(
"keyboardReport",
hidKeyboardPayload(stroke.modifier, stroke.keys),
params => {
if ("error" in params) return reject(params.error);
resolve();
}
);
});
}
}, [selectedKeyboard, rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteModeEnabled]);
useEffect(() => { useEffect(() => {
if (TextAreaRef.current) { if (TextAreaRef.current) {
@ -122,14 +123,18 @@ export default function PasteModal() {
/> />
<div <div
className="animate-fadeIn opacity-0 space-y-2" className="animate-fadeIn space-y-2 opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
}} }}
> >
<div> <div>
<div className="w-full" onKeyUp={e => e.stopPropagation()} onKeyDown={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"
@ -171,9 +176,31 @@ export default function PasteModal() {
)} )}
</div> </div>
</div> </div>
<div className="text-xs text-slate-600 dark:text-slate-400">
<InputFieldWithLabel
type="number"
label="Delay between keys"
placeholder="Delay between keys"
min={50}
max={65534}
value={delayValue}
onChange={e => {
setDelayValue(parseInt(e.target.value, 10));
}}
/>
{delayValue < 50 || delayValue > 65534 && (
<div className="mt-2 flex items-center gap-x-2">
<ExclamationCircleIcon className="h-4 w-4 text-red-500 dark:text-red-400" />
<span className="text-xs text-red-500 dark:text-red-400">
Delay must be between 50 and 65534
</span>
</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: {selectedKeyboard.isoCode}-{selectedKeyboard.name} Sending text using keyboard layout: {selectedKeyboard.isoCode}-
{selectedKeyboard.name}
</p> </p>
</div> </div>
</div> </div>
@ -181,7 +208,7 @@ export default function PasteModal() {
</div> </div>
</div> </div>
<div <div
className="flex animate-fadeIn opacity-0 items-center justify-end gap-x-2" className="flex animate-fadeIn items-center justify-end gap-x-2 opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",
@ -200,6 +227,7 @@ export default function PasteModal() {
size="SM" size="SM"
theme="primary" theme="primary"
text="Confirm Paste" text="Confirm Paste"
disabled={isPasteModeEnabled}
onClick={onConfirmPaste} onClick={onConfirmPaste}
LeadingIcon={LuCornerDownLeft} LeadingIcon={LuCornerDownLeft}
/> />

View File

@ -7,8 +7,11 @@ export const HID_RPC_MESSAGE_TYPES = {
WheelReport: 0x04, WheelReport: 0x04,
KeypressReport: 0x05, KeypressReport: 0x05,
MouseReport: 0x06, MouseReport: 0x06,
KeyboardMacroReport: 0x07,
CancelKeyboardMacroReport: 0x08,
KeyboardLedState: 0x32, KeyboardLedState: 0x32,
KeysDownState: 0x33, KeysDownState: 0x33,
KeyboardMacroStateReport: 0x34,
} }
export type HidRpcMessageType = typeof HID_RPC_MESSAGE_TYPES[keyof typeof HID_RPC_MESSAGE_TYPES]; export type HidRpcMessageType = typeof HID_RPC_MESSAGE_TYPES[keyof typeof HID_RPC_MESSAGE_TYPES];
@ -32,6 +35,30 @@ const fromInt32toUint8 = (n: number) => {
]); ]);
}; };
const fromUint16toUint8 = (n: number) => {
if (n > 65535 || n < 0) {
throw new Error(`Number ${n} is not within the uint16 range`);
}
return new Uint8Array([
(n >> 8) & 0xFF,
(n >> 0) & 0xFF,
]);
};
const fromUint32toUint8 = (n: number) => {
if (n > 4294967295 || n < 0) {
throw new Error(`Number ${n} is not within the uint32 range`);
}
return new Uint8Array([
(n >> 24) & 0xFF,
(n >> 16) & 0xFF,
(n >> 8) & 0xFF,
(n >> 0) & 0xFF,
]);
};
const fromInt8ToUint8 = (n: number) => { const fromInt8ToUint8 = (n: number) => {
if (n < -128 || n > 127) { if (n < -128 || n > 127) {
throw new Error(`Number ${n} is not within the int8 range`); throw new Error(`Number ${n} is not within the int8 range`);
@ -186,6 +213,99 @@ export class KeyboardReportMessage extends RpcMessage {
} }
} }
export interface KeyboardMacroStep extends KeysDownState {
delay: number;
}
export class KeyboardMacroReportMessage extends RpcMessage {
isPaste: boolean;
length: number;
steps: KeyboardMacroStep[];
KEYS_LENGTH = 6;
constructor(isPaste: boolean, length: number, steps: KeyboardMacroStep[]) {
super(HID_RPC_MESSAGE_TYPES.KeyboardMacroReport);
this.isPaste = isPaste;
this.length = length;
this.steps = steps;
}
marshal(): Uint8Array {
// validate if length is correct
if (this.length !== this.steps.length) {
throw new Error(`Length ${this.length} is not equal to the number of steps ${this.steps.length}`);
}
const data = new Uint8Array(this.length * 9 + 6);
data.set(new Uint8Array([
this.messageType,
this.isPaste ? 1 : 0,
...fromUint32toUint8(this.length),
]), 0);
for (let i = 0; i < this.length; i++) {
const step = this.steps[i];
if (!withinUint8Range(step.modifier)) {
throw new Error(`Modifier ${step.modifier} is not within the uint8 range`);
}
// Ensure the keys are within the KEYS_LENGTH range
const keys = step.keys;
if (keys.length > this.KEYS_LENGTH) {
throw new Error(`Keys ${keys} is not within the hidKeyBufferSize range`);
} else if (keys.length < this.KEYS_LENGTH) {
keys.push(...Array(this.KEYS_LENGTH - keys.length).fill(0));
}
for (const key of keys) {
if (!withinUint8Range(key)) {
throw new Error(`Key ${key} is not within the uint8 range`);
}
}
const macroBinary = new Uint8Array([
step.modifier,
...keys,
...fromUint16toUint8(step.delay),
]);
const offset = 6 + i * 9;
data.set(macroBinary, offset);
}
return data;
}
}
export class KeyboardMacroStateReportMessage extends RpcMessage {
state: boolean;
isPaste: boolean;
constructor(state: boolean, isPaste: boolean) {
super(HID_RPC_MESSAGE_TYPES.KeyboardMacroStateReport);
this.state = state;
this.isPaste = isPaste;
}
marshal(): Uint8Array {
return new Uint8Array([
this.messageType,
this.state ? 1 : 0,
this.isPaste ? 1 : 0,
]);
}
public static unmarshal(data: Uint8Array): KeyboardMacroStateReportMessage | undefined {
if (data.length < 1) {
throw new Error(`Invalid keyboard macro state report message length: ${data.length}`);
}
return new KeyboardMacroStateReportMessage(data[0] === 1, data[1] === 1);
}
}
export class KeyboardLedStateMessage extends RpcMessage { export class KeyboardLedStateMessage extends RpcMessage {
keyboardLedState: KeyboardLedState; keyboardLedState: KeyboardLedState;
@ -256,6 +376,17 @@ export class PointerReportMessage extends RpcMessage {
} }
} }
export class CancelKeyboardMacroReportMessage extends RpcMessage {
constructor() {
super(HID_RPC_MESSAGE_TYPES.CancelKeyboardMacroReport);
}
marshal(): Uint8Array {
return new Uint8Array([this.messageType]);
}
}
export class MouseReportMessage extends RpcMessage { export class MouseReportMessage extends RpcMessage {
dx: number; dx: number;
dy: number; dy: number;
@ -284,6 +415,9 @@ export const messageRegistry = {
[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.KeyboardMacroReport]: KeyboardMacroReportMessage,
[HID_RPC_MESSAGE_TYPES.CancelKeyboardMacroReport]: CancelKeyboardMacroReportMessage,
[HID_RPC_MESSAGE_TYPES.KeyboardMacroStateReport]: KeyboardMacroStateReportMessage,
} }
export const unmarshalHidRpcMessage = (data: Uint8Array): RpcMessage | undefined => { export const unmarshalHidRpcMessage = (data: Uint8Array): RpcMessage | undefined => {

View File

@ -3,8 +3,11 @@ import { useCallback, useEffect, useMemo } from "react";
import { useRTCStore } from "@/hooks/stores"; import { useRTCStore } from "@/hooks/stores";
import { import {
CancelKeyboardMacroReportMessage,
HID_RPC_VERSION, HID_RPC_VERSION,
HandshakeMessage, HandshakeMessage,
KeyboardMacroStep,
KeyboardMacroReportMessage,
KeyboardReportMessage, KeyboardReportMessage,
KeypressReportMessage, KeypressReportMessage,
MouseReportMessage, MouseReportMessage,
@ -68,6 +71,21 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
[sendMessage], [sendMessage],
); );
const reportKeyboardMacroEvent = useCallback(
(macro: KeyboardMacroStep[]) => {
const d = new KeyboardMacroReportMessage(false, macro.length, macro);
sendMessage(d);
},
[sendMessage],
);
const cancelOngoingKeyboardMacro = useCallback(
() => {
sendMessage(new CancelKeyboardMacroReportMessage());
},
[sendMessage],
);
const sendHandshake = useCallback(() => { const sendHandshake = useCallback(() => {
if (rpcHidProtocolVersion) return; if (rpcHidProtocolVersion) return;
if (!rpcHidChannel) return; if (!rpcHidChannel) return;
@ -143,6 +161,8 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
reportKeypressEvent, reportKeypressEvent,
reportAbsMouseEvent, reportAbsMouseEvent,
reportRelMouseEvent, reportRelMouseEvent,
reportKeyboardMacroEvent,
cancelOngoingKeyboardMacro,
rpcHidProtocolVersion, rpcHidProtocolVersion,
rpcHidReady, rpcHidReady,
rpcHidStatus, rpcHidStatus,

View File

@ -1,15 +1,21 @@
import { useCallback } from "react"; import { useCallback, useMemo } 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";
import { useHidRpc } from "@/hooks/useHidRpc"; import { useHidRpc } from "@/hooks/useHidRpc";
import { KeyboardLedStateMessage, KeysDownStateMessage } from "@/hooks/hidRpc"; import { KeyboardLedStateMessage, KeyboardMacroStateReportMessage, KeyboardMacroStep, KeysDownStateMessage } from "@/hooks/hidRpc";
import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings"; import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings";
export default function useKeyboard() { export default function useKeyboard() {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { rpcDataChannel } = useRTCStore(); const { rpcDataChannel } = useRTCStore();
const { keysDownState, setKeysDownState, setKeyboardLedState } = useHidStore(); const { keysDownState, setKeysDownState, setKeyboardLedState, setPasteModeEnabled } = useHidStore();
// 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
@ -26,8 +32,10 @@ export default function useKeyboard() {
const { const {
reportKeyboardEvent: sendKeyboardEventHidRpc, reportKeyboardEvent: sendKeyboardEventHidRpc,
reportKeypressEvent: sendKeypressEventHidRpc, reportKeypressEvent: sendKeypressEventHidRpc,
reportKeyboardMacroEvent: sendKeyboardMacroEventHidRpc,
cancelOngoingKeyboardMacro: cancelOngoingKeyboardMacroHidRpc,
rpcHidReady, rpcHidReady,
} = useHidRpc((message) => { } = useHidRpc(message => {
switch (message.constructor) { switch (message.constructor) {
case KeysDownStateMessage: case KeysDownStateMessage:
setKeysDownState((message as KeysDownStateMessage).keysDownState); setKeysDownState((message as KeysDownStateMessage).keysDownState);
@ -35,6 +43,10 @@ export default function useKeyboard() {
case KeyboardLedStateMessage: case KeyboardLedStateMessage:
setKeyboardLedState((message as KeyboardLedStateMessage).keyboardLedState); setKeyboardLedState((message as KeyboardLedStateMessage).keyboardLedState);
break; break;
case KeyboardMacroStateReportMessage:
if (!(message as KeyboardMacroStateReportMessage).isPaste) break;
setPasteModeEnabled((message as KeyboardMacroStateReportMessage).state);
break;
default: default:
break; break;
} }
@ -48,7 +60,9 @@ export default function useKeyboard() {
async (state: KeysDownState) => { async (state: KeysDownState) => {
if (rpcDataChannel?.readyState !== "open" && !rpcHidReady) return; if (rpcDataChannel?.readyState !== "open" && !rpcHidReady) return;
console.debug(`Send keyboardReport keys: ${state.keys}, modifier: ${state.modifier}`); console.debug(
`Send keyboardReport keys: ${state.keys}, modifier: ${state.modifier}`,
);
if (rpcHidReady) { if (rpcHidReady) {
console.debug("Sending keyboard report via HidRPC"); console.debug("Sending keyboard report via HidRPC");
@ -56,31 +70,32 @@ export default function useKeyboard() {
return; return;
} }
send("keyboardReport", { keys: state.keys, modifier: state.modifier }, (resp: JsonRpcResponse) => { send(
if ("error" in resp) { "keyboardReport",
console.error(`Failed to send keyboard report ${state}`, resp.error); { keys: state.keys, modifier: state.modifier },
} (resp: JsonRpcResponse) => {
}); if ("error" in resp) {
console.error(`Failed to send keyboard report ${state}`, resp.error);
}
},
);
}, },
[ [rpcDataChannel?.readyState, rpcHidReady, send, sendKeyboardEventHidRpc],
rpcDataChannel?.readyState,
rpcHidReady,
send,
sendKeyboardEventHidRpc,
],
); );
const MACRO_RESET_KEYBOARD_STATE = useMemo(() => ({
keys: new Array(hidKeyBufferSize).fill(0),
modifier: 0,
delay: 0,
}), []);
// resetKeyboardState is used to reset the keyboard state to no keys pressed and no modifiers. // 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 // This is useful for macros and when the browser loses focus to ensure that the keyboard state
// is clean. // is clean.
const resetKeyboardState = useCallback( const resetKeyboardState = useCallback(async () => {
async () => { // Reset the keys buffer to zeros and the modifier state to zero
// Reset the keys buffer to zeros and the modifier state to zero sendKeyboardEvent(MACRO_RESET_KEYBOARD_STATE);
keysDownState.keys.length = hidKeyBufferSize; }, [sendKeyboardEvent, MACRO_RESET_KEYBOARD_STATE]);
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.
@ -88,29 +103,32 @@ export default function useKeyboard() {
// After the delay, the keys and modifiers are released and the next step is executed. // After the delay, the keys and modifiers are released and the next step is executed.
// If a step has no keys or modifiers, it is treated as a delay-only step. // If a step has no keys or modifiers, it is treated as a delay-only step.
// A small pause is added between steps to ensure that the device can process the events. // A small pause is added between steps to ensure that the device can process the events.
const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => { const executeMacro = async (
for (const [index, step] of steps.entries()) { steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[],
) => {
const macro: KeyboardMacroStep[] = [];
for (const [_, step] of steps.entries()) {
const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean); const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean);
const modifierMask: number = (step.modifiers || []).map(mod => modifiers[mod]).reduce((acc, val) => acc + val, 0); const modifierMask: number = (step.modifiers || [])
.map(mod => modifiers[mod])
.reduce((acc, val) => acc + val, 0);
// If the step has keys and/or modifiers, press them and hold for the delay // If the step has keys and/or modifiers, press them and hold for the delay
if (keyValues.length > 0 || modifierMask > 0) { if (keyValues.length > 0 || modifierMask > 0) {
sendKeyboardEvent({ keys: keyValues, modifier: modifierMask }); macro.push({ keys: keyValues, modifier: modifierMask, delay: 20 });
await new Promise(resolve => setTimeout(resolve, step.delay || 50)); macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 });
resetKeyboardState();
} else {
// This is a delay-only step, just wait for the delay amount
await new Promise(resolve => setTimeout(resolve, step.delay || 50));
}
// Add a small pause between steps if not the last step
if (index < steps.length - 1) {
await new Promise(resolve => setTimeout(resolve, 10));
} }
} }
sendKeyboardMacroEventHidRpc(macro);
}; };
const cancelExecuteMacro = useCallback(async () => {
if (!rpcHidReady) return;
cancelOngoingKeyboardMacroHidRpc();
}, [rpcHidReady, cancelOngoingKeyboardMacroHidRpc]);
// 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.
@ -132,7 +150,11 @@ export default function useKeyboard() {
sendKeypressEventHidRpc(key, press); sendKeypressEventHidRpc(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,
);
sendKeyboardEvent(downState); // then we send the full state sendKeyboardEvent(downState); // then we send the full state
// if we just sent ErrorRollOver, reset to empty state // if we just sent ErrorRollOver, reset to empty state
@ -152,7 +174,11 @@ export default function useKeyboard() {
); );
// IMPORTANT: See the keyPressReportApiAvailable comment above for the reason this exists // IMPORTANT: See the keyPressReportApiAvailable comment above for the reason this exists
function simulateDeviceSideKeyHandlingForLegacyDevices(state: KeysDownState, key: number, press: boolean): KeysDownState { function simulateDeviceSideKeyHandlingForLegacyDevices(
state: KeysDownState,
key: number,
press: boolean,
): KeysDownState {
// 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
// for handling key presses and releases. It ensures that the USB gadget // for handling key presses and releases. It ensures that the USB gadget
// behaves similarly to a real USB HID keyboard. This logic is paralleled // behaves similarly to a real USB HID keyboard. This logic is paralleled
@ -181,7 +207,7 @@ export default function useKeyboard() {
// and if we find a zero byte, we can place the key there (if press is true) // and if we find a zero byte, we can place the key there (if press is true)
if (keys[i] === key || keys[i] === 0) { if (keys[i] === key || keys[i] === 0) {
if (press) { if (press) {
keys[i] = key // overwrites the zero byte or the same key if already pressed keys[i] = key; // overwrites the zero byte or the same key if already pressed
} else { } else {
// we are releasing the key, remove it from the buffer // we are releasing the key, remove it from the buffer
if (keys[i] !== 0) { if (keys[i] !== 0) {
@ -197,18 +223,20 @@ export default function useKeyboard() {
// If we reach here it means we didn't find an empty slot or the key in the buffer // If we reach here it means we didn't find an empty slot or the key in the buffer
if (overrun) { if (overrun) {
if (press) { if (press) {
console.warn(`keyboard buffer overflow current keys ${keys}, key: ${key} not added`); console.warn(
`keyboard buffer overflow current keys ${keys}, key: ${key} not added`,
);
// Fill all key slots with ErrorRollOver (0x01) to indicate overflow // Fill all key slots with ErrorRollOver (0x01) to indicate overflow
keys.length = hidKeyBufferSize; keys.length = hidKeyBufferSize;
keys.fill(hidErrorRollOver); keys.fill(hidErrorRollOver);
} else { } else {
// If we are releasing a key, and we didn't find it in a slot, who cares? // If we are releasing a key, and we didn't find it in a slot, who cares?
console.debug(`key ${key} not found in buffer, nothing to release`) console.debug(`key ${key} not found in buffer, nothing to release`);
} }
} }
} }
return { modifier: modifiers, keys }; return { modifier: modifiers, keys };
} }
return { handleKeyPress, resetKeyboardState, executeMacro }; return { handleKeyPress, resetKeyboardState, executeMacro, cancelExecuteMacro };
} }

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

20
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
@ -218,6 +228,10 @@ func handleWebRTCSession(c *gin.Context) {
_ = peerConn.Close() _ = peerConn.Close()
}() }()
} }
// Cancel any ongoing keyboard report multi when session changes
cancelKeyboardMacro()
currentSession = session currentSession = session
c.JSON(http.StatusOK, gin.H{"sd": sd}) c.JSON(http.StatusOK, gin.H{"sd": sd})
} }

View File

@ -266,6 +266,8 @@ func newSession(config SessionConfig) (*Session, error) {
if connectionState == webrtc.ICEConnectionStateClosed { if connectionState == webrtc.ICEConnectionStateClosed {
scopedLogger.Debug().Msg("ICE Connection State is closed, unmounting virtual media") scopedLogger.Debug().Msg("ICE Connection State is closed, unmounting virtual media")
if session == currentSession { if session == currentSession {
// Cancel any ongoing keyboard report multi when session closes
cancelKeyboardMacro()
currentSession = nil currentSession = nil
} }
// Stop RPC processor // Stop RPC processor