Compare commits

25 Commits

Author SHA1 Message Date
luckfox-eng29
ec0581c1f4 Update App version to 0.1.3
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
2026-05-16 16:40:14 +08:00
luckfox-eng29
2a2890e7b3 feat(video): enhance video frame handling with atomic reference counting and memory management
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
2026-05-16 16:40:10 +08:00
luckfox-eng29
40f5af2120 feat(usb): add UsbStatusPanel component and integrate it into BottomBarPC and MobileDesktop
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
2026-05-16 16:40:07 +08:00
luckfox-eng29
b1090c9493 feat(hid): remove HID-RPC related code and improve keyboard handling logic
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
2026-05-16 16:40:04 +08:00
luckfox-eng29
18f7d8425f feat(api): implement API key generation and management functionality
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
2026-05-16 16:40:00 +08:00
luckfox-eng29
233e6e9cd6 feat(ota): add OTA signature verification and public key handling
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
2026-05-16 16:39:58 +08:00
luckfox-eng29
d47bca1940 feat: add support for MCP service and CLI subcommands
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
2026-05-16 16:39:54 +08:00
luckfox-eng29
bf84660c8b feat(keyboard): update keyboard layouts and key display mappings for multiple languages
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
2026-05-16 16:39:52 +08:00
luckfox-eng29
7cef8baa0d feat: Add VPN tool management functionality
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
2026-05-16 16:39:48 +08:00
luckfox-eng29
95f2b6bada feat(network): add proxy configuration options and environment variable handling
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
2026-05-16 16:39:46 +08:00
luckfox-eng29
225ee790d2 fix(ui): keep fps only and ensure overlays fully cover video
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
2026-05-16 16:39:42 +08:00
luckfox-eng29
2a5c0e585a feat(keyboard): integrate keyboard layout management and shortcuts functionality
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
2026-05-16 16:39:39 +08:00
luckfox-eng29
4798bde987 fix: resolve failure to rename macro after creation
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
2026-05-16 16:39:33 +08:00
luckfox-eng29
21fa9533d1 feat(mobile): adapt touch mouse behavior
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
2026-05-16 16:39:31 +08:00
luckfox-eng29
b7cf769cb2 feat(sd): add filesystem type selection for SD card format (exFAT/FAT32)
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
2026-05-16 16:39:28 +08:00
luckfox-eng29
a3f65e4893 feat(video): add video rate control settings and UI integration
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
2026-05-16 16:39:24 +08:00
luckfox-eng29
d3c7f6e01b feat(led): add disabled mode for LED settings in UI
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
2026-05-16 16:39:18 +08:00
luckfox-eng29
fda0138dd1 feat(ui): add OCR detect overlay and capture flow
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
2026-05-16 16:39:15 +08:00
luckfox-eng29
6292537c23 refactor(hid): improve keyboard layout compatibility in HID handling functions
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
2026-05-16 16:39:09 +08:00
luckfox-eng29
a1da483b27 chore: add .worktrees to gitignore
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
2026-05-16 16:39:05 +08:00
luckfox-eng29
97faba9dac Merge pull request #2 from Augtons/bugfix/pointer-lock
fix(ui): fix relative mouse mode on browsers without the "pointer-lock" permission query
2026-05-09 16:57:36 +08:00
luckfox-eng29
141c16b9f7 Merge pull request #1 from Augtons/luckfox
Add configurable STUN and TURN servers
2026-05-09 16:54:40 +08:00
Augtons
461516665c fix(ui): fix relative mouse mode on browsers without the 'pointer-lock' permission query 2026-05-06 23:43:19 +08:00
Augtons
f1a6c75fc0 feat(webrtc): add configurable STUN and TURN servers
Add backend config, RPC handlers, and an HTTP endpoint for WebRTC ICE servers. Replace hardcoded frontend STUN usage with server-provided ICE server configuration, and add access settings UI for STUN and TURN entries.
2026-05-03 16:43:29 +08:00
luckfox-eng29
d5bfaffd86 Update App version to 0.1.2
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
2026-03-23 12:04:54 +08:00
103 changed files with 12801 additions and 1108 deletions

1
.gitignore vendored
View File

@@ -9,3 +9,4 @@ node_modules/
ui/.i18n_extractor.json
device-tests.tar.gz
.worktrees/

View File

@@ -2,12 +2,19 @@ BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
BUILDDATE ?= $(shell date -u +%FT%T%z)
BUILDTS ?= $(shell date -u +%s)
REVISION ?= $(shell git rev-parse HEAD)
VERSION_DEV ?= 0.1.1-dev
VERSION ?= 0.1.1
VERSION_DEV ?= 0.1.3-dev
VERSION ?= 0.1.3
PROMETHEUS_TAG := github.com/prometheus/common/version
KVM_PKG_NAME := kvm
# OTA signing key path (Ed25519 private key for auto-signing at build time)
OTA_SIGNING_KEY ?=
# OTA signing public key (hex-encoded Ed25519 public key, 64 hex chars)
# Default empty = signature verification disabled (backward compatible)
OTA_PUBLIC_KEY ?=
GO_BUILD_ARGS := -tags netgo
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
GO_LDFLAGS := \
@@ -15,7 +22,8 @@ GO_LDFLAGS := \
-X $(PROMETHEUS_TAG).Branch=$(BRANCH) \
-X $(PROMETHEUS_TAG).BuildDate=$(BUILDDATE) \
-X $(PROMETHEUS_TAG).Revision=$(REVISION) \
-X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS)
-X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS) \
-X $(KVM_PKG_NAME).builtOtaPublicKey=$(OTA_PUBLIC_KEY)
GO_CMD := GOOS=linux GOARCH=arm GOARM=7 go
BIN_DIR := $(shell pwd)/bin
@@ -28,6 +36,12 @@ build_dev:
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
$(GO_RELEASE_BUILD_ARGS) \
-o $(BIN_DIR)/kvm_app cmd/main.go
@if [ -n "$(OTA_SIGNING_KEY)" ]; then \
echo "Signing $(BIN_DIR)/kvm_app..."; \
go run cmd/main.go cli signer sign --key "$(OTA_SIGNING_KEY)" $(BIN_DIR)/kvm_app; \
else \
echo "OTA_SIGNING_KEY not set, skipping signing."; \
fi
frontend:
cd ui && npm ci && npm run build:device
@@ -38,3 +52,13 @@ build_release: frontend
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" \
$(GO_RELEASE_BUILD_ARGS) \
-o bin/kvm_app cmd/main.go
@if [ -n "$(OTA_SIGNING_KEY)" ]; then \
echo "Signing bin/kvm_app..."; \
go run cmd/main.go cli signer sign --key "$(OTA_SIGNING_KEY)" bin/kvm_app; \
else \
echo "OTA_SIGNING_KEY not set, skipping signing."; \
fi
sign:
@echo "Signing firmware files..."
go run cmd/main.go cli signer sign --key $(KEY) $(FILES)

389
api.go Normal file
View File

@@ -0,0 +1,389 @@
package kvm
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func StartAPIServer(port int) {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
r.Use(corsMiddleware())
r.Use(apiKeyAuthMiddleware(config.APIKey))
// Health check (no auth required)
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"device": GetDeviceID(),
"version": builtAppVersion,
})
})
// API routes
api := r.Group("/api/lan")
{
// Mouse
api.POST("/mouse/absolute", handleAPIMouseAbsolute)
api.POST("/mouse/relative", handleAPIMouseRelative)
api.POST("/mouse/click", handleAPIMouseClick)
api.POST("/mouse/scroll", handleAPIMouseScroll)
// Keyboard
api.POST("/keyboard/key", handleAPIKeyboardKey)
api.POST("/keyboard/combo", handleAPIKeyboardCombo)
api.POST("/keyboard/type", handleAPIKeyboardType)
// Capture
api.GET("/capture", handleAPICapture)
// Status
api.GET("/status", handleAPIStatus)
api.GET("/video/state", handleAPIVideoState)
}
addr := fmt.Sprintf(":%d", port)
logger.Info().Str("addr", addr).Msg("Starting LAN API server")
if err := r.Run(addr); err != nil {
logger.Error().Err(err).Msg("LAN API server failed")
}
}
// === Middleware ===
func corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
func apiKeyAuthMiddleware(expectedKey string) gin.HandlerFunc {
return func(c *gin.Context) {
// Skip auth for health endpoint
if c.Request.URL.Path == "/health" {
c.Next()
return
}
// Skip auth for localhost
remoteAddr := c.Request.RemoteAddr
if strings.HasPrefix(remoteAddr, "127.0.0.1:") ||
strings.HasPrefix(remoteAddr, "[::1]:") {
c.Next()
return
}
// If no API key configured, reject LAN requests
if expectedKey == "" {
c.AbortWithStatusJSON(401, gin.H{"error": "API key not configured"})
return
}
auth := c.GetHeader("Authorization")
var key string
if _, err := fmt.Sscanf(auth, "Bearer %s", &key); err != nil {
c.AbortWithStatusJSON(401, gin.H{"error": "missing or invalid authorization"})
return
}
if !strings.EqualFold(key, expectedKey) {
c.AbortWithStatusJSON(401, gin.H{"error": "invalid api key"})
return
}
c.Next()
}
}
// === API Handlers: Mouse ===
func handleAPIMouseAbsolute(c *gin.Context) {
var req struct {
X int `json:"x" binding:"required"`
Y int `json:"y" binding:"required"`
Buttons uint8 `json:"buttons"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
"x": req.X, "y": req.Y, "buttons": req.Buttons,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "x": req.X, "y": req.Y})
}
func handleAPIMouseRelative(c *gin.Context) {
var req struct {
Dx int8 `json:"dx" binding:"required"`
Dy int8 `json:"dy" binding:"required"`
Buttons uint8 `json:"buttons"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := callRPCHandler(rpcHandlers["relMouseReport"], map[string]interface{}{
"dx": req.Dx, "dy": req.Dy, "buttons": req.Buttons,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "dx": req.Dx, "dy": req.Dy})
}
func handleAPIMouseClick(c *gin.Context) {
var req struct {
Button string `json:"button" binding:"required"`
Double bool `json:"double"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var buttons uint8
switch req.Button {
case "left":
buttons = 1
case "right":
buttons = 2
case "middle":
buttons = 4
default:
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("unknown button: %s", req.Button)})
return
}
clickCount := 1
if req.Double {
clickCount = 2
}
for i := 0; i < clickCount; i++ {
_, err := callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
"x": 0, "y": 0, "buttons": buttons,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_, err = callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
"x": 0, "y": 0, "buttons": 0,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "button": req.Button, "clicks": clickCount})
}
func handleAPIMouseScroll(c *gin.Context) {
var req struct {
Delta int8 `json:"delta" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := callRPCHandler(rpcHandlers["wheelReport"], map[string]interface{}{
"wheelY": req.Delta,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "delta": req.Delta})
}
// === API Handlers: Keyboard ===
func handleAPIKeyboardKey(c *gin.Context) {
var req struct {
Key string `json:"key" binding:"required"`
Action string `json:"action"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
keyCode, ok := keyNameToCode[req.Key]
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("unknown key: %s", req.Key)})
return
}
action := strings.ToLower(req.Action)
if action == "" {
action = "press"
}
switch action {
case "press":
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": 0, "keys": []uint8{keyCode},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": 0, "keys": []uint8{},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
case "down":
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": 0, "keys": []uint8{keyCode},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
case "up":
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": 0, "keys": []uint8{},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("unknown action: %s", req.Action)})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "key": req.Key, "action": action})
}
func handleAPIKeyboardCombo(c *gin.Context) {
var req struct {
Keys []string `json:"keys" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var keys []uint8
var modifier uint8
for _, keyName := range req.Keys {
switch strings.ToLower(keyName) {
case "ctrl", "control":
modifier |= 0x01
continue
case "shift":
modifier |= 0x02
continue
case "alt":
modifier |= 0x04
continue
case "meta", "win", "cmd":
modifier |= 0x08
continue
}
keyCode, ok := keyNameToCode[keyName]
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("unknown key: %s", keyName)})
return
}
keys = append(keys, keyCode)
}
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": modifier, "keys": keys,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": 0, "keys": []uint8{},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "keys": req.Keys})
}
func handleAPIKeyboardType(c *gin.Context) {
var req struct {
Text string `json:"text" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
for _, char := range req.Text {
keyCode, modifier, ok := charToKeyCode(uint8(char))
if !ok {
continue
}
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": modifier, "keys": []uint8{keyCode},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": 0, "keys": []uint8{},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "text": req.Text})
}
// === API Handlers: Capture ===
func handleAPICapture(c *gin.Context) {
data, err := captureScreenshot("jpeg")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Data(http.StatusOK, "image/jpeg", data)
}
// === API Handlers: Status ===
func handleAPIStatus(c *gin.Context) {
state := lastVideoState
c.JSON(http.StatusOK, gin.H{
"video": gin.H{
"ready": state.Ready,
"width": state.Width,
"height": state.Height,
"fps": state.FramePerSecond,
"error": state.Error,
},
"device": gin.H{
"name": "PicoKVM",
"version": builtAppVersion,
},
})
}
func handleAPIVideoState(c *gin.Context) {
state := lastVideoState
c.JSON(http.StatusOK, gin.H{
"ready": state.Ready,
"width": state.Width,
"height": state.Height,
"fps": state.FramePerSecond,
"error": state.Error,
})
}

57
boot_storage.go Normal file
View File

@@ -0,0 +1,57 @@
package kvm
import (
"os"
"strings"
"sync"
)
type BootStorageType string
const (
BootStorageUnknown BootStorageType = "unknown"
BootStorageEMMC BootStorageType = "emmc"
BootStorageSD BootStorageType = "sd"
)
var (
bootStorageOnce sync.Once
bootStorageType BootStorageType = BootStorageUnknown
)
func GetBootStorageType() BootStorageType {
bootStorageOnce.Do(func() {
bootStorageType = detectBootStorageType()
})
return bootStorageType
}
func IsBootFromSD() bool {
return GetBootStorageType() == BootStorageSD
}
func detectBootStorageType() BootStorageType {
cmdlineBytes, err := os.ReadFile("/proc/cmdline")
if err != nil {
return BootStorageUnknown
}
cmdline := strings.TrimSpace(string(cmdlineBytes))
for _, field := range strings.Fields(cmdline) {
if !strings.HasPrefix(field, "root=") {
continue
}
root := strings.TrimPrefix(field, "root=")
switch {
case strings.HasPrefix(root, "/dev/mmcblk0"):
return BootStorageEMMC
case strings.HasPrefix(root, "/dev/mmcblk1"):
return BootStorageSD
default:
return BootStorageUnknown
}
}
return BootStorageUnknown
}

552
cli.go Normal file
View File

@@ -0,0 +1,552 @@
package kvm
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/spf13/cobra"
)
var cliRootCmd = &cobra.Command{
Use: "kvm_app cli",
Short: "PicoKVM CLI tools",
Long: `Command line interface for controlling HID devices via API`,
SilenceErrors: true,
SilenceUsage: true,
}
func RunCLI(args []string) {
cliRootCmd.SetArgs(args)
if err := cliRootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var apiBaseURL = "http://localhost:8080/api/lan"
func apiPost(path string, body interface{}) error {
jsonBody, err := json.Marshal(body)
if err != nil {
return err
}
resp, err := http.Post(apiBaseURL+path, "application/json", strings.NewReader(string(jsonBody)))
if err != nil {
return fmt.Errorf("failed to connect to API server: %w\nIs kvm_app running?", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
bodyBytes, _ := io.ReadAll(resp.Body)
var errResp map[string]interface{}
if json.Unmarshal(bodyBytes, &errResp) == nil {
if msg, ok := errResp["error"].(string); ok {
return fmt.Errorf("API error: %s", msg)
}
}
return fmt.Errorf("API error: HTTP %d - %s", resp.StatusCode, string(bodyBytes))
}
return nil
}
func apiGetBytes(path string) ([]byte, error) {
resp, err := http.Get(apiBaseURL + path)
if err != nil {
return nil, fmt.Errorf("failed to connect to API server: %w\nIs kvm_app running?", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error: HTTP %d - %s", resp.StatusCode, string(bodyBytes))
}
return io.ReadAll(resp.Body)
}
func apiGetJSON(path string) (map[string]interface{}, error) {
resp, err := http.Get(apiBaseURL + path)
if err != nil {
return nil, fmt.Errorf("failed to connect to API server: %w\nIs kvm_app running?", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error: HTTP %d - %s", resp.StatusCode, string(bodyBytes))
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func ShowHelp() {
fmt.Println(`PicoKVM - Remote KVM over IP
Usage:
kvm_app Start the main web service (default)
kvm_app cli <command> Run CLI commands for HID control
Commands:
cli HID control via API (mouse, keyboard, capture, status, signer)
Examples:
kvm_app cli mouse move 100 200
kvm_app cli mouse click left
kvm_app cli keyboard type "Hello World"
kvm_app cli capture
kvm_app cli status
kvm_app cli signer keygen
kvm_app cli signer sign --key ota_ed25519.key firmware.bin
kvm_app cli signer verify --pubkey ota_ed25519.pub firmware.bin
For more help on a specific command, use:
kvm_app cli --help`)
}
// === Mouse Commands ===
var mouseCmd = &cobra.Command{
Use: "mouse",
Short: "Mouse control commands",
}
var mouseMoveCmd = &cobra.Command{
Use: "move [x] [y]",
Short: "Move mouse to position",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
x, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("invalid x coordinate: %w", err)
}
y, err := strconv.Atoi(args[1])
if err != nil {
return fmt.Errorf("invalid y coordinate: %w", err)
}
relative, _ := cmd.Flags().GetBool("relative")
if relative {
return apiPost("/mouse/relative", map[string]interface{}{
"dx": x, "dy": y,
})
}
return apiPost("/mouse/absolute", map[string]interface{}{
"x": x, "y": y,
})
},
}
var mouseClickCmd = &cobra.Command{
Use: "click [button]",
Short: "Click a mouse button",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return apiPost("/mouse/click", map[string]interface{}{
"button": args[0],
})
},
}
var mouseScrollCmd = &cobra.Command{
Use: "scroll [delta]",
Short: "Scroll mouse wheel",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
delta, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("invalid delta: %w", err)
}
return apiPost("/mouse/scroll", map[string]interface{}{
"delta": delta,
})
},
}
var mouseDownCmd = &cobra.Command{
Use: "down [button]",
Short: "Press and hold a mouse button",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// API doesn't support hold yet, simulate with click
return apiPost("/mouse/click", map[string]interface{}{
"button": args[0],
})
},
}
var mouseUpCmd = &cobra.Command{
Use: "up",
Short: "Release all mouse buttons",
RunE: func(cmd *cobra.Command, args []string) error {
// API doesn't support hold yet, no-op
return nil
},
}
// === Keyboard Commands ===
var keyboardCmd = &cobra.Command{
Use: "keyboard",
Short: "Keyboard control commands",
}
var keyboardKeyCmd = &cobra.Command{
Use: "key [keyname]",
Short: "Press a key",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return apiPost("/keyboard/key", map[string]interface{}{
"key": args[0],
})
},
}
var keyboardComboCmd = &cobra.Command{
Use: "combo [keys...]",
Short: "Press a key combination",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return apiPost("/keyboard/combo", map[string]interface{}{
"keys": args,
})
},
}
var keyboardTypeCmd = &cobra.Command{
Use: "type [text]",
Short: "Type a text string",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return apiPost("/keyboard/type", map[string]interface{}{
"text": args[0],
})
},
}
// === Capture Command ===
var captureScreenshotPath = "/userdata/picokvm/screenshot/kvm_screenshot.jpg"
var captureCmd = &cobra.Command{
Use: "capture",
Short: "Capture a JPEG screenshot",
Long: "Capture screenshot using hardware JPEG encoder and save to fixed path",
RunE: func(cmd *cobra.Command, args []string) error {
data, err := apiGetBytes("/capture")
if err != nil {
return err
}
if err := os.WriteFile(captureScreenshotPath, data, 0644); err != nil {
return fmt.Errorf("failed to write: %w", err)
}
fmt.Printf("Screenshot saved to %s (%d bytes)\n", captureScreenshotPath, len(data))
return nil
},
}
// === Status Command ===
var statusCmd = &cobra.Command{
Use: "status",
Short: "Show device status",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := apiGetJSON("/video/state")
if err != nil {
return err
}
fmt.Printf("Video State:\n")
fmt.Printf(" Ready: %v\n", resp["ready"])
fmt.Printf(" Error: %s\n", resp["error"])
fmt.Printf(" Size: %dx%d\n", int(resp["width"].(float64)), int(resp["height"].(float64)))
fmt.Printf(" FPS: %.1f\n", resp["fps"])
return nil
},
}
// === Key Maps ===
var keyNameToCode = map[string]uint8{
"a": 0x04, "b": 0x05, "c": 0x06, "d": 0x07,
"e": 0x08, "f": 0x09, "g": 0x0A, "h": 0x0B,
"i": 0x0C, "j": 0x0D, "k": 0x0E, "l": 0x0F,
"m": 0x10, "n": 0x11, "o": 0x12, "p": 0x13,
"q": 0x14, "r": 0x15, "s": 0x16, "t": 0x17,
"u": 0x18, "v": 0x19, "w": 0x1A, "x": 0x1B,
"y": 0x1C, "z": 0x1D,
"1": 0x1E, "2": 0x1F, "3": 0x20, "4": 0x21,
"5": 0x22, "6": 0x23, "7": 0x24, "8": 0x25,
"9": 0x26, "0": 0x27,
"Enter": 0x28,
"Escape": 0x29,
"Backspace": 0x2A,
"Tab": 0x2B,
"Space": 0x2C,
"Minus": 0x2D,
"Equal": 0x2E,
"LeftBrace": 0x2F,
"RightBrace": 0x30,
"Backslash": 0x31,
"Semicolon": 0x33,
"Quote": 0x34,
"Grave": 0x35,
"Comma": 0x36,
"Dot": 0x37,
"Slash": 0x38,
"CapsLock": 0x39,
"F1": 0x3A, "F2": 0x3B, "F3": 0x3C, "F4": 0x3D,
"F5": 0x3E, "F6": 0x3F, "F7": 0x40, "F8": 0x41,
"F9": 0x42, "F10": 0x43, "F11": 0x44, "F12": 0x45,
"PrintScreen": 0x46,
"ScrollLock": 0x47,
"Pause": 0x48,
"Insert": 0x49,
"Home": 0x4A,
"PageUp": 0x4B,
"Delete": 0x4C,
"End": 0x4D,
"PageDown": 0x4E,
"Right": 0x4F,
"Left": 0x50,
"Down": 0x51,
"Up": 0x52,
}
func charToKeyCode(char uint8) (keyCode uint8, modifier uint8, ok bool) {
if char >= 'a' && char <= 'z' {
return keyNameToCode[string(char)], 0, true
}
if char >= 'A' && char <= 'Z' {
return keyNameToCode[strings.ToLower(string(char))], 0x02, true
}
if char >= '0' && char <= '9' {
return keyNameToCode[string(char)], 0, true
}
if char == ' ' {
return 0x2C, 0, true
}
switch char {
case '-':
return 0x2D, 0, true
case '=':
return 0x2E, 0, true
case '[':
return 0x2F, 0, true
case ']':
return 0x30, 0, true
case '\\':
return 0x31, 0, true
case ';':
return 0x33, 0, true
case '\'':
return 0x34, 0, true
case '`':
return 0x35, 0, true
case ',':
return 0x36, 0, true
case '.':
return 0x37, 0, true
case '/':
return 0x38, 0, true
}
return 0, 0, false
}
var signerCmd = &cobra.Command{
Use: "signer",
Short: "OTA firmware signing tool",
Long: "Generate keys, sign, and verify OTA firmware using Ed25519",
}
var signerKeygenCmd = &cobra.Command{
Use: "keygen",
Short: "Generate Ed25519 key pair",
RunE: func(cmd *cobra.Command, args []string) error {
outputDir, _ := cmd.Flags().GetString("output-dir")
pubKeyPath := filepath.Join(outputDir, "ota_ed25519.pub")
privKeyPath := filepath.Join(outputDir, "ota_ed25519.key")
if _, err := os.Stat(privKeyPath); err == nil {
return fmt.Errorf("private key already exists at %s, refusing to overwrite", privKeyPath)
}
publicKey, privateKey, err := ed25519.GenerateKey(nil)
if err != nil {
return fmt.Errorf("generating key pair: %w", err)
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("creating output directory: %w", err)
}
if err := os.WriteFile(privKeyPath, privateKey, 0600); err != nil {
return fmt.Errorf("writing private key: %w", err)
}
if err := os.WriteFile(pubKeyPath, publicKey, 0644); err != nil {
return fmt.Errorf("writing public key: %w", err)
}
fmt.Println(hex.EncodeToString(publicKey))
fmt.Fprintf(os.Stderr, "Private key: %s\n", privKeyPath)
fmt.Fprintf(os.Stderr, "Public key: %s\n", pubKeyPath)
return nil
},
}
var signerSignCmd = &cobra.Command{
Use: "sign --key <private-key> <firmware-file>",
Short: "Sign a firmware file",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
keyArg, _ := cmd.Flags().GetString("key")
filePath := args[0]
if keyArg == "" {
return fmt.Errorf("--key is required")
}
privateKey, err := os.ReadFile(keyArg)
if err != nil {
privateKey, err = hex.DecodeString(keyArg)
if err != nil {
return fmt.Errorf("invalid private key: not a valid file path or hex string")
}
}
if len(privateKey) != ed25519.PrivateKeySize {
return fmt.Errorf("invalid private key size: got %d bytes, expected %d", len(privateKey), ed25519.PrivateKeySize)
}
fileData, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("reading firmware file: %w", err)
}
fileHash := sha256.Sum256(fileData)
signature := ed25519.Sign(ed25519.PrivateKey(privateKey), fileHash[:])
sigPath := filePath + ".sig"
if err := os.WriteFile(sigPath, signature, 0644); err != nil {
return fmt.Errorf("writing signature: %w", err)
}
hash := sha256.Sum256(fileData)
fmt.Println(hex.EncodeToString(hash[:]))
fmt.Fprintf(os.Stderr, "Signature written to: %s\n", sigPath)
return nil
},
}
var signerVerifyCmd = &cobra.Command{
Use: "verify [--pubkey <pubkey-path-or-hex>] <firmware-file> [<sig-file>]",
Short: "Verify firmware signature",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
pubKeyArg, _ := cmd.Flags().GetString("pubkey")
filePath := args[0]
sigPath := ""
if len(args) > 1 {
sigPath = args[1]
}
if sigPath == "" {
sigPath = filePath + ".sig"
}
var publicKey ed25519.PublicKey
if pubKeyArg == "" {
keyStr := strings.TrimSpace(builtOtaPublicKey)
if keyStr == "" {
return fmt.Errorf("no --pubkey provided and no OTA public key embedded in binary")
}
keyBytes, err := hex.DecodeString(keyStr)
if err != nil {
return fmt.Errorf("decoding embedded public key hex: %w", err)
}
publicKey = ed25519.PublicKey(keyBytes)
} else if _, err := os.Stat(pubKeyArg); err == nil {
keyBytes, err := os.ReadFile(pubKeyArg)
if err != nil {
return fmt.Errorf("reading public key file: %w", err)
}
publicKey = ed25519.PublicKey(keyBytes)
} else {
keyBytes, err := hex.DecodeString(pubKeyArg)
if err != nil {
return fmt.Errorf("decoding public key hex: %w", err)
}
publicKey = ed25519.PublicKey(keyBytes)
}
if len(publicKey) != ed25519.PublicKeySize {
return fmt.Errorf("invalid public key size: got %d bytes, expected %d", len(publicKey), ed25519.PublicKeySize)
}
fileData, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("reading firmware file: %w", err)
}
sigBytes, err := os.ReadFile(sigPath)
if err != nil {
return fmt.Errorf("reading signature file: %w", err)
}
if len(sigBytes) != ed25519.SignatureSize {
return fmt.Errorf("invalid signature size: got %d bytes, expected %d", len(sigBytes), ed25519.SignatureSize)
}
fileHash := sha256.Sum256(fileData)
if !ed25519.Verify(publicKey, fileHash[:], sigBytes) {
return fmt.Errorf("VERIFICATION FAILED: signature is invalid")
}
fmt.Fprintf(os.Stderr, "VERIFICATION OK: signature is valid\n")
fmt.Fprintf(os.Stderr, "SHA256: %s\n", hex.EncodeToString(fileHash[:]))
return nil
},
}
func init() {
mouseMoveCmd.Flags().Bool("relative", false, "Use relative positioning")
mouseCmd.AddCommand(mouseMoveCmd)
mouseCmd.AddCommand(mouseClickCmd)
mouseCmd.AddCommand(mouseScrollCmd)
mouseCmd.AddCommand(mouseDownCmd)
mouseCmd.AddCommand(mouseUpCmd)
cliRootCmd.AddCommand(mouseCmd)
keyboardCmd.AddCommand(keyboardKeyCmd)
keyboardCmd.AddCommand(keyboardComboCmd)
keyboardCmd.AddCommand(keyboardTypeCmd)
cliRootCmd.AddCommand(keyboardCmd)
cliRootCmd.AddCommand(captureCmd)
signerKeygenCmd.Flags().String("output-dir", ".", "Directory for key output")
signerCmd.AddCommand(signerKeygenCmd)
signerSignCmd.Flags().String("key", "", "Path to private key")
signerCmd.AddCommand(signerSignCmd)
signerVerifyCmd.Flags().String("pubkey", "", "Path to public key file or hex-encoded public key")
signerCmd.AddCommand(signerVerifyCmd)
cliRootCmd.AddCommand(signerCmd)
cliRootCmd.AddCommand(statusCmd)
}

View File

@@ -1,9 +1,15 @@
package main
import (
"os"
"kvm"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == "cli" {
kvm.RunCLI(os.Args[2:])
return
}
kvm.Main()
}

182
config.go
View File

@@ -2,6 +2,9 @@ package kvm
import (
"bufio"
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
@@ -21,6 +24,12 @@ type WakeOnLanDevice struct {
MacAddress string `json:"macAddress"`
}
type TurnServer struct {
URL string `json:"url"`
Username string `json:"username"`
Credential string `json:"credential"`
}
// Constants for keyboard macro limits
const (
MaxMacrosPerDevice = 25
@@ -81,13 +90,16 @@ func (m *KeyboardMacro) Validate() error {
type Config struct {
STUN string `json:"stun"`
TurnServers []TurnServer `json:"turn_servers"`
JigglerEnabled bool `json:"jiggler_enabled"`
AutoUpdateEnabled bool `json:"auto_update_enabled"`
IncludePreRelease bool `json:"include_pre_release"`
UpdateDownloadProxy string `json:"update_download_proxy"`
HashedPassword string `json:"hashed_password"`
LocalAuthToken string `json:"local_auth_token"`
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
LocalLoopbackOnly bool `json:"local_loopback_only"`
UsbEnhancedDetection bool `json:"usb_enhanced_detection"`
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
KeyboardLayout string `json:"keyboard_layout"`
@@ -126,6 +138,41 @@ type Config struct {
WireguardAutoStart bool `json:"wireguard_autostart"`
WireguardConfig WireguardConfig `json:"wireguard_config"`
NpuAppEnabled bool `json:"npu_app_enabled"`
Firewall *FirewallConfig `json:"firewall"`
APIKey string `json:"api_key"`
}
type FirewallConfig struct {
Base FirewallBaseRule `json:"base"`
Rules []FirewallRule `json:"rules"`
PortForwards []FirewallPortRule `json:"portForwards"`
}
type FirewallBaseRule struct {
InputPolicy string `json:"inputPolicy"`
OutputPolicy string `json:"outputPolicy"`
ForwardPolicy string `json:"forwardPolicy"`
}
type FirewallRule struct {
Chain string `json:"chain"`
SourceIP string `json:"sourceIP"`
SourcePort *int `json:"sourcePort,omitempty"`
Protocols []string `json:"protocols"`
DestinationIP string `json:"destinationIP"`
DestinationPort *int `json:"destinationPort,omitempty"`
Action string `json:"action"`
Comment string `json:"comment"`
}
type FirewallPortRule struct {
Chain string `json:"chain,omitempty"`
Managed *bool `json:"managed,omitempty"`
SourcePort int `json:"sourcePort"`
Protocols []string `json:"protocols"`
DestinationIP string `json:"destinationIP"`
DestinationPort int `json:"destinationPort"`
Comment string `json:"comment"`
}
type VntConfig struct {
@@ -147,8 +194,13 @@ type WireguardConfig struct {
const configPath = "/userdata/kvm_config.json"
const sdConfigPath = "/mnt/sdcard/kvm_config.json"
// builtOtaPublicKey is the hex-encoded Ed25519 public key for OTA signature verification,
// injected via -ldflags at build time. Empty string disables signature verification.
var builtOtaPublicKey = ""
var defaultConfig = &Config{
STUN: "stun:stun.l.google.com:19302",
TurnServers: []TurnServer{},
AutoUpdateEnabled: false, // Set a default value
ActiveExtension: "",
KeyboardMacros: []KeyboardMacro{},
@@ -160,6 +212,7 @@ var defaultConfig = &Config{
DisplayOffAfterSec: 1800, // 30 minutes
TLSMode: "",
ForceHpd: false, // 默认不强制输出EDID
UsbEnhancedDetection: true,
UsbConfig: &usbgadget.Config{
VendorId: "0x1d6b", //The Linux Foundation
ProductId: "0x0104", //Multifunction Composite Gadget
@@ -191,6 +244,15 @@ var defaultConfig = &Config{
AutoMountSystemInfo: true,
WireguardAutoStart: false,
NpuAppEnabled: false,
Firewall: &FirewallConfig{
Base: FirewallBaseRule{
InputPolicy: "accept",
OutputPolicy: "accept",
ForwardPolicy: "accept",
},
Rules: []FirewallRule{},
PortForwards: []FirewallPortRule{},
},
}
var (
@@ -198,6 +260,75 @@ var (
configLock = &sync.Mutex{}
)
type ConfigMigrationFunc func(raw json.RawMessage) (json.RawMessage, error)
var configMigrations = []ConfigMigrationFunc{
migrateLocalAuthMode,
}
func migrateLocalAuthMode(raw json.RawMessage) (json.RawMessage, error) {
var rawMap map[string]json.RawMessage
if err := json.Unmarshal(raw, &rawMap); err != nil {
return nil, fmt.Errorf("migrateLocalAuthMode: failed to parse config JSON: %w", err)
}
if authModeRaw, exists := rawMap["localAuthMode"]; exists {
var authMode string
if err := json.Unmarshal(authModeRaw, &authMode); err == nil {
if authMode != "" {
validModes := map[string]bool{"password": true, "noPassword": true}
if !validModes[authMode] {
rawMap["localAuthMode"] = json.RawMessage(`"password"`)
}
}
} else {
delete(rawMap, "localAuthMode")
}
}
result, err := json.Marshal(rawMap)
if err != nil {
return nil, fmt.Errorf("migrateLocalAuthMode: failed to marshal migrated config: %w", err)
}
return result, nil
}
func runMigrations(raw json.RawMessage) (json.RawMessage, bool, error) {
current := raw
didMigrate := false
for i, migration := range configMigrations {
migrated, err := migration(current)
if err != nil {
return current, didMigrate, fmt.Errorf("migration %d failed: %w", i, err)
}
if string(migrated) != string(current) {
didMigrate = true
logger.Info().Int("migration", i).Msg("config migrated")
}
current = migrated
}
return current, didMigrate, nil
}
func writeRawConfig(path string, raw json.RawMessage) error {
file, err := os.Create(path)
if err != nil {
return fmt.Errorf("failed to create config file: %w", err)
}
defer file.Close()
var indented bytes.Buffer
if err := json.Indent(&indented, raw, "", " "); err != nil {
return fmt.Errorf("failed to indent config JSON: %w", err)
}
if _, err := indented.WriteTo(file); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return nil
}
func LoadConfig() {
configLock.Lock()
defer configLock.Unlock()
@@ -207,7 +338,6 @@ func LoadConfig() {
return
}
// load the default config
if defaultConfig.UsbConfig.SerialNumber == "" {
serialNumber, err := extractSerialNumber()
if err != nil {
@@ -219,24 +349,38 @@ func LoadConfig() {
loadedConfig := *defaultConfig
config = &loadedConfig
file, err := os.Open(configPath)
rawData, err := os.ReadFile(configPath)
if err != nil {
logger.Debug().Msg("default config file doesn't exist, using default")
logger.Debug().Msg("config file does not exist, using default")
return
}
defer file.Close()
// load and merge the default config with the user config
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
logger.Warn().Err(err).Msg("config file JSON parsing failed")
os.Remove(configPath)
if _, err := os.Stat(sdConfigPath); err == nil {
os.Remove(sdConfigPath)
migrated, didMigrate, err := runMigrations(json.RawMessage(rawData))
if err != nil {
logger.Warn().Err(err).Msg("config migration failed, preserving corrupt file")
corruptPath := configPath + ".corrupt"
_ = os.Rename(configPath, corruptPath)
logger.Info().Str("corrupt_path", corruptPath).Msg("corrupt config preserved for diagnosis")
return
}
if didMigrate {
if writeErr := writeRawConfig(configPath, migrated); writeErr != nil {
logger.Warn().Err(writeErr).Msg("failed to write migrated config, continuing with in-memory version")
} else {
logger.Info().Msg("migrated config saved to disk")
SyncConfigSD(false)
}
}
if err := json.Unmarshal(migrated, &loadedConfig); err != nil {
logger.Warn().Err(err).Msg("config file JSON parsing failed, preserving corrupt file")
corruptPath := configPath + ".corrupt"
_ = os.Rename(configPath, corruptPath)
logger.Info().Str("corrupt_path", corruptPath).Msg("corrupt config preserved for diagnosis")
return
}
// merge the user config with the default config
if loadedConfig.UsbConfig == nil {
loadedConfig.UsbConfig = defaultConfig.UsbConfig
}
@@ -249,6 +393,14 @@ func LoadConfig() {
loadedConfig.NetworkConfig = defaultConfig.NetworkConfig
}
if loadedConfig.Firewall == nil {
loadedConfig.Firewall = defaultConfig.Firewall
}
if loadedConfig.TurnServers == nil {
loadedConfig.TurnServers = []TurnServer{}
}
config = &loadedConfig
logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel)
@@ -347,6 +499,14 @@ func SaveConfig() error {
return nil
}
func generateAPIKey() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
func ensureConfigLoaded() {
if config == nil {
LoadConfig()

1470
firewall.go Normal file

File diff suppressed because it is too large Load Diff

25
go.mod
View File

@@ -1,14 +1,11 @@
module kvm
go 1.23.4
toolchain go1.24.3
go 1.25.5
require (
github.com/Masterminds/semver/v3 v3.3.1
github.com/beevik/ntp v1.4.3
github.com/coder/websocket v1.8.13
github.com/coreos/go-oidc/v3 v3.11.0
github.com/creack/pty v1.1.23
github.com/fsnotify/fsnotify v1.9.0
github.com/gin-contrib/logger v1.2.6
@@ -17,8 +14,10 @@ require (
github.com/guregu/null/v6 v6.0.0
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf
github.com/hanwen/go-fuse/v2 v2.8.0
github.com/mark3labs/mcp-go v0.52.0
github.com/pion/logging v0.2.3
github.com/pion/mdns/v2 v2.0.7
github.com/pion/rtp v1.8.18
github.com/pion/webrtc/v4 v4.0.16
github.com/pojntfx/go-nbd v0.3.2
github.com/prometheus/client_golang v1.22.0
@@ -27,6 +26,7 @@ require (
github.com/psanford/httpreadat v0.1.0
github.com/rs/zerolog v1.34.0
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.10.0
github.com/vishvananda/netlink v1.3.0
go.bug.st/serial v1.6.2
@@ -47,11 +47,12 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
@@ -63,35 +64,29 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pilebones/go-udev v0.9.0 // indirect
github.com/pion/datachannel v1.5.10 // indirect
github.com/pion/dtls/v2 v2.2.12 // indirect
github.com/pion/dtls/v3 v3.0.6 // indirect
github.com/pion/ice/v2 v2.3.36 // indirect
github.com/pion/ice/v4 v4.0.10 // indirect
github.com/pion/interceptor v0.1.40 // indirect
github.com/pion/mdns v0.0.12 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.15 // indirect
github.com/pion/rtp v1.8.18 // indirect
github.com/pion/sctp v1.8.39 // indirect
github.com/pion/sdp/v3 v3.0.13 // indirect
github.com/pion/srtp/v2 v2.0.20 // indirect
github.com/pion/srtp/v3 v3.0.5 // indirect
github.com/pion/stun v0.6.1 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v2 v2.2.10 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v2 v2.1.6 // indirect
github.com/pion/turn/v4 v4.0.2 // indirect
github.com/pion/webrtc/v3 v3.3.5 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/arch v0.17.0 // indirect
golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/text v0.26.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

109
go.sum
View File

@@ -18,9 +18,8 @@ github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJ
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
@@ -28,6 +27,10 @@ github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
@@ -38,8 +41,6 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -54,7 +55,8 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ=
@@ -63,6 +65,8 @@ github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf h1:JO6ISZIvEUitto
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
github.com/hanwen/go-fuse/v2 v2.8.0 h1:wV8rG7rmCz8XHSOwBZhG5YcVqcYjkzivjmbaMafPlAs=
github.com/hanwen/go-fuse/v2 v2.8.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@@ -71,7 +75,6 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -83,6 +86,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mark3labs/mcp-go v0.52.0 h1:uRSzupNSUyPGDpF4owY5X4zEpACPwBnlM3FAFuXN6gQ=
github.com/mark3labs/mcp-go v0.52.0/go.mod h1:Zg9cB2HdwdMMVgY0xtTzq3KvYIOJQDsaut+jWjwDaQY=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -105,59 +110,34 @@ github.com/pilebones/go-udev v0.9.0 h1:N1uEO/SxUwtIctc0WLU0t69JeBxIYEYnj8lT/Nabl
github.com/pilebones/go-udev v0.9.0/go.mod h1:T2eI2tUSK0hA2WS5QLjXJUfQkluZQu+18Cqvem3CaXI=
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk=
github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
github.com/pion/ice/v2 v2.3.36 h1:SopeXiVbbcooUg2EIR8sq4b13RQ8gzrkkldOVg+bBsc=
github.com/pion/ice/v2 v2.3.36/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ=
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8=
github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk=
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/rtp v1.8.18 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU=
github.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
github.com/pion/sdp/v3 v3.0.13 h1:uN3SS2b+QDZnWXgdr69SM8KB4EbcnPnPf2Laxhty/l4=
github.com/pion/sdp/v3 v3.0.13/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
github.com/pion/srtp/v2 v2.0.20 h1:HNNny4s+OUmG280ETrCdgFndp4ufx3/uy85EawYEhTk=
github.com/pion/srtp/v2 v2.0.20/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA=
github.com/pion/srtp/v3 v3.0.5 h1:8XLB6Dt3QXkMkRFpoqC3314BemkpMQK2mZeJc4pUKqo=
github.com/pion/srtp/v3 v3.0.5/go.mod h1:r1G7y5r1scZRLe2QJI/is+/O83W2d+JoEsuIexpw+uM=
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q=
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc=
github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps=
github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
github.com/pion/webrtc/v3 v3.3.5 h1:ZsSzaMz/i9nblPdiAkZoP+E6Kmjw+jnyq3bEmU3EtRg=
github.com/pion/webrtc/v3 v3.3.5/go.mod h1:liNa+E1iwyzyXqNUwvoMRNQ10x8h8FOeJKL8RkIbamE=
github.com/pion/webrtc/v4 v4.0.16 h1:5f8QMVIbNvJr2mPRGi2QamkPa/LVUB6NWolOCwphKHA=
github.com/pion/webrtc/v4 v4.0.16/go.mod h1:C3uTCPzVafUA0eUzru9f47OgNt3nEO7ZJ6zNY6VSJno=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -178,8 +158,17 @@ github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUz
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f h1:VgoRCP1efSCEZIcF2THLQ46+pIBzzgNiaUBe9wEDwYU=
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzro7BGorij2WgrjEammtrkbo3+xldxo+KaGLGUiD+Q=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -188,8 +177,6 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
@@ -200,81 +187,31 @@ github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQ
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8=
go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -33,24 +33,27 @@ type IPv6StaticConfig struct {
DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"`
}
type NetworkConfig struct {
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
HTTPProxy null.String `json:"http_proxy,omitempty"`
HTTPSProxy null.String `json:"https_proxy,omitempty"`
ALLProxy null.String `json:"all_proxy,omitempty"`
IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"`
IPv4RequestAddress null.String `json:"ipv4_request_address,omitempty"`
IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"`
IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"`
IPv4RequestAddress null.String `json:"ipv4_request_address,omitempty"`
IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"`
IPv6Mode null.String `json:"ipv6_mode,omitempty" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"`
IPv6Static *IPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"`
IPv6Mode null.String `json:"ipv6_mode,omitempty" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"`
IPv6Static *IPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"`
LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"`
LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"`
MDNSMode null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"`
TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"`
TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,ntp_fallback" default:"ntp,http"`
TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"`
PendingReboot null.Bool `json:"pending_reboot,omitempty" default:"false"`
LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"`
LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"`
MDNSMode null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"`
TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"`
TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,ntp_fallback" default:"ntp,http"`
TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"`
PendingReboot null.Bool `json:"pending_reboot,omitempty" default:"false"`
}
func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {

View File

@@ -176,6 +176,49 @@ func (s *NetworkInterfaceState) MACString() string {
return s.macAddr.String()
}
func (s *NetworkInterfaceState) SetMACAddress(macAddress string) (string, error) {
macAddress = strings.TrimSpace(macAddress)
if macAddress == "" {
return "", fmt.Errorf("mac address is empty")
}
hw, err := net.ParseMAC(macAddress)
if err != nil {
return "", fmt.Errorf("invalid mac address")
}
if len(hw) != 6 {
return "", fmt.Errorf("invalid mac address length")
}
normalized := strings.ToLower(hw.String())
s.stateLock.Lock()
iface, err := netlink.LinkByName(s.interfaceName)
if err != nil {
s.stateLock.Unlock()
return "", err
}
if err := netlink.LinkSetDown(iface); err != nil {
s.stateLock.Unlock()
return "", err
}
if err := netlink.LinkSetHardwareAddr(iface, hw); err != nil {
s.stateLock.Unlock()
return "", err
}
if err := netlink.LinkSetUp(iface); err != nil {
s.stateLock.Unlock()
return "", err
}
s.stateLock.Unlock()
if s.dhcpClient != nil && strings.TrimSpace(s.config.IPv4Mode.String) == "dhcp" {
_ = s.dhcpClient.Renew()
}
if _, err := s.update(); err != nil {
return normalized, err
}
return normalized, nil
}
func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
s.stateLock.Lock()
defer s.stateLock.Unlock()

View File

@@ -131,10 +131,10 @@ func (u *UsbGadget) GetKeyboardState() KeyboardState {
return u.keyboardState
}
func (u *UsbGadget) listenKeyboardEvents() {
func (u *UsbGadget) listenKeyboardEvents(ctx context.Context, file *os.File) {
var path string
if u.keyboardHidFile != nil {
path = u.keyboardHidFile.Name()
if file != nil {
path = file.Name()
}
l := u.log.With().Str("listener", "keyboardEvents").Str("path", path).Logger()
l.Trace().Msg("starting")
@@ -143,12 +143,12 @@ func (u *UsbGadget) listenKeyboardEvents() {
buf := make([]byte, hidReadBufferSize)
for {
select {
case <-u.keyboardStateCtx.Done():
case <-ctx.Done():
l.Info().Msg("context done")
return
default:
l.Trace().Msg("reading from keyboard")
if u.keyboardHidFile == nil {
if file == nil {
u.logWithSupression("keyboardHidFileNil", 100, &l, nil, "keyboardHidFile is nil")
// show the error every 100 times to avoid spamming the logs
time.Sleep(time.Second)
@@ -157,16 +157,26 @@ func (u *UsbGadget) listenKeyboardEvents() {
// reset the counter
u.resetLogSuppressionCounter("keyboardHidFileNil")
n, err := u.keyboardHidFile.Read(buf)
n, err := file.Read(buf)
if err != nil {
if ctx.Err() != nil {
l.Info().Msg("context canceled while reading keyboard HID file")
return
}
u.logWithSupression("keyboardHidFileRead", 100, &l, err, "failed to read")
continue
if reopenErr := u.reopenKeyboardHidFile(); reopenErr != nil {
u.logWithSupression("keyboardHidFileReopen", 100, &l, reopenErr, "failed to reopen keyboard HID file")
} else {
u.resetLogSuppressionCounter("keyboardHidFileReopen")
}
return
}
u.resetLogSuppressionCounter("keyboardHidFileRead")
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")
if n < 1 {
l.Info().Int("n", n).Msg("expected at least 1 byte, got 0")
continue
}
u.updateKeyboardState(buf[0])
@@ -175,13 +185,52 @@ func (u *UsbGadget) listenKeyboardEvents() {
}()
}
func (u *UsbGadget) openKeyboardHidFile() error {
func openWithTimeout(name string, flag int, perm os.FileMode, timeout time.Duration) (*os.File, error) {
type result struct {
file *os.File
err error
}
ch := make(chan result, 1)
go func() {
f, err := os.OpenFile(name, flag, perm)
ch <- result{f, err}
}()
select {
case r := <-ch:
return r.file, r.err
case <-time.After(timeout):
// Drain the channel in the background to close the leaked fd if the
// open eventually succeeds.
go func() {
if r := <-ch; r.file != nil {
r.file.Close()
}
}()
return nil, fmt.Errorf("open %s: timed out after %s", name, timeout)
}
}
func (u *UsbGadget) closeKeyboardHidFileLocked() {
if u.keyboardStateCancel != nil {
u.keyboardStateCancel()
u.keyboardStateCancel = nil
}
if u.keyboardHidFile != nil {
u.keyboardHidFile.Close()
u.keyboardHidFile = nil
}
}
func (u *UsbGadget) openKeyboardHidFileLocked(forceReopen bool) error {
if forceReopen {
u.closeKeyboardHidFileLocked()
} else if u.keyboardHidFile != nil {
return nil
}
var err error
u.keyboardHidFile, err = os.OpenFile("/dev/hidg0", os.O_RDWR, 0666)
file, err := openWithTimeout("/dev/hidg0", os.O_RDWR, 0666, 3*time.Second)
if err != nil {
if errors.Is(err, os.ErrNotExist) || strings.Contains(err.Error(), "no such file or directory") || strings.Contains(err.Error(), "no such device") {
u.log.Error().
@@ -196,33 +245,203 @@ func (u *UsbGadget) openKeyboardHidFile() error {
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()
ctx, cancel := context.WithCancel(context.Background())
u.keyboardHidFile = file
u.keyboardStateCtx = ctx
u.keyboardStateCancel = cancel
u.listenKeyboardEvents(ctx, file)
return nil
}
func (u *UsbGadget) openKeyboardHidFile() error {
u.keyboardLock.Lock()
defer u.keyboardLock.Unlock()
return u.openKeyboardHidFileLocked(false)
}
func (u *UsbGadget) reopenKeyboardHidFile() error {
u.keyboardLock.Lock()
defer u.keyboardLock.Unlock()
return u.openKeyboardHidFileLocked(true)
}
func (u *UsbGadget) OpenKeyboardHidFile() error {
return u.openKeyboardHidFile()
}
func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
if err := u.openKeyboardHidFile(); err != nil {
return err
func (u *UsbGadget) ReopenKeyboardHidFile() error {
return u.reopenKeyboardHidFile()
}
func (u *UsbGadget) keyboardWriteHidFileLocked(modifier byte, keys []byte) error {
if len(keys) > 6 {
keys = keys[:6]
}
if len(keys) < 6 {
keys = append(keys, make([]byte, 6-len(keys))...)
}
_, err := u.keyboardHidFile.Write(data)
data := []byte{modifier, 0, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]}
if u.keyboardHidFile == nil {
if err := u.openKeyboardHidFileLocked(false); err != nil {
return err
}
}
_, err := u.writeWithTimeout(u.keyboardHidFile, data)
if err != nil {
u.logWithSupression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0")
u.keyboardHidFile.Close()
u.keyboardHidFile = nil
u.closeKeyboardHidFileLocked()
return err
}
u.resetLogSuppressionCounter("keyboardWriteHidFile")
u.resetUserInputTime()
return nil
}
type autoReleaseTimer struct {
timer *time.Timer
key byte
active bool
}
type KeysDownState struct {
Modifier byte
Keys [6]byte
}
func (u *UsbGadget) scheduleAutoRelease(key byte) {
// Cancel existing timer for this key
for i := range u.autoReleaseTimers {
if u.autoReleaseTimers[i].key == key && u.autoReleaseTimers[i].active {
u.autoReleaseTimers[i].timer.Stop()
u.autoReleaseTimers[i].active = false
}
}
// Schedule new timer
timer := time.AfterFunc(100*time.Millisecond, func() {
u.autoReleaseKey(key)
})
u.autoReleaseTimers = append(u.autoReleaseTimers, autoReleaseTimer{
timer: timer,
key: key,
active: true,
})
}
func (u *UsbGadget) autoReleaseKey(key byte) {
u.keyboardLock.Lock()
defer u.keyboardLock.Unlock()
// Remove key from buffer
found := false
for i := 0; i < len(u.keysDownState.Keys); i++ {
if u.keysDownState.Keys[i] == key {
found = true
}
if found && i < len(u.keysDownState.Keys)-1 {
u.keysDownState.Keys[i] = u.keysDownState.Keys[i+1]
}
}
if found {
u.keysDownState.Keys[len(u.keysDownState.Keys)-1] = 0
u.keyboardWriteHidFileLocked(u.keysDownState.Modifier, u.keysDownState.Keys[:])
}
// Mark timer as inactive
for i := range u.autoReleaseTimers {
if u.autoReleaseTimers[i].key == key && u.autoReleaseTimers[i].active {
u.autoReleaseTimers[i].active = false
}
}
}
func (u *UsbGadget) cancelAutoRelease(key byte) {
for i := range u.autoReleaseTimers {
if u.autoReleaseTimers[i].key == key && u.autoReleaseTimers[i].active {
u.autoReleaseTimers[i].timer.Stop()
u.autoReleaseTimers[i].active = false
}
}
}
func (u *UsbGadget) resetAllAutoReleaseTimers() {
for i := range u.autoReleaseTimers {
if u.autoReleaseTimers[i].active {
u.autoReleaseTimers[i].timer.Stop()
u.autoReleaseTimers[i].active = false
}
}
}
func (u *UsbGadget) KeypressReport(key byte, press bool) error {
u.keyboardLock.Lock()
defer u.keyboardLock.Unlock()
if press {
// Check if key already in buffer
for _, k := range u.keysDownState.Keys {
if k == key {
return nil // Already pressed
}
}
// Find empty slot
emptySlot := -1
for i, k := range u.keysDownState.Keys {
if k == 0 {
emptySlot = i
break
}
}
if emptySlot == -1 {
// Buffer full - ErrorRollOver
u.keysDownState.Keys = [6]byte{0x01, 0x01, 0x01, 0x01, 0x01, 0x01}
} else {
u.keysDownState.Keys[emptySlot] = key
}
u.scheduleAutoRelease(key)
} else {
// Remove key from buffer
found := false
for i := 0; i < len(u.keysDownState.Keys); i++ {
if u.keysDownState.Keys[i] == key {
found = true
}
if found && i < len(u.keysDownState.Keys)-1 {
u.keysDownState.Keys[i] = u.keysDownState.Keys[i+1]
}
}
if found {
u.keysDownState.Keys[len(u.keysDownState.Keys)-1] = 0
}
u.cancelAutoRelease(key)
}
return u.keyboardWriteHidFileLocked(u.keysDownState.Modifier, u.keysDownState.Keys[:])
}
func (u *UsbGadget) KeypressKeepAlive() error {
u.keyboardLock.Lock()
defer u.keyboardLock.Unlock()
// Reset auto-release timers for all currently held keys
for _, key := range u.keysDownState.Keys {
if key != 0 {
u.scheduleAutoRelease(key)
}
}
return nil
}
@@ -230,18 +449,8 @@ func (u *UsbGadget) KeyboardReport(modifier uint8, keys []uint8) error {
u.keyboardLock.Lock()
defer u.keyboardLock.Unlock()
if len(keys) > 6 {
keys = keys[:6]
}
if len(keys) < 6 {
keys = append(keys, make([]uint8, 6-len(keys))...)
}
u.keysDownState.Modifier = modifier
copy(u.keysDownState.Keys[:], keys)
err := u.keyboardWriteHidFile([]byte{modifier, 0, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]})
if err != nil {
return err
}
u.resetUserInputTime()
return nil
return u.keyboardWriteHidFileLocked(modifier, keys)
}

View File

@@ -51,14 +51,15 @@ func (u *UsbGadget) RebindUsb(ignoreUnbindError bool) error {
}
// GetUsbState returns the current state of the USB gadget
func (u *UsbGadget) GetUsbState() (state string) {
// Check the auxiliary disc node first
discFile := "/sys/devices/platform/ff3e0000.usb2-phy/disc"
discBytes, err := os.ReadFile(discFile)
if err == nil {
discState := strings.TrimSpace(string(discBytes))
if discState == "DISCONNECTED" {
return "not attached"
func (u *UsbGadget) GetUsbState(enhancedDetection bool) (state string) {
if enhancedDetection {
discFile := "/sys/devices/platform/ff3e0000.usb2-phy/disc"
discBytes, err := os.ReadFile(discFile)
if err == nil {
discState := strings.TrimSpace(string(discBytes))
if discState == "DISCONNECTED" {
return "not attached"
}
}
}

View File

@@ -82,6 +82,9 @@ type UsbGadget struct {
onKeyboardStateChange *func(state KeyboardState)
onHidDeviceMissing *func(device string, err error)
keysDownState KeysDownState
autoReleaseTimers []autoReleaseTimer
log *zerolog.Logger
logSuppressionCounter map[string]int

View File

@@ -2,10 +2,13 @@ package usbgadget
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/rs/zerolog"
)
@@ -107,3 +110,36 @@ func (u *UsbGadget) resetLogSuppressionCounter(counterName string) {
u.logSuppressionCounter[counterName] = 0
}
}
const hidWriteTimeout = 50 * time.Millisecond
func (u *UsbGadget) writeWithTimeout(file *os.File, data []byte) (n int, err error) {
if err := file.SetWriteDeadline(time.Now().Add(hidWriteTimeout)); err != nil {
return -1, err
}
n, err = file.Write(data)
if err == nil {
return
}
u.log.Trace().
Str("file", file.Name()).
Bytes("data", data).
Err(err).
Msg("write failed")
if errors.Is(err, os.ErrDeadlineExceeded) {
u.logWithSupression(
fmt.Sprintf("writeWithTimeout_%s", file.Name()),
1000,
u.log,
err,
"write timed out: %s",
file.Name(),
)
err = nil
}
return
}

9
io.go
View File

@@ -105,6 +105,15 @@ func setLedMode(ledConfigPath string, mode string) error {
if err != nil {
return fmt.Errorf("failed to set LED trigger: %v", err)
}
case "disabled":
err := os.WriteFile(ledConfigPath+"/trigger", []byte("none"), 0644)
if err != nil {
return fmt.Errorf("failed to set LED trigger: %v", err)
}
err = os.WriteFile(ledConfigPath+"/brightness", []byte("0"), 0644)
if err != nil {
return fmt.Errorf("failed to set LED brightness: %v", err)
}
default:
return fmt.Errorf("invalid LED mode: %s", mode)
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"os/exec"
"reflect"
@@ -78,15 +79,12 @@ func writeJSONRPCEvent(event string, params interface{}, session *Session) {
}
requestString := string(requestBytes)
scopedLogger := jsonRpcLogger.With().
Str("data", requestString).
Logger()
scopedLogger.Info().Msg("sending JSONRPC event")
jsonRpcLogger.Trace().Str("event", event).Msg("sending JSONRPC event")
err = session.RPCChannel.SendText(requestString)
if err != nil {
scopedLogger.Warn().Err(err).Msg("error sending JSONRPC event")
jsonRpcLogger.Warn().Err(err).Str("event", event).Msg("error sending JSONRPC event")
return
}
}
@@ -163,6 +161,16 @@ func rpcPing() (string, error) {
return "pong", nil
}
type BootStorageTypeResponse struct {
Type string `json:"type"`
}
func rpcGetBootStorageType() (*BootStorageTypeResponse, error) {
return &BootStorageTypeResponse{
Type: string(GetBootStorageType()),
}, nil
}
func rpcGetDeviceID() (string, error) {
return GetDeviceID(), nil
}
@@ -225,6 +233,124 @@ func rpcSetStreamEncodecType(encodecType string) error {
return nil
}
type RcQpParams struct {
S32FirstFrameStartQp int `json:"s32FirstFrameStartQp"`
U32StepQp int `json:"u32StepQp"`
U32MinQp int `json:"u32MinQp"`
U32MaxQp int `json:"u32MaxQp"`
U32MinIQp int `json:"u32MinIQp"`
U32MaxIQp int `json:"u32MaxIQp"`
S32DeltIpQp int `json:"s32DeltIpQp"`
S32MaxReEncodeTimes int `json:"s32MaxReEncodeTimes"`
U32FrmMaxQp int `json:"u32FrmMaxQp"`
U32FrmMinQp int `json:"u32FrmMinQp"`
U32FrmMinIQp int `json:"u32FrmMinIQp"`
U32FrmMaxIQp int `json:"u32FrmMaxIQp"`
U32MotionStaticSwitchFrmQp int `json:"u32MotionStaticSwitchFrmQp"`
}
type VideoRcConfigParams struct {
H264 RcQpParams `json:"h264"`
H265 RcQpParams `json:"h265"`
}
func rpcSetVideoRc(params VideoRcConfigParams) error {
logger.Info().Interface("params", params).Msg("Setting video RC params")
rcParams := map[string]interface{}{
"h264": map[string]interface{}{
"s32FirstFrameStartQp": params.H264.S32FirstFrameStartQp,
"u32StepQp": params.H264.U32StepQp,
"u32MinQp": params.H264.U32MinQp,
"u32MaxQp": params.H264.U32MaxQp,
"u32MinIQp": params.H264.U32MinIQp,
"u32MaxIQp": params.H264.U32MaxIQp,
"s32DeltIpQp": params.H264.S32DeltIpQp,
"s32MaxReEncodeTimes": params.H264.S32MaxReEncodeTimes,
"u32FrmMaxQp": params.H264.U32FrmMaxQp,
"u32FrmMinQp": params.H264.U32FrmMinQp,
"u32FrmMinIQp": params.H264.U32FrmMinIQp,
"u32FrmMaxIQp": params.H264.U32FrmMaxIQp,
"u32MotionStaticSwitchFrmQp": params.H264.U32MotionStaticSwitchFrmQp,
},
"h265": map[string]interface{}{
"s32FirstFrameStartQp": params.H265.S32FirstFrameStartQp,
"u32StepQp": params.H265.U32StepQp,
"u32MinQp": params.H265.U32MinQp,
"u32MaxQp": params.H265.U32MaxQp,
"u32MinIQp": params.H265.U32MinIQp,
"u32MaxIQp": params.H265.U32MaxIQp,
"s32DeltIpQp": params.H265.S32DeltIpQp,
"s32MaxReEncodeTimes": params.H265.S32MaxReEncodeTimes,
"u32FrmMaxQp": params.H265.U32FrmMaxQp,
"u32FrmMinQp": params.H265.U32FrmMinQp,
"u32FrmMinIQp": params.H265.U32FrmMinIQp,
"u32FrmMaxIQp": params.H265.U32FrmMaxIQp,
"u32MotionStaticSwitchFrmQp": params.H265.U32MotionStaticSwitchFrmQp,
},
}
var _, err = CallCtrlAction("set_video_rc", rcParams)
return err
}
func rpcGetVideoRc() (VideoRcConfigParams, error) {
resp, err := CallCtrlAction("get_video_rc", nil)
if err != nil {
return VideoRcConfigParams{}, err
}
result := resp.Result
if result == nil {
return VideoRcConfigParams{}, errors.New("invalid response format")
}
h264Map, _ := result["h264"].(map[string]interface{})
h265Map, _ := result["h265"].(map[string]interface{})
getInt := func(m map[string]interface{}, k string) int {
if v, ok := m[k].(float64); ok {
return int(v)
}
return 0
}
getUint := func(m map[string]interface{}, k string) int {
return getInt(m, k)
}
rc := VideoRcConfigParams{
H264: RcQpParams{
S32FirstFrameStartQp: getInt(h264Map, "s32FirstFrameStartQp"),
U32StepQp: getUint(h264Map, "u32StepQp"),
U32MinQp: getUint(h264Map, "u32MinQp"),
U32MaxQp: getUint(h264Map, "u32MaxQp"),
U32MinIQp: getUint(h264Map, "u32MinIQp"),
U32MaxIQp: getUint(h264Map, "u32MaxIQp"),
S32DeltIpQp: getInt(h264Map, "s32DeltIpQp"),
S32MaxReEncodeTimes: getInt(h264Map, "s32MaxReEncodeTimes"),
U32FrmMaxQp: getUint(h264Map, "u32FrmMaxQp"),
U32FrmMinQp: getUint(h264Map, "u32FrmMinQp"),
U32FrmMinIQp: getUint(h264Map, "u32FrmMinIQp"),
U32FrmMaxIQp: getUint(h264Map, "u32FrmMaxIQp"),
U32MotionStaticSwitchFrmQp: getUint(h264Map, "u32MotionStaticSwitchFrmQp"),
},
H265: RcQpParams{
S32FirstFrameStartQp: getInt(h265Map, "s32FirstFrameStartQp"),
U32StepQp: getUint(h265Map, "u32StepQp"),
U32MinQp: getUint(h265Map, "u32MinQp"),
U32MaxQp: getUint(h265Map, "u32MaxQp"),
U32MinIQp: getUint(h265Map, "u32MinIQp"),
U32MaxIQp: getUint(h265Map, "u32MaxIQp"),
S32DeltIpQp: getInt(h265Map, "s32DeltIpQp"),
S32MaxReEncodeTimes: getInt(h265Map, "s32MaxReEncodeTimes"),
U32FrmMaxQp: getUint(h265Map, "u32FrmMaxQp"),
U32FrmMinQp: getUint(h265Map, "u32FrmMinQp"),
U32FrmMinIQp: getUint(h265Map, "u32FrmMinIQp"),
U32FrmMaxIQp: getUint(h265Map, "u32FrmMaxIQp"),
U32MotionStaticSwitchFrmQp: getUint(h265Map, "u32MotionStaticSwitchFrmQp"),
},
}
return rc, nil
}
func rpcSetNpuAppStatus(enable bool) error {
logger.Info().Bool("enable", enable).Msg("Setting NPU app status")
var _, err = CallCtrlAction("set_yolo_enable", map[string]interface{}{"enable": enable})
@@ -368,6 +494,36 @@ func rpcGetUpdateStatus() (*UpdateStatus, error) {
return updateStatus, nil
}
type SelfSignatureStatus struct {
AppSignatureAbsent bool `json:"appSignatureAbsent,omitempty"`
AppSignatureInvalid bool `json:"appSignatureInvalid,omitempty"`
AppNoPublicKey bool `json:"appNoPublicKey,omitempty"`
}
func rpcGetSelfSignatureStatus() (*SelfSignatureStatus, error) {
return getSelfSignatureStatus(), nil
}
func getSelfSignatureStatus() *SelfSignatureStatus {
status := &SelfSignatureStatus{}
publicKey := getOTAPublicKey()
appBinPath := "/userdata/picokvm/bin/kvm_app"
appSigPath := appBinPath + ".sig"
status.AppSignatureAbsent = isSigFileAbsent(appSigPath)
if !status.AppSignatureAbsent {
if publicKey == nil {
status.AppNoPublicKey = true
} else {
status.AppSignatureInvalid = !verifyLocalFileSignature(appBinPath, appSigPath, publicKey)
}
}
return status
}
func rpcTryUpdate() error {
includePreRelease := config.IncludePreRelease
go func() {
@@ -379,6 +535,14 @@ func rpcTryUpdate() error {
return nil
}
func rpcUpdateSignatures() (*SignatureUpdateResult, error) {
result, err := UpdateSignatures(context.Background())
if err != nil {
logger.Warn().Err(err).Msg("failed to update signatures")
}
return result, err
}
func rpcGetCustomUpdateBaseURL() (string, error) {
return customUpdateBaseURL, nil
}
@@ -388,6 +552,32 @@ func rpcSetCustomUpdateBaseURL(baseURL string) error {
return nil
}
func rpcGetUpdateDownloadProxy() (string, error) {
return config.UpdateDownloadProxy, nil
}
func rpcSetUpdateDownloadProxy(proxy string) error {
proxy = strings.TrimSpace(proxy)
if proxy != "" {
parsed, err := url.Parse(proxy)
if err != nil || strings.TrimSpace(parsed.Scheme) == "" || strings.TrimSpace(parsed.Host) == "" {
return fmt.Errorf("invalid update download proxy")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("update download proxy must use http or https")
}
if !strings.HasSuffix(proxy, "/") {
proxy += "/"
}
}
config.UpdateDownloadProxy = proxy
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func rpcSetDisplayRotation(params DisplayRotationSettings) error {
var err error
_, err = lvDispSetRotation(params.Rotation)
@@ -723,6 +913,29 @@ func rpcSetUsbEmulationState(enabled bool) error {
}
}
func rpcGetUsbEnhancedDetection() (bool, error) {
ensureConfigLoaded()
return config.UsbEnhancedDetection, nil
}
func rpcSetUsbEnhancedDetection(enabled bool) error {
ensureConfigLoaded()
if config.UsbEnhancedDetection == enabled {
return nil
}
config.UsbEnhancedDetection = enabled
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
if gadget != nil {
checkUSBState()
}
return nil
}
func rpcGetUsbConfig() (usbgadget.Config, error) {
LoadConfig()
return *config.UsbConfig, nil
@@ -762,6 +975,90 @@ func rpcResetConfig() error {
return nil
}
func rpcGetConfigRaw() (string, error) {
configLock.Lock()
defer configLock.Unlock()
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return "", fmt.Errorf("failed to marshal config: %w", err)
}
return string(data), nil
}
func rpcSetConfigRaw(configStr string) error {
var newConfig Config
if err := json.Unmarshal([]byte(configStr), &newConfig); err != nil {
return fmt.Errorf("failed to unmarshal config: %w", err)
}
configLock.Lock()
config = &newConfig
configLock.Unlock()
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
logger.Info().Msg("Configuration updated via raw JSON")
return nil
}
type RtcServersConfig struct {
STUN string `json:"stun"`
DefaultSTUN string `json:"defaultStun"`
TurnServers []TurnServer `json:"turnServers"`
}
func rpcGetRtcServersConfig() (RtcServersConfig, error) {
return RtcServersConfig{
STUN: config.STUN,
DefaultSTUN: DefaultSTUN,
TurnServers: config.TurnServers,
}, nil
}
func rpcSetStunServer(stun string) error {
config.STUN = stun
return SaveConfig()
}
type SetTurnServersParams struct {
Servers []TurnServer `json:"servers"`
}
func rpcSetTurnServers(params SetTurnServersParams) error {
config.TurnServers = params.Servers
if config.TurnServers == nil {
config.TurnServers = []TurnServer{}
}
return SaveConfig()
}
type IceServerJSON struct {
URLs []string `json:"urls"`
Username string `json:"username,omitempty"`
Credential string `json:"credential,omitempty"`
}
func rpcGetIceServers() ([]IceServerJSON, error) {
raw := buildICEServers()
out := make([]IceServerJSON, 0, len(raw))
for _, server := range raw {
credential := ""
if server.Credential != nil {
credential = fmt.Sprintf("%v", server.Credential)
}
out = append(out, IceServerJSON{
URLs: server.URLs,
Username: server.Username,
Credential: credential,
})
}
return out, nil
}
func rpcGetActiveExtension() (string, error) {
return config.ActiveExtension, nil
}
@@ -1036,6 +1333,30 @@ func rpcSetLocalLoopbackOnly(enabled bool) error {
return nil
}
func rpcGetApiKey() (string, error) {
return config.APIKey, nil
}
func rpcSetApiKey(apiKey string) error {
config.APIKey = apiKey
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func rpcGenerateApiKey() (string, error) {
key, err := generateAPIKey()
if err != nil {
return "", fmt.Errorf("failed to generate API key: %w", err)
}
config.APIKey = key
if err := SaveConfig(); err != nil {
return "", fmt.Errorf("failed to save config: %w", err)
}
return key, nil
}
type IOSettings struct {
IO0Status bool `json:"io0Status"`
IO1Status bool `json:"io1Status"`
@@ -1194,10 +1515,117 @@ func rpcSetAutoMountSystemInfo(enabled bool) error {
return nil
}
func rpcGetFirewallConfig() (FirewallConfig, error) {
LoadConfig()
if systemCfg, err := ReadFirewallConfigFromSystem(); err == nil && systemCfg != nil {
return *systemCfg, nil
}
if config.Firewall == nil {
return *defaultConfig.Firewall, nil
}
return *config.Firewall, nil
}
func rpcSetFirewallConfig(firewallCfg FirewallConfig) error {
LoadConfig()
managedCfg := firewallCfg
managedCfg.PortForwards = filterManagedPortForwards(firewallCfg.PortForwards)
if err := ApplyFirewallConfig(&managedCfg); err != nil {
return err
}
config.Firewall = &managedCfg
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func filterManagedPortForwards(in []FirewallPortRule) []FirewallPortRule {
out := make([]FirewallPortRule, 0, len(in))
for _, r := range in {
if r.Managed != nil && !*r.Managed {
continue
}
out = append(out, r)
}
return out
}
func rpcConfirmOtherSession() (bool, error) {
return true, nil
}
const jpegScreenshotPath = "/userdata/picokvm/screenshot/kvm_screenshot.jpg"
// StartJpegCapture starts continuous JPEG capture mode.
func StartJpegCapture() error {
_, err := CallCtrlAction("jpeg_capture_start", nil)
if err != nil {
return fmt.Errorf("failed to start JPEG capture: %w", err)
}
return nil
}
// StopJpegCapture stops continuous JPEG capture mode.
func StopJpegCapture() error {
_, err := CallCtrlAction("jpeg_capture_stop", nil)
if err != nil {
return fmt.Errorf("failed to stop JPEG capture: %w", err)
}
return nil
}
// captureScreenshot captures a JPEG screenshot using hardware encoder.
func captureScreenshot(format string) ([]byte, error) {
if format != "jpeg" && format != "jpg" {
return nil, fmt.Errorf("only JPEG format is supported")
}
logger.Info().Msg("triggering JPEG snapshot via jpeg_take_snapshot")
if err := os.MkdirAll("/userdata/picokvm/screenshot", 0o755); err != nil {
logger.Warn().Err(err).Msg("failed to create screenshot directory")
}
os.Remove(jpegScreenshotPath)
// drain any stale signal before triggering
select {
case <-jpegReadyCh:
default:
}
_, err := CallCtrlAction("jpeg_take_snapshot", nil)
if err != nil {
logger.Error().Err(err).Msg("jpeg_take_snapshot failed")
return nil, fmt.Errorf("failed to trigger JPEG capture: %w", err)
}
// wait for jpeg_ready event from native, fall back to polling on timeout
timeout := time.NewTimer(2 * time.Second)
defer timeout.Stop()
select {
case <-jpegReadyCh:
case <-timeout.C:
logger.Warn().Msg("jpeg_ready event not received within 2s, falling back to polling")
for i := 0; i < 5; i++ {
if data, err := os.ReadFile(jpegScreenshotPath); err == nil && len(data) > 0 {
logger.Info().Int("size", len(data)).Msg("JPEG captured (fallback polling)")
return data, nil
}
time.Sleep(200 * time.Millisecond)
}
return nil, fmt.Errorf("JPEG file not found at %s", jpegScreenshotPath)
}
data, err := os.ReadFile(jpegScreenshotPath)
if err != nil || len(data) == 0 {
return nil, fmt.Errorf("JPEG file not readable after jpeg_ready event: %w", err)
}
logger.Info().Int("size", len(data)).Msg("JPEG captured successfully")
return data, nil
}
var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing},
"reboot": {Func: rpcReboot, Params: []string{"force"}},
@@ -1205,6 +1633,7 @@ var rpcHandlers = map[string]RPCHandler{
"getNetworkState": {Func: rpcGetNetworkState},
"getNetworkSettings": {Func: rpcGetNetworkSettings},
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
"setEthernetMacAddress": {Func: rpcSetEthernetMacAddress, Params: []string{"macAddress"}},
"renewDHCPLease": {Func: rpcRenewDHCPLease},
"requestDHCPAddress": {Func: rpcRequestDHCPAddress, Params: []string{"ip"}},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
@@ -1234,12 +1663,19 @@ var rpcHandlers = map[string]RPCHandler{
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
"getLocalUpdateStatus": {Func: rpcGetLocalUpdateStatus},
"getUpdateStatus": {Func: rpcGetUpdateStatus},
"getSelfSignatureStatus": {Func: rpcGetSelfSignatureStatus},
"tryUpdate": {Func: rpcTryUpdate},
"updateSignatures": {Func: rpcUpdateSignatures},
"getCustomUpdateBaseURL": {Func: rpcGetCustomUpdateBaseURL},
"setCustomUpdateBaseURL": {Func: rpcSetCustomUpdateBaseURL, Params: []string{"baseURL"}},
"getUpdateDownloadProxy": {Func: rpcGetUpdateDownloadProxy},
"setUpdateDownloadProxy": {Func: rpcSetUpdateDownloadProxy, Params: []string{"proxy"}},
"getDevModeState": {Func: rpcGetDevModeState},
"getSSHKeyState": {Func: rpcGetSSHKeyState},
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
"getApiKey": {Func: rpcGetApiKey},
"setApiKey": {Func: rpcSetApiKey, Params: []string{"apiKey"}},
"generateApiKey": {Func: rpcGenerateApiKey},
"getTLSState": {Func: rpcGetTLSState},
"setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}},
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
@@ -1247,6 +1683,8 @@ var rpcHandlers = map[string]RPCHandler{
"isUpdatePending": {Func: rpcIsUpdatePending},
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
"getUsbEnhancedDetection": {Func: rpcGetUsbEnhancedDetection},
"setUsbEnhancedDetection": {Func: rpcSetUsbEnhancedDetection, Params: []string{"enabled"}},
"getUsbConfig": {Func: rpcGetUsbConfig},
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
@@ -1256,6 +1694,7 @@ var rpcHandlers = map[string]RPCHandler{
"resetSDStorage": {Func: rpcResetSDStorage},
"mountSDStorage": {Func: rpcMountSDStorage},
"unmountSDStorage": {Func: rpcUnmountSDStorage},
"formatSDStorage": {Func: rpcFormatSDStorage, Params: []string{"confirm", "fsType"}},
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
@@ -1272,6 +1711,12 @@ var rpcHandlers = map[string]RPCHandler{
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
"resetConfig": {Func: rpcResetConfig},
"getConfigRaw": {Func: rpcGetConfigRaw},
"setConfigRaw": {Func: rpcSetConfigRaw, Params: []string{"configStr"}},
"getRtcServersConfig": {Func: rpcGetRtcServersConfig},
"setStunServer": {Func: rpcSetStunServer, Params: []string{"stun"}},
"setTurnServers": {Func: rpcSetTurnServers, Params: []string{"params"}},
"getIceServers": {Func: rpcGetIceServers},
"setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}},
"getDisplayRotation": {Func: rpcGetDisplayRotation},
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
@@ -1335,8 +1780,18 @@ var rpcHandlers = map[string]RPCHandler{
"stopCloudflared": {Func: rpcStopCloudflared},
"getCloudflaredStatus": {Func: rpcGetCloudflaredStatus},
"getCloudflaredLog": {Func: rpcGetCloudflaredLog},
"getVpnToolSystemInfo": {Func: rpcGetVpnToolSystemInfo},
"getVpnToolStatus": {Func: rpcGetVpnToolStatus, Params: []string{"tool"}},
"listVpnToolReleases": {Func: rpcListVpnToolReleases, Params: []string{"tool"}},
"installVpnTool": {Func: rpcInstallVpnTool, Params: []string{"tool", "version", "assetName", "downloadURL"}},
"startVpnToolInstall": {Func: rpcStartVpnToolInstall, Params: []string{"tool", "version", "assetName", "downloadURL"}},
"getVpnToolInstallTask": {Func: rpcGetVpnToolInstallTask, Params: []string{"tool"}},
"useVpnToolVersion": {Func: rpcUseVpnToolVersion, Params: []string{"tool", "version"}},
"uninstallVpnToolVersion": {Func: rpcUninstallVpnToolVersion, Params: []string{"tool", "version"}},
"getStreamEncodecType": {Func: rpcGetStreamEncodecType},
"setStreamEncodecType": {Func: rpcSetStreamEncodecType, Params: []string{"encodecType"}},
"setVideoRc": {Func: rpcSetVideoRc, Params: []string{"params"}},
"getVideoRc": {Func: rpcGetVideoRc},
"setNpuAppStatus": {Func: rpcSetNpuAppStatus, Params: []string{"enable"}},
"getNpuAppStatus": {Func: rpcGetNpuAppStatus},
"startWireguard": {Func: rpcStartWireguard, Params: []string{"configFile"}},
@@ -1345,4 +1800,7 @@ var rpcHandlers = map[string]RPCHandler{
"getWireguardConfig": {Func: rpcGetWireguardConfig},
"getWireguardLog": {Func: rpcGetWireguardLog},
"getWireguardInfo": {Func: rpcGetWireguardInfo},
"getFirewallConfig": {Func: rpcGetFirewallConfig},
"setFirewallConfig": {Func: rpcSetFirewallConfig, Params: []string{"config"}},
"getBootStorageType": {Func: rpcGetBootStorageType},
}

245
keys.go Normal file
View File

@@ -0,0 +1,245 @@
package kvm
import (
"context"
"encoding/binary"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"golang.org/x/sys/unix"
)
const (
evKey = 0x01
)
type keyHoldResetDetector struct {
mu sync.Mutex
pressAt map[uint16]time.Time
threshold time.Duration
now func() time.Time
afterFunc func(d time.Duration, f func()) func() bool
stop map[uint16]func() bool
triggered bool
onTrigger func(code uint16, hold time.Duration)
}
func newKeyHoldResetDetector(threshold time.Duration, now func() time.Time, afterFunc func(d time.Duration, f func()) func() bool, onTrigger func(code uint16, hold time.Duration)) *keyHoldResetDetector {
if now == nil {
now = time.Now
}
if afterFunc == nil {
afterFunc = func(d time.Duration, f func()) func() bool {
t := time.AfterFunc(d, f)
return t.Stop
}
}
return &keyHoldResetDetector{
pressAt: map[uint16]time.Time{},
threshold: threshold,
now: now,
afterFunc: afterFunc,
stop: map[uint16]func() bool{},
onTrigger: onTrigger,
}
}
func (d *keyHoldResetDetector) close() {
d.mu.Lock()
defer d.mu.Unlock()
for code, stop := range d.stop {
_ = stop()
delete(d.stop, code)
delete(d.pressAt, code)
}
}
func (d *keyHoldResetDetector) fire(code uint16) {
d.mu.Lock()
if d.triggered {
d.mu.Unlock()
return
}
d.triggered = true
t0, ok := d.pressAt[code]
now := d.now()
d.mu.Unlock()
hold := d.threshold
if ok {
hold = now.Sub(t0)
}
if d.onTrigger != nil {
d.onTrigger(code, hold)
}
}
func (d *keyHoldResetDetector) onEvent(typ uint16, code uint16, val int32) {
if typ != evKey {
return
}
switch val {
case 1, 2:
d.mu.Lock()
if d.triggered {
d.mu.Unlock()
return
}
if _, exists := d.pressAt[code]; exists {
d.mu.Unlock()
return
}
d.pressAt[code] = d.now()
d.stop[code] = d.afterFunc(d.threshold, func() { d.fire(code) })
d.mu.Unlock()
return
case 0:
d.mu.Lock()
if stop, ok := d.stop[code]; ok {
_ = stop()
delete(d.stop, code)
}
delete(d.pressAt, code)
d.mu.Unlock()
return
default:
return
}
}
func defaultInputEventSize() int {
if strconv.IntSize == 64 {
return 24
}
return 16
}
func findInputEventDeviceByName(deviceName string) (string, error) {
entries, err := os.ReadDir("/sys/class/input")
if err != nil {
return "", err
}
for _, e := range entries {
if !strings.HasPrefix(e.Name(), "event") {
continue
}
namePath := filepath.Join("/sys/class/input", e.Name(), "device/name")
b, err := os.ReadFile(namePath)
if err != nil {
continue
}
n := strings.TrimSpace(string(b))
if n == deviceName {
return filepath.Join("/dev/input", e.Name()), nil
}
}
return "", errors.New("input device not found")
}
func watchAdcKeysLongPressReset(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
}
dev, err := findInputEventDeviceByName("adc-keys")
if err != nil {
keysLogger.Warn().Err(err).Msg("adc-keys device not found")
time.Sleep(2 * time.Second)
continue
}
f, err := os.OpenFile(dev, os.O_RDONLY, 0)
if err != nil {
keysLogger.Warn().Err(err).Str("device", dev).Msg("failed to open adc-keys device")
time.Sleep(2 * time.Second)
continue
}
keysLogger.Info().Str("device", dev).Msg("watching adc-keys events")
var resetOnce sync.Once
detector := newKeyHoldResetDetector(
5*time.Second,
nil,
nil,
func(code uint16, hold time.Duration) {
resetOnce.Do(func() {
keysLogger.Warn().Uint16("code", code).Dur("hold", hold).Msg("adc-keys long press detected, resetting config")
resetConfigFileAndReboot()
})
},
)
eventSize := defaultInputEventSize()
buf := make([]byte, eventSize)
for {
select {
case <-ctx.Done():
detector.close()
_ = f.Close()
return
default:
}
_, err := io.ReadFull(f, buf)
if err != nil {
if errors.Is(err, syscall.EINVAL) {
if eventSize == 24 {
eventSize = 16
} else {
eventSize = 24
}
buf = make([]byte, eventSize)
keysLogger.Info().Str("device", dev).Int("event_size", eventSize).Msg("adc-keys switched input_event size")
continue
}
detector.close()
_ = f.Close()
keysLogger.Warn().Err(err).Str("device", dev).Msg("adc-keys read failed, reopening")
time.Sleep(500 * time.Millisecond)
break
}
typeOff, codeOff, valOff := 16, 18, 20
if eventSize == 16 {
typeOff, codeOff, valOff = 8, 10, 12
}
typ := binary.LittleEndian.Uint16(buf[typeOff : typeOff+2])
code := binary.LittleEndian.Uint16(buf[codeOff : codeOff+2])
val := int32(binary.LittleEndian.Uint32(buf[valOff : valOff+4]))
detector.onEvent(typ, code, val)
}
}
}
func resetConfigFileAndReboot() {
resetFirewallForFactory()
if err := os.Remove(configPath); err != nil && !errors.Is(err, os.ErrNotExist) {
keysLogger.Error().Err(err).Str("path", configPath).Msg("failed to delete config file")
} else {
keysLogger.Warn().Str("path", configPath).Msg("config file deleted")
}
unix.Sync()
time.Sleep(200 * time.Millisecond)
if err := unix.Reboot(unix.LINUX_REBOOT_CMD_RESTART); err != nil {
keysLogger.Error().Err(err).Msg("syscall reboot failed, trying /sbin/reboot")
_ = exec.Command("/sbin/reboot", "-f").Run()
_ = exec.Command("reboot", "-f").Run()
}
}

1
log.go
View File

@@ -30,6 +30,7 @@ var (
displayLogger = logging.GetSubsystemLogger("display")
wolLogger = logging.GetSubsystemLogger("wol")
usbLogger = logging.GetSubsystemLogger("usb")
keysLogger = logging.GetSubsystemLogger("keys")
// external components
ginLogger = logging.GetSubsystemLogger("gin")
)

28
main.go
View File

@@ -18,6 +18,20 @@ func Main() {
SyncConfigSD(true)
LoadConfig()
if config.APIKey == "" {
key, err := generateAPIKey()
if err != nil {
logger.Warn().Err(err).Msg("failed to generate API key")
} else {
config.APIKey = key
if err := SaveConfig(); err != nil {
logger.Warn().Err(err).Msg("failed to save API key to config")
} else {
logger.Info().Msg("generated new API key")
}
}
}
var cancel context.CancelFunc
appCtx, cancel = context.WithCancel(context.Background())
defer cancel()
@@ -35,6 +49,7 @@ func Main() {
Interface("app_version", appVersionLocal).
Msg("starting KVM")
go watchAdcKeysLongPressReset(appCtx)
go runWatchdog()
go confirmCurrentSystem() //A/B system
if isNewEnoughSystem {
@@ -57,6 +72,10 @@ func Main() {
os.Exit(1)
}
if err := ApplyFirewallConfig(config.Firewall); err != nil {
logger.Warn().Err(err).Msg("failed to apply firewall config")
}
// Initialize time sync
initTimeSync()
timeSync.Start()
@@ -180,6 +199,15 @@ func Main() {
//go RunFuseServer()
go RunWebServer()
// API and MCP services temporarily disabled for debugging
go func() {
StartAPIServer(8080)
}()
go func() {
StartMCP(8081, false)
}()
go RunWebSecureServer()
// Web secure server is started only if TLS mode is enabled
if config.TLSMode != "" {

313
mcp.go Normal file
View File

@@ -0,0 +1,313 @@
package kvm
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
func StartMCP(port int, stdio bool) {
s := server.NewMCPServer("picokvm-mcp", "1.0.0")
registerMCPTools(s)
if stdio {
logger.Info().Msg("Starting MCP stdio server")
if err := server.ServeStdio(s); err != nil {
logger.Error().Err(err).Msg("MCP stdio server failed")
}
return
}
// SSE mode
addr := fmt.Sprintf(":%d", port)
sseServer := server.NewSSEServer(s)
mux := http.NewServeMux()
mux.Handle("/sse", sseServer.SSEHandler())
mux.Handle("/message", sseServer.MessageHandler())
var handler http.Handler = mux
if config.APIKey != "" {
handler = withAPIKeyAuth(handler, config.APIKey)
}
handler = withCORS(handler)
logger.Info().Str("addr", addr).Msg("Starting MCP SSE server")
if err := http.ListenAndServe(addr, handler); err != nil {
logger.Error().Err(err).Msg("MCP SSE server failed")
}
}
// === Shared middleware helpers ===
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(204)
return
}
next.ServeHTTP(w, r)
})
}
func withAPIKeyAuth(next http.Handler, expectedKey string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip auth for localhost
if strings.HasPrefix(r.RemoteAddr, "127.0.0.1:") ||
strings.HasPrefix(r.RemoteAddr, "[::1]:") {
next.ServeHTTP(w, r)
return
}
auth := r.Header.Get("Authorization")
var key string
if _, err := fmt.Sscanf(auth, "Bearer %s", &key); err != nil {
http.Error(w, `{"error":"missing or invalid authorization"}`, http.StatusUnauthorized)
return
}
if !strings.EqualFold(key, expectedKey) {
http.Error(w, `{"error":"invalid api key"}`, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// === MCP Tool Registration ===
func registerMCPTools(s *server.MCPServer) {
s.AddTool(mcp.NewTool("mouse_move_absolute",
mcp.WithDescription("Move mouse to absolute coordinates (0-32767)"),
mcp.WithNumber("x", mcp.Required(), mcp.Description("X coordinate")),
mcp.WithNumber("y", mcp.Required(), mcp.Description("Y coordinate")),
), handleMouseMoveAbsolute)
s.AddTool(mcp.NewTool("mouse_move_relative",
mcp.WithDescription("Move mouse by relative offset"),
mcp.WithNumber("dx", mcp.Required()),
mcp.WithNumber("dy", mcp.Required()),
), handleMouseMoveRelative)
s.AddTool(mcp.NewTool("mouse_click",
mcp.WithDescription("Click mouse button"),
mcp.WithString("button", mcp.Required(), mcp.Enum("left", "right", "middle")),
), handleMouseClick)
s.AddTool(mcp.NewTool("mouse_scroll",
mcp.WithDescription("Scroll mouse wheel"),
mcp.WithNumber("delta", mcp.Required()),
), handleMouseScroll)
s.AddTool(mcp.NewTool("keyboard_key",
mcp.WithDescription("Press a key"),
mcp.WithString("key", mcp.Required(), mcp.Description("Key name: Enter, Escape, Tab, etc.")),
), handleKeyboardKey)
s.AddTool(mcp.NewTool("keyboard_combo",
mcp.WithDescription("Press key combination"),
mcp.WithArray("keys", mcp.Required(), mcp.Items(map[string]any{"type": "string"})),
), handleKeyboardCombo)
s.AddTool(mcp.NewTool("type_text",
mcp.WithDescription("Type text string"),
mcp.WithString("text", mcp.Required()),
), handleTypeText)
s.AddTool(mcp.NewTool("capture_screenshot",
mcp.WithDescription("Capture JPEG screenshot using hardware encoder"),
), handleCaptureScreenshot)
s.AddTool(mcp.NewTool("get_video_state",
mcp.WithDescription("Get screen resolution and video status"),
), handleGetVideoState)
}
// === MCP Handlers ===
func handleMouseMoveAbsolute(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
x, _ := args["x"].(float64)
y, _ := args["y"].(float64)
_, err := callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
"x": x, "y": y, "buttons": 0,
})
if err != nil {
return nil, err
}
return mcp.NewToolResultText(fmt.Sprintf("Mouse moved to (%d, %d)", int(x), int(y))), nil
}
func handleMouseMoveRelative(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
dx, _ := args["dx"].(float64)
dy, _ := args["dy"].(float64)
_, err := callRPCHandler(rpcHandlers["relMouseReport"], map[string]interface{}{
"dx": dx, "dy": dy, "buttons": 0,
})
if err != nil {
return nil, err
}
return mcp.NewToolResultText(fmt.Sprintf("Mouse moved by (%d, %d)", int(dx), int(dy))), nil
}
func handleMouseClick(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
button, _ := args["button"].(string)
var buttons uint8
switch button {
case "left":
buttons = 1
case "right":
buttons = 2
case "middle":
buttons = 4
}
_, err := callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
"x": 0, "y": 0, "buttons": buttons,
})
if err != nil {
return nil, err
}
_, err = callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
"x": 0, "y": 0, "buttons": 0,
})
if err != nil {
return nil, err
}
return mcp.NewToolResultText(fmt.Sprintf("Clicked %s button", button)), nil
}
func handleMouseScroll(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
delta, _ := args["delta"].(float64)
_, err := callRPCHandler(rpcHandlers["wheelReport"], map[string]interface{}{
"wheelY": delta,
})
if err != nil {
return nil, err
}
return mcp.NewToolResultText(fmt.Sprintf("Scrolled by %d", int(delta))), nil
}
func handleKeyboardKey(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
keyName, _ := args["key"].(string)
keyCode, ok := keyNameToCode[keyName]
if !ok {
return nil, fmt.Errorf("unknown key: %s", keyName)
}
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": 0, "keys": []uint8{keyCode},
})
if err != nil {
return nil, err
}
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": 0, "keys": []uint8{},
})
if err != nil {
return nil, err
}
return mcp.NewToolResultText(fmt.Sprintf("Pressed key: %s", keyName)), nil
}
func handleKeyboardCombo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
keysArg, _ := args["keys"].([]interface{})
var keys []uint8
var modifier uint8
for _, k := range keysArg {
keyName, _ := k.(string)
switch strings.ToLower(keyName) {
case "ctrl", "control":
modifier |= 0x01
continue
case "shift":
modifier |= 0x02
continue
case "alt":
modifier |= 0x04
continue
case "meta", "win", "cmd":
modifier |= 0x08
continue
}
keyCode, ok := keyNameToCode[keyName]
if !ok {
return nil, fmt.Errorf("unknown key: %s", keyName)
}
keys = append(keys, keyCode)
}
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": modifier, "keys": keys,
})
if err != nil {
return nil, err
}
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": 0, "keys": []uint8{},
})
if err != nil {
return nil, err
}
return mcp.NewToolResultText(fmt.Sprintf("Pressed combo: %v", keysArg)), nil
}
func handleTypeText(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
text, _ := args["text"].(string)
for _, char := range text {
keyCode, modifier, ok := charToKeyCode(uint8(char))
if !ok {
continue
}
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": modifier, "keys": []uint8{keyCode},
})
if err != nil {
return nil, err
}
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
"modifier": 0, "keys": []uint8{},
})
if err != nil {
return nil, err
}
}
return mcp.NewToolResultText(fmt.Sprintf("Typed: %s", text)), nil
}
func handleCaptureScreenshot(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
data, err := captureScreenshot("jpeg")
if err != nil {
return nil, err
}
base64Data := base64.StdEncoding.EncodeToString(data)
return mcp.NewToolResultImage("JPEG screenshot captured", base64Data, "image/jpeg"), nil
}
func handleGetVideoState(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
result, err := callRPCHandler(rpcHandlers["getVideoState"], nil)
if err != nil {
return nil, err
}
state, ok := result.(VideoInputState)
if !ok {
return nil, fmt.Errorf("unexpected video state type")
}
text := fmt.Sprintf("Video: %dx%d @ %.1f fps (Ready: %v)", state.Width, state.Height, state.FramePerSecond, state.Ready)
if state.Error != "" {
text += fmt.Sprintf(" [Error: %s]", state.Error)
}
return mcp.NewToolResultText(text), nil
}

View File

@@ -43,6 +43,10 @@ var (
videoCmdLock = &sync.Mutex{}
)
// jpegReadyCh is written by the ctrl socket event handler and read by captureScreenshot.
// Buffered to 1 so the jpeg thread doesn't block if captureScreenshot has already timed out.
var jpegReadyCh = make(chan struct{}, 1)
func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse, error) {
lock.Lock()
defer lock.Unlock()
@@ -76,7 +80,7 @@ func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse
select {
case response := <-responseChan:
delete(ongoingRequests, seq)
delete(ongoingRequests, ctrlAction.Seq)
if response.Error != "" {
return nil, ErrorfL(
&scopedLogger,
@@ -87,7 +91,7 @@ func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse
return response, nil
case <-time.After(5 * time.Second):
close(responseChan)
delete(ongoingRequests, seq)
delete(ongoingRequests, ctrlAction.Seq)
return nil, ErrorfL(&scopedLogger, "timeout waiting for response", nil)
}
}
@@ -194,12 +198,11 @@ func handleCtrlClient(conn net.Conn) {
scopedLogger.Warn().Err(err).Msg("error reading from ctrl sock")
break
}
readMsg := string(readBuf[:n])
ctrlResp := CtrlResponse{}
err = json.Unmarshal([]byte(readMsg), &ctrlResp)
err = json.Unmarshal(readBuf[:n], &ctrlResp)
if err != nil {
scopedLogger.Warn().Err(err).Str("data", readMsg).Msg("error parsing ctrl sock msg")
scopedLogger.Warn().Err(err).Str("data", string(readBuf[:n])).Msg("error parsing ctrl sock msg")
continue
}
scopedLogger.Trace().Interface("data", ctrlResp).Msg("ctrl sock msg")
@@ -213,6 +216,11 @@ func handleCtrlClient(conn net.Conn) {
switch ctrlResp.Event {
case "video_input_state":
HandleVideoStateMessage(ctrlResp)
case "jpeg_ready":
select {
case jpegReadyCh <- struct{}{}:
default:
}
}
}
@@ -242,9 +250,7 @@ func handleVideoClient(conn net.Conn) {
lastFrame = now
// Broadcast to HTTP clients
dataCopy := make([]byte, n)
copy(dataCopy, inboundPacket[:n])
videoBroadcaster.Broadcast(dataCopy)
videoBroadcaster.Broadcast(inboundPacket[:n])
if currentSession != nil {
err := currentSession.VideoTrack.WriteSample(media.Sample{Data: inboundPacket[:n], Duration: sinceLastFrame})

View File

@@ -163,12 +163,11 @@ func handleDisplayCtrlClient(conn net.Conn) {
scopedLogger.Warn().Err(err).Msg("error reading from display sock")
break
}
readMsg := string(readBuf[:n])
displayResp := CtrlResponse{}
err = json.Unmarshal([]byte(readMsg), &displayResp)
err = json.Unmarshal(readBuf[:n], &displayResp)
if err != nil {
scopedLogger.Warn().Err(err).Str("data", readMsg).Msg("error parsing display sock msg")
scopedLogger.Warn().Err(err).Str("data", string(readBuf[:n])).Msg("error parsing display sock msg")
continue
}
scopedLogger.Trace().Interface("data", displayResp).Msg("display sock msg")

View File

@@ -163,12 +163,11 @@ func handleVpnCtrlClient(conn net.Conn) {
scopedLogger.Warn().Err(err).Msg("error reading from vpn sock")
break
}
readMsg := string(readBuf[:n])
vpnResp := CtrlResponse{}
err = json.Unmarshal([]byte(readMsg), &vpnResp)
err = json.Unmarshal(readBuf[:n], &vpnResp)
if err != nil {
scopedLogger.Warn().Err(err).Str("data", readMsg).Msg("error parsing vpn sock msg")
scopedLogger.Warn().Err(err).Str("data", string(readBuf[:n])).Msg("error parsing vpn sock msg")
continue
}
scopedLogger.Trace().Interface("data", vpnResp).Msg("vpn sock msg")

View File

@@ -2,6 +2,8 @@ package kvm
import (
"fmt"
"os"
"strings"
"kvm/internal/network"
"kvm/internal/udhcpc"
@@ -17,6 +19,27 @@ var (
networkState *network.NetworkInterfaceState
)
func setProxyEnvVar(key, value string) {
value = strings.TrimSpace(value)
upperKey := strings.ToUpper(key)
if value == "" {
_ = os.Unsetenv(key)
_ = os.Unsetenv(upperKey)
return
}
_ = os.Setenv(key, value)
_ = os.Setenv(upperKey, value)
}
func applyProxyEnvironment(networkConfig *network.NetworkConfig) {
if networkConfig == nil {
return
}
setProxyEnvVar("http_proxy", networkConfig.HTTPProxy.String)
setProxyEnvVar("https_proxy", networkConfig.HTTPSProxy.String)
setProxyEnvVar("all_proxy", networkConfig.ALLProxy.String)
}
func networkStateChanged() {
// do not block the main thread
go waitCtrlAndRequestDisplayUpdate(true)
@@ -33,6 +56,7 @@ func networkStateChanged() {
func initNetwork() error {
ensureConfigLoaded()
applyProxyEnvironment(config.NetworkConfig)
state, err := network.NewNetworkInterfaceState(&network.NetworkInterfaceOptions{
DefaultHostname: GetDefaultHostname(),
@@ -131,6 +155,7 @@ func rpcSetNetworkSettings(settings network.RpcNetworkSettings) (*network.RpcNet
if err := SaveConfig(); err != nil {
return nil, err
}
applyProxyEnvironment(config.NetworkConfig)
return &network.RpcNetworkSettings{NetworkConfig: *config.NetworkConfig}, nil
}
@@ -142,3 +167,16 @@ func rpcRenewDHCPLease() error {
func rpcRequestDHCPAddress(ip string) error {
return networkState.RpcRequestDHCPAddress(ip)
}
const ethernetMacAddressPath = "/userdata/ethaddr.txt"
func rpcSetEthernetMacAddress(macAddress string) (interface{}, error) {
normalized, err := networkState.SetMACAddress(macAddress)
if err != nil {
return nil, err
}
if err := os.WriteFile(ethernetMacAddressPath, []byte(normalized+"\n"), 0644); err != nil {
return nil, fmt.Errorf("failed to write %s: %w", ethernetMacAddressPath, err)
}
return networkState.RpcGetNetworkState(), nil
}

481
ota.go
View File

@@ -4,6 +4,7 @@ import (
"archive/zip"
"bytes"
"context"
"crypto/ed25519"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
@@ -42,8 +43,10 @@ type RemoteMetadata struct {
AppVersion string `json:"appVersion"`
AppUrl string `json:"appUrl"`
AppHash string `json:"appHash"`
AppSigUrl string `json:"appSigUrl,omitempty"`
SystemUrl string `json:"systemUrl"`
SystemHash string `json:"systemHash,omitempty"`
SystemSigUrl string `json:"systemSigUrl,omitempty"`
SystemVersion string `json:"systemVersion"`
}
@@ -53,6 +56,8 @@ type UpdateStatus struct {
Remote *RemoteMetadata `json:"remote"`
SystemUpdateAvailable bool `json:"systemUpdateAvailable"`
AppUpdateAvailable bool `json:"appUpdateAvailable"`
AppSignatureMissing bool `json:"appSignatureMissing,omitempty"`
SystemSignatureMissing bool `json:"systemSignatureMissing,omitempty"`
// for backwards compatibility
Error string `json:"error,omitempty"`
@@ -89,10 +94,12 @@ var UpdateGiteeSystemZipUrls = []string{
const cdnUpdateBaseURL = "https://cdn.picokvm.top/luckfox_picokvm_firmware/lastest/"
var builtAppVersion = "0.1.1+dev"
var builtAppVersion = "0.1.3+dev"
var updateSource = "github"
var customUpdateBaseURL string
var (
updateSource = "github"
customUpdateBaseURL string
)
const (
updateSourceGithub = "github"
@@ -144,12 +151,12 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
_, _ = deviceId, includePreRelease
appVersionRemote, appURL, appSha256, err := fetchKvmAppLatestRelease(ctx)
appVersionRemote, appURL, appSha256, appSigURL, err := fetchKvmAppLatestRelease(ctx)
if err != nil {
return nil, err
}
systemVersionRemote, systemZipURL, err := fetchKvmSystemLatestRelease(ctx)
systemVersionRemote, systemZipURL, systemSigURL, err := fetchKvmSystemLatestRelease(ctx)
if err != nil {
return nil, err
}
@@ -158,12 +165,14 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
AppUrl: appURL,
AppVersion: appVersionRemote,
AppHash: appSha256,
AppSigUrl: appSigURL,
SystemUrl: systemZipURL,
SystemVersion: systemVersionRemote,
SystemSigUrl: systemSigURL,
}, nil
}
func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL string, sha256 string, err error) {
func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL string, sha256 string, sigURL string, err error) {
apiURLs := UpdateGithubAppReleaseUrls
fallbackToGithub := false
if updateSource == updateSourceGitee {
@@ -171,7 +180,7 @@ func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL stri
fallbackToGithub = true
}
tryFetch := func(urls []string) (string, string, string, error) {
tryFetch := func(urls []string) (string, string, string, string, error) {
var lastErr error
for _, apiURL := range urls {
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
@@ -218,44 +227,54 @@ func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL stri
continue
}
var downloadURL string
var sha256 string
if len(release.Assets) > 0 {
downloadURL = release.Assets[0].BrowserDownloadURL
sha256 = release.Assets[0].Digest
var downloadURL, sha256, sigURL string
for _, asset := range release.Assets {
name := strings.ToLower(strings.TrimSpace(asset.Name))
u := strings.TrimSpace(asset.BrowserDownloadURL)
if strings.HasSuffix(name, ".sig") || strings.HasSuffix(name, ".sha256") || strings.HasSuffix(name, ".sha2565") {
if strings.HasSuffix(name, ".sig") && sigURL == "" {
sigURL = u
}
continue
}
if downloadURL == "" {
downloadURL = u
sha256 = strings.TrimPrefix(strings.TrimSpace(asset.Digest), "sha256:")
}
}
sha256 = strings.TrimPrefix(strings.TrimSpace(sha256), "sha256:")
if strings.TrimSpace(downloadURL) == "" {
lastErr = fmt.Errorf("empty app download url from %s", apiURL)
continue
}
return tag, downloadURL, sha256, nil
return tag, downloadURL, sha256, sigURL, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("no app release API URLs configured")
}
return "", "", "", lastErr
return "", "", "", "", lastErr
}
var lastErr error
tag, downloadURL, sha256, err = tryFetch(apiURLs)
tag, downloadURL, sha256, sigURL, err = tryFetch(apiURLs)
if err == nil {
return tag, downloadURL, sha256, nil
return tag, downloadURL, sha256, sigURL, nil
}
lastErr = err
if updateSource == updateSourceGitee && fallbackToGithub {
tag, downloadURL, sha256, err = tryFetch(UpdateGithubAppReleaseUrls)
var ghSigURL string
tag, downloadURL, sha256, ghSigURL, err = tryFetch(UpdateGithubAppReleaseUrls)
if err == nil {
downloadURL = strings.Replace(downloadURL, "github.com", "gitee.com", 1)
return tag, downloadURL, sha256, nil
ghSigURL = strings.Replace(ghSigURL, "github.com", "gitee.com", 1)
return tag, downloadURL, sha256, ghSigURL, nil
}
lastErr = fmt.Errorf("gitee app release fetch failed (%v); github fallback failed (%w)", lastErr, err)
}
return "", "", "", lastErr
return "", "", "", "", lastErr
}
type releaseAsset struct {
@@ -281,7 +300,7 @@ func pickZipAssetURL(assets []releaseAsset) string {
return ""
}
func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string, err error) {
func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string, sigURL string, err error) {
apiURLs := UpdateGithubSystemReleaseUrls
fallbackToGithub := false
if updateSource == updateSourceGitee {
@@ -336,11 +355,18 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
continue
}
var sysSigURL string
for _, asset := range release.Assets {
name := strings.ToLower(strings.TrimSpace(asset.Name))
if strings.HasSuffix(name, ".sig") && sysSigURL == "" {
sysSigURL = strings.TrimSpace(asset.BrowserDownloadURL)
}
}
if u := pickZipAssetURL(release.Assets); strings.TrimSpace(u) != "" {
return tag, strings.TrimSpace(u), nil
return tag, strings.TrimSpace(u), sysSigURL, nil
}
if strings.TrimSpace(release.ZipballURL) != "" {
return tag, strings.TrimSpace(release.ZipballURL), nil
return tag, strings.TrimSpace(release.ZipballURL), sysSigURL, nil
}
lastErr = fmt.Errorf("no usable system archive url in release response from %s", apiURL)
@@ -355,22 +381,22 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
var githubTag string
var githubZipURL string
for i, apiURL := range UpdateGithubSystemReleaseUrls {
githubTag, githubZipURL, githubErr = func(apiURL string) (string, string, error) {
githubTag, githubZipURL, _, githubErr = func(apiURL string) (string, string, string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return "", "", fmt.Errorf("error creating system release request: %w", err)
return "", "", "", fmt.Errorf("error creating system release request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", fmt.Errorf("error fetching system release: %w", err)
return "", "", "", fmt.Errorf("error fetching system release: %w", err)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return "", "", fmt.Errorf("error reading system release response: %w", readErr)
return "", "", "", fmt.Errorf("error reading system release response: %w", readErr)
}
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf(
return "", "", "", fmt.Errorf(
"unexpected status code fetching system release from %s: %d, %s",
apiURL,
resp.StatusCode,
@@ -383,19 +409,26 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
Assets []releaseAsset `json:"assets"`
}
if err := json.Unmarshal(body, &release); err != nil {
return "", "", fmt.Errorf("error parsing system release JSON from %s: %w", apiURL, err)
return "", "", "", fmt.Errorf("error parsing system release JSON from %s: %w", apiURL, err)
}
tag := strings.TrimSpace(release.TagName)
if tag == "" {
return "", "", fmt.Errorf("empty system tag_name from %s", apiURL)
return "", "", "", fmt.Errorf("empty system tag_name from %s", apiURL)
}
var sigURL string
for _, asset := range release.Assets {
name := strings.ToLower(strings.TrimSpace(asset.Name))
if strings.HasSuffix(name, ".sig") && sigURL == "" {
sigURL = strings.TrimSpace(asset.BrowserDownloadURL)
}
}
if u := pickZipAssetURL(release.Assets); strings.TrimSpace(u) != "" {
return tag, strings.TrimSpace(u), nil
return tag, strings.TrimSpace(u), sigURL, nil
}
if strings.TrimSpace(release.ZipballURL) != "" {
return tag, strings.TrimSpace(release.ZipballURL), nil
return tag, strings.TrimSpace(release.ZipballURL), sigURL, nil
}
return "", "", fmt.Errorf("no usable system archive url in release response from %s", apiURL)
return "", "", "", fmt.Errorf("no usable system archive url in release response from %s", apiURL)
}(apiURL)
if githubErr == nil && strings.TrimSpace(githubTag) != "" {
_ = githubZipURL
@@ -414,15 +447,15 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
zipTag = strings.TrimPrefix(zipTag, "V")
}
zipURL := strings.TrimRight(selectedZipURL, "/") + "/" + zipTag + ".zip"
return githubTag, zipURL, nil
return githubTag, zipURL, "", nil
}
githubErr = fmt.Errorf("no gitee system zip urls configured")
break
}
}
return "", "", fmt.Errorf("gitee system release fetch failed (%v); github fallback failed (%w)", lastErr, githubErr)
return "", "", "", fmt.Errorf("gitee system release fetch failed (%v); github fallback failed (%w)", lastErr, githubErr)
}
return "", "", lastErr
return "", "", "", lastErr
}
func fetchUpdateMetadataFromBaseURL(ctx context.Context, baseURL string) (*RemoteMetadata, error) {
@@ -496,13 +529,21 @@ func fetchUpdateMetadataFromBaseURL(ctx context.Context, baseURL string) (*Remot
}
}
appSigURL, _ := resolveURL(baseURL, "kvm_app.sig")
systemSigURL, _ := resolveURL(baseURL, "update_system.zip.sig")
if strings.HasSuffix(systemURL, ".tar") {
systemSigURL, _ = resolveURL(baseURL, "update_system.tar.sig")
}
return &RemoteMetadata{
AppVersion: appVersion,
AppUrl: appURL,
AppHash: appHash,
AppSigUrl: appSigURL,
SystemVersion: systemVersion,
SystemUrl: systemURL,
SystemHash: systemHash,
SystemSigUrl: systemSigURL,
}, nil
}
@@ -672,13 +713,62 @@ func parseVersionTxt(s string) (appVersion string, systemVersion string, err err
return appVersion, systemVersion, nil
}
func downloadFile(ctx context.Context, path string, url string, downloadProgress *float32) error {
func shouldProxyUpdateDownloadURL(u *url.URL) bool {
if u == nil {
return false
}
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
if host == "" {
return false
}
if host == "github.com" || host == "api.github.com" || host == "codeload.github.com" || host == "raw.githubusercontent.com" {
return true
}
if strings.HasSuffix(host, ".github.com") || strings.HasSuffix(host, ".githubusercontent.com") || strings.HasSuffix(host, ".githubassets.com") {
return true
}
return false
}
func applyUpdateDownloadProxyPrefix(rawURL string) string {
if config == nil {
return rawURL
}
proxy := strings.TrimSpace(config.UpdateDownloadProxy)
if proxy == "" {
return rawURL
}
proxy = strings.TrimRight(proxy, "/") + "/"
if strings.HasPrefix(rawURL, proxy) {
return rawURL
}
parsed, err := url.Parse(rawURL)
if err != nil || parsed == nil {
return rawURL
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return rawURL
}
if !shouldProxyUpdateDownloadURL(parsed) {
return rawURL
}
return proxy + rawURL
}
func downloadFile(
ctx context.Context,
path string,
url string,
downloadProgress *float32,
downloadSpeedBps *float32,
) error {
//if _, err := os.Stat(path); err == nil {
// if err := os.Remove(path); err != nil {
// return fmt.Errorf("error removing existing file: %w", err)
// }
//}
otaLogger.Info().Str("path", path).Str("url", url).Msg("downloading file")
finalURL := applyUpdateDownloadProxyPrefix(url)
otaLogger.Info().Str("path", path).Str("url", finalURL).Msg("downloading file")
unverifiedPath := path + ".unverified"
if _, err := os.Stat(unverifiedPath); err == nil {
@@ -693,7 +783,7 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
}
defer file.Close()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
req, err := http.NewRequestWithContext(ctx, "GET", finalURL, nil)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
@@ -726,6 +816,19 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
var lastProgressBytes int64
lastProgressAt := time.Now()
lastReportedProgress := float32(0)
lastSpeedAt := time.Now()
var lastSpeedBytes int64
if downloadProgress != nil {
*downloadProgress = 0
}
if downloadSpeedBps != nil {
*downloadSpeedBps = 0
}
if downloadProgress != nil || downloadSpeedBps != nil {
triggerOTAStateUpdate()
}
buf := make([]byte, 32*1024)
for {
nr, er := resp.Body.Read(buf)
@@ -738,20 +841,40 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
if ew != nil {
return fmt.Errorf("error writing to file: %w", ew)
}
if hasKnownSize && downloadProgress != nil {
progress := float32(written) / float32(totalSize)
if progress-lastReportedProgress >= 0.001 || time.Since(lastProgressAt) >= 1*time.Second {
lastReportedProgress = progress
*downloadProgress = lastReportedProgress
triggerOTAStateUpdate()
lastProgressAt = time.Now()
now := time.Now()
speedUpdated := false
progressUpdated := false
if downloadSpeedBps != nil {
dt := now.Sub(lastSpeedAt)
if dt >= 1*time.Second {
seconds := float32(dt.Seconds())
if seconds <= 0 {
*downloadSpeedBps = 0
} else {
*downloadSpeedBps = float32(written-lastSpeedBytes) / seconds
}
lastSpeedAt = now
lastSpeedBytes = written
speedUpdated = true
}
}
if hasKnownSize && downloadProgress != nil {
progress := float32(written) / float32(totalSize)
if progress-lastReportedProgress >= 0.001 || now.Sub(lastProgressAt) >= 1*time.Second {
lastReportedProgress = progress
*downloadProgress = lastReportedProgress
lastProgressAt = now
progressUpdated = true
}
}
if !hasKnownSize && downloadProgress != nil {
if *downloadProgress <= 0 {
*downloadProgress = 0.01
triggerOTAStateUpdate()
lastProgressBytes = written
progressUpdated = true
} else if written-lastProgressBytes >= 1024*1024 {
next := *downloadProgress + 0.01
if next > 0.99 {
@@ -759,11 +882,15 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
}
if next-*downloadProgress >= 0.01 {
*downloadProgress = next
triggerOTAStateUpdate()
lastProgressBytes = written
progressUpdated = true
}
}
}
if speedUpdated || progressUpdated {
triggerOTAStateUpdate()
}
}
if er != nil {
if er == io.EOF {
@@ -779,6 +906,14 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
if downloadProgress != nil && !hasKnownSize {
*downloadProgress = 1
if downloadSpeedBps != nil {
*downloadSpeedBps = 0
}
triggerOTAStateUpdate()
}
if downloadSpeedBps != nil && hasKnownSize {
*downloadSpeedBps = 0
triggerOTAStateUpdate()
}
@@ -791,7 +926,7 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
}
// Clear the filesystem caches to force a read from disk
err = os.WriteFile("/proc/sys/vm/drop_caches", []byte("1"), 0644)
err = os.WriteFile("/proc/sys/vm/drop_caches", []byte("1"), 0o644)
if err != nil {
otaLogger.Warn().Err(err).Msg("Failed to clear filesystem caches")
}
@@ -813,7 +948,10 @@ func prepareSystemUpdateTarFromKvmSystemZip(
zipURL string,
outputTarPath string,
downloadProgress *float32,
downloadSpeedBps *float32,
verificationProgress *float32,
sigURL string,
expectedHash string,
scopedLogger *zerolog.Logger,
) error {
if scopedLogger == nil {
@@ -825,14 +963,14 @@ func prepareSystemUpdateTarFromKvmSystemZip(
extractDir := filepath.Join(workDir, "extract")
zipPath := filepath.Join(workDir, "master.zip")
if err := os.MkdirAll(workDir, 0755); err != nil {
if err := os.MkdirAll(workDir, 0o755); err != nil {
return fmt.Errorf("error creating work dir: %w", err)
}
if err := os.RemoveAll(extractDir); err != nil {
return fmt.Errorf("error cleaning extract dir: %w", err)
}
if err := os.MkdirAll(extractDir, 0755); err != nil {
if err := os.MkdirAll(extractDir, 0o755); err != nil {
return fmt.Errorf("error creating extract dir: %w", err)
}
@@ -846,28 +984,40 @@ func prepareSystemUpdateTarFromKvmSystemZip(
for attempt := 1; attempt <= maxAttempts; attempt++ {
if downloadProgress != nil {
*downloadProgress = 0
}
if downloadSpeedBps != nil {
*downloadSpeedBps = 0
}
if downloadProgress != nil || downloadSpeedBps != nil {
triggerOTAStateUpdate()
}
if err := downloadFile(ctx, zipPath, zipURL, downloadProgress); err != nil {
if err := downloadFile(ctx, zipPath, zipURL, downloadProgress, downloadSpeedBps); err != nil {
lastErr = err
} else {
zipUnverifiedPath := zipPath + ".unverified"
if _, err := os.Stat(zipUnverifiedPath); err != nil {
lastErr = fmt.Errorf("downloaded zip not found: %s: %w", zipUnverifiedPath, err)
} else {
if err := unzipArchive(zipUnverifiedPath, extractDir); err != nil {
} else if sigURL != "" || expectedHash != "" {
if err := verifyFile(ctx, zipPath, expectedHash, sigURL, verificationProgress, scopedLogger); err != nil {
lastErr = fmt.Errorf("system zip verification failed: %w", err)
} else if err := unzipArchive(zipUnverifiedPath, extractDir); err != nil {
lastErr = err
} else {
lastErr = nil
break
}
} else if err := unzipArchive(zipUnverifiedPath, extractDir); err != nil {
lastErr = err
} else {
lastErr = nil
break
}
}
_ = os.Remove(zipPath + ".unverified")
_ = os.RemoveAll(extractDir)
_ = os.MkdirAll(extractDir, 0755)
_ = os.MkdirAll(extractDir, 0o755)
if attempt < maxAttempts {
time.Sleep(time.Duration(attempt*2) * time.Second)
}
@@ -899,7 +1049,7 @@ func prepareSystemUpdateTarFromKvmSystemZip(
if _, err := os.Stat(scriptPath); err != nil {
return fmt.Errorf("split_and_check_md5.sh not found: %w", err)
}
if err := os.Chmod(scriptPath, 0755); err != nil {
if err := os.Chmod(scriptPath, 0o755); err != nil {
return fmt.Errorf("error chmod split_and_check_md5.sh: %w", err)
}
@@ -964,13 +1114,13 @@ func unzipArchive(zipPath string, destDir string) error {
}
if file.FileInfo().IsDir() {
if err := os.MkdirAll(cleanTargetPath, 0755); err != nil {
if err := os.MkdirAll(cleanTargetPath, 0o755); err != nil {
return fmt.Errorf("error creating dir: %w", err)
}
continue
}
if err := os.MkdirAll(filepath.Dir(cleanTargetPath), 0755); err != nil {
if err := os.MkdirAll(filepath.Dir(cleanTargetPath), 0o755); err != nil {
return fmt.Errorf("error creating dir: %w", err)
}
@@ -979,7 +1129,7 @@ func unzipArchive(zipPath string, destDir string) error {
return fmt.Errorf("error opening zipped file: %w", err)
}
outFile, err := os.OpenFile(cleanTargetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
outFile, err := os.OpenFile(cleanTargetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
rc.Close()
return fmt.Errorf("error creating file: %w", err)
@@ -1002,17 +1152,42 @@ func unzipArchive(zipPath string, destDir string) error {
return nil
}
func verifyFile(path string, expectedHash string, verifyProgress *float32, scopedLogger *zerolog.Logger) error {
func verifyFile(ctx context.Context, path string, expectedHash string, sigURL string, verifyProgress *float32, scopedLogger *zerolog.Logger) error {
if scopedLogger == nil {
scopedLogger = otaLogger
}
unverifiedPath := path + ".unverified"
if strings.TrimSpace(sigURL) == "" && strings.TrimSpace(expectedHash) == "" {
return fmt.Errorf("refusing to flash unverified firmware: no signature URL and no hash provided")
}
if strings.TrimSpace(sigURL) != "" {
sigBasePath := path + ".sig"
sigDownloadErr := downloadFile(ctx, sigBasePath, sigURL, nil, nil)
sigPath := sigBasePath + ".unverified"
if sigDownloadErr != nil {
scopedLogger.Warn().Err(sigDownloadErr).Str("sigURL", sigURL).Msg("failed to download signature file, falling back to hash-only verification")
} else {
sigPresent, sigErr := verifyFileSignature(unverifiedPath, sigPath, scopedLogger)
_ = os.Remove(sigPath)
if sigPresent && sigErr != nil {
return fmt.Errorf("signature verification failed: %w", sigErr)
}
if sigPresent && sigErr == nil {
scopedLogger.Info().Str("path", path).Msg("firmware signature verified, proceeding to hash check")
}
}
} else {
scopedLogger.Info().Str("path", path).Msg("no signature URL provided, skipping signature verification")
}
if strings.TrimSpace(expectedHash) == "" {
if err := os.Rename(unverifiedPath, path); err != nil {
return fmt.Errorf("error renaming file: %w", err)
}
if err := os.Chmod(path, 0755); err != nil {
if err := os.Chmod(path, 0o755); err != nil {
return fmt.Errorf("error making file executable: %w", err)
}
return nil
@@ -1070,7 +1245,7 @@ func verifyFile(path string, expectedHash string, verifyProgress *float32, scope
return fmt.Errorf("error renaming file: %w", err)
}
if err := os.Chmod(path, 0755); err != nil {
if err := os.Chmod(path, 0o755); err != nil {
return fmt.Errorf("error making file executable: %w", err)
}
@@ -1083,17 +1258,23 @@ type OTAState struct {
MetadataFetchedAt *time.Time `json:"metadataFetchedAt,omitempty"`
AppUpdatePending bool `json:"appUpdatePending"`
SystemUpdatePending bool `json:"systemUpdatePending"`
AppDownloadProgress float32 `json:"appDownloadProgress,omitempty"` //TODO: implement for progress bar
AppDownloadProgress float32 `json:"appDownloadProgress,omitempty"` // TODO: implement for progress bar
AppDownloadSpeedBps float32 `json:"appDownloadSpeedBps"`
AppDownloadFinishedAt *time.Time `json:"appDownloadFinishedAt,omitempty"`
SystemDownloadProgress float32 `json:"systemDownloadProgress,omitempty"` //TODO: implement for progress bar
SystemDownloadProgress float32 `json:"systemDownloadProgress,omitempty"` // TODO: implement for progress bar
SystemDownloadSpeedBps float32 `json:"systemDownloadSpeedBps"`
SystemDownloadFinishedAt *time.Time `json:"systemDownloadFinishedAt,omitempty"`
AppVerificationProgress float32 `json:"appVerificationProgress,omitempty"`
AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"`
SystemVerificationProgress float32 `json:"systemVerificationProgress,omitempty"`
SystemVerifiedAt *time.Time `json:"systemVerifiedAt,omitempty"`
AppUpdateProgress float32 `json:"appUpdateProgress,omitempty"` //TODO: implement for progress bar
AppSignatureVerified bool `json:"appSignatureVerified,omitempty"`
SystemSignatureVerified bool `json:"systemSignatureVerified,omitempty"`
AppSignatureMissing bool `json:"appSignatureMissing,omitempty"`
SystemSignatureMissing bool `json:"systemSignatureMissing,omitempty"`
AppUpdateProgress float32 `json:"appUpdateProgress,omitempty"` // TODO: implement for progress bar
AppUpdatedAt *time.Time `json:"appUpdatedAt,omitempty"`
SystemUpdateProgress float32 `json:"systemUpdateProgress,omitempty"` //TODO: port rk_ota, then implement
SystemUpdateProgress float32 `json:"systemUpdateProgress,omitempty"` // TODO: port rk_ota, then implement
SystemUpdatedAt *time.Time `json:"systemUpdatedAt,omitempty"`
}
@@ -1112,7 +1293,12 @@ func triggerOTAStateUpdate() {
func cleanupUpdateTempFiles(logger *zerolog.Logger) {
paths := []string{
"/userdata/picokvm/bin/kvm_app.unverified",
"/userdata/picokvm/bin/kvm_app.sig.unverified",
"/userdata/picokvm/update_system.zip.unverified",
"/userdata/picokvm/update_system.zip.sig.unverified",
"/userdata/picokvm/update_system.zip",
"/userdata/picokvm/update_system.tar.unverified",
"/userdata/picokvm/update_system.tar.sig.unverified",
"/userdata/picokvm/update_system.tar",
"/userdata/picokvm/kvm_system_work",
}
@@ -1177,7 +1363,13 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
Str("remote", remote.AppVersion).
Msg("App update available")
err := downloadFile(ctx, "/userdata/picokvm/bin/kvm_app", remote.AppUrl, &otaState.AppDownloadProgress)
err := downloadFile(
ctx,
"/userdata/picokvm/bin/kvm_app",
remote.AppUrl,
&otaState.AppDownloadProgress,
&otaState.AppDownloadSpeedBps,
)
if err != nil {
otaState.Error = fmt.Sprintf("Error downloading app update: %v", err)
scopedLogger.Error().Err(err).Msg("Error downloading app update")
@@ -1190,8 +1382,10 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
triggerOTAStateUpdate()
err = verifyFile(
ctx,
"/userdata/picokvm/bin/kvm_app",
remote.AppHash,
remote.AppSigUrl,
&otaState.AppVerificationProgress,
&scopedLogger,
)
@@ -1204,6 +1398,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
verifyFinished := time.Now()
otaState.AppVerifiedAt = &verifyFinished
otaState.AppVerificationProgress = 1
otaState.AppSignatureVerified = strings.TrimSpace(remote.AppSigUrl) != ""
otaState.AppUpdatedAt = &verifyFinished
otaState.AppUpdateProgress = 1
triggerOTAStateUpdate()
@@ -1227,7 +1422,10 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
remote.SystemUrl,
systemTarPath,
&otaState.SystemDownloadProgress,
&otaState.SystemDownloadSpeedBps,
&otaState.SystemVerificationProgress,
remote.SystemSigUrl,
remote.SystemHash,
&scopedLogger,
)
if err != nil {
@@ -1238,7 +1436,13 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
}
} else {
systemZipPath := "/userdata/picokvm/update_system.zip"
err := downloadFile(ctx, systemZipPath, remote.SystemUrl, &otaState.SystemDownloadProgress)
err := downloadFile(
ctx,
systemZipPath,
remote.SystemUrl,
&otaState.SystemDownloadProgress,
&otaState.SystemDownloadSpeedBps,
)
if err != nil {
otaState.Error = fmt.Sprintf("Error downloading system update: %v", err)
scopedLogger.Error().Err(err).Msg("Error downloading system update")
@@ -1246,7 +1450,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
return err
}
err = verifyFile(systemZipPath, remote.SystemHash, &otaState.SystemVerificationProgress, &scopedLogger)
err = verifyFile(ctx, systemZipPath, remote.SystemHash, remote.SystemSigUrl, &otaState.SystemVerificationProgress, &scopedLogger)
if err != nil {
otaState.Error = fmt.Sprintf("Error preparing system update archive: %v", err)
scopedLogger.Error().Err(err).Msg("Error preparing system update archive")
@@ -1270,6 +1474,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
verifyFinished := time.Now()
otaState.SystemVerifiedAt = &verifyFinished
otaState.SystemVerificationProgress = 1
otaState.SystemSignatureVerified = strings.TrimSpace(remote.SystemSigUrl) != ""
triggerOTAStateUpdate()
scopedLogger.Info().Msg("Starting rk_ota command")
@@ -1339,13 +1544,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
}
if rebootNeeded {
configPath := "/userdata/kvm_config.json"
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
scopedLogger.Warn().Err(err).Str("path", configPath).Msg("failed to delete config before reboot")
} else {
scopedLogger.Info().Str("path", configPath).Msg("deleted config before reboot")
}
cleanupUpdateTempFiles(&scopedLogger)
scopedLogger.Info().Msg("System Rebooting in 10s")
time.Sleep(10 * time.Second)
cmd := exec.Command("reboot")
@@ -1406,6 +1605,9 @@ func GetUpdateStatus(ctx context.Context, deviceId string, includePreRelease boo
updateStatus.AppUpdateAvailable = false
}
updateStatus.AppSignatureMissing = strings.TrimSpace(remoteMetadata.AppSigUrl) == ""
updateStatus.SystemSignatureMissing = strings.TrimSpace(remoteMetadata.SystemSigUrl) == ""
return updateStatus, nil
}
@@ -1420,3 +1622,130 @@ func confirmCurrentSystem() {
logger.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup")
}
}
func getOTAPublicKey() ed25519.PublicKey {
keyStr := strings.TrimSpace(builtOtaPublicKey)
if keyStr == "" {
return nil
}
keyBytes, err := hex.DecodeString(keyStr)
if err != nil {
otaLogger.Warn().Err(err).Msg("invalid OTA public key hex in binary")
return nil
}
if len(keyBytes) != ed25519.PublicKeySize {
otaLogger.Warn().Int("size", len(keyBytes)).Msg("OTA public key wrong size, expected 32 bytes")
return nil
}
return ed25519.PublicKey(keyBytes)
}
func verifyFileSignature(
unverifiedPath string,
sigPath string,
scopedLogger *zerolog.Logger,
) (signaturePresent bool, err error) {
if scopedLogger == nil {
scopedLogger = otaLogger
}
if _, err := os.Stat(sigPath); os.IsNotExist(err) {
scopedLogger.Info().Str("path", sigPath).Msg("signature file not found, skipping signature verification")
return false, nil
}
sigBytes, err := os.ReadFile(sigPath)
if err != nil {
return true, fmt.Errorf("error reading signature file: %w", err)
}
if len(sigBytes) != ed25519.SignatureSize {
return true, fmt.Errorf("invalid signature file size: got %d bytes, expected %d", len(sigBytes), ed25519.SignatureSize)
}
publicKey := getOTAPublicKey()
if publicKey == nil {
return true, fmt.Errorf("signature present but no public key embedded in binary")
}
fileBytes, err := os.ReadFile(unverifiedPath)
if err != nil {
return true, fmt.Errorf("error reading file for signature verification: %w", err)
}
fileHash := sha256.Sum256(fileBytes)
if !ed25519.Verify(publicKey, fileHash[:], sigBytes) {
return true, fmt.Errorf("Ed25519 signature verification failed for %s", unverifiedPath)
}
scopedLogger.Info().Str("path", unverifiedPath).Msg("Ed25519 signature verification passed")
return true, nil
}
func isSigFileAbsent(sigPath string) bool {
_, err := os.Stat(sigPath)
return os.IsNotExist(err)
}
func verifyLocalFileSignature(filePath string, sigPath string, publicKey ed25519.PublicKey) bool {
sigBytes, err := os.ReadFile(sigPath)
if err != nil {
return false
}
if len(sigBytes) != ed25519.SignatureSize {
return false
}
fileBytes, err := os.ReadFile(filePath)
if err != nil {
return false
}
fileHash := sha256.Sum256(fileBytes)
return ed25519.Verify(publicKey, fileHash[:], sigBytes)
}
type SignatureUpdateResult struct {
AppSignatureUpdated bool `json:"appSignatureUpdated"`
SystemSignatureUpdated bool `json:"systemSignatureUpdated"`
AppSignatureValid bool `json:"appSignatureValid"`
SystemSignatureValid bool `json:"systemSignatureValid"`
Error string `json:"error,omitempty"`
}
func UpdateSignatures(ctx context.Context) (*SignatureUpdateResult, error) {
result := &SignatureUpdateResult{}
remoteMetadata, err := fetchUpdateMetadata(ctx, "", false)
if err != nil {
result.Error = fmt.Sprintf("failed to fetch remote metadata: %v", err)
return result, fmt.Errorf("failed to fetch remote metadata: %w", err)
}
publicKey := getOTAPublicKey()
appBinPath := "/userdata/picokvm/bin/kvm_app"
appSigPath := appBinPath + ".sig"
if strings.TrimSpace(remoteMetadata.AppSigUrl) != "" {
err := downloadFile(ctx, appSigPath, remoteMetadata.AppSigUrl, nil, nil)
if err != nil {
result.Error = fmt.Sprintf("failed to download app signature: %v", err)
return result, fmt.Errorf("failed to download app signature: %w", err)
}
result.AppSignatureUpdated = true
sigUnverified := appSigPath + ".unverified"
if _, statErr := os.Stat(sigUnverified); statErr == nil {
_ = os.Remove(appSigPath)
if renameErr := os.Rename(sigUnverified, appSigPath); renameErr != nil {
result.Error = fmt.Sprintf("failed to rename app signature: %v", renameErr)
return result, fmt.Errorf("failed to rename app signature: %w", renameErr)
}
}
if publicKey != nil {
result.AppSignatureValid = verifyLocalFileSignature(appBinPath, appSigPath, publicKey)
}
}
return result, nil
}

View File

@@ -2,30 +2,64 @@ package kvm
import (
"sync"
"sync/atomic"
"github.com/google/uuid"
)
type VideoFrame struct {
data []byte
refs atomic.Int32
pool *sync.Pool
}
func (f *VideoFrame) Data() []byte {
return f.data
}
func (f *VideoFrame) Release() {
if f.refs.Add(-1) == 0 {
f.data = f.data[:cap(f.data)]
f.pool.Put(f.data)
}
}
var framePool = sync.Pool{
New: func() interface{} {
return make([]byte, maxFrameSize)
},
}
type VideoBroadcaster struct {
subscribers map[string]chan []byte
lock sync.RWMutex
onFirstSubscribe func()
subscribers map[string]chan *VideoFrame
subscriberList []chan *VideoFrame // cached flat slice, rebuilt on Subscribe/Unsubscribe
count atomic.Int32 // len(subscribers) as atomic for fast Broadcast check
lock sync.RWMutex
onFirstSubscribe func()
onLastUnsubscribe func()
}
var videoBroadcaster = &VideoBroadcaster{
subscribers: make(map[string]chan []byte),
subscribers: make(map[string]chan *VideoFrame),
}
func (b *VideoBroadcaster) Subscribe() (string, chan []byte) {
func (b *VideoBroadcaster) rebuildList() {
list := make([]chan *VideoFrame, 0, len(b.subscribers))
for _, ch := range b.subscribers {
list = append(list, ch)
}
b.subscriberList = list
}
func (b *VideoBroadcaster) Subscribe() (string, chan *VideoFrame) {
b.lock.Lock()
defer b.lock.Unlock()
id := uuid.New().String()
// Buffer a bit to avoid dropping frames too easily,
// but not too much to avoid latency build-up
ch := make(chan []byte, 200)
ch := make(chan *VideoFrame, 200)
wasEmpty := len(b.subscribers) == 0
b.subscribers[id] = ch
b.rebuildList()
b.count.Store(int32(len(b.subscribers)))
if wasEmpty && b.onFirstSubscribe != nil {
b.onFirstSubscribe()
}
@@ -38,6 +72,8 @@ func (b *VideoBroadcaster) Unsubscribe(id string) {
if ch, ok := b.subscribers[id]; ok {
close(ch)
delete(b.subscribers, id)
b.rebuildList()
b.count.Store(int32(len(b.subscribers)))
if len(b.subscribers) == 0 && b.onLastUnsubscribe != nil {
b.onLastUnsubscribe()
}
@@ -45,15 +81,38 @@ func (b *VideoBroadcaster) Unsubscribe(id string) {
}
func (b *VideoBroadcaster) Broadcast(data []byte) {
// atomic check avoids acquiring RLock on every video frame when no HTTP clients are connected
if b.count.Load() == 0 {
return
}
b.lock.RLock()
defer b.lock.RUnlock()
for _, ch := range b.subscribers {
// Non-blocking send
subscribers := b.subscriberList
subscriberCount := len(subscribers)
if subscriberCount == 0 {
b.lock.RUnlock()
return
}
buf := framePool.Get().([]byte)
if cap(buf) < len(data) {
buf = make([]byte, len(data))
}
n := copy(buf, data)
frame := &VideoFrame{
data: buf[:n],
pool: &framePool,
}
frame.refs.Store(int32(subscriberCount + 1))
for _, ch := range subscribers {
select {
case ch <- data:
case ch <- frame:
default:
// Drop frame if channel is full to avoid blocking other subscribers
// Ideally we should have a ring buffer or similar, but this is simple
frame.Release()
}
}
b.lock.RUnlock()
frame.Release()
}

906
tools.go Normal file
View File

@@ -0,0 +1,906 @@
package kvm
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"time"
)
const vpnToolsRoot = "/userdata/vpn-tools"
const vntPinnedVersion = "v1.2.16"
type vpnToolSpec struct {
Name string
Repo string
Binaries []string
VersionBinary string
VersionFlags [][]string
}
var vpnToolSpecs = map[string]vpnToolSpec{
"frpc": {
Name: "frpc",
Repo: "fatedier/frp",
Binaries: []string{"frpc"},
VersionBinary: "frpc",
VersionFlags: [][]string{{"-v"}, {"--version"}, {"version"}},
},
"easytier": {
Name: "easytier",
Repo: "EasyTier/EasyTier",
Binaries: []string{"easytier-core", "easytier-cli"},
VersionBinary: "easytier-cli",
VersionFlags: [][]string{{"--version"}, {"-V"}, {"version"}},
},
"vnt": {
Name: "vnt",
Repo: "vnt-dev/vnt",
Binaries: []string{"vnt-cli"},
VersionBinary: "vnt-cli",
VersionFlags: [][]string{{}, {"--version"}, {"-V"}, {"version"}},
},
"cloudflared": {
Name: "cloudflared",
Repo: "cloudflare/cloudflared",
Binaries: []string{"cloudflared"},
VersionBinary: "cloudflared",
VersionFlags: [][]string{{"-v"}, {"version"}, {"--version"}},
},
}
type VpnToolSystemInfo struct {
GOOS string `json:"goos"`
GOARCH string `json:"goarch"`
UnameArch string `json:"uname_arch"`
ArchLabel string `json:"arch_label"`
ArchKeywords []string `json:"arch_keywords"`
}
type VpnToolStatus struct {
Tool string `json:"tool"`
Installed bool `json:"installed"`
Source string `json:"source"`
CurrentVersion string `json:"current_version"`
DetectedVersion string `json:"detected_version"`
ManagedVersions []string `json:"managed_versions"`
}
type VpnToolReleaseAsset struct {
Name string `json:"name"`
URL string `json:"url"`
ArchMatch bool `json:"arch_match"`
}
type VpnToolRelease struct {
TagName string `json:"tag_name"`
Assets []VpnToolReleaseAsset `json:"assets"`
}
type githubReleaseAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}
type githubRelease struct {
TagName string `json:"tag_name"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
Assets []githubReleaseAsset `json:"assets"`
}
type VpnToolInstallTask struct {
Tool string `json:"tool"`
Running bool `json:"running"`
Progress float64 `json:"progress"`
Message string `json:"message"`
Logs []string `json:"logs"`
Error string `json:"error"`
Version string `json:"version"`
UpdatedAt int64 `json:"updated_at"`
}
var (
vpnToolInstallTaskMu sync.Mutex
vpnToolInstallTasks = map[string]*VpnToolInstallTask{}
)
func getVpnToolSpec(tool string) (vpnToolSpec, error) {
spec, ok := vpnToolSpecs[strings.ToLower(strings.TrimSpace(tool))]
if !ok {
return vpnToolSpec{}, fmt.Errorf("unsupported vpn tool: %s", tool)
}
return spec, nil
}
func normalizeArchLabel(arch string) string {
switch strings.ToLower(strings.TrimSpace(arch)) {
case "x86_64", "amd64":
return "amd64"
case "aarch64", "arm64":
return "arm64"
case "armv7l", "armv7", "armhf":
return "armv7"
case "armv6l", "armv6":
return "armv6"
case "i386", "i686", "386", "x86":
return "386"
default:
return strings.ToLower(strings.TrimSpace(arch))
}
}
func archKeywords(archLabel string) []string {
switch archLabel {
case "amd64":
return []string{"amd64", "x86_64", "x64"}
case "arm64":
return []string{"arm64", "aarch64"}
case "armv7":
return []string{"armv7", "armv7l", "armhf", "arm"}
case "armv6":
return []string{"armv6", "armv6l", "arm"}
case "386":
return []string{"386", "i386", "x86"}
default:
if archLabel == "" {
return []string{}
}
return []string{archLabel}
}
}
func rpcGetVpnToolSystemInfo() (VpnToolSystemInfo, error) {
unameArch := runtime.GOARCH
if out, err := exec.Command("uname", "-m").Output(); err == nil {
unameArch = strings.TrimSpace(string(out))
}
archLabel := normalizeArchLabel(unameArch)
if archLabel == "" {
archLabel = normalizeArchLabel(runtime.GOARCH)
}
return VpnToolSystemInfo{
GOOS: runtime.GOOS,
GOARCH: runtime.GOARCH,
UnameArch: unameArch,
ArchLabel: archLabel,
ArchKeywords: archKeywords(archLabel),
}, nil
}
func vpnToolDir(spec vpnToolSpec) string {
return filepath.Join(vpnToolsRoot, spec.Name)
}
func vpnToolVersionsDir(spec vpnToolSpec) string {
return filepath.Join(vpnToolDir(spec), "versions")
}
func vpnToolCurrentDir(spec vpnToolSpec) string {
return filepath.Join(vpnToolDir(spec), "current")
}
func managedBinaryPath(spec vpnToolSpec, binaryName string) string {
return filepath.Join(vpnToolCurrentDir(spec), binaryName)
}
func findExecutablePath(binary string) (string, error) {
if strings.Contains(binary, "/") {
info, err := os.Stat(binary)
if err != nil {
return "", err
}
if info.Mode().IsRegular() || (info.Mode()&os.ModeSymlink) != 0 {
return binary, nil
}
return "", fmt.Errorf("not executable file: %s", binary)
}
return exec.LookPath(binary)
}
func resolveVpnToolBinary(tool, defaultBinary string) string {
spec, err := getVpnToolSpec(tool)
if err != nil {
return defaultBinary
}
managed := managedBinaryPath(spec, defaultBinary)
if _, err := os.Stat(managed); err == nil {
return managed
}
return defaultBinary
}
func detectCommandVersion(binaryPath string, flags [][]string) string {
for _, args := range flags {
cmd := exec.Command(binaryPath, args...)
out, err := cmd.CombinedOutput()
if err != nil && len(out) == 0 {
continue
}
line := extractVersionLine(string(out))
if line != "" {
return line
}
}
return ""
}
func extractVersionLine(output string) string {
normalized := strings.ReplaceAll(output, "\r\n", "\n")
lines := strings.Split(normalized, "\n")
// Prefer explicit version lines to handle tools like vnt-cli
// where the first line is usage text.
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
lower := strings.ToLower(line)
if strings.Contains(lower, "version:") {
return line
}
}
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
lower := strings.ToLower(line)
if strings.Contains(lower, "version") || strings.HasPrefix(lower, "v") {
return line
}
}
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line != "" {
return line
}
}
return ""
}
func listManagedVersions(spec vpnToolSpec) []string {
versionsDir := vpnToolVersionsDir(spec)
entries, err := os.ReadDir(versionsDir)
if err != nil {
return []string{}
}
versions := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
versions = append(versions, e.Name())
}
}
sort.Slice(versions, func(i, j int) bool { return versions[i] > versions[j] })
return versions
}
func currentManagedVersion(spec vpnToolSpec) string {
currentDir := vpnToolCurrentDir(spec)
for _, binary := range spec.Binaries {
p := filepath.Join(currentDir, binary)
target, err := os.Readlink(p)
if err != nil {
continue
}
abs := target
if !filepath.IsAbs(target) {
abs = filepath.Clean(filepath.Join(filepath.Dir(p), target))
}
versionsRoot := vpnToolVersionsDir(spec) + string(os.PathSeparator)
if strings.HasPrefix(abs, versionsRoot) {
rel := strings.TrimPrefix(abs, versionsRoot)
parts := strings.Split(rel, string(os.PathSeparator))
if len(parts) > 0 && parts[0] != "" {
return parts[0]
}
}
}
return ""
}
func initVpnToolInstallTask(tool, version string) {
vpnToolInstallTaskMu.Lock()
defer vpnToolInstallTaskMu.Unlock()
vpnToolInstallTasks[tool] = &VpnToolInstallTask{
Tool: tool,
Running: true,
Progress: 0,
Message: "preparing",
Logs: []string{"Preparing install task..."},
Error: "",
Version: version,
UpdatedAt: time.Now().Unix(),
}
}
func updateVpnToolInstallTask(tool string, progress float64, message string) {
vpnToolInstallTaskMu.Lock()
defer vpnToolInstallTaskMu.Unlock()
task, ok := vpnToolInstallTasks[tool]
if !ok {
return
}
if progress < 0 {
progress = 0
}
if progress > 1 {
progress = 1
}
task.Progress = progress
if strings.TrimSpace(message) != "" {
task.Message = message
}
task.UpdatedAt = time.Now().Unix()
}
func appendVpnToolInstallLog(tool, line string) {
vpnToolInstallTaskMu.Lock()
defer vpnToolInstallTaskMu.Unlock()
task, ok := vpnToolInstallTasks[tool]
if !ok {
return
}
line = strings.TrimSpace(line)
if line == "" {
return
}
task.Logs = append(task.Logs, line)
if len(task.Logs) > 200 {
task.Logs = task.Logs[len(task.Logs)-200:]
}
task.UpdatedAt = time.Now().Unix()
}
func finishVpnToolInstallTask(tool string, err error) {
vpnToolInstallTaskMu.Lock()
defer vpnToolInstallTaskMu.Unlock()
task, ok := vpnToolInstallTasks[tool]
if !ok {
return
}
task.Running = false
task.UpdatedAt = time.Now().Unix()
if err != nil {
task.Error = err.Error()
task.Message = "failed"
task.Logs = append(task.Logs, "Install failed: "+err.Error())
return
}
task.Progress = 1
task.Message = "completed"
task.Error = ""
task.Logs = append(task.Logs, "Install completed.")
}
func rpcGetVpnToolInstallTask(tool string) (VpnToolInstallTask, error) {
spec, err := getVpnToolSpec(tool)
if err != nil {
return VpnToolInstallTask{}, err
}
_ = spec
vpnToolInstallTaskMu.Lock()
defer vpnToolInstallTaskMu.Unlock()
task, ok := vpnToolInstallTasks[tool]
if !ok {
return VpnToolInstallTask{
Tool: tool,
Running: false,
Progress: 0,
Message: "",
Logs: []string{},
Error: "",
Version: "",
UpdatedAt: time.Now().Unix(),
}, nil
}
cp := *task
cp.Logs = append([]string{}, task.Logs...)
return cp, nil
}
func rpcGetVpnToolStatus(tool string) (VpnToolStatus, error) {
spec, err := getVpnToolSpec(tool)
if err != nil {
return VpnToolStatus{}, err
}
status := VpnToolStatus{
Tool: spec.Name,
Installed: false,
Source: "none",
CurrentVersion: currentManagedVersion(spec),
DetectedVersion: "",
ManagedVersions: listManagedVersions(spec),
}
primaryBinary := spec.Binaries[0]
versionBinary := spec.VersionBinary
if strings.TrimSpace(versionBinary) == "" {
versionBinary = primaryBinary
}
managedPrimary := managedBinaryPath(spec, primaryBinary)
managedVersionBinary := managedBinaryPath(spec, versionBinary)
if _, err := os.Stat(managedPrimary); err == nil {
status.Installed = true
status.Source = "managed"
status.DetectedVersion = detectCommandVersion(managedVersionBinary, spec.VersionFlags)
if status.CurrentVersion == "" {
status.CurrentVersion = "managed"
}
return status, nil
}
primaryLookedUp, primaryErr := findExecutablePath(primaryBinary)
if primaryErr == nil {
status.Installed = true
status.Source = "system"
_ = primaryLookedUp
versionLookedUp, versionErr := findExecutablePath(versionBinary)
if versionErr == nil {
status.DetectedVersion = detectCommandVersion(versionLookedUp, spec.VersionFlags)
}
}
return status, nil
}
func rpcListVpnToolReleases(tool string) ([]VpnToolRelease, error) {
spec, err := getVpnToolSpec(tool)
if err != nil {
return nil, err
}
systemInfo, err := rpcGetVpnToolSystemInfo()
if err != nil {
return nil, err
}
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=20", spec.Repo)
req, err := http.NewRequestWithContext(
context.Background(),
http.MethodGet,
apiURL,
nil,
)
if err != nil {
return nil, fmt.Errorf("failed to build release request: %w", err)
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "kvm-vpn-tool-manager")
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch releases: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("fetch releases failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var ghReleases []githubRelease
if err := json.NewDecoder(resp.Body).Decode(&ghReleases); err != nil {
return nil, fmt.Errorf("failed to parse release json: %w", err)
}
keywords := systemInfo.ArchKeywords
results := make([]VpnToolRelease, 0, len(ghReleases))
for _, rel := range ghReleases {
if rel.Draft {
continue
}
if spec.Name == "vnt" {
tag := strings.TrimSpace(rel.TagName)
if tag != vntPinnedVersion && tag != strings.TrimPrefix(vntPinnedVersion, "v") {
continue
}
}
releaseItem := VpnToolRelease{
TagName: strings.TrimSpace(rel.TagName),
Assets: make([]VpnToolReleaseAsset, 0, len(rel.Assets)),
}
for _, asset := range rel.Assets {
nameLower := strings.ToLower(strings.TrimSpace(asset.Name))
isLinux := strings.Contains(nameLower, "linux")
archMatch := false
for _, kw := range keywords {
if strings.Contains(nameLower, strings.ToLower(kw)) {
archMatch = true
break
}
}
if !isLinux {
archMatch = false
}
releaseItem.Assets = append(releaseItem.Assets, VpnToolReleaseAsset{
Name: asset.Name,
URL: strings.TrimSpace(asset.BrowserDownloadURL),
ArchMatch: archMatch,
})
}
if releaseItem.TagName != "" {
results = append(results, releaseItem)
}
}
return results, nil
}
func downloadFileToPath(url, targetPath string, onProgress func(downloaded int64, total int64)) error {
if strings.TrimSpace(url) == "" {
return fmt.Errorf("empty download url")
}
req, err := http.NewRequestWithContext(
context.Background(),
http.MethodGet,
url,
nil,
)
if err != nil {
return fmt.Errorf("failed to create download request: %w", err)
}
req.Header.Set("User-Agent", "kvm-vpn-tool-manager")
client := &http.Client{Timeout: 2 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("download failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
out, err := os.Create(targetPath)
if err != nil {
return fmt.Errorf("failed to create target file: %w", err)
}
defer out.Close()
total := resp.ContentLength
var downloaded int64
buf := make([]byte, 64*1024)
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
written, writeErr := out.Write(buf[:n])
if writeErr != nil {
return fmt.Errorf("failed to write target file: %w", writeErr)
}
if written != n {
return fmt.Errorf("short write while saving downloaded file")
}
downloaded += int64(n)
if onProgress != nil {
onProgress(downloaded, total)
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
return fmt.Errorf("failed to read download stream: %w", readErr)
}
}
return nil
}
func extractFromTarGz(archivePath string, targetBinaryNames []string, outDir string) (map[string]bool, error) {
f, err := os.Open(archivePath)
if err != nil {
return nil, err
}
defer f.Close()
gzReader, err := gzip.NewReader(f)
if err != nil {
return nil, err
}
defer gzReader.Close()
tr := tar.NewReader(gzReader)
need := make(map[string]bool, len(targetBinaryNames))
found := make(map[string]bool, len(targetBinaryNames))
for _, b := range targetBinaryNames {
need[b] = true
found[b] = false
}
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return found, err
}
if hdr.Typeflag != tar.TypeReg {
continue
}
base := filepath.Base(hdr.Name)
if !need[base] {
continue
}
dstPath := filepath.Join(outDir, base)
dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
if err != nil {
return found, err
}
if _, err := io.Copy(dst, tr); err != nil {
dst.Close()
return found, err
}
if err := dst.Close(); err != nil {
return found, err
}
found[base] = true
}
return found, nil
}
func extractFromZip(archivePath string, targetBinaryNames []string, outDir string) (map[string]bool, error) {
zr, err := zip.OpenReader(archivePath)
if err != nil {
return nil, err
}
defer zr.Close()
need := make(map[string]bool, len(targetBinaryNames))
found := make(map[string]bool, len(targetBinaryNames))
for _, b := range targetBinaryNames {
need[b] = true
found[b] = false
}
for _, f := range zr.File {
base := filepath.Base(f.Name)
if !need[base] {
continue
}
src, err := f.Open()
if err != nil {
return found, err
}
dstPath := filepath.Join(outDir, base)
dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
if err != nil {
src.Close()
return found, err
}
if _, err := io.Copy(dst, src); err != nil {
src.Close()
dst.Close()
return found, err
}
src.Close()
if err := dst.Close(); err != nil {
return found, err
}
found[base] = true
}
return found, nil
}
func ensureBinariesInstalled(found map[string]bool, required []string) error {
missing := make([]string, 0)
for _, b := range required {
if !found[b] {
missing = append(missing, b)
}
}
if len(missing) > 0 {
return fmt.Errorf("required binaries are missing in package: %s", strings.Join(missing, ", "))
}
return nil
}
func setupCurrentSymlinks(spec vpnToolSpec, version string) error {
currentDir := vpnToolCurrentDir(spec)
versionDir := filepath.Join(vpnToolVersionsDir(spec), version)
if err := os.MkdirAll(currentDir, 0755); err != nil {
return err
}
for _, b := range spec.Binaries {
currentLink := filepath.Join(currentDir, b)
_ = os.Remove(currentLink)
target := filepath.Join(versionDir, b)
if err := os.Symlink(target, currentLink); err != nil {
return fmt.Errorf("failed to update current binary symlink for %s: %w", b, err)
}
}
return nil
}
func rpcInstallVpnTool(tool, version, assetName, downloadURL string) error {
spec, err := getVpnToolSpec(tool)
if err != nil {
return err
}
version = strings.TrimSpace(version)
if version == "" {
return fmt.Errorf("version is required")
}
if spec.Name == "vnt" && version != vntPinnedVersion && version != strings.TrimPrefix(vntPinnedVersion, "v") {
return fmt.Errorf("vnt install is temporarily pinned to %s", vntPinnedVersion)
}
downloadURL = strings.TrimSpace(downloadURL)
if downloadURL == "" {
return fmt.Errorf("downloadURL is required")
}
versionDir := filepath.Join(vpnToolVersionsDir(spec), version)
appendVpnToolInstallLog(tool, "Preparing version directory...")
updateVpnToolInstallTask(tool, 0.05, "preparing")
if err := os.MkdirAll(versionDir, 0755); err != nil {
return fmt.Errorf("failed to create version directory: %w", err)
}
tmpFile, err := os.CreateTemp("", "vpn-tool-*")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
tmpPath := tmpFile.Name()
tmpFile.Close()
defer os.Remove(tmpPath)
appendVpnToolInstallLog(tool, "Downloading release package...")
if err := downloadFileToPath(downloadURL, tmpPath, func(downloaded int64, total int64) {
if total > 0 {
ratio := float64(downloaded) / float64(total)
updateVpnToolInstallTask(tool, 0.1+ratio*0.65, "downloading")
return
}
// Unknown total size, keep showing progress moving slowly.
updateVpnToolInstallTask(tool, 0.4, "downloading")
}); err != nil {
return err
}
appendVpnToolInstallLog(tool, "Download completed.")
assetLower := strings.ToLower(strings.TrimSpace(assetName))
if assetLower == "" {
assetLower = strings.ToLower(strings.TrimSpace(downloadURL))
}
found := map[string]bool{}
updateVpnToolInstallTask(tool, 0.8, "extracting")
appendVpnToolInstallLog(tool, "Extracting package...")
switch {
case strings.HasSuffix(assetLower, ".tar.gz") || strings.HasSuffix(assetLower, ".tgz"):
found, err = extractFromTarGz(tmpPath, spec.Binaries, versionDir)
if err != nil {
return fmt.Errorf("failed to extract tar.gz: %w", err)
}
case strings.HasSuffix(assetLower, ".zip"):
found, err = extractFromZip(tmpPath, spec.Binaries, versionDir)
if err != nil {
return fmt.Errorf("failed to extract zip: %w", err)
}
default:
if len(spec.Binaries) != 1 {
return fmt.Errorf("single binary install is not supported for %s", spec.Name)
}
dstPath := filepath.Join(versionDir, spec.Binaries[0])
input, err := os.Open(tmpPath)
if err != nil {
return err
}
defer input.Close()
output, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
if err != nil {
return err
}
if _, err := io.Copy(output, input); err != nil {
output.Close()
return err
}
if err := output.Close(); err != nil {
return err
}
found[spec.Binaries[0]] = true
}
appendVpnToolInstallLog(tool, "Validating installed binaries...")
updateVpnToolInstallTask(tool, 0.92, "validating")
if err := ensureBinariesInstalled(found, spec.Binaries); err != nil {
return err
}
appendVpnToolInstallLog(tool, "Switching to installed version...")
updateVpnToolInstallTask(tool, 0.97, "activating")
if err := setupCurrentSymlinks(spec, version); err != nil {
return err
}
updateVpnToolInstallTask(tool, 1, "completed")
return nil
}
func rpcStartVpnToolInstall(tool, version, assetName, downloadURL string) error {
_, err := getVpnToolSpec(tool)
if err != nil {
return err
}
vpnToolInstallTaskMu.Lock()
if task, ok := vpnToolInstallTasks[tool]; ok && task.Running {
vpnToolInstallTaskMu.Unlock()
return fmt.Errorf("install task is already running for %s", tool)
}
vpnToolInstallTaskMu.Unlock()
initVpnToolInstallTask(tool, version)
go func() {
err := rpcInstallVpnTool(tool, version, assetName, downloadURL)
finishVpnToolInstallTask(tool, err)
}()
return nil
}
func rpcUseVpnToolVersion(tool, version string) error {
spec, err := getVpnToolSpec(tool)
if err != nil {
return err
}
version = strings.TrimSpace(version)
if version == "" {
return fmt.Errorf("version is required")
}
versionDir := filepath.Join(vpnToolVersionsDir(spec), version)
info, err := os.Stat(versionDir)
if err != nil {
return fmt.Errorf("version is not installed: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("invalid version directory")
}
for _, b := range spec.Binaries {
if _, err := os.Stat(filepath.Join(versionDir, b)); err != nil {
return fmt.Errorf("binary %s is missing in version %s", b, version)
}
}
return setupCurrentSymlinks(spec, version)
}
func rpcUninstallVpnToolVersion(tool, version string) error {
spec, err := getVpnToolSpec(tool)
if err != nil {
return err
}
version = strings.TrimSpace(version)
if version == "" {
return fmt.Errorf("version is required")
}
versionDir := filepath.Join(vpnToolVersionsDir(spec), version)
if err := os.RemoveAll(versionDir); err != nil {
return fmt.Errorf("failed to uninstall version: %w", err)
}
if currentManagedVersion(spec) == version {
currentDir := vpnToolCurrentDir(spec)
for _, b := range spec.Binaries {
_ = os.Remove(filepath.Join(currentDir, b))
}
}
remaining := listManagedVersions(spec)
if len(remaining) > 0 {
// Automatically switch to latest remaining managed version.
if err := setupCurrentSymlinks(spec, remaining[0]); err != nil {
return err
}
}
return nil
}

673
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -54,6 +54,7 @@
"recharts": "^2.15.3",
"styled-components": "^6.1.19",
"tailwind-merge": "^3.3.0",
"tesseract.js": "^7.0.0",
"usehooks-ts": "^3.1.1",
"validator": "^13.15.0",
"w-touch": "^2.0.0",

View File

@@ -18,6 +18,7 @@ interface ConfirmDialogProps {
onClose: () => void;
title: string;
description: React.ReactNode;
children?: React.ReactNode;
variant?: Variant;
confirmText?: string;
cancelText?: string | null;
@@ -65,6 +66,7 @@ export function ConfirmDialog({
onClose,
title,
description,
children,
variant = "info",
confirmText = "Confirm",
cancelText = "Cancel",
@@ -107,9 +109,10 @@ export function ConfirmDialog({
{description}
</div>
</div>
{children}
</div>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<div className={cx("mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3", isConfirming && "pointer-events-none")}>
<Button
size="LG"
theme={buttonTheme}
@@ -157,10 +160,11 @@ export function ConfirmDialog({
<div className="mt-2 text-sm leading-snug text-slate-600 dark:text-[#ffffff]">
{description}
</div>
{children}
</div>
</div>
<div className="flex justify-end gap-x-2">
<div className={cx("flex justify-end gap-x-2", isConfirming && "pointer-events-none")}>
{cancelText && (
<Button size="SM" theme="light" text={cancelText} onClick={onClose} />
)}

View File

@@ -20,10 +20,12 @@ export function FileUploader({
onBack,
incompleteFileName,
media,
accept,
}: {
onBack: () => void;
incompleteFileName?: string;
media?: string;
accept?: string;
})
{
const { $at }= useReactAt();
@@ -39,9 +41,25 @@ export function FileUploader({
const [send] = useJsonRpc();
const rtcDataChannelRef = useRef<RTCDataChannel | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const xhrRef = useRef<XMLHttpRequest | null>(null);
const validateSelectedFile = (file: File) => {
if (!accept) return null;
const allowedExts = accept
.split(",")
.map(s => s.trim().toLowerCase())
.filter(s => s.startsWith("."));
if (allowedExts.length === 0) return null;
const lowerName = file.name.toLowerCase();
if (allowedExts.some(ext => lowerName.endsWith(ext))) return null;
return $at("Only {{types}} files are supported").replace(
"{{types}}",
allowedExts.join(", "),
);
};
useEffect(() => {
const ref = rtcDataChannelRef.current;
return () => {
@@ -262,6 +280,13 @@ export function FileUploader({
}
setFileError(null);
const validationError = validateSelectedFile(file);
if (validationError) {
setFileError(validationError);
setUploadState("idle");
event.target.value = "";
return;
}
console.log(`File selected: ${file.name}, size: ${file.size} bytes`);
setUploadedFileName(file.name);
setUploadedFileSize(file.size);
@@ -338,7 +363,7 @@ export function FileUploader({
<div
onClick={() => {
if (uploadState === "idle") {
document.getElementById("file-upload")?.click();
fileInputRef.current?.click();
}
}}
className="block select-none"
@@ -369,7 +394,7 @@ export function FileUploader({
<span>{$at("Click here to select {{fileName}} to resume upload").replace("{{fileName}}", formatters.truncateMiddle(incompleteFileName.replace(".incomplete", ""), 30))}</span>
</div>
)
: $at("Click here to upload a new image")
: $at("Click here to upload")
}
</div>
{/*<p className="text-xs leading-none text-slate-700 dark:text-slate-300">*/}
@@ -435,10 +460,11 @@ export function FileUploader({
</div>
</div>
<input
id="file-upload"
type="file"
onChange={handleFileChange}
className="hidden"
ref={fileInputRef}
accept={accept}
/>
{fileError && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{fileError}</p>

View File

@@ -27,7 +27,7 @@ const comboboxVariants = cva({
variants: { size: sizes },
});
type BaseProps = React.ComponentProps<typeof HeadlessCombobox>;
type BaseProps = React.ComponentProps<typeof HeadlessCombobox<ComboboxOption>>;
interface ComboboxProps extends Omit<BaseProps, "displayValue"> {
displayValue: (option: ComboboxOption) => string;

View File

@@ -205,7 +205,8 @@ export function MacroStepCard({
)}
<div className="relative w-full">
<Combobox
onChange={(value: { value: string; label: string }) => {
onChange={value => {
if (!value) return;
onKeySelect(value);
onKeyQueryChange('');
}}

View File

@@ -0,0 +1,458 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useReactAt } from "i18n-auto-extractor/react";
import { motion } from "framer-motion";
import { useSettingsStore, useUiStore, useVideoStore } from "@/hooks/stores";
import Card from "@components/Card";
import { ConfirmDialog } from "@components/ConfirmDialog";
import TextArea from "@components/TextArea";
import notifications from "@/notifications";
import { eventMatchesShortcut } from "@/utils/shortcuts";
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
type OcrStatus = "idle" | "selecting" | "processing" | "result";
type TesseractWorker = {
recognize: (image: HTMLCanvasElement, options?: unknown, output?: unknown) => Promise<{
data: {
text?: string;
blocks?: Array<{
paragraphs: Array<{
lines: Array<{
text: string;
bbox: { x0: number };
words: Array<{ text: string; bbox: { x0: number; x1: number } }>;
}>;
}>;
}>;
};
}>;
terminate: () => Promise<unknown>;
};
async function loadTesseract() {
const { createWorker } = await import("tesseract.js");
return createWorker;
}
let workerPromise: Promise<TesseractWorker> | null = null;
let cleanupTimer: ReturnType<typeof setTimeout> | null = null;
async function initWorker() {
const createWorker = await loadTesseract();
return createWorker("eng", 1) as Promise<TesseractWorker>;
}
async function terminateWorker() {
if (!workerPromise) return;
try {
const worker = await workerPromise;
await worker.terminate();
} catch {
// Ignore termination errors.
}
workerPromise = null;
}
function getWorker() {
if (cleanupTimer) {
clearTimeout(cleanupTimer);
cleanupTimer = null;
}
if (!workerPromise) {
workerPromise = initWorker().catch(err => {
workerPromise = null;
throw err;
});
}
// Auto-terminate worker after inactivity to reduce memory footprint.
cleanupTimer = setTimeout(() => {
void terminateWorker();
cleanupTimer = null;
}, 60_000);
return workerPromise;
}
async function performOcr(canvas: HTMLCanvasElement): Promise<string> {
const worker = await getWorker();
const { data } = await worker.recognize(canvas, {}, { text: true, blocks: true });
const lines = data.blocks?.flatMap(b => b.paragraphs.flatMap(p => p.lines)) ?? [];
if (lines.length === 0) return (data.text || "").trim();
// Estimate character width from OCR words so left indentation is preserved.
let totalCharWidth = 0;
let samples = 0;
for (const line of lines) {
for (const word of line.words) {
const len = word.text.trim().length;
if (len > 0) {
totalCharWidth += (word.bbox.x1 - word.bbox.x0) / len;
samples++;
}
}
}
if (samples === 0) return (data.text || "").trim();
const charWidth = totalCharWidth / samples;
const minX = Math.min(...lines.map(l => l.bbox.x0));
return lines
.map(line => {
const indent = Math.round((line.bbox.x0 - minX) / charWidth);
return " ".repeat(indent) + line.text.trim();
})
.join("\n")
.trim();
}
function captureRegion(videoEl: HTMLVideoElement, rect: Rect): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.width = rect.width;
canvas.height = rect.height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Failed to acquire 2D canvas context");
ctx.drawImage(videoEl, rect.x, rect.y, rect.width, rect.height, 0, 0, rect.width, rect.height);
return canvas;
}
function getVideoDisplayRect(videoElement: HTMLVideoElement, videoWidth: number, videoHeight: number) {
const videoRect = videoElement.getBoundingClientRect();
const elementAspectRatio = videoRect.width / videoRect.height;
const streamAspectRatio = videoWidth / videoHeight;
let effectiveWidth = videoRect.width;
let effectiveHeight = videoRect.height;
let offsetX = 0;
let offsetY = 0;
if (elementAspectRatio > streamAspectRatio) {
effectiveWidth = videoRect.height * streamAspectRatio;
offsetX = (videoRect.width - effectiveWidth) / 2;
} else if (elementAspectRatio < streamAspectRatio) {
effectiveHeight = videoRect.width / streamAspectRatio;
offsetY = (videoRect.height - effectiveHeight) / 2;
}
return { videoRect, effectiveWidth, effectiveHeight, offsetX, offsetY };
}
interface OcrOverlayProps {
videoRef: React.RefObject<HTMLVideoElement>;
containerRef: React.RefObject<HTMLDivElement>;
}
export default function OcrOverlay({ videoRef, containerRef }: OcrOverlayProps) {
const { width: videoWidth, height: videoHeight } = useVideoStore();
const isOcrMode = useUiStore(state => state.isOcrMode);
const setOcrMode = useUiStore(state => state.setOcrMode);
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const ocrShortcutEnabled = useSettingsStore(state => state.ocrShortcutEnabled);
const ocrShortcut = useSettingsStore(state => state.ocrShortcut);
const { $at } = useReactAt();
const mountedRef = useRef(true);
const resultRef = useRef<HTMLTextAreaElement>(null);
const [status, setStatus] = useState<OcrStatus>("idle");
const [selectionStart, setSelectionStart] = useState<{ x: number; y: number } | null>(null);
const [selectionRect, setSelectionRect] = useState<Rect | null>(null);
const [ocrResult, setOcrResult] = useState("");
const [isClosing, setIsClosing] = useState(false);
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!ocrShortcutEnabled) return;
if (!eventMatchesShortcut(e, ocrShortcut)) return;
const activeElement = document.activeElement as HTMLElement | null;
const isEditable =
!!activeElement
&& (activeElement.tagName === "INPUT"
|| activeElement.tagName === "TEXTAREA"
|| activeElement.isContentEditable);
if (isEditable) return;
if (videoWidth === 0 || videoHeight === 0) return;
e.preventDefault();
e.stopPropagation();
setOcrMode(!isOcrMode);
};
document.addEventListener("keydown", handleKeyDown, { capture: true });
return () => document.removeEventListener("keydown", handleKeyDown, { capture: true });
}, [isOcrMode, ocrShortcut, ocrShortcutEnabled, setOcrMode, videoWidth, videoHeight]);
const closeOverlay = useCallback(() => {
if (status === "processing" || status === "result") {
setIsClosing(true);
setSelectionRect(null);
setSelectionStart(null);
setTimeout(() => setOcrMode(false), 200);
return;
}
setOcrMode(false);
}, [setOcrMode, status]);
useEffect(() => {
if (!isOcrMode) {
setStatus("idle");
setSelectionRect(null);
setSelectionStart(null);
setOcrResult("");
setIsClosing(false);
}
}, [isOcrMode]);
useEffect(() => {
if (!isOcrMode) return;
if (status === "processing" || status === "result") {
setDisableVideoFocusTrap(true);
return () => setDisableVideoFocusTrap(false);
}
}, [isOcrMode, setDisableVideoFocusTrap, status]);
useEffect(() => {
if (!isOcrMode) return;
if (status === "processing" || status === "result") return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setOcrMode(false);
}
};
document.addEventListener("keydown", handleKeyDown, { capture: true });
return () => document.removeEventListener("keydown", handleKeyDown, { capture: true });
}, [isOcrMode, setOcrMode, status]);
useEffect(() => {
if (!isOcrMode) return;
if (status !== "result") return;
const handleCopy = () => {
notifications.success($at("Copied"), { duration: 4000 });
closeOverlay();
};
document.addEventListener("copy", handleCopy);
return () => document.removeEventListener("copy", handleCopy);
}, [closeOverlay, isOcrMode, status, $at]);
useEffect(() => {
if (status === "result" && resultRef.current) {
resultRef.current.focus();
resultRef.current.select();
}
}, [status]);
const toVideoCoords = useCallback((clientX: number, clientY: number) => {
const videoElement = videoRef.current;
if (!videoElement) return { x: 0, y: 0 };
const { videoRect, effectiveWidth, effectiveHeight, offsetX, offsetY } = getVideoDisplayRect(
videoElement,
videoWidth,
videoHeight,
);
const relX = clientX - videoRect.left - offsetX;
const relY = clientY - videoRect.top - offsetY;
const scaleX = videoWidth / effectiveWidth;
const scaleY = videoHeight / effectiveHeight;
return {
x: Math.max(0, Math.min(videoWidth, Math.round(relX * scaleX))),
y: Math.max(0, Math.min(videoHeight, Math.round(relY * scaleY))),
};
}, [videoHeight, videoRef, videoWidth]);
const finishSelection = useCallback(async () => {
const videoElement = videoRef.current;
if (status !== "selecting") return;
if (!videoElement || !selectionRect) {
setStatus("idle");
setSelectionStart(null);
setSelectionRect(null);
return;
}
if (selectionRect.width < 10 || selectionRect.height < 10) {
setStatus("idle");
setSelectionStart(null);
setSelectionRect(null);
return;
}
setStatus("processing");
try {
const canvas = captureRegion(videoElement, selectionRect);
const text = await performOcr(canvas);
canvas.width = 0;
canvas.height = 0;
if (!mountedRef.current) return;
if (text) {
setOcrResult(text);
setStatus("result");
} else {
notifications.error($at("No text detected"));
closeOverlay();
}
} catch (error) {
if (!mountedRef.current) return;
console.error("OCR failed:", error);
notifications.error($at("OCR failed"));
closeOverlay();
}
}, [closeOverlay, selectionRect, status, videoRef, $at]);
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (status === "processing" || status === "result") return;
if (!videoRef.current || videoWidth === 0 || videoHeight === 0) return;
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
const coords = toVideoCoords(e.clientX, e.clientY);
setSelectionStart(coords);
setSelectionRect(null);
setStatus("selecting");
}, [status, toVideoCoords, videoHeight, videoRef, videoWidth]);
const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (status !== "selecting" || !selectionStart) return;
e.preventDefault();
e.stopPropagation();
const coords = toVideoCoords(e.clientX, e.clientY);
const x = Math.min(selectionStart.x, coords.x);
const y = Math.min(selectionStart.y, coords.y);
const width = Math.abs(coords.x - selectionStart.x);
const height = Math.abs(coords.y - selectionStart.y);
setSelectionRect({ x, y, width, height });
}, [selectionStart, status, toVideoCoords]);
const onPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (status !== "selecting") return;
e.preventDefault();
e.stopPropagation();
e.currentTarget.releasePointerCapture(e.pointerId);
void finishSelection();
}, [finishSelection, status]);
const selectionStyle = useMemo(() => {
const videoElement = videoRef.current;
const containerElement = containerRef.current;
if (!selectionRect || !videoElement || !containerElement || videoWidth === 0 || videoHeight === 0) {
return undefined;
}
const { videoRect, effectiveWidth, effectiveHeight, offsetX, offsetY } = getVideoDisplayRect(
videoElement,
videoWidth,
videoHeight,
);
const containerRect = containerElement.getBoundingClientRect();
const baseX = videoRect.left - containerRect.left + offsetX;
const baseY = videoRect.top - containerRect.top + offsetY;
return {
left: `${baseX + (selectionRect.x / videoWidth) * effectiveWidth}px`,
top: `${baseY + (selectionRect.y / videoHeight) * effectiveHeight}px`,
width: `${(selectionRect.width / videoWidth) * effectiveWidth}px`,
height: `${(selectionRect.height / videoHeight) * effectiveHeight}px`,
};
}, [containerRef, selectionRect, videoHeight, videoRef, videoWidth]);
if (!isOcrMode) return null;
return (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute inset-0 z-20 touch-none"
style={{ cursor: status === "result" ? "default" : "crosshair" }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<div className="fixed inset-0 bg-black/20" />
{status === "idle" && (
<div className="pointer-events-none absolute inset-x-0 top-4 flex justify-center">
<div className="rounded-md bg-black/70 px-3 py-1.5 text-xs font-medium text-white">
{$at("Drag to select text area")}
</div>
</div>
)}
{selectionRect && selectionStyle && status !== "result" && (
<div
className="absolute border-2 border-dashed border-blue-400 bg-blue-400/10"
style={selectionStyle}
>
{selectionRect.width >= 10 && selectionRect.height >= 10 && (
<Card className="absolute right-0 -bottom-6 w-auto px-1.5 py-0.5 text-[10px] font-medium tabular-nums dark:text-white">
{selectionRect.width} &times; {selectionRect.height}
</Card>
)}
</div>
)}
</motion.div>
<ConfirmDialog
open={(status === "processing" || status === "result") && !isClosing}
onClose={closeOverlay}
title={status === "result" ? $at("Copy text") : $at("Recognizing text...")}
description={status === "result" ? $at("Review the recognized text before copying.") : $at("Please wait while OCR is running.")}
confirmText={$at("Copy text")}
onConfirm={() => {
if (status !== "result") return;
if (navigator.clipboard?.writeText && window.isSecureContext) {
navigator.clipboard.writeText(ocrResult).then(() => {
notifications.success($at("Copied"), { duration: 4000 });
closeOverlay();
}).catch(() => {
if (!resultRef.current) return;
resultRef.current.focus();
resultRef.current.select();
document.execCommand("copy");
});
return;
}
if (!resultRef.current) return;
resultRef.current.focus();
resultRef.current.select();
document.execCommand("copy");
}}
isConfirming={status === "processing"}
>
{status === "processing" ? (
<div className="mt-2 space-y-2">
<div className="h-4 w-full animate-pulse rounded bg-slate-200 dark:bg-slate-700" />
<div className="h-4 w-3/4 animate-pulse rounded bg-slate-200 dark:bg-slate-700" />
<div className="h-4 w-5/6 animate-pulse rounded bg-slate-200 dark:bg-slate-700" />
</div>
) : (
<div className="mt-2">
<TextArea
ref={resultRef}
value={ocrResult}
readOnly
rows={Math.min(10, ocrResult.split("\n").length + 1)}
/>
</div>
)}
</ConfirmDialog>
</>
);
}

View File

@@ -11,6 +11,7 @@ interface PopoverButtonProps {
align?: "left" | "right";
buttonClassName?: string;
panelClassName?: string;
style?: React.CSSProperties;
}
const BottomPopoverButton: React.FC<PopoverButtonProps> = ({
@@ -18,6 +19,7 @@ const BottomPopoverButton: React.FC<PopoverButtonProps> = ({
buttonIconNode,
panelContent,
align = "left",
style,
}) => {
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
return (
@@ -28,7 +30,7 @@ const BottomPopoverButton: React.FC<PopoverButtonProps> = ({
<>
<PopoverButton
as="div"
style={{ display: "flex", justifyContent: "center", alignItems: "center" }}>
style={{ display: "flex", justifyContent: "center", alignItems: "center", ...style }}>
<div
onClick={() => {
setDisableFocusTrap(true);

View File

@@ -13,9 +13,10 @@ export const VideoElement = forwardRef<HTMLVideoElement, VideoElementProps>(
({ onPlaying, style, className }, ref) => {
const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled);
const isVirtualKeyboardEnabled = useHidStore(state => state.isVirtualKeyboardEnabled);
const allowTapToOpenVirtualKeyboard = useHidStore(state => state.allowTapToOpenVirtualKeyboard);
const handleClick = () => {
if (isMobile && !isVirtualKeyboardEnabled) {
if (isMobile && allowTapToOpenVirtualKeyboard && !isVirtualKeyboardEnabled) {
setVirtualKeyboardEnabled(true);
}
};

View File

@@ -84,7 +84,7 @@ export function LoadingConnectionOverlay({ show, text }: LoadingConnectionOverla
<AnimatePresence>
{show && (
<motion.div
className="aspect-video h-full w-full"
className="absolute inset-0 h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0 } }}
@@ -124,7 +124,7 @@ export function ConnectionFailedOverlay({
<AnimatePresence>
{show && (
<motion.div
className="aspect-video h-full w-full"
className="absolute inset-0 h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0 } }}
@@ -215,7 +215,7 @@ export function PeerConnectionDisconnectedOverlay({
<AnimatePresence>
{show && (
<motion.div
className="aspect-video h-full w-full"
className="absolute inset-0 h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0 } }}
@@ -278,9 +278,10 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled);
const isVirtualKeyboardEnabled = useHidStore(state => state.isVirtualKeyboardEnabled);
const allowTapToOpenVirtualKeyboard = useHidStore(state => state.allowTapToOpenVirtualKeyboard);
const handleClick = () => {
if (isMobile && !isVirtualKeyboardEnabled) {
if (isMobile && allowTapToOpenVirtualKeyboard && !isVirtualKeyboardEnabled) {
setVirtualKeyboardEnabled(true);
}
};
@@ -301,7 +302,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
<AnimatePresence>
{show && isNoSignal && (
<motion.div
className="absolute inset-0 aspect-video h-full w-full "
className="absolute inset-0 h-full w-full "
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
@@ -385,7 +386,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
<AnimatePresence>
{show && isOtherError && (
<motion.div
className="absolute inset-0 aspect-video h-full w-full"
className="absolute inset-0 h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
@@ -445,7 +446,7 @@ export function NoAutoplayPermissionsOverlay({
<AnimatePresence>
{show && (
<motion.div
className="absolute inset-0 z-10 aspect-video h-full w-full"
className="absolute inset-0 z-10 h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}

View File

@@ -14,7 +14,8 @@ 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, keyDisplayMap2, keys, modifiers, sKeyDisplayMap } from "@/keyboardMappings";
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
import { keyDisplayMap2, keys, modifiers, sKeyDisplayMap, latchingKeys } from "@/keyboardMappings";
import { dark_bg2_style} from "@/layout/theme_color";
import GoBottomSvg from "@/assets/second/gobottom.svg?react";
@@ -33,6 +34,15 @@ function KeyboardWrapper() {
);
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
const { selectedKeyboard } = useKeyboardLayout();
const keyDisplayMap = useMemo(() => {
return selectedKeyboard.keyDisplayMap;
}, [selectedKeyboard]);
const virtualKeyboardLayout = useMemo(() => {
return selectedKeyboard.virtualKeyboard;
}, [selectedKeyboard]);
const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
@@ -260,6 +270,7 @@ function KeyboardWrapper() {
setIsCapsLockActive(false);
}
sendKeyboardEvent([keys["CapsLock"]], []);
setTimeout(resetKeyboardState, 100);
return;
}
}
@@ -837,24 +848,7 @@ function KeyboardWrapper() {
? [{ class: "modifier-locked", buttons: modifierLockButtons }]
: []
}
layout={{
default: [
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter",
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
shift: [
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
}}
layout={virtualKeyboardLayout.main}
disableButtonHold={true}
syncInstanceInputs={true}
debug={false}
@@ -874,10 +868,7 @@ function KeyboardWrapper() {
layoutName={layoutName}
onKeyPress={onKeyDown}
display={keyDisplayMap}
layout={{
default: ["PrintScreen ScrollLock Pause", "Insert Home Pageup", "Delete End Pagedown"],
shift: ["(PrintScreen) ScrollLock (Pause)", "Insert Home Pageup", "Delete End Pagedown"],
}}
layout={virtualKeyboardLayout.control}
syncInstanceInputs={true}
debug={false}
/>
@@ -902,7 +893,7 @@ function KeyboardWrapper() {
onKeyPress={onKeyDown}
display={keyDisplayMap}
layout={{
default: ["ArrowUp"],
default: [virtualKeyboardLayout.arrows?.default?.[0] || "ArrowUp"],
}}
syncInstanceInputs={true}
debug={false}
@@ -916,7 +907,7 @@ function KeyboardWrapper() {
onKeyPress={onKeyDown}
display={keyDisplayMap}
layout={{
default: ["ArrowLeft ArrowDown ArrowRight"],
default: [virtualKeyboardLayout.arrows?.default?.[1] || "ArrowLeft ArrowDown ArrowRight"],
}}
syncInstanceInputs={true}
debug={false}

View File

@@ -38,8 +38,8 @@ const appendStatToMap = <T extends { timestamp: number }>(
};
// Constants and types
export type AvailableSidebarViews ="ConsoleLogViewer"|"MacroMoreList"|"Fullscreen"|"TerminalTabsMobile"|"SettingsModal"|"ClipboardMobile"|"KeyboardPanel"|"MousePanel"|"SettingsVideo"
|"connection-stats"|"Clipboard"|"PowerControl"|"Macros"|"VirtualMedia"|"SharedFolders"|null;
export type AvailableSidebarViews = "ConsoleLogViewer" | "MacroMoreList" | "Fullscreen" | "TerminalTabsMobile" | "SettingsModal" | "ClipboardMobile" | "KeyboardPanel" | "MousePanel" | "SettingsVideo"
| "connection-stats" | "Clipboard" | "PowerControl" | "Macros" | "VirtualMedia" | "SharedFolders" | "UsbEpModeSelect" | "UsbStatusPanel" | null;
export type AvailableTerminalTypes = "kvm" | "serial" | "none";
export interface User {
@@ -64,6 +64,8 @@ interface UIState {
disableVideoFocusTrap: boolean;
setDisableVideoFocusTrap: (enabled: boolean) => void;
isOcrMode: boolean;
setOcrMode: (enabled: boolean) => void;
isWakeOnLanModalVisible: boolean;
setWakeOnLanModalVisibility: (enabled: boolean) => void;
@@ -91,6 +93,8 @@ export const useUiStore = create<UIState>(set => ({
disableVideoFocusTrap: false,
setDisableVideoFocusTrap: enabled => set({ disableVideoFocusTrap: enabled }),
isOcrMode: false,
setOcrMode: enabled => set({ isOcrMode: enabled }),
isAnimationComplete: false,
setIsAnimationComplete: enabled => set({ isAnimationComplete: enabled }),
@@ -386,6 +390,14 @@ interface SettingsState {
overrideCtrlV: boolean;
setOverrideCtrlV: (enabled: boolean) => void;
pasteShortcutEnabled: boolean;
setPasteShortcutEnabled: (enabled: boolean) => void;
pasteShortcut: string;
setPasteShortcut: (shortcut: string) => void;
ocrShortcutEnabled: boolean;
setOcrShortcutEnabled: (enabled: boolean) => void;
ocrShortcut: string;
setOcrShortcut: (shortcut: string) => void;
// Video enhancement settings
videoSaturation: number;
@@ -455,6 +467,14 @@ export const useSettingsStore = create(
overrideCtrlV: false,
setOverrideCtrlV: enabled => set({ overrideCtrlV: enabled }),
pasteShortcutEnabled: true,
setPasteShortcutEnabled: enabled => set({ pasteShortcutEnabled: enabled }),
pasteShortcut: "Ctrl+V",
setPasteShortcut: shortcut => set({ pasteShortcut: shortcut }),
ocrShortcutEnabled: true,
setOcrShortcutEnabled: enabled => set({ ocrShortcutEnabled: enabled }),
ocrShortcut: "Ctrl+C",
setOcrShortcut: shortcut => set({ ocrShortcut: shortcut }),
// Video enhancement settings with default values (1.0 = normal)
videoSaturation: 1.0,
@@ -566,8 +586,13 @@ export interface HidState {
keyboardLedStateSyncAvailable: boolean;
setKeyboardLedStateSyncAvailable: (available: boolean) => void;
keysDownState?: { modifier: number; keys: number[] };
setKeysDownState: (state: { modifier: number; keys: number[] }) => void;
isVirtualKeyboardEnabled: boolean;
setVirtualKeyboardEnabled: (enabled: boolean) => void;
allowTapToOpenVirtualKeyboard: boolean;
setAllowTapToOpenVirtualKeyboard: (enabled: boolean) => void;
isPasteModeEnabled: boolean;
setPasteModeEnabled: (enabled: boolean) => void;
@@ -622,11 +647,16 @@ export const useHidStore = create<HidState>((set, get) => ({
set({ keyboardLedState });
},
keysDownState: undefined,
setKeysDownState: state => set({ keysDownState: state }),
keyboardLedStateSyncAvailable: false,
setKeyboardLedStateSyncAvailable: available => set({ keyboardLedStateSyncAvailable: available }),
isVirtualKeyboardEnabled: false,
setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }),
allowTapToOpenVirtualKeyboard: true,
setAllowTapToOpenVirtualKeyboard: enabled => set({ allowTapToOpenVirtualKeyboard: enabled }),
isPasteModeEnabled: false,
setPasteModeEnabled: enabled => set({ isPasteModeEnabled: enabled }),
@@ -678,6 +708,7 @@ export interface UpdateState {
appUpdatePending: boolean;
appDownloadProgress: number;
appDownloadSpeedBps: number;
appDownloadFinishedAt: string | null;
appVerificationProgress: number;
@@ -690,6 +721,7 @@ export interface UpdateState {
systemUpdatePending: boolean;
systemDownloadProgress: number;
systemDownloadSpeedBps: number;
systemDownloadFinishedAt: string | null;
systemVerificationProgress: number;
@@ -697,6 +729,13 @@ export interface UpdateState {
systemUpdateProgress: number;
systemUpdatedAt: string | null;
appSignatureMissing: boolean;
systemSignatureMissing: boolean;
appSignatureAbsent: boolean;
appSignatureInvalid: boolean;
appNoPublicKey: boolean;
signatureVerified: boolean;
};
setOtaState: (state: UpdateState["otaState"]) => void;
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
@@ -732,10 +771,12 @@ export const useUpdateStore = create<UpdateState>(set => ({
appUpdatePending: false,
systemUpdatePending: false,
appDownloadProgress: 0,
appDownloadSpeedBps: 0,
appDownloadFinishedAt: null,
appVerificationProgress: 0,
appVerifiedAt: null,
systemDownloadProgress: 0,
systemDownloadSpeedBps: 0,
systemDownloadFinishedAt: null,
systemVerificationProgress: 0,
systemVerifiedAt: null,
@@ -743,6 +784,12 @@ export const useUpdateStore = create<UpdateState>(set => ({
appUpdatedAt: null,
systemUpdateProgress: 0,
systemUpdatedAt: null,
appSignatureMissing: false,
systemSignatureMissing: false,
appSignatureAbsent: false,
appSignatureInvalid: false,
appNoPublicKey: false,
signatureVerified: false,
},
updateDialogHasBeenMinimized: false,
@@ -892,6 +939,9 @@ export interface IPv4StaticConfig {
export interface NetworkSettings {
hostname: string;
domain: string;
http_proxy?: string;
https_proxy?: string;
all_proxy?: string;
ipv4_mode: IPv4Mode;
ipv4_request_address?: string;
ipv4_static?: IPv4StaticConfig;
@@ -1143,3 +1193,15 @@ export interface LogEntry {
message: string;
originalArgs: any[];
}
export type BootStorageType = 'emmc' | 'sd' | 'unknown';
interface BootStorageState {
bootStorageType: BootStorageType;
setBootStorageType: (type: BootStorageType) => void;
}
export const useBootStorageStore = create<BootStorageState>(set => ({
bootStorageType: 'unknown',
setBootStorageType: type => set({ bootStorageType: type }),
}));

View File

@@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
import { useJsonRpc } from "./useJsonRpc";
import { useBootStorageStore, BootStorageType } from "./stores";
export const useBootStorageType = () => {
const [send] = useJsonRpc();
const bootStorageType = useBootStorageStore(state => state.bootStorageType);
const setBootStorageType = useBootStorageStore(state => state.setBootStorageType);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (bootStorageType !== "unknown") {
setLoading(false);
return;
}
send("getBootStorageType", {}, res => {
setLoading(false);
if ("error" in res) {
console.error("Failed to get boot storage type:", res.error);
setBootStorageType("unknown");
return;
}
const { type } = res.result as { type: BootStorageType };
setBootStorageType(type);
});
}, [send, bootStorageType, setBootStorageType]);
return { bootStorageType, loading };
};

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useEffect, useRef } from "react";
import notifications from "@/notifications";
import { useHidStore, useRTCStore, useSettingsStore } from "@/hooks/stores";
@@ -16,6 +16,10 @@ export default function useKeyboard() {
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
const usbState = useHidStore(state => state.usbState);
// Track held keys for keepalive
const heldKeysRef = useRef<Set<number>>(new Set());
const keepaliveIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const sendKeyboardEvent = useCallback(
(keys: number[], modifiers: number[]) => {
if (!forceHttp && rpcDataChannel?.readyState !== "open") return;
@@ -24,6 +28,7 @@ export default function useKeyboard() {
if (usbState !== "configured") return;
const accModifier = modifiers.reduce((acc, val) => acc + val, 0);
// Fallback to JSON-RPC
send("keyboardReport", { keys, modifier: accModifier }, resp => {
if ("error" in resp) {
const msg = (resp.error.data as string) || resp.error.message || "";
@@ -39,10 +44,37 @@ export default function useKeyboard() {
[forceHttp, rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers, isReinitializingGadget, usbState],
);
// Send per-key press/release
const sendKeypress = useCallback(
(key: number, press: boolean) => {
if (isReinitializingGadget || usbState !== "configured") return;
// Legacy: simulate device-side key handling
// This maintains the 6-key buffer on the frontend for legacy compatibility
// For simplicity in migration, we fall back to full state reports
const modifier = press ? 0 : 0; // Simplified - would need proper modifier tracking
sendKeyboardEvent(press ? [key] : [], [modifier]);
},
[isReinitializingGadget, usbState, sendKeyboardEvent]
);
const resetKeyboardState = useCallback(() => {
// Release all held keys
sendKeyboardEvent([], []);
heldKeysRef.current.clear();
if (keepaliveIntervalRef.current) {
clearInterval(keepaliveIntervalRef.current);
keepaliveIntervalRef.current = null;
}
}, [sendKeyboardEvent]);
// Cleanup on unmount
useEffect(() => {
return () => {
resetKeyboardState();
};
}, [resetKeyboardState]);
const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => {
for (const [index, step] of steps.entries()) {
const keyValues = step.keys?.map(key => keys[key]).filter(Boolean) || [];
@@ -66,5 +98,5 @@ export default function useKeyboard() {
}
};
return { sendKeyboardEvent, resetKeyboardState, executeMacro };
return { sendKeyboardEvent, sendKeypress, resetKeyboardState, executeMacro };
}

View File

@@ -0,0 +1,29 @@
import { useMemo } from "react";
import { useSettingsStore } from "@/hooks/stores";
import { keyboards } from "@/keyboardLayouts";
export default function useKeyboardLayout() {
const { keyboardLayout } = useSettingsStore();
const keyboardOptions = useMemo(() => {
return keyboards.map(keyboard => {
return { label: keyboard.name, value: keyboard.isoCode };
});
}, []);
const isoCode = useMemo(() => {
if (keyboardLayout && keyboardLayout.length > 0)
return keyboardLayout.replace("en_US", "en-US");
return "en-US";
}, [keyboardLayout]);
const selectedKeyboard = useMemo(() => {
return (
keyboards.find(keyboard => keyboard.isoCode === isoCode) ??
keyboards.find(keyboard => keyboard.isoCode === "en-US")!
);
}, [isoCode]);
return { keyboardOptions, isoCode, selectedKeyboard };
}

View File

@@ -1,45 +1,85 @@
import { chars as chars_fr_BE, name as name_fr_BE } from "@/keyboardLayouts/fr_BE"
import { chars as chars_cs_CZ, name as name_cs_CZ } from "@/keyboardLayouts/cs_CZ"
import { chars as chars_en_UK, name as name_en_UK } from "@/keyboardLayouts/en_UK"
import { chars as chars_en_US, name as name_en_US } from "@/keyboardLayouts/en_US"
import { chars as chars_fr_FR, name as name_fr_FR } from "@/keyboardLayouts/fr_FR"
import { chars as chars_de_DE, name as name_de_DE } from "@/keyboardLayouts/de_DE"
import { chars as chars_it_IT, name as name_it_IT } from "@/keyboardLayouts/it_IT"
import { chars as chars_nb_NO, name as name_nb_NO } from "@/keyboardLayouts/nb_NO"
import { chars as chars_es_ES, name as name_es_ES } from "@/keyboardLayouts/es_ES"
import { chars as chars_sv_SE, name as name_sv_SE } from "@/keyboardLayouts/sv_SE"
import { chars as chars_fr_CH, name as name_fr_CH } from "@/keyboardLayouts/fr_CH"
import { chars as chars_de_CH, name as name_de_CH } from "@/keyboardLayouts/de_CH"
interface KeyInfo { key: string | number; shift?: boolean, altRight?: boolean }
export type KeyCombo = KeyInfo & { deadKey?: boolean, accentKey?: KeyInfo }
export const layouts: Record<string, string> = {
en_UK: name_en_UK,
en_US: name_en_US,
fr_FR: name_fr_FR,
be_FR: name_fr_BE,
cs_CZ: name_cs_CZ,
de_DE: name_de_DE,
it_IT: name_it_IT,
nb_NO: name_nb_NO,
es_ES: name_es_ES,
sv_SE: name_sv_SE,
fr_CH: name_fr_CH,
de_CH: name_de_CH,
export interface KeyStroke {
modifier: number;
keys: number[];
}
export const chars: Record<string, Record<string, KeyCombo>> = {
be_FR: chars_fr_BE,
cs_CZ: chars_cs_CZ,
en_UK: chars_en_UK,
en_US: chars_en_US,
fr_FR: chars_fr_FR,
de_DE: chars_de_DE,
it_IT: chars_it_IT,
nb_NO: chars_nb_NO,
es_ES: chars_es_ES,
sv_SE: chars_sv_SE,
fr_CH: chars_fr_CH,
de_CH: chars_de_CH,
};
export interface KeyInfo {
key: string | number;
shift?: boolean;
altRight?: boolean;
}
export interface KeyCombo extends KeyInfo {
deadKey?: boolean;
accentKey?: KeyInfo;
}
export interface KeyboardLayout {
isoCode: string;
name: string;
chars: Record<string, KeyCombo>;
modifierDisplayMap: Record<string, string>;
keyDisplayMap: Record<string, string>;
virtualKeyboard: {
main: { default: string[]; shift: string[] };
control?: { default: string[]; shift?: string[] };
arrows?: { default: string[] };
numpad?: {
numlocked: string[];
default: string[];
};
};
}
// Import all layouts
import { cs_CZ } from "./keyboardLayouts/cs_CZ";
import { da_DK } from "./keyboardLayouts/da_DK";
import { de_CH } from "./keyboardLayouts/de_CH";
import { de_DE } from "./keyboardLayouts/de_DE";
import { en_US } from "./keyboardLayouts/en_US";
import { en_UK } from "./keyboardLayouts/en_UK";
import { es_ES } from "./keyboardLayouts/es_ES";
import { fr_BE } from "./keyboardLayouts/fr_BE";
import { fr_CH } from "./keyboardLayouts/fr_CH";
import { fr_FR } from "./keyboardLayouts/fr_FR";
import { hu_HU } from "./keyboardLayouts/hu_HU";
import { it_IT } from "./keyboardLayouts/it_IT";
import { ja_JP } from "./keyboardLayouts/ja_JP";
import { nb_NO } from "./keyboardLayouts/nb_NO";
import { pl_PL } from "./keyboardLayouts/pl_PL";
import { pt_PT } from "./keyboardLayouts/pt_PT";
import { sv_SE } from "./keyboardLayouts/sv_SE";
import { sl_SI } from "./keyboardLayouts/sl_SI";
import { ru_RU } from "./keyboardLayouts/ru_RU";
export const keyboards: KeyboardLayout[] = [
cs_CZ,
da_DK,
de_CH,
de_DE,
en_UK,
en_US,
es_ES,
fr_BE,
fr_CH,
fr_FR,
hu_HU,
it_IT,
ja_JP,
nb_NO,
pl_PL,
pt_PT,
sv_SE,
sl_SI,
ru_RU,
];
// Backward-compatible maps
export const layouts: Record<string, string> = {};
export const chars: Record<string, Record<string, KeyCombo>> = {};
keyboards.forEach(kb => {
const oldCode = kb.isoCode.replace("-", "_");
layouts[oldCode] = kb.name;
chars[oldCode] = kb.chars;
});

View File

@@ -1,6 +1,8 @@
import { KeyCombo } from "../keyboardLayouts"
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
export const name = "Čeština";
const name = "Čeština";
const isoCode = "cs-CZ";
const keyTrema = { key: "Backslash" } // tréma (umlaut), two dots placed above a vowel
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
@@ -97,11 +99,11 @@ export const chars = {
"Ẇ": { key: "KeyW", shift: true, accentKey: keyOverdot },
X: { key: "KeyX", shift: true },
"Ẋ": { key: "KeyX", shift: true, accentKey: keyOverdot },
Y: { key: "KeyY", shift: true },
"Ý": { key: "KeyY", shift: true, accentKey: keyAcute },
"Ẏ": { key: "KeyY", shift: true, accentKey: keyOverdot },
Z: { key: "KeyZ", shift: true },
"Ż": { key: "KeyZ", shift: true, accentKey: keyOverdot },
Y: { key: "KeyZ", shift: true },
"Ý": { key: "KeyZ", shift: true, accentKey: keyAcute },
"Ẏ": { key: "KeyZ", shift: true, accentKey: keyOverdot },
Z: { key: "KeyY", shift: true },
"Ż": { key: "KeyY", shift: true, accentKey: keyOverdot },
a: { key: "KeyA" },
"ä": { key: "KeyA", accentKey: keyTrema },
"â": { key: "KeyA", accentKey: keyHat },
@@ -189,10 +191,10 @@ export const chars = {
x: { key: "KeyX" },
"#": { key: "KeyX", altRight: true },
"ẋ": { key: "KeyX", accentKey: keyOverdot },
y: { key: "KeyY" },
"ẏ": { key: "KeyY", accentKey: keyOverdot },
z: { key: "KeyZ" },
"ż": { key: "KeyZ", accentKey: keyOverdot },
y: { key: "KeyZ" },
"ẏ": { key: "KeyZ", accentKey: keyOverdot },
z: { key: "KeyY" },
"ż": { key: "KeyY", accentKey: keyOverdot },
";": { key: "Backquote" },
"°": { key: "Backquote", shift: true, deadKey: true },
"+": { key: "Digit1" },
@@ -242,3 +244,20 @@ export const chars = {
Enter: { key: "Enter" },
Tab: { key: "Tab" },
} as Record<string, KeyCombo>;
const cs_CZ_keyDisplayMap = {
...keyDisplayMap,
KeyY: "z",
KeyZ: "y",
"(KeyY)": "Z",
"(KeyZ)": "Y",
} as Record<string, string>;
export const cs_CZ: KeyboardLayout = {
isoCode,
name,
chars,
keyDisplayMap: cs_CZ_keyDisplayMap,
modifierDisplayMap,
virtualKeyboard,
};

View File

@@ -0,0 +1,186 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts";
import { en_US } from "./en_US"; // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
export const name = "Dansk";
const isoCode = "da-DK";
const keyTrema = { key: "BracketRight" };
const keyAcute = { key: "Equal", altRight: true };
const keyHat = { key: "BracketRight", shift: true };
const keyGrave = { key: "Equal", shift: true };
const keyTilde = { key: "BracketRight", altRight: true };
export const chars = {
A: { key: "KeyA", shift: true },
Ä: { key: "KeyA", shift: true, accentKey: keyTrema },
Á: { key: "KeyA", shift: true, accentKey: keyAcute },
Â: { key: "KeyA", shift: true, accentKey: keyHat },
À: { key: "KeyA", shift: true, accentKey: keyGrave },
Ã: { key: "KeyA", shift: true, accentKey: keyTilde },
B: { key: "KeyB", shift: true },
C: { key: "KeyC", shift: true },
D: { key: "KeyD", shift: true },
E: { key: "KeyE", shift: true },
Ë: { key: "KeyE", shift: true, accentKey: keyTrema },
É: { key: "KeyE", shift: true, accentKey: keyAcute },
Ê: { key: "KeyE", shift: true, accentKey: keyHat },
È: { key: "KeyE", shift: true, accentKey: keyGrave },
: { key: "KeyE", shift: true, accentKey: keyTilde },
F: { key: "KeyF", shift: true },
G: { key: "KeyG", shift: true },
H: { key: "KeyH", shift: true },
I: { key: "KeyI", shift: true },
Ï: { key: "KeyI", shift: true, accentKey: keyTrema },
Í: { key: "KeyI", shift: true, accentKey: keyAcute },
Î: { key: "KeyI", shift: true, accentKey: keyHat },
Ì: { key: "KeyI", shift: true, accentKey: keyGrave },
Ĩ: { key: "KeyI", shift: true, accentKey: keyTilde },
J: { key: "KeyJ", shift: true },
K: { key: "KeyK", shift: true },
L: { key: "KeyL", shift: true },
M: { key: "KeyM", shift: true },
N: { key: "KeyN", shift: true },
O: { key: "KeyO", shift: true },
Ö: { key: "KeyO", shift: true, accentKey: keyTrema },
Ó: { key: "KeyO", shift: true, accentKey: keyAcute },
Ô: { key: "KeyO", shift: true, accentKey: keyHat },
Ò: { key: "KeyO", shift: true, accentKey: keyGrave },
Õ: { key: "KeyO", shift: true, accentKey: keyTilde },
P: { key: "KeyP", shift: true },
Q: { key: "KeyQ", shift: true },
R: { key: "KeyR", shift: true },
S: { key: "KeyS", shift: true },
T: { key: "KeyT", shift: true },
U: { key: "KeyU", shift: true },
Ü: { key: "KeyU", shift: true, accentKey: keyTrema },
Ú: { key: "KeyU", shift: true, accentKey: keyAcute },
Û: { key: "KeyU", shift: true, accentKey: keyHat },
Ù: { key: "KeyU", shift: true, accentKey: keyGrave },
Ũ: { key: "KeyU", shift: true, accentKey: keyTilde },
V: { key: "KeyV", shift: true },
W: { key: "KeyW", shift: true },
X: { key: "KeyX", shift: true },
Y: { key: "KeyY", shift: true },
Z: { key: "KeyZ", shift: true },
a: { key: "KeyA" },
ä: { key: "KeyA", accentKey: keyTrema },
á: { key: "KeyA", accentKey: keyAcute },
â: { key: "KeyA", accentKey: keyHat },
à: { key: "KeyA", accentKey: keyGrave },
ã: { key: "KeyA", accentKey: keyTilde },
b: { key: "KeyB" },
c: { key: "KeyC" },
d: { key: "KeyD" },
e: { key: "KeyE" },
ë: { key: "KeyE", accentKey: keyTrema },
é: { key: "KeyE", accentKey: keyAcute },
ê: { key: "KeyE", accentKey: keyHat },
è: { key: "KeyE", accentKey: keyGrave },
: { key: "KeyE", accentKey: keyTilde },
"€": { key: "KeyE", altRight: true },
f: { key: "KeyF" },
g: { key: "KeyG" },
h: { key: "KeyH" },
i: { key: "KeyI" },
ï: { key: "KeyI", accentKey: keyTrema },
í: { key: "KeyI", accentKey: keyAcute },
î: { key: "KeyI", accentKey: keyHat },
ì: { key: "KeyI", accentKey: keyGrave },
ĩ: { key: "KeyI", accentKey: keyTilde },
j: { key: "KeyJ" },
k: { key: "KeyK" },
l: { key: "KeyL" },
m: { key: "KeyM" },
n: { key: "KeyN" },
o: { key: "KeyO" },
ö: { key: "KeyO", accentKey: keyTrema },
ó: { key: "KeyO", accentKey: keyAcute },
ô: { key: "KeyO", accentKey: keyHat },
ò: { key: "KeyO", accentKey: keyGrave },
õ: { key: "KeyO", accentKey: keyTilde },
p: { key: "KeyP" },
q: { key: "KeyQ" },
r: { key: "KeyR" },
s: { key: "KeyS" },
t: { key: "KeyT" },
u: { key: "KeyU" },
ü: { key: "KeyU", accentKey: keyTrema },
ú: { key: "KeyU", accentKey: keyAcute },
û: { key: "KeyU", accentKey: keyHat },
ù: { key: "KeyU", accentKey: keyGrave },
ũ: { key: "KeyU", accentKey: keyTilde },
v: { key: "KeyV" },
w: { key: "KeyW" },
x: { key: "KeyX" },
y: { key: "KeyY" }, // <-- corrected
z: { key: "KeyZ" }, // <-- corrected
"½": { key: "Backquote" },
"§": { key: "Backquote", shift: true },
1: { key: "Digit1" },
"!": { key: "Digit1", shift: true },
2: { key: "Digit2" },
'"': { key: "Digit2", shift: true },
"@": { key: "Digit2", altRight: true },
3: { key: "Digit3" },
"#": { key: "Digit3", shift: true },
"£": { key: "Digit3", altRight: true },
4: { key: "Digit4" },
"¤": { key: "Digit4", shift: true },
$: { key: "Digit4", altRight: true },
5: { key: "Digit5" },
"%": { key: "Digit5", shift: true },
6: { key: "Digit6" },
"&": { key: "Digit6", shift: true },
7: { key: "Digit7" },
"/": { key: "Digit7", shift: true },
"{": { key: "Digit7", altRight: true },
8: { key: "Digit8" },
"(": { key: "Digit8", shift: true },
"[": { key: "Digit8", altRight: true },
9: { key: "Digit9" },
")": { key: "Digit9", shift: true },
"]": { key: "Digit9", altRight: true },
0: { key: "Digit0" },
"=": { key: "Digit0", shift: true },
"}": { key: "Digit0", altRight: true },
"+": { key: "Minus" },
"?": { key: "Minus", shift: true },
"\\": { key: "Equal" },
å: { key: "BracketLeft" },
Å: { key: "BracketLeft", shift: true },
ø: { key: "Semicolon" },
Ø: { key: "Semicolon", shift: true },
æ: { key: "Quote" },
Æ: { key: "Quote", shift: true },
"'": { key: "Backslash" },
"*": { key: "Backslash", shift: true },
",": { key: "Comma" },
";": { key: "Comma", shift: true },
".": { key: "Period" },
":": { key: "Period", shift: true },
"-": { key: "Slash" },
_: { key: "Slash", shift: true },
"<": { key: "IntlBackslash" },
">": { key: "IntlBackslash", shift: true },
"~": { key: "BracketRight", deadKey: true, altRight: true },
"^": { key: "BracketRight", deadKey: true, shift: true },
"¨": { key: "BracketRight", deadKey: true },
"|": { key: "Equal", deadKey: true, altRight: true },
"`": { key: "Equal", deadKey: true, shift: true },
"´": { key: "Equal", deadKey: true },
" ": { key: "Space" },
"\n": { key: "Enter" },
Enter: { key: "Enter" },
Tab: { key: "Tab" },
} as Record<string, KeyCombo>;
export const da_DK: KeyboardLayout = {
isoCode: isoCode,
name: name,
chars: chars,
// TODO need to localize these maps and layouts
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard,
};

View File

@@ -1,6 +1,9 @@
import { KeyCombo } from "../keyboardLayouts"
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
export { keyDisplayMap } from "./en_US";
export const name = "Schwiizerdütsch";
const name = "Schwiizerdütsch";
const isoCode = "de-CH";
const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
const keyAcute = { key: "Minus", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
@@ -163,3 +166,20 @@ export const chars = {
Enter: { key: "Enter" },
Tab: { key: "Tab" },
} as Record<string, KeyCombo>;
export const de_CH_keyDisplayMap = {
...keyDisplayMap,
KeyY: "z",
KeyZ: "y",
"(KeyY)": "Z",
"(KeyZ)": "Y",
} as Record<string, string>;
export const de_CH: KeyboardLayout = {
isoCode,
name,
chars,
keyDisplayMap: de_CH_keyDisplayMap,
modifierDisplayMap,
virtualKeyboard,
};

View File

@@ -1,6 +1,8 @@
import { KeyCombo } from "../keyboardLayouts"
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
export const name = "Deutsch";
const name = "Deutsch";
const isoCode = "de-DE";
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
const keyHat = { key: "Backquote" } // accent circonflexe (accent hat), mark ^ placed above the letter
@@ -150,3 +152,20 @@ export const chars = {
Enter: { key: "Enter" },
Tab: { key: "Tab" },
} as Record<string, KeyCombo>;
const de_DE_keyDisplayMap = {
...keyDisplayMap,
KeyY: "z",
KeyZ: "y",
"(KeyY)": "Z",
"(KeyZ)": "Y",
} as Record<string, string>;
export const de_DE: KeyboardLayout = {
isoCode,
name,
chars,
keyDisplayMap: de_DE_keyDisplayMap,
modifierDisplayMap,
virtualKeyboard,
};

View File

@@ -1,6 +1,8 @@
import { KeyCombo } from "../keyboardLayouts"
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
export const name = "English (UK)";
const name = "English (UK)";
const isoCode = "en-GB";
export const chars = {
A: { key: "KeyA", shift: true },
@@ -104,4 +106,13 @@ export const chars = {
"\n": { key: "Enter" },
Enter: { key: "Enter" },
Tab: { key: "Tab" },
} as Record<string, KeyCombo>
} as Record<string, KeyCombo>;
export const en_UK: KeyboardLayout = {
isoCode,
name,
chars,
keyDisplayMap,
modifierDisplayMap,
virtualKeyboard,
};

View File

@@ -1,6 +1,7 @@
import { KeyCombo } from "../keyboardLayouts"
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
export const name = "English (US)";
const name = "English (US)";
const isoCode = "en-US";
export const chars = {
A: { key: "KeyA", shift: true },
@@ -89,25 +90,283 @@ export const chars = {
">": { key: "Period", shift: true },
";": { key: "Semicolon" },
":": { key: "Semicolon", shift: true },
"¶": { key: "Semicolon", altRight: true }, // pilcrow sign
"[": { key: "BracketLeft" },
"{": { key: "BracketLeft", shift: true },
"«": { key: "BracketLeft", altRight: true }, // double left quote sign
"]": { key: "BracketRight" },
"}": { key: "BracketRight", shift: true },
"»": { key: "BracketRight", altRight: true }, // double right quote sign
"\\": { key: "Backslash" },
"|": { key: "Backslash", shift: true },
"¬": { key: "Backslash", altRight: true }, // not sign
"`": { key: "Backquote" },
"~": { key: "Backquote", shift: true },
"§": { key: "IntlBackslash" },
"±": { key: "IntlBackslash", shift: true },
" ": { key: "Space", shift: false },
"\n": { key: "Enter", shift: false },
Enter: { key: "Enter", shift: false },
Tab: { key: "Tab", shift: false },
PrintScreen: { key: "Prt Sc", shift: false },
" ": { key: "Space" },
"\n": { key: "Enter" },
Enter: { key: "Enter" },
Escape: { key: "Escape" },
Tab: { key: "Tab" },
PrintScreen: { key: "Prt Sc" },
SystemRequest: { key: "Prt Sc", shift: true },
ScrollLock: { key: "ScrollLock", shift: false},
Pause: { key: "Pause", shift: false },
ScrollLock: { key: "ScrollLock" },
Pause: { key: "Pause" },
Break: { key: "Pause", shift: true },
Insert: { key: "Insert", shift: false },
Delete: { key: "Delete", shift: false },
} as Record<string, KeyCombo>
Insert: { key: "Insert" },
Delete: { key: "Delete" },
} as Record<string, KeyCombo>;
export const modifierDisplayMap: Record<string, string> = {
ControlLeft: "Left Ctrl",
ControlRight: "Right Ctrl",
ShiftLeft: "Left Shift",
ShiftRight: "Right Shift",
AltLeft: "Left Alt",
AltRight: "Right Alt",
MetaLeft: "Left Meta",
MetaRight: "Right Meta",
AltGr: "AltGr",
} as Record<string, string>;
export const keyDisplayMap: Record<string, string> = {
CtrlAltDelete: "Ctrl + Alt + Delete",
AltMetaEscape: "Alt + Meta + Escape",
CtrlAltBackspace: "Ctrl + Alt + Backspace",
AltGr: "AltGr",
AltLeft: "Alt ⌥",
AltRight: "⌥ Alt",
ArrowDown: "↓",
ArrowLeft: "←",
ArrowRight: "→",
ArrowUp: "↑",
Backspace: "Backspace",
"(Backspace)": "Backspace",
CapsLock: "Caps Lock ⇪",
Clear: "Clear",
ControlLeft: "Ctrl ⌃",
ControlRight: "⌃ Ctrl",
Delete: "Delete ⌦",
End: "End",
Enter: "Enter",
Escape: "Esc",
Home: "Home",
Insert: "Insert",
Menu: "Menu",
MetaLeft: "Meta ⌘",
MetaRight: "⌘ Meta",
PageDown: "PgDn",
PageUp: "PgUp",
ShiftLeft: "Shift ⇧",
ShiftRight: "⇧ Shift",
Space: " ",
Tab: "Tab ⇥",
// Letters
KeyA: "a",
KeyB: "b",
KeyC: "c",
KeyD: "d",
KeyE: "e",
KeyF: "f",
KeyG: "g",
KeyH: "h",
KeyI: "i",
KeyJ: "j",
KeyK: "k",
KeyL: "l",
KeyM: "m",
KeyN: "n",
KeyO: "o",
KeyP: "p",
KeyQ: "q",
KeyR: "r",
KeyS: "s",
KeyT: "t",
KeyU: "u",
KeyV: "v",
KeyW: "w",
KeyX: "x",
KeyY: "y",
KeyZ: "z",
// Capital letters
"(KeyA)": "A",
"(KeyB)": "B",
"(KeyC)": "C",
"(KeyD)": "D",
"(KeyE)": "E",
"(KeyF)": "F",
"(KeyG)": "G",
"(KeyH)": "H",
"(KeyI)": "I",
"(KeyJ)": "J",
"(KeyK)": "K",
"(KeyL)": "L",
"(KeyM)": "M",
"(KeyN)": "N",
"(KeyO)": "O",
"(KeyP)": "P",
"(KeyQ)": "Q",
"(KeyR)": "R",
"(KeyS)": "S",
"(KeyT)": "T",
"(KeyU)": "U",
"(KeyV)": "V",
"(KeyW)": "W",
"(KeyX)": "X",
"(KeyY)": "Y",
"(KeyZ)": "Z",
// Numbers
Digit1: "1",
Digit2: "2",
Digit3: "3",
Digit4: "4",
Digit5: "5",
Digit6: "6",
Digit7: "7",
Digit8: "8",
Digit9: "9",
Digit0: "0",
// Shifted Numbers
"(Digit1)": "!",
"(Digit2)": "@",
"(Digit3)": "#",
"(Digit4)": "$",
"(Digit5)": "%",
"(Digit6)": "^",
"(Digit7)": "&",
"(Digit8)": "*",
"(Digit9)": "(",
"(Digit0)": ")",
// Symbols
Minus: "-",
"(Minus)": "_",
Equal: "=",
"(Equal)": "+",
BracketLeft: "[",
"(BracketLeft)": "{",
BracketRight: "]",
"(BracketRight)": "}",
Backslash: "\\",
"(Backslash)": "|",
Semicolon: ";",
"(Semicolon)": ":",
Quote: "'",
"(Quote)": '"',
Comma: ",",
"(Comma)": "<",
Period: ".",
"(Period)": ">",
Slash: "/",
"(Slash)": "?",
Backquote: "`",
"(Backquote)": "~",
IntlBackslash: "\\",
// Function keys
F1: "F1",
F2: "F2",
F3: "F3",
F4: "F4",
F5: "F5",
F6: "F6",
F7: "F7",
F8: "F8",
F9: "F9",
F10: "F10",
F11: "F11",
F12: "F12",
// Numpad
Numpad0: "Num 0",
Numpad1: "Num 1",
Numpad2: "Num 2",
Numpad3: "Num 3",
Numpad4: "Num 4",
Numpad5: "Num 5",
Numpad6: "Num 6",
Numpad7: "Num 7",
Numpad8: "Num 8",
Numpad9: "Num 9",
NumpadAdd: "Num +",
NumpadSubtract: "Num -",
NumpadMultiply: "Num *",
NumpadDivide: "Num /",
NumpadDecimal: "Num .",
NumpadEqual: "Num =",
NumpadEnter: "Num Enter",
NumpadInsert: "Ins",
NumpadDelete: "Del",
NumLock: "Num Lock",
// Modals
PrintScreen: "Prt Sc",
ScrollLock: "Scr Lk",
Pause: "Pause",
"(PrintScreen)": "Sys Rq",
"(Pause)": "Break",
SystemRequest: "Sys Rq",
Break: "Break",
};
export const virtualKeyboard = {
main: {
default: [
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter",
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
"ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight",
],
shift: [
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
"ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight",
],
},
control: {
default: ["PrintScreen ScrollLock Pause", "Insert Home PageUp", "Delete End PageDown"],
shift: ["(PrintScreen) ScrollLock (Pause)", "Insert Home PageUp", "Delete End PageDown"],
},
arrows: {
default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
},
numpad: {
numlocked: [
"NumLock NumpadDivide NumpadMultiply NumpadSubtract",
"Numpad7 Numpad8 Numpad9 NumpadAdd",
"Numpad4 Numpad5 Numpad6",
"Numpad1 Numpad2 Numpad3 NumpadEnter",
"Numpad0 NumpadDecimal",
],
default: [
"NumLock NumpadDivide NumpadMultiply NumpadSubtract",
"Home ArrowUp PageUp NumpadAdd",
"ArrowLeft Clear ArrowRight",
"End ArrowDown PageDown NumpadEnter",
"NumpadInsert NumpadDelete",
],
},
};
export const en_US: KeyboardLayout = {
isoCode,
name,
chars,
keyDisplayMap,
modifierDisplayMap,
virtualKeyboard,
};

View File

@@ -1,6 +1,8 @@
import { KeyCombo } from "../keyboardLayouts"
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
export const name = "Español";
const name = "Español";
const isoCode = "es-ES";
const keyTrema = { key: "Quote", shift: true } // tréma (umlaut), two dots placed above a vowel
const keyAcute = { key: "Quote" } // accent aigu (acute accent), mark ´ placed above the letter
@@ -166,3 +168,12 @@ export const chars = {
Enter: { key: "Enter" },
Tab: { key: "Tab" },
} as Record<string, KeyCombo>;
export const es_ES: KeyboardLayout = {
isoCode,
name,
chars,
keyDisplayMap,
modifierDisplayMap,
virtualKeyboard,
};

View File

@@ -1,6 +1,8 @@
import { KeyCombo } from "../keyboardLayouts"
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
export const name = "Belgisch Nederlands";
const name = "Belgisch Nederlands";
const isoCode = "fr-BE";
const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
@@ -56,10 +58,10 @@ export const chars = {
"Ù": { key: "KeyU", shift: true, accentKey: keyGrave },
"Ũ": { key: "KeyU", shift: true, accentKey: keyTilde },
V: { key: "KeyV", shift: true },
W: { key: "KeyW", shift: true },
W: { key: "KeyZ", shift: true },
X: { key: "KeyX", shift: true },
Y: { key: "KeyZ", shift: true },
Z: { key: "KeyY", shift: true },
Y: { key: "KeyY", shift: true },
Z: { key: "KeyW", shift: true },
a: { key: "KeyQ" },
"ä": { key: "KeyQ", accentKey: keyTrema },
"â": { key: "KeyQ", accentKey: keyHat },
@@ -104,10 +106,10 @@ export const chars = {
"ú": { key: "KeyU", accentKey: keyAcute },
"ũ": { key: "KeyU", accentKey: keyTilde },
v: { key: "KeyV" },
w: { key: "KeyW" },
w: { key: "KeyZ" },
x: { key: "KeyX" },
y: { key: "KeyZ" },
z: { key: "KeyY" },
y: { key: "KeyY" },
z: { key: "KeyW" },
"²": { key: "Backquote" },
"³": { key: "Backquote", shift: true },
"&": { key: "Digit1" },
@@ -165,3 +167,28 @@ export const chars = {
Enter: { key: "Enter" },
Tab: { key: "Tab" },
} as Record<string, KeyCombo>;
const fr_BE_keyDisplayMap = {
...keyDisplayMap,
KeyA: "q",
KeyQ: "a",
KeyW: "z",
KeyZ: "w",
Semicolon: "m",
KeyM: ",",
"(KeyA)": "Q",
"(KeyQ)": "A",
"(KeyW)": "Z",
"(KeyZ)": "W",
"(Semicolon)": "M",
"(KeyM)": "?",
} as Record<string, string>;
export const fr_BE: KeyboardLayout = {
isoCode,
name,
chars,
keyDisplayMap: fr_BE_keyDisplayMap,
modifierDisplayMap,
virtualKeyboard,
};

View File

@@ -1,8 +1,9 @@
import { KeyCombo } from "../keyboardLayouts"
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
import { chars as chars_de_CH, de_CH_keyDisplayMap } from "./de_CH"
import { modifierDisplayMap, virtualKeyboard } from "./en_US"
import { chars as chars_de_CH } from "./de_CH"
export const name = "Français de Suisse";
const name = "Français de Suisse";
const isoCode = "fr-CH";
export const chars = {
...chars_de_CH,
@@ -13,3 +14,22 @@ export const chars = {
"à": { key: "Quote" },
"ä": { key: "Quote", shift: true },
} as Record<string, KeyCombo>;
const fr_CH_keyDisplayMap = {
...de_CH_keyDisplayMap,
BracketLeft: "è",
"(BracketLeft)": "ü",
Semicolon: "é",
"(Semicolon)": "ö",
Quote: "à",
"(Quote)": "ä",
} as Record<string, string>;
export const fr_CH: KeyboardLayout = {
isoCode,
name,
chars,
keyDisplayMap: fr_CH_keyDisplayMap,
modifierDisplayMap,
virtualKeyboard,
};

View File

@@ -1,6 +1,8 @@
import { KeyCombo } from "../keyboardLayouts"
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
export const name = "Français";
const name = "Français";
const isoCode = "fr-FR";
const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
@@ -137,3 +139,28 @@ export const chars = {
Enter: { key: "Enter" },
Tab: { key: "Tab" },
} as Record<string, KeyCombo>;
const fr_FR_keyDisplayMap = {
...keyDisplayMap,
KeyA: "q",
KeyQ: "a",
KeyW: "z",
KeyZ: "w",
Semicolon: "m",
KeyM: ",",
"(KeyA)": "Q",
"(KeyQ)": "A",
"(KeyW)": "Z",
"(KeyZ)": "W",
"(Semicolon)": "M",
"(KeyM)": "?",
} as Record<string, string>;
export const fr_FR: KeyboardLayout = {
isoCode,
name,
chars,
keyDisplayMap: fr_FR_keyDisplayMap,
modifierDisplayMap,
virtualKeyboard,
};

View File

@@ -0,0 +1,177 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts";
import { en_US } from "./en_US";
const name = "Magyar";
const isoCode = "hu-HU";
const keyAcute: KeyCombo = { key: "Digit9", altRight: true };
const keyDoubleAcute: KeyCombo = { key: "Equal", shift: true };
const keyTrema: KeyCombo = { key: "Equal", altRight: true };
const chars = {
A: { key: "KeyA", shift: true },
Á: { key: "Semicolon", shift: true, accentKey: keyAcute },
B: { key: "KeyB", shift: true },
C: { key: "KeyC", shift: true },
D: { key: "KeyD", shift: true },
E: { key: "KeyE", shift: true },
É: { key: "Quote", shift: true, accentKey: keyAcute },
F: { key: "KeyF", shift: true },
G: { key: "KeyG", shift: true },
H: { key: "KeyH", shift: true },
I: { key: "KeyI", shift: true },
Í: { key: "IntlBackslash", shift: true, accentKey: keyAcute },
J: { key: "KeyJ", shift: true },
K: { key: "KeyK", shift: true },
L: { key: "KeyL", shift: true },
M: { key: "KeyM", shift: true },
N: { key: "KeyN", shift: true },
O: { key: "KeyO", shift: true },
Ó: { key: "BracketLeft", shift: true, accentKey: keyAcute },
Ö: { key: "Minus", shift: true, accentKey: keyTrema },
Ő: { key: "BracketRight", shift: true, accentKey: keyDoubleAcute },
P: { key: "KeyP", shift: true },
Q: { key: "KeyQ", shift: true },
R: { key: "KeyR", shift: true },
S: { key: "KeyS", shift: true },
T: { key: "KeyT", shift: true },
U: { key: "KeyU", shift: true },
Ú: { key: "Backslash", shift: true, accentKey: keyAcute },
Ü: { key: "Equal", shift: true, accentKey: keyTrema },
Ű: { key: "Backquote", shift: true, accentKey: keyDoubleAcute },
V: { key: "KeyV", shift: true },
W: { key: "KeyW", shift: true },
X: { key: "KeyX", shift: true },
Y: { key: "KeyZ", shift: true },
Z: { key: "KeyY", shift: true },
a: { key: "KeyA" },
á: { key: "Semicolon", accentKey: keyAcute },
b: { key: "KeyB" },
c: { key: "KeyC" },
d: { key: "KeyD" },
e: { key: "KeyE" },
é: { key: "Quote", accentKey: keyAcute },
f: { key: "KeyF" },
g: { key: "KeyG" },
h: { key: "KeyH" },
i: { key: "KeyI" },
í: { key: "IntlBackslash", accentKey: keyAcute },
j: { key: "KeyJ" },
k: { key: "KeyK" },
l: { key: "KeyL" },
m: { key: "KeyM" },
n: { key: "KeyN" },
o: { key: "KeyO" },
ó: { key: "BracketLeft", accentKey: keyAcute },
ö: { key: "Minus", accentKey: keyTrema },
ő: { key: "BracketRight", accentKey: keyDoubleAcute },
p: { key: "KeyP" },
q: { key: "KeyQ" },
r: { key: "KeyR" },
s: { key: "KeyS" },
t: { key: "KeyT" },
u: { key: "KeyU" },
ú: { key: "Backslash", accentKey: keyAcute },
ü: { key: "Equal", accentKey: keyTrema },
ű: { key: "Backquote", accentKey: keyDoubleAcute },
v: { key: "KeyV" },
w: { key: "KeyW" },
x: { key: "KeyX" },
y: { key: "KeyZ" },
z: { key: "KeyY" },
// Numbers and top row symbols
0: { key: "Digit0" },
"§": { key: "Digit0", shift: true },
1: { key: "Digit1" },
"'": { key: "Digit1", shift: true },
2: { key: "Digit2" },
'"': { key: "Digit2", shift: true },
3: { key: "Digit3" },
"+": { key: "Digit3", shift: true },
4: { key: "Digit4" },
"!": { key: "Digit4", shift: true },
5: { key: "Digit5" },
"%": { key: "Digit5", shift: true },
6: { key: "Digit6" },
"/": { key: "Digit6", shift: true },
7: { key: "Digit7" },
"=": { key: "Digit7", shift: true },
8: { key: "Digit8" },
"(": { key: "Digit8", shift: true },
9: { key: "Digit9" },
")": { key: "Digit9", shift: true },
// AltGr symbols
"~": { key: "Digit1", altRight: true },
ˇ: { key: "Digit2", altRight: true },
"^": { key: "Digit3", altRight: true },
"˘": { key: "Digit4", altRight: true },
"°": { key: "Digit5", altRight: true },
"˛": { key: "Digit6", altRight: true },
"`": { key: "Digit7", altRight: true },
"˙": { key: "Digit8", altRight: true },
"´": { key: "Digit9", altRight: true },
"˝": { key: "Digit0", altRight: true },
"„": { key: "KeyO", altRight: true },
"\\": { key: "KeyQ", altRight: true },
"|": { key: "KeyW", altRight: true },
"€": { key: "KeyU", altRight: true },
đ: { key: "KeyS", altRight: true },
"[": { key: "KeyF", altRight: true },
"]": { key: "KeyG", altRight: true },
ß: { key: "Semicolon", altRight: true },
$: { key: "Quote", altRight: true },
"¤": { key: "Backquote", altRight: true },
"@": { key: "KeyV", altRight: true },
"{": { key: "KeyB", altRight: true },
"}": { key: "KeyN", altRight: true },
"<": { key: "IntlBackslash", altRight: true },
">": { key: "KeyZ", altRight: true },
"#": { key: "KeyX", altRight: true },
"&": { key: "KeyC", altRight: true },
";": { key: "Comma", altRight: true },
"*": { key: "Period", altRight: true },
"÷": { key: "BracketRight", altRight: true },
"×": { key: "Backslash", altRight: true },
// Punctuation
",": { key: "Comma" },
"?": { key: "Comma", shift: true },
".": { key: "Period" },
":": { key: "Period", shift: true },
"-": { key: "Slash" },
_: { key: "Slash", shift: true },
" ": { key: "Space" },
"\n": { key: "Enter" },
Enter: { key: "Enter" },
Tab: { key: "Tab" },
} as Record<string, KeyCombo>;
const keyDisplayMap = {
...en_US.keyDisplayMap,
Digit0: "0",
Backquote: "ű",
Minus: "ö",
Equal: "ü",
BracketLeft: "ó",
BracketRight: "ő",
Semicolon: "á",
Quote: "é",
Backslash: "ú",
IntlBackslash: "í",
KeyY: "Z",
KeyZ: "Y",
} as Record<string, string>;
export const hu_HU: KeyboardLayout = {
isoCode,
name,
chars,
keyDisplayMap,
modifierDisplayMap: {
...en_US.modifierDisplayMap,
altRight: "AltGr",
},
virtualKeyboard: en_US.virtualKeyboard,
};

View File

@@ -1,6 +1,8 @@
import { KeyCombo } from "../keyboardLayouts"
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
export const name = "Italiano";
const name = "Italiano";
const isoCode = "it-IT";
export const chars = {
A: { key: "KeyA", shift: true },
@@ -111,3 +113,12 @@ export const chars = {
Enter: { key: "Enter" },
Tab: { key: "Tab" },
} as Record<string, KeyCombo>;
export const it_IT: KeyboardLayout = {
isoCode,
name,
chars,
keyDisplayMap,
modifierDisplayMap,
virtualKeyboard,
};

View File

@@ -0,0 +1,124 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts";
import { en_US } from "./en_US";
const name = "Japanese";
const isoCode = "ja-JP";
// NOTE:
// This layout is primarily implemented with primarily targets Windows/Linux in mind on common JIS 106/109 keyboards.
// Across Windows, Linux, and macOS, there are small but important differences in:
// - how backslash ("\\") vs yen ("¥") are produced / interpreted, and
// - how Japanese IME mode switching keys behave (e.g. Henkan/Muhenkan/KatakanaHiragana).
//
// For Windows/Linux friendliness, we intentionally map both "\\" and "¥" to the Yen key,
// since many environments/applications render the Yen key as a backslash.
//
// TODO:
// If macOS-specific behavior is required, consider adding a dedicated macOS JIS layout
// (e.g. ja_JP_mac) and adjust mappings (often mapping "\\" to Backslash instead of Yen),
// plus any IME-key semantics differences as needed.
export const chars = {
...en_US.chars,
'"': { key: "Digit2", shift: true },
"&": { key: "Digit6", shift: true },
"'": { key: "Digit7", shift: true },
"(": { key: "Digit8", shift: true },
")": { key: "Digit9", shift: true },
"=": { key: "Minus", shift: true },
"^": { key: "Equal" },
"~": { key: "Equal", shift: true },
"\\": { key: "Yen" },
"¥": { key: "Yen" },
"|": { key: "Yen", shift: true },
"@": { key: "BracketLeft" },
"`": { key: "BracketLeft", shift: true },
"[": { key: "BracketRight" },
"{": { key: "BracketRight", shift: true },
";": { key: "Semicolon" },
"+": { key: "Semicolon", shift: true },
":": { key: "Quote" },
"*": { key: "Quote", shift: true },
"]": { key: "Backslash" },
"}": { key: "Backslash", shift: true },
_: { key: "KeyRO", shift: true },
} as Record<string, KeyCombo>;
// NOTE:
// We intentionally avoid providing Hiragana glyph labels on keycaps in the UI.
// Only about 5.1% of users typed with Kana input as of 2015; thus Kana legends are
// generally omitted to reduce visual clutter while keeping IME-related keys functional
// (Henkan/Muhenkan/KatakanaHiragana) for users who need them.
// Source: https://ja.wikipedia.org/wiki/%E3%81%8B%E3%81%AA%E5%85%A5%E5%8A%9B#%E3%81%8B%E3%81%AA%E5%85%A5%E5%8A%9B%E3%81%AE%E5%88%A9%E7%94%A8%E7%8A%B6%E6%B3%81
export const keyDisplayMap: Record<string, string> = {
...en_US.keyDisplayMap,
"(Digit2)": '"',
"(Digit6)": "&",
"(Digit7)": "'",
"(Digit8)": "(",
"(Digit9)": ")",
"(Minus)": "=",
Equal: "^",
"(Equal)": "~",
Yen: "¥",
"(Yen)": "|",
KeyRO: "\\",
"(KeyRO)": "_",
Henkan: "変換",
Muhenkan: "無変換",
KatakanaHiragana: "ひらがな",
Backquote: "半角/全角",
"(KatakanaHiragana)": "ローマ字",
BracketLeft: "@",
"(BracketLeft)": "`",
BracketRight: "[",
"(BracketRight)": "{",
Semicolon: ";",
"(Semicolon)": "+",
Quote: ":",
"(Quote)": "*",
Backslash: "]",
"(Backslash)": "}",
ContextMenu: "Menu",
// UI-only notes:
// - Keep a placeholder label for shifted Digit0 to avoid a "missing" keycap in the UI.
// - Use "⏎" to hint at the tall, JIS/ISO-style L-shaped Enter key in the UI,
// while internally representing it with two virtual buttons.
"(Digit0)": " ",
"(Enter)": "⏎",
};
export const virtualKeyboard = {
...en_US.virtualKeyboard,
main: {
default: [
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Yen Backspace",
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Enter",
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Backslash (Enter)",
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash KeyRO ShiftRight",
"ControlLeft MetaLeft AltLeft Muhenkan Space Henkan KatakanaHiragana AltRight MetaRight ContextMenu ControlRight",
],
shift: [
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"Backquote (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Yen) (Backspace)",
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) Enter",
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) (Backslash) (Enter)",
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) (KeyRO) ShiftRight",
"ControlLeft MetaLeft AltLeft Muhenkan Space Henkan (KatakanaHiragana) AltRight MetaRight ContextMenu ControlRight",
],
},
};
export const ja_JP: KeyboardLayout = {
isoCode,
name,
chars,
keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard,
};

View File

@@ -1,6 +1,8 @@
import { KeyCombo } from "../keyboardLayouts"
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
export const name = "Norsk bokmål";
const name = "Norsk bokmål";
const isoCode = "nb-NO";
const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
const keyAcute = { key: "Equal", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
@@ -58,8 +60,8 @@ export const chars = {
V: { key: "KeyV", shift: true },
W: { key: "KeyW", shift: true },
X: { key: "KeyX", shift: true },
Y: { key: "KeyZ", shift: true },
Z: { key: "KeyY", shift: true },
Y: { key: "KeyY", shift: true },
Z: { key: "KeyZ", shift: true },
a: { key: "KeyA" },
"ä": { key: "KeyA", accentKey: keyTrema },
"á": { key: "KeyA", accentKey: keyAcute },
@@ -110,8 +112,8 @@ export const chars = {
v: { key: "KeyV" },
w: { key: "KeyW" },
x: { key: "KeyX" },
y: { key: "KeyZ" },
z: { key: "KeyY" },
y: { key: "KeyY" },
z: { key: "KeyZ" },
"|": { key: "Backquote" },
"§": { key: "Backquote", shift: true },
1: { key: "Digit1" },
@@ -165,3 +167,12 @@ export const chars = {
Enter: { key: "Enter" },
Tab: { key: "Tab" },
} as Record<string, KeyCombo>;
export const nb_NO: KeyboardLayout = {
isoCode,
name,
chars,
keyDisplayMap,
modifierDisplayMap,
virtualKeyboard,
};

View File

@@ -0,0 +1,40 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts";
import { en_US, chars as en_US_chars } from "./en_US";
const name = "Polski";
const isoCode = "pl-PL";
// Polish Programmer layout (kbdpl1): QWERTY + AltGr diacritics, no dead keys
const chars: Record<string, KeyCombo> = {
...en_US_chars,
// lowercase diacritics (AltGr + letter)
ą: { key: "KeyA", altRight: true },
ć: { key: "KeyC", altRight: true },
ę: { key: "KeyE", altRight: true },
ł: { key: "KeyL", altRight: true },
ń: { key: "KeyN", altRight: true },
ó: { key: "KeyO", altRight: true },
ś: { key: "KeyS", altRight: true },
ż: { key: "KeyZ", altRight: true },
ź: { key: "KeyX", altRight: true },
// uppercase diacritics (Shift + AltGr + letter)
Ą: { key: "KeyA", shift: true, altRight: true },
Ć: { key: "KeyC", shift: true, altRight: true },
Ę: { key: "KeyE", shift: true, altRight: true },
Ł: { key: "KeyL", shift: true, altRight: true },
Ń: { key: "KeyN", shift: true, altRight: true },
Ó: { key: "KeyO", shift: true, altRight: true },
Ś: { key: "KeyS", shift: true, altRight: true },
Ż: { key: "KeyZ", shift: true, altRight: true },
Ź: { key: "KeyX", shift: true, altRight: true },
};
export const pl_PL: KeyboardLayout = {
isoCode: isoCode,
name: name,
chars: chars,
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard,
};

View File

@@ -0,0 +1,209 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts";
import { en_US } from "./en_US"; // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
const name = "Português";
const isoCode = "pt-PT";
// Dead keys
const keyAcute: KeyCombo = { key: "BracketRight" }; // ´ (dead) on SC 1B base
const keyGrave: KeyCombo = { key: "BracketRight", shift: true }; // ` (dead) on SC 1B shift
const keyTrema: KeyCombo = { key: "BracketLeft", altRight: true }; // ¨ (dead) on SC 1A AltGr
const keyTilde: KeyCombo = { key: "Backslash" }; // ~ (dead) on SC 2B base
const keyHat: KeyCombo = { key: "Backslash", shift: true }; // ^ (dead) on SC 2B shift
const chars = {
// Uppercase letters
A: { key: "KeyA", shift: true },
Á: { key: "KeyA", shift: true, accentKey: keyAcute },
À: { key: "KeyA", shift: true, accentKey: keyGrave },
Ä: { key: "KeyA", shift: true, accentKey: keyTrema },
Ã: { key: "KeyA", shift: true, accentKey: keyTilde },
Â: { key: "KeyA", shift: true, accentKey: keyHat },
B: { key: "KeyB", shift: true },
C: { key: "KeyC", shift: true },
D: { key: "KeyD", shift: true },
E: { key: "KeyE", shift: true },
É: { key: "KeyE", shift: true, accentKey: keyAcute },
È: { key: "KeyE", shift: true, accentKey: keyGrave },
Ë: { key: "KeyE", shift: true, accentKey: keyTrema },
Ê: { key: "KeyE", shift: true, accentKey: keyHat },
F: { key: "KeyF", shift: true },
G: { key: "KeyG", shift: true },
H: { key: "KeyH", shift: true },
I: { key: "KeyI", shift: true },
Í: { key: "KeyI", shift: true, accentKey: keyAcute },
Ì: { key: "KeyI", shift: true, accentKey: keyGrave },
Ï: { key: "KeyI", shift: true, accentKey: keyTrema },
Î: { key: "KeyI", shift: true, accentKey: keyHat },
J: { key: "KeyJ", shift: true },
K: { key: "KeyK", shift: true },
L: { key: "KeyL", shift: true },
M: { key: "KeyM", shift: true },
N: { key: "KeyN", shift: true },
Ñ: { key: "KeyN", shift: true, accentKey: keyTilde },
O: { key: "KeyO", shift: true },
Ó: { key: "KeyO", shift: true, accentKey: keyAcute },
Ò: { key: "KeyO", shift: true, accentKey: keyGrave },
Ö: { key: "KeyO", shift: true, accentKey: keyTrema },
Õ: { key: "KeyO", shift: true, accentKey: keyTilde },
Ô: { key: "KeyO", shift: true, accentKey: keyHat },
P: { key: "KeyP", shift: true },
Q: { key: "KeyQ", shift: true },
R: { key: "KeyR", shift: true },
S: { key: "KeyS", shift: true },
T: { key: "KeyT", shift: true },
U: { key: "KeyU", shift: true },
Ú: { key: "KeyU", shift: true, accentKey: keyAcute },
Ù: { key: "KeyU", shift: true, accentKey: keyGrave },
Ü: { key: "KeyU", shift: true, accentKey: keyTrema },
Û: { key: "KeyU", shift: true, accentKey: keyHat },
V: { key: "KeyV", shift: true },
W: { key: "KeyW", shift: true },
X: { key: "KeyX", shift: true },
Y: { key: "KeyY", shift: true },
Ý: { key: "KeyY", shift: true, accentKey: keyAcute },
Z: { key: "KeyZ", shift: true },
// Lowercase letters
a: { key: "KeyA" },
á: { key: "KeyA", accentKey: keyAcute },
à: { key: "KeyA", accentKey: keyGrave },
ä: { key: "KeyA", accentKey: keyTrema },
ã: { key: "KeyA", accentKey: keyTilde },
â: { key: "KeyA", accentKey: keyHat },
b: { key: "KeyB" },
c: { key: "KeyC" },
d: { key: "KeyD" },
e: { key: "KeyE" },
é: { key: "KeyE", accentKey: keyAcute },
è: { key: "KeyE", accentKey: keyGrave },
ë: { key: "KeyE", accentKey: keyTrema },
ê: { key: "KeyE", accentKey: keyHat },
"€": { key: "KeyE", altRight: true },
f: { key: "KeyF" },
g: { key: "KeyG" },
h: { key: "KeyH" },
i: { key: "KeyI" },
í: { key: "KeyI", accentKey: keyAcute },
ì: { key: "KeyI", accentKey: keyGrave },
ï: { key: "KeyI", accentKey: keyTrema },
î: { key: "KeyI", accentKey: keyHat },
j: { key: "KeyJ" },
k: { key: "KeyK" },
l: { key: "KeyL" },
m: { key: "KeyM" },
n: { key: "KeyN" },
ñ: { key: "KeyN", accentKey: keyTilde },
o: { key: "KeyO" },
ó: { key: "KeyO", accentKey: keyAcute },
ò: { key: "KeyO", accentKey: keyGrave },
ö: { key: "KeyO", accentKey: keyTrema },
õ: { key: "KeyO", accentKey: keyTilde },
ô: { key: "KeyO", accentKey: keyHat },
p: { key: "KeyP" },
q: { key: "KeyQ" },
r: { key: "KeyR" },
s: { key: "KeyS" },
t: { key: "KeyT" },
u: { key: "KeyU" },
ú: { key: "KeyU", accentKey: keyAcute },
ù: { key: "KeyU", accentKey: keyGrave },
ü: { key: "KeyU", accentKey: keyTrema },
û: { key: "KeyU", accentKey: keyHat },
v: { key: "KeyV" },
w: { key: "KeyW" },
x: { key: "KeyX" },
y: { key: "KeyY" },
ý: { key: "KeyY", accentKey: keyAcute },
ÿ: { key: "KeyY", accentKey: keyTrema },
z: { key: "KeyZ" },
// SC 29 (OEM_5) → Backquote: \ |
"\\": { key: "Backquote" },
"|": { key: "Backquote", shift: true },
// Number row
1: { key: "Digit1" },
"!": { key: "Digit1", shift: true },
2: { key: "Digit2" },
'"': { key: "Digit2", shift: true },
"@": { key: "Digit2", altRight: true },
3: { key: "Digit3" },
"#": { key: "Digit3", shift: true },
"£": { key: "Digit3", altRight: true },
4: { key: "Digit4" },
$: { key: "Digit4", shift: true },
"§": { key: "Digit4", altRight: true },
5: { key: "Digit5" },
"%": { key: "Digit5", shift: true },
6: { key: "Digit6" },
"&": { key: "Digit6", shift: true },
7: { key: "Digit7" },
"/": { key: "Digit7", shift: true },
"{": { key: "Digit7", altRight: true },
8: { key: "Digit8" },
"(": { key: "Digit8", shift: true },
"[": { key: "Digit8", altRight: true },
9: { key: "Digit9" },
")": { key: "Digit9", shift: true },
"]": { key: "Digit9", altRight: true },
0: { key: "Digit0" },
"=": { key: "Digit0", shift: true },
"}": { key: "Digit0", altRight: true },
// SC 0C (OEM_4) → Minus: ' ?
"'": { key: "Minus" },
"?": { key: "Minus", shift: true },
// SC 0D (OEM_6) → Equal: « »
"«": { key: "Equal" },
"»": { key: "Equal", shift: true },
// SC 1A (OEM_PLUS) → BracketLeft: + * ¨(dead)
"+": { key: "BracketLeft" },
"*": { key: "BracketLeft", shift: true },
"¨": { key: "BracketLeft", altRight: true, deadKey: true },
// SC 1B (OEM_1) → BracketRight: ´(dead) `(dead)
"´": { key: "BracketRight", deadKey: true },
"`": { key: "BracketRight", shift: true, deadKey: true },
// SC 27 (OEM_3) → Semicolon: ç Ç
ç: { key: "Semicolon" },
Ç: { key: "Semicolon", shift: true },
// SC 28 (OEM_7) → Quote: º ª
º: { key: "Quote" },
ª: { key: "Quote", shift: true },
// SC 2B (OEM_2) → Backslash: ~(dead) ^(dead)
"~": { key: "Backslash", deadKey: true },
"^": { key: "Backslash", shift: true, deadKey: true },
// SC 33-35: Comma, Period, Slash
",": { key: "Comma" },
";": { key: "Comma", shift: true },
".": { key: "Period" },
":": { key: "Period", shift: true },
"-": { key: "Slash" },
_: { key: "Slash", shift: true },
// SC 56 (OEM_102) → IntlBackslash: < >
"<": { key: "IntlBackslash" },
">": { key: "IntlBackslash", shift: true },
" ": { key: "Space" },
"\n": { key: "Enter" },
Enter: { key: "Enter" },
Tab: { key: "Tab" },
} as Record<string, KeyCombo>;
export const pt_PT: KeyboardLayout = {
isoCode: isoCode,
name: name,
chars: chars,
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard,
};

View File

@@ -0,0 +1,171 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts";
import { en_US } from "./en_US";
const name = "Русская";
const isoCode = "ru-RU";
export const chars = {
...en_US.chars,
А: { key: "KeyF", shift: true },
Б: { key: "Comma", shift: true },
В: { key: "KeyD", shift: true },
Г: { key: "KeyU", shift: true },
Д: { key: "KeyL", shift: true },
Е: { key: "KeyT", shift: true },
Ё: { key: "Backquote", shift: true },
Ж: { key: "Semicolon", shift: true },
З: { key: "KeyP", shift: true },
И: { key: "KeyB", shift: true },
Й: { key: "KeyQ", shift: true },
К: { key: "KeyR", shift: true },
Л: { key: "KeyK", shift: true },
М: { key: "KeyV", shift: true },
Н: { key: "KeyY", shift: true },
О: { key: "KeyJ", shift: true },
П: { key: "KeyG", shift: true },
Р: { key: "KeyH", shift: true },
С: { key: "KeyC", shift: true },
Т: { key: "KeyN", shift: true },
У: { key: "KeyE", shift: true },
Ф: { key: "KeyA", shift: true },
Х: { key: "BracketLeft", shift: true },
Ц: { key: "KeyW", shift: true },
Ч: { key: "KeyX", shift: true },
Ш: { key: "KeyI", shift: true },
Щ: { key: "KeyO", shift: true },
Ъ: { key: "BracketRight", shift: true },
Ы: { key: "KeyS", shift: true },
Ь: { key: "KeyM", shift: true },
Э: { key: "Quote", shift: true },
Ю: { key: "Period", shift: true },
Я: { key: "KeyZ", shift: true },
а: { key: "KeyF" },
б: { key: "Comma" },
в: { key: "KeyD" },
г: { key: "KeyU" },
д: { key: "KeyL" },
е: { key: "KeyT" },
ё: { key: "Backquote" },
ж: { key: "Semicolon" },
з: { key: "KeyP" },
и: { key: "KeyB" },
й: { key: "KeyQ" },
к: { key: "KeyR" },
л: { key: "KeyK" },
м: { key: "KeyV" },
н: { key: "KeyY" },
о: { key: "KeyJ" },
п: { key: "KeyG" },
р: { key: "KeyH" },
с: { key: "KeyC" },
т: { key: "KeyN" },
у: { key: "KeyE" },
ф: { key: "KeyA" },
х: { key: "BracketLeft" },
ц: { key: "KeyW" },
ч: { key: "KeyX" },
ш: { key: "KeyI" },
щ: { key: "KeyO" },
ъ: { key: "BracketRight" },
ы: { key: "KeyS" },
ь: { key: "KeyM" },
э: { key: "Quote" },
ю: { key: "Period" },
я: { key: "KeyZ" },
'"': { key: "Digit2", shift: true },
"№": { key: "Digit3", shift: true },
";": { key: "Digit4", shift: true },
":": { key: "Digit6", shift: true },
"?": { key: "Digit7", shift: true },
".": { key: "Slash" },
",": { key: "Slash", shift: true },
} as Record<string, KeyCombo>;
export const keyDisplayMap = {
...en_US.keyDisplayMap,
KeyF: "а",
Comma: "б",
KeyD: "в",
KeyU: "г",
KeyL: "д",
KeyT: "е",
Backquote: "ё",
Semicolon: "ж",
KeyP: "з",
KeyB: "и",
KeyQ: "й",
KeyR: "к",
KeyK: "л",
KeyV: "м",
KeyY: "н",
KeyJ: "о",
KeyG: "п",
KeyH: "р",
KeyC: "с",
KeyN: "т",
KeyE: "у",
KeyA: "ф",
BracketLeft: "х",
KeyW: "ц",
KeyX: "ч",
KeyI: "ш",
KeyO: "щ",
BracketRight: "ъ",
KeyS: "ы",
KeyM: "ь",
Quote: "э",
Period: "ю",
KeyZ: "я",
Slash: ".",
"(KeyF)": "А",
"(Comma)": "Б",
"(KeyD)": "В",
"(KeyU)": "Г",
"(KeyL)": "Д",
"(KeyT)": "Е",
"(Backquote)": "Ё",
"(Semicolon)": "Ж",
"(KeyP)": "З",
"(KeyB)": "И",
"(KeyQ)": "Й",
"(KeyR)": "К",
"(KeyK)": "Л",
"(KeyV)": "М",
"(KeyY)": "Н",
"(KeyJ)": "О",
"(KeyG)": "П",
"(KeyH)": "Р",
"(KeyC)": "С",
"(KeyN)": "Т",
"(KeyE)": "У",
"(KeyA)": "Ф",
"(BracketLeft)": "Х",
"(KeyW)": "Ц",
"(KeyX)": "Ч",
"(KeyI)": "Ш",
"(KeyO)": "Щ",
"(BracketRight)": "Ъ",
"(KeyS)": "Ы",
"(KeyM)": "Ь",
"(Quote)": "Э",
"(Period)": "Ю",
"(KeyZ)": "Я",
"(Digit2)": '"',
"(Digit3)": "№",
"(Digit4)": ";",
"(Digit6)": ":",
"(Digit7)": "?",
"(Slash)": ",",
};
export const modifierDisplayMap = en_US.modifierDisplayMap;
export const virtualKeyboard = en_US.virtualKeyboard;
export const ru_RU: KeyboardLayout = {
isoCode,
name,
chars,
keyDisplayMap,
modifierDisplayMap,
virtualKeyboard,
};

View File

@@ -0,0 +1,164 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts";
import { en_US } from "./en_US"; // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
const name = "Slovenian";
const isoCode = "sl-SI";
export const chars = {
A: { key: "KeyA", shift: true },
B: { key: "KeyB", shift: true },
C: { key: "KeyC", shift: true },
Č: { key: "Semicolon", shift: true },
Ć: { key: "Quote", shift: true },
D: { key: "KeyD", shift: true },
Đ: { key: "BracketRight", shift: true },
E: { key: "KeyE", shift: true },
F: { key: "KeyF", shift: true },
G: { key: "KeyG", shift: true },
H: { key: "KeyH", shift: true },
I: { key: "KeyI", shift: true },
J: { key: "KeyJ", shift: true },
K: { key: "KeyK", shift: true },
L: { key: "KeyL", shift: true },
M: { key: "KeyM", shift: true },
N: { key: "KeyN", shift: true },
O: { key: "KeyO", shift: true },
P: { key: "KeyP", shift: true },
Q: { key: "KeyQ", shift: true },
R: { key: "KeyR", shift: true },
S: { key: "KeyS", shift: true },
Š: { key: "BracketLeft", shift: true },
T: { key: "KeyT", shift: true },
U: { key: "KeyU", shift: true },
V: { key: "KeyV", shift: true },
W: { key: "KeyW", shift: true },
X: { key: "KeyX", shift: true },
Y: { key: "KeyZ", shift: true },
Z: { key: "KeyY", shift: true },
Ž: { key: "Backslash", shift: true },
a: { key: "KeyA" },
b: { key: "KeyB" },
c: { key: "KeyC" },
č: { key: "Semicolon" },
ć: { key: "Quote" },
d: { key: "KeyD" },
đ: { key: "BracketRight" },
e: { key: "KeyE" },
f: { key: "KeyF" },
g: { key: "KeyG" },
h: { key: "KeyH" },
i: { key: "KeyI" },
j: { key: "KeyJ" },
k: { key: "KeyK" },
l: { key: "KeyL" },
m: { key: "KeyM" },
n: { key: "KeyN" },
o: { key: "KeyO" },
p: { key: "KeyP" },
q: { key: "KeyQ" },
r: { key: "KeyR" },
s: { key: "KeyS" },
š: { key: "BracketLeft" },
t: { key: "KeyT" },
u: { key: "KeyU" },
v: { key: "KeyV" },
w: { key: "KeyW" },
x: { key: "KeyX" },
y: { key: "KeyZ" },
z: { key: "KeyY" },
ž: { key: "Backslash" },
1: { key: "Digit1" },
"!": { key: "Digit1", shift: true },
2: { key: "Digit2" },
'"': { key: "Digit2", shift: true },
3: { key: "Digit3" },
"#": { key: "Digit3", shift: true },
4: { key: "Digit4" },
$: { key: "Digit4", shift: true },
5: { key: "Digit5" },
"%": { key: "Digit5", shift: true },
6: { key: "Digit6" },
"&": { key: "Digit6", shift: true },
7: { key: "Digit7" },
"/": { key: "Digit7", shift: true },
8: { key: "Digit8" },
"(": { key: "Digit8", shift: true },
9: { key: "Digit9" },
")": { key: "Digit9", shift: true },
0: { key: "Digit0" },
"=": { key: "Digit0", shift: true },
"'": { key: "Minus" },
"?": { key: "Minus", shift: true },
"+": { key: "Equal" },
"*": { key: "Equal", shift: true },
"<": { key: "IntlBackslash" },
">": { key: "IntlBackslash", shift: true },
",": { key: "Comma" },
";": { key: "Comma", shift: true },
".": { key: "Period" },
":": { key: "Period", shift: true },
"-": { key: "Slash" },
_: { key: "Slash", shift: true },
"~": { key: "Digit1", shift: true },
ˇ: { key: "Digit2", shift: true },
"^": { key: "Digit3", shift: true },
"˘": { key: "Digit4", shift: true },
"°": { key: "Digit5", shift: true },
"˛": { key: "Digit6", shift: true },
"`": { key: "Digit7", shift: true },
"˙": { key: "Digit8", shift: true },
"´": { key: "Digit9", shift: true },
"˝": { key: "Digit0", shift: true },
"¨": { key: "Minus", shift: true },
"¸": { key: "Equal", shift: true },
"\\": { key: "KeyQ", AltGr: true },
"|": { key: "KeyW", AltGr: true },
"€": { key: "KeyE", AltGr: true },
"÷": { key: "BracketLeft", AltGr: true },
"×": { key: "BracketRight", AltGr: true },
"[": { key: "KeyF", AltGr: true },
"]": { key: "KeyG", AltGr: true },
ł: { key: "KeyK", AltGr: true },
Ł: { key: "KeyL", AltGr: true },
ß: { key: "Quote", AltGr: true },
"¤": { key: "Backslash", AltGr: true },
"@": { key: "KeyV", AltGr: true },
"{": { key: "KeyB", AltGr: true },
"}": { key: "KeyN", AltGr: true },
"§": { key: "KeyM", AltGr: true },
// "<": { key: "Comma", AltGr: true }, // Can be typed in two different locations (`IntlBackslash`)
// ">": { key: "Period", AltGr: true }, // Can be typed in two different locations (`IntlBackslash+Shift`)
" ": { key: "Space" },
"\n": { key: "Enter" },
Enter: { key: "Enter" },
Escape: { key: "Escape" },
Tab: { key: "Tab" },
PrintScreen: { key: "Prt Sc" },
SystemRequest: { key: "Prt Sc", shift: true },
ScrollLock: { key: "ScrollLock" },
Pause: { key: "Pause" },
Break: { key: "Pause", shift: true },
Insert: { key: "Insert" },
Delete: { key: "Delete" },
} as Record<string, KeyCombo>;
const sl_SI_keyDisplayMap = {
...en_US.keyDisplayMap,
KeyY: "z",
KeyZ: "y",
"(KeyY)": "Z",
"(KeyZ)": "Y",
} as Record<string, string>;
export const sl_SI: KeyboardLayout = {
isoCode: isoCode,
name: name,
chars: chars,
keyDisplayMap: sl_SI_keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard,
};

View File

@@ -1,6 +1,8 @@
import { KeyCombo } from "../keyboardLayouts"
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
export const name = "Svenska";
const name = "Svenska";
const isoCode = "sv-SE";
const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
@@ -162,3 +164,12 @@ export const chars = {
Enter: { key: "Enter" },
Tab: { key: "Tab" },
} as Record<string, KeyCombo>;
export const sv_SE: KeyboardLayout = {
isoCode,
name,
chars,
keyDisplayMap,
modifierDisplayMap,
virtualKeyboard,
};

View File

@@ -105,6 +105,11 @@ export const keys = {
Space: 0x2c,
SystemRequest: 0x9a,
Tab: 0x2b,
Yen: 0x89,
KeyRO: 0x87,
Henkan: 0x8a,
Muhenkan: 0x8b,
KatakanaHiragana: 0x88,
} as Record<string, number>;
export const modifiers = {
@@ -228,6 +233,17 @@ export const keyDisplayMap: Record<string, string> = {
};
export const latchingKeys = ["CapsLock", "ScrollLock", "NumLock", "MetaLeft", "MetaRight", "Compose", "Kana"];
export function decodeModifiers(modifier: number) {
return {
isShiftActive: (modifier & (modifiers.ShiftLeft | modifiers.ShiftRight)) !== 0,
isControlActive: (modifier & (modifiers.ControlLeft | modifiers.ControlRight)) !== 0,
isAltActive: (modifier & (modifiers.AltLeft | modifiers.AltRight)) !== 0,
isMetaActive: (modifier & (modifiers.MetaLeft | modifiers.MetaRight)) !== 0,
};
}
export const keyDisplayMap2: Record<string, string> = {
...keyDisplayMap,
...{

View File

@@ -6,7 +6,7 @@ import { CloseOutlined } from '@ant-design/icons';
import { useReactAt } from "i18n-auto-extractor/react";
import ScrollThrottlingSelect, { Option } from "@components/ScrollThrottlingSelect";
import { layouts } from "@/keyboardLayouts";
import { layouts, keyboards } from "@/keyboardLayouts";
import { KeyboardLedSync, useSettingsStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
@@ -24,11 +24,21 @@ const KeyboardPanel: React.FC = () => {
const [layoutOptions, setLayoutOptions] = useState<Option[]>();
const [maxShowCount, setMaxShowCount] = useState(3);
const layoutAbbrevMap = useMemo(() => {
const map: Record<string, string> = {};
keyboards.forEach(kb => {
const oldCode = kb.isoCode.replace("-", "_");
map[oldCode] = oldCode;
});
return map;
}, []);
useEffect(() => {
const curLayoutOptions = (() => {
const options = Object.entries(layouts).map(([code, language]) => ({
value: code,
label: language,
label: `${language} (${layoutAbbrevMap[code] || code})`,
}));
const currentLayout = keyboardLayout ?? "";
@@ -47,7 +57,7 @@ const KeyboardPanel: React.FC = () => {
return options;
})();
setLayoutOptions(curLayoutOptions);
}, [layouts, keyboardLayout]);
}, [layouts, keyboardLayout, layoutAbbrevMap]);
const safeKeyboardLayout = useMemo(() => {
if (keyboardLayout && keyboardLayout.length > 0)

View File

@@ -0,0 +1,79 @@
import React, { useCallback } from "react";
import { useReactAt } from "i18n-auto-extractor/react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
import { dark_bg2_style, dark_bd_style, dark_line_style, dark_bg_style_fun } from "@/layout/theme_color";
import { useThemeSettings } from "@routes/login_page/useLocalAuth";
import { isMobile } from "react-device-detect";
const UsbStatusPanel: React.FC = () => {
const { $at } = useReactAt();
const { isDark } = useThemeSettings();
const [send] = useJsonRpc();
const handleReinitializeUsbGadget = useCallback(() => {
send("reinitializeUsbGadget", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to reinitialize USB gadget: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("USB Gadget reinitialized successfully");
});
}, [send]);
if (isMobile) {
return (
<div className={`w-full h-full flex flex-col ${dark_bg_style_fun(isDark)} p-4`}>
<div className={`flex flex-col w-full mx-auto ${isDark ? 'text-white' : 'text-black'}`}>
<div
className={`
flex items-center justify-between py-4 w-full
cursor-pointer transition-all duration-200 ease-in-out
${isDark ? 'text-white' : 'text-black'}
`}
onClick={handleReinitializeUsbGadget}
>
<span className="font-normal tracking-[0.5px]">
{$at("Reinitialize USB Gadget")}
</span>
</div>
</div>
</div>
);
}
return (
<div
style={{ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)' }}
className={`p-1.5 w-[200px] rounded font-sans ${dark_bg2_style} border ${dark_bd_style}`}
>
<div className="flex flex-col">
<div
style={{
padding: '8px 12px',
cursor: 'pointer',
backgroundColor: 'transparent',
color: isDark ? 'rgba(255, 255, 255, 0.85)' : 'rgba(0, 0, 0, 0.85)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderRadius: '4px',
}}
onClick={handleReinitializeUsbGadget}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.04)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
>
<span style={{ fontSize: "12px" }}>{$at("Reinitialize USB Gadget")}</span>
</div>
</div>
</div>
);
};
export default UsbStatusPanel;

View File

@@ -22,6 +22,8 @@ import { useVpnStore, useLocalAuthModalStore } from "@/hooks/stores";
import { LogDialog } from "@components/LogDialog";
import { Dialog } from "@/layout/components_setting/access/auth";
import AutoHeight from "@components/AutoHeight";
import FirewallSettings from "./FirewallSettings";
import WebRtcServersSettings from "./WebRtcServers";
export interface TailScaleResponse {
state: string;
@@ -69,6 +71,47 @@ export interface CloudflaredRunningResponse {
running: boolean;
}
type ManagedVpnTool = "frpc" | "easytier" | "vnt" | "cloudflared";
export interface VpnToolSystemInfo {
goos: string;
goarch: string;
uname_arch: string;
arch_label: string;
arch_keywords: string[];
}
export interface VpnToolStatus {
tool: string;
installed: boolean;
source: string;
current_version: string;
detected_version: string;
managed_versions: string[];
}
export interface VpnToolReleaseAsset {
name: string;
url: string;
arch_match: boolean;
}
export interface VpnToolRelease {
tag_name: string;
assets: VpnToolReleaseAsset[];
}
export interface VpnToolInstallTask {
tool: string;
running: boolean;
progress: number;
message: string;
logs: string[];
error: string;
version: string;
updated_at: number;
}
export interface WireguardStatus {
running: boolean;
}
@@ -196,6 +239,20 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
const [cloudflaredLog, setCloudflaredLog] = useState<string>("");
const [showCloudflaredLogModal, setShowCloudflaredLogModal] = useState(false);
const [vpnToolSystemInfo, setVpnToolSystemInfo] = useState<VpnToolSystemInfo | null>(null);
const [vpnToolStatusMap, setVpnToolStatusMap] = useState<Record<string, VpnToolStatus>>({});
const [vpnToolReleasesMap, setVpnToolReleasesMap] = useState<Record<string, VpnToolRelease[]>>({});
const [vpnToolSelectedVersionMap, setVpnToolSelectedVersionMap] = useState<Record<string, string>>({});
const [vpnToolSelectedAssetMap, setVpnToolSelectedAssetMap] = useState<Record<string, string>>({});
const [vpnToolBusyMap, setVpnToolBusyMap] = useState<Record<string, boolean>>({});
const [vpnToolInstallTaskMap, setVpnToolInstallTaskMap] = useState<Record<string, VpnToolInstallTask>>({});
const [vpnToolInstallPanelOpenMap, setVpnToolInstallPanelOpenMap] = useState<Record<string, boolean>>({
frpc: false,
easytier: false,
vnt: false,
cloudflared: false,
});
const getTLSState = useCallback(() => {
send("getTLSState", {}, resp => {
@@ -280,10 +337,156 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
});
}, [send]);
const managedTools: ManagedVpnTool[] = ["frpc", "easytier", "vnt", "cloudflared"];
const getVpnToolSystemInfo = useCallback(() => {
send("getVpnToolSystemInfo", {}, resp => {
if ("error" in resp) {
notifications.error(`Failed to get system architecture info: ${resp.error.data || "Unknown error"}`);
return;
}
setVpnToolSystemInfo(resp.result as VpnToolSystemInfo);
});
}, [send]);
const getVpnToolStatus = useCallback((tool: ManagedVpnTool) => {
send("getVpnToolStatus", { tool }, resp => {
if ("error" in resp) {
notifications.error(`Failed to get ${tool} status: ${resp.error.data || "Unknown error"}`);
return;
}
setVpnToolStatusMap(prev => ({ ...prev, [tool]: resp.result as VpnToolStatus }));
});
}, [send]);
const listVpnToolReleases = useCallback((tool: ManagedVpnTool) => {
send("listVpnToolReleases", { tool }, resp => {
if ("error" in resp) {
notifications.error(`Failed to list ${tool} releases: ${resp.error.data || "Unknown error"}`);
return;
}
const releases = resp.result as VpnToolRelease[];
setVpnToolReleasesMap(prev => ({ ...prev, [tool]: releases }));
if (!releases.length) {
return;
}
setVpnToolSelectedVersionMap(prev => {
if (prev[tool]) return prev;
return { ...prev, [tool]: releases[0].tag_name };
});
setVpnToolSelectedAssetMap(prev => {
if (prev[tool]) return prev;
const firstRelease = releases[0];
const preferred = firstRelease.assets.find(asset => asset.arch_match) || firstRelease.assets[0];
return preferred ? { ...prev, [tool]: preferred.url } : prev;
});
});
}, [send]);
const refreshVpnToolManager = useCallback((tool: ManagedVpnTool, withReleases: boolean) => {
getVpnToolStatus(tool);
if (withReleases) {
listVpnToolReleases(tool);
}
}, [getVpnToolStatus, listVpnToolReleases]);
const getVpnToolInstallTask = useCallback((tool: ManagedVpnTool) => {
send("getVpnToolInstallTask", { tool }, resp => {
if ("error" in resp) {
return;
}
setVpnToolInstallTaskMap(prev => ({ ...prev, [tool]: resp.result as VpnToolInstallTask }));
});
}, [send]);
const handleVpnToolVersionChange = useCallback((tool: ManagedVpnTool, version: string) => {
setVpnToolSelectedVersionMap(prev => ({ ...prev, [tool]: version }));
const releases = vpnToolReleasesMap[tool] || [];
const selectedRelease = releases.find(release => release.tag_name === version);
if (!selectedRelease || !selectedRelease.assets.length) {
setVpnToolSelectedAssetMap(prev => ({ ...prev, [tool]: "" }));
return;
}
const preferred = selectedRelease.assets.find(asset => asset.arch_match) || selectedRelease.assets[0];
setVpnToolSelectedAssetMap(prev => ({ ...prev, [tool]: preferred?.url || "" }));
}, [vpnToolReleasesMap]);
const handleInstallVpnTool = useCallback((tool: ManagedVpnTool) => {
const version = vpnToolSelectedVersionMap[tool];
const downloadURL = vpnToolSelectedAssetMap[tool];
const releases = vpnToolReleasesMap[tool] || [];
const release = releases.find(item => item.tag_name === version);
const selectedAsset = release?.assets.find(asset => asset.url === downloadURL);
if (!version || !downloadURL || !selectedAsset) {
notifications.error(`Please select version and release asset for ${tool}`);
return;
}
setVpnToolBusyMap(prev => ({ ...prev, [tool]: true }));
send("startVpnToolInstall", { tool, version, assetName: selectedAsset.name, downloadURL }, resp => {
setVpnToolBusyMap(prev => ({ ...prev, [tool]: false }));
if ("error" in resp) {
notifications.error(`Failed to install ${tool}: ${resp.error.data || "Unknown error"}`);
return;
}
notifications.success(`${tool} install task started (${version})`);
getVpnToolInstallTask(tool);
});
}, [send, vpnToolSelectedVersionMap, vpnToolSelectedAssetMap, vpnToolReleasesMap, getVpnToolInstallTask]);
const handleUninstallVpnToolVersion = useCallback((tool: ManagedVpnTool, version: string) => {
if (!version) {
notifications.error("Please select installed version");
return;
}
setVpnToolBusyMap(prev => ({ ...prev, [tool]: true }));
send("uninstallVpnToolVersion", { tool, version }, resp => {
setVpnToolBusyMap(prev => ({ ...prev, [tool]: false }));
if ("error" in resp) {
notifications.error(`Failed to uninstall ${tool} version ${version}: ${resp.error.data || "Unknown error"}`);
return;
}
notifications.success(`${tool} ${version} uninstalled`);
refreshVpnToolManager(tool, true);
});
}, [send, refreshVpnToolManager]);
useEffect(() => {
getCloudflaredStatus();
}, [getCloudflaredStatus]);
useEffect(() => {
getVpnToolSystemInfo();
managedTools.forEach(tool => {
refreshVpnToolManager(tool, false);
getVpnToolInstallTask(tool);
});
}, [getVpnToolSystemInfo, refreshVpnToolManager, getVpnToolInstallTask]);
useEffect(() => {
const timer = setInterval(() => {
managedTools.forEach(tool => {
getVpnToolInstallTask(tool);
});
}, 1000);
return () => clearInterval(timer);
}, [getVpnToolInstallTask]);
useEffect(() => {
const tabToolMap: Partial<Record<string, ManagedVpnTool>> = {
frp: "frpc",
easytier: "easytier",
vnt: "vnt",
cloudflared: "cloudflared",
};
const tool = tabToolMap[activeTab];
if (tool) {
refreshVpnToolManager(tool, false);
}
if (activeTab === "cloudflared") {
getCloudflaredStatus();
}
}, [activeTab, refreshVpnToolManager, getCloudflaredStatus]);
// Handle TLS mode change
const handleTlsModeChange = (value: string) => {
setTlsMode(value);
@@ -868,6 +1071,154 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
getVntConfigFile();
}, [getVntStatus, getVntConfig]);
const renderVpnToolManager = (tool: ManagedVpnTool, label: string) => {
const status = vpnToolStatusMap[tool];
const releases = vpnToolReleasesMap[tool] || [];
const selectedVersion = vpnToolSelectedVersionMap[tool] || "";
const selectedRelease = releases.find(release => release.tag_name === selectedVersion);
const selectedAsset = vpnToolSelectedAssetMap[tool] || "";
const assets = selectedRelease?.assets || [];
const busy = vpnToolBusyMap[tool] || false;
const installTask = vpnToolInstallTaskMap[tool];
const installRunning = installTask?.running === true;
const installPanelOpen = vpnToolInstallPanelOpenMap[tool] || false;
const isInstalled = status?.installed === true;
const uninstallVersion = status?.current_version || status?.managed_versions?.[0] || "";
return (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-700">
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
{label} {$at("Version Manager")}
</span>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="light"
text={$at("Refresh")}
onClick={() => refreshVpnToolManager(tool, installPanelOpen)}
disabled={busy || installRunning}
/>
<Button
size="SM"
theme="light"
text={installPanelOpen ? $at("Hide Install Actions") : $at("Show Install Actions")}
onClick={() => {
const nextOpen = !installPanelOpen;
setVpnToolInstallPanelOpenMap(prev => ({ ...prev, [tool]: nextOpen }));
if (nextOpen) {
listVpnToolReleases(tool);
}
}}
disabled={busy || installRunning}
/>
</div>
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{$at("Install Status")}: {status?.installed ? $at("Installed") : $at("Not Installed")}
{status?.source ? ` (${status.source})` : ""}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{$at("Detected Version")}: {status?.detected_version || "-"}
</div>
{installPanelOpen ? (
<>
<div className="text-xs text-slate-500 dark:text-slate-400">
{$at("System Architecture")}: {vpnToolSystemInfo?.arch_label || "unknown"}
{vpnToolSystemInfo?.arch_keywords?.length
? ` (${vpnToolSystemInfo.arch_keywords.join(", ")})`
: ""}
</div>
<div className="space-y-2">
<SettingsItem title={$at("Release Version")} description="">
<Select
className={isMobile ? "!w-full !h-[36px]" : "!w-[28%] !h-[36px]"}
value={selectedVersion}
onChange={value => handleVpnToolVersionChange(tool, value)}
options={releases.map(release => ({
value: release.tag_name,
label: release.tag_name,
}))}
/>
</SettingsItem>
<SettingsItem title={$at("Release Asset")} description="">
<Select
className={isMobile ? "!w-full !h-[36px]" : "!w-[60%] !h-[36px]"}
value={selectedAsset}
onChange={value => setVpnToolSelectedAssetMap(prev => ({ ...prev, [tool]: value }))}
options={assets.map(asset => ({
value: asset.url,
label: `${asset.arch_match ? "[ARCH OK] " : ""}${asset.name}`,
}))}
/>
</SettingsItem>
</div>
<div className="flex items-center gap-x-2">
{isInstalled ? (
<>
<Button
size="SM"
theme="primary"
text={$at("Update")}
onClick={() => handleInstallVpnTool(tool)}
disabled={busy || installRunning || !selectedVersion || !selectedAsset}
/>
<Button
size="SM"
theme="danger"
text={$at("Uninstall")}
onClick={() => handleUninstallVpnToolVersion(tool, uninstallVersion)}
disabled={busy || installRunning || !uninstallVersion}
/>
</>
) : (
<Button
size="SM"
theme="primary"
text={$at("Install")}
onClick={() => handleInstallVpnTool(tool)}
disabled={busy || installRunning || !selectedVersion || !selectedAsset}
/>
)}
</div>
{installRunning && installTask ? (
<div className="space-y-2 rounded border border-slate-200 p-2 dark:border-slate-700">
<div className="text-xs text-slate-500 dark:text-slate-400">
{$at("Install Task")}: {installTask.message || "-"}
{installTask.error ? ` (${installTask.error})` : ""}
</div>
<div className="h-2 w-full rounded bg-slate-200 dark:bg-slate-700">
<div
className="h-2 rounded bg-blue-500 transition-all"
style={{ width: `${Math.max(0, Math.min(100, Math.round((installTask.progress || 0) * 100)))}%` }}
/>
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{$at("Progress")}: {Math.max(0, Math.min(100, Math.round((installTask.progress || 0) * 100)))}%
</div>
{!!installTask.logs?.length && (
<pre className="max-h-36 overflow-auto rounded bg-slate-50 p-2 text-[11px] text-slate-600 dark:bg-slate-900 dark:text-slate-300">
{installTask.logs.join("\n")}
</pre>
)}
</div>
) : null}
{status?.managed_versions?.length ? (
<div className="text-xs text-slate-500 dark:text-slate-400">
{$at("Installed Versions")}: {status.managed_versions.join(", ")}
</div>
) : null}
</>
) : null}
</div>
</div>
);
};
return (
<div className="space-y-4">
@@ -988,11 +1339,28 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
</AntdButton>
</SettingsItem>
)}
<FirewallSettings />
</div>
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
</>
)}
<div className="space-y-4">
<SettingsSectionHeader
title={$at("WebRTC Servers")}
description={$at("STUN and TURN servers used for peer connections")}
/>
<GridCard>
<AutoHeight>
<div className="space-y-4 p-4">
<WebRtcServersSettings />
</div>
</AutoHeight>
</GridCard>
</div>
<div className="space-y-4">
<SettingsSectionHeader
title={$at("Remote")}
@@ -1288,6 +1656,7 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
<GridCard>
<div className="p-4">
<div className="space-y-4">
{renderVpnToolManager("easytier", "EasyTier")}
{ easyTierRunningStatus.running ? (
<div className="flex-1 space-y-2">
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
@@ -1405,6 +1774,7 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
<GridCard>
<div className="p-4">
<div className="space-y-4">
{renderVpnToolManager("vnt", "Vnt")}
{ vntRunningStatus.running ? (
<div className="flex-1 space-y-2">
@@ -1617,6 +1987,7 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
<GridCard>
<div className="p-4">
<div className="space-y-4">
{renderVpnToolManager("cloudflared", "Cloudflare")}
{cloudflaredRunningStatus.running ? (
<div className="flex items-center gap-x-2">
<Button
@@ -1665,6 +2036,7 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
<GridCard>
<div className="p-4">
<div className="space-y-4">
{renderVpnToolManager("frpc", "frpc")}
<TextAreaWithLabel
label={$at("Edit frpc.toml")}
placeholder={$at("Enter frpc configuration")}

View File

@@ -0,0 +1,940 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button as AntdButton, Checkbox, Input, Modal, Select } from "antd";
import { useReactAt } from "i18n-auto-extractor/react";
import { SettingsSectionHeader } from "@components/Settings/SettingsSectionHeader";
import { isMobile } from "react-device-detect";
import { SettingsItem } from "@components/Settings/SettingsView";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
import AutoHeight from "@components/AutoHeight";
import { GridCard } from "@components/Card";
import { ConfirmDialog } from "@components/ConfirmDialog";
type FirewallChain = "input" | "output" | "forward";
type FirewallAction = "accept" | "drop" | "reject";
export interface FirewallConfig {
base: {
inputPolicy: FirewallAction;
outputPolicy: FirewallAction;
forwardPolicy: FirewallAction;
};
rules: FirewallRule[];
portForwards: FirewallPortRule[];
}
export interface FirewallRule {
chain: FirewallChain;
sourceIP: string;
sourcePort?: number | null;
protocols: string[];
destinationIP: string;
destinationPort?: number | null;
action: FirewallAction;
comment: string;
}
export interface FirewallPortRule {
chain?: "output" | "prerouting" | "prerouting_redirect";
managed?: boolean;
sourcePort: number;
protocols: string[];
destinationIP: string;
destinationPort: number;
comment: string;
}
const defaultFirewallConfig: FirewallConfig = {
base: { inputPolicy: "accept", outputPolicy: "accept", forwardPolicy: "accept" },
rules: [],
portForwards: [],
};
const actionOptions: { value: FirewallAction; label: string }[] = [
{ value: "accept", label: "Accept" },
{ value: "drop", label: "Drop" },
{ value: "reject", label: "Reject" },
];
const chainOptions: { value: FirewallChain; label: string }[] = [
{ value: "input", label: "Input" },
{ value: "output", label: "Output" },
{ value: "forward", label: "Forward" },
];
const commProtocolOptions = [
{ key: "any", label: "Any" },
{ key: "tcp", label: "TCP" },
{ key: "udp", label: "UDP" },
{ key: "icmp", label: "ICMP" },
{ key: "igmp", label: "IGMP" },
];
const portForwardProtocolOptions = [
{ key: "tcp", label: "TCP" },
{ key: "udp", label: "UDP" },
{ key: "sctp", label: "SCTP" },
{ key: "dccp", label: "DCCP" },
];
function formatProtocols(protocols: string[]) {
if (!protocols?.length) return "-";
if (protocols.includes("any")) return "Any";
return protocols.map(p => p.toUpperCase()).join(", ");
}
function actionLabel(action: FirewallAction) {
return actionOptions.find(o => o.value === action)?.label ?? action;
}
function chainLabel(chain: FirewallChain) {
return chainOptions.find(o => o.value === chain)?.label ?? chain;
}
function portForwardChainLabel(chain: FirewallPortRule["chain"]) {
switch (chain ?? "prerouting") {
case "output":
return "OUTPUT";
case "prerouting_redirect":
return "PREROUTING_REDIRECT";
default:
return "PREROUTING";
}
}
function formatEndpoint(ip: string, port: number | null | undefined, anyText: string) {
const t = (ip || "").trim();
if (!t && (port === null || port === undefined)) return anyText;
if (!t && port !== null && port !== undefined) return `${anyText}:${port}`;
if (t && (port === null || port === undefined)) return t;
return `${t}:${port}`;
}
function normalizePort(v: string) {
const t = v.trim();
if (t === "") return null;
const n = Number(t);
if (!Number.isFinite(n)) return null;
if (n < 1 || n > 65535) return null;
return Math.trunc(n);
}
function normalizeRuleProtocols(list: string[]) {
if (list.includes("any")) return ["any"];
return list;
}
export default function FirewallSettings() {
const { $at } = useReactAt();
const [send] = useJsonRpc();
const [activeTab, setActiveTab] = useState<"base" | "rules" | "portForwards">(
"base",
);
const [appliedConfig, setAppliedConfig] = useState<FirewallConfig>(defaultFirewallConfig);
const [baseDraft, setBaseDraft] = useState<FirewallConfig["base"]>(
defaultFirewallConfig.base,
);
const [loading, setLoading] = useState(false);
const [applying, setApplying] = useState(false);
const [showBaseSubmitConfirm, setShowBaseSubmitConfirm] = useState(false);
const [selectedRuleRows, setSelectedRuleRows] = useState<Set<number>>(new Set());
const [selectedPortForwardRows, setSelectedPortForwardRows] = useState<Set<number>>(
new Set(),
);
const [ruleModalOpen, setRuleModalOpen] = useState(false);
const [ruleEditingIndex, setRuleEditingIndex] = useState<number | null>(null);
const [ruleDraft, setRuleDraft] = useState<FirewallRule>({
chain: "input",
sourceIP: "",
sourcePort: null,
protocols: ["any"],
destinationIP: "",
destinationPort: null,
action: "accept",
comment: "",
});
const [ruleSourcePortText, setRuleSourcePortText] = useState<string>("");
const [ruleDestinationPortText, setRuleDestinationPortText] = useState<string>("");
const [pfModalOpen, setPfModalOpen] = useState(false);
const [pfEditingIndex, setPfEditingIndex] = useState<number | null>(null);
const [pfDraft, setPfDraft] = useState<FirewallPortRule>({
chain: "prerouting",
sourcePort: 1,
protocols: ["tcp"],
destinationIP: "",
destinationPort: 1,
comment: "",
});
const [pfSourcePortText, setPfSourcePortText] = useState<string>("1");
const [pfDestinationPortText, setPfDestinationPortText] = useState<string>("1");
const fetchConfig = useCallback(() => {
setLoading(true);
send("getFirewallConfig", {}, resp => {
setLoading(false);
if ("error" in resp) {
notifications.error(
`${$at("Failed to get firewall config")}: ${resp.error.data || resp.error.message}`,
);
return;
}
const cfg = resp.result as FirewallConfig;
setAppliedConfig(cfg);
setBaseDraft(cfg.base);
setSelectedRuleRows(new Set());
setSelectedPortForwardRows(new Set());
});
}, [send, $at]);
useEffect(() => {
fetchConfig();
}, [fetchConfig]);
const hasBaseChanges = useMemo(() => {
return JSON.stringify(appliedConfig.base) !== JSON.stringify(baseDraft);
}, [appliedConfig.base, baseDraft]);
const applyFirewallConfig = useCallback(
(
nextConfig: FirewallConfig,
opts?: { onSuccess?: () => void; successText?: string },
) => {
setApplying(true);
send("setFirewallConfig", { config: nextConfig }, resp => {
setApplying(false);
if ("error" in resp) {
notifications.error(
`${$at("Failed to apply firewall config")}: ${resp.error.data || resp.error.message}`,
);
return;
}
setAppliedConfig(nextConfig);
if (opts?.successText) notifications.success(opts.successText);
if (opts?.onSuccess) opts.onSuccess();
});
},
[send, $at],
);
const handleBaseSubmit = useCallback(() => {
const nextConfig: FirewallConfig = { ...appliedConfig, base: baseDraft };
applyFirewallConfig(nextConfig, {
successText: $at("Firewall config applied"),
onSuccess: () => {
setShowBaseSubmitConfirm(false);
},
});
}, [appliedConfig, baseDraft, applyFirewallConfig, $at]);
const requestBaseSubmit = useCallback(() => {
if (!hasBaseChanges) return;
setShowBaseSubmitConfirm(true);
}, [hasBaseChanges]);
const openAddRule = () => {
setRuleEditingIndex(null);
setRuleDraft({
chain: "input",
sourceIP: "",
sourcePort: null,
protocols: ["any"],
destinationIP: "",
destinationPort: null,
action: "accept",
comment: "",
});
setRuleSourcePortText("");
setRuleDestinationPortText("");
setRuleModalOpen(true);
};
const openEditRule = (idx: number) => {
const current = appliedConfig.rules[idx];
if (!current) return;
setRuleEditingIndex(idx);
setRuleDraft({ ...current });
setRuleSourcePortText(current.sourcePort ? String(current.sourcePort) : "");
setRuleDestinationPortText(current.destinationPort ? String(current.destinationPort) : "");
setRuleModalOpen(true);
};
const saveRuleDraft = () => {
const next: FirewallRule = {
...ruleDraft,
protocols: normalizeRuleProtocols(ruleDraft.protocols),
sourcePort: normalizePort(ruleSourcePortText),
destinationPort: normalizePort(ruleDestinationPortText),
sourceIP: ruleDraft.sourceIP.trim(),
destinationIP: ruleDraft.destinationIP.trim(),
comment: ruleDraft.comment.trim(),
};
if (!next.protocols.length) {
notifications.error($at("Please select protocol"));
return;
}
if (!next.chain || !next.action) {
notifications.error($at("Missing required fields"));
return;
}
const rules = [...appliedConfig.rules];
if (ruleEditingIndex === null) {
rules.push(next);
} else {
rules[ruleEditingIndex] = next;
}
const nextConfig: FirewallConfig = {
...appliedConfig,
rules,
};
applyFirewallConfig(nextConfig, {
successText: $at("Firewall config applied"),
onSuccess: () => {
setRuleModalOpen(false);
setSelectedRuleRows(new Set());
},
});
};
const deleteSelectedRules = () => {
const idxs = [...selectedRuleRows.values()].sort((a, b) => a - b);
if (!idxs.length) return;
const nextRules = appliedConfig.rules.filter((_, i) => !selectedRuleRows.has(i));
const nextConfig: FirewallConfig = {
...appliedConfig,
rules: nextRules,
};
applyFirewallConfig(nextConfig, {
successText: $at("Firewall config applied"),
onSuccess: () => {
setSelectedRuleRows(new Set());
},
});
};
const openAddPortForward = () => {
setPfEditingIndex(null);
setPfDraft({
chain: "prerouting",
sourcePort: 1,
protocols: ["tcp"],
destinationIP: "",
destinationPort: 1,
comment: "",
});
setPfSourcePortText("1");
setPfDestinationPortText("1");
setPfModalOpen(true);
};
const openEditPortForward = (idx: number) => {
const current = appliedConfig.portForwards[idx];
if (!current) return;
if (current.managed === false) return;
setPfEditingIndex(idx);
const inferredChain =
current.chain ??
(current.destinationIP?.trim() === "0.0.0.0" || current.destinationIP?.trim() === "127.0.0.1"
? "output"
: "prerouting");
setPfDraft({
...current,
chain: inferredChain,
destinationIP:
inferredChain === "output" || inferredChain === "prerouting_redirect"
? "0.0.0.0"
: current.destinationIP?.trim() === "0.0.0.0" || current.destinationIP?.trim() === "127.0.0.1"
? ""
: current.destinationIP,
});
setPfSourcePortText(String(current.sourcePort));
setPfDestinationPortText(String(current.destinationPort));
setPfModalOpen(true);
};
const savePortForwardDraft = () => {
const srcPort = normalizePort(pfSourcePortText);
const dstPort = normalizePort(pfDestinationPortText);
if (!srcPort || !dstPort) {
notifications.error($at("Invalid port"));
return;
}
const next: FirewallPortRule = {
...pfDraft,
sourcePort: srcPort,
destinationPort: dstPort,
destinationIP:
(pfDraft.chain ?? "prerouting") === "output" ||
(pfDraft.chain ?? "prerouting") === "prerouting_redirect"
? "0.0.0.0"
: pfDraft.destinationIP.trim(),
protocols: normalizeRuleProtocols(pfDraft.protocols).filter(p => p !== "any"),
comment: pfDraft.comment.trim(),
};
const pfChain = next.chain ?? "prerouting";
if (pfChain === "prerouting" && ["0.0.0.0", "127.0.0.1"].includes(next.destinationIP.trim())) {
notifications.error($at("For PREROUTING, Destination IP must be a real host IP"));
return;
}
if (pfChain === "prerouting" && !next.destinationIP) {
notifications.error($at("Destination IP is required"));
return;
}
if (!next.protocols.length) {
notifications.error($at("Please select protocol"));
return;
}
const items = [...appliedConfig.portForwards];
if (pfEditingIndex === null) {
items.push(next);
} else {
items[pfEditingIndex] = next;
}
const nextConfig: FirewallConfig = {
...appliedConfig,
portForwards: items,
};
applyFirewallConfig(nextConfig, {
successText: $at("Firewall config applied"),
onSuccess: () => {
setPfModalOpen(false);
setSelectedPortForwardRows(new Set());
},
});
};
const deleteSelectedPortForwards = () => {
const idxs = [...selectedPortForwardRows.values()].sort((a, b) => a - b);
if (!idxs.length) return;
const nextItems = appliedConfig.portForwards.filter((r, i) => {
if (!selectedPortForwardRows.has(i)) return true;
return r.managed === false;
});
const nextConfig: FirewallConfig = {
...appliedConfig,
portForwards: nextItems,
};
applyFirewallConfig(nextConfig, {
successText: $at("Firewall config applied"),
onSuccess: () => {
setSelectedPortForwardRows(new Set());
},
});
};
return (
<div className="space-y-2">
<SettingsItem
title={$at("Firewall")}
badge="Experimental"
description={$at("Manage the firewall rules of the device")}
>
</SettingsItem>
<div className="overflow-x-auto pb-2">
<div className="flex min-w-max">
{[
{ id: "base", label: $at("Basic") },
{ id: "rules", label: $at("Communication Rules") },
{ id: "portForwards", label: $at("Port Forwarding") },
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`
flex-1 min-w-[120px] px-6 py-3 text-sm font-medium transition-all duration-200 border-y border-r first:border-l first:rounded-l-lg last:rounded-r-lg flex items-center justify-center gap-2
${
activeTab === tab.id
? "!bg-[rgba(22,152,217,1)] dark:!bg-[rgba(45,106,229,1))] !text-white border-[rgba(22,152,217,1)] dark:border-[rgba(45,106,229,1)]"
: "bg-transparent text-slate-600 dark:text-slate-400 border-slate-200 dark:border-slate-700 hover:border-[rgba(22,152,217,1)] dark:hover:border-[rgba(45,106,229,1)] hover:text-[rgba(22,152,217,1)] dark:hover:text-[rgba(45,106,229,1)]"
}
`}
>
{tab.label}
</button>
))}
</div>
</div>
<div>
{activeTab === "base" && (
<AutoHeight>
<GridCard>
<div className="p-4">
<div className="space-y-4">
<div className="grid grid-cols-[64px_1fr] gap-y-3 items-center">
<div className="text-sm text-slate-700 dark:text-slate-300">
{$at("Input")}
</div>
<div className="flex justify-end">
<Select
className={isMobile ? "!w-full !h-[36px]" : "!w-[28%] !h-[36px]"}
value={baseDraft.inputPolicy}
onChange={v =>
setBaseDraft({
...baseDraft,
inputPolicy: v as FirewallAction,
})
}
options={actionOptions}
/>
</div>
<div className="text-sm text-slate-700 dark:text-slate-300">
{$at("Output")}
</div>
<div className="flex justify-end">
<Select
className={isMobile ? "!w-full !h-[36px]" : "!w-[28%] !h-[36px]"}
value={baseDraft.outputPolicy}
onChange={v =>
setBaseDraft({
...baseDraft,
outputPolicy: v as FirewallAction,
})
}
options={actionOptions}
/>
</div>
<div className="text-sm text-slate-700 dark:text-slate-300">
{$at("Forward")}
</div>
<div className="flex justify-end">
<Select
className={isMobile ? "!w-full !h-[36px]" : "!w-[28%] !h-[36px]"}
value={baseDraft.forwardPolicy}
onChange={v =>
setBaseDraft({
...baseDraft,
forwardPolicy: v as FirewallAction,
})
}
options={actionOptions}
/>
</div>
</div>
<div className="flex items-center gap-2">
<AntdButton onClick={fetchConfig} loading={loading}>
{$at("Refresh")}
</AntdButton>
<AntdButton
type="primary"
onClick={requestBaseSubmit}
loading={applying}
disabled={!hasBaseChanges}
>
{$at("Submit")}
</AntdButton>
</div>
</div>
</div>
</GridCard>
</AutoHeight>
)}
{activeTab === "rules" && (
<AutoHeight>
<GridCard>
<div className="p-4">
<div className="space-y-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<AntdButton
type="primary"
onClick={openAddRule}
>
{$at("Add")}
</AntdButton>
<AntdButton danger onClick={deleteSelectedRules} disabled={!selectedRuleRows.size}>
{$at("Delete")}
</AntdButton>
</div>
</div>
<div className="overflow-x-auto rounded border border-slate-200 dark:border-slate-700">
<table className="min-w-max w-full text-sm whitespace-nowrap">
<thead className="bg-slate-50 text-xs text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">
<tr>
<th className="w-10 p-2 text-center font-medium" />
<th className="p-2 text-center font-medium">{$at("Chain")}</th>
<th className="p-2 text-center font-medium">{$at("Source")}</th>
<th className="p-2 text-center font-medium">{$at("Protocol")}</th>
<th className="p-2 text-center font-medium">{$at("Destination")}</th>
<th className="p-2 text-center font-medium">{$at("Action")}</th>
<th className="p-2 text-center font-medium">{$at("Description")}</th>
<th className="w-20 p-2 text-center font-medium">{$at("Operation")}</th>
</tr>
</thead>
<tbody>
{appliedConfig.rules.length === 0 ? (
<tr>
<td colSpan={8} className="p-6 text-center text-slate-500 dark:text-slate-400">
{$at("No rules available")}
</td>
</tr>
) : (
appliedConfig.rules.map((r, idx) => (
<tr
key={idx}
className="border-t border-slate-200 dark:border-slate-700"
>
<td className="p-2 text-center">
<Checkbox
checked={selectedRuleRows.has(idx)}
onChange={e => {
const next = new Set(selectedRuleRows);
if (e.target.checked) next.add(idx);
else next.delete(idx);
setSelectedRuleRows(next);
}}
/>
</td>
<td className="p-2 text-center">{chainLabel(r.chain)}</td>
<td className="p-2 text-center">
{formatEndpoint(r.sourceIP, r.sourcePort, $at("Any"))}
</td>
<td className="p-2 text-center">{formatProtocols(r.protocols)}</td>
<td className="p-2 text-center">
{formatEndpoint(r.destinationIP, r.destinationPort, $at("Any"))}
</td>
<td className="p-2 text-center">{actionLabel(r.action)}</td>
<td className="p-2 text-center">{r.comment || "-"}</td>
<td className="p-2 text-center">
<AntdButton size="small" onClick={() => openEditRule(idx)}>
{$at("Edit")}
</AntdButton>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</GridCard>
</AutoHeight>
)}
{activeTab === "portForwards" && (
<AutoHeight>
<GridCard>
<div className="p-4">
<div className="space-y-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<AntdButton
type="primary"
onClick={openAddPortForward}
>
{$at("Add")}
</AntdButton>
<AntdButton
danger
onClick={deleteSelectedPortForwards}
disabled={!selectedPortForwardRows.size}
>
{$at("Delete")}
</AntdButton>
</div>
</div>
<div className="overflow-x-auto rounded border border-slate-200 dark:border-slate-700">
<table className="min-w-max w-full text-sm whitespace-nowrap">
<thead className="bg-slate-50 text-xs text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">
<tr>
<th className="w-10 p-2 text-center font-medium" />
<th className="p-2 text-center font-medium">{$at("Chain")}</th>
<th className="p-2 text-center font-medium">{$at("Source")}</th>
<th className="p-2 text-center font-medium">{$at("Protocol")}</th>
<th className="p-2 text-center font-medium">{$at("Destination")}</th>
<th className="p-2 text-center font-medium">{$at("Description")}</th>
<th className="w-20 p-2 text-center font-medium">{$at("Operation")}</th>
</tr>
</thead>
<tbody>
{appliedConfig.portForwards.length === 0 ? (
<tr>
<td colSpan={7} className="p-6 text-center text-slate-500 dark:text-slate-400">
{$at("No data available")}
</td>
</tr>
) : (
appliedConfig.portForwards.map((r, idx) => (
<tr
key={idx}
className="border-t border-slate-200 dark:border-slate-700"
>
<td className="p-2 text-center">
<Checkbox
checked={selectedPortForwardRows.has(idx)}
disabled={r.managed === false}
onChange={e => {
const next = new Set(selectedPortForwardRows);
if (e.target.checked) next.add(idx);
else next.delete(idx);
setSelectedPortForwardRows(next);
}}
/>
</td>
<td className="p-2 text-center">{portForwardChainLabel(r.chain)}</td>
<td className="p-2 text-center">
{formatEndpoint("", r.sourcePort, $at("Any"))}
</td>
<td className="p-2 text-center">{formatProtocols(r.protocols)}</td>
<td className="p-2 text-center">
{formatEndpoint(r.destinationIP, r.destinationPort, $at("Any"))}
</td>
<td className="p-2 text-center">{r.comment || "-"}</td>
<td className="p-2 text-center">
<AntdButton size="small" disabled={r.managed === false} onClick={() => openEditPortForward(idx)}>
{$at("Edit")}
</AntdButton>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</GridCard>
</AutoHeight>
)}
</div>
<Modal
open={ruleModalOpen}
onCancel={() => setRuleModalOpen(false)}
onOk={saveRuleDraft}
confirmLoading={applying}
title={$at(ruleEditingIndex === null ? "Add Rule" : "Edit Rule")}
okText={$at("OK")}
cancelText={$at("Cancel")}
destroyOnClose
>
<div className="space-y-3">
<div className="space-y-1">
<div className="text-sm">{$at("Chain")}</div>
<Select
className="!w-full"
value={ruleDraft.chain}
onChange={v => setRuleDraft({ ...ruleDraft, chain: v as FirewallChain })}
options={chainOptions}
/>
</div>
<div className="space-y-1">
<div className="text-sm">{$at("Source IP")}</div>
<Input
value={ruleDraft.sourceIP}
placeholder={$at("Any")}
onChange={e => setRuleDraft({ ...ruleDraft, sourceIP: e.target.value })}
/>
</div>
<div className="space-y-1">
<div className="text-sm">{$at("Source Port")}</div>
<Input
value={ruleSourcePortText}
placeholder={$at("Any")}
onChange={e => setRuleSourcePortText(e.target.value)}
inputMode="numeric"
/>
</div>
<div className="space-y-1">
<div className="text-sm">{$at("Protocol")}</div>
<div className="flex flex-wrap gap-3">
{commProtocolOptions.map(p => (
<Checkbox
key={p.key}
checked={ruleDraft.protocols.includes(p.key)}
onChange={e => {
const checked = e.target.checked;
const next = new Set(ruleDraft.protocols);
if (checked) next.add(p.key);
else next.delete(p.key);
const arr = [...next.values()];
setRuleDraft({ ...ruleDraft, protocols: normalizeRuleProtocols(arr) });
}}
>
{p.label}
</Checkbox>
))}
</div>
</div>
<div className="space-y-1">
<div className="text-sm">{$at("Destination IP")}</div>
<Input
value={ruleDraft.destinationIP}
placeholder={$at("Any")}
onChange={e => setRuleDraft({ ...ruleDraft, destinationIP: e.target.value })}
/>
</div>
<div className="space-y-1">
<div className="text-sm">{$at("Destination Port")}</div>
<Input
value={ruleDestinationPortText}
placeholder={$at("Any")}
onChange={e => setRuleDestinationPortText(e.target.value)}
inputMode="numeric"
/>
</div>
<div className="space-y-1">
<div className="text-sm">{$at("Action")}</div>
<Select
className="!w-full"
value={ruleDraft.action}
onChange={v => setRuleDraft({ ...ruleDraft, action: v as FirewallAction })}
options={actionOptions}
/>
</div>
<div className="space-y-1">
<div className="text-sm">{$at("Description")}</div>
<Input
value={ruleDraft.comment}
onChange={e => setRuleDraft({ ...ruleDraft, comment: e.target.value })}
/>
</div>
</div>
</Modal>
<Modal
open={pfModalOpen}
onCancel={() => setPfModalOpen(false)}
onOk={savePortForwardDraft}
confirmLoading={applying}
title={$at(pfEditingIndex === null ? "Add Rule" : "Edit Rule")}
okText={$at("OK")}
cancelText={$at("Cancel")}
destroyOnClose
>
<div className="space-y-3">
<div className="space-y-1">
<div className="text-sm">
<span className="text-red-500">*</span> {$at("Chain")}
</div>
<Select
className="!w-full"
value={pfDraft.chain ?? "prerouting"}
onChange={v => {
const c = v as "output" | "prerouting" | "prerouting_redirect";
const curDst = pfDraft.destinationIP.trim();
const forceLocal = c === "output" || c === "prerouting_redirect";
setPfDraft({
...pfDraft,
chain: c,
destinationIP: forceLocal
? "0.0.0.0"
: curDst === "0.0.0.0" || curDst === "127.0.0.1"
? ""
: pfDraft.destinationIP,
});
}}
options={[
{ value: "prerouting", label: "PREROUTING" },
{ value: "prerouting_redirect", label: "PREROUTING_REDIRECT" },
{ value: "output", label: "OUTPUT" },
]}
/>
</div>
<div className="space-y-1">
<div className="text-sm">
<span className="text-red-500">*</span> {$at("Source Port")}
</div>
<Input
value={pfSourcePortText}
onChange={e => setPfSourcePortText(e.target.value)}
inputMode="numeric"
/>
</div>
<div className="space-y-1">
<div className="text-sm">{$at("Protocol")}</div>
<div className="flex flex-wrap gap-3">
{portForwardProtocolOptions.map(p => (
<Checkbox
key={p.key}
checked={pfDraft.protocols.includes(p.key)}
onChange={e => {
const checked = e.target.checked;
const next = new Set(pfDraft.protocols);
if (checked) next.add(p.key);
else next.delete(p.key);
setPfDraft({ ...pfDraft, protocols: [...next.values()] });
}}
>
{p.label}
</Checkbox>
))}
</div>
</div>
<div className="space-y-1">
<div className="text-sm">
<span className="text-red-500">*</span> {$at("Destination IP")}
</div>
<Input
value={pfDraft.destinationIP}
onChange={e => setPfDraft({ ...pfDraft, destinationIP: e.target.value })}
disabled={(pfDraft.chain ?? "prerouting") !== "prerouting"}
/>
</div>
<div className="space-y-1">
<div className="text-sm">
<span className="text-red-500">*</span> {$at("Destination Port")}
</div>
<Input
value={pfDestinationPortText}
onChange={e => setPfDestinationPortText(e.target.value)}
inputMode="numeric"
/>
</div>
<div className="space-y-1">
<div className="text-sm">{$at("Description")}</div>
<Input
value={pfDraft.comment}
onChange={e => setPfDraft({ ...pfDraft, comment: e.target.value })}
/>
</div>
</div>
</Modal>
<ConfirmDialog
open={showBaseSubmitConfirm}
onClose={() => {
setShowBaseSubmitConfirm(false);
}}
title={$at("Submit Firewall Policies?")}
description={
<>
<p>
{$at(
"Warning: Adjusting some policies may cause network address loss, leading to device unavailability.",
)}
</p>
</>
}
variant="warning"
cancelText={$at("Cancel")}
confirmText={$at("Submit")}
onConfirm={handleBaseSubmit}
/>
</div>
);
}

View File

@@ -0,0 +1,159 @@
import { useEffect, useState } from "react";
import { useReactAt } from "i18n-auto-extractor/react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
import { Button } from "@components/Button";
import { InputField } from "@components/InputField";
import { SettingsItem } from "@components/Settings/SettingsView";
interface TurnServer {
url: string;
username: string;
credential: string;
}
interface RtcServersConfig {
stun: string;
defaultStun: string;
turnServers: TurnServer[] | null;
}
export default function WebRtcServersSettings() {
const { $at } = useReactAt();
const [send] = useJsonRpc();
const [stun, setStun] = useState("");
const [defaultStun, setDefaultStun] = useState("");
const [turnServers, setTurnServers] = useState<TurnServer[]>([]);
useEffect(() => {
send("getRtcServersConfig", {}, resp => {
if ("error" in resp) {
notifications.error(`${$at("Failed to load WebRTC servers")}: ${resp.error.data || $at("Unknown error")}`);
return;
}
const cfg = resp.result as RtcServersConfig;
setDefaultStun(cfg.defaultStun);
setStun(cfg.stun || cfg.defaultStun);
setTurnServers(cfg.turnServers ?? []);
});
}, [send]);
const saveStun = (value: string) => {
send("setStunServer", { stun: value }, resp => {
if ("error" in resp) {
notifications.error(`${$at("Failed to save STUN")}: ${resp.error.data || $at("Unknown error")}`);
return;
}
setStun(value);
notifications.success($at("STUN server saved"));
});
};
const persistTurnServers = (servers: TurnServer[]) => {
send("setTurnServers", { params: { servers } }, resp => {
if ("error" in resp) {
notifications.error(`${$at("Failed to save TURN")}: ${resp.error.data || $at("Unknown error")}`);
return;
}
setTurnServers(servers);
notifications.success($at("TURN servers saved"));
});
};
const updateTurnRow = (index: number, field: keyof TurnServer, value: string) => {
setTurnServers(prev => prev.map((server, i) => (i === index ? { ...server, [field]: value } : server)));
};
const addTurnRow = () => {
setTurnServers(prev => [...prev, { url: "", username: "", credential: "" }]);
};
const deleteTurnRow = (index: number) => {
persistTurnServers(turnServers.filter((_, i) => i !== index));
};
return (
<div className="space-y-6">
<SettingsItem
title={$at("STUN Server")}
description={$at("Public STUN server for NAT traversal")}
noCol
>
<div className="flex w-full max-w-2xl flex-col gap-2 sm:flex-row">
<InputField
value={stun}
onChange={e => setStun(e.target.value)}
placeholder={defaultStun}
className="min-w-0"
/>
<div className="flex shrink-0 gap-2">
<Button size="MD" theme="primary" text={$at("Save")} onClick={() => saveStun(stun)} />
<Button
size="MD"
theme="light"
text={$at("Restore Default")}
onClick={() => saveStun(defaultStun)}
/>
</div>
</div>
</SettingsItem>
<div className="space-y-0.5">
<div className="text-base font-semibold text-black dark:text-white">
{$at("TURN Servers")}
</div>
<div className="text-sm text-slate-700 dark:text-slate-300">
{$at("Used as relay when direct peer-to-peer connection fails")}
</div>
</div>
<div className="space-y-3">
{turnServers.length === 0 && (
<div className="text-sm text-slate-500 dark:text-slate-400">{$at("No TURN servers configured")}</div>
)}
{turnServers.map((server, index) => (
<div key={index} className="grid gap-2 lg:grid-cols-[minmax(220px,1fr)_minmax(120px,180px)_minmax(120px,180px)_auto]">
<InputField
value={server.url}
onChange={e => updateTurnRow(index, "url", e.target.value)}
placeholder="turn:turn.example.com:3478"
/>
<InputField
value={server.username}
onChange={e => updateTurnRow(index, "username", e.target.value)}
placeholder={$at("Username")}
/>
<InputField
value={server.credential}
onChange={e => updateTurnRow(index, "credential", e.target.value)}
placeholder={$at("Credential")}
type="password"
/>
<Button
size="MD"
theme="lightDanger"
text={$at("Delete")}
onClick={() => deleteTurnRow(index)}
/>
</div>
))}
<div className="flex flex-wrap gap-2">
<Button size="MD" theme="light" text={$at("Add TURN Server")} onClick={addTurnRow} />
<Button
size="MD"
theme="primary"
text={$at("Save TURN Servers")}
onClick={() => persistTurnServers(turnServers)}
/>
</div>
</div>
</div>
);
}

View File

@@ -19,9 +19,18 @@ export default function SettingsAdvanced() {
const [sshKey, setSSHKey] = useState<string>("");
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
const [usbEnhancedDetectionEnabled, setUsbEnhancedDetectionEnabled] =
useState(true);
const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);
const [showRebootConfirm, setShowRebootConfirm] = useState(false);
const [showConfigEdit, setShowConfigEdit] = useState(false);
const [showConfigSavedReboot, setShowConfigSavedReboot] = useState(false);
const [configContent, setConfigContent] = useState("");
const [isSavingConfig, setIsSavingConfig] = useState(false);
const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
const [apiKey, setApiKey] = useState<string>("");
const [apiKeyInput, setApiKeyInput] = useState<string>("");
const [showApiKeyClearWarning, setShowApiKeyClearWarning] = useState(false);
const settings = useSettingsStore();
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
@@ -38,10 +47,22 @@ export default function SettingsAdvanced() {
setUsbEmulationEnabled(resp.result as boolean);
});
send("getUsbEnhancedDetection", {}, resp => {
if ("error" in resp) return;
setUsbEnhancedDetectionEnabled(resp.result as boolean);
});
send("getLocalLoopbackOnly", {}, resp => {
if ("error" in resp) return;
setLocalLoopbackOnly(resp.result as boolean);
});
send("getApiKey", {}, resp => {
if ("error" in resp) return;
const key = resp.result as string;
setApiKey(key);
setApiKeyInput(key);
});
}, [send, setDeveloperMode]);
const getUsbEmulationState = useCallback(() => {
@@ -67,6 +88,24 @@ export default function SettingsAdvanced() {
[getUsbEmulationState, send],
);
const handleUsbEnhancedDetectionToggle = useCallback(
(enabled: boolean) => {
send("setUsbEnhancedDetection", { enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to ${enabled ? "enable" : "disable"} USB enhanced detection: ${resp.error.data || "Unknown error"}`,
);
return;
}
setUsbEnhancedDetectionEnabled(enabled);
notifications.success(
enabled ? "USB enhanced detection enabled" : "USB enhanced detection disabled",
);
});
},
[send],
);
const handleResetConfig = useCallback(() => {
send("resetConfig", {}, resp => {
if ("error" in resp) {
@@ -79,6 +118,54 @@ export default function SettingsAdvanced() {
});
}, [send]);
const handleUpdateApiKey = useCallback(() => {
if (apiKeyInput === "") {
setShowApiKeyClearWarning(true);
return;
}
send("setApiKey", { apiKey: apiKeyInput }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to update API key: ${resp.error.data || "Unknown error"}`,
);
return;
}
setApiKey(apiKeyInput);
notifications.success("API key updated successfully");
});
}, [send, apiKeyInput]);
const handleGenerateApiKey = useCallback(() => {
send("generateApiKey", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to generate API key: ${resp.error.data || "Unknown error"}`,
);
return;
}
const newKey = resp.result as string;
setApiKey(newKey);
setApiKeyInput(newKey);
notifications.success("New API key generated and saved");
});
}, [send]);
const confirmClearApiKey = useCallback(() => {
send("generateApiKey", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to generate API key: ${resp.error.data || "Unknown error"}`,
);
return;
}
const newKey = resp.result as string;
setApiKey(newKey);
setApiKeyInput(newKey);
notifications.success("New API key generated and saved");
});
setShowApiKeyClearWarning(false);
}, [send]);
const handleUpdateSSHKey = useCallback(() => {
send("setSSHKeyState", { sshKey }, resp => {
if ("error" in resp) {
@@ -133,6 +220,35 @@ export default function SettingsAdvanced() {
setShowLoopbackWarning(false);
}, [applyLoopbackOnlyMode, setShowLoopbackWarning]);
const handleOpenConfigEditor = useCallback(() => {
send("getConfigRaw", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to load configuration: ${resp.error.data || "Unknown error"}`,
);
return;
}
setConfigContent(resp.result as string);
setShowConfigEdit(true);
});
}, [send]);
const handleSaveConfig = useCallback(() => {
setIsSavingConfig(true);
send("setConfigRaw", { configStr: configContent }, resp => {
setIsSavingConfig(false);
if ("error" in resp) {
notifications.error(
`Failed to save configuration: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("Configuration saved successfully");
setShowConfigEdit(false);
setShowConfigSavedReboot(true);
});
}, [send, configContent]);
return (
<div className="space-y-4">
<SettingsPageHeader
@@ -182,6 +298,42 @@ export default function SettingsAdvanced() {
</div>
)}
{isOnDevice && (
<div className="space-y-4">
<SettingsItem
title={$at("API Key")}
description={$at("API key for MCP and REST API authentication")}
/>
<div className="space-y-4">
<TextAreaWithLabel
label={$at("API Key")}
value={apiKeyInput || ""}
rows={2}
onChange={e => setApiKeyInput(e.target.value)}
placeholder={$at("Enter API key or leave empty to auto-generate")}
/>
<p className="text-xs text-slate-600 dark:text-[#ffffff]">
{$at("Used for authenticating MCP and REST API requests.")}
</p>
<div className="flex items-center gap-x-2">
<AntdButton
type="primary"
onClick={handleUpdateApiKey}
className={isMobile?"w-full":""}
>
{$at("Save API Key")}
</AntdButton>
<AntdButton
onClick={handleGenerateApiKey}
className={isMobile?"w-full":""}
>
{$at("Generate New")}
</AntdButton>
</div>
</div>
</div>
)}
<SettingsItem
title={$at("Force HTTP Transmission")}
badge="Experimental"
@@ -197,6 +349,17 @@ export default function SettingsAdvanced() {
/>
</SettingsItem>
<SettingsItem
title={$at("USB detection enhancement")}
description={$at("The DISC state is also checked during USB status retrieval")}
noCol
>
<Checkbox
checked={usbEnhancedDetectionEnabled}
onChange={e => handleUsbEnhancedDetectionToggle(e.target.checked)}
/>
</SettingsItem>
<SettingsItem
title={$at("USB Emulation")}
description={$at("Control the USB emulation state")}
@@ -256,15 +419,24 @@ export default function SettingsAdvanced() {
</AntdButton>
</SettingsItem>
<SettingsItem
title={$at("Edit Configuration")}
description={$at("Edit the raw configuration file directly")}
>
<AntdButton
type="primary"
className={`${isMobile?"w-full":""}`}
onClick={handleOpenConfigEditor}
>
{$at("Edit")}
</AntdButton>
</SettingsItem>
<SettingsItem
title={$at("Reset Configuration")}
description={$at("Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.")}
>
<AntdButton
type="primary"
className={`${isMobile?"w-full":""}`}
onClick={() => {
@@ -301,6 +473,28 @@ export default function SettingsAdvanced() {
onConfirm={confirmLoopbackModeEnable}
/>
<ConfirmDialog
open={showApiKeyClearWarning}
onClose={() => {
setShowApiKeyClearWarning(false);
}}
title={$at("Clear API Key?")}
description={
<>
<p>
{$at("Setting the API key to empty will auto-generate a new random key.")}
</p>
<p className="text-xs text-slate-600 dark:text-slate-400 mt-2">
{$at("Make sure to update your clients with the new key after saving.")}
</p>
</>
}
variant="warning"
cancelText={$at("Cancel")}
confirmText={$at("Generate New Key")}
onConfirm={confirmClearApiKey}
/>
<ConfirmDialog
open={showRebootConfirm}
onClose={() => {
@@ -333,6 +527,61 @@ export default function SettingsAdvanced() {
});
}}
/>
<ConfirmDialog
open={showConfigEdit}
onClose={() => setShowConfigEdit(false)}
title={$at("Edit Configuration")}
description={
<div className="space-y-4">
<p className="text-xs text-slate-600 dark:text-slate-400">
{$at("Edit the raw configuration JSON. Be careful when making changes as invalid JSON can cause system issues.")}
</p>
<textarea
value={configContent}
onChange={e => setConfigContent(e.target.value)}
className="w-full h-64 p-3 font-mono text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:text-white"
spellCheck={false}
/>
</div>
}
variant="info"
cancelText={$at("Cancel")}
confirmText={isSavingConfig ? `${$at("Saving")}...` : $at("Save")}
onConfirm={handleSaveConfig}
isConfirming={isSavingConfig}
/>
<ConfirmDialog
open={showConfigSavedReboot}
onClose={() => setShowConfigSavedReboot(false)}
title={$at("Configuration Saved")}
description={
<>
<p>
{$at("Configuration has been saved successfully. Some changes may require a system restart to take effect.")}
</p>
<p className="text-xs text-slate-600 dark:text-slate-400 mt-2">
{$at("Would you like to restart the system now?")}
</p>
</>
}
variant="info"
cancelText={$at("Later")}
confirmText={$at("Restart Now")}
onConfirm={() => {
setShowConfigSavedReboot(false);
send("reboot", { force: false }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to reboot: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("System rebooting...");
});
}}
/>
</div>
);
}

View File

@@ -290,6 +290,7 @@ export default function SettingsHardware() {
value={settings.ledGreenMode.toString()}
className={`${isMobile?"w-full":"h-[36px] w-[22%]"}`}
options={[
{ value: "disabled", label: $at("Disabled") },
{ value: "network-link", label: $at("network-link") },
{ value: "network-tx", label: $at("network-tx") },
{ value: "network-rx", label: $at("network-rx") },
@@ -309,8 +310,9 @@ export default function SettingsHardware() {
<SelectMenuBasic
value={settings.ledYellowMode.toString()}
className={`${isMobile?"w-full":""}`}
className={`${isMobile?"w-full":"h-[36px] w-[22%]"}`}
options={[
{ value: "disabled", label: $at("Disabled") },
{ value: "network-link", label: $at("network-link") },
{ value: "network-tx", label: $at("network-tx") },
{ value: "network-rx", label: $at("network-rx") },

View File

@@ -39,6 +39,9 @@ dayjs.extend(relativeTime);
const defaultNetworkSettings: NetworkSettings = {
hostname: "",
domain: "",
http_proxy: "",
https_proxy: "",
all_proxy: "",
ipv4_mode: "unknown",
ipv6_mode: "unknown",
lldp_mode: "unknown",
@@ -85,6 +88,10 @@ export default function SettingsNetwork() {
const [networkSettings, setNetworkSettings] =
useState<NetworkSettings>(defaultNetworkSettings);
const [macAddressInput, setMacAddressInput] = useState<string>("");
const macAddressTouched = useRef(false);
const initialMacAddress = useRef<string>("");
// We use this to determine whether the settings have changed
const firstNetworkSettings = useRef<NetworkSettings | undefined>(undefined);
// We use this to indicate whether saved settings differ from initial (effective) settings
@@ -165,8 +172,37 @@ export default function SettingsNetwork() {
});
}, [send, setNetworkState]);
const normalizeMacAddress = useCallback((value: string) => {
return value.trim().toLowerCase();
}, []);
const isValidMacAddress = useCallback((value: string) => {
const v = normalizeMacAddress(value);
return /^([0-9a-f]{2}:){5}[0-9a-f]{2}$/.test(v);
}, [normalizeMacAddress]);
const setNetworkSettingsRemote = useCallback(
(settings: NetworkSettings) => {
const currentMac = (networkState?.mac_address || "").toLowerCase();
const newMac = normalizeMacAddress(macAddressInput);
const macChanged = newMac !== currentMac;
if (macChanged) {
if (!isValidMacAddress(macAddressInput)) {
notifications.error("Please enter a valid MAC address");
return;
}
setPendingMacAddress(newMac);
setShowMacChangeConfirm(true);
} else {
saveNetworkSettings(settings);
}
},
[networkState?.mac_address, macAddressInput, isValidMacAddress, normalizeMacAddress],
);
const saveNetworkSettings = useCallback(
(settings: NetworkSettings, onSaved?: () => void) => {
setNetworkSettingsLoaded(false);
send("setNetworkSettings", { settings }, resp => {
if ("error" in resp) {
@@ -177,12 +213,13 @@ export default function SettingsNetwork() {
setNetworkSettingsLoaded(true);
return;
}
// We need to update the firstNetworkSettings ref to the new settings so we can use it to determine if the settings have changed
firstNetworkSettings.current = resp.result as NetworkSettings;
setNetworkSettings(resp.result as NetworkSettings);
macAddressTouched.current = false;
getNetworkState();
setNetworkSettingsLoaded(true);
notifications.success("Network settings saved");
onSaved?.();
});
},
[getNetworkState, send],
@@ -203,6 +240,14 @@ export default function SettingsNetwork() {
getNetworkSettings();
}, [getNetworkState, getNetworkSettings]);
useEffect(() => {
if (networkState?.mac_address && initialMacAddress.current === "") {
const normalized = networkState.mac_address.toLowerCase();
setMacAddressInput(normalized);
initialMacAddress.current = normalized;
}
}, [networkState?.mac_address]);
const handleIpv4ModeChange = (value: IPv4Mode | string) => {
const newMode = value as IPv4Mode;
const updatedSettings: NetworkSettings = { ...networkSettings, ipv4_mode: newMode };
@@ -265,6 +310,10 @@ export default function SettingsNetwork() {
setNetworkSettings({ ...networkSettings, domain: value });
};
const handleProxyChange = (field: "http_proxy" | "https_proxy" | "all_proxy", value: string) => {
setNetworkSettings({ ...networkSettings, [field]: value });
};
const handleDomainOptionChange = (value: string) => {
setSelectedDomainOption(value);
if (value !== "custom") {
@@ -290,6 +339,8 @@ export default function SettingsNetwork() {
const [showRequestAddrConfirm, setShowRequestAddrConfirm] = useState(false);
const [showApplyStaticConfirm, setShowApplyStaticConfirm] = useState(false);
const [showIpv4RestartConfirm, setShowIpv4RestartConfirm] = useState(false);
const [showMacChangeConfirm, setShowMacChangeConfirm] = useState(false);
const [pendingMacAddress, setPendingMacAddress] = useState("");
const [pendingIpv4Mode, setPendingIpv4Mode] = useState<IPv4Mode | null>(null);
const [ipv4StaticDnsText, setIpv4StaticDnsText] = useState("");
@@ -336,19 +387,13 @@ export default function SettingsNetwork() {
>
<Input
type="text"
value={networkState?.mac_address}
readOnly={true}
value={macAddressInput}
onChange={e => {
macAddressTouched.current = true;
setMacAddressInput(e.target.value);
}}
className={isMobile ? "!w-full !h-[36px]" : "!w-[35%] !h-[36px]"}
/>
{/*<InputField*/}
{/* type="text"*/}
{/* size="SM"*/}
{/* value={networkState?.mac_address}*/}
{/* error={""}*/}
{/* readOnly={true}*/}
{/* className="dark:!text-opacity-60 "*/}
{/*/>*/}
</SettingsItem>
</div>
<div className="space-y-4">
@@ -499,11 +544,53 @@ export default function SettingsNetwork() {
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title={$at("HTTP Proxy")}
description={$at("Configure program HTTP proxy (optional)")}
className={`${isMobile ? "w-full flex-col" : ""}`}
>
<Input
type="text"
value={networkSettings.http_proxy || ""}
placeholder="http://127.0.0.1:7890"
onChange={e => handleProxyChange("http_proxy", e.target.value)}
className={isMobile ? "!w-full !h-[36px]" : "!w-[37%] !h-[36px]"}
/>
</SettingsItem>
<SettingsItem
title={$at("HTTPS Proxy")}
description={$at("Configure program HTTPS proxy (optional)")}
className={`${isMobile ? "w-full flex-col" : ""}`}
>
<Input
type="text"
value={networkSettings.https_proxy || ""}
placeholder="http://127.0.0.1:7890"
onChange={e => handleProxyChange("https_proxy", e.target.value)}
className={isMobile ? "!w-full !h-[36px]" : "!w-[37%] !h-[36px]"}
/>
</SettingsItem>
<SettingsItem
title={$at("ALL Proxy")}
description={$at("Configure program ALL proxy (optional)")}
className={`${isMobile ? "w-full flex-col" : ""}`}
>
<Input
type="text"
value={networkSettings.all_proxy || ""}
placeholder="socks5://127.0.0.1:7890"
onChange={e => handleProxyChange("all_proxy", e.target.value)}
className={isMobile ? "!w-full !h-[36px]" : "!w-[37%] !h-[36px]"}
/>
</SettingsItem>
</div>
<AntdButton
type="primary"
disabled={
firstNetworkSettings.current === networkSettings ||
(networkSettings.ipv4_mode === "static" && firstNetworkSettings.current?.ipv4_mode !== "static")
(!macAddressTouched.current && firstNetworkSettings.current === networkSettings) ||
(networkSettings.ipv4_mode === "static" && firstNetworkSettings.current?.ipv4_mode !== "static") ||
(macAddressTouched.current && !isValidMacAddress(macAddressInput))
}
onClick={() => setNetworkSettingsRemote(networkSettings)}
className={isMobile ? "w-full" : ""}
@@ -797,6 +884,31 @@ export default function SettingsNetwork() {
}
}}
/>
<ConfirmDialog
open={showMacChangeConfirm}
onClose={() => setShowMacChangeConfirm(false)}
title={$at("Change MAC Address?")}
description={$at("Changing the MAC address may cause the device IP to be reassigned and changed.")}
variant="warning"
confirmText={$at("Confirm")}
cancelText={$at("Cancel")}
onConfirm={() => {
setShowMacChangeConfirm(false);
send("setEthernetMacAddress", { macAddress: pendingMacAddress }, resp => {
if ("error" in resp) {
notifications.error(
"Failed to apply MAC address: " +
(resp.error.data ? resp.error.data : resp.error.message),
);
return;
}
setNetworkState(resp.result as NetworkState);
saveNetworkSettings(networkSettings, () => {
initialMacAddress.current = pendingMacAddress;
});
});
}}
/>
</>
);
}

View File

@@ -6,6 +6,7 @@ import { CheckCircleIcon } from "@heroicons/react/20/solid";
import { isMobile } from "react-device-detect";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useBootStorageType } from "@/hooks/useBootStorage";
import { SettingsPageHeader } from "@components/Settings/SettingsPageheader";
import { SettingsItem } from "@components/Settings/SettingsView";
import Card from "@components/Card";
@@ -14,12 +15,22 @@ import { Button } from "@components/Button";
import { InputFieldWithLabel } from "@components/InputField";
import { UpdateState, useDeviceStore, useUpdateStore } from "@/hooks/stores";
import notifications from "@/notifications";
import { formatters } from "@/utils";
export interface SystemVersionInfo {
local: { appVersion: string; systemVersion: string };
remote?: { appVersion: string; systemVersion: string };
systemUpdateAvailable: boolean;
appUpdateAvailable: boolean;
appSignatureMissing?: boolean;
systemSignatureMissing?: boolean;
appSignatureAbsent?: boolean;
systemSignatureAbsent?: boolean;
appSignatureInvalid?: boolean;
systemSignatureInvalid?: boolean;
appNoPublicKey?: boolean;
systemNoPublicKey?: boolean;
signatureVerified?: boolean;
error?: string;
}
@@ -33,10 +44,20 @@ export default function SettingsVersion() {
const [autoUpdate, setAutoUpdate] = useState(true);
const { $at } = useReactAt();
const { setModalView, otaState } = useUpdateStore();
const { bootStorageType } = useBootStorageType();
const isBootFromSD = bootStorageType === "sd";
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false);
const [signatureStatusLoading, setSignatureStatusLoading] = useState(true);
const [signatureStatus, setSignatureStatus] = useState<{
appSignatureAbsent: boolean;
appSignatureInvalid: boolean;
appNoPublicKey: boolean;
signatureVerified: boolean;
} | null>(null);
const updatePanelRef = useRef<HTMLDivElement | null>(null);
const [updateSource, setUpdateSource] = useState("github");
const [customUpdateBaseURL, setCustomUpdateBaseURL] = useState("");
const [updateDownloadProxy, setUpdateDownloadProxy] = useState("");
const currentVersions = useDeviceStore(state => {
const { appVersion, systemVersion } = state;
@@ -58,6 +79,13 @@ export default function SettingsVersion() {
});
}, [send]);
useEffect(() => {
send("getUpdateDownloadProxy", {}, resp => {
if ("error" in resp) return;
setUpdateDownloadProxy(resp.result as string);
});
}, [send]);
const handleAutoUpdateChange = (enabled: boolean) => {
send("setAutoUpdateState", { enabled }, resp => {
if ("error" in resp) {
@@ -70,6 +98,23 @@ export default function SettingsVersion() {
});
};
useEffect(() => {
setSignatureStatusLoading(true);
send("getSelfSignatureStatus", {}, resp => {
setSignatureStatusLoading(false);
if ("error" in resp) return;
const sigStatus = resp.result as {
appSignatureAbsent: boolean;
appSignatureInvalid: boolean;
appNoPublicKey: boolean;
};
const hasSigFiles = !sigStatus.appSignatureAbsent;
const noPublicKey = sigStatus.appNoPublicKey;
const signatureVerified = hasSigFiles && !noPublicKey && !sigStatus.appSignatureInvalid;
setSignatureStatus({ ...sigStatus, signatureVerified });
});
}, [send]);
const applyUpdateSource = useCallback(
(source: string) => {
send("setUpdateSource", { source }, resp => {
@@ -96,6 +141,18 @@ export default function SettingsVersion() {
});
}, [customUpdateBaseURL, send]);
const applyUpdateDownloadProxy = useCallback(() => {
send("setUpdateDownloadProxy", { proxy: updateDownloadProxy }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to save update download proxy: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("Update download proxy applied");
});
}, [send, updateDownloadProxy]);
const closeUpdateDialog = useCallback(() => {
setIsUpdateDialogOpen(false);
}, []);
@@ -181,19 +238,28 @@ export default function SettingsVersion() {
}
/>
<UpdateSourceSettings
updateSource={updateSource}
onUpdateSourceChange={applyUpdateSource}
customUpdateBaseURL={customUpdateBaseURL}
onCustomUpdateBaseURLChange={setCustomUpdateBaseURL}
onSaveCustomUpdateBaseURL={applyCustomUpdateBaseURL}
<SignatureStatusCard
signatureStatus={signatureStatus}
signatureStatusLoading={signatureStatusLoading}
/>
<div className="flex items-center justify-start">
<AntdButton type="primary" onClick={checkForUpdates} className={isMobile ? "w-full" : ""}>
{$at("Check for Updates")}
</AntdButton>
</div>
{!isBootFromSD && (
<>
<UpdateSourceSettings
updateSource={updateSource}
onUpdateSourceChange={applyUpdateSource}
customUpdateBaseURL={customUpdateBaseURL}
onCustomUpdateBaseURLChange={setCustomUpdateBaseURL}
onSaveCustomUpdateBaseURL={applyCustomUpdateBaseURL}
/>
<div className="flex items-center justify-start">
<AntdButton type="primary" onClick={checkForUpdates} className={isMobile ? "w-full" : ""}>
{$at("Check for Updates")}
</AntdButton>
</div>
</>
)}
<div className="hidden">
<SettingsItem
@@ -211,7 +277,14 @@ export default function SettingsVersion() {
{isUpdateDialogOpen && (
<div ref={updatePanelRef} className="pt-2">
<UpdateContent onClose={closeUpdateDialog} onConfirmUpdate={onConfirmUpdate} />
<UpdateContent
onClose={closeUpdateDialog}
onConfirmUpdate={onConfirmUpdate}
updateSource={updateSource}
updateDownloadProxy={updateDownloadProxy}
onUpdateDownloadProxyChange={setUpdateDownloadProxy}
onSaveUpdateDownloadProxy={applyUpdateDownloadProxy}
/>
</div>
)}
</div>
@@ -278,9 +351,17 @@ function UpdateSourceSettings({
function UpdateContent({
onClose,
onConfirmUpdate,
updateSource,
updateDownloadProxy,
onUpdateDownloadProxyChange,
onSaveUpdateDownloadProxy,
}: {
onClose: () => void;
onConfirmUpdate: () => void;
updateSource: string;
updateDownloadProxy: string;
onUpdateDownloadProxyChange: (proxy: string) => void;
onSaveUpdateDownloadProxy: () => void;
}) {
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
const { modalView, setModalView, otaState } = useUpdateStore();
@@ -324,6 +405,10 @@ function UpdateContent({
onConfirmUpdate={onConfirmUpdate}
onClose={onClose}
versionInfo={versionInfo!}
updateSource={updateSource}
updateDownloadProxy={updateDownloadProxy}
onUpdateDownloadProxyChange={onUpdateDownloadProxyChange}
onSaveUpdateDownloadProxy={onSaveUpdateDownloadProxy}
/>
)}
@@ -335,6 +420,7 @@ function UpdateContent({
<SystemUpToDateState
checkUpdate={() => setModalView("loading")}
onClose={onClose}
versionInfo={versionInfo}
/>
)}
@@ -360,23 +446,58 @@ function LoadingState({
const getVersionInfo = useCallback(() => {
return new Promise<SystemVersionInfo>((resolve, reject) => {
send("getUpdateStatus", {}, async resp => {
if ("error" in resp) {
notifications.error(`Failed to check for updates: ${resp.error}`);
reject(new Error("Failed to check for updates"));
} else {
const result = resp.result as SystemVersionInfo;
setAppVersion(result.local.appVersion);
setSystemVersion(result.local.systemVersion);
if (result.error) {
notifications.error(`Failed to check for updates: ${result.error}`);
reject(new Error("Failed to check for updates"));
} else {
resolve(result);
}
}
});
Promise.all([
new Promise<SystemVersionInfo>((res, rej) => {
send("getUpdateStatus", {}, resp => {
if ("error" in resp) {
notifications.error(`Failed to check for updates: ${resp.error}`);
rej(new Error("Failed to check for updates"));
} else {
const result = resp.result as SystemVersionInfo;
setAppVersion(result.local.appVersion);
setSystemVersion(result.local.systemVersion);
if (result.error) {
notifications.error(`Failed to check for updates: ${result.error}`);
rej(new Error("Failed to check for updates"));
} else {
res(result);
}
}
});
}),
new Promise<SystemVersionInfo>((res, rej) => {
send("getSelfSignatureStatus", {}, resp => {
if ("error" in resp) {
rej(new Error("Failed to get signature status"));
} else {
const sigStatus = resp.result as {
appSignatureAbsent: boolean;
appSignatureInvalid: boolean;
appNoPublicKey: boolean;
};
const hasSigFiles = !sigStatus.appSignatureAbsent;
const signatureVerified = hasSigFiles && !sigStatus.appNoPublicKey && !sigStatus.appSignatureInvalid;
const partial: Partial<SystemVersionInfo> = {
appSignatureAbsent: sigStatus.appSignatureAbsent,
appSignatureInvalid: sigStatus.appSignatureInvalid,
appNoPublicKey: sigStatus.appNoPublicKey,
signatureVerified,
};
res(partial as SystemVersionInfo);
}
});
}),
])
.then(([versionResult, sigResult]) => {
resolve({
...versionResult,
appSignatureAbsent: sigResult.appSignatureAbsent,
appSignatureInvalid: sigResult.appSignatureInvalid,
appNoPublicKey: sigResult.appNoPublicKey,
signatureVerified: sigResult.signatureVerified,
});
})
.catch(reject);
});
}, [send, setAppVersion, setSystemVersion]);
@@ -482,11 +603,14 @@ function UpdatingDeviceState({
const downloadFinishedAt = otaState[`${type}DownloadFinishedAt`];
const verfiedAt = otaState[`${type}VerifiedAt`];
const updatedAt = otaState[`${type}UpdatedAt`];
const downloadSpeedBps = (otaState as any)[`${type}DownloadSpeedBps`] as number | undefined;
const formattedSpeed =
downloadSpeedBps && downloadSpeedBps > 0 ? `${formatters.bytes(downloadSpeedBps, 1)}/s` : null;
if (!otaState.metadataFetchedAt) {
return "Fetching update information...";
} else if (!downloadFinishedAt) {
return `Downloading ${type} update...`;
return formattedSpeed ? `Downloading ${type} update... (${formattedSpeed})` : `Downloading ${type} update...`;
} else if (!verfiedAt) {
return `Verifying ${type} update...`;
} else if (!updatedAt) {
@@ -619,11 +743,50 @@ function UpdatingDeviceState({
function SystemUpToDateState({
checkUpdate,
onClose,
versionInfo,
}: {
checkUpdate: () => void;
onClose: () => void;
versionInfo: SystemVersionInfo | null;
}) {
const { $at } = useReactAt();
const [send] = useJsonRpc();
const [sigUpdateLoading, setSigUpdateLoading] = useState(false);
const [sigUpdateResult, setSigUpdateResult] = useState<string | null>(null);
const hasAbsentSig = versionInfo?.appSignatureAbsent;
const hasInvalidSig = versionInfo?.appSignatureInvalid;
const hasNoPublicKey = versionInfo?.appNoPublicKey;
const handleUpdateSignatures = useCallback(() => {
setSigUpdateLoading(true);
setSigUpdateResult(null);
send("updateSignatures", {}, resp => {
setSigUpdateLoading(false);
if ("error" in resp) {
setSigUpdateResult(`Failed: ${resp.error.data || "Unknown error"}`);
notifications.error(`Signature update failed: ${resp.error.data || "Unknown error"}`);
} else {
const result = resp.result as {
appSignatureUpdated: boolean;
systemSignatureUpdated: boolean;
appSignatureValid: boolean;
systemSignatureValid: boolean;
error?: string;
};
if (result.error) {
setSigUpdateResult(`Failed: ${result.error}`);
notifications.error(`Signature update failed: ${result.error}`);
} else {
const parts: string[] = [];
if (result.appSignatureUpdated) parts.push("App signature updated");
if (result.systemSignatureUpdated) parts.push("System signature updated");
setSigUpdateResult(parts.join(", ") || "No signatures to update");
notifications.success("Signatures updated successfully");
}
}
});
}, [send]);
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left">
@@ -634,6 +797,39 @@ function SystemUpToDateState({
{$at("Your system is running the latest version. No updates are currently available.")}
</p>
{hasAbsentSig && (
<div className="mt-4 rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
{$at("Missing Signature File")}
</p>
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
{$at("The current firmware is missing signature files. Integrity cannot be fully verified.")}
</p>
</div>
)}
{hasInvalidSig && (
<div className="mt-4 rounded-md border border-red-500 bg-red-50 p-3 dark:border-red-600 dark:bg-red-900/30">
<p className="text-sm font-medium text-red-800 dark:text-red-200">
{$at("Signature Verification Failed")}
</p>
<p className="mt-1 text-xs text-red-700 dark:text-red-300">
{$at("The signature file exists but does not match the firmware. This may indicate tampering.")}
</p>
</div>
)}
{hasNoPublicKey && (
<div className="mt-4 rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
{$at("No Embedded Public Key")}
</p>
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
{$at("This build does not have an OTA public key embedded. Signature verification is unavailable.")}
</p>
</div>
)}
<div className="mt-4 flex gap-x-2">
<AntdButton type="primary" onClick={checkUpdate}>
{$at("Check Again")}
@@ -642,6 +838,32 @@ function SystemUpToDateState({
{$at("Back")}
</AntdButton>
</div>
<p className="text-base font-semibold text-black dark:text-white">
{$at("Update Signatures")}
</p>
<p className="mb-2 text-sm text-slate-600 dark:text-slate-300">
{$at("Update the signature of kvm_app to the latest version. If the current version is not up to date, signature verification will fail.")}
</p>
{sigUpdateResult && (
<div className="rounded-md border border-blue-500 bg-blue-50 p-3 dark:border-blue-600 dark:bg-blue-900/30">
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">
{$at("Signature Update Result")}
</p>
<p className="mt-1 text-xs text-blue-700 dark:text-blue-300">
{sigUpdateResult}
</p>
</div>
)}
<div className="space-y-4">
<div className="flex items-center justify-start gap-x-2">
<AntdButton type="primary" loading={sigUpdateLoading} onClick={handleUpdateSignatures}>
{$at("Update")}
</AntdButton>
</div>
</div>
</div>
</div>
);
@@ -651,12 +873,54 @@ function UpdateAvailableState({
versionInfo,
onConfirmUpdate,
onClose,
updateSource,
updateDownloadProxy,
onUpdateDownloadProxyChange,
onSaveUpdateDownloadProxy,
}: {
versionInfo: SystemVersionInfo;
onConfirmUpdate: () => void;
onClose: () => void;
updateSource: string;
updateDownloadProxy: string;
onUpdateDownloadProxyChange: (proxy: string) => void;
onSaveUpdateDownloadProxy: () => void;
}) {
const { $at } = useReactAt();
const [send] = useJsonRpc();
const [sigUpdateLoading, setSigUpdateLoading] = useState(false);
const [sigUpdateResult, setSigUpdateResult] = useState<string | null>(null);
const handleUpdateSignatures = useCallback(() => {
setSigUpdateLoading(true);
setSigUpdateResult(null);
send("updateSignatures", {}, resp => {
setSigUpdateLoading(false);
if ("error" in resp) {
setSigUpdateResult(`Failed: ${resp.error.data || "Unknown error"}`);
notifications.error(`Signature update failed: ${resp.error.data || "Unknown error"}`);
} else {
const result = resp.result as {
appSignatureUpdated: boolean;
systemSignatureUpdated: boolean;
appSignatureValid: boolean;
systemSignatureValid: boolean;
error?: string;
};
if (result.error) {
setSigUpdateResult(`Failed: ${result.error}`);
notifications.error(`Signature update failed: ${result.error}`);
} else {
const parts: string[] = [];
if (result.appSignatureUpdated) parts.push("App signature updated");
if (result.systemSignatureUpdated) parts.push("System signature updated");
setSigUpdateResult(parts.join(", ") || "No signatures to update");
notifications.success("Signatures updated successfully");
}
}
});
}, [send]);
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="w-full space-y-4">
@@ -681,6 +945,21 @@ function UpdateAvailableState({
) : null}
</p>
{updateSource === "github" && (
<div className="mb-4 flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label={$at("Download Proxy Prefix")}
value={updateDownloadProxy}
onChange={e => onUpdateDownloadProxyChange(e.target.value)}
placeholder="https://gh-proxy.com/"
/>
<AntdButton type="primary" onClick={onSaveUpdateDownloadProxy}>
{$at("Apply")}
</AntdButton>
</div>
)}
<div className="space-y-4">
<div className="flex items-center justify-start gap-x-2">
<AntdButton type="primary" onClick={onConfirmUpdate}>
@@ -691,6 +970,32 @@ function UpdateAvailableState({
</AntdButton>
</div>
</div>
<p className="text-base font-semibold text-black dark:text-white">
{$at("Update Signatures")}
</p>
<p className="mb-2 text-sm text-slate-600 dark:text-slate-300">
{$at("Update the signature of kvm_app to the latest version. If the current version is not up to date, signature verification will fail.")}
</p>
{sigUpdateResult && (
<div className="rounded-md border border-blue-500 bg-blue-50 p-3 dark:border-blue-600 dark:bg-blue-900/30">
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">
{$at("Signature Update Result")}
</p>
<p className="mt-1 text-xs text-blue-700 dark:text-blue-300">
{sigUpdateResult}
</p>
</div>
)}
<div className="space-y-4">
<div className="flex items-center justify-start gap-x-2">
<AntdButton type="primary" loading={sigUpdateLoading} onClick={handleUpdateSignatures}>
{$at("Update")}
</AntdButton>
</div>
</div>
</div>
</div>
</div>
@@ -752,3 +1057,101 @@ function UpdateErrorState({
</div>
);
}
function SignatureStatusCard({
signatureStatus,
signatureStatusLoading,
}: {
signatureStatus: {
appSignatureAbsent: boolean;
appSignatureInvalid: boolean;
appNoPublicKey: boolean;
signatureVerified: boolean;
} | null;
signatureStatusLoading: boolean;
}) {
const { $at } = useReactAt();
if (signatureStatusLoading) {
return (
<div className="rounded-md border border-slate-300 bg-slate-50 p-3 dark:border-slate-600 dark:bg-slate-900/30">
<div className="flex items-center gap-x-2">
<LoadingSpinner className="h-4 w-4 text-[rgba(22,152,217,1)] dark:text-[rgba(45,106,229,1)]" />
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
{$at("Verifying signature...")}
</p>
</div>
<p className="mt-1 text-xs text-slate-600 dark:text-slate-300">
{$at("Please wait while verifying firmware signature.")}
</p>
</div>
);
}
if (!signatureStatus) {
return (
<div className="rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
{$at("Signature Status Unavailable")}
</p>
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
{$at("Unable to retrieve signature verification status.")}
</p>
</div>
);
}
if (signatureStatus.signatureVerified) {
return (
<div className="rounded-md border border-green-500 bg-green-50 p-3 dark:border-green-600 dark:bg-green-900/30">
<p className="text-sm font-medium text-green-800 dark:text-green-200">
<CheckCircleIcon className="inline h-4 w-4 mr-1" />
{$at("Signature Verified")}
</p>
<p className="mt-1 text-xs text-green-700 dark:text-green-300">
{$at("Firmware signature has been verified and is valid.")}
</p>
</div>
);
}
if (signatureStatus.appSignatureAbsent) {
return (
<div className="rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
{$at("Missing Signature File")}
</p>
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
{$at("The current firmware is missing signature files. Integrity cannot be fully verified.")}
</p>
</div>
);
}
if (signatureStatus.appSignatureInvalid) {
return (
<div className="rounded-md border border-red-500 bg-red-50 p-3 dark:border-red-600 dark:bg-red-900/30">
<p className="text-sm font-medium text-red-800 dark:text-red-200">
{$at("Signature Verification Failed")}
</p>
<p className="mt-1 text-xs text-red-700 dark:text-red-300">
{$at("The signature file exists but does not match the firmware. This may indicate tampering.")}
</p>
</div>
);
}
if (signatureStatus.appNoPublicKey) {
return (
<div className="rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
{$at("No Embedded Public Key")}
</p>
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
{$at("This build does not have an OTA public key embedded. Signature verification is unavailable.")}
</p>
</div>
);
}
return null;
}

View File

@@ -1,16 +1,18 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
import { useClose } from "@headlessui/react";
import { Checkbox, Button } from "antd";
import { Checkbox, Button, Input } from "antd";
import { useReactAt } from "i18n-auto-extractor/react";
import { isMobile } from "react-device-detect";
import { TextAreaWithLabel } from "@components/TextArea";
import { SettingsItem } from "@components/Settings/SettingsView";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useHidStore, useRTCStore, useUiStore, useSettingsStore } from "@/hooks/stores";
import { useHidStore, useRTCStore, useUiStore, useSettingsStore, useVideoStore } from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings";
import { layouts, chars } from "@/keyboardLayouts";
import notifications from "@/notifications";
import { eventMatchesShortcut, shortcutFromKeyboardEvent } from "@/utils/shortcuts";
const hidKeyboardPayload = (keys: number[], modifier: number) => {
return { keys, modifier };
@@ -28,15 +30,24 @@ export default function Clipboard() {
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const setSidebarView = useUiStore(state => state.setSidebarView);
const toggleTopBarView = useUiStore(state => state.toggleTopBarView);
const isOcrMode = useUiStore(state => state.isOcrMode);
const setOcrMode = useUiStore(state => state.setOcrMode);
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
const videoWidth = useVideoStore(state => state.width);
const videoHeight = useVideoStore(state => state.height);
const [send] = useJsonRpc();
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const [invalidChars, setInvalidChars] = useState<string[]>([]);
const close = useClose();
const overrideCtrlV = useSettingsStore(state => state.overrideCtrlV);
const setOverrideCtrlV = useSettingsStore(state => state.setOverrideCtrlV);
const [pasteBuffer, setPasteBuffer] = useState<string>("");
const pasteShortcutEnabled = useSettingsStore(state => state.pasteShortcutEnabled);
const setPasteShortcutEnabled = useSettingsStore(state => state.setPasteShortcutEnabled);
const pasteShortcut = useSettingsStore(state => state.pasteShortcut);
const setPasteShortcut = useSettingsStore(state => state.setPasteShortcut);
const ocrShortcutEnabled = useSettingsStore(state => state.ocrShortcutEnabled);
const setOcrShortcutEnabled = useSettingsStore(state => state.setOcrShortcutEnabled);
const ocrShortcut = useSettingsStore(state => state.ocrShortcut);
const setOcrShortcut = useSettingsStore(state => state.setOcrShortcut);
const [readyToRender, setReadyToRender] = useState(false);
useEffect(() => {
@@ -127,7 +138,6 @@ export default function Clipboard() {
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, safeKeyboardLayout]);
const handleTextSend = useCallback(async (text: string) => {
setPasteBuffer(text);
const segInvalid = [
...new Set(
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
@@ -192,12 +202,35 @@ export default function Clipboard() {
}, [handleTextSend]);
useEffect(() => {
// When overrideCtrlV is true, we want to focus the container div to capture paste events
// When it is false, we want to focus the textarea if it exists
if (!overrideCtrlV && TextAreaRef.current) {
if (readyToRender && TextAreaRef.current) {
TextAreaRef.current.focus();
}
}, [readyToRender, overrideCtrlV]);
}, [readyToRender]);
const handleShortcutInput = useCallback(
(setter: (shortcut: string) => void) => (e: React.KeyboardEvent<HTMLInputElement>) => {
e.preventDefault();
e.stopPropagation();
const shortcut = shortcutFromKeyboardEvent(e.nativeEvent);
if (!shortcut) return;
setter(shortcut);
},
[],
);
const handleOpenOcr = useCallback(() => {
if (videoWidth === 0 || videoHeight === 0) {
notifications.error($at("No video signal"));
return;
}
setOcrMode(!isOcrMode);
close();
if (isMobile) {
toggleTopBarView("ClipboardMobile");
} else {
setSidebarView(null);
}
}, [videoWidth, videoHeight, $at, setOcrMode, isOcrMode, close, toggleTopBarView, setSidebarView]);
return (
<div className="space-y-4 py-3" >
@@ -205,32 +238,42 @@ export default function Clipboard() {
<div className="h-full space-y-4">
<div className="space-y-4">
<div className="flex items-center">
<Checkbox
checked={overrideCtrlV}
onChange={e => setOverrideCtrlV(e.target.checked)}
>
{$at("Use Ctrl+V to paste clipboard to remote")}
</Checkbox>
<div className="grid grid-cols-[minmax(0,1fr)_140px] items-center gap-2 sm:grid-cols-[minmax(0,1fr)_180px]">
<Checkbox
className="min-w-0"
checked={pasteShortcutEnabled}
onChange={e => setPasteShortcutEnabled(e.target.checked)}
>
<span className="whitespace-normal break-words">
{$at("Enable paste shortcut")}
</span>
</Checkbox>
<Input
size="small"
value={pasteShortcut}
onKeyDown={handleShortcutInput(setPasteShortcut)}
onChange={() => void 0}
className="w-full"
/>
</div>
<div className="w-full px-1 outline-none"
tabIndex={overrideCtrlV ? 0 : -1}
tabIndex={pasteShortcutEnabled ? 0 : -1}
ref={(el) => {
if (el && overrideCtrlV && readyToRender) {
if (el && pasteShortcutEnabled && readyToRender) {
el.focus();
}
}}
onKeyUp={e => e.stopPropagation()}
onKeyDown={e => {
e.stopPropagation();
if (overrideCtrlV && (e.key.toLowerCase() === "v" || e.code === "KeyV") && (e.metaKey || e.ctrlKey)) {
if (pasteShortcutEnabled && eventMatchesShortcut(e.nativeEvent, pasteShortcut)) {
e.preventDefault();
readClipboardToBufferAndSend();
}
}}
onPaste={e => {
if (overrideCtrlV) {
if (pasteShortcutEnabled) {
e.preventDefault();
const txt = e.clipboardData?.getData("text") || "";
if (txt) {
@@ -240,7 +283,7 @@ export default function Clipboard() {
}
}
}}>
{!overrideCtrlV && readyToRender && <TextAreaWithLabel
{readyToRender && <TextAreaWithLabel
ref={TextAreaRef}
label={$at("Copy text from your client to the remote host")}
rows={4}
@@ -295,32 +338,50 @@ export default function Clipboard() {
</div>
<div
className="flex animate-fadeIn opacity-0 items-center justify-start gap-x-2"
className="flex animate-fadeIn opacity-0 flex-col gap-y-2"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
type="primary"
className={isMobile ? "w-[49%]" : ""}
className="w-full"
onClick={onConfirmPaste}
>
{$at("Confirm paste")}</Button>
<Button
className={isMobile ? "w-[49%]" : ""}
onClick={() => {
onCancelPasteMode();
close();
if(isMobile){
toggleTopBarView("ClipboardMobile");
}else{
setSidebarView(null)
}
}}
>{$at("Cancel")}</Button>
<div className="grid grid-cols-[minmax(0,1fr)_140px] items-center gap-2 sm:grid-cols-[minmax(0,1fr)_180px]">
<Checkbox
className="min-w-0"
checked={ocrShortcutEnabled}
onChange={e => setOcrShortcutEnabled(e.target.checked)}
>
<span className="whitespace-normal break-words">
{$at("Enable OCR shortcut")}
</span>
</Checkbox>
<Input
size="small"
value={ocrShortcut}
onKeyDown={handleShortcutInput(setOcrShortcut)}
onChange={() => void 0}
className="w-full"
/>
</div>
<SettingsItem
title={$at("OCR")}
description={$at("Open OCR selection mode on the video area")}
>
<Button
type="primary"
className={`${isMobile ? "w-full" : ""}`}
onClick={handleOpenOcr}
>
{$at("Open")}
</Button>
</SettingsItem>
</div>
</div>

View File

@@ -117,16 +117,17 @@ const SettingsMacrosEdit: React.FC<MenuComponentProps> = ({ onMenuSelect,macroId
disabled={isDeleting}
/>
</div>
<MacroForm
initialData={macro}
onSubmit={handleUpdateMacro}
onCancel={() => {
console.log("MacroForm onCancel")
onMenuSelect("index");
}}
isSubmitting={isUpdating}
/>
<div onKeyUp={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}>
<MacroForm
initialData={macro}
onSubmit={handleUpdateMacro}
onCancel={() => {
console.log("MacroForm onCancel")
onMenuSelect("index");
}}
isSubmitting={isUpdating}
/>
</div>
<ConfirmDialog
open={showDeleteConfirm}

View File

@@ -65,7 +65,10 @@ interface StorageFilePageProps {
showSDManagement?: boolean;
onResetSDStorage?: () => void;
onUnmountSDStorage?: () => void;
onFormatSDStorage?: () => void;
onMountSDStorage?: () => void;
fsType?: 'exfat' | 'fat32';
onFsTypeChange?: (value: 'exfat' | 'fat32') => void;
}
export const FileManager: React.FC<StorageFilePageProps> = ({
@@ -77,6 +80,9 @@ export const FileManager: React.FC<StorageFilePageProps> = ({
showSDManagement = false,
onResetSDStorage,
onUnmountSDStorage,
onFormatSDStorage,
fsType,
onFsTypeChange,
}) => {
const { $at } = useReactAt();
@@ -212,6 +218,16 @@ export const FileManager: React.FC<StorageFilePageProps> = ({
}
}, [onResetSDStorage, syncStorage]);
const handleFormatWrapper = useCallback(async () => {
if (onFormatSDStorage) {
setLoading(true);
await onFormatSDStorage();
setSDMountStatus(null);
syncStorage();
setLoading(false);
}
}, [onFormatSDStorage, syncStorage]);
if (mediaType === "sd" && sdMountStatus && sdMountStatus !== "ok") {
return (
<div className="mx-auto max-w-4xl py-8">
@@ -237,6 +253,32 @@ export const FileManager: React.FC<StorageFilePageProps> = ({
? $at("Please insert an SD card and try again.")
: $at("Please format the SD card and try again.")}
</p>
{sdMountStatus !== "none" && (
<div className="pt-2">
<div className="w-full space-y-2">
<p className="w-full text-left text-xs text-slate-700 dark:text-slate-300">
{$at("Choose the file system for MicroSD formatting")}
</p>
<select
value={fsType || "fat32"}
onChange={(e) => onFsTypeChange?.(e.target.value as 'exfat' | 'fat32')}
style={{ width: "100%", padding: "8px", borderRadius: "4px" }}
>
<option value="fat32">FAT32</option>
<option value="exfat">exFAT</option>
</select>
<AntdButton
disabled={loading}
danger={true}
type="primary"
onClick={handleFormatWrapper}
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
>
{$at("Format MicroSD Card")} ({(fsType || "fat32")})
</AntdButton>
</div>
</div>
)}
</div>
</div>
@@ -291,7 +333,10 @@ export const FileManager: React.FC<StorageFilePageProps> = ({
showSDManagement={showSDManagement}
onNewImageClick={handleNewImageClick}
onUnmountSDStorage={handleUnmountWrapper}
onFormatSDStorage={handleFormatWrapper}
syncStorage={syncStorage}
fsType={fsType}
onFsTypeChange={onFsTypeChange}
/>
{uploadFile ? (
@@ -476,23 +521,48 @@ interface ActionButtonsSectionProps {
showSDManagement?: boolean;
onNewImageClick: (incompleteFileName?: string) => void;
onUnmountSDStorage?: () => void;
onFormatSDStorage?: () => void;
syncStorage: () => void;
fsType?: 'exfat' | 'fat32';
onFsTypeChange?: (value: 'exfat' | 'fat32') => void;
}
const ActionButtonsSection: React.FC<ActionButtonsSectionProps> = ({
mediaType,
loading,
showSDManagement,
onUnmountSDStorage,
}) => {
mediaType,
loading,
showSDManagement,
onUnmountSDStorage,
onFormatSDStorage,
fsType,
onFsTypeChange,
}) => {
const { $at } = useReactAt();
if (mediaType === "sd" && showSDManagement) {
return (
<div className="flex animate-fadeIn justify-between opacity-0"
<div className="animate-fadeIn space-y-2 opacity-0"
style={{ animationDuration: "0.7s", animationDelay: "0.25s" }}
>
<div className="w-full space-y-2">
<p className="w-full text-left text-xs text-slate-700 dark:text-slate-300">
{$at("Choose the file system for MicroSD formatting")}
</p>
<select
value={fsType || "fat32"}
onChange={(e) => onFsTypeChange?.(e.target.value as 'exfat' | 'fat32')}
style={{ width: "100%", padding: "8px", borderRadius: "4px" }}
>
<option value="fat32">FAT32</option>
<option value="exfat">exFAT</option>
</select>
<AntdButton
disabled={loading}
type="primary"
danger={true}
onClick={onFormatSDStorage}
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
>{$at("Format MicroSD Card")} ({(fsType || "fat32")})</AntdButton>
</div>
<AntdButton
disabled={loading}
type="primary"

View File

@@ -1,12 +1,15 @@
import { useState } from "react";
import { useReactAt } from "i18n-auto-extractor/react";
import { FileManager } from "@/layout/components_side/SharedFolders/FileManager";
import notifications from "@/notifications";
import { useJsonRpc } from "@/hooks/useJsonRpc";
export default function SDFilePage() {
const { $at } = useReactAt();
const [send] = useJsonRpc();
const [loading, setLoading] = useState(false);
const [fsType, setFsType] = useState<'exfat' | 'fat32'>('fat32');
const handleResetSDStorage = async () => {
setLoading(true);
@@ -34,17 +37,39 @@ export default function SDFilePage() {
setLoading(false);
};
const handleFormatSDStorage = async () => {
if (!window.confirm($at(`Formatting the SD card as ${fsType.toUpperCase()} will erase all data. Continue?`))) {
return;
}
setLoading(true);
send("formatSDStorage", { confirm: true, fsType }, res => {
if ("error" in res) {
notifications.error(res.error.data || res.error.message);
setLoading(false);
return;
}
notifications.success($at("SD card formatted successfully"));
});
await new Promise(resolve => setTimeout(resolve, 2000));
setLoading(false);
};
return (
<FileManager
mediaType="sd"
returnTo="/sd-files"
listFilesMethod="listSDStorageFiles"
getSpaceMethod="getSDStorageSpace"
deleteFileMethod="deleteSDStorageFile"
downloadUrlPrefix="/storage/sd-download"
showSDManagement={true}
onResetSDStorage={handleResetSDStorage}
onUnmountSDStorage={handleUnmountSDStorage}
/>
<>
<FileManager
mediaType="sd"
returnTo="/sd-files"
listFilesMethod="listSDStorageFiles"
getSpaceMethod="getSDStorageSpace"
deleteFileMethod="deleteSDStorageFile"
downloadUrlPrefix="/storage/sd-download"
showSDManagement={true}
onResetSDStorage={handleResetSDStorage}
onUnmountSDStorage={handleUnmountSDStorage}
onFormatSDStorage={handleFormatSDStorage}
fsType={fsType}
onFsTypeChange={setFsType}
/>
</>
);
}

View File

@@ -3,9 +3,16 @@ import React from "react";
import SideTabs from "@components/Sidebar/SideTabs";
import DeviceFilePage from "@/layout/components_side/SharedFolders/DeviceFilePage";
import SDFilePage from "@/layout/components_side/SharedFolders/SDFilePage";
import { useBootStorageType } from "@/hooks/useBootStorage";
const SharedFolders: React.FC = () => {
const { bootStorageType } = useBootStorageType();
const isBootFromSD = bootStorageType === "sd";
if (isBootFromSD) {
return <DeviceFilePage />;
}
return (
<SideTabs

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { Button as AntdButton , Slider , Checkbox, Select } from "antd";
import { Button as AntdButton , Slider , Checkbox, Select, Modal, InputNumber, Tabs, Typography } from "antd";
import { useReactAt } from "i18n-auto-extractor/react";
import { isMobile } from "react-device-detect";
@@ -10,6 +10,7 @@ import { SettingsItem, SettingsItemNew } from "@components/Settings/SettingsView
import notifications from "../../../notifications";
const { Text } = Typography;
@@ -45,6 +46,133 @@ const streamQualityOptions = [
{ value: "0.1", label: "Low" },
];
type RcQpParams = {
s32FirstFrameStartQp: number;
u32StepQp: number;
u32MinQp: number;
u32MaxQp: number;
u32MinIQp: number;
u32MaxIQp: number;
s32DeltIpQp: number;
s32MaxReEncodeTimes: number;
u32FrmMaxQp: number;
u32FrmMinQp: number;
u32FrmMinIQp: number;
u32FrmMaxIQp: number;
u32MotionStaticSwitchFrmQp: number;
};
type VideoRcConfig = {
h264: RcQpParams;
h265: RcQpParams;
};
type RcSliderValues = {
stepQp: number;
minQp: number;
minIQp: number;
deltIpQp: number;
};
type RcSliderState = {
h264: RcSliderValues;
h265: RcSliderValues;
};
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
const sliderValueToNumber = (value: number | number[]) =>
Array.isArray(value) ? value[0] : value;
const DEFAULT_RC_CODEC: RcQpParams = {
s32FirstFrameStartQp: 0,
u32StepQp: 48,
u32MinQp: 48,
u32MaxQp: 51,
u32MinIQp: 48,
u32MaxIQp: 51,
s32DeltIpQp: 7,
s32MaxReEncodeTimes: 2,
u32FrmMaxQp: 51,
u32FrmMinQp: 48,
u32FrmMinIQp: 51,
u32FrmMaxIQp: 48,
u32MotionStaticSwitchFrmQp: 50,
};
const DEFAULT_VIDEO_RC_CONFIG: VideoRcConfig = {
h264: { ...DEFAULT_RC_CODEC },
h265: { ...DEFAULT_RC_CODEC },
};
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
const toVideoRcConfigOrDefault = (value: unknown): VideoRcConfig => {
if (!isObjectRecord(value)) return DEFAULT_VIDEO_RC_CONFIG;
if (!("h264" in value) || !("h265" in value)) return DEFAULT_VIDEO_RC_CONFIG;
return value as VideoRcConfig;
};
const RC_LIMITS = {
stepQp: { min: 1, max: 51 },
maxQp: { min: 1, max: 51 },
maxIQp: { min: 1, max: 51 },
deltIpQp: { min: -7, max: 7 },
maxReEncodeTimes: { min: 0, max: 3 },
frmQp: { min: 1, max: 51 },
};
const normalizeRcCodec = (codec: RcQpParams): RcQpParams => {
const maxQp = clamp(Number(codec.u32MaxQp), RC_LIMITS.maxQp.min, RC_LIMITS.maxQp.max);
const maxIQp = clamp(Number(codec.u32MaxIQp), RC_LIMITS.maxIQp.min, RC_LIMITS.maxIQp.max);
const minQp = clamp(Number(codec.u32MinQp), RC_LIMITS.maxQp.min, maxQp);
const minIQp = clamp(Number(codec.u32MinIQp), RC_LIMITS.maxIQp.min, maxIQp);
return {
...codec,
s32FirstFrameStartQp: clamp(Number(codec.s32FirstFrameStartQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max),
u32StepQp: clamp(Number(codec.u32StepQp), RC_LIMITS.stepQp.min, RC_LIMITS.stepQp.max),
u32MaxQp: maxQp,
u32MinQp: minQp,
u32MaxIQp: maxIQp,
u32MinIQp: minIQp,
s32DeltIpQp: clamp(Number(codec.s32DeltIpQp), RC_LIMITS.deltIpQp.min, RC_LIMITS.deltIpQp.max),
s32MaxReEncodeTimes: clamp(
Number(codec.s32MaxReEncodeTimes),
RC_LIMITS.maxReEncodeTimes.min,
RC_LIMITS.maxReEncodeTimes.max,
),
u32FrmMaxQp: clamp(Number(codec.u32FrmMaxQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max),
u32FrmMinQp: clamp(Number(codec.u32FrmMinQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max),
u32FrmMinIQp: clamp(Number(codec.u32FrmMinIQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max),
u32FrmMaxIQp: clamp(Number(codec.u32FrmMaxIQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max),
u32MotionStaticSwitchFrmQp: clamp(
Number(codec.u32MotionStaticSwitchFrmQp),
RC_LIMITS.frmQp.min,
RC_LIMITS.frmQp.max,
),
};
};
const normalizeVideoRcConfig = (config: VideoRcConfig): VideoRcConfig => ({
h264: normalizeRcCodec(config.h264),
h265: normalizeRcCodec(config.h265),
});
const sliderValuesFromCodec = (codec: RcQpParams): RcSliderValues => ({
stepQp: clamp(Number(codec.u32StepQp), 1, 50),
minQp: clamp(Number(codec.u32MinQp), 1, 50),
minIQp: clamp(Number(codec.u32MinIQp), 1, 50),
deltIpQp: clamp(Number(codec.s32DeltIpQp), -7, 7),
});
const sliderStateFromConfig = (config: VideoRcConfig): RcSliderState => ({
h264: sliderValuesFromCodec(config.h264),
h265: sliderValuesFromCodec(config.h265),
});
export default function SettingsVideoSide() {
const { $at } = useReactAt();
const [send] = useJsonRpc();
@@ -54,6 +182,12 @@ export default function SettingsVideoSide() {
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
const [edid, setEdid] = useState<string | null>(null);
const [forceHpd, setForceHpd] = useState(false);
const [videoRcConfig, setVideoRcConfig] = useState<VideoRcConfig>(DEFAULT_VIDEO_RC_CONFIG);
const [rcSliderValues, setRcSliderValues] = useState<RcSliderState>(
sliderStateFromConfig(DEFAULT_VIDEO_RC_CONFIG),
);
const [showRcAdvanced, setShowRcAdvanced] = useState(false);
const [rcDraftConfig, setRcDraftConfig] = useState<VideoRcConfig | null>(null);
// Video enhancement settings from store
const videoSaturation = useSettingsStore(state => state.videoSaturation);
@@ -63,6 +197,129 @@ export default function SettingsVideoSide() {
const videoContrast = useSettingsStore(state => state.videoContrast);
const setVideoContrast = useSettingsStore(state => state.setVideoContrast);
const currentCodec: "h264" | "h265" = streamEncodecType === "hevc" ? "h265" : "h264";
const currentSliders = rcSliderValues[currentCodec];
const applySliderToCodec = (codec: RcQpParams, sliders: RcSliderValues): RcQpParams => ({
...codec,
u32StepQp: sliders.stepQp,
u32MinQp: sliders.minQp,
u32MinIQp: sliders.minIQp,
s32DeltIpQp: sliders.deltIpQp,
});
const applyRcBasicConfig = () => {
const nextRcConfig = normalizeVideoRcConfig({
...videoRcConfig,
[currentCodec]: applySliderToCodec(videoRcConfig[currentCodec], currentSliders),
});
send("setVideoRc", { params: nextRcConfig }, resp => {
if ("error" in resp) {
notifications.error(`Failed to set video RC: ${resp.error.data || "Unknown error"}`);
return;
}
setVideoRcConfig(nextRcConfig);
setRcDraftConfig(nextRcConfig);
setRcSliderValues(sliderStateFromConfig(nextRcConfig));
notifications.success("Video RC updated");
});
};
const updateRcDraftField = (
codec: "h264" | "h265",
field: keyof RcQpParams,
value: number,
) => {
setRcDraftConfig(prev => {
if (!prev) return prev;
const nextCodec = { ...prev[codec], [field]: value };
const normalizedCodec = normalizeRcCodec(nextCodec);
return {
...prev,
[codec]: normalizedCodec,
};
});
};
const openRcAdvancedModal = () => {
const nextDraft = normalizeVideoRcConfig({
...videoRcConfig,
[currentCodec]: applySliderToCodec(videoRcConfig[currentCodec], currentSliders),
});
setRcDraftConfig(nextDraft);
setShowRcAdvanced(true);
};
const applyRcAdvancedConfig = () => {
if (!rcDraftConfig) {
return;
}
const normalized = normalizeVideoRcConfig(rcDraftConfig);
send("setVideoRc", { params: normalized }, resp => {
if ("error" in resp) {
notifications.error(`Failed to set video RC: ${resp.error.data || "Unknown error"}`);
return;
}
setVideoRcConfig(normalized);
setRcDraftConfig(normalized);
setRcSliderValues(sliderStateFromConfig(normalized));
notifications.success("Video RC updated");
setShowRcAdvanced(false);
});
};
const renderRcAdvancedForm = (codec: "h264" | "h265") => {
const current = rcDraftConfig?.[codec];
if (!current) return null;
const fields: Array<{
key: keyof RcQpParams;
label: string;
min?: number;
max?: number;
}> = [
{ key: "s32FirstFrameStartQp", label: "FirstFrameStartQp", min: 1, max: 51 },
{ key: "u32StepQp", label: "StepQp", min: 1, max: 51 },
{ key: "u32MaxQp", label: "MaxQp", min: 1, max: 51 },
{ key: "u32MinQp", label: "MinQp", min: 1, max: Number(current.u32MaxQp) || 51 },
{ key: "u32MaxIQp", label: "MaxIQp", min: 1, max: 51 },
{ key: "u32MinIQp", label: "MinIQp", min: 1, max: Number(current.u32MaxIQp) || 51 },
{ key: "s32DeltIpQp", label: "DeltIpQp", min: -7, max: 7 },
{ key: "s32MaxReEncodeTimes", label: "MaxReEncodeTimes", min: 0, max: 3 },
{ key: "u32FrmMaxQp", label: "FrmMaxQp", min: 1, max: 51 },
{ key: "u32FrmMinQp", label: "FrmMinQp", min: 1, max: 51 },
{ key: "u32FrmMaxIQp", label: "FrmMaxIQp", min: 1, max: 51 },
{ key: "u32FrmMinIQp", label: "FrmMinIQp", min: 1, max: 51 },
{ key: "u32MotionStaticSwitchFrmQp", label: "MotionStaticSwitchFrmQp", min: 1, max: 51 },
];
return (
<div className="grid grid-cols-1 gap-3">
{fields.map(field => (
<div key={`${codec}-${field.key}`} className="flex items-center justify-between gap-3">
<Text className="text-xs">{field.label}</Text>
<InputNumber
size="small"
value={current[field.key]}
min={field.min}
max={field.max}
step={1}
style={{ width: 140 }}
onChange={val => {
if (typeof val !== "number") return;
const safeValue =
field.min !== undefined && field.max !== undefined
? clamp(Number(val), field.min, field.max)
: Number(val);
updateRcDraftField(codec, field.key, safeValue);
}}
/>
</div>
))}
</div>
);
};
useEffect(() => {
send("getNpuAppStatus", {}, resp => {
if ("error" in resp) return;
@@ -79,6 +336,20 @@ export default function SettingsVideoSide() {
setStreamQuality(String(resp.result));
});
send("getVideoRc", {}, resp => {
if ("error" in resp) {
notifications.error(`Failed to get video RC: ${resp.error.data || "Unknown error"}`);
const fallbackRc = normalizeVideoRcConfig(DEFAULT_VIDEO_RC_CONFIG);
setVideoRcConfig(fallbackRc);
setRcSliderValues(sliderStateFromConfig(fallbackRc));
return;
}
const rc = normalizeVideoRcConfig(toVideoRcConfigOrDefault(resp.result));
setVideoRcConfig(rc);
setRcSliderValues(sliderStateFromConfig(rc));
});
send("getEDID", {}, resp => {
if ("error" in resp) {
notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`);
@@ -218,6 +489,116 @@ export default function SettingsVideoSide() {
/>
</SettingsItem>
<SettingsItem
title={$at("RC Control")}
description={$at("Adjust rate control QP settings for better balance between quality and bitrate")}
/>
<div className="space-y-4">
<SettingsItemNew
title={$at("StepQp")}
description={String(currentSliders.stepQp)}
className={"flex-col w-full h-[40px]"}
>
<Slider
min={1}
max={51}
step={1}
value={currentSliders.stepQp}
onChange={value => {
const nextStepQp = clamp(sliderValueToNumber(value), 1, 50);
setRcSliderValues(prev => ({
...prev,
[currentCodec]: {
...prev[currentCodec],
stepQp: nextStepQp,
},
}));
}}
className={"w-full"}
/>
</SettingsItemNew>
<SettingsItemNew
title={$at("MinQp")}
description={String(currentSliders.minQp)}
className={"flex-col w-full h-[40px]"}
>
<Slider
min={1}
max={50}
step={1}
value={currentSliders.minQp}
onChange={value => {
const nextMinQp = clamp(sliderValueToNumber(value), 1, 50);
setRcSliderValues(prev => ({
...prev,
[currentCodec]: {
...prev[currentCodec],
minQp: nextMinQp,
},
}));
}}
className={"w-full"}
/>
</SettingsItemNew>
<SettingsItemNew
title={$at("MinIQp")}
description={String(currentSliders.minIQp)}
className={"flex-col w-full h-[40px]"}
>
<Slider
min={1}
max={50}
step={1}
value={currentSliders.minIQp}
onChange={value => {
const nextMinIQp = clamp(sliderValueToNumber(value), 1, 50);
setRcSliderValues(prev => ({
...prev,
[currentCodec]: {
...prev[currentCodec],
minIQp: nextMinIQp,
},
}));
}}
className={"w-full"}
/>
</SettingsItemNew>
<SettingsItemNew
title={$at("DetlpQp")}
description={String(currentSliders.deltIpQp)}
className={"flex-col w-full h-[40px]"}
>
<Slider
min={-7}
max={7}
step={1}
value={currentSliders.deltIpQp}
onChange={value => {
const nextDeltIpQp = clamp(sliderValueToNumber(value), -7, 7);
setRcSliderValues(prev => ({
...prev,
[currentCodec]: {
...prev[currentCodec],
deltIpQp: nextDeltIpQp,
},
}));
}}
className={"w-full"}
/>
</SettingsItemNew>
<div className="flex justify-end gap-2">
<AntdButton onClick={openRcAdvancedModal}>
{$at("Advanced")}
</AntdButton>
<AntdButton type="primary" onClick={applyRcBasicConfig}>
{$at("Apply")}
</AntdButton>
</div>
</div>
<SettingsItem
title={$at("NPU Application")}
@@ -362,25 +743,7 @@ export default function SettingsVideoSide() {
}}
options={[...edids, { value: "custom", label: "Custom" }]}
/>
{/* options={[...edids, { value: "custom", label: "Custom" }]}*/}
</SettingsItem>
{/*<SelectMenuBasic*/}
{/* size="SM"*/}
{/* label=""*/}
{/* fullWidth*/}
{/* value={customEdidValue ? "custom" : edid || "asd"}*/}
{/* onChange={e => {*/}
{/* console.log(e.target.value)*/}
{/* if (e.target.value === "custom") {*/}
{/* setEdid("custom");*/}
{/* setCustomEdidValue("");*/}
{/* } else {*/}
{/* setCustomEdidValue(null);*/}
{/* handleEDIDChange(e.target.value as string);*/}
{/* }*/}
{/* }}*/}
{/* options={[...edids, { value: "custom", label: "Custom" }]}*/}
{/*/>*/}
{customEdidValue !== null && (
<>
@@ -418,6 +781,39 @@ export default function SettingsVideoSide() {
)}
</div>
<div className={"h-[10vh]"}></div>
<Modal
title={
<Text strong style={{ fontSize: "16px" }}>
{$at("RC Advanced Config")}
</Text>
}
open={showRcAdvanced}
onCancel={() => setShowRcAdvanced(false)}
onOk={applyRcAdvancedConfig}
okText={$at("Apply")}
cancelText={$at("Cancel")}
maskClosable={true}
keyboard={true}
width={520}
styles={{
body: {
padding: "20px 24px",
},
header: {
borderBottom: "1px solid #f0f0f0",
padding: "16px 24px",
marginBottom: 0,
}
}}
>
<Tabs
items={[
{ key: "h264", label: "H264", children: renderRcAdvancedForm("h264") },
{ key: "h265", label: "H265", children: renderRcAdvancedForm("h265") },
]}
/>
</Modal>
</div>
);
}

View File

@@ -53,6 +53,11 @@ export interface StorageSpace {
bytesFree: number;
}
const isMountableVirtualMediaFile = (filename: string) => {
const lower = filename.toLowerCase();
return lower.endsWith(".img") || lower.endsWith(".iso");
};
const LoadingOverlay: React.FC = () => {
const { $at } = useReactAt();
@@ -89,7 +94,6 @@ export default function ImageManager({
const { $at } = useReactAt();
const [send] = useJsonRpc();
// 状态管理
const [storageFiles, setStorageFiles] = useState<StorageFile[]>([]);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [usbMode, setUsbMode] = useState<RemoteVirtualMediaState["mode"]>("CDROM");
@@ -101,9 +105,9 @@ export default function ImageManager({
const [sdMountStatus, setSDMountStatus] = useState<"ok" | "none" | "fail" | null>(storageType === 'sd' ? null : 'ok');
const [loading, setLoading] = useState(false);
const [uploadFile, setUploadFile] = useState<string | null>(null);
const [fsType, setFsType] = useState<'exfat' | 'fat32'>('fat32');
const filesPerPage = 5;
// 计算属性
const percentageUsed = useMemo(() => {
if (!storageSpace) return 0;
return Number(
@@ -162,13 +166,30 @@ export default function ImageManager({
setLoading(false);
};
// 数据获取函数
const handleFormatSDStorage = async () => {
if (!window.confirm($at("Formatting the SD card will erase all data. Continue?"))) {
return;
}
setLoading(true);
send("formatSDStorage", { confirm: true, fsType }, res => {
if ("error" in res) {
notifications.error(res.error.data || res.error.message);
setLoading(false);
return;
}
notifications.success($at("SD card formatted successfully"));
setSDMountStatus(null);
checkSDStatus();
});
await new Promise(resolve => setTimeout(resolve, 2000));
setLoading(false);
};
const syncStorage = useCallback(() => {
if (storageType === 'sd' && sdMountStatus !== 'ok') {
return;
}
// 获取存储文件列表
send(listFilesApi, {}, res => {
if ("error" in res) {
notifications.error(`Error listing storage files: ${res.error}`);
@@ -180,10 +201,11 @@ export default function ImageManager({
size: formatters.bytes(file.size),
createdAt: formatters.date(new Date(file?.createdAt)),
}));
setStorageFiles(formattedFiles);
const mountableFiles = formattedFiles.filter(f => isMountableVirtualMediaFile(f.name));
setStorageFiles(mountableFiles);
setSelectedFile(prev => (prev && mountableFiles.some(f => f.name === prev) ? prev : null));
});
// 获取存储空间信息
send(getSpaceApi, {}, res => {
if ("error" in res) {
notifications.error(`Error getting storage space: ${res.error}`);
@@ -192,7 +214,6 @@ export default function ImageManager({
setStorageSpace(res.result as StorageSpace);
});
// 获取自动挂载设置
if (showAutoMount && getAutoMountApi) {
send(getAutoMountApi, {}, resp => {
if ("error" in resp) {
@@ -205,7 +226,6 @@ export default function ImageManager({
}
}, [send, listFilesApi, getSpaceApi, showAutoMount, getAutoMountApi, storageType, sdMountStatus]);
// 初始化数据
useEffect(() => {
if (storageType === 'sd') {
checkSDStatus();
@@ -220,7 +240,6 @@ export default function ImageManager({
}
}, [sdMountStatus, syncStorage]);
// 事件处理函数
const handleDeleteFile = useCallback((file: StorageFile) => {
if (window.confirm($at("Are you sure you want to delete " + file.name + "?"))) {
send(deleteFileApi, { filename: file.name }, res => {
@@ -235,9 +254,10 @@ export default function ImageManager({
const handleSelectFile = useCallback((file: StorageFile) => {
setSelectedFile(file.name);
if (file.name.endsWith(".iso")) {
const lower = file.name.toLowerCase();
if (lower.endsWith(".iso")) {
setUsbMode("CDROM");
} else if (file.name.endsWith(".img")) {
} else if (lower.endsWith(".img")) {
setUsbMode("Disk");
}
}, []);
@@ -263,7 +283,6 @@ export default function ImageManager({
return;
}
// 挂载成功
syncRemoteVirtualMediaState()
setMountInProgress(false);
if (onMountSuccess) {
@@ -327,6 +346,32 @@ export default function ImageManager({
? $at("Please insert an SD card and try again.")
: $at("Please format the SD card and try again.")}
</p>
{sdMountStatus !== "none" && (
<div className="pt-2">
<div className="mx-auto w-full max-w-[360px] space-y-2">
<p className="w-full text-left text-xs text-slate-700 dark:text-slate-300">
{$at("Choose the file system for MicroSD formatting")}
</p>
<select
value={fsType}
onChange={(e) => setFsType(e.target.value as 'exfat' | 'fat32')}
style={{ width: "100%", padding: "8px", borderRadius: "4px" }}
>
<option value="fat32">FAT32</option>
<option value="exfat">exFAT</option>
</select>
<AntdButton
disabled={loading}
danger={true}
type="primary"
onClick={handleFormatSDStorage}
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
>
{$at("Format MicroSD Card")} ({fsType})
</AntdButton>
</div>
</div>
)}
</div>
</div>
</div>
@@ -343,7 +388,6 @@ export default function ImageManager({
title={$at("Mount from KVM Storage")}
description={$at("Select the image you want to mount from the KVM storage")}
/>
{/* 自动挂载设置 */}
{showAutoMount && (
<div className="w-full animate-fadeIn opacity-0" style={{ animationDuration: "0.7s", animationDelay: "0.1s" }}>
<SettingsItem
@@ -361,7 +405,6 @@ export default function ImageManager({
{showAutoMount && <hr className="border-slate-800/20 dark:border-slate-300/20" />}
{/* 文件列表 */}
<div className="w-full animate-fadeIn opacity-0 px-0.5" style={{ animationDuration: "0.7s", animationDelay: "0.1s" }}>
<div className="relative">
<Card>
@@ -402,7 +445,6 @@ export default function ImageManager({
/>
))}
{/* 分页控件 */}
{storageFiles.length > filesPerPage && (
<div className="flex items-center justify-between px-3 py-2">
<p className="text-sm text-slate-700 dark:text-slate-300">
@@ -438,7 +480,6 @@ export default function ImageManager({
</div>
</div>
{/* 操作按钮 */}
{storageFiles.length > 0 && (
<div className="flex animate-fadeIn items-end justify-between opacity-0" style={{ animationDuration: "0.7s", animationDelay: "0.15s" }}>
<Fieldset disabled={selectedFile === null}>
@@ -456,7 +497,6 @@ export default function ImageManager({
</div>
)}
{/* 存储空间信息 */}
<hr className="border-slate-800/20 dark:border-slate-300/20" />
<div className="animate-fadeIn space-y-2 opacity-0" style={{ animationDuration: "0.7s", animationDelay: "0.20s" }}>
<StorageSpaceBar
@@ -466,11 +506,30 @@ export default function ImageManager({
/>
</div>
{/* 自定义操作区域 */}
{unmountApi && storageType === 'sd' && (
<div className="flex animate-fadeIn justify-between opacity-0"
<div className="animate-fadeIn space-y-2 opacity-0"
style={{ animationDuration: "0.7s", animationDelay: "0.25s" }}
>
<div className="w-full space-y-2">
<p className="w-full text-left text-xs text-slate-700 dark:text-slate-300">
{$at("Choose the file system for MicroSD formatting")}
</p>
<select
value={fsType}
onChange={(e) => setFsType(e.target.value as 'exfat' | 'fat32')}
style={{ width: "100%", padding: "8px", borderRadius: "4px" }}
>
<option value="fat32">FAT32</option>
<option value="exfat">exFAT</option>
</select>
<AntdButton
disabled={loading}
type="primary"
danger={true}
onClick={handleFormatSDStorage}
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
>{$at("Format MicroSD Card")} ({fsType})</AntdButton>
</div>
<AntdButton
disabled={loading}
type="primary"
@@ -482,7 +541,6 @@ export default function ImageManager({
)}
{customActions}
{/* 上传新镜像按钮 */}
{uploadFile ? (
<FileUploader
key={`resume-${uploadFile}`}
@@ -492,12 +550,14 @@ export default function ImageManager({
}}
incompleteFileName={uploadFile}
media={storageType}
accept=".img,.iso"
/>
) : (
<FileUploader
key="new-upload"
onBack={handleFileUploadComplete}
media={storageType}
accept=".img,.iso"
/>
)}

View File

@@ -4,16 +4,17 @@ import SideTabs from "@components/Sidebar/SideTabs";
import DevicePage from "@/layout/components_side/VirtualMediaSource/DevicePage";
import SDPage from "@/layout/components_side/VirtualMediaSource/SDPage";
import UnMountPage from "@/layout/components_side/VirtualMediaSource/UnMount";
import { useBootStorageType } from "@/hooks/useBootStorage";
////* KVM MicroSD Mount */
// width: 143px;
// height: 11px;
// display: flex;
// flex-direction: row;
// align-items: center;
// 主组件
const VirtualMediaSource: React.FC = () => {
const { bootStorageType } = useBootStorageType();
const isBootFromSD = bootStorageType === "sd";
if (isBootFromSD) {
return (
<UnMountPage unmountedPage={<DevicePage />} />
);
}
return (
<UnMountPage unmountedPage={(
@@ -25,7 +26,6 @@ const VirtualMediaSource: React.FC = () => {
defaultActiveKey="1"
/>
)}/>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Button as AntdButton, Typography } from "antd";
import { useReactAt } from "i18n-auto-extractor/react";
import KeyboardSVG from "@assets/second/keyboard.svg?react";
@@ -22,10 +22,10 @@ import {
useRTCStore,
useSettingsStore,
useUiStore,
useVideoStore,
useVpnStore,
} from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { keyboards } from "@/keyboardLayouts";
import {
button_primary_color,
dark_bd_style,
@@ -48,10 +48,13 @@ const views = [
export default function BottomBarMobile() {
const { $at } = useReactAt();
const keyboardLedState = useHidStore(state => state.keyboardLedState);
const videoSize = useVideoStore(
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
);
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
const { isDark } = useTheme();
const layoutAbbrev = useMemo(() => {
if (!keyboardLayout) return "en_US";
return keyboardLayout;
}, [keyboardLayout]);
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
const forceHttp = useSettingsStore(state => state.forceHttp);
@@ -79,10 +82,7 @@ export default function BottomBarMobile() {
stats?.forEach(report => {
if (report.type === "inbound-rtp") {
if(report.framesPerSecond){
setFps(report.framesPerSecond)
}
setFps(report.framesPerSecond ?? 0);
}
});
})();
@@ -117,7 +117,7 @@ export default function BottomBarMobile() {
{ icon: isDark ? Video2SVG : VideoSVG, label: $at("video") },
{ icon: StateSvg, label: $at("status") },
];
const videoButtonLabel = forceHttp ? `${videoSize}` : `${videoSize} ${fps}fps `;
const videoButtonLabel = forceHttp ? "N/A fps" : `${Math.round(fps)}fps`;
if(isVirtualKeyboardEnabled){
return <></>
}
@@ -151,6 +151,7 @@ export default function BottomBarMobile() {
<LedStatusButton ledState={keyboardLedState?.num_lock} text={$at("Num")} />
<LedStatusButton ledState={keyboardLedState?.caps_lock} text={$at("Caps")} />
<LedStatusButton ledState={keyboardLedState?.scroll_lock} text={$at("Scroll")} />
<span className="pl-2 text-xs opacity-70">{layoutAbbrev}</span>
</div>
<div className="w-[20%] flex flex-row flex-wrap items-center justify-end">
<AntdButton
@@ -175,11 +176,13 @@ export default function BottomBarMobile() {
text={$at("HDMI")}
isActive={!!peerConnectionState}
/>
<ConnectionStatusButton
icon={usbState === "configured" ? <Usb2SVG /> : <UsbSVG />}
text={$at("USB")}
isActive={usbState === "configured"}
/>
<div onClick={() => toggleSidebarView("UsbStatusPanel")}>
<ConnectionStatusButton
icon={usbState === "configured" ? <Usb2SVG /> : <UsbSVG />}
text={$at("USB")}
isActive={usbState === "configured"}
/>
</div>
<VpnStatusButton
text={$at("TailScale")}
peerState={peerConnectionState}

View File

@@ -27,10 +27,12 @@ import {
useVpnStore,
} from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings";
import { keyboards } from "@/keyboardLayouts";
import BottomPopoverButton from "@components/PopoverButton";
import MousePanel from "@components/MousePanel";
import KeyboardPanel from "@/layout/components_bottom/keyboard/KeyboardPanel";
import UsbEpModeSelect from "@/layout/components_bottom/usbepmode/UsbEpModeSelect";
import UsbStatusPanel from "@/layout/components_bottom/usb_status/UsbStatusPanel";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { dark_bg2_style, selected_bt_bg } from "@/layout/theme_color";
import { useThemeSettings } from "@routes/login_page/useLocalAuth";
@@ -44,12 +46,6 @@ export default function BottomBarPC() {
const activeModifiers = useHidStore(state => state.activeModifiers);
const audioMode = useAudioModeStore(state => state.audioMode);
const usbEpMode = useUsbEpModeStore(state => state.usbEpMode);
// const videoSize = useVideoStore(
// state => `${Math.round(state.width)}x${Math.round(state.height)}`,
// );
const videoSize = useVideoStore(
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
);
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
const showPressedKeys = useSettingsStore(state => state.showPressedKeys);
@@ -63,8 +59,14 @@ export default function BottomBarPC() {
const keyboardLedState = useHidStore(state => state.keyboardLedState);
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
const layoutAbbrev = useMemo(() => {
if (!keyboardLayout) return "en_US";
return keyboardLayout;
}, [keyboardLayout]);
const [hostname, setHostname] = useState("");
const [send] = useJsonRpc();
const peerConnection = useRTCStore(state => state.peerConnection);
@@ -89,10 +91,10 @@ export default function BottomBarPC() {
const videoButtonLabel = useMemo(() => {
if (forceHttp) {
return `${videoSize}`;
return "N/A fps";
}
return `${videoSize} ${fps}fps `;
}, [forceHttp, videoSize, fps]);
return `${Math.round(fps)}fps`;
}, [forceHttp, fps]);
useEffect(() => {
send("getNetworkSettings", {}, resp => {
if ("error" in resp) return;
@@ -114,10 +116,12 @@ export default function BottomBarPC() {
isActive={hdmiState === "ready"}
/>
<ConnectionStatusButton
icon={usbState === "configured" ? <Usb2SVG fontSize={16} /> : <UsbSVG fontSize={16} />}
text={$at("USB")}
isActive={usbState === "configured"}
<BottomPopoverButton
buttonIconNode={usbState === "configured" ? <Usb2SVG fontSize={16} /> : <UsbSVG fontSize={16} />}
buttonText={$at("USB")}
style={{ color: usbState === "configured" ? "rgba(0, 205, 27, 1)" : "inherit" }}
panelContent={<UsbStatusPanel />}
align="left"
/>
<VpnStatusButton
@@ -170,6 +174,7 @@ export default function BottomBarPC() {
ledState={keyboardLedState?.scroll_lock}
text={$at("Scroll")}
/>
<span className="pl-1 text-xs opacity-70" style={{ fontSize: 12 }}>{layoutAbbrev}</span>
</div>
}
align="left"

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
import { BsMouseFill, BsLockFill, BsUnlockFill } from "react-icons/bs";
import { BsKeyboardFill, BsLockFill, BsUnlockFill } from "react-icons/bs";
import { useReactAt } from "i18n-auto-extractor/react";
import VirtualKeyboard from "@components/VirtualKeyboard";
@@ -24,7 +24,7 @@ import KeyboardPanel from "@/layout/components_bottom/keyboard/KeyboardPanel";
import Clipboard from "@/layout/components_side/Clipboard/Clipboard";
import SettingsModal from "@/layout/components_setting";
import { MacroMoreList } from "@/layout/components_side/Macros/MacroTopBar";
import { useMacrosSideTitleState , useHidStore, useMouseStore, useSettingsStore } from "@/hooks/stores";
import { useMacrosSideTitleState , useHidStore, useMouseStore, useSettingsStore, useUiStore } from "@/hooks/stores";
import MobileTerminal from "@/layout/components_bottom/terminal/index.mobile";
import { dark_bg_desktop, dark_bg_style_fun } from "@/layout/theme_color";
import PowerControl from "@/layout/components_side/Power";
@@ -37,10 +37,52 @@ import SettingsMacros from "@/layout/components_side/Macros";
import { useTouchZoom } from "@/layout/core/desktop/hooks/useTouchZoom";
import { usePasteHandler } from "@/layout/core/desktop/hooks/usePasteHandler";
import UsbEpModeSelect from "@/layout/components_bottom/usbepmode/UsbEpModeSelect";
import UsbStatusPanel from "@/layout/components_bottom/usb_status/UsbStatusPanel";
import VirtualMediaSource from "@/layout/components_side/VirtualMediaSource";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import OcrOverlay from "@components/OcrOverlay";
const GestureIcon = ({ className = "h-4 w-4" }: { className?: string }) => (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="10" cy="10" r="6.5" />
<path d="M14.8 14.8L21 21" />
<path d="M10 7.5V12.5" />
<path d="M7.5 10H12.5" />
</svg>
);
const ResetViewIcon = ({ className = "h-4 w-4" }: { className?: string }) => (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M19 12A7 7 0 1 1 12 5" />
<path d="M12 2L12 6L16 6" />
</svg>
);
const MouseStickIcon = ({ className = "h-4 w-4" }: { className?: string }) => (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="7" y="3" width="10" height="18" rx="5" />
<path d="M12 3V8" />
<circle cx="12" cy="13" r="1.5" fill="currentColor" stroke="none" />
</svg>
);
const FourWayMoveIcon = ({ className = "h-5 w-5" }: { className?: string }) => (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 3V21" />
<path d="M3 12H21" />
<path d="M12 3L9 6" />
<path d="M12 3L15 6" />
<path d="M12 21L9 18" />
<path d="M12 21L15 18" />
<path d="M3 12L6 9" />
<path d="M3 12L6 15" />
<path d="M21 12L18 9" />
<path d="M21 12L18 15" />
</svg>
);
export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number }) {
const joystickSpeedLevels = [1.4, 1.05, 0.7];
const { $at } = useReactAt();
const { isDark } = useTheme();
const videoElm = useRef<HTMLVideoElement>(null);
@@ -49,56 +91,126 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
const zoomContainerRef = useRef<HTMLDivElement>(null);
const pasteCaptureRef = useRef<HTMLTextAreaElement>(null);
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
const isOcrMode = useUiStore(state => state.isOcrMode);
const macrosSideTitle = useMacrosSideTitleState(state => state.sideTitle);
const videoEffects = useVideoEffects();
const videoStream = useVideoStream(videoElm as React.RefObject<HTMLVideoElement>, audioElm as React.RefObject<HTMLAudioElement>);
const pointerLock = usePointerLock(videoElm as React.RefObject<HTMLVideoElement>);
useFullscreen(videoElm as React.RefObject<HTMLVideoElement>, pointerLock, isFullscreen);
const touchZoom = useTouchZoom(zoomContainerRef as React.RefObject<HTMLDivElement>);
const [isTouchGestureEnabled, setIsTouchGestureEnabled] = useState(true);
const touchZoom = useTouchZoom(zoomContainerRef as React.RefObject<HTMLDivElement>, isTouchGestureEnabled);
const { handleGlobalPaste } = usePasteHandler(pasteCaptureRef as React.RefObject<HTMLTextAreaElement>);
const keyboardEvents = useKeyboardEvents(pasteCaptureRef as React.RefObject<HTMLTextAreaElement>, isReinitializingGadget);
const [showVirtualMouseButtons, setShowVirtualMouseButtons] = useState(false);
const [showVirtualJoystick, setShowVirtualJoystick] = useState(false);
const [joystickVector, setJoystickVector] = useState({ x: 0, y: 0 });
const [joystickSensitivity, setJoystickSensitivity] = useState(1);
const [joystickPos, setJoystickPos] = useState({ x: 16, y: 24 });
const [lockedButtons, setLockedButtons] = useState(0);
const mouseEvents = useMouseEvents(videoElm as React.RefObject<HTMLVideoElement>, pointerLock, touchZoom, showVirtualMouseButtons, lockedButtons);
const overlays = useVideoOverlays(videoStream, pointerLock, videoEffects);
const forceHttp = useSettingsStore(state => state.forceHttp);
const mouseMode = useSettingsStore(state => state.mouseMode);
const mouseX = useMouseStore(state => state.mouseX);
const mouseY = useMouseStore(state => state.mouseY);
const allowTapToOpenVirtualKeyboard = useHidStore(state => state.allowTapToOpenVirtualKeyboard);
const setAllowTapToOpenVirtualKeyboard = useHidStore(state => state.setAllowTapToOpenVirtualKeyboard);
const [send] = useJsonRpc();
const [leftBtnPos, setLeftBtnPos] = useState({ x: 40, y: 40 });
const [rightBtnPos, setRightBtnPos] = useState({ x: 120, y: 40 });
const [leftLockPos, setLeftLockPos] = useState({ x: 40, y: 110 });
const [rightLockPos, setRightLockPos] = useState({ x: 120, y: 110 });
const [wheelPos, setWheelPos] = useState({ x: 184, y: 140 });
const [draggingBtn, setDraggingBtn] = useState<"left" | "right" | "leftLock" | "rightLock" | null>(null);
const [draggingBtn, setDraggingBtn] = useState<"left" | "right" | "wheel" | null>(null);
const dragOffset = useRef({ x: 0, y: 0 });
const joystickAreaRef = useRef<HTMLDivElement>(null);
const joystickPointerIdRef = useRef<number | null>(null);
const joystickVectorRef = useRef({ x: 0, y: 0 });
const joystickFrameRef = useRef<number | null>(null);
const joystickLastTsRef = useRef<number | null>(null);
const joystickMovePointerIdRef = useRef<number | null>(null);
const joystickMoveHoldTimerRef = useRef<number | null>(null);
const joystickMoveEnabledRef = useRef(false);
const activeButtonsRef = useRef(0);
useEffect(() => {
if (isFullscreen) {
setShowVirtualMouseButtons(false);
setShowVirtualJoystick(false);
}
}, [isFullscreen]);
useEffect(() => {
joystickVectorRef.current = joystickVector;
}, [joystickVector]);
useEffect(() => {
if (!showVirtualJoystick) {
joystickPointerIdRef.current = null;
joystickMovePointerIdRef.current = null;
if (joystickMoveHoldTimerRef.current !== null) {
window.clearTimeout(joystickMoveHoldTimerRef.current);
joystickMoveHoldTimerRef.current = null;
}
joystickMoveEnabledRef.current = false;
joystickLastTsRef.current = null;
setJoystickVector({ x: 0, y: 0 });
if (joystickFrameRef.current !== null) {
cancelAnimationFrame(joystickFrameRef.current);
joystickFrameRef.current = null;
}
return;
}
const tick = (timestamp: number) => {
const prevTs = joystickLastTsRef.current ?? timestamp;
joystickLastTsRef.current = timestamp;
const frameScale = Math.min(2, Math.max(0.5, (timestamp - prevTs) / 16.67));
const vector = joystickVectorRef.current;
const container = containerRef.current;
const containerWidth = container?.clientWidth ?? 1280;
// Reduce sensitivity on small screens to avoid over-shooting.
const resolutionFactor = Math.max(0.35, Math.min(1, containerWidth / 1280));
const speed = 12 * resolutionFactor * joystickSensitivity;
const dx = Math.round(vector.x * speed * frameScale);
const dy = Math.round(vector.y * speed * frameScale);
if (dx !== 0 || dy !== 0) {
mouseEvents.sendVirtualRelativeMovement(dx, dy, 0);
}
joystickFrameRef.current = requestAnimationFrame(tick);
};
joystickFrameRef.current = requestAnimationFrame(tick);
return () => {
if (joystickFrameRef.current !== null) {
cancelAnimationFrame(joystickFrameRef.current);
joystickFrameRef.current = null;
}
joystickLastTsRef.current = null;
};
}, [showVirtualJoystick, mouseEvents, joystickSensitivity]);
const updateButtons = (mask: number, isDown: boolean) => {
if (isReinitializingGadget) return;
let newButtons = activeButtonsRef.current;
if (isDown) {
newButtons |= mask;
} else if (lockedButtons & mask) {
// Keep pressed while lock is enabled.
newButtons |= mask;
} else {
newButtons &= ~mask;
}
if (!isDown && (lockedButtons & mask)) {
setLockedButtons(prev => prev & ~mask);
}
activeButtonsRef.current = newButtons;
send("absMouseReport", { x: mouseX, y: mouseY, buttons: newButtons });
if (mouseMode === "relative") {
mouseEvents.sendVirtualRelativeMovement(0, 0, newButtons);
} else {
send("absMouseReport", { x: mouseX, y: mouseY, buttons: newButtons });
}
};
const toggleLock = (mask: number) => {
@@ -115,10 +227,14 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
}
activeButtonsRef.current = newButtons;
send("absMouseReport", { x: mouseX, y: mouseY, buttons: newButtons });
if (mouseMode === "relative") {
mouseEvents.sendVirtualRelativeMovement(0, 0, newButtons);
} else {
send("absMouseReport", { x: mouseX, y: mouseY, buttons: newButtons });
}
};
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>, type: "left" | "right" | "leftLock" | "rightLock") => {
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>, type: "left" | "right" | "wheel") => {
const target = e.currentTarget;
const rect = target.getBoundingClientRect();
dragOffset.current = {
@@ -136,26 +252,121 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
const containerRect = container.getBoundingClientRect();
const x = e.clientX - containerRect.left - dragOffset.current.x;
const y = e.clientY - containerRect.top - dragOffset.current.y;
const clampedX = Math.max(0, Math.min(containerRect.width - 56, x));
const clampedY = Math.max(0, Math.min(containerRect.height - 56, y));
const dragWidth = draggingBtn === "wheel" ? 32 : 56;
const dragHeight = draggingBtn === "wheel" ? 68 : 56;
const clampedX = Math.max(0, Math.min(containerRect.width - dragWidth, x));
const clampedY = Math.max(0, Math.min(containerRect.height - dragHeight, y));
if (draggingBtn === "left") {
setLeftBtnPos({ x: clampedX, y: clampedY });
} else if (draggingBtn === "right") {
setRightBtnPos({ x: clampedX, y: clampedY });
} else if (draggingBtn === "leftLock") {
setLeftLockPos({ x: clampedX, y: clampedY });
} else if (draggingBtn === "rightLock") {
setRightLockPos({ x: clampedX, y: clampedY });
} else if (draggingBtn === "wheel") {
setWheelPos({ x: clampedX, y: clampedY });
}
};
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>, type: "left" | "right" | "leftLock" | "rightLock") => {
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>, type: "left" | "right" | "wheel") => {
const wasDragging = draggingBtn === type;
setDraggingBtn(null);
(e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
if (!wasDragging) return;
};
const updateJoystickVector = (clientX: number, clientY: number) => {
const joystickElm = joystickAreaRef.current;
if (!joystickElm) return;
const rect = joystickElm.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const rawX = clientX - centerX;
const rawY = clientY - centerY;
const maxRadius = 32;
const length = Math.hypot(rawX, rawY);
if (!length || length <= maxRadius) {
setJoystickVector({ x: rawX / maxRadius, y: rawY / maxRadius });
return;
}
const scale = maxRadius / length;
setJoystickVector({ x: (rawX * scale) / maxRadius, y: (rawY * scale) / maxRadius });
};
const handleJoystickPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (joystickMovePointerIdRef.current !== null) return;
e.preventDefault();
joystickPointerIdRef.current = e.pointerId;
e.currentTarget.setPointerCapture(e.pointerId);
updateJoystickVector(e.clientX, e.clientY);
};
const handleJoystickPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (joystickPointerIdRef.current !== e.pointerId) return;
e.preventDefault();
updateJoystickVector(e.clientX, e.clientY);
};
const handleJoystickPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
if (joystickPointerIdRef.current !== e.pointerId) return;
joystickPointerIdRef.current = null;
e.currentTarget.releasePointerCapture(e.pointerId);
setJoystickVector({ x: 0, y: 0 });
};
const handleJoystickMoveStart = (e: React.PointerEvent<HTMLDivElement>) => {
joystickMovePointerIdRef.current = e.pointerId;
joystickMoveEnabledRef.current = false;
if (joystickMoveHoldTimerRef.current !== null) {
window.clearTimeout(joystickMoveHoldTimerRef.current);
}
joystickMoveHoldTimerRef.current = window.setTimeout(() => {
joystickMoveEnabledRef.current = true;
}, 350);
e.currentTarget.setPointerCapture(e.pointerId);
e.preventDefault();
e.stopPropagation();
};
const handleJoystickMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (joystickMovePointerIdRef.current !== e.pointerId) return;
if (!joystickMoveEnabledRef.current) return;
const container = containerRef.current;
if (!container) return;
const containerRect = container.getBoundingClientRect();
const joystickSize = 80;
const nextLeft = e.clientX - containerRect.left - joystickSize / 2;
const nextTop = e.clientY - containerRect.top - joystickSize / 2;
const clampedLeft = Math.max(0, Math.min(containerRect.width - joystickSize, nextLeft));
const clampedTop = Math.max(0, Math.min(containerRect.height - joystickSize, nextTop));
const nextBottom = containerRect.height - joystickSize - clampedTop;
setJoystickPos({ x: clampedLeft, y: nextBottom });
e.preventDefault();
e.stopPropagation();
};
const handleJoystickMoveEnd = (e: React.PointerEvent<HTMLDivElement>) => {
if (joystickMovePointerIdRef.current !== e.pointerId) return;
if (joystickMoveHoldTimerRef.current !== null) {
window.clearTimeout(joystickMoveHoldTimerRef.current);
joystickMoveHoldTimerRef.current = null;
}
joystickMoveEnabledRef.current = false;
joystickMovePointerIdRef.current = null;
e.currentTarget.releasePointerCapture(e.pointerId);
e.preventDefault();
e.stopPropagation();
};
const isMouseControlEnabled = showVirtualJoystick || showVirtualMouseButtons;
const joystickSpeedIndex = joystickSpeedLevels.reduce((bestIndex, value, index, arr) => {
const bestDistance = Math.abs(arr[bestIndex] - joystickSensitivity);
const currentDistance = Math.abs(value - joystickSensitivity);
return currentDistance < bestDistance ? index : bestIndex;
}, 0);
const toggleMouseControl = () => {
const nextEnabled = !isMouseControlEnabled;
setShowVirtualJoystick(nextEnabled);
setShowVirtualMouseButtons(nextEnabled);
};
useEffect(() => {
const keyboardCleanup = keyboardEvents.setupKeyboardEvents();
const videoCleanup = videoStream.setupVideoEventListeners();
@@ -213,6 +424,11 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
drawerRender={() => (<UsbEpModeSelect/>)}
className={"px-[20px]"}
/>
<EnhancedDrawer
targetView={"UsbStatusPanel"}
placement={"bottom"}
drawerRender={() => (<UsbStatusPanel/>)}
/>
<EnhancedDrawer
title={$at("Virtual Media Source")}
@@ -267,6 +483,7 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
`h-full w-full ${dark_bg_style_fun(isDark)} object-contain transition-all duration-1000`,
{
"cursor-none": videoEffects.settings.isCursorHidden,
"pointer-events-none": isOcrMode,
"opacity-0": overlays.shouldHideVideo,
"opacity-60!": overlays.showPointerLockBar,
"animate-slideUpFade dark:border-slate-300/20":
@@ -274,6 +491,10 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
},
)}
/>
<OcrOverlay
videoRef={videoElm as React.RefObject<HTMLVideoElement>}
containerRef={zoomContainerRef as React.RefObject<HTMLDivElement>}
/>
{(videoStream.peerConnectionState === "connected" || forceHttp) && (
<div
@@ -296,107 +517,292 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
className="pointer-events-none absolute inset-0"
onPointerMove={handlePointerMove}
>
<div
className={cx(
"pointer-events-auto absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full text-white text-xs",
isDark ? "bg-gray-500/70" : "bg-black/30",
)}
style={{
touchAction: "none",
}}
onClick={() => setShowVirtualMouseButtons(prev => !prev)}
>
<BsMouseFill className="h-4 w-4" />
<div className="pointer-events-auto absolute right-3 top-3 grid grid-cols-[auto_auto_auto] grid-rows-2 gap-1.5">
<div
className={cx(
"flex h-8 w-8 items-center justify-center rounded-full text-white",
isTouchGestureEnabled
? "bg-green-600/80"
: "bg-gray-500/70",
)}
style={{
touchAction: "none",
}}
onClick={() => setIsTouchGestureEnabled(prev => !prev)}
>
<GestureIcon />
</div>
<div
className={cx(
"flex h-8 w-8 items-center justify-center rounded-full text-white",
isMouseControlEnabled
? "bg-green-600/80"
: "bg-gray-500/70",
)}
style={{
touchAction: "none",
}}
onClick={toggleMouseControl}
>
<MouseStickIcon />
</div>
<div
className={cx(
"flex h-8 w-8 items-center justify-center rounded-full text-white",
allowTapToOpenVirtualKeyboard
? "bg-green-600/80"
: "bg-gray-500/70",
)}
style={{
touchAction: "none",
}}
onClick={() => setAllowTapToOpenVirtualKeyboard(!allowTapToOpenVirtualKeyboard)}
>
<BsKeyboardFill className="h-4 w-4" />
</div>
<div
className={cx(
"col-span-3 flex h-8 items-center justify-center rounded-full text-white",
isDark ? "bg-gray-500/70" : "bg-black/30",
)}
style={{
touchAction: "none",
}}
onClick={() => touchZoom.resetTransform()}
>
<ResetViewIcon />
</div>
</div>
{showVirtualMouseButtons && (
<>
<div
className={cx(
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100",
isDark ? "bg-gray-500/70" : "bg-black/30",
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100 relative",
(lockedButtons & 1) ? "bg-green-600/80" : (isDark ? "bg-gray-500/70" : "bg-black/30"),
)}
style={{
left: leftBtnPos.x,
top: leftBtnPos.y,
touchAction: "none",
}}
onPointerDown={e => {
handlePointerDown(e, "left");
onPointerDown={() => {
updateButtons(1, true);
}}
onPointerUp={e => {
handlePointerUp(e, "left");
onPointerUp={() => {
updateButtons(1, false);
}}
>
L
<div
className="absolute -left-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/45 text-white"
onPointerDown={e => {
e.stopPropagation();
handlePointerDown(e, "left");
}}
onPointerMove={e => {
e.stopPropagation();
handlePointerMove(e);
}}
onPointerUp={e => {
e.stopPropagation();
handlePointerUp(e, "left");
}}
>
<FourWayMoveIcon className="h-3 w-3" />
</div>
<div
className={cx(
"absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full text-white",
(lockedButtons & 1) ? "bg-green-600/90" : "bg-black/45",
)}
onPointerDown={e => {
e.stopPropagation();
}}
onClick={e => {
e.stopPropagation();
toggleLock(1);
}}
>
{(lockedButtons & 1) ? <BsLockFill className="h-3 w-3" /> : <BsUnlockFill className="h-3 w-3" />}
</div>
</div>
<div
className={cx(
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100",
(lockedButtons & 1) ? "bg-green-600/80" : (isDark ? "bg-gray-500/70" : "bg-black/30"),
)}
style={{
left: leftLockPos.x,
top: leftLockPos.y,
touchAction: "none",
}}
onPointerDown={e => {
handlePointerDown(e, "leftLock");
}}
onPointerUp={e => {
handlePointerUp(e, "leftLock");
}}
onClick={() => toggleLock(1)}
>
{(lockedButtons & 1) ? <BsLockFill /> : <BsUnlockFill />} L
</div>
<div
className={cx(
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100",
isDark ? "bg-gray-500/70" : "bg-black/30",
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100 relative",
(lockedButtons & 2) ? "bg-green-600/80" : (isDark ? "bg-gray-500/70" : "bg-black/30"),
)}
style={{
left: rightBtnPos.x,
top: rightBtnPos.y,
touchAction: "none",
}}
onPointerDown={e => {
handlePointerDown(e, "right");
onPointerDown={() => {
updateButtons(2, true);
}}
onPointerUp={e => {
handlePointerUp(e, "right");
onPointerUp={() => {
updateButtons(2, false);
}}
>
R
<div
className="absolute -left-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/45 text-white"
onPointerDown={e => {
e.stopPropagation();
handlePointerDown(e, "right");
}}
onPointerMove={e => {
e.stopPropagation();
handlePointerMove(e);
}}
onPointerUp={e => {
e.stopPropagation();
handlePointerUp(e, "right");
}}
>
<FourWayMoveIcon className="h-3 w-3" />
</div>
<div
className={cx(
"absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full text-white",
(lockedButtons & 2) ? "bg-green-600/90" : "bg-black/45",
)}
onPointerDown={e => {
e.stopPropagation();
}}
onClick={e => {
e.stopPropagation();
toggleLock(2);
}}
>
{(lockedButtons & 2) ? <BsLockFill className="h-3 w-3" /> : <BsUnlockFill className="h-3 w-3" />}
</div>
</div>
<div
className={cx(
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100",
(lockedButtons & 2) ? "bg-green-600/80" : (isDark ? "bg-gray-500/70" : "bg-black/30"),
)}
className="pointer-events-auto absolute"
style={{
left: rightLockPos.x,
top: rightLockPos.y,
left: wheelPos.x,
top: wheelPos.y,
touchAction: "none",
}}
onPointerDown={e => {
handlePointerDown(e, "rightLock");
}}
onPointerUp={e => {
handlePointerUp(e, "rightLock");
}}
onClick={() => toggleLock(2)}
>
{(lockedButtons & 2) ? <BsLockFill /> : <BsUnlockFill />} R
<div
className={cx(
"flex h-8 w-8 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100",
isDark ? "bg-gray-500/70" : "bg-black/30",
)}
onClick={() => { send("wheelReport", { wheelY: 1 }); }}
>
</div>
<div
className="mt-1 flex h-5 w-8 items-center justify-center rounded-full bg-black/45 text-white"
onPointerDown={e => {
e.stopPropagation();
handlePointerDown(e, "wheel");
}}
onPointerMove={e => {
e.stopPropagation();
handlePointerMove(e);
}}
onPointerUp={e => {
e.stopPropagation();
handlePointerUp(e, "wheel");
}}
>
<FourWayMoveIcon className="h-3 w-3" />
</div>
<div
className={cx(
"mt-1 flex h-8 w-8 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100",
isDark ? "bg-gray-500/70" : "bg-black/30",
)}
onClick={() => { send("wheelReport", { wheelY: -1 }); }}
>
</div>
</div>
</>
)}
{showVirtualJoystick && (
<div
className="pointer-events-auto absolute"
style={{ left: joystickPos.x, bottom: joystickPos.y }}
>
<div className="absolute -top-7 left-0 flex items-center gap-1">
<div
className={cx(
"flex h-6 w-6 items-center justify-center rounded-full text-white transition-transform duration-100 active:scale-95",
isDark ? "bg-gray-500/70" : "bg-black/30",
)}
style={{ touchAction: "none" }}
onPointerDown={handleJoystickMoveStart}
onPointerMove={handleJoystickMove}
onPointerUp={handleJoystickMoveEnd}
onPointerCancel={handleJoystickMoveEnd}
>
<FourWayMoveIcon className="h-4 w-4" />
</div>
</div>
<div className="absolute right-[-36px] top-0 flex h-20 items-center">
<div
className={cx(
"relative flex h-16 w-3 flex-col justify-between rounded-full py-1",
isDark ? "bg-white/25" : "bg-black/20",
)}
style={{ touchAction: "none" }}
>
{joystickSpeedLevels.map((level, index) => (
<button
key={level}
type="button"
className={cx(
"relative z-10 h-3 w-3 rounded-full border",
joystickSpeedIndex === index
? "border-blue-300 bg-blue-400"
: (isDark ? "border-white/60 bg-white/40" : "border-black/40 bg-black/20"),
)}
onClick={() => setJoystickSensitivity(level)}
aria-label={`Set joystick speed ${level.toFixed(2)}`}
/>
))}
<div
className="pointer-events-none absolute left-full top-1/2 ml-[1px] -translate-y-1/2 border-y-[4px] border-l-[6px] border-y-transparent border-l-blue-400"
style={{
top: `${(joystickSpeedIndex / (joystickSpeedLevels.length - 1)) * 100}%`,
}}
/>
</div>
<div className="ml-1 flex h-16 flex-col justify-between text-[8px] text-white/80">
<span>Fast</span>
<span>Slow</span>
</div>
</div>
<div
ref={joystickAreaRef}
className={cx(
"flex h-20 w-20 items-center justify-center rounded-full border",
isDark ? "border-white/40 bg-black/20" : "border-black/30 bg-white/20",
)}
style={{ touchAction: "none" }}
onPointerDown={handleJoystickPointerDown}
onPointerMove={handleJoystickPointerMove}
onPointerUp={handleJoystickPointerUp}
onPointerCancel={handleJoystickPointerUp}
>
<div
className={cx(
"h-9 w-9 rounded-full",
isDark ? "bg-white/70" : "bg-black/50",
)}
style={{
transform: `translate(${joystickVector.x * 24}px, ${joystickVector.y * 24}px)`,
}}
/>
</div>
</div>
)}
</div>
</div>
<VirtualKeyboard />

View File

@@ -12,6 +12,7 @@ import { cx } from "@/cva.config";
import { useVideoEffects } from "@/layout/core/desktop/hooks/useVideoEffects";
import { useVideoStream } from "@/layout/core/desktop/hooks/useVideoStream";
import { usePointerLock } from "@/layout/core/desktop/hooks/usePointerLock";
import { useFullscreen } from "@/layout/core/desktop/hooks/useFullscreen";
import { useKeyboardEvents } from "@/layout/core/desktop/hooks/useKeyboardEvents";
import { useMouseEvents } from "@/layout/core/desktop/hooks/useMouseEvents";
import { useVideoOverlays } from "@/layout/core/desktop/hooks/useVideoOverlays";
@@ -24,8 +25,9 @@ import { MacroMoreList } from "@/layout/components_side/Macros/MacroTopBar";
import { useUiStore, useHidStore, useSettingsStore } from "@/hooks/stores";
import { useTouchZoom } from "@/layout/core/desktop/hooks/useTouchZoom";
import { usePasteHandler } from "@/layout/core/desktop/hooks/usePasteHandler";
import OcrOverlay from "@components/OcrOverlay";
export default function PCDesktop() {
export default function PCDesktop({ isFullscreen }: { isFullscreen?: number }) {
const videoElm = useRef<HTMLVideoElement>(null);
const audioElm = useRef<HTMLAudioElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -37,6 +39,7 @@ export default function PCDesktop() {
const setTerminalType = useUiStore(state => state.setTerminalType);
const terminalType = useUiStore(state => state.terminalType);
const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled);
const isOcrMode = useUiStore(state => state.isOcrMode);
const forceHttp = useSettingsStore(state => state.forceHttp);
@@ -55,6 +58,7 @@ export default function PCDesktop() {
const videoEffects = useVideoEffects();
const videoStream = useVideoStream(videoElm as React.RefObject<HTMLVideoElement>, audioElm as React.RefObject<HTMLAudioElement>);
const pointerLock = usePointerLock(videoElm as React.RefObject<HTMLVideoElement>);
useFullscreen(videoElm as React.RefObject<HTMLVideoElement>, pointerLock, isFullscreen);
const touchZoom = useTouchZoom(zoomContainerRef as React.RefObject<HTMLDivElement>);
const { handleGlobalPaste } = usePasteHandler(pasteCaptureRef as React.RefObject<HTMLTextAreaElement>);
@@ -114,6 +118,7 @@ export default function PCDesktop() {
`max-h-full min-h-[384px] max-w-full min-w-[512px] object-contain transition-all duration-1000`,
{
"cursor-none": videoEffects.settings.isCursorHidden,
"pointer-events-none": isOcrMode,
"opacity-0": overlays.shouldHideVideo,
"opacity-60!": overlays.showPointerLockBar,
"animate-slideUpFade shadow-xs ":
@@ -121,6 +126,10 @@ export default function PCDesktop() {
},
)}
/>
<OcrOverlay
videoRef={videoElm as React.RefObject<HTMLVideoElement>}
containerRef={zoomContainerRef as React.RefObject<HTMLDivElement>}
/>
{(videoStream.peerConnectionState === "connected" || forceHttp) && (
<div

View File

@@ -1,8 +1,10 @@
import { useCallback } from "react";
import useKeyboard from "@/hooks/useKeyboard";
import { useHidStore, useSettingsStore } from "@/hooks/stores";
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings";
import { keyboards } from "@/keyboardLayouts";
import { eventMatchesShortcut } from "@/utils/shortcuts";
export const useKeyboardEvents = (
pasteCaptureRef?: React.RefObject<HTMLTextAreaElement>,
@@ -14,7 +16,31 @@ export const useKeyboardEvents = (
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const isKeyboardLedManagedByHost = keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable;
const overrideCtrlV = useSettingsStore(state => state.overrideCtrlV);
const pasteShortcutEnabled = useSettingsStore(state => state.pasteShortcutEnabled);
const pasteShortcut = useSettingsStore(state => state.pasteShortcut);
const isOcrMode = useUiStore(state => state.isOcrMode);
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
const remapCode = useCallback((code: string, key: string): string => {
const modifierCodes = ["ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight", "AltLeft", "AltRight", "MetaLeft", "MetaRight", "CapsLock", "Tab", "Enter", "Backspace", "Delete", "Insert", "Home", "End", "PageUp", "PageDown", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Escape", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12", "PrintScreen", "ScrollLock", "Pause", "ContextMenu", "Menu"];
if (modifierCodes.includes(code)) return code;
if (code.startsWith("Digit") || code.startsWith("Numpad")) return code;
if (code.startsWith("Key") && code.length === 4) {
const letter = code.charAt(3);
if (letter >= "A" && letter <= "Z") {
const isoCode = (keyboardLayout || "en-US").replace("_", "-");
const layout = keyboards.find(k => k.isoCode === isoCode);
if (layout && layout.chars) {
const charLower = key.toLowerCase();
const charEntry = layout.chars[charLower] || layout.chars[key];
if (charEntry && charEntry.key && typeof charEntry.key === "string" && charEntry.key !== code) {
return charEntry.key;
}
}
}
}
return code;
}, [keyboardLayout]);
const handleModifierKeys = useCallback((e: KeyboardEvent, activeModifiers: number[]) => {
const { shiftKey, ctrlKey, altKey, metaKey } = e;
@@ -28,17 +54,22 @@ export const useKeyboardEvents = (
}, []);
const keyDownHandler = useCallback(async (e: KeyboardEvent) => {
if (overrideCtrlV && (e.code === "KeyV" || e.key.toLowerCase() === "v") && (e.ctrlKey || e.metaKey)) {
console.log("Override Ctrl V");
if (isReinitializingGadget) return;
if (pasteCaptureRef && pasteCaptureRef.current) {
pasteCaptureRef.current.value = "";
pasteCaptureRef.current.focus();
}
return;
if (isOcrMode) return;
if (pasteShortcutEnabled && eventMatchesShortcut(e, pasteShortcut)) {
if (isReinitializingGadget) return;
if (pasteCaptureRef && pasteCaptureRef.current) {
pasteCaptureRef.current.value = "";
pasteCaptureRef.current.focus();
}
return;
}
if (isReinitializingGadget) return;
if (e.repeat) {
e.preventDefault();
return;
}
e.preventDefault();
const prev = useHidStore.getState();
let code = e.code;
@@ -56,6 +87,8 @@ export const useKeyboardEvents = (
code = "IntlBackslash";
}
code = remapCode(code, key);
const newKeys = [...prev.activeKeys, keys[code]].filter(Boolean);
const newModifiers = handleModifierKeys(e, [...prev.activeModifiers, modifiers[code]]);
@@ -67,12 +100,15 @@ export const useKeyboardEvents = (
}
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
}, [handleModifierKeys, sendKeyboardEvent, isKeyboardLedManagedByHost, setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive, overrideCtrlV, pasteCaptureRef, isReinitializingGadget]);
}, [handleModifierKeys, remapCode, sendKeyboardEvent, isKeyboardLedManagedByHost, setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive, pasteShortcutEnabled, pasteShortcut, pasteCaptureRef, isReinitializingGadget, isOcrMode]);
const keyUpHandler = useCallback((e: KeyboardEvent) => {
if (isOcrMode) return;
if (isReinitializingGadget) return;
e.preventDefault();
const prev = useHidStore.getState();
const key = e.key;
let code = remapCode(e.code, key);
if (!isKeyboardLedManagedByHost) {
setIsNumLockActive(e.getModifierState("NumLock"));
@@ -80,14 +116,14 @@ export const useKeyboardEvents = (
setIsScrollLockActive(e.getModifierState("ScrollLock"));
}
const newKeys = prev.activeKeys.filter(k => k !== keys[e.code]).filter(Boolean);
const newKeys = prev.activeKeys.filter(k => k !== keys[code]).filter(Boolean);
const newModifiers = handleModifierKeys(
e,
prev.activeModifiers.filter(k => k !== modifiers[e.code]),
prev.activeModifiers.filter(k => k !== modifiers[code]),
);
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
}, [handleModifierKeys, sendKeyboardEvent, isKeyboardLedManagedByHost, setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive]);
}, [handleModifierKeys, remapCode, sendKeyboardEvent, isKeyboardLedManagedByHost, setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive, isOcrMode, isReinitializingGadget]);
const setupKeyboardEvents = useCallback(() => {
const abortController = new AbortController();

View File

@@ -25,15 +25,18 @@ export const useMouseEvents = (
const { setMousePosition, setMouseMove } = useMouseStore();
const { width: videoWidth, height: videoHeight } = useVideoStore();
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
const touchDragActiveRef = useRef(false);
const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos);
const sendRelMouseMovement = useCallback(
(x: number, y: number, buttons: number) => {
if (settings.mouseMode !== "relative") return;
(x: number, y: number, buttons: number, force = false) => {
if (!force && settings.mouseMode !== "relative") return;
// Don't send mouse events while reinitializing gadget
if (isReinitializingGadget) return;
send("relMouseReport", { dx: calcDelta(x), dy: calcDelta(y), buttons });
const dx = calcDelta(x);
const dy = calcDelta(y);
send("relMouseReport", { dx, dy, buttons });
setMouseMove({ x, y, buttons });
},
[send, setMouseMove, settings.mouseMode, isReinitializingGadget],
@@ -50,6 +53,13 @@ export const useMouseEvents = (
[send, setMousePosition, settings.mouseMode, isReinitializingGadget],
);
const sendVirtualRelativeMovement = useCallback(
(x: number, y: number, buttons = 0) => {
sendRelMouseMovement(x, y, buttons, true);
},
[sendRelMouseMovement],
);
const relMouseMoveHandler = useCallback(
(e: MouseEvent) => {
const pt = (e as unknown as PointerEvent).pointerType as unknown as string;
@@ -76,11 +86,17 @@ export const useMouseEvents = (
const absMouseMoveHandler = useCallback(
(e: MouseEvent) => {
const pt = (e as unknown as PointerEvent).pointerType as unknown as string;
const pointerEvent = e as unknown as PointerEvent;
const eventType = pointerEvent.type;
if (pt === "touch") {
if (touchZoom) {
const touchCount = touchZoom.activeTouchPointers.current.size;
const eventType = (e as unknown as PointerEvent).type;
if (touchCount >= 2 && eventType !== "pointerup") return;
const touchCount = touchZoom.activeTouchPointers.current.size;
if (touchCount >= 2) {
if (eventType === "pointerup" || eventType === "pointercancel") {
touchDragActiveRef.current = false;
}
return;
}
}
}
@@ -135,28 +151,20 @@ export const useMouseEvents = (
let buttons = e.buttons;
if (pt === "touch") {
const touchCount = touchZoom ? touchZoom.activeTouchPointers.current.size : 1;
const pointerEvent = e as unknown as PointerEvent;
const eventType = pointerEvent.type;
if (eventType === "pointerup") {
if (touchCount >= 2 || disableTouchClick) {
buttons = 0;
} else {
buttons = 1;
}
} else {
if (eventType === "pointerdown") {
touchDragActiveRef.current = !disableTouchClick;
}
if (eventType === "pointerup" || eventType === "pointercancel") {
buttons = 0;
touchDragActiveRef.current = false;
} else {
buttons = touchDragActiveRef.current ? 1 : 0;
}
}
buttons |= externalButtons;
sendAbsMouseMovement(x, y, buttons);
if (pt === "touch" && buttons !== externalButtons && (e as unknown as PointerEvent).type === "pointerup") {
sendAbsMouseMovement(x, y, externalButtons);
}
},
[settings.mouseMode, videoElm, videoWidth, videoHeight, sendAbsMouseMovement, touchZoom, disableTouchClick, externalButtons],
);
@@ -216,8 +224,10 @@ export const useMouseEvents = (
};
videoElmRefValue.addEventListener("mousemove", eventHandler, { signal });
videoElmRefValue.addEventListener("pointermove", eventHandler, { signal });
videoElmRefValue.addEventListener("pointerdown", eventHandler, { signal });
videoElmRefValue.addEventListener("pointerup", eventHandler, { signal });
videoElmRefValue.addEventListener("pointercancel", eventHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
signal,
passive: true,
@@ -255,5 +265,6 @@ export const useMouseEvents = (
return {
setupMouseEvents,
sendVirtualRelativeMovement,
};
};

View File

@@ -5,10 +5,12 @@ import { useSettingsStore, useHidStore, useUiStore } from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings";
import { chars } from "@/keyboardLayouts";
import notifications from "@/notifications";
import { eventMatchesShortcut } from "@/utils/shortcuts";
export const usePasteHandler = (pasteCaptureRef?: React.RefObject<HTMLTextAreaElement>) => {
const [send] = useJsonRpc();
const overrideCtrlV = useSettingsStore(state => state.overrideCtrlV);
const pasteShortcutEnabled = useSettingsStore(state => state.pasteShortcutEnabled);
const pasteShortcut = useSettingsStore(state => state.pasteShortcut);
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
const setKeyboardLayout = useSettingsStore(state => state.setKeyboardLayout);
const debugMode = useSettingsStore(state => state.debugMode);
@@ -140,12 +142,11 @@ export const usePasteHandler = (pasteCaptureRef?: React.RefObject<HTMLTextAreaEl
}, [log, send, setKeyboardLayout]);
useEffect(() => {
if (!overrideCtrlV) return;
if (!pasteShortcutEnabled) return;
const onKeyDownCapture = (e: KeyboardEvent) => {
if (!overrideCtrlV) return;
if (!(e.ctrlKey || e.metaKey)) return;
if (e.code !== "KeyV" && e.key.toLowerCase() !== "v") return;
if (!pasteShortcutEnabled) return;
if (!eventMatchesShortcut(e, pasteShortcut)) return;
if (isReinitializingGadget) return;
const activeElement = document.activeElement as HTMLElement | null;
@@ -196,20 +197,20 @@ export const usePasteHandler = (pasteCaptureRef?: React.RefObject<HTMLTextAreaEl
return () => {
document.removeEventListener("keydown", onKeyDownCapture, { capture: true });
};
}, [ensureFocusTrapPaused, isReinitializingGadget, log, overrideCtrlV, pasteCaptureRef, safeKeyboardLayout, sendTextToRemote]);
}, [ensureFocusTrapPaused, isReinitializingGadget, log, pasteShortcutEnabled, pasteShortcut, pasteCaptureRef, safeKeyboardLayout, sendTextToRemote]);
const handleGlobalPaste = useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement> | ClipboardEvent) => {
if (!overrideCtrlV) return;
if (!pasteShortcutEnabled) return;
e.preventDefault();
const clipboardData = (e as React.ClipboardEvent).clipboardData || (e as ClipboardEvent).clipboardData;
const txt = clipboardData?.getData("text") || "";
await sendTextToRemote(txt);
}, [log, overrideCtrlV, safeKeyboardLayout, sendTextToRemote]);
}, [log, pasteShortcutEnabled, safeKeyboardLayout, sendTextToRemote]);
return {
handleGlobalPaste,
overrideCtrlV,
pasteShortcutEnabled,
};
};

View File

@@ -7,31 +7,17 @@ export const usePointerLock = (videoElm: React.RefObject<HTMLVideoElement>) => {
const settings = useSettingsStore();
const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost";
const checkNavigatorPermissions = useCallback(async (permissionName: string) => {
if (!navigator.permissions?.query) return false;
const requestPointerLock = useCallback(async () => {
if (!isPointerLockPossible || !videoElm.current || document.pointerLockElement || settings.mouseMode !== "relative") {
return;
}
try {
const { state } = await navigator.permissions.query({
name: permissionName as PermissionName
});
return state === "granted";
} catch {
return false;
await videoElm.current.requestPointerLock();
} catch (err) {
console.warn("[pointer-lock] requestPointerLock failed:", err);
}
}, []);
const requestPointerLock = useCallback(async () => {
if (!isPointerLockPossible || !videoElm.current || document.pointerLockElement) return;
const isPointerLockGranted = await checkNavigatorPermissions("pointer-lock");
if (isPointerLockGranted && settings.mouseMode === "relative") {
try {
await videoElm.current.requestPointerLock();
} catch {
// ignore errors
}
}
}, [checkNavigatorPermissions, isPointerLockPossible, settings.mouseMode, videoElm]);
}, [isPointerLockPossible, settings.mouseMode, videoElm]);
useEffect(() => {
if (!isPointerLockPossible || !videoElm.current) return;
@@ -40,6 +26,7 @@ export const usePointerLock = (videoElm: React.RefObject<HTMLVideoElement>) => {
setIsPointerLockActive(!!document.pointerLockElement);
};
handlePointerLockChange();
document.addEventListener("pointerlockchange", handlePointerLockChange);
return () => document.removeEventListener("pointerlockchange", handlePointerLockChange);
}, [isPointerLockPossible, videoElm]);

View File

@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from "react";
export const useTouchZoom = (
containerRef: React.RefObject<HTMLDivElement>
containerRef: React.RefObject<HTMLDivElement>,
gestureEnabled = true,
) => {
const [mobileScale, setMobileScale] = useState(1);
const [mobileTx, setMobileTx] = useState(0);
@@ -17,67 +18,83 @@ export const useTouchZoom = (
if (!el) return;
const abortController = new AbortController();
const signal = abortController.signal;
const isPointInVideo = (x: number, y: number) => {
const video = el.querySelector("video") as HTMLVideoElement | null;
if (!video) return false;
const vRect = video.getBoundingClientRect();
return x >= vRect.left && x <= vRect.right && y >= vRect.top && y <= vRect.bottom;
};
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType !== "touch") return;
activeTouchPointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (!gestureEnabled) return;
let shouldHandleLocalGesture = false;
if (activeTouchPointers.current.size === 1) {
const now = Date.now();
let isInVideo = false;
const video = el.querySelector("video") as HTMLVideoElement | null;
if (video) {
const vRect = video.getBoundingClientRect();
if (
e.clientX >= vRect.left &&
e.clientX <= vRect.right &&
e.clientY >= vRect.top &&
e.clientY <= vRect.bottom
) {
isInVideo = true;
}
}
const isInVideo = isPointInVideo(e.clientX, e.clientY);
if (!isInVideo) {
if (now - lastTapAt.current < 300) {
setMobileScale(1);
setMobileTx(0);
setMobileTy(0);
}
shouldHandleLocalGesture = true;
}
lastTapAt.current = now;
lastPanPoint.current = { x: e.clientX, y: e.clientY };
if (mobileScale > 1) {
lastPanPoint.current = { x: e.clientX, y: e.clientY };
shouldHandleLocalGesture = true;
} else {
lastPanPoint.current = null;
}
} else if (activeTouchPointers.current.size === 2) {
const pts = Array.from(activeTouchPointers.current.values());
const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
initialPinchDistance.current = d;
initialPinchScale.current = mobileScale;
shouldHandleLocalGesture = true;
}
if (shouldHandleLocalGesture) {
e.preventDefault();
e.stopPropagation();
}
e.preventDefault();
e.stopPropagation();
};
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType !== "touch") return;
const prev = activeTouchPointers.current.get(e.pointerId);
activeTouchPointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (!gestureEnabled) return;
const pts = Array.from(activeTouchPointers.current.values());
let shouldHandleLocalGesture = false;
if (pts.length === 2 && initialPinchDistance.current) {
const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
const factor = d / initialPinchDistance.current;
const next = Math.max(1, Math.min(4, initialPinchScale.current * factor));
setMobileScale(next);
} else if (pts.length === 1 && lastPanPoint.current && prev) {
shouldHandleLocalGesture = true;
} else if (pts.length === 1 && mobileScale > 1 && lastPanPoint.current && prev) {
const dx = e.clientX - lastPanPoint.current.x;
const dy = e.clientY - lastPanPoint.current.y;
lastPanPoint.current = { x: e.clientX, y: e.clientY };
setMobileTx(v => v + dx);
setMobileTy(v => v + dy);
shouldHandleLocalGesture = true;
}
if (shouldHandleLocalGesture) {
e.preventDefault();
e.stopPropagation();
}
e.preventDefault();
e.stopPropagation();
};
const onPointerUp = (e: PointerEvent) => {
if (e.pointerType !== "touch") return;
const wasHandlingLocalGesture = gestureEnabled && (initialPinchDistance.current !== null || mobileScale > 1);
activeTouchPointers.current.delete(e.pointerId);
if (activeTouchPointers.current.size < 2) {
initialPinchDistance.current = null;
@@ -85,8 +102,11 @@ export const useTouchZoom = (
if (activeTouchPointers.current.size === 0) {
lastPanPoint.current = null;
}
e.preventDefault();
e.stopPropagation();
if (wasHandlingLocalGesture) {
e.preventDefault();
e.stopPropagation();
}
};
el.addEventListener("pointerdown", onPointerDown, { signal });
@@ -95,7 +115,15 @@ export const useTouchZoom = (
el.addEventListener("pointercancel", onPointerUp, { signal });
return () => abortController.abort();
}, [mobileScale, containerRef]);
}, [mobileScale, containerRef, gestureEnabled]);
const resetTransform = () => {
initialPinchDistance.current = null;
lastPanPoint.current = null;
setMobileScale(1);
setMobileTx(0);
setMobileTy(0);
};
useEffect(() => {
const container = containerRef.current;
@@ -115,5 +143,6 @@ export const useTouchZoom = (
mobileTy,
activeTouchPointers,
lastPanPoint,
resetTransform,
};
};

View File

@@ -9,5 +9,5 @@ export default function Desktop({ isFullscreen }: { isFullscreen?: number }) {
if(isMobile){
return <MobileDesktop isFullscreen={isFullscreen}/>
}
return <PCDesktop/> ;
return <PCDesktop isFullscreen={isFullscreen}/> ;
}

View File

@@ -33,6 +33,7 @@ import {
useSettingsStore,
useVpnStore } from "@/hooks/stores";
import { JsonRpcRequest, useJsonRpc, resetHttpSessionId } from "@/hooks/useJsonRpc";
import api from "@/api";
import Modal from "@components/Modal";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import {
@@ -351,6 +352,18 @@ export default function MobileHome() {
try {
console.log("[setupPeerConnection] Creating peer connection");
setLoadingMessage("Creating peer connection...");
let fetchedIceServers: RTCIceServer[] = [];
if (!iceConfig?.iceServers) {
try {
const res = await api.GET("/api/ice-servers");
const data = await res.json();
fetchedIceServers = data.iceServers ?? [];
} catch (e) {
console.error("failed to fetch ICE servers, fallback", e);
fetchedIceServers = [{ urls: ["stun:stun.l.google.com:19302"] }];
}
}
pc = new RTCPeerConnection({
// We only use STUN or TURN servers if we're in the cloud
//...(isInCloud && iceConfig?.iceServers
@@ -358,13 +371,7 @@ export default function MobileHome() {
// : {}),
...(iceConfig?.iceServers
? { iceServers: [iceConfig?.iceServers] }
: {
iceServers: [
{
urls: ['stun:stun.l.google.com:19302']
}
]
}),
: { iceServers: fetchedIceServers }),
});
setPeerConnectionState(pc.connectionState);

View File

@@ -363,6 +363,18 @@ export default function PCHome() {
try {
console.log("[setupPeerConnection] Creating peer connection");
setLoadingMessage("Creating peer connection...");
let fetchedIceServers: RTCIceServer[] = [];
if (!iceConfig?.iceServers) {
try {
const res = await api.GET("/api/ice-servers");
const data = await res.json();
fetchedIceServers = data.iceServers ?? [];
} catch (e) {
console.error("failed to fetch ICE servers, fallback", e);
fetchedIceServers = [{ urls: ["stun:stun.l.google.com:19302"] }];
}
}
pc = new RTCPeerConnection({
// We only use STUN or TURN servers if we're in the cloud
//...(isInCloud && iceConfig?.iceServers
@@ -370,13 +382,7 @@ export default function PCHome() {
// : {}),
...(iceConfig?.iceServers
? { iceServers: [iceConfig?.iceServers] }
: {
iceServers: [
{
urls: ['stun:stun.l.google.com:19302']
}
]
}),
: { iceServers: fetchedIceServers }),
});
setPeerConnectionState(pc.connectionState);
@@ -837,9 +843,9 @@ export default function PCHome() {
<Desktop isFullscreen={isFullscreen} />
<div
style={{ animationDuration: "500ms" }}
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center p-4"
className="animate-slideUpFade pointer-events-none absolute inset-0 z-20 flex items-center justify-center"
>
<div className={`relative h-full max-h-[720px] w-full rounded-md`}>
<div className="relative h-full w-full">
{!!ConnectionStatusElement && ConnectionStatusElement}
{/*<ConnectionFailedOverlay show={true} setupPeerConnection={setupPeerConnection} />*/}
</div>

View File

@@ -14,10 +14,11 @@
"9aab28af69": "Variation in packet delay, affecting video smoothness.",
"75316ccce3": "Frame per second",
"75a4e60fe7": "Number of video frames displayed per second.",
"867f8b3151": "Only {{types}} files are supported",
"13c27e178b": "Please select the file {{fileName}} to continue the upload.",
"8282fb0974": "Resume Upload",
"411f67b4c6": "Click here to select {{fileName}} to resume upload",
"5d2e347cdb": "Click here to upload a new image",
"6e8daad8ab": "Click here to upload",
"ddeb0eac69": "Do not support directories",
"3f1c5b0049": "Uploading",
"f287042190": "Uploading...",
@@ -103,6 +104,14 @@
"f9099fc033": "Renew DHCP Lease",
"6e188f5984": "IPv6 Information",
"cfdffa4fc7": "Link-local",
"a588b1abc5": "Copied",
"c5505be6c3": "No text detected",
"a29aaf6603": "OCR failed",
"dc67ba5f40": "Drag to select text area",
"b603b043e8": "Copy text",
"f5f78c501b": "Recognizing text...",
"5df17e67f5": "Review the recognized text before copying.",
"4dfb4a614f": "Please wait while OCR is running.",
"d6e24388b4": "Continue Uploading",
"62a5e49088": "Hide",
"b34e855501": "USB Expansion Function",
@@ -172,6 +181,24 @@
"c40a72ad93": "KVM Terminal",
"dd110018d2": "Serial Console",
"7b277018e4": "Not connected",
"02d2f33ec9": "Reinitialize USB Gadget",
"c8e875e1c9": "Version Manager",
"63a6a88c06": "Refresh",
"d15f6e3312": "Hide Install Actions",
"2b9b84a087": "Show Install Actions",
"81dfff9d6f": "Install Status",
"98dd43dfae": "Installed",
"ddd8eef6f8": "Not Installed",
"e2dc83997b": "Detected Version",
"e3cc7e7df9": "System Architecture",
"bc56777902": "Release Version",
"c319e3982f": "Release Asset",
"06933067aa": "Update",
"a27dfe7717": "Uninstall",
"349838fb1d": "Install",
"4a0764788d": "Install Task",
"46b5f8c58b": "Progress",
"5e4caae72b": "Installed Versions",
"bf733d8a93": "Access",
"70e23e7d6f": "Manage the Access Control of the device",
"509820290d": "Local",
@@ -194,6 +221,8 @@
"efd3cc702e": "Enable Password",
"8f1e77e0d2": "Change Password",
"3d0de21428": "Update your device access password",
"d6d56d5972": "WebRTC Servers",
"8bea04c48e": "STUN and TURN servers used for peer connections",
"f8508f576c": "Remote",
"a8bb6f5f9f": "Manage the mode of Remote access to the device",
"0b86461350": "TailScale use xEdge server",
@@ -250,6 +279,58 @@
"c420b0d8f0": "Enter Cloudflare Tunnel Token",
"1bbabcdef3": "Edit frpc.toml",
"9c088a303a": "Enter frpc configuration",
"87ed72fc20": "Failed to get firewall config",
"a25ea1e1ee": "Failed to apply firewall config",
"bcbc6c71a1": "Firewall config applied",
"f4b0e93291": "Please select protocol",
"7687f1154f": "Missing required fields",
"a133cae395": "Invalid port",
"d10b3069c2": "For PREROUTING, Destination IP must be a real host IP",
"0834bb6c87": "Destination IP is required",
"c5381dc540": "Firewall",
"8256c0c407": "Manage the firewall rules of the device",
"972e73b7a8": "Basic",
"7218073b8c": "Communication Rules",
"23fed496dd": "Port Forwarding",
"324118a672": "Input",
"29c2c02a36": "Output",
"67d2f6740a": "Forward",
"a4d3b161ce": "Submit",
"ec211f7c20": "Add",
"5320550175": "Chain",
"f31bbdd1b3": "Source",
"888a77f5ac": "Protocol",
"12007e1d59": "Destination",
"004bf6c9a4": "Action",
"b5a7adde1a": "Description",
"2a78ed7645": "Operation",
"095c375025": "No rules available",
"ed36a1ef76": "Any",
"7dce122004": "Edit",
"efba20a02e": "No data available",
"e0aa021e21": "OK",
"7d367dab8b": "Source IP",
"c050956ed2": "Source Port",
"94386968c2": "Destination IP",
"64ae5dd047": "Destination Port",
"fbb3878eb7": "Submit Firewall Policies?",
"1c17a7e15b": "Warning: Adjusting some policies may cause network address loss, leading to device unavailability.",
"6f20995c95": "Failed to load WebRTC servers",
"aee9784c03": "Unknown error",
"6ef7ce5b80": "Failed to save STUN",
"4cd48aed7f": "STUN server saved",
"4b5d050e51": "Failed to save TURN",
"f6f10b4517": "TURN servers saved",
"d235995f96": "STUN Server",
"fc1bd2c935": "Public STUN server for NAT traversal",
"289929755b": "Restore Default",
"0268827609": "TURN Servers",
"b3c14a0273": "Used as relay when direct peer-to-peer connection fails",
"bbc48fb751": "No TURN servers configured",
"f6039d44b2": "Username",
"03bc142e64": "Credential",
"ca3e8baee9": "Add TURN Server",
"0acde1b3e3": "Save TURN Servers",
"867cee98fd": "Passwords do not match",
"9864ff9420": "Please enter your old password",
"14a714ab22": "Please enter a new password",
@@ -290,24 +371,44 @@
"3369c97f56": "Enter your SSH public key",
"81bafb2833": "The default SSH user is ",
"7a941a0f87": "Update SSH Key",
"d876ff8da6": "API Key",
"96164f17cf": "API key for MCP and REST API authentication",
"538ce1893b": "Enter API key or leave empty to auto-generate",
"a55c6d580b": "Used for authenticating MCP and REST API requests.",
"e85c388332": "Save API Key",
"927c25953b": "Generate New",
"fdad65b7be": "Force HTTP Transmission",
"e033b5e2a3": "Force using HTTP for video streaming instead of WebRTC",
"bda22ca687": "USB detection enhancement",
"f7df928057": "The DISC state is also checked during USB status retrieval",
"021fa314ef": "USB Emulation",
"5c43d74dbd": "Control the USB emulation state",
"f6c8ddbadf": "Disable USB Emulation",
"020b92cfbb": "Enable USB Emulation",
"9f55f64b0f": "USB Gadget Reinitialize",
"40dc677a89": "Reinitialize USB gadget configuration",
"02d2f33ec9": "Reinitialize USB Gadget",
"f5ddf02991": "Reboot System",
"1dbbf194af": "Restart the device system",
"1de72c4fc6": "Reboot",
"c887e5a479": "Edit Configuration",
"90b21a0536": "Edit the raw configuration file directly",
"f43c0398a4": "Reset Configuration",
"0031dbef48": "Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.",
"0d784092e8": "Reset Config",
"115082e888": "Clear API Key?",
"78fcaed30d": "Setting the API key to empty will auto-generate a new random key.",
"81e8b4cd6b": "Make sure to update your clients with the new key after saving.",
"211730be68": "Generate New Key",
"a776e925bf": "Reboot System?",
"1f070051ff": "Are you sure you want to reboot the system?",
"f1a79f466e": "The device will restart and you will be disconnected from the web interface.",
"919ff9ff77": "Edit the raw configuration JSON. Be careful when making changes as invalid JSON can cause system issues.",
"4e11db406c": "Saving",
"f24a0236a3": "Configuration Saved",
"b032420c4b": "Configuration has been saved successfully. Some changes may require a system restart to take effect.",
"c8850fa947": "Would you like to restart the system now?",
"61057a0c84": "Later",
"2d9c2140c5": "Restart Now",
"0db377921f": "General",
"3845ee1693": "Configure device settings and update preferences",
"d721757161": "Theme",
@@ -354,6 +455,12 @@
"ef64a3770e": "Control mDNS (multicast DNS) operational mode",
"be8226fe0c": "Time synchronization",
"7e06bd28a6": "Configure time synchronization settings",
"a2323452ba": "HTTP Proxy",
"7fedd1ea53": "Configure program HTTP proxy (optional)",
"a30d487cba": "HTTPS Proxy",
"3f4c7e23cb": "Configure program HTTPS proxy (optional)",
"48d941841f": "ALL Proxy",
"3a2cd1e4a7": "Configure program ALL proxy (optional)",
"d4dccb8ca2": "Save settings",
"8750a898cb": "IPv4 Mode",
"72c2543791": "Configure IPv4 mode",
@@ -372,6 +479,8 @@
"a30194c638": "This will save the requested IPv4 address. Changes take effect after a restart.",
"cc172c234b": "Change IPv4 Mode?",
"a344a29861": "IPv4 mode changes will take effect after a restart.",
"0ddd0ae8fd": "Change MAC Address?",
"d0158ce3c1": "Changing the MAC address may cause the device IP to be reassigned and changed.",
"34b6cd7517": "Version",
"a1937e8ac1": "Check the versions of the system and applications",
"04a115bdd8": "AppVersion",
@@ -393,19 +502,40 @@
"5b404b3c98": "Update in Background",
"3723a3f846": "System is up to date",
"a6b8796d51": "Your system is running the latest version. No updates are currently available.",
"a8d2a89696": "Missing Signature File",
"480f05b41b": "The current firmware is missing signature files. Integrity cannot be fully verified.",
"323ebc9620": "Signature Verification Failed",
"21355c9ecb": "The signature file exists but does not match the firmware. This may indicate tampering.",
"0b54a6c322": "No Embedded Public Key",
"9beb932d5a": "This build does not have an OTA public key embedded. Signature verification is unavailable.",
"7c81553358": "Check Again",
"3b5f9a1f01": "Update Signatures",
"d25bfbb978": "Update the signature of kvm_app to the latest version. If the current version is not up to date, signature verification will fail.",
"1bdd158a19": "Signature Update Result",
"217b416896": "Update available",
"f00af8c98f": "A new update is available to enhance system performance and improve compatibility. We recommend updating to ensure everything runs smoothly.",
"8977c3f0b7": "Download Proxy Prefix",
"99b1054c0f": "Update Now",
"7f3cd4480d": "Do it later",
"cffae9918d": "Update Error",
"49cba7cadf": "An error occurred while updating your device. Please try again later.",
"d849d5b330": "Error details:",
"2b2f7a6d7c": "Use Ctrl+V to paste clipboard to remote",
"fcfc8da1a3": "Verifying signature...",
"4e16083ef8": "Please wait while verifying firmware signature.",
"5367acff78": "Signature Status Unavailable",
"634aac26af": "Unable to retrieve signature verification status.",
"77de342f39": "Signature Verified",
"20ac4a17cc": "Firmware signature has been verified and is valid.",
"36a41c937c": "No video signal",
"d34da01de2": "Enable paste shortcut",
"2b1a1676d1": "Copy text from your client to the remote host",
"604c45fbf2": "The following characters will not be pasted:",
"a7eb9efa0b": "Sending text using keyboard layout:",
"2593d0a3d5": "Confirm paste",
"ac7e2a9783": "Enable OCR shortcut",
"f529c51ee6": "OCR",
"607440855c": "Open OCR selection mode on the video area",
"c3bf447eab": "Open",
"a0e3947a02": "Macros",
"276043dbf0": "Create a new keyboard macro",
"9605ef9593": "Modify your keyboard macro",
@@ -413,7 +543,6 @@
"6b2f64058e": "Are you sure you want to delete this macro? This action cannot be undone.",
"ffbb410a55": "Deleting",
"3dd2e50646": "Delay Only",
"7dce122004": "Edit",
"5fb63579fc": "Copy",
"fc2839fcdf": "+ Add new macro",
"05fd7d5b9c": "Are you sure you want to delete",
@@ -436,6 +565,8 @@
"21d104a54f": "Processing...",
"9844086d90": "No SD Card Detected",
"b56e598918": "SD Card Mount Failed",
"d646589704": "Choose the file system for MicroSD formatting",
"16eb8ed6c8": "Format MicroSD Card",
"a63d5e0260": "Manage Shared Folders in KVM MicroSD Card",
"1d50425f88": "Manage Shared Folders in KVM Storage",
"74ff4bad28": "No files found",
@@ -446,8 +577,15 @@
"8bf8854beb": "of",
"53e61336bb": "results",
"d1f5a81904": "Unmount MicroSD Card",
"5874cf46ff": "SD card formatted successfully",
"7dc25305d4": "Encodec Type",
"41d263a988": "Stream Quality",
"d8d7a5377b": "RC Control",
"a09ff5b350": "Adjust rate control QP settings for better balance between quality and bitrate",
"b1f9d95e72": "StepQp",
"bf1af7559b": "MinQp",
"6e4e284fcc": "MinIQp",
"df1b8f72f2": "DetlpQp",
"41f5283941": "NPU Application",
"466990072d": "Enable NPU to Object Detection",
"a6c2e30b8e": "Video Enhancement",
@@ -464,6 +602,8 @@
"85a003df9b": "EDID File",
"5c7a666766": "Set Custom EDID",
"7dad0ba758": "Restore to default",
"7b2fb72a68": "RC Advanced Config",
"827048afc2": "Formatting the SD card will erase all data. Continue?",
"556c7553f1": "KVM MicroSD Card Mount",
"b99cf1ecb7": "Manage and mount images from MicroSD card",
"0a01e80566": "Mounted from KVM storage",

View File

@@ -14,10 +14,11 @@
"9aab28af69": "数据包延迟的变化,影响视频流畅度。",
"75316ccce3": "帧率",
"75a4e60fe7": "每秒显示的视频帧数。",
"867f8b3151": "只支持 {{types}} 文件",
"13c27e178b": "请选择 {{fileName}} 继续上传",
"8282fb0974": "重新上传",
"411f67b4c6": "点击这里选择 {{fileName}} 重新上传",
"5d2e347cdb": "点击这里上传新镜像",
"6e8daad8ab": "点击这里上传",
"ddeb0eac69": "不支持目录",
"3f1c5b0049": "上传中",
"f287042190": "正在上传...",
@@ -103,6 +104,14 @@
"f9099fc033": "重订 DHCP 租约",
"6e188f5984": "IPv6 信息",
"cfdffa4fc7": "本地链路",
"a588b1abc5": "已复制",
"c5505be6c3": "未检测到文本",
"a29aaf6603": "OCR 失败",
"dc67ba5f40": "拖动选择文本区域",
"b603b043e8": "复制文本",
"f5f78c501b": "正在识别文本...",
"5df17e67f5": "复制前请检查识别出的文本。",
"4dfb4a614f": "请等待 OCR 运行完成。",
"d6e24388b4": "继续上传",
"62a5e49088": "隐藏",
"b34e855501": "USB拓展功能",
@@ -172,6 +181,24 @@
"c40a72ad93": "KVM 终端",
"dd110018d2": "串口控制台",
"7b277018e4": "未连接",
"02d2f33ec9": "重新初始化 USB 设备",
"c8e875e1c9": "版本管理器",
"63a6a88c06": "刷新",
"d15f6e3312": "隐藏安装操作",
"2b9b84a087": "显示安装操作",
"81dfff9d6f": "安装状态",
"98dd43dfae": "已安装",
"ddd8eef6f8": "未安装",
"e2dc83997b": "检测到的版本",
"e3cc7e7df9": "系统架构",
"bc56777902": "发布版本",
"c319e3982f": "发布资源",
"06933067aa": "更新",
"a27dfe7717": "卸载",
"349838fb1d": "安装",
"4a0764788d": "安装任务",
"46b5f8c58b": "进度",
"5e4caae72b": "已安装版本",
"bf733d8a93": "访问",
"70e23e7d6f": "管理设备的访问控制",
"509820290d": "本地",
@@ -194,6 +221,8 @@
"efd3cc702e": "启用密码",
"8f1e77e0d2": "更改密码",
"3d0de21428": "更新设备访问密码",
"d6d56d5972": "WebRTC 服务器",
"8bea04c48e": "用于点对点连接的 STUN 和 TURN 服务器",
"f8508f576c": "远程",
"a8bb6f5f9f": "管理远程访问设备的方式",
"0b86461350": "TailScale 使用 xEdge 服务器",
@@ -250,6 +279,58 @@
"c420b0d8f0": "输入 Cloudflare 通道令牌",
"1bbabcdef3": "编辑 frpc.toml",
"9c088a303a": "输入 frpc 配置",
"87ed72fc20": "获取防火墙配置失败",
"a25ea1e1ee": "应用防火墙配置失败",
"bcbc6c71a1": "防火墙配置已应用",
"f4b0e93291": "请选择协议",
"7687f1154f": "缺少必填字段",
"a133cae395": "无效端口",
"d10b3069c2": "对于 PREROUTING目标 IP 必须是真实主机 IP",
"0834bb6c87": "目标 IP 是必填项",
"c5381dc540": "防火墙",
"8256c0c407": "管理设备的防火墙规则",
"972e73b7a8": "基础",
"7218073b8c": "通讯规则",
"23fed496dd": "端口转发",
"324118a672": "输入",
"29c2c02a36": "输出",
"67d2f6740a": "转发",
"a4d3b161ce": "提交",
"ec211f7c20": "添加",
"5320550175": "链",
"f31bbdd1b3": "源",
"888a77f5ac": "协议",
"12007e1d59": "目标",
"004bf6c9a4": "操作",
"b5a7adde1a": "描述",
"2a78ed7645": "操作",
"095c375025": "没有可用规则",
"ed36a1ef76": "任何",
"7dce122004": "编辑",
"efba20a02e": "没有可用数据",
"e0aa021e21": "确定",
"7d367dab8b": "源 IP",
"c050956ed2": "源端口",
"94386968c2": "目标 IP",
"64ae5dd047": "目标端口",
"fbb3878eb7": "提交防火墙策略?",
"1c17a7e15b": "警告:调整某些策略可能会导致网络地址丢失,导致设备不可用。",
"6f20995c95": "加载 WebRTC 服务器失败",
"aee9784c03": "未知错误",
"6ef7ce5b80": "保存 STUN 失败",
"4cd48aed7f": "STUN 服务器已保存",
"4b5d050e51": "保存 TURN 失败",
"f6f10b4517": "TURN 服务器已保存",
"d235995f96": "STUN 服务器",
"fc1bd2c935": "用于 NAT 穿透的公共 STUN 服务器",
"289929755b": "恢复默认",
"0268827609": "TURN 服务器",
"b3c14a0273": "当直接点对点连接失败时用作中继",
"bbc48fb751": "未配置 TURN 服务器",
"f6039d44b2": "用户名",
"03bc142e64": "凭据",
"ca3e8baee9": "添加 TURN 服务器",
"0acde1b3e3": "保存 TURN 服务器",
"867cee98fd": "密码不一致",
"9864ff9420": "请输入旧密码",
"14a714ab22": "请输入新密码",
@@ -290,24 +371,44 @@
"3369c97f56": "输入您的 SSH 公钥",
"81bafb2833": "默认 SSH 用户是 ",
"7a941a0f87": "更新 SSH 密钥",
"d876ff8da6": "API 密钥",
"96164f17cf": "用于 MCP 和 REST API 认证的 API 密钥",
"538ce1893b": "输入 API 密钥或留空以自动生成",
"a55c6d580b": "用于认证 MCP 和 REST API 请求。",
"e85c388332": "保存 API 密钥",
"927c25953b": "生成新密钥",
"fdad65b7be": "强制 HTTP 传输",
"e033b5e2a3": "强制使用 HTTP 传输数据替代 WebRTC",
"bda22ca687": "USB 检测增强",
"f7df928057": "在获取 USB 状态时也会检查 DISC 状态",
"021fa314ef": "USB 复用",
"5c43d74dbd": "控制 USB 复用状态",
"f6c8ddbadf": "禁用 USB 复用",
"020b92cfbb": "启用 USB 复用",
"9f55f64b0f": "USB 设备重新初始化",
"40dc677a89": "重新初始化 USB 设备配置",
"02d2f33ec9": "重新初始化 USB 设备",
"f5ddf02991": "重启系统",
"1dbbf194af": "重启设备系统",
"1de72c4fc6": "重启",
"c887e5a479": "编辑配置",
"90b21a0536": "编辑原始配置文件",
"f43c0398a4": "重置配置",
"0031dbef48": "重置配置,这将使你退出登录。部分配置重启后生效。",
"0d784092e8": "重置配置",
"115082e888": "清除 API 密钥?",
"78fcaed30d": "将 API 密钥留空将自动生成一个新的随机密钥。",
"81e8b4cd6b": "请确保在保存后使用新密钥更新您的客户端。",
"211730be68": "生成新密钥",
"a776e925bf": "重启系统?",
"1f070051ff": "你确定重启系统吗?",
"f1a79f466e": "设备将重启,你将从 Web 界面断开连接。",
"919ff9ff77": "编辑原始配置 JSON。小心修改无效 JSON 可能导致系统问题。",
"4e11db406c": "保存中",
"f24a0236a3": "配置已保存",
"b032420c4b": "配置已成功保存。某些更改可能需要系统重启才能生效。",
"c8850fa947": "是否现在重启系统?",
"61057a0c84": "稍后",
"2d9c2140c5": "立刻重启",
"0db377921f": "常规",
"3845ee1693": "查看设备的版本信息",
"d721757161": "主题",
@@ -354,6 +455,12 @@
"ef64a3770e": "控制 mDNS多播 DNS运行模式",
"be8226fe0c": "时间同步",
"7e06bd28a6": "配置时间同步设置",
"a2323452ba": "HTTP 代理",
"7fedd1ea53": "配置程序 HTTP 代理(可选)",
"a30d487cba": "HTTPS 代理",
"3f4c7e23cb": "配置程序 HTTPS 代理(可选)",
"48d941841f": "ALL 代理",
"3a2cd1e4a7": "配置程序 ALL 代理(可选)",
"d4dccb8ca2": "保存设置",
"8750a898cb": "IPv4 模式",
"72c2543791": "配置 IPv4 模式",
@@ -372,6 +479,8 @@
"a30194c638": "这将保存请求的 IPv4 地址。更改将在重启后生效。",
"cc172c234b": "更改 IPv4 模式?",
"a344a29861": "IPv4 模式更改将在重启后生效。",
"0ddd0ae8fd": "更改 MAC 地址?",
"d0158ce3c1": "更改 MAC 地址可能会导致设备 IP 重新分配和更改。",
"34b6cd7517": "版本",
"a1937e8ac1": "检查系统和应用程序的版本",
"04a115bdd8": "应用版本",
@@ -393,19 +502,40 @@
"5b404b3c98": "后台更新",
"3723a3f846": "系统已更新到最新版本",
"a6b8796d51": "您的系统已运行最新版本。当前没有可用的更新。",
"a8d2a89696": "签名文件缺失",
"480f05b41b": "当前固件缺少签名文件,无法完全验证完整性。",
"323ebc9620": "签名验证失败",
"21355c9ecb": "签名文件存在但与固件不匹配,可能存在篡改。",
"0b54a6c322": "未嵌入公钥",
"9beb932d5a": "此版本未嵌入 OTA 公钥,无法进行签名验证。",
"7c81553358": "再次检查",
"3b5f9a1f01": "更新签名",
"d25bfbb978": "更新 kvm_app 的签名到最新版本。如果当前版本不是最新版本,签名验证将会失败。",
"1bdd158a19": "签名更新结果",
"217b416896": "更新可用",
"f00af8c98f": "新的更新可用,以增强系统性能和提高兼容性。我们建议更新以确保一切正常运行。",
"8977c3f0b7": "下载代理加速前缀",
"99b1054c0f": "立即更新",
"7f3cd4480d": "稍后更新",
"cffae9918d": "更新错误",
"49cba7cadf": "更新设备时发生错误。请稍后重试。",
"d849d5b330": "错误详情:",
"2b2f7a6d7c": "按下 Ctrl+V 直接将本地剪贴板内容发送到远端主机",
"fcfc8da1a3": "正在验证签名...",
"4e16083ef8": "请等待固件签名验证完成。",
"5367acff78": "签名状态不可用",
"634aac26af": "无法获取签名验证状态。",
"77de342f39": "签名已验证",
"20ac4a17cc": "固件签名已验证且有效。",
"36a41c937c": "无视频信号",
"d34da01de2": "启用粘贴快捷键",
"2b1a1676d1": "将文本从您的客户端复制到远程主机",
"604c45fbf2": "以下字符将不会被粘贴:",
"a7eb9efa0b": "使用键盘布局发送文本:",
"2593d0a3d5": "确认粘贴",
"ac7e2a9783": "启用 OCR 快捷键",
"f529c51ee6": "OCR",
"607440855c": "在视频区域打开 OCR 选择模式",
"c3bf447eab": "打开",
"a0e3947a02": "宏",
"276043dbf0": "创建新的键盘宏",
"9605ef9593": "修改键盘宏",
@@ -413,7 +543,6 @@
"6b2f64058e": "确定要删除此宏吗?此操作无法撤销。",
"ffbb410a55": "删除中",
"3dd2e50646": "仅延迟",
"7dce122004": "编辑",
"5fb63579fc": "复制",
"fc2839fcdf": "+ 添加新宏",
"05fd7d5b9c": "您确定要删除",
@@ -436,6 +565,8 @@
"21d104a54f": "处理中...",
"9844086d90": "未检测到 SD 卡",
"b56e598918": "SD 卡挂载失败",
"d646589704": "选择 MicroSD 格式化的文件系统",
"16eb8ed6c8": "格式化 MicroSD 卡",
"a63d5e0260": "管理 KVM MicroSD Card 的共享文件夹",
"1d50425f88": "管理 KVM 存储中的共享文件夹",
"74ff4bad28": "未找到文件",
@@ -446,8 +577,15 @@
"8bf8854beb": "共",
"53e61336bb": "条结果",
"d1f5a81904": "卸载 MicroSD 卡",
"5874cf46ff": "SD 卡格式化成功",
"7dc25305d4": "视频编码类型",
"41d263a988": "视频质量",
"d8d7a5377b": "RC 控制",
"a09ff5b350": "调整码率控制 QP 参数,在质量和码率之间取得更好的平衡",
"b1f9d95e72": "StepQp",
"bf1af7559b": "MinQp",
"6e4e284fcc": "MinIQp",
"df1b8f72f2": "DetlpQp",
"41f5283941": "NPU 应用",
"466990072d": "启用 NPU 以进行物体检测",
"a6c2e30b8e": "视频增强",
@@ -464,6 +602,8 @@
"85a003df9b": "EDID 文件",
"5c7a666766": "设置自定义 EDID",
"7dad0ba758": "恢复默认",
"7b2fb72a68": "RC 高级配置",
"827048afc2": "格式化 SD 卡会清除所有数据。是否继续?",
"556c7553f1": "从 KVM MicroSD 卡挂载",
"b99cf1ecb7": "从 MicroSD 卡中管理和挂载镜像",
"0a01e80566": "已从 KVM 存储挂载",

118
ui/src/utils/shortcuts.ts Normal file
View File

@@ -0,0 +1,118 @@
const MODIFIER_KEYS = new Set([
"Control",
"Shift",
"Alt",
"Meta",
]);
const SPECIAL_CODE_TO_KEY: Record<string, string> = {
Space: "Space",
Enter: "Enter",
Escape: "Esc",
Tab: "Tab",
Backspace: "Backspace",
Delete: "Delete",
ArrowUp: "Up",
ArrowDown: "Down",
ArrowLeft: "Left",
ArrowRight: "Right",
};
type ShortcutSpec = {
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
key: string;
};
function normalizeShortcutToken(token: string) {
return token.trim().toLowerCase();
}
function normalizeKeyName(key: string) {
const trimmed = key.trim();
if (trimmed.length === 1) return trimmed.toUpperCase();
const lower = trimmed.toLowerCase();
if (lower === "escape") return "Esc";
if (lower === " ") return "Space";
if (lower === "arrowup") return "Up";
if (lower === "arrowdown") return "Down";
if (lower === "arrowleft") return "Left";
if (lower === "arrowright") return "Right";
return trimmed;
}
function keyFromEvent(e: KeyboardEvent) {
const { code, key } = e;
if (code.startsWith("Key")) return code.slice(3).toUpperCase();
if (code.startsWith("Digit")) return code.slice(5);
if (SPECIAL_CODE_TO_KEY[code]) return SPECIAL_CODE_TO_KEY[code];
return normalizeKeyName(key);
}
function parseShortcut(shortcut: string): ShortcutSpec | null {
if (!shortcut) return null;
const tokens = shortcut
.split("+")
.map(token => token.trim())
.filter(Boolean);
if (tokens.length === 0) return null;
const spec: ShortcutSpec = {
ctrl: false,
shift: false,
alt: false,
meta: false,
key: "",
};
for (const token of tokens) {
const normalized = normalizeShortcutToken(token);
if (normalized === "ctrl" || normalized === "control") {
spec.ctrl = true;
continue;
}
if (normalized === "shift") {
spec.shift = true;
continue;
}
if (normalized === "alt" || normalized === "option") {
spec.alt = true;
continue;
}
if (normalized === "meta" || normalized === "cmd" || normalized === "command") {
spec.meta = true;
continue;
}
spec.key = normalizeKeyName(token);
}
if (!spec.key) return null;
return spec;
}
export function eventMatchesShortcut(e: KeyboardEvent, shortcut: string) {
const spec = parseShortcut(shortcut);
if (!spec) return false;
const eventKey = keyFromEvent(e);
return (
e.ctrlKey === spec.ctrl
&& e.shiftKey === spec.shift
&& e.altKey === spec.alt
&& e.metaKey === spec.meta
&& eventKey === spec.key
);
}
export function shortcutFromKeyboardEvent(e: KeyboardEvent) {
if (MODIFIER_KEYS.has(e.key)) return null;
const key = keyFromEvent(e);
const modifiers: string[] = [];
if (e.ctrlKey) modifiers.push("Ctrl");
if (e.shiftKey) modifiers.push("Shift");
if (e.altKey) modifiers.push("Alt");
if (e.metaKey) modifiers.push("Meta");
if (modifiers.length === 0) return null;
return [...modifiers, key].join("+");
}

14
usb.go
View File

@@ -37,7 +37,7 @@ func initUsbGadget() {
go func() {
for {
checkUSBState()
time.Sleep(500 * time.Millisecond)
time.Sleep(2500 * time.Millisecond)
}
}()
@@ -111,6 +111,14 @@ func rpcKeyboardReport(modifier uint8, keys []uint8) error {
return gadget.KeyboardReport(modifier, keys)
}
func rpcKeypressReport(key uint8, press bool) error {
return gadget.KeypressReport(key, press)
}
func rpcKeypressKeepAlive() error {
return gadget.KeypressKeepAlive()
}
func rpcAbsMouseReport(x, y int, buttons uint8) error {
return gadget.AbsMouseReport(x, y, buttons)
}
@@ -130,7 +138,7 @@ func rpcGetKeyboardLedState() (state usbgadget.KeyboardState) {
var usbState = "unknown"
func rpcGetUSBState() (state string) {
return gadget.GetUsbState()
return gadget.GetUsbState(config.UsbEnhancedDetection)
}
func triggerUSBStateUpdate() {
@@ -144,7 +152,7 @@ func triggerUSBStateUpdate() {
}
func checkUSBState() {
newState := gadget.GetUsbState()
newState := gadget.GetUsbState(config.UsbEnhancedDetection)
if newState == usbState {
return
}

View File

@@ -1,6 +1,7 @@
package kvm
import (
"bytes"
"encoding/json"
"errors"
"fmt"
@@ -781,6 +782,117 @@ func rpcUnmountSDStorage() error {
return nil
}
func rpcFormatSDStorage(confirm bool, fsType string) error {
validFsTypes := map[string]bool{"exfat": true, "fat32": true}
if !validFsTypes[fsType] {
fsType = "fat32"
}
if !confirm {
return fmt.Errorf("format not confirmed")
}
if _, err := os.Stat("/dev/mmcblk1"); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("sd device not found: /dev/mmcblk1")
}
return fmt.Errorf("failed to stat sd device: %w", err)
}
if err := updateMtp(false); err != nil {
logger.Error().Err(err).Msg("failed to update mtp before formatting sd")
}
if out, err := exec.Command("mount").Output(); err == nil {
if strings.Contains(string(out), " on /mnt/sdcard") {
if umOut, umErr := exec.Command("umount", "/mnt/sdcard").CombinedOutput(); umErr != nil {
return fmt.Errorf("failed to unmount sdcard: %w: %s", umErr, strings.TrimSpace(string(umOut)))
}
}
}
if err := os.MkdirAll(SDImagesFolder, 0755); err != nil {
return fmt.Errorf("failed to ensure mount point: %w", err)
}
if _, err := os.Stat("/dev/mmcblk1p1"); os.IsNotExist(err) {
var lastErr error
if _, err := exec.LookPath("sfdisk"); err == nil {
sfdiskInput := "label: dos\nunit: sectors\n\n2048,,c,*\n"
cmd := exec.Command("sfdisk", "/dev/mmcblk1")
cmd.Stdin = bytes.NewBufferString(sfdiskInput)
partOut, partErr := cmd.CombinedOutput()
if partErr != nil {
lastErr = fmt.Errorf("sfdisk failed: %w: %s", partErr, strings.TrimSpace(string(partOut)))
} else {
lastErr = nil
}
} else if _, err := exec.LookPath("fdisk"); err == nil {
fdiskScript := "o\nn\np\n1\n2048\n\nt\n1\nc\na\n1\nw\n"
cmd := exec.Command("fdisk", "/dev/mmcblk1")
cmd.Stdin = bytes.NewBufferString(fdiskScript)
partOut, partErr := cmd.CombinedOutput()
if partErr != nil {
lastErr = fmt.Errorf("fdisk failed: %w: %s", partErr, strings.TrimSpace(string(partOut)))
} else {
lastErr = nil
}
} else if _, err := exec.LookPath("parted"); err == nil {
partedOut, partedErr := exec.Command("parted", "-s", "/dev/mmcblk1", "mklabel", "msdos", "mkpart", "primary", "fat32", "1MiB", "100%").CombinedOutput()
if partedErr != nil {
lastErr = fmt.Errorf("parted failed: %w: %s", partedErr, strings.TrimSpace(string(partedOut)))
} else {
lastErr = nil
}
} else {
return fmt.Errorf("no partitioning tool found (need sfdisk, fdisk, or parted)")
}
if lastErr != nil {
return fmt.Errorf("failed to create sd partition: %w", lastErr)
}
if _, err := exec.LookPath("partprobe"); err == nil {
if _, err := exec.Command("partprobe", "/dev/mmcblk1").CombinedOutput(); err != nil {
time.Sleep(800 * time.Millisecond)
} else {
time.Sleep(300 * time.Millisecond)
}
} else {
time.Sleep(800 * time.Millisecond)
}
}
if _, err := os.Stat("/dev/mmcblk1p1"); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("sd partition not found after partitioning: /dev/mmcblk1p1")
}
return fmt.Errorf("failed to stat sd partition: %w", err)
}
var mkfsCmd *exec.Cmd
if fsType == "exfat" {
mkfsCmd = exec.Command("mkfs.exfat", "-n", "PICOKVM", "/dev/mmcblk1p1")
} else {
mkfsCmd = exec.Command("mkfs.vfat", "-F", "32", "-n", "PICOKVM", "/dev/mmcblk1p1")
}
mkfsOut, mkfsErr := mkfsCmd.CombinedOutput()
if mkfsErr != nil {
return fmt.Errorf("failed to format sdcard: %w: %s", mkfsErr, strings.TrimSpace(string(mkfsOut)))
}
mountOut, mountErr := exec.Command("mount", "/dev/mmcblk1p1", SDImagesFolder).CombinedOutput()
if mountErr != nil {
return fmt.Errorf("failed to mount sdcard after format: %w: %s", mountErr, strings.TrimSpace(string(mountOut)))
}
SyncConfigSD(false)
if err := updateMtp(true); err != nil {
return fmt.Errorf("failed to update mtp after formatting sd: %w", err)
}
return nil
}
func rpcListSDStorageFiles() (*StorageFiles, error) {
files, err := os.ReadDir(SDImagesFolder)
if err != nil {
@@ -842,6 +954,7 @@ const (
type SDMountStatusResponse struct {
Status SDMountStatus `json:"status"`
Reason string `json:"reason,omitempty"`
}
func rpcGetSDMountStatus() (*SDMountStatusResponse, error) {
@@ -849,9 +962,13 @@ func rpcGetSDMountStatus() (*SDMountStatusResponse, error) {
return &SDMountStatusResponse{Status: SDMountNone}, nil
}
if _, err := os.Stat("/dev/mmcblk1p1"); os.IsNotExist(err) {
return &SDMountStatusResponse{Status: SDMountFail, Reason: "no_partition"}, nil
}
output, err := exec.Command("mount").Output()
if err != nil {
return &SDMountStatusResponse{Status: SDMountFail}, fmt.Errorf("failed to check mount status: %v", err)
return &SDMountStatusResponse{Status: SDMountFail, Reason: "check_mount_failed"}, fmt.Errorf("failed to check mount status: %v", err)
}
if strings.Contains(string(output), "/dev/mmcblk1p1 on /mnt/sdcard") {
@@ -860,19 +977,19 @@ func rpcGetSDMountStatus() (*SDMountStatusResponse, error) {
err = exec.Command("mount", "/dev/mmcblk1p1", "/mnt/sdcard").Run()
if err != nil {
return &SDMountStatusResponse{Status: SDMountFail}, fmt.Errorf("failed to mount SD card: %v", err)
return &SDMountStatusResponse{Status: SDMountFail, Reason: "mount_failed"}, fmt.Errorf("failed to mount SD card: %v", err)
}
output, err = exec.Command("mount").Output()
if err != nil {
return &SDMountStatusResponse{Status: SDMountFail}, fmt.Errorf("failed to check mount status after mounting: %v", err)
return &SDMountStatusResponse{Status: SDMountFail, Reason: "check_mount_after_failed"}, fmt.Errorf("failed to check mount status after mounting: %v", err)
}
if strings.Contains(string(output), "/dev/mmcblk1p1 on /mnt/sdcard") {
return &SDMountStatusResponse{Status: SDMountOK}, nil
}
return &SDMountStatusResponse{Status: SDMountFail}, nil
return &SDMountStatusResponse{Status: SDMountFail, Reason: "mount_unknown"}, nil
}
type SDStorageFileUpload struct {
@@ -984,21 +1101,13 @@ usb_max_packet_size 0x200
return os.WriteFile(umtprdConfPath, []byte(conf), 0644)
}
func updateMtpWithSDStatus() error {
resp, _ := rpcGetSDMountStatus()
if resp.Status == SDMountOK {
if err := writeUmtprdConfFile(true); err != nil {
logger.Error().Err(err).Msg("failed to write umtprd conf file")
}
} else {
if err := writeUmtprdConfFile(false); err != nil {
logger.Error().Err(err).Msg("failed to write umtprd conf file")
}
func updateMtp(withSD bool) error {
if err := writeUmtprdConfFile(withSD); err != nil {
logger.Error().Err(err).Msg("failed to write umtprd conf file")
}
if config.UsbDevices.Mtp {
if err := gadget.UnbindUDCToDWC3(); err != nil {
logger.Error().Err(err).Msg("failed to unbind UDC")
if err := gadget.UnbindUDC(); err != nil {
logger.Error().Err(err).Msg("failed to unbind gadget from UDC")
}
if out, err := exec.Command("pgrep", "-x", "umtprd").Output(); err == nil && len(out) > 0 {
@@ -1013,10 +1122,29 @@ func updateMtpWithSDStatus() error {
return fmt.Errorf("failed to exec binary: %w", err)
}
if err := rpcSetUsbDevices(*config.UsbDevices); err != nil {
return fmt.Errorf("failed to set usb devices: %w", err)
var lastErr error
for attempt := 0; attempt < 6; attempt++ {
if err := rpcSetUsbDevices(*config.UsbDevices); err == nil {
lastErr = nil
break
} else {
lastErr = err
logger.Warn().
Int("attempt", attempt+1).
Err(err).
Msg("failed to re-apply usb devices after mtp update, retrying")
time.Sleep(time.Duration(300*(attempt+1)) * time.Millisecond)
}
}
if lastErr != nil {
return fmt.Errorf("failed to set usb devices after mtp update: %w", lastErr)
}
}
return nil
}
func updateMtpWithSDStatus() error {
resp, _ := rpcGetSDMountStatus()
return updateMtp(resp.Status == SDMountOK)
}

View File

@@ -24,7 +24,7 @@ type VideoInputState struct {
Error string `json:"error,omitempty"` //no_signal, no_lock, out_of_range
Width int `json:"width"`
Height int `json:"height"`
FramePerSecond float64 `json:"fps"`
FramePerSecond float64 `json:"frame_per_second"`
}
var lastVideoState VideoInputState

Some files were not shown because too many files have changed in this diff Show More