Compare commits

1 Commits

Author SHA1 Message Date
luckfox-eng29
6f426e8999 Update App version to 0.1.2
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
2026-03-22 23:06:10 +08:00
92 changed files with 967 additions and 8832 deletions

1
.gitignore vendored
View File

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

View File

@@ -2,19 +2,12 @@ BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
BUILDDATE ?= $(shell date -u +%FT%T%z) BUILDDATE ?= $(shell date -u +%FT%T%z)
BUILDTS ?= $(shell date -u +%s) BUILDTS ?= $(shell date -u +%s)
REVISION ?= $(shell git rev-parse HEAD) REVISION ?= $(shell git rev-parse HEAD)
VERSION_DEV ?= 0.1.3-dev VERSION_DEV ?= 0.1.2-dev
VERSION ?= 0.1.3 VERSION ?= 0.1.2
PROMETHEUS_TAG := github.com/prometheus/common/version PROMETHEUS_TAG := github.com/prometheus/common/version
KVM_PKG_NAME := kvm 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_BUILD_ARGS := -tags netgo
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS) GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
GO_LDFLAGS := \ GO_LDFLAGS := \
@@ -22,8 +15,7 @@ GO_LDFLAGS := \
-X $(PROMETHEUS_TAG).Branch=$(BRANCH) \ -X $(PROMETHEUS_TAG).Branch=$(BRANCH) \
-X $(PROMETHEUS_TAG).BuildDate=$(BUILDDATE) \ -X $(PROMETHEUS_TAG).BuildDate=$(BUILDDATE) \
-X $(PROMETHEUS_TAG).Revision=$(REVISION) \ -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 GO_CMD := GOOS=linux GOARCH=arm GOARM=7 go
BIN_DIR := $(shell pwd)/bin BIN_DIR := $(shell pwd)/bin
@@ -36,12 +28,6 @@ build_dev:
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \ -ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
$(GO_RELEASE_BUILD_ARGS) \ $(GO_RELEASE_BUILD_ARGS) \
-o $(BIN_DIR)/kvm_app cmd/main.go -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: frontend:
cd ui && npm ci && npm run build:device cd ui && npm ci && npm run build:device
@@ -52,13 +38,3 @@ build_release: frontend
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" \ -ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" \
$(GO_RELEASE_BUILD_ARGS) \ $(GO_RELEASE_BUILD_ARGS) \
-o bin/kvm_app cmd/main.go -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
View File

@@ -1,389 +0,0 @@
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,
})
}

552
cli.go
View File

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

132
config.go
View File

@@ -2,9 +2,6 @@ package kvm
import ( import (
"bufio" "bufio"
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -24,12 +21,6 @@ type WakeOnLanDevice struct {
MacAddress string `json:"macAddress"` MacAddress string `json:"macAddress"`
} }
type TurnServer struct {
URL string `json:"url"`
Username string `json:"username"`
Credential string `json:"credential"`
}
// Constants for keyboard macro limits // Constants for keyboard macro limits
const ( const (
MaxMacrosPerDevice = 25 MaxMacrosPerDevice = 25
@@ -90,7 +81,6 @@ func (m *KeyboardMacro) Validate() error {
type Config struct { type Config struct {
STUN string `json:"stun"` STUN string `json:"stun"`
TurnServers []TurnServer `json:"turn_servers"`
JigglerEnabled bool `json:"jiggler_enabled"` JigglerEnabled bool `json:"jiggler_enabled"`
AutoUpdateEnabled bool `json:"auto_update_enabled"` AutoUpdateEnabled bool `json:"auto_update_enabled"`
IncludePreRelease bool `json:"include_pre_release"` IncludePreRelease bool `json:"include_pre_release"`
@@ -139,7 +129,6 @@ type Config struct {
WireguardConfig WireguardConfig `json:"wireguard_config"` WireguardConfig WireguardConfig `json:"wireguard_config"`
NpuAppEnabled bool `json:"npu_app_enabled"` NpuAppEnabled bool `json:"npu_app_enabled"`
Firewall *FirewallConfig `json:"firewall"` Firewall *FirewallConfig `json:"firewall"`
APIKey string `json:"api_key"`
} }
type FirewallConfig struct { type FirewallConfig struct {
@@ -194,13 +183,8 @@ type WireguardConfig struct {
const configPath = "/userdata/kvm_config.json" const configPath = "/userdata/kvm_config.json"
const sdConfigPath = "/mnt/sdcard/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{ var defaultConfig = &Config{
STUN: "stun:stun.l.google.com:19302", STUN: "stun:stun.l.google.com:19302",
TurnServers: []TurnServer{},
AutoUpdateEnabled: false, // Set a default value AutoUpdateEnabled: false, // Set a default value
ActiveExtension: "", ActiveExtension: "",
KeyboardMacros: []KeyboardMacro{}, KeyboardMacros: []KeyboardMacro{},
@@ -260,75 +244,6 @@ var (
configLock = &sync.Mutex{} 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() { func LoadConfig() {
configLock.Lock() configLock.Lock()
defer configLock.Unlock() defer configLock.Unlock()
@@ -338,6 +253,7 @@ func LoadConfig() {
return return
} }
// load the default config
if defaultConfig.UsbConfig.SerialNumber == "" { if defaultConfig.UsbConfig.SerialNumber == "" {
serialNumber, err := extractSerialNumber() serialNumber, err := extractSerialNumber()
if err != nil { if err != nil {
@@ -349,38 +265,24 @@ func LoadConfig() {
loadedConfig := *defaultConfig loadedConfig := *defaultConfig
config = &loadedConfig config = &loadedConfig
rawData, err := os.ReadFile(configPath) file, err := os.Open(configPath)
if err != nil { if err != nil {
logger.Debug().Msg("config file does not exist, using default") logger.Debug().Msg("default config file doesn't exist, using default")
return return
} }
defer file.Close()
migrated, didMigrate, err := runMigrations(json.RawMessage(rawData)) // load and merge the default config with the user config
if err != nil { if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
logger.Warn().Err(err).Msg("config migration failed, preserving corrupt file") logger.Warn().Err(err).Msg("config file JSON parsing failed")
corruptPath := configPath + ".corrupt" os.Remove(configPath)
_ = os.Rename(configPath, corruptPath) if _, err := os.Stat(sdConfigPath); err == nil {
logger.Info().Str("corrupt_path", corruptPath).Msg("corrupt config preserved for diagnosis") os.Remove(sdConfigPath)
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 return
} }
// merge the user config with the default config
if loadedConfig.UsbConfig == nil { if loadedConfig.UsbConfig == nil {
loadedConfig.UsbConfig = defaultConfig.UsbConfig loadedConfig.UsbConfig = defaultConfig.UsbConfig
} }
@@ -397,10 +299,6 @@ func LoadConfig() {
loadedConfig.Firewall = defaultConfig.Firewall loadedConfig.Firewall = defaultConfig.Firewall
} }
if loadedConfig.TurnServers == nil {
loadedConfig.TurnServers = []TurnServer{}
}
config = &loadedConfig config = &loadedConfig
logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel) logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel)
@@ -499,14 +397,6 @@ func SaveConfig() error {
return nil 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() { func ensureConfigLoaded() {
if config == nil { if config == nil {
LoadConfig() LoadConfig()

25
go.mod
View File

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

109
go.sum
View File

@@ -18,8 +18,9 @@ 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/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 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= 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/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 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0= github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
@@ -27,10 +28,6 @@ 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
@@ -41,6 +38,8 @@ 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-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 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 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= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -55,8 +54,7 @@ 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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= github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ=
@@ -65,8 +63,6 @@ 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/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 h1:wV8rG7rmCz8XHSOwBZhG5YcVqcYjkzivjmbaMafPlAs=
github.com/hanwen/go-fuse/v2 v2.8.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI= 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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 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= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@@ -75,6 +71,7 @@ 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 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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/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.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -86,8 +83,6 @@ 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/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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 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.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 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -110,34 +105,59 @@ 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/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 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= 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 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU= 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 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= 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 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic= 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 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90= 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 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= 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 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= 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 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= 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 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU=
github.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= 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 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= 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 h1:uN3SS2b+QDZnWXgdr69SM8KB4EbcnPnPf2Laxhty/l4=
github.com/pion/sdp/v3 v3.0.13/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= 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 h1:8XLB6Dt3QXkMkRFpoqC3314BemkpMQK2mZeJc4pUKqo=
github.com/pion/srtp/v3 v3.0.5/go.mod h1:r1G7y5r1scZRLe2QJI/is+/O83W2d+JoEsuIexpw+uM= 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 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= 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 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= 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 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps=
github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs= 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 h1:5f8QMVIbNvJr2mPRGi2QamkPa/LVUB6NWolOCwphKHA=
github.com/pion/webrtc/v4 v4.0.16/go.mod h1:C3uTCPzVafUA0eUzru9f47OgNt3nEO7ZJ6zNY6VSJno= github.com/pion/webrtc/v4 v4.0.16/go.mod h1:C3uTCPzVafUA0eUzru9f47OgNt3nEO7ZJ6zNY6VSJno=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -158,17 +178,8 @@ 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/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 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 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 h1:VgoRCP1efSCEZIcF2THLQ46+pIBzzgNiaUBe9wEDwYU=
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzro7BGorij2WgrjEammtrkbo3+xldxo+KaGLGUiD+Q= 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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -177,6 +188,8 @@ 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.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.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.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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
@@ -187,31 +200,81 @@ 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/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 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= 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 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
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 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8=
go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE= 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 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= 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 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= 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 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= 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.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.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.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.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.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 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 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 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -33,27 +33,24 @@ type IPv6StaticConfig struct {
DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"` DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"`
} }
type NetworkConfig struct { type NetworkConfig struct {
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"` Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
Domain null.String `json:"domain,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"` IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"`
IPv4RequestAddress null.String `json:"ipv4_request_address,omitempty"` IPv4RequestAddress null.String `json:"ipv4_request_address,omitempty"`
IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"` 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"` 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"` IPv6Static *IPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"`
LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"` 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"` 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"` 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"` 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"` 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"` TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"` TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"`
PendingReboot null.Bool `json:"pending_reboot,omitempty" default:"false"` PendingReboot null.Bool `json:"pending_reboot,omitempty" default:"false"`
} }
func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions { func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {

View File

@@ -131,10 +131,10 @@ func (u *UsbGadget) GetKeyboardState() KeyboardState {
return u.keyboardState return u.keyboardState
} }
func (u *UsbGadget) listenKeyboardEvents(ctx context.Context, file *os.File) { func (u *UsbGadget) listenKeyboardEvents() {
var path string var path string
if file != nil { if u.keyboardHidFile != nil {
path = file.Name() path = u.keyboardHidFile.Name()
} }
l := u.log.With().Str("listener", "keyboardEvents").Str("path", path).Logger() l := u.log.With().Str("listener", "keyboardEvents").Str("path", path).Logger()
l.Trace().Msg("starting") l.Trace().Msg("starting")
@@ -143,12 +143,12 @@ func (u *UsbGadget) listenKeyboardEvents(ctx context.Context, file *os.File) {
buf := make([]byte, hidReadBufferSize) buf := make([]byte, hidReadBufferSize)
for { for {
select { select {
case <-ctx.Done(): case <-u.keyboardStateCtx.Done():
l.Info().Msg("context done") l.Info().Msg("context done")
return return
default: default:
l.Trace().Msg("reading from keyboard") l.Trace().Msg("reading from keyboard")
if file == nil { if u.keyboardHidFile == nil {
u.logWithSupression("keyboardHidFileNil", 100, &l, nil, "keyboardHidFile is nil") u.logWithSupression("keyboardHidFileNil", 100, &l, nil, "keyboardHidFile is nil")
// show the error every 100 times to avoid spamming the logs // show the error every 100 times to avoid spamming the logs
time.Sleep(time.Second) time.Sleep(time.Second)
@@ -157,26 +157,16 @@ func (u *UsbGadget) listenKeyboardEvents(ctx context.Context, file *os.File) {
// reset the counter // reset the counter
u.resetLogSuppressionCounter("keyboardHidFileNil") u.resetLogSuppressionCounter("keyboardHidFileNil")
n, err := file.Read(buf) n, err := u.keyboardHidFile.Read(buf)
if err != nil { 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") u.logWithSupression("keyboardHidFileRead", 100, &l, err, "failed to read")
if reopenErr := u.reopenKeyboardHidFile(); reopenErr != nil { continue
u.logWithSupression("keyboardHidFileReopen", 100, &l, reopenErr, "failed to reopen keyboard HID file")
} else {
u.resetLogSuppressionCounter("keyboardHidFileReopen")
}
return
} }
u.resetLogSuppressionCounter("keyboardHidFileRead") u.resetLogSuppressionCounter("keyboardHidFileRead")
l.Trace().Int("n", n).Bytes("buf", buf).Msg("got data from keyboard") l.Trace().Int("n", n).Bytes("buf", buf).Msg("got data from keyboard")
if n < 1 { if n != 1 {
l.Info().Int("n", n).Msg("expected at least 1 byte, got 0") l.Trace().Int("n", n).Msg("expected 1 byte, got")
continue continue
} }
u.updateKeyboardState(buf[0]) u.updateKeyboardState(buf[0])
@@ -185,52 +175,13 @@ func (u *UsbGadget) listenKeyboardEvents(ctx context.Context, file *os.File) {
}() }()
} }
func openWithTimeout(name string, flag int, perm os.FileMode, timeout time.Duration) (*os.File, error) { func (u *UsbGadget) openKeyboardHidFile() 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 { 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 return nil
} }
file, err := openWithTimeout("/dev/hidg0", os.O_RDWR, 0666, 3*time.Second) var err error
u.keyboardHidFile, err = os.OpenFile("/dev/hidg0", os.O_RDWR, 0666)
if err != nil { 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") { 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(). u.log.Error().
@@ -245,203 +196,33 @@ func (u *UsbGadget) openKeyboardHidFileLocked(forceReopen bool) error {
return fmt.Errorf("failed to open hidg0: %w", err) return fmt.Errorf("failed to open hidg0: %w", err)
} }
ctx, cancel := context.WithCancel(context.Background()) if u.keyboardStateCancel != nil {
u.keyboardHidFile = file u.keyboardStateCancel()
u.keyboardStateCtx = ctx }
u.keyboardStateCancel = cancel
u.listenKeyboardEvents(ctx, file) u.keyboardStateCtx, u.keyboardStateCancel = context.WithCancel(context.Background())
u.listenKeyboardEvents()
return nil 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 { func (u *UsbGadget) OpenKeyboardHidFile() error {
return u.openKeyboardHidFile() return u.openKeyboardHidFile()
} }
func (u *UsbGadget) ReopenKeyboardHidFile() error { func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
return u.reopenKeyboardHidFile() if err := u.openKeyboardHidFile(); err != nil {
} return err
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))...)
} }
data := []byte{modifier, 0, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]} _, err := u.keyboardHidFile.Write(data)
if u.keyboardHidFile == nil {
if err := u.openKeyboardHidFileLocked(false); err != nil {
return err
}
}
_, err := u.writeWithTimeout(u.keyboardHidFile, data)
if err != nil { if err != nil {
u.logWithSupression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0") u.logWithSupression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0")
u.closeKeyboardHidFileLocked() u.keyboardHidFile.Close()
u.keyboardHidFile = nil
return err return err
} }
u.resetLogSuppressionCounter("keyboardWriteHidFile") 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 return nil
} }
@@ -449,8 +230,18 @@ func (u *UsbGadget) KeyboardReport(modifier uint8, keys []uint8) error {
u.keyboardLock.Lock() u.keyboardLock.Lock()
defer u.keyboardLock.Unlock() defer u.keyboardLock.Unlock()
u.keysDownState.Modifier = modifier if len(keys) > 6 {
copy(u.keysDownState.Keys[:], keys) keys = keys[:6]
}
if len(keys) < 6 {
keys = append(keys, make([]uint8, 6-len(keys))...)
}
return u.keyboardWriteHidFileLocked(modifier, 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
} }

View File

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

View File

@@ -2,13 +2,10 @@ package usbgadget
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
@@ -110,36 +107,3 @@ func (u *UsbGadget) resetLogSuppressionCounter(counterName string) {
u.logSuppressionCounter[counterName] = 0 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,15 +105,6 @@ func setLedMode(ledConfigPath string, mode string) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to set LED trigger: %v", err) 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: default:
return fmt.Errorf("invalid LED mode: %s", mode) return fmt.Errorf("invalid LED mode: %s", mode)
} }

View File

@@ -79,12 +79,15 @@ func writeJSONRPCEvent(event string, params interface{}, session *Session) {
} }
requestString := string(requestBytes) requestString := string(requestBytes)
scopedLogger := jsonRpcLogger.With().
Str("data", requestString).
Logger()
jsonRpcLogger.Trace().Str("event", event).Msg("sending JSONRPC event") scopedLogger.Info().Msg("sending JSONRPC event")
err = session.RPCChannel.SendText(requestString) err = session.RPCChannel.SendText(requestString)
if err != nil { if err != nil {
jsonRpcLogger.Warn().Err(err).Str("event", event).Msg("error sending JSONRPC event") scopedLogger.Warn().Err(err).Msg("error sending JSONRPC event")
return return
} }
} }
@@ -233,124 +236,6 @@ func rpcSetStreamEncodecType(encodecType string) error {
return nil 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 { func rpcSetNpuAppStatus(enable bool) error {
logger.Info().Bool("enable", enable).Msg("Setting NPU app status") logger.Info().Bool("enable", enable).Msg("Setting NPU app status")
var _, err = CallCtrlAction("set_yolo_enable", map[string]interface{}{"enable": enable}) var _, err = CallCtrlAction("set_yolo_enable", map[string]interface{}{"enable": enable})
@@ -494,36 +379,6 @@ func rpcGetUpdateStatus() (*UpdateStatus, error) {
return updateStatus, nil 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 { func rpcTryUpdate() error {
includePreRelease := config.IncludePreRelease includePreRelease := config.IncludePreRelease
go func() { go func() {
@@ -535,14 +390,6 @@ func rpcTryUpdate() error {
return nil 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) { func rpcGetCustomUpdateBaseURL() (string, error) {
return customUpdateBaseURL, nil return customUpdateBaseURL, nil
} }
@@ -1005,60 +852,6 @@ func rpcSetConfigRaw(configStr string) error {
return nil 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) { func rpcGetActiveExtension() (string, error) {
return config.ActiveExtension, nil return config.ActiveExtension, nil
} }
@@ -1333,30 +1126,6 @@ func rpcSetLocalLoopbackOnly(enabled bool) error {
return nil 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 { type IOSettings struct {
IO0Status bool `json:"io0Status"` IO0Status bool `json:"io0Status"`
IO1Status bool `json:"io1Status"` IO1Status bool `json:"io1Status"`
@@ -1555,77 +1324,6 @@ func rpcConfirmOtherSession() (bool, error) {
return true, nil 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{ var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing}, "ping": {Func: rpcPing},
"reboot": {Func: rpcReboot, Params: []string{"force"}}, "reboot": {Func: rpcReboot, Params: []string{"force"}},
@@ -1663,9 +1361,7 @@ var rpcHandlers = map[string]RPCHandler{
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}}, "setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
"getLocalUpdateStatus": {Func: rpcGetLocalUpdateStatus}, "getLocalUpdateStatus": {Func: rpcGetLocalUpdateStatus},
"getUpdateStatus": {Func: rpcGetUpdateStatus}, "getUpdateStatus": {Func: rpcGetUpdateStatus},
"getSelfSignatureStatus": {Func: rpcGetSelfSignatureStatus},
"tryUpdate": {Func: rpcTryUpdate}, "tryUpdate": {Func: rpcTryUpdate},
"updateSignatures": {Func: rpcUpdateSignatures},
"getCustomUpdateBaseURL": {Func: rpcGetCustomUpdateBaseURL}, "getCustomUpdateBaseURL": {Func: rpcGetCustomUpdateBaseURL},
"setCustomUpdateBaseURL": {Func: rpcSetCustomUpdateBaseURL, Params: []string{"baseURL"}}, "setCustomUpdateBaseURL": {Func: rpcSetCustomUpdateBaseURL, Params: []string{"baseURL"}},
"getUpdateDownloadProxy": {Func: rpcGetUpdateDownloadProxy}, "getUpdateDownloadProxy": {Func: rpcGetUpdateDownloadProxy},
@@ -1673,9 +1369,6 @@ var rpcHandlers = map[string]RPCHandler{
"getDevModeState": {Func: rpcGetDevModeState}, "getDevModeState": {Func: rpcGetDevModeState},
"getSSHKeyState": {Func: rpcGetSSHKeyState}, "getSSHKeyState": {Func: rpcGetSSHKeyState},
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}}, "setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
"getApiKey": {Func: rpcGetApiKey},
"setApiKey": {Func: rpcSetApiKey, Params: []string{"apiKey"}},
"generateApiKey": {Func: rpcGenerateApiKey},
"getTLSState": {Func: rpcGetTLSState}, "getTLSState": {Func: rpcGetTLSState},
"setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}}, "setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}},
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}}, "setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
@@ -1694,7 +1387,7 @@ var rpcHandlers = map[string]RPCHandler{
"resetSDStorage": {Func: rpcResetSDStorage}, "resetSDStorage": {Func: rpcResetSDStorage},
"mountSDStorage": {Func: rpcMountSDStorage}, "mountSDStorage": {Func: rpcMountSDStorage},
"unmountSDStorage": {Func: rpcUnmountSDStorage}, "unmountSDStorage": {Func: rpcUnmountSDStorage},
"formatSDStorage": {Func: rpcFormatSDStorage, Params: []string{"confirm", "fsType"}}, "formatSDStorage": {Func: rpcFormatSDStorage, Params: []string{"confirm"}},
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}}, "mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}}, "mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}}, "mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
@@ -1713,10 +1406,6 @@ var rpcHandlers = map[string]RPCHandler{
"resetConfig": {Func: rpcResetConfig}, "resetConfig": {Func: rpcResetConfig},
"getConfigRaw": {Func: rpcGetConfigRaw}, "getConfigRaw": {Func: rpcGetConfigRaw},
"setConfigRaw": {Func: rpcSetConfigRaw, Params: []string{"configStr"}}, "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"}}, "setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}},
"getDisplayRotation": {Func: rpcGetDisplayRotation}, "getDisplayRotation": {Func: rpcGetDisplayRotation},
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}}, "setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
@@ -1780,18 +1469,8 @@ var rpcHandlers = map[string]RPCHandler{
"stopCloudflared": {Func: rpcStopCloudflared}, "stopCloudflared": {Func: rpcStopCloudflared},
"getCloudflaredStatus": {Func: rpcGetCloudflaredStatus}, "getCloudflaredStatus": {Func: rpcGetCloudflaredStatus},
"getCloudflaredLog": {Func: rpcGetCloudflaredLog}, "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}, "getStreamEncodecType": {Func: rpcGetStreamEncodecType},
"setStreamEncodecType": {Func: rpcSetStreamEncodecType, Params: []string{"encodecType"}}, "setStreamEncodecType": {Func: rpcSetStreamEncodecType, Params: []string{"encodecType"}},
"setVideoRc": {Func: rpcSetVideoRc, Params: []string{"params"}},
"getVideoRc": {Func: rpcGetVideoRc},
"setNpuAppStatus": {Func: rpcSetNpuAppStatus, Params: []string{"enable"}}, "setNpuAppStatus": {Func: rpcSetNpuAppStatus, Params: []string{"enable"}},
"getNpuAppStatus": {Func: rpcGetNpuAppStatus}, "getNpuAppStatus": {Func: rpcGetNpuAppStatus},
"startWireguard": {Func: rpcStartWireguard, Params: []string{"configFile"}}, "startWireguard": {Func: rpcStartWireguard, Params: []string{"configFile"}},

23
main.go
View File

@@ -18,20 +18,6 @@ func Main() {
SyncConfigSD(true) SyncConfigSD(true)
LoadConfig() 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 var cancel context.CancelFunc
appCtx, cancel = context.WithCancel(context.Background()) appCtx, cancel = context.WithCancel(context.Background())
defer cancel() defer cancel()
@@ -199,15 +185,6 @@ func Main() {
//go RunFuseServer() //go RunFuseServer()
go RunWebServer() go RunWebServer()
// API and MCP services temporarily disabled for debugging
go func() {
StartAPIServer(8080)
}()
go func() {
StartMCP(8081, false)
}()
go RunWebSecureServer() go RunWebSecureServer()
// Web secure server is started only if TLS mode is enabled // Web secure server is started only if TLS mode is enabled
if config.TLSMode != "" { if config.TLSMode != "" {

313
mcp.go
View File

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

View File

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

View File

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

View File

@@ -2,8 +2,6 @@ package kvm
import ( import (
"fmt" "fmt"
"os"
"strings"
"kvm/internal/network" "kvm/internal/network"
"kvm/internal/udhcpc" "kvm/internal/udhcpc"
@@ -19,27 +17,6 @@ var (
networkState *network.NetworkInterfaceState 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() { func networkStateChanged() {
// do not block the main thread // do not block the main thread
go waitCtrlAndRequestDisplayUpdate(true) go waitCtrlAndRequestDisplayUpdate(true)
@@ -56,7 +33,6 @@ func networkStateChanged() {
func initNetwork() error { func initNetwork() error {
ensureConfigLoaded() ensureConfigLoaded()
applyProxyEnvironment(config.NetworkConfig)
state, err := network.NewNetworkInterfaceState(&network.NetworkInterfaceOptions{ state, err := network.NewNetworkInterfaceState(&network.NetworkInterfaceOptions{
DefaultHostname: GetDefaultHostname(), DefaultHostname: GetDefaultHostname(),
@@ -155,7 +131,6 @@ func rpcSetNetworkSettings(settings network.RpcNetworkSettings) (*network.RpcNet
if err := SaveConfig(); err != nil { if err := SaveConfig(); err != nil {
return nil, err return nil, err
} }
applyProxyEnvironment(config.NetworkConfig)
return &network.RpcNetworkSettings{NetworkConfig: *config.NetworkConfig}, nil return &network.RpcNetworkSettings{NetworkConfig: *config.NetworkConfig}, nil
} }
@@ -167,16 +142,3 @@ func rpcRenewDHCPLease() error {
func rpcRequestDHCPAddress(ip string) error { func rpcRequestDHCPAddress(ip string) error {
return networkState.RpcRequestDHCPAddress(ip) 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
}

20
network_mac.go Normal file
View File

@@ -0,0 +1,20 @@
package kvm
import (
"fmt"
"os"
)
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
}

336
ota.go
View File

@@ -4,7 +4,6 @@ import (
"archive/zip" "archive/zip"
"bytes" "bytes"
"context" "context"
"crypto/ed25519"
"crypto/sha256" "crypto/sha256"
"crypto/tls" "crypto/tls"
"encoding/hex" "encoding/hex"
@@ -43,10 +42,8 @@ type RemoteMetadata struct {
AppVersion string `json:"appVersion"` AppVersion string `json:"appVersion"`
AppUrl string `json:"appUrl"` AppUrl string `json:"appUrl"`
AppHash string `json:"appHash"` AppHash string `json:"appHash"`
AppSigUrl string `json:"appSigUrl,omitempty"`
SystemUrl string `json:"systemUrl"` SystemUrl string `json:"systemUrl"`
SystemHash string `json:"systemHash,omitempty"` SystemHash string `json:"systemHash,omitempty"`
SystemSigUrl string `json:"systemSigUrl,omitempty"`
SystemVersion string `json:"systemVersion"` SystemVersion string `json:"systemVersion"`
} }
@@ -56,8 +53,6 @@ type UpdateStatus struct {
Remote *RemoteMetadata `json:"remote"` Remote *RemoteMetadata `json:"remote"`
SystemUpdateAvailable bool `json:"systemUpdateAvailable"` SystemUpdateAvailable bool `json:"systemUpdateAvailable"`
AppUpdateAvailable bool `json:"appUpdateAvailable"` AppUpdateAvailable bool `json:"appUpdateAvailable"`
AppSignatureMissing bool `json:"appSignatureMissing,omitempty"`
SystemSignatureMissing bool `json:"systemSignatureMissing,omitempty"`
// for backwards compatibility // for backwards compatibility
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
@@ -94,12 +89,10 @@ var UpdateGiteeSystemZipUrls = []string{
const cdnUpdateBaseURL = "https://cdn.picokvm.top/luckfox_picokvm_firmware/lastest/" const cdnUpdateBaseURL = "https://cdn.picokvm.top/luckfox_picokvm_firmware/lastest/"
var builtAppVersion = "0.1.3+dev" var builtAppVersion = "0.1.2+dev"
var ( var updateSource = "github"
updateSource = "github" var customUpdateBaseURL string
customUpdateBaseURL string
)
const ( const (
updateSourceGithub = "github" updateSourceGithub = "github"
@@ -151,12 +144,12 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
_, _ = deviceId, includePreRelease _, _ = deviceId, includePreRelease
appVersionRemote, appURL, appSha256, appSigURL, err := fetchKvmAppLatestRelease(ctx) appVersionRemote, appURL, appSha256, err := fetchKvmAppLatestRelease(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
systemVersionRemote, systemZipURL, systemSigURL, err := fetchKvmSystemLatestRelease(ctx) systemVersionRemote, systemZipURL, err := fetchKvmSystemLatestRelease(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -165,14 +158,12 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
AppUrl: appURL, AppUrl: appURL,
AppVersion: appVersionRemote, AppVersion: appVersionRemote,
AppHash: appSha256, AppHash: appSha256,
AppSigUrl: appSigURL,
SystemUrl: systemZipURL, SystemUrl: systemZipURL,
SystemVersion: systemVersionRemote, SystemVersion: systemVersionRemote,
SystemSigUrl: systemSigURL,
}, nil }, nil
} }
func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL string, sha256 string, sigURL string, err error) { func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL string, sha256 string, err error) {
apiURLs := UpdateGithubAppReleaseUrls apiURLs := UpdateGithubAppReleaseUrls
fallbackToGithub := false fallbackToGithub := false
if updateSource == updateSourceGitee { if updateSource == updateSourceGitee {
@@ -180,7 +171,7 @@ func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL stri
fallbackToGithub = true fallbackToGithub = true
} }
tryFetch := func(urls []string) (string, string, string, string, error) { tryFetch := func(urls []string) (string, string, string, error) {
var lastErr error var lastErr error
for _, apiURL := range urls { for _, apiURL := range urls {
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
@@ -227,54 +218,44 @@ func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL stri
continue continue
} }
var downloadURL, sha256, sigURL string var downloadURL string
for _, asset := range release.Assets { var sha256 string
name := strings.ToLower(strings.TrimSpace(asset.Name)) if len(release.Assets) > 0 {
u := strings.TrimSpace(asset.BrowserDownloadURL) downloadURL = release.Assets[0].BrowserDownloadURL
if strings.HasSuffix(name, ".sig") || strings.HasSuffix(name, ".sha256") || strings.HasSuffix(name, ".sha2565") { sha256 = release.Assets[0].Digest
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) == "" { if strings.TrimSpace(downloadURL) == "" {
lastErr = fmt.Errorf("empty app download url from %s", apiURL) lastErr = fmt.Errorf("empty app download url from %s", apiURL)
continue continue
} }
return tag, downloadURL, sha256, sigURL, nil return tag, downloadURL, sha256, nil
} }
if lastErr == nil { if lastErr == nil {
lastErr = fmt.Errorf("no app release API URLs configured") lastErr = fmt.Errorf("no app release API URLs configured")
} }
return "", "", "", "", lastErr return "", "", "", lastErr
} }
var lastErr error var lastErr error
tag, downloadURL, sha256, sigURL, err = tryFetch(apiURLs) tag, downloadURL, sha256, err = tryFetch(apiURLs)
if err == nil { if err == nil {
return tag, downloadURL, sha256, sigURL, nil return tag, downloadURL, sha256, nil
} }
lastErr = err lastErr = err
if updateSource == updateSourceGitee && fallbackToGithub { if updateSource == updateSourceGitee && fallbackToGithub {
var ghSigURL string tag, downloadURL, sha256, err = tryFetch(UpdateGithubAppReleaseUrls)
tag, downloadURL, sha256, ghSigURL, err = tryFetch(UpdateGithubAppReleaseUrls)
if err == nil { if err == nil {
downloadURL = strings.Replace(downloadURL, "github.com", "gitee.com", 1) downloadURL = strings.Replace(downloadURL, "github.com", "gitee.com", 1)
ghSigURL = strings.Replace(ghSigURL, "github.com", "gitee.com", 1) return tag, downloadURL, sha256, nil
return tag, downloadURL, sha256, ghSigURL, nil
} }
lastErr = fmt.Errorf("gitee app release fetch failed (%v); github fallback failed (%w)", lastErr, err) lastErr = fmt.Errorf("gitee app release fetch failed (%v); github fallback failed (%w)", lastErr, err)
} }
return "", "", "", "", lastErr return "", "", "", lastErr
} }
type releaseAsset struct { type releaseAsset struct {
@@ -300,7 +281,7 @@ func pickZipAssetURL(assets []releaseAsset) string {
return "" return ""
} }
func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string, sigURL string, err error) { func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string, err error) {
apiURLs := UpdateGithubSystemReleaseUrls apiURLs := UpdateGithubSystemReleaseUrls
fallbackToGithub := false fallbackToGithub := false
if updateSource == updateSourceGitee { if updateSource == updateSourceGitee {
@@ -355,18 +336,11 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
continue 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) != "" { if u := pickZipAssetURL(release.Assets); strings.TrimSpace(u) != "" {
return tag, strings.TrimSpace(u), sysSigURL, nil return tag, strings.TrimSpace(u), nil
} }
if strings.TrimSpace(release.ZipballURL) != "" { if strings.TrimSpace(release.ZipballURL) != "" {
return tag, strings.TrimSpace(release.ZipballURL), sysSigURL, nil return tag, strings.TrimSpace(release.ZipballURL), nil
} }
lastErr = fmt.Errorf("no usable system archive url in release response from %s", apiURL) lastErr = fmt.Errorf("no usable system archive url in release response from %s", apiURL)
@@ -381,22 +355,22 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
var githubTag string var githubTag string
var githubZipURL string var githubZipURL string
for i, apiURL := range UpdateGithubSystemReleaseUrls { for i, apiURL := range UpdateGithubSystemReleaseUrls {
githubTag, githubZipURL, _, githubErr = func(apiURL string) (string, string, string, error) { githubTag, githubZipURL, githubErr = func(apiURL string) (string, string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != 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) resp, err := http.DefaultClient.Do(req)
if err != nil { 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) body, readErr := io.ReadAll(resp.Body)
resp.Body.Close() resp.Body.Close()
if readErr != nil { 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 { if resp.StatusCode != http.StatusOK {
return "", "", "", fmt.Errorf( return "", "", fmt.Errorf(
"unexpected status code fetching system release from %s: %d, %s", "unexpected status code fetching system release from %s: %d, %s",
apiURL, apiURL,
resp.StatusCode, resp.StatusCode,
@@ -409,26 +383,19 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
Assets []releaseAsset `json:"assets"` Assets []releaseAsset `json:"assets"`
} }
if err := json.Unmarshal(body, &release); err != nil { 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) tag := strings.TrimSpace(release.TagName)
if tag == "" { 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) != "" { if u := pickZipAssetURL(release.Assets); strings.TrimSpace(u) != "" {
return tag, strings.TrimSpace(u), sigURL, nil return tag, strings.TrimSpace(u), nil
} }
if strings.TrimSpace(release.ZipballURL) != "" { if strings.TrimSpace(release.ZipballURL) != "" {
return tag, strings.TrimSpace(release.ZipballURL), sigURL, nil return tag, strings.TrimSpace(release.ZipballURL), 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) }(apiURL)
if githubErr == nil && strings.TrimSpace(githubTag) != "" { if githubErr == nil && strings.TrimSpace(githubTag) != "" {
_ = githubZipURL _ = githubZipURL
@@ -447,15 +414,15 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
zipTag = strings.TrimPrefix(zipTag, "V") zipTag = strings.TrimPrefix(zipTag, "V")
} }
zipURL := strings.TrimRight(selectedZipURL, "/") + "/" + zipTag + ".zip" zipURL := strings.TrimRight(selectedZipURL, "/") + "/" + zipTag + ".zip"
return githubTag, zipURL, "", nil return githubTag, zipURL, nil
} }
githubErr = fmt.Errorf("no gitee system zip urls configured") githubErr = fmt.Errorf("no gitee system zip urls configured")
break 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) { func fetchUpdateMetadataFromBaseURL(ctx context.Context, baseURL string) (*RemoteMetadata, error) {
@@ -529,21 +496,13 @@ 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{ return &RemoteMetadata{
AppVersion: appVersion, AppVersion: appVersion,
AppUrl: appURL, AppUrl: appURL,
AppHash: appHash, AppHash: appHash,
AppSigUrl: appSigURL,
SystemVersion: systemVersion, SystemVersion: systemVersion,
SystemUrl: systemURL, SystemUrl: systemURL,
SystemHash: systemHash, SystemHash: systemHash,
SystemSigUrl: systemSigURL,
}, nil }, nil
} }
@@ -926,7 +885,7 @@ func downloadFile(
} }
// Clear the filesystem caches to force a read from disk // Clear the filesystem caches to force a read from disk
err = os.WriteFile("/proc/sys/vm/drop_caches", []byte("1"), 0o644) err = os.WriteFile("/proc/sys/vm/drop_caches", []byte("1"), 0644)
if err != nil { if err != nil {
otaLogger.Warn().Err(err).Msg("Failed to clear filesystem caches") otaLogger.Warn().Err(err).Msg("Failed to clear filesystem caches")
} }
@@ -950,8 +909,6 @@ func prepareSystemUpdateTarFromKvmSystemZip(
downloadProgress *float32, downloadProgress *float32,
downloadSpeedBps *float32, downloadSpeedBps *float32,
verificationProgress *float32, verificationProgress *float32,
sigURL string,
expectedHash string,
scopedLogger *zerolog.Logger, scopedLogger *zerolog.Logger,
) error { ) error {
if scopedLogger == nil { if scopedLogger == nil {
@@ -963,14 +920,14 @@ func prepareSystemUpdateTarFromKvmSystemZip(
extractDir := filepath.Join(workDir, "extract") extractDir := filepath.Join(workDir, "extract")
zipPath := filepath.Join(workDir, "master.zip") zipPath := filepath.Join(workDir, "master.zip")
if err := os.MkdirAll(workDir, 0o755); err != nil { if err := os.MkdirAll(workDir, 0755); err != nil {
return fmt.Errorf("error creating work dir: %w", err) return fmt.Errorf("error creating work dir: %w", err)
} }
if err := os.RemoveAll(extractDir); err != nil { if err := os.RemoveAll(extractDir); err != nil {
return fmt.Errorf("error cleaning extract dir: %w", err) return fmt.Errorf("error cleaning extract dir: %w", err)
} }
if err := os.MkdirAll(extractDir, 0o755); err != nil { if err := os.MkdirAll(extractDir, 0755); err != nil {
return fmt.Errorf("error creating extract dir: %w", err) return fmt.Errorf("error creating extract dir: %w", err)
} }
@@ -998,26 +955,19 @@ func prepareSystemUpdateTarFromKvmSystemZip(
zipUnverifiedPath := zipPath + ".unverified" zipUnverifiedPath := zipPath + ".unverified"
if _, err := os.Stat(zipUnverifiedPath); err != nil { if _, err := os.Stat(zipUnverifiedPath); err != nil {
lastErr = fmt.Errorf("downloaded zip not found: %s: %w", zipUnverifiedPath, err) lastErr = fmt.Errorf("downloaded zip not found: %s: %w", zipUnverifiedPath, err)
} else if sigURL != "" || expectedHash != "" { } else {
if err := verifyFile(ctx, zipPath, expectedHash, sigURL, verificationProgress, scopedLogger); err != nil { if err := unzipArchive(zipUnverifiedPath, extractDir); err != nil {
lastErr = fmt.Errorf("system zip verification failed: %w", err)
} else if err := unzipArchive(zipUnverifiedPath, extractDir); err != nil {
lastErr = err lastErr = err
} else { } else {
lastErr = nil lastErr = nil
break break
} }
} else if err := unzipArchive(zipUnverifiedPath, extractDir); err != nil {
lastErr = err
} else {
lastErr = nil
break
} }
} }
_ = os.Remove(zipPath + ".unverified") _ = os.Remove(zipPath + ".unverified")
_ = os.RemoveAll(extractDir) _ = os.RemoveAll(extractDir)
_ = os.MkdirAll(extractDir, 0o755) _ = os.MkdirAll(extractDir, 0755)
if attempt < maxAttempts { if attempt < maxAttempts {
time.Sleep(time.Duration(attempt*2) * time.Second) time.Sleep(time.Duration(attempt*2) * time.Second)
} }
@@ -1049,7 +999,7 @@ func prepareSystemUpdateTarFromKvmSystemZip(
if _, err := os.Stat(scriptPath); err != nil { if _, err := os.Stat(scriptPath); err != nil {
return fmt.Errorf("split_and_check_md5.sh not found: %w", err) return fmt.Errorf("split_and_check_md5.sh not found: %w", err)
} }
if err := os.Chmod(scriptPath, 0o755); err != nil { if err := os.Chmod(scriptPath, 0755); err != nil {
return fmt.Errorf("error chmod split_and_check_md5.sh: %w", err) return fmt.Errorf("error chmod split_and_check_md5.sh: %w", err)
} }
@@ -1114,13 +1064,13 @@ func unzipArchive(zipPath string, destDir string) error {
} }
if file.FileInfo().IsDir() { if file.FileInfo().IsDir() {
if err := os.MkdirAll(cleanTargetPath, 0o755); err != nil { if err := os.MkdirAll(cleanTargetPath, 0755); err != nil {
return fmt.Errorf("error creating dir: %w", err) return fmt.Errorf("error creating dir: %w", err)
} }
continue continue
} }
if err := os.MkdirAll(filepath.Dir(cleanTargetPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(cleanTargetPath), 0755); err != nil {
return fmt.Errorf("error creating dir: %w", err) return fmt.Errorf("error creating dir: %w", err)
} }
@@ -1129,7 +1079,7 @@ func unzipArchive(zipPath string, destDir string) error {
return fmt.Errorf("error opening zipped file: %w", err) return fmt.Errorf("error opening zipped file: %w", err)
} }
outFile, err := os.OpenFile(cleanTargetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) outFile, err := os.OpenFile(cleanTargetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
if err != nil { if err != nil {
rc.Close() rc.Close()
return fmt.Errorf("error creating file: %w", err) return fmt.Errorf("error creating file: %w", err)
@@ -1152,42 +1102,17 @@ func unzipArchive(zipPath string, destDir string) error {
return nil return nil
} }
func verifyFile(ctx context.Context, path string, expectedHash string, sigURL string, verifyProgress *float32, scopedLogger *zerolog.Logger) error { func verifyFile(path string, expectedHash string, verifyProgress *float32, scopedLogger *zerolog.Logger) error {
if scopedLogger == nil { if scopedLogger == nil {
scopedLogger = otaLogger scopedLogger = otaLogger
} }
unverifiedPath := path + ".unverified" 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 strings.TrimSpace(expectedHash) == "" {
if err := os.Rename(unverifiedPath, path); err != nil { if err := os.Rename(unverifiedPath, path); err != nil {
return fmt.Errorf("error renaming file: %w", err) return fmt.Errorf("error renaming file: %w", err)
} }
if err := os.Chmod(path, 0o755); err != nil { if err := os.Chmod(path, 0755); err != nil {
return fmt.Errorf("error making file executable: %w", err) return fmt.Errorf("error making file executable: %w", err)
} }
return nil return nil
@@ -1245,7 +1170,7 @@ func verifyFile(ctx context.Context, path string, expectedHash string, sigURL st
return fmt.Errorf("error renaming file: %w", err) return fmt.Errorf("error renaming file: %w", err)
} }
if err := os.Chmod(path, 0o755); err != nil { if err := os.Chmod(path, 0755); err != nil {
return fmt.Errorf("error making file executable: %w", err) return fmt.Errorf("error making file executable: %w", err)
} }
@@ -1258,23 +1183,19 @@ type OTAState struct {
MetadataFetchedAt *time.Time `json:"metadataFetchedAt,omitempty"` MetadataFetchedAt *time.Time `json:"metadataFetchedAt,omitempty"`
AppUpdatePending bool `json:"appUpdatePending"` AppUpdatePending bool `json:"appUpdatePending"`
SystemUpdatePending bool `json:"systemUpdatePending"` 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"` AppDownloadSpeedBps float32 `json:"appDownloadSpeedBps"`
AppDownloadFinishedAt *time.Time `json:"appDownloadFinishedAt,omitempty"` 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"` SystemDownloadSpeedBps float32 `json:"systemDownloadSpeedBps"`
SystemDownloadFinishedAt *time.Time `json:"systemDownloadFinishedAt,omitempty"` SystemDownloadFinishedAt *time.Time `json:"systemDownloadFinishedAt,omitempty"`
AppVerificationProgress float32 `json:"appVerificationProgress,omitempty"` AppVerificationProgress float32 `json:"appVerificationProgress,omitempty"`
AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"` AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"`
SystemVerificationProgress float32 `json:"systemVerificationProgress,omitempty"` SystemVerificationProgress float32 `json:"systemVerificationProgress,omitempty"`
SystemVerifiedAt *time.Time `json:"systemVerifiedAt,omitempty"` SystemVerifiedAt *time.Time `json:"systemVerifiedAt,omitempty"`
AppSignatureVerified bool `json:"appSignatureVerified,omitempty"` AppUpdateProgress float32 `json:"appUpdateProgress,omitempty"` //TODO: implement for progress bar
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"` 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"` SystemUpdatedAt *time.Time `json:"systemUpdatedAt,omitempty"`
} }
@@ -1293,12 +1214,7 @@ func triggerOTAStateUpdate() {
func cleanupUpdateTempFiles(logger *zerolog.Logger) { func cleanupUpdateTempFiles(logger *zerolog.Logger) {
paths := []string{ paths := []string{
"/userdata/picokvm/bin/kvm_app.unverified", "/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.unverified",
"/userdata/picokvm/update_system.tar.sig.unverified",
"/userdata/picokvm/update_system.tar", "/userdata/picokvm/update_system.tar",
"/userdata/picokvm/kvm_system_work", "/userdata/picokvm/kvm_system_work",
} }
@@ -1382,10 +1298,8 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
triggerOTAStateUpdate() triggerOTAStateUpdate()
err = verifyFile( err = verifyFile(
ctx,
"/userdata/picokvm/bin/kvm_app", "/userdata/picokvm/bin/kvm_app",
remote.AppHash, remote.AppHash,
remote.AppSigUrl,
&otaState.AppVerificationProgress, &otaState.AppVerificationProgress,
&scopedLogger, &scopedLogger,
) )
@@ -1398,7 +1312,6 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
verifyFinished := time.Now() verifyFinished := time.Now()
otaState.AppVerifiedAt = &verifyFinished otaState.AppVerifiedAt = &verifyFinished
otaState.AppVerificationProgress = 1 otaState.AppVerificationProgress = 1
otaState.AppSignatureVerified = strings.TrimSpace(remote.AppSigUrl) != ""
otaState.AppUpdatedAt = &verifyFinished otaState.AppUpdatedAt = &verifyFinished
otaState.AppUpdateProgress = 1 otaState.AppUpdateProgress = 1
triggerOTAStateUpdate() triggerOTAStateUpdate()
@@ -1424,8 +1337,6 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
&otaState.SystemDownloadProgress, &otaState.SystemDownloadProgress,
&otaState.SystemDownloadSpeedBps, &otaState.SystemDownloadSpeedBps,
&otaState.SystemVerificationProgress, &otaState.SystemVerificationProgress,
remote.SystemSigUrl,
remote.SystemHash,
&scopedLogger, &scopedLogger,
) )
if err != nil { if err != nil {
@@ -1450,7 +1361,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
return err return err
} }
err = verifyFile(ctx, systemZipPath, remote.SystemHash, remote.SystemSigUrl, &otaState.SystemVerificationProgress, &scopedLogger) err = verifyFile(systemZipPath, remote.SystemHash, &otaState.SystemVerificationProgress, &scopedLogger)
if err != nil { if err != nil {
otaState.Error = fmt.Sprintf("Error preparing system update archive: %v", err) otaState.Error = fmt.Sprintf("Error preparing system update archive: %v", err)
scopedLogger.Error().Err(err).Msg("Error preparing system update archive") scopedLogger.Error().Err(err).Msg("Error preparing system update archive")
@@ -1474,7 +1385,6 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
verifyFinished := time.Now() verifyFinished := time.Now()
otaState.SystemVerifiedAt = &verifyFinished otaState.SystemVerifiedAt = &verifyFinished
otaState.SystemVerificationProgress = 1 otaState.SystemVerificationProgress = 1
otaState.SystemSignatureVerified = strings.TrimSpace(remote.SystemSigUrl) != ""
triggerOTAStateUpdate() triggerOTAStateUpdate()
scopedLogger.Info().Msg("Starting rk_ota command") scopedLogger.Info().Msg("Starting rk_ota command")
@@ -1544,7 +1454,13 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
} }
if rebootNeeded { if rebootNeeded {
cleanupUpdateTempFiles(&scopedLogger) 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")
}
scopedLogger.Info().Msg("System Rebooting in 10s") scopedLogger.Info().Msg("System Rebooting in 10s")
time.Sleep(10 * time.Second) time.Sleep(10 * time.Second)
cmd := exec.Command("reboot") cmd := exec.Command("reboot")
@@ -1605,9 +1521,6 @@ func GetUpdateStatus(ctx context.Context, deviceId string, includePreRelease boo
updateStatus.AppUpdateAvailable = false updateStatus.AppUpdateAvailable = false
} }
updateStatus.AppSignatureMissing = strings.TrimSpace(remoteMetadata.AppSigUrl) == ""
updateStatus.SystemSignatureMissing = strings.TrimSpace(remoteMetadata.SystemSigUrl) == ""
return updateStatus, nil return updateStatus, nil
} }
@@ -1622,130 +1535,3 @@ func confirmCurrentSystem() {
logger.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup") 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,64 +2,30 @@ package kvm
import ( import (
"sync" "sync"
"sync/atomic"
"github.com/google/uuid" "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 { type VideoBroadcaster struct {
subscribers map[string]chan *VideoFrame subscribers map[string]chan []byte
subscriberList []chan *VideoFrame // cached flat slice, rebuilt on Subscribe/Unsubscribe lock sync.RWMutex
count atomic.Int32 // len(subscribers) as atomic for fast Broadcast check onFirstSubscribe func()
lock sync.RWMutex
onFirstSubscribe func()
onLastUnsubscribe func() onLastUnsubscribe func()
} }
var videoBroadcaster = &VideoBroadcaster{ var videoBroadcaster = &VideoBroadcaster{
subscribers: make(map[string]chan *VideoFrame), subscribers: make(map[string]chan []byte),
} }
func (b *VideoBroadcaster) rebuildList() { func (b *VideoBroadcaster) Subscribe() (string, chan []byte) {
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() b.lock.Lock()
defer b.lock.Unlock() defer b.lock.Unlock()
id := uuid.New().String() id := uuid.New().String()
ch := make(chan *VideoFrame, 200) // Buffer a bit to avoid dropping frames too easily,
// but not too much to avoid latency build-up
ch := make(chan []byte, 200)
wasEmpty := len(b.subscribers) == 0 wasEmpty := len(b.subscribers) == 0
b.subscribers[id] = ch b.subscribers[id] = ch
b.rebuildList()
b.count.Store(int32(len(b.subscribers)))
if wasEmpty && b.onFirstSubscribe != nil { if wasEmpty && b.onFirstSubscribe != nil {
b.onFirstSubscribe() b.onFirstSubscribe()
} }
@@ -72,8 +38,6 @@ func (b *VideoBroadcaster) Unsubscribe(id string) {
if ch, ok := b.subscribers[id]; ok { if ch, ok := b.subscribers[id]; ok {
close(ch) close(ch)
delete(b.subscribers, id) delete(b.subscribers, id)
b.rebuildList()
b.count.Store(int32(len(b.subscribers)))
if len(b.subscribers) == 0 && b.onLastUnsubscribe != nil { if len(b.subscribers) == 0 && b.onLastUnsubscribe != nil {
b.onLastUnsubscribe() b.onLastUnsubscribe()
} }
@@ -81,38 +45,15 @@ func (b *VideoBroadcaster) Unsubscribe(id string) {
} }
func (b *VideoBroadcaster) Broadcast(data []byte) { 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() b.lock.RLock()
subscribers := b.subscriberList defer b.lock.RUnlock()
subscriberCount := len(subscribers) for _, ch := range b.subscribers {
if subscriberCount == 0 { // Non-blocking send
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 { select {
case ch <- frame: case ch <- data:
default: default:
frame.Release() // Drop frame if channel is full to avoid blocking other subscribers
// Ideally we should have a ring buffer or similar, but this is simple
} }
} }
b.lock.RUnlock()
frame.Release()
} }

906
tools.go
View File

@@ -1,906 +0,0 @@
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
}

392
ui/package-lock.json generated
View File

@@ -43,7 +43,6 @@
"recharts": "^2.15.3", "recharts": "^2.15.3",
"styled-components": "^6.1.19", "styled-components": "^6.1.19",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.0",
"tesseract.js": "^7.0.0",
"usehooks-ts": "^3.1.1", "usehooks-ts": "^3.1.1",
"validator": "^13.15.0", "validator": "^13.15.0",
"w-touch": "^2.0.0", "w-touch": "^2.0.0",
@@ -509,6 +508,12 @@
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@emotion/babel-plugin/node_modules/@emotion/memoize": {
"version": "0.9.0",
"resolved": "https://registry.npmmirror.com/@emotion/memoize/-/memoize-0.9.0.tgz",
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
"license": "MIT"
},
"node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": {
"version": "1.9.0", "version": "1.9.0",
"resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-1.9.0.tgz", "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-1.9.0.tgz",
@@ -534,6 +539,12 @@
"stylis": "4.2.0" "stylis": "4.2.0"
} }
}, },
"node_modules/@emotion/cache/node_modules/@emotion/memoize": {
"version": "0.9.0",
"resolved": "https://registry.npmmirror.com/@emotion/memoize/-/memoize-0.9.0.tgz",
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
"license": "MIT"
},
"node_modules/@emotion/cache/node_modules/stylis": { "node_modules/@emotion/cache/node_modules/stylis": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.2.0.tgz", "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.2.0.tgz",
@@ -560,18 +571,18 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@emotion/is-prop-valid": { "node_modules/@emotion/is-prop-valid": {
"version": "1.4.0", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", "resolved": "https://registry.npmmirror.com/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz",
"integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", "integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@emotion/memoize": "^0.9.0" "@emotion/memoize": "^0.8.1"
} }
}, },
"node_modules/@emotion/memoize": { "node_modules/@emotion/memoize": {
"version": "0.9.0", "version": "0.8.1",
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", "resolved": "https://registry.npmmirror.com/@emotion/memoize/-/memoize-0.8.1.tgz",
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@emotion/react": { "node_modules/@emotion/react": {
@@ -617,6 +628,12 @@
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@emotion/serialize/node_modules/@emotion/memoize": {
"version": "0.9.0",
"resolved": "https://registry.npmmirror.com/@emotion/memoize/-/memoize-0.9.0.tgz",
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
"license": "MIT"
},
"node_modules/@emotion/serialize/node_modules/@emotion/unitless": { "node_modules/@emotion/serialize/node_modules/@emotion/unitless": {
"version": "0.10.0", "version": "0.10.0",
"resolved": "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.10.0.tgz", "resolved": "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.10.0.tgz",
@@ -1760,9 +1777,9 @@
} }
}, },
"node_modules/@rollup/pluginutils/node_modules/picomatch": { "node_modules/@rollup/pluginutils/node_modules/picomatch": {
"version": "4.0.4", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -2804,66 +2821,6 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.4.3",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.0.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.4.3",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.0.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.9",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.0",
"@emnapi/runtime": "^1.4.0",
"@tybys/wasm-util": "^0.9.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.9.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.0",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.7", "version": "4.1.7",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.7.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.7.tgz",
@@ -3127,6 +3084,12 @@
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
}, },
"node_modules/@types/stylis": {
"version": "4.2.5",
"resolved": "https://registry.npmmirror.com/@types/stylis/-/stylis-4.2.5.tgz",
"integrity": "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==",
"license": "MIT"
},
"node_modules/@types/validator": { "node_modules/@types/validator": {
"version": "13.15.0", "version": "13.15.0",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.0.tgz", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.0.tgz",
@@ -3299,9 +3262,9 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.6", "version": "5.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3870,9 +3833,9 @@
} }
}, },
"node_modules/babel-plugin-macros/node_modules/yaml": { "node_modules/babel-plugin-macros/node_modules/yaml": {
"version": "1.10.3", "version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", "resolved": "https://registry.npmmirror.com/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -3884,16 +3847,10 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/bmp-js": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
"license": "MIT"
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.14", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
@@ -4236,9 +4193,9 @@
} }
}, },
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/cva": { "node_modules/cva": {
@@ -5379,9 +5336,9 @@
} }
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.4.2", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/focus-trap": { "node_modules/focus-trap": {
@@ -5820,12 +5777,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/idb-keyval": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
"license": "Apache-2.0"
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -5848,9 +5799,9 @@
} }
}, },
"node_modules/immutable": { "node_modules/immutable": {
"version": "3.8.3", "version": "3.8.2",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.3.tgz", "resolved": "https://registry.npmmirror.com/immutable/-/immutable-3.8.2.tgz",
"integrity": "sha512-AUY/VyX0E5XlibOmWt10uabJzam1zlYjwiEgQSDc5+UIkFNaF9WM0JxXKaNMGf+F/ffUF+7kRKXM9A7C0xXqMg==", "integrity": "sha512-15gZoQ38eYjEjxkorfbcgBKBL6R7T459OuK+CpcWt7O3KF4uPCx2tD0uFETlUDIyo+1789crbMhTvQBSR5yBMg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -6248,12 +6199,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/is-weakmap": { "node_modules/is-weakmap": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
@@ -6719,9 +6664,9 @@
} }
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.18.1", "version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.castarray": { "node_modules/lodash.castarray": {
@@ -7018,26 +6963,6 @@
"tslib": "^2.0.3" "tslib": "^2.0.3"
} }
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.19", "version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@@ -7187,15 +7112,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/opencollective-postinstall": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
"license": "MIT",
"bin": {
"opencollective-postinstall": "index.js"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -7396,9 +7312,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.2", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -7418,9 +7334,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.14", "version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -7437,7 +7353,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.8",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@@ -8514,12 +8430,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/regexp.prototype.flags": { "node_modules/regexp.prototype.flags": {
"version": "1.5.4", "version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -8797,6 +8707,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/shallowequal": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/shallowequal/-/shallowequal-1.1.0.tgz",
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
"license": "MIT"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -9128,15 +9044,20 @@
} }
}, },
"node_modules/styled-components": { "node_modules/styled-components": {
"version": "6.4.1", "version": "6.1.19",
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.4.1.tgz", "resolved": "https://registry.npmmirror.com/styled-components/-/styled-components-6.1.19.tgz",
"integrity": "sha512-ADu2dF53esUzzM4I0ewxhxFtsDd6v4V6dNkg3vG0iFKhnt06sJneTZnRvujAosZwW0XD58IKgGMQoqri4wHRqg==", "integrity": "sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@emotion/is-prop-valid": "1.4.0", "@emotion/is-prop-valid": "1.2.2",
"@emotion/unitless": "0.8.1",
"@types/stylis": "4.2.5",
"css-to-react-native": "3.2.0", "css-to-react-native": "3.2.0",
"csstype": "3.2.3", "csstype": "3.1.3",
"stylis": "4.3.6" "postcss": "8.4.49",
"shallowequal": "1.1.0",
"stylis": "4.3.2",
"tslib": "2.6.2"
}, },
"engines": { "engines": {
"node": ">= 16" "node": ">= 16"
@@ -9146,23 +9067,56 @@
"url": "https://opencollective.com/styled-components" "url": "https://opencollective.com/styled-components"
}, },
"peerDependencies": { "peerDependencies": {
"css-to-react-native": ">= 3.2.0",
"react": ">= 16.8.0", "react": ">= 16.8.0",
"react-dom": ">= 16.8.0", "react-dom": ">= 16.8.0"
"react-native": ">= 0.68.0"
},
"peerDependenciesMeta": {
"css-to-react-native": {
"optional": true
},
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
} }
}, },
"node_modules/styled-components/node_modules/@emotion/unitless": {
"version": "0.8.1",
"resolved": "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.8.1.tgz",
"integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==",
"license": "MIT"
},
"node_modules/styled-components/node_modules/postcss": {
"version": "8.4.49",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.49.tgz",
"integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/styled-components/node_modules/stylis": {
"version": "4.3.2",
"resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.3.2.tgz",
"integrity": "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==",
"license": "MIT"
},
"node_modules/styled-components/node_modules/tslib": {
"version": "2.6.2",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
"license": "0BSD"
},
"node_modules/stylis": { "node_modules/stylis": {
"version": "4.3.6", "version": "4.3.6",
"resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.3.6.tgz", "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.3.6.tgz",
@@ -9233,9 +9187,9 @@
} }
}, },
"node_modules/tar": { "node_modules/tar": {
"version": "7.5.15", "version": "7.5.9",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz",
"integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
@@ -9249,30 +9203,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/tesseract.js": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"bmp-js": "^0.1.0",
"idb-keyval": "^6.2.0",
"is-url": "^1.2.4",
"node-fetch": "^2.6.9",
"opencollective-postinstall": "^2.0.3",
"regenerator-runtime": "^0.13.3",
"tesseract.js-core": "^7.0.0",
"wasm-feature-detect": "^1.8.0",
"zlibjs": "^0.3.1"
}
},
"node_modules/tesseract.js-core": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
"license": "Apache-2.0"
},
"node_modules/text-encoding-utf-8": { "node_modules/text-encoding-utf-8": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmmirror.com/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz", "resolved": "https://registry.npmmirror.com/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz",
@@ -9324,9 +9254,9 @@
} }
}, },
"node_modules/tinyglobby/node_modules/picomatch": { "node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.4", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -9354,12 +9284,6 @@
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
@@ -9675,9 +9599,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.4.2", "version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
@@ -9798,9 +9722,9 @@
} }
}, },
"node_modules/vite/node_modules/picomatch": { "node_modules/vite/node_modules/picomatch": {
"version": "4.0.4", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -9815,34 +9739,12 @@
"integrity": "sha512-PYnngF+KHzZRBFI6qsm5PHwVNO5Lx3dYDrvNv//Ei9fvnYlaOWr9sKAriD/crlshWee9ZPsyvDMA4UnoR1LUHw==", "integrity": "sha512-PYnngF+KHzZRBFI6qsm5PHwVNO5Lx3dYDrvNv//Ei9fvnYlaOWr9sKAriD/crlshWee9ZPsyvDMA4UnoR1LUHw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/wasm-feature-detect": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-fetch": { "node_modules/whatwg-fetch": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmmirror.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz", "resolved": "https://registry.npmmirror.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz",
"integrity": "sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng==", "integrity": "sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -9963,9 +9865,9 @@
} }
}, },
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.8.4", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.1.tgz",
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
"license": "ISC", "license": "ISC",
"optional": true, "optional": true,
"peer": true, "peer": true,
@@ -9974,9 +9876,6 @@
}, },
"engines": { "engines": {
"node": ">= 14.6" "node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
} }
}, },
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
@@ -9991,15 +9890,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/zlibjs": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/zustand": { "node_modules/zustand": {
"version": "4.5.7", "version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,8 +14,7 @@ import DetachIconRaw from "@/assets/detach-icon.svg";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores"; import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
import useKeyboard from "@/hooks/useKeyboard"; import useKeyboard from "@/hooks/useKeyboard";
import useKeyboardLayout from "@/hooks/useKeyboardLayout"; import { keyDisplayMap, keyDisplayMap2, keys, modifiers, sKeyDisplayMap } from "@/keyboardMappings";
import { keyDisplayMap2, keys, modifiers, sKeyDisplayMap, latchingKeys } from "@/keyboardMappings";
import { dark_bg2_style} from "@/layout/theme_color"; import { dark_bg2_style} from "@/layout/theme_color";
import GoBottomSvg from "@/assets/second/gobottom.svg?react"; import GoBottomSvg from "@/assets/second/gobottom.svg?react";
@@ -34,15 +33,6 @@ function KeyboardWrapper() {
); );
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard(); 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 [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 }); const [position, setPosition] = useState({ x: 0, y: 0 });
@@ -270,7 +260,6 @@ function KeyboardWrapper() {
setIsCapsLockActive(false); setIsCapsLockActive(false);
} }
sendKeyboardEvent([keys["CapsLock"]], []); sendKeyboardEvent([keys["CapsLock"]], []);
setTimeout(resetKeyboardState, 100);
return; return;
} }
} }
@@ -848,7 +837,24 @@ function KeyboardWrapper() {
? [{ class: "modifier-locked", buttons: modifierLockButtons }] ? [{ class: "modifier-locked", buttons: modifierLockButtons }]
: [] : []
} }
layout={virtualKeyboardLayout.main} 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",
],
}}
disableButtonHold={true} disableButtonHold={true}
syncInstanceInputs={true} syncInstanceInputs={true}
debug={false} debug={false}
@@ -868,7 +874,10 @@ function KeyboardWrapper() {
layoutName={layoutName} layoutName={layoutName}
onKeyPress={onKeyDown} onKeyPress={onKeyDown}
display={keyDisplayMap} display={keyDisplayMap}
layout={virtualKeyboardLayout.control} layout={{
default: ["PrintScreen ScrollLock Pause", "Insert Home Pageup", "Delete End Pagedown"],
shift: ["(PrintScreen) ScrollLock (Pause)", "Insert Home Pageup", "Delete End Pagedown"],
}}
syncInstanceInputs={true} syncInstanceInputs={true}
debug={false} debug={false}
/> />
@@ -893,7 +902,7 @@ function KeyboardWrapper() {
onKeyPress={onKeyDown} onKeyPress={onKeyDown}
display={keyDisplayMap} display={keyDisplayMap}
layout={{ layout={{
default: [virtualKeyboardLayout.arrows?.default?.[0] || "ArrowUp"], default: ["ArrowUp"],
}} }}
syncInstanceInputs={true} syncInstanceInputs={true}
debug={false} debug={false}
@@ -907,7 +916,7 @@ function KeyboardWrapper() {
onKeyPress={onKeyDown} onKeyPress={onKeyDown}
display={keyDisplayMap} display={keyDisplayMap}
layout={{ layout={{
default: [virtualKeyboardLayout.arrows?.default?.[1] || "ArrowLeft ArrowDown ArrowRight"], default: ["ArrowLeft ArrowDown ArrowRight"],
}} }}
syncInstanceInputs={true} syncInstanceInputs={true}
debug={false} debug={false}

View File

@@ -38,8 +38,8 @@ const appendStatToMap = <T extends { timestamp: number }>(
}; };
// Constants and types // Constants and types
export type AvailableSidebarViews = "ConsoleLogViewer" | "MacroMoreList" | "Fullscreen" | "TerminalTabsMobile" | "SettingsModal" | "ClipboardMobile" | "KeyboardPanel" | "MousePanel" | "SettingsVideo" export type AvailableSidebarViews ="ConsoleLogViewer"|"MacroMoreList"|"Fullscreen"|"TerminalTabsMobile"|"SettingsModal"|"ClipboardMobile"|"KeyboardPanel"|"MousePanel"|"SettingsVideo"
| "connection-stats" | "Clipboard" | "PowerControl" | "Macros" | "VirtualMedia" | "SharedFolders" | "UsbEpModeSelect" | "UsbStatusPanel" | null; |"connection-stats"|"Clipboard"|"PowerControl"|"Macros"|"VirtualMedia"|"SharedFolders"|null;
export type AvailableTerminalTypes = "kvm" | "serial" | "none"; export type AvailableTerminalTypes = "kvm" | "serial" | "none";
export interface User { export interface User {
@@ -64,8 +64,6 @@ interface UIState {
disableVideoFocusTrap: boolean; disableVideoFocusTrap: boolean;
setDisableVideoFocusTrap: (enabled: boolean) => void; setDisableVideoFocusTrap: (enabled: boolean) => void;
isOcrMode: boolean;
setOcrMode: (enabled: boolean) => void;
isWakeOnLanModalVisible: boolean; isWakeOnLanModalVisible: boolean;
setWakeOnLanModalVisibility: (enabled: boolean) => void; setWakeOnLanModalVisibility: (enabled: boolean) => void;
@@ -93,8 +91,6 @@ export const useUiStore = create<UIState>(set => ({
disableVideoFocusTrap: false, disableVideoFocusTrap: false,
setDisableVideoFocusTrap: enabled => set({ disableVideoFocusTrap: enabled }), setDisableVideoFocusTrap: enabled => set({ disableVideoFocusTrap: enabled }),
isOcrMode: false,
setOcrMode: enabled => set({ isOcrMode: enabled }),
isAnimationComplete: false, isAnimationComplete: false,
setIsAnimationComplete: enabled => set({ isAnimationComplete: enabled }), setIsAnimationComplete: enabled => set({ isAnimationComplete: enabled }),
@@ -390,14 +386,6 @@ interface SettingsState {
overrideCtrlV: boolean; overrideCtrlV: boolean;
setOverrideCtrlV: (enabled: boolean) => void; 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 // Video enhancement settings
videoSaturation: number; videoSaturation: number;
@@ -467,14 +455,6 @@ export const useSettingsStore = create(
overrideCtrlV: false, overrideCtrlV: false,
setOverrideCtrlV: enabled => set({ overrideCtrlV: enabled }), 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) // Video enhancement settings with default values (1.0 = normal)
videoSaturation: 1.0, videoSaturation: 1.0,
@@ -586,13 +566,8 @@ export interface HidState {
keyboardLedStateSyncAvailable: boolean; keyboardLedStateSyncAvailable: boolean;
setKeyboardLedStateSyncAvailable: (available: boolean) => void; setKeyboardLedStateSyncAvailable: (available: boolean) => void;
keysDownState?: { modifier: number; keys: number[] };
setKeysDownState: (state: { modifier: number; keys: number[] }) => void;
isVirtualKeyboardEnabled: boolean; isVirtualKeyboardEnabled: boolean;
setVirtualKeyboardEnabled: (enabled: boolean) => void; setVirtualKeyboardEnabled: (enabled: boolean) => void;
allowTapToOpenVirtualKeyboard: boolean;
setAllowTapToOpenVirtualKeyboard: (enabled: boolean) => void;
isPasteModeEnabled: boolean; isPasteModeEnabled: boolean;
setPasteModeEnabled: (enabled: boolean) => void; setPasteModeEnabled: (enabled: boolean) => void;
@@ -647,16 +622,11 @@ export const useHidStore = create<HidState>((set, get) => ({
set({ keyboardLedState }); set({ keyboardLedState });
}, },
keysDownState: undefined,
setKeysDownState: state => set({ keysDownState: state }),
keyboardLedStateSyncAvailable: false, keyboardLedStateSyncAvailable: false,
setKeyboardLedStateSyncAvailable: available => set({ keyboardLedStateSyncAvailable: available }), setKeyboardLedStateSyncAvailable: available => set({ keyboardLedStateSyncAvailable: available }),
isVirtualKeyboardEnabled: false, isVirtualKeyboardEnabled: false,
setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }), setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }),
allowTapToOpenVirtualKeyboard: true,
setAllowTapToOpenVirtualKeyboard: enabled => set({ allowTapToOpenVirtualKeyboard: enabled }),
isPasteModeEnabled: false, isPasteModeEnabled: false,
setPasteModeEnabled: enabled => set({ isPasteModeEnabled: enabled }), setPasteModeEnabled: enabled => set({ isPasteModeEnabled: enabled }),
@@ -729,13 +699,6 @@ export interface UpdateState {
systemUpdateProgress: number; systemUpdateProgress: number;
systemUpdatedAt: string | null; systemUpdatedAt: string | null;
appSignatureMissing: boolean;
systemSignatureMissing: boolean;
appSignatureAbsent: boolean;
appSignatureInvalid: boolean;
appNoPublicKey: boolean;
signatureVerified: boolean;
}; };
setOtaState: (state: UpdateState["otaState"]) => void; setOtaState: (state: UpdateState["otaState"]) => void;
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void; setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
@@ -784,12 +747,6 @@ export const useUpdateStore = create<UpdateState>(set => ({
appUpdatedAt: null, appUpdatedAt: null,
systemUpdateProgress: 0, systemUpdateProgress: 0,
systemUpdatedAt: null, systemUpdatedAt: null,
appSignatureMissing: false,
systemSignatureMissing: false,
appSignatureAbsent: false,
appSignatureInvalid: false,
appNoPublicKey: false,
signatureVerified: false,
}, },
updateDialogHasBeenMinimized: false, updateDialogHasBeenMinimized: false,
@@ -939,9 +896,6 @@ export interface IPv4StaticConfig {
export interface NetworkSettings { export interface NetworkSettings {
hostname: string; hostname: string;
domain: string; domain: string;
http_proxy?: string;
https_proxy?: string;
all_proxy?: string;
ipv4_mode: IPv4Mode; ipv4_mode: IPv4Mode;
ipv4_request_address?: string; ipv4_request_address?: string;
ipv4_static?: IPv4StaticConfig; ipv4_static?: IPv4StaticConfig;

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback } from "react";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { useHidStore, useRTCStore, useSettingsStore } from "@/hooks/stores"; import { useHidStore, useRTCStore, useSettingsStore } from "@/hooks/stores";
@@ -16,10 +16,6 @@ export default function useKeyboard() {
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget); const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
const usbState = useHidStore(state => state.usbState); 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( const sendKeyboardEvent = useCallback(
(keys: number[], modifiers: number[]) => { (keys: number[], modifiers: number[]) => {
if (!forceHttp && rpcDataChannel?.readyState !== "open") return; if (!forceHttp && rpcDataChannel?.readyState !== "open") return;
@@ -28,7 +24,6 @@ export default function useKeyboard() {
if (usbState !== "configured") return; if (usbState !== "configured") return;
const accModifier = modifiers.reduce((acc, val) => acc + val, 0); const accModifier = modifiers.reduce((acc, val) => acc + val, 0);
// Fallback to JSON-RPC
send("keyboardReport", { keys, modifier: accModifier }, resp => { send("keyboardReport", { keys, modifier: accModifier }, resp => {
if ("error" in resp) { if ("error" in resp) {
const msg = (resp.error.data as string) || resp.error.message || ""; const msg = (resp.error.data as string) || resp.error.message || "";
@@ -44,37 +39,10 @@ export default function useKeyboard() {
[forceHttp, rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers, isReinitializingGadget, usbState], [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(() => { const resetKeyboardState = useCallback(() => {
// Release all held keys
sendKeyboardEvent([], []); sendKeyboardEvent([], []);
heldKeysRef.current.clear();
if (keepaliveIntervalRef.current) {
clearInterval(keepaliveIntervalRef.current);
keepaliveIntervalRef.current = null;
}
}, [sendKeyboardEvent]); }, [sendKeyboardEvent]);
// Cleanup on unmount
useEffect(() => {
return () => {
resetKeyboardState();
};
}, [resetKeyboardState]);
const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => { const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => {
for (const [index, step] of steps.entries()) { for (const [index, step] of steps.entries()) {
const keyValues = step.keys?.map(key => keys[key]).filter(Boolean) || []; const keyValues = step.keys?.map(key => keys[key]).filter(Boolean) || [];
@@ -98,5 +66,5 @@ export default function useKeyboard() {
} }
}; };
return { sendKeyboardEvent, sendKeypress, resetKeyboardState, executeMacro }; return { sendKeyboardEvent, resetKeyboardState, executeMacro };
} }

View File

@@ -1,29 +0,0 @@
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,85 +1,45 @@
export interface KeyStroke { import { chars as chars_fr_BE, name as name_fr_BE } from "@/keyboardLayouts/fr_BE"
modifier: number; import { chars as chars_cs_CZ, name as name_cs_CZ } from "@/keyboardLayouts/cs_CZ"
keys: number[]; 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 KeyInfo { export const chars: Record<string, Record<string, KeyCombo>> = {
key: string | number; be_FR: chars_fr_BE,
shift?: boolean; cs_CZ: chars_cs_CZ,
altRight?: boolean; en_UK: chars_en_UK,
} en_US: chars_en_US,
fr_FR: chars_fr_FR,
export interface KeyCombo extends KeyInfo { de_DE: chars_de_DE,
deadKey?: boolean; it_IT: chars_it_IT,
accentKey?: KeyInfo; nb_NO: chars_nb_NO,
} es_ES: chars_es_ES,
sv_SE: chars_sv_SE,
export interface KeyboardLayout { fr_CH: chars_fr_CH,
isoCode: string; de_CH: chars_de_CH,
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,8 +1,6 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" import { KeyCombo } from "../keyboardLayouts"
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
const name = "Čeština"; export const name = "Čeština";
const isoCode = "cs-CZ";
const keyTrema = { key: "Backslash" } // tréma (umlaut), two dots placed above a vowel 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 const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
@@ -99,11 +97,11 @@ export const chars = {
"Ẇ": { key: "KeyW", shift: true, accentKey: keyOverdot }, "Ẇ": { key: "KeyW", shift: true, accentKey: keyOverdot },
X: { key: "KeyX", shift: true }, X: { key: "KeyX", shift: true },
"Ẋ": { key: "KeyX", shift: true, accentKey: keyOverdot }, "Ẋ": { key: "KeyX", shift: true, accentKey: keyOverdot },
Y: { key: "KeyZ", shift: true }, Y: { key: "KeyY", shift: true },
"Ý": { key: "KeyZ", shift: true, accentKey: keyAcute }, "Ý": { key: "KeyY", shift: true, accentKey: keyAcute },
"Ẏ": { key: "KeyZ", shift: true, accentKey: keyOverdot }, "Ẏ": { key: "KeyY", shift: true, accentKey: keyOverdot },
Z: { key: "KeyY", shift: true }, Z: { key: "KeyZ", shift: true },
"Ż": { key: "KeyY", shift: true, accentKey: keyOverdot }, "Ż": { key: "KeyZ", shift: true, accentKey: keyOverdot },
a: { key: "KeyA" }, a: { key: "KeyA" },
"ä": { key: "KeyA", accentKey: keyTrema }, "ä": { key: "KeyA", accentKey: keyTrema },
"â": { key: "KeyA", accentKey: keyHat }, "â": { key: "KeyA", accentKey: keyHat },
@@ -191,10 +189,10 @@ export const chars = {
x: { key: "KeyX" }, x: { key: "KeyX" },
"#": { key: "KeyX", altRight: true }, "#": { key: "KeyX", altRight: true },
"ẋ": { key: "KeyX", accentKey: keyOverdot }, "ẋ": { key: "KeyX", accentKey: keyOverdot },
y: { key: "KeyZ" }, y: { key: "KeyY" },
"ẏ": { key: "KeyZ", accentKey: keyOverdot }, "ẏ": { key: "KeyY", accentKey: keyOverdot },
z: { key: "KeyY" }, z: { key: "KeyZ" },
"ż": { key: "KeyY", accentKey: keyOverdot }, "ż": { key: "KeyZ", accentKey: keyOverdot },
";": { key: "Backquote" }, ";": { key: "Backquote" },
"°": { key: "Backquote", shift: true, deadKey: true }, "°": { key: "Backquote", shift: true, deadKey: true },
"+": { key: "Digit1" }, "+": { key: "Digit1" },
@@ -244,20 +242,3 @@ export const chars = {
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, Tab: { key: "Tab" },
} as Record<string, KeyCombo>; } 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

@@ -1,186 +0,0 @@
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,9 +1,6 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" import { KeyCombo } from "../keyboardLayouts"
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
export { keyDisplayMap } from "./en_US";
const name = "Schwiizerdütsch"; export const name = "Schwiizerdütsch";
const isoCode = "de-CH";
const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel 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 const keyAcute = { key: "Minus", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
@@ -166,20 +163,3 @@ export const chars = {
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, Tab: { key: "Tab" },
} as Record<string, KeyCombo>; } 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,8 +1,6 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" import { KeyCombo } from "../keyboardLayouts"
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
const name = "Deutsch"; export const name = "Deutsch";
const isoCode = "de-DE";
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter 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 const keyHat = { key: "Backquote" } // accent circonflexe (accent hat), mark ^ placed above the letter
@@ -152,20 +150,3 @@ export const chars = {
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, Tab: { key: "Tab" },
} as Record<string, KeyCombo>; } 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,8 +1,6 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" import { KeyCombo } from "../keyboardLayouts"
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
const name = "English (UK)"; export const name = "English (UK)";
const isoCode = "en-GB";
export const chars = { export const chars = {
A: { key: "KeyA", shift: true }, A: { key: "KeyA", shift: true },
@@ -106,13 +104,4 @@ export const chars = {
"\n": { key: "Enter" }, "\n": { key: "Enter" },
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, 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,7 +1,6 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" import { KeyCombo } from "../keyboardLayouts"
const name = "English (US)"; export const name = "English (US)";
const isoCode = "en-US";
export const chars = { export const chars = {
A: { key: "KeyA", shift: true }, A: { key: "KeyA", shift: true },
@@ -90,283 +89,25 @@ export const chars = {
">": { key: "Period", shift: true }, ">": { key: "Period", shift: true },
";": { key: "Semicolon" }, ";": { key: "Semicolon" },
":": { key: "Semicolon", shift: true }, ":": { key: "Semicolon", shift: true },
"¶": { key: "Semicolon", altRight: true }, // pilcrow sign
"[": { key: "BracketLeft" }, "[": { key: "BracketLeft" },
"{": { key: "BracketLeft", shift: true }, "{": { key: "BracketLeft", shift: true },
"«": { key: "BracketLeft", altRight: true }, // double left quote sign
"]": { key: "BracketRight" }, "]": { key: "BracketRight" },
"}": { key: "BracketRight", shift: true }, "}": { key: "BracketRight", shift: true },
"»": { key: "BracketRight", altRight: true }, // double right quote sign
"\\": { key: "Backslash" }, "\\": { key: "Backslash" },
"|": { key: "Backslash", shift: true }, "|": { key: "Backslash", shift: true },
"¬": { key: "Backslash", altRight: true }, // not sign
"`": { key: "Backquote" }, "`": { key: "Backquote" },
"~": { key: "Backquote", shift: true }, "~": { key: "Backquote", shift: true },
"§": { key: "IntlBackslash" }, "§": { key: "IntlBackslash" },
"±": { key: "IntlBackslash", shift: true }, "±": { key: "IntlBackslash", shift: true },
" ": { key: "Space" }, " ": { key: "Space", shift: false },
"\n": { key: "Enter" }, "\n": { key: "Enter", shift: false },
Enter: { key: "Enter" }, Enter: { key: "Enter", shift: false },
Escape: { key: "Escape" }, Tab: { key: "Tab", shift: false },
Tab: { key: "Tab" }, PrintScreen: { key: "Prt Sc", shift: false },
PrintScreen: { key: "Prt Sc" },
SystemRequest: { key: "Prt Sc", shift: true }, SystemRequest: { key: "Prt Sc", shift: true },
ScrollLock: { key: "ScrollLock" }, ScrollLock: { key: "ScrollLock", shift: false},
Pause: { key: "Pause" }, Pause: { key: "Pause", shift: false },
Break: { key: "Pause", shift: true }, Break: { key: "Pause", shift: true },
Insert: { key: "Insert" }, Insert: { key: "Insert", shift: false },
Delete: { key: "Delete" }, Delete: { key: "Delete", shift: false },
} as Record<string, KeyCombo>; } 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,8 +1,6 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" import { KeyCombo } from "../keyboardLayouts"
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
const name = "Español"; export const name = "Español";
const isoCode = "es-ES";
const keyTrema = { key: "Quote", shift: true } // tréma (umlaut), two dots placed above a vowel 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 const keyAcute = { key: "Quote" } // accent aigu (acute accent), mark ´ placed above the letter
@@ -168,12 +166,3 @@ export const chars = {
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, Tab: { key: "Tab" },
} as Record<string, KeyCombo>; } as Record<string, KeyCombo>;
export const es_ES: KeyboardLayout = {
isoCode,
name,
chars,
keyDisplayMap,
modifierDisplayMap,
virtualKeyboard,
};

View File

@@ -1,8 +1,6 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" import { KeyCombo } from "../keyboardLayouts"
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
const name = "Belgisch Nederlands"; export const name = "Belgisch Nederlands";
const isoCode = "fr-BE";
const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel 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 const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
@@ -58,10 +56,10 @@ export const chars = {
"Ù": { key: "KeyU", shift: true, accentKey: keyGrave }, "Ù": { key: "KeyU", shift: true, accentKey: keyGrave },
"Ũ": { key: "KeyU", shift: true, accentKey: keyTilde }, "Ũ": { key: "KeyU", shift: true, accentKey: keyTilde },
V: { key: "KeyV", shift: true }, V: { key: "KeyV", shift: true },
W: { key: "KeyZ", shift: true }, W: { key: "KeyW", shift: true },
X: { key: "KeyX", shift: true }, X: { key: "KeyX", shift: true },
Y: { key: "KeyY", shift: true }, Y: { key: "KeyZ", shift: true },
Z: { key: "KeyW", shift: true }, Z: { key: "KeyY", shift: true },
a: { key: "KeyQ" }, a: { key: "KeyQ" },
"ä": { key: "KeyQ", accentKey: keyTrema }, "ä": { key: "KeyQ", accentKey: keyTrema },
"â": { key: "KeyQ", accentKey: keyHat }, "â": { key: "KeyQ", accentKey: keyHat },
@@ -106,10 +104,10 @@ export const chars = {
"ú": { key: "KeyU", accentKey: keyAcute }, "ú": { key: "KeyU", accentKey: keyAcute },
"ũ": { key: "KeyU", accentKey: keyTilde }, "ũ": { key: "KeyU", accentKey: keyTilde },
v: { key: "KeyV" }, v: { key: "KeyV" },
w: { key: "KeyZ" }, w: { key: "KeyW" },
x: { key: "KeyX" }, x: { key: "KeyX" },
y: { key: "KeyY" }, y: { key: "KeyZ" },
z: { key: "KeyW" }, z: { key: "KeyY" },
"²": { key: "Backquote" }, "²": { key: "Backquote" },
"³": { key: "Backquote", shift: true }, "³": { key: "Backquote", shift: true },
"&": { key: "Digit1" }, "&": { key: "Digit1" },
@@ -167,28 +165,3 @@ export const chars = {
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, Tab: { key: "Tab" },
} as Record<string, KeyCombo>; } 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,9 +1,8 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" import { KeyCombo } from "../keyboardLayouts"
import { chars as chars_de_CH, de_CH_keyDisplayMap } from "./de_CH"
import { modifierDisplayMap, virtualKeyboard } from "./en_US"
const name = "Français de Suisse"; import { chars as chars_de_CH } from "./de_CH"
const isoCode = "fr-CH";
export const name = "Français de Suisse";
export const chars = { export const chars = {
...chars_de_CH, ...chars_de_CH,
@@ -14,22 +13,3 @@ export const chars = {
"à": { key: "Quote" }, "à": { key: "Quote" },
"ä": { key: "Quote", shift: true }, "ä": { key: "Quote", shift: true },
} as Record<string, KeyCombo>; } 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,8 +1,6 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" import { KeyCombo } from "../keyboardLayouts"
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
const name = "Français"; export const name = "Français";
const isoCode = "fr-FR";
const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel 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 const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
@@ -139,28 +137,3 @@ export const chars = {
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, Tab: { key: "Tab" },
} as Record<string, KeyCombo>; } 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

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

View File

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

View File

@@ -1,40 +0,0 @@
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

@@ -1,209 +0,0 @@
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

@@ -1,171 +0,0 @@
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

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

View File

@@ -105,11 +105,6 @@ export const keys = {
Space: 0x2c, Space: 0x2c,
SystemRequest: 0x9a, SystemRequest: 0x9a,
Tab: 0x2b, Tab: 0x2b,
Yen: 0x89,
KeyRO: 0x87,
Henkan: 0x8a,
Muhenkan: 0x8b,
KatakanaHiragana: 0x88,
} as Record<string, number>; } as Record<string, number>;
export const modifiers = { export const modifiers = {
@@ -233,17 +228,6 @@ 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> = { export const keyDisplayMap2: Record<string, string> = {
...keyDisplayMap, ...keyDisplayMap,
...{ ...{

View File

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

View File

@@ -1,79 +0,0 @@
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

@@ -23,7 +23,6 @@ import { LogDialog } from "@components/LogDialog";
import { Dialog } from "@/layout/components_setting/access/auth"; import { Dialog } from "@/layout/components_setting/access/auth";
import AutoHeight from "@components/AutoHeight"; import AutoHeight from "@components/AutoHeight";
import FirewallSettings from "./FirewallSettings"; import FirewallSettings from "./FirewallSettings";
import WebRtcServersSettings from "./WebRtcServers";
export interface TailScaleResponse { export interface TailScaleResponse {
state: string; state: string;
@@ -71,47 +70,6 @@ export interface CloudflaredRunningResponse {
running: boolean; 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 { export interface WireguardStatus {
running: boolean; running: boolean;
} }
@@ -239,20 +197,6 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
const [cloudflaredLog, setCloudflaredLog] = useState<string>(""); const [cloudflaredLog, setCloudflaredLog] = useState<string>("");
const [showCloudflaredLogModal, setShowCloudflaredLogModal] = useState(false); 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(() => { const getTLSState = useCallback(() => {
send("getTLSState", {}, resp => { send("getTLSState", {}, resp => {
@@ -337,156 +281,10 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
}); });
}, [send]); }, [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(() => { useEffect(() => {
getCloudflaredStatus(); getCloudflaredStatus();
}, [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 // Handle TLS mode change
const handleTlsModeChange = (value: string) => { const handleTlsModeChange = (value: string) => {
setTlsMode(value); setTlsMode(value);
@@ -1071,154 +869,6 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
getVntConfigFile(); getVntConfigFile();
}, [getVntStatus, getVntConfig]); }, [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 ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -1347,20 +997,6 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
</> </>
)} )}
<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"> <div className="space-y-4">
<SettingsSectionHeader <SettingsSectionHeader
title={$at("Remote")} title={$at("Remote")}
@@ -1656,7 +1292,6 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
<GridCard> <GridCard>
<div className="p-4"> <div className="p-4">
<div className="space-y-4"> <div className="space-y-4">
{renderVpnToolManager("easytier", "EasyTier")}
{ easyTierRunningStatus.running ? ( { easyTierRunningStatus.running ? (
<div className="flex-1 space-y-2"> <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"> <div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
@@ -1774,7 +1409,6 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
<GridCard> <GridCard>
<div className="p-4"> <div className="p-4">
<div className="space-y-4"> <div className="space-y-4">
{renderVpnToolManager("vnt", "Vnt")}
{ vntRunningStatus.running ? ( { vntRunningStatus.running ? (
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
@@ -1987,7 +1621,6 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
<GridCard> <GridCard>
<div className="p-4"> <div className="p-4">
<div className="space-y-4"> <div className="space-y-4">
{renderVpnToolManager("cloudflared", "Cloudflare")}
{cloudflaredRunningStatus.running ? ( {cloudflaredRunningStatus.running ? (
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
<Button <Button
@@ -2036,7 +1669,6 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
<GridCard> <GridCard>
<div className="p-4"> <div className="p-4">
<div className="space-y-4"> <div className="space-y-4">
{renderVpnToolManager("frpc", "frpc")}
<TextAreaWithLabel <TextAreaWithLabel
label={$at("Edit frpc.toml")} label={$at("Edit frpc.toml")}
placeholder={$at("Enter frpc configuration")} placeholder={$at("Enter frpc configuration")}

View File

@@ -1,159 +0,0 @@
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

@@ -28,9 +28,6 @@ export default function SettingsAdvanced() {
const [configContent, setConfigContent] = useState(""); const [configContent, setConfigContent] = useState("");
const [isSavingConfig, setIsSavingConfig] = useState(false); const [isSavingConfig, setIsSavingConfig] = useState(false);
const [localLoopbackOnly, setLocalLoopbackOnly] = 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 settings = useSettingsStore();
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget); const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
@@ -56,13 +53,6 @@ export default function SettingsAdvanced() {
if ("error" in resp) return; if ("error" in resp) return;
setLocalLoopbackOnly(resp.result as boolean); 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]); }, [send, setDeveloperMode]);
const getUsbEmulationState = useCallback(() => { const getUsbEmulationState = useCallback(() => {
@@ -118,54 +108,6 @@ export default function SettingsAdvanced() {
}); });
}, [send]); }, [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(() => { const handleUpdateSSHKey = useCallback(() => {
send("setSSHKeyState", { sshKey }, resp => { send("setSSHKeyState", { sshKey }, resp => {
if ("error" in resp) { if ("error" in resp) {
@@ -298,42 +240,6 @@ export default function SettingsAdvanced() {
</div> </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 <SettingsItem
title={$at("Force HTTP Transmission")} title={$at("Force HTTP Transmission")}
badge="Experimental" badge="Experimental"
@@ -473,28 +379,6 @@ export default function SettingsAdvanced() {
onConfirm={confirmLoopbackModeEnable} 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 <ConfirmDialog
open={showRebootConfirm} open={showRebootConfirm}
onClose={() => { onClose={() => {

View File

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

View File

@@ -39,9 +39,6 @@ dayjs.extend(relativeTime);
const defaultNetworkSettings: NetworkSettings = { const defaultNetworkSettings: NetworkSettings = {
hostname: "", hostname: "",
domain: "", domain: "",
http_proxy: "",
https_proxy: "",
all_proxy: "",
ipv4_mode: "unknown", ipv4_mode: "unknown",
ipv6_mode: "unknown", ipv6_mode: "unknown",
lldp_mode: "unknown", lldp_mode: "unknown",
@@ -310,10 +307,6 @@ export default function SettingsNetwork() {
setNetworkSettings({ ...networkSettings, domain: value }); setNetworkSettings({ ...networkSettings, domain: value });
}; };
const handleProxyChange = (field: "http_proxy" | "https_proxy" | "all_proxy", value: string) => {
setNetworkSettings({ ...networkSettings, [field]: value });
};
const handleDomainOptionChange = (value: string) => { const handleDomainOptionChange = (value: string) => {
setSelectedDomainOption(value); setSelectedDomainOption(value);
if (value !== "custom") { if (value !== "custom") {
@@ -544,47 +537,6 @@ export default function SettingsNetwork() {
</SettingsItem> </SettingsItem>
</div> </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 <AntdButton
type="primary" type="primary"
disabled={ disabled={

View File

@@ -6,7 +6,6 @@ import { CheckCircleIcon } from "@heroicons/react/20/solid";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useBootStorageType } from "@/hooks/useBootStorage";
import { SettingsPageHeader } from "@components/Settings/SettingsPageheader"; import { SettingsPageHeader } from "@components/Settings/SettingsPageheader";
import { SettingsItem } from "@components/Settings/SettingsView"; import { SettingsItem } from "@components/Settings/SettingsView";
import Card from "@components/Card"; import Card from "@components/Card";
@@ -22,15 +21,6 @@ export interface SystemVersionInfo {
remote?: { appVersion: string; systemVersion: string }; remote?: { appVersion: string; systemVersion: string };
systemUpdateAvailable: boolean; systemUpdateAvailable: boolean;
appUpdateAvailable: boolean; appUpdateAvailable: boolean;
appSignatureMissing?: boolean;
systemSignatureMissing?: boolean;
appSignatureAbsent?: boolean;
systemSignatureAbsent?: boolean;
appSignatureInvalid?: boolean;
systemSignatureInvalid?: boolean;
appNoPublicKey?: boolean;
systemNoPublicKey?: boolean;
signatureVerified?: boolean;
error?: string; error?: string;
} }
@@ -44,16 +34,7 @@ export default function SettingsVersion() {
const [autoUpdate, setAutoUpdate] = useState(true); const [autoUpdate, setAutoUpdate] = useState(true);
const { $at } = useReactAt(); const { $at } = useReactAt();
const { setModalView, otaState } = useUpdateStore(); const { setModalView, otaState } = useUpdateStore();
const { bootStorageType } = useBootStorageType();
const isBootFromSD = bootStorageType === "sd";
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false); 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 updatePanelRef = useRef<HTMLDivElement | null>(null);
const [updateSource, setUpdateSource] = useState("github"); const [updateSource, setUpdateSource] = useState("github");
const [customUpdateBaseURL, setCustomUpdateBaseURL] = useState(""); const [customUpdateBaseURL, setCustomUpdateBaseURL] = useState("");
@@ -98,23 +79,6 @@ 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( const applyUpdateSource = useCallback(
(source: string) => { (source: string) => {
send("setUpdateSource", { source }, resp => { send("setUpdateSource", { source }, resp => {
@@ -238,28 +202,19 @@ export default function SettingsVersion() {
} }
/> />
<SignatureStatusCard <UpdateSourceSettings
signatureStatus={signatureStatus} updateSource={updateSource}
signatureStatusLoading={signatureStatusLoading} onUpdateSourceChange={applyUpdateSource}
customUpdateBaseURL={customUpdateBaseURL}
onCustomUpdateBaseURLChange={setCustomUpdateBaseURL}
onSaveCustomUpdateBaseURL={applyCustomUpdateBaseURL}
/> />
{!isBootFromSD && ( <div className="flex items-center justify-start">
<> <AntdButton type="primary" onClick={checkForUpdates} className={isMobile ? "w-full" : ""}>
<UpdateSourceSettings {$at("Check for Updates")}
updateSource={updateSource} </AntdButton>
onUpdateSourceChange={applyUpdateSource} </div>
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"> <div className="hidden">
<SettingsItem <SettingsItem
@@ -420,7 +375,6 @@ function UpdateContent({
<SystemUpToDateState <SystemUpToDateState
checkUpdate={() => setModalView("loading")} checkUpdate={() => setModalView("loading")}
onClose={onClose} onClose={onClose}
versionInfo={versionInfo}
/> />
)} )}
@@ -446,58 +400,23 @@ function LoadingState({
const getVersionInfo = useCallback(() => { const getVersionInfo = useCallback(() => {
return new Promise<SystemVersionInfo>((resolve, reject) => { return new Promise<SystemVersionInfo>((resolve, reject) => {
Promise.all([ send("getUpdateStatus", {}, async resp => {
new Promise<SystemVersionInfo>((res, rej) => { if ("error" in resp) {
send("getUpdateStatus", {}, resp => { notifications.error(`Failed to check for updates: ${resp.error}`);
if ("error" in resp) { reject(new Error("Failed to check for updates"));
notifications.error(`Failed to check for updates: ${resp.error}`); } else {
rej(new Error("Failed to check for updates")); const result = resp.result as SystemVersionInfo;
} else { setAppVersion(result.local.appVersion);
const result = resp.result as SystemVersionInfo; setSystemVersion(result.local.systemVersion);
setAppVersion(result.local.appVersion);
setSystemVersion(result.local.systemVersion); if (result.error) {
if (result.error) { notifications.error(`Failed to check for updates: ${result.error}`);
notifications.error(`Failed to check for updates: ${result.error}`); reject(new Error("Failed to check for updates"));
rej(new Error("Failed to check for updates")); } else {
} else { resolve(result);
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]); }, [send, setAppVersion, setSystemVersion]);
@@ -743,50 +662,11 @@ function UpdatingDeviceState({
function SystemUpToDateState({ function SystemUpToDateState({
checkUpdate, checkUpdate,
onClose, onClose,
versionInfo,
}: { }: {
checkUpdate: () => void; checkUpdate: () => void;
onClose: () => void; onClose: () => void;
versionInfo: SystemVersionInfo | null;
}) { }) {
const { $at } = useReactAt(); 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 ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left"> <div className="text-left">
@@ -797,39 +677,6 @@ function SystemUpToDateState({
{$at("Your system is running the latest version. No updates are currently available.")} {$at("Your system is running the latest version. No updates are currently available.")}
</p> </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"> <div className="mt-4 flex gap-x-2">
<AntdButton type="primary" onClick={checkUpdate}> <AntdButton type="primary" onClick={checkUpdate}>
{$at("Check Again")} {$at("Check Again")}
@@ -838,32 +685,6 @@ function SystemUpToDateState({
{$at("Back")} {$at("Back")}
</AntdButton> </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> </div>
); );
@@ -887,40 +708,6 @@ function UpdateAvailableState({
onSaveUpdateDownloadProxy: () => void; onSaveUpdateDownloadProxy: () => void;
}) { }) {
const { $at } = useReactAt(); 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 ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="w-full space-y-4"> <div className="w-full space-y-4">
@@ -970,32 +757,6 @@ function UpdateAvailableState({
</AntdButton> </AntdButton>
</div> </div>
</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> </div>
</div> </div>
@@ -1057,101 +818,3 @@ function UpdateErrorState({
</div> </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,18 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ExclamationCircleIcon } from "@heroicons/react/16/solid"; import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
import { useClose } from "@headlessui/react"; import { useClose } from "@headlessui/react";
import { Checkbox, Button, Input } from "antd"; import { Checkbox, Button } from "antd";
import { useReactAt } from "i18n-auto-extractor/react"; import { useReactAt } from "i18n-auto-extractor/react";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { TextAreaWithLabel } from "@components/TextArea"; import { TextAreaWithLabel } from "@components/TextArea";
import { SettingsItem } from "@components/Settings/SettingsView";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useHidStore, useRTCStore, useUiStore, useSettingsStore, useVideoStore } from "@/hooks/stores"; import { useHidStore, useRTCStore, useUiStore, useSettingsStore } from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings"; import { keys, modifiers } from "@/keyboardMappings";
import { layouts, chars } from "@/keyboardLayouts"; import { layouts, chars } from "@/keyboardLayouts";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { eventMatchesShortcut, shortcutFromKeyboardEvent } from "@/utils/shortcuts";
const hidKeyboardPayload = (keys: number[], modifier: number) => { const hidKeyboardPayload = (keys: number[], modifier: number) => {
return { keys, modifier }; return { keys, modifier };
@@ -30,24 +28,15 @@ export default function Clipboard() {
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const setSidebarView = useUiStore(state => state.setSidebarView); const setSidebarView = useUiStore(state => state.setSidebarView);
const toggleTopBarView = useUiStore(state => state.toggleTopBarView); 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 isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
const videoWidth = useVideoStore(state => state.width);
const videoHeight = useVideoStore(state => state.height);
const [send] = useJsonRpc(); const [send] = useJsonRpc();
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const [invalidChars, setInvalidChars] = useState<string[]>([]); const [invalidChars, setInvalidChars] = useState<string[]>([]);
const close = useClose(); const close = useClose();
const pasteShortcutEnabled = useSettingsStore(state => state.pasteShortcutEnabled); const overrideCtrlV = useSettingsStore(state => state.overrideCtrlV);
const setPasteShortcutEnabled = useSettingsStore(state => state.setPasteShortcutEnabled); const setOverrideCtrlV = useSettingsStore(state => state.setOverrideCtrlV);
const pasteShortcut = useSettingsStore(state => state.pasteShortcut); const [pasteBuffer, setPasteBuffer] = useState<string>("");
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); const [readyToRender, setReadyToRender] = useState(false);
useEffect(() => { useEffect(() => {
@@ -138,6 +127,7 @@ export default function Clipboard() {
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, safeKeyboardLayout]); }, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, safeKeyboardLayout]);
const handleTextSend = useCallback(async (text: string) => { const handleTextSend = useCallback(async (text: string) => {
setPasteBuffer(text);
const segInvalid = [ const segInvalid = [
...new Set( ...new Set(
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments // @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
@@ -202,35 +192,12 @@ export default function Clipboard() {
}, [handleTextSend]); }, [handleTextSend]);
useEffect(() => { useEffect(() => {
if (readyToRender && TextAreaRef.current) { // 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) {
TextAreaRef.current.focus(); TextAreaRef.current.focus();
} }
}, [readyToRender]); }, [readyToRender, overrideCtrlV]);
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 ( return (
<div className="space-y-4 py-3" > <div className="space-y-4 py-3" >
@@ -238,42 +205,32 @@ export default function Clipboard() {
<div className="h-full space-y-4"> <div className="h-full space-y-4">
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-[minmax(0,1fr)_140px] items-center gap-2 sm:grid-cols-[minmax(0,1fr)_180px]"> <div className="flex items-center">
<Checkbox <Checkbox
className="min-w-0" checked={overrideCtrlV}
checked={pasteShortcutEnabled} onChange={e => setOverrideCtrlV(e.target.checked)}
onChange={e => setPasteShortcutEnabled(e.target.checked)} >
> {$at("Use Ctrl+V to paste clipboard to remote")}
<span className="whitespace-normal break-words"> </Checkbox>
{$at("Enable paste shortcut")}
</span>
</Checkbox>
<Input
size="small"
value={pasteShortcut}
onKeyDown={handleShortcutInput(setPasteShortcut)}
onChange={() => void 0}
className="w-full"
/>
</div> </div>
<div className="w-full px-1 outline-none" <div className="w-full px-1 outline-none"
tabIndex={pasteShortcutEnabled ? 0 : -1} tabIndex={overrideCtrlV ? 0 : -1}
ref={(el) => { ref={(el) => {
if (el && pasteShortcutEnabled && readyToRender) { if (el && overrideCtrlV && readyToRender) {
el.focus(); el.focus();
} }
}} }}
onKeyUp={e => e.stopPropagation()} onKeyUp={e => e.stopPropagation()}
onKeyDown={e => { onKeyDown={e => {
e.stopPropagation(); e.stopPropagation();
if (pasteShortcutEnabled && eventMatchesShortcut(e.nativeEvent, pasteShortcut)) { if (overrideCtrlV && (e.key.toLowerCase() === "v" || e.code === "KeyV") && (e.metaKey || e.ctrlKey)) {
e.preventDefault(); e.preventDefault();
readClipboardToBufferAndSend(); readClipboardToBufferAndSend();
} }
}} }}
onPaste={e => { onPaste={e => {
if (pasteShortcutEnabled) { if (overrideCtrlV) {
e.preventDefault(); e.preventDefault();
const txt = e.clipboardData?.getData("text") || ""; const txt = e.clipboardData?.getData("text") || "";
if (txt) { if (txt) {
@@ -283,7 +240,7 @@ export default function Clipboard() {
} }
} }
}}> }}>
{readyToRender && <TextAreaWithLabel {!overrideCtrlV && readyToRender && <TextAreaWithLabel
ref={TextAreaRef} ref={TextAreaRef}
label={$at("Copy text from your client to the remote host")} label={$at("Copy text from your client to the remote host")}
rows={4} rows={4}
@@ -338,50 +295,32 @@ export default function Clipboard() {
</div> </div>
<div <div
className="flex animate-fadeIn opacity-0 flex-col gap-y-2" className="flex animate-fadeIn opacity-0 items-center justify-start gap-x-2"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",
}} }}
> >
<Button <Button
type="primary" type="primary"
className="w-full" className={isMobile ? "w-[49%]" : ""}
onClick={onConfirmPaste} onClick={onConfirmPaste}
> >
{$at("Confirm paste")}</Button> {$at("Confirm paste")}</Button>
<Button
<div className="grid grid-cols-[minmax(0,1fr)_140px] items-center gap-2 sm:grid-cols-[minmax(0,1fr)_180px]"> className={isMobile ? "w-[49%]" : ""}
<Checkbox onClick={() => {
className="min-w-0" onCancelPasteMode();
checked={ocrShortcutEnabled} close();
onChange={e => setOcrShortcutEnabled(e.target.checked)} if(isMobile){
> toggleTopBarView("ClipboardMobile");
<span className="whitespace-normal break-words"> }else{
{$at("Enable OCR shortcut")} setSidebarView(null)
</span> }
</Checkbox> }}
<Input >{$at("Cancel")}</Button>
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>
</div> </div>

View File

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

View File

@@ -67,8 +67,6 @@ interface StorageFilePageProps {
onUnmountSDStorage?: () => void; onUnmountSDStorage?: () => void;
onFormatSDStorage?: () => void; onFormatSDStorage?: () => void;
onMountSDStorage?: () => void; onMountSDStorage?: () => void;
fsType?: 'exfat' | 'fat32';
onFsTypeChange?: (value: 'exfat' | 'fat32') => void;
} }
export const FileManager: React.FC<StorageFilePageProps> = ({ export const FileManager: React.FC<StorageFilePageProps> = ({
@@ -81,8 +79,6 @@ export const FileManager: React.FC<StorageFilePageProps> = ({
onResetSDStorage, onResetSDStorage,
onUnmountSDStorage, onUnmountSDStorage,
onFormatSDStorage, onFormatSDStorage,
fsType,
onFsTypeChange,
}) => { }) => {
const { $at } = useReactAt(); const { $at } = useReactAt();
@@ -255,28 +251,15 @@ export const FileManager: React.FC<StorageFilePageProps> = ({
</p> </p>
{sdMountStatus !== "none" && ( {sdMountStatus !== "none" && (
<div className="pt-2"> <div className="pt-2">
<div className="w-full space-y-2"> <AntdButton
<p className="w-full text-left text-xs text-slate-700 dark:text-slate-300"> disabled={loading}
{$at("Choose the file system for MicroSD formatting")} danger={true}
</p> type="primary"
<select onClick={handleFormatWrapper}
value={fsType || "fat32"} className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
onChange={(e) => onFsTypeChange?.(e.target.value as 'exfat' | 'fat32')} >
style={{ width: "100%", padding: "8px", borderRadius: "4px" }} {$at("Format MicroSD Card")}
> </AntdButton>
<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> </div>
@@ -335,8 +318,6 @@ export const FileManager: React.FC<StorageFilePageProps> = ({
onUnmountSDStorage={handleUnmountWrapper} onUnmountSDStorage={handleUnmountWrapper}
onFormatSDStorage={handleFormatWrapper} onFormatSDStorage={handleFormatWrapper}
syncStorage={syncStorage} syncStorage={syncStorage}
fsType={fsType}
onFsTypeChange={onFsTypeChange}
/> />
{uploadFile ? ( {uploadFile ? (
@@ -523,46 +504,29 @@ interface ActionButtonsSectionProps {
onUnmountSDStorage?: () => void; onUnmountSDStorage?: () => void;
onFormatSDStorage?: () => void; onFormatSDStorage?: () => void;
syncStorage: () => void; syncStorage: () => void;
fsType?: 'exfat' | 'fat32';
onFsTypeChange?: (value: 'exfat' | 'fat32') => void;
} }
const ActionButtonsSection: React.FC<ActionButtonsSectionProps> = ({ const ActionButtonsSection: React.FC<ActionButtonsSectionProps> = ({
mediaType, mediaType,
loading, loading,
showSDManagement, showSDManagement,
onUnmountSDStorage, onUnmountSDStorage,
onFormatSDStorage, onFormatSDStorage,
fsType, }) => {
onFsTypeChange,
}) => {
const { $at } = useReactAt(); const { $at } = useReactAt();
if (mediaType === "sd" && showSDManagement) { if (mediaType === "sd" && showSDManagement) {
return ( return (
<div className="animate-fadeIn space-y-2 opacity-0" <div className="flex animate-fadeIn justify-between gap-2 opacity-0"
style={{ animationDuration: "0.7s", animationDelay: "0.25s" }} style={{ animationDuration: "0.7s", animationDelay: "0.25s" }}
> >
<div className="w-full space-y-2"> <AntdButton
<p className="w-full text-left text-xs text-slate-700 dark:text-slate-300"> disabled={loading}
{$at("Choose the file system for MicroSD formatting")} type="primary"
</p> danger={true}
<select onClick={onFormatSDStorage}
value={fsType || "fat32"} className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
onChange={(e) => onFsTypeChange?.(e.target.value as 'exfat' | 'fat32')} >{$at("Format MicroSD Card")}</AntdButton>
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 <AntdButton
disabled={loading} disabled={loading}
type="primary" type="primary"

View File

@@ -9,7 +9,6 @@ export default function SDFilePage() {
const { $at } = useReactAt(); const { $at } = useReactAt();
const [send] = useJsonRpc(); const [send] = useJsonRpc();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [fsType, setFsType] = useState<'exfat' | 'fat32'>('fat32');
const handleResetSDStorage = async () => { const handleResetSDStorage = async () => {
setLoading(true); setLoading(true);
@@ -38,11 +37,11 @@ export default function SDFilePage() {
}; };
const handleFormatSDStorage = async () => { const handleFormatSDStorage = async () => {
if (!window.confirm($at(`Formatting the SD card as ${fsType.toUpperCase()} will erase all data. Continue?`))) { if (!window.confirm($at("Formatting the SD card will erase all data. Continue?"))) {
return; return;
} }
setLoading(true); setLoading(true);
send("formatSDStorage", { confirm: true, fsType }, res => { send("formatSDStorage", { confirm: true }, res => {
if ("error" in res) { if ("error" in res) {
notifications.error(res.error.data || res.error.message); notifications.error(res.error.data || res.error.message);
setLoading(false); setLoading(false);
@@ -55,21 +54,17 @@ export default function SDFilePage() {
}; };
return ( return (
<> <FileManager
<FileManager mediaType="sd"
mediaType="sd" returnTo="/sd-files"
returnTo="/sd-files" listFilesMethod="listSDStorageFiles"
listFilesMethod="listSDStorageFiles" getSpaceMethod="getSDStorageSpace"
getSpaceMethod="getSDStorageSpace" deleteFileMethod="deleteSDStorageFile"
deleteFileMethod="deleteSDStorageFile" downloadUrlPrefix="/storage/sd-download"
downloadUrlPrefix="/storage/sd-download" showSDManagement={true}
showSDManagement={true} onResetSDStorage={handleResetSDStorage}
onResetSDStorage={handleResetSDStorage} onUnmountSDStorage={handleUnmountSDStorage}
onUnmountSDStorage={handleUnmountSDStorage} onFormatSDStorage={handleFormatSDStorage}
onFormatSDStorage={handleFormatSDStorage} />
fsType={fsType}
onFsTypeChange={setFsType}
/>
</>
); );
} }

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Button as AntdButton , Slider , Checkbox, Select, Modal, InputNumber, Tabs, Typography } from "antd"; import { Button as AntdButton , Slider , Checkbox, Select } from "antd";
import { useReactAt } from "i18n-auto-extractor/react"; import { useReactAt } from "i18n-auto-extractor/react";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
@@ -10,7 +10,6 @@ import { SettingsItem, SettingsItemNew } from "@components/Settings/SettingsView
import notifications from "../../../notifications"; import notifications from "../../../notifications";
const { Text } = Typography;
@@ -46,133 +45,6 @@ const streamQualityOptions = [
{ value: "0.1", label: "Low" }, { 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() { export default function SettingsVideoSide() {
const { $at } = useReactAt(); const { $at } = useReactAt();
const [send] = useJsonRpc(); const [send] = useJsonRpc();
@@ -182,12 +54,6 @@ export default function SettingsVideoSide() {
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null); const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
const [edid, setEdid] = useState<string | null>(null); const [edid, setEdid] = useState<string | null>(null);
const [forceHpd, setForceHpd] = useState(false); 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 // Video enhancement settings from store
const videoSaturation = useSettingsStore(state => state.videoSaturation); const videoSaturation = useSettingsStore(state => state.videoSaturation);
@@ -197,129 +63,6 @@ export default function SettingsVideoSide() {
const videoContrast = useSettingsStore(state => state.videoContrast); const videoContrast = useSettingsStore(state => state.videoContrast);
const setVideoContrast = useSettingsStore(state => state.setVideoContrast); 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(() => { useEffect(() => {
send("getNpuAppStatus", {}, resp => { send("getNpuAppStatus", {}, resp => {
if ("error" in resp) return; if ("error" in resp) return;
@@ -336,20 +79,6 @@ export default function SettingsVideoSide() {
setStreamQuality(String(resp.result)); 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 => { send("getEDID", {}, resp => {
if ("error" in resp) { if ("error" in resp) {
notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`); notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`);
@@ -489,116 +218,6 @@ export default function SettingsVideoSide() {
/> />
</SettingsItem> </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 <SettingsItem
title={$at("NPU Application")} title={$at("NPU Application")}
@@ -743,7 +362,25 @@ export default function SettingsVideoSide() {
}} }}
options={[...edids, { value: "custom", label: "Custom" }]} options={[...edids, { value: "custom", label: "Custom" }]}
/> />
{/* options={[...edids, { value: "custom", label: "Custom" }]}*/}
</SettingsItem> </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 && ( {customEdidValue !== null && (
<> <>
@@ -781,39 +418,6 @@ export default function SettingsVideoSide() {
)} )}
</div> </div>
<div className={"h-[10vh]"}></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> </div>
); );
} }

View File

@@ -105,7 +105,6 @@ export default function ImageManager({
const [sdMountStatus, setSDMountStatus] = useState<"ok" | "none" | "fail" | null>(storageType === 'sd' ? null : 'ok'); const [sdMountStatus, setSDMountStatus] = useState<"ok" | "none" | "fail" | null>(storageType === 'sd' ? null : 'ok');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [uploadFile, setUploadFile] = useState<string | null>(null); const [uploadFile, setUploadFile] = useState<string | null>(null);
const [fsType, setFsType] = useState<'exfat' | 'fat32'>('fat32');
const filesPerPage = 5; const filesPerPage = 5;
const percentageUsed = useMemo(() => { const percentageUsed = useMemo(() => {
@@ -171,7 +170,7 @@ export default function ImageManager({
return; return;
} }
setLoading(true); setLoading(true);
send("formatSDStorage", { confirm: true, fsType }, res => { send("formatSDStorage", { confirm: true }, res => {
if ("error" in res) { if ("error" in res) {
notifications.error(res.error.data || res.error.message); notifications.error(res.error.data || res.error.message);
setLoading(false); setLoading(false);
@@ -348,28 +347,15 @@ export default function ImageManager({
</p> </p>
{sdMountStatus !== "none" && ( {sdMountStatus !== "none" && (
<div className="pt-2"> <div className="pt-2">
<div className="mx-auto w-full max-w-[360px] space-y-2"> <AntdButton
<p className="w-full text-left text-xs text-slate-700 dark:text-slate-300"> disabled={loading}
{$at("Choose the file system for MicroSD formatting")} danger={true}
</p> type="primary"
<select onClick={handleFormatSDStorage}
value={fsType} className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
onChange={(e) => setFsType(e.target.value as 'exfat' | 'fat32')} >
style={{ width: "100%", padding: "8px", borderRadius: "4px" }} {$at("Format MicroSD Card")}
> </AntdButton>
<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>
@@ -507,29 +493,16 @@ export default function ImageManager({
</div> </div>
{unmountApi && storageType === 'sd' && ( {unmountApi && storageType === 'sd' && (
<div className="animate-fadeIn space-y-2 opacity-0" <div className="flex animate-fadeIn justify-between gap-2 opacity-0"
style={{ animationDuration: "0.7s", animationDelay: "0.25s" }} style={{ animationDuration: "0.7s", animationDelay: "0.25s" }}
> >
<div className="w-full space-y-2"> <AntdButton
<p className="w-full text-left text-xs text-slate-700 dark:text-slate-300"> disabled={loading}
{$at("Choose the file system for MicroSD formatting")} type="primary"
</p> danger={true}
<select onClick={handleFormatSDStorage}
value={fsType} className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
onChange={(e) => setFsType(e.target.value as 'exfat' | 'fat32')} >{$at("Format MicroSD Card")}</AntdButton>
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 <AntdButton
disabled={loading} disabled={loading}
type="primary" type="primary"

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { BsKeyboardFill, BsLockFill, BsUnlockFill } from "react-icons/bs"; import { BsMouseFill, BsLockFill, BsUnlockFill } from "react-icons/bs";
import { useReactAt } from "i18n-auto-extractor/react"; import { useReactAt } from "i18n-auto-extractor/react";
import VirtualKeyboard from "@components/VirtualKeyboard"; 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 Clipboard from "@/layout/components_side/Clipboard/Clipboard";
import SettingsModal from "@/layout/components_setting"; import SettingsModal from "@/layout/components_setting";
import { MacroMoreList } from "@/layout/components_side/Macros/MacroTopBar"; import { MacroMoreList } from "@/layout/components_side/Macros/MacroTopBar";
import { useMacrosSideTitleState , useHidStore, useMouseStore, useSettingsStore, useUiStore } from "@/hooks/stores"; import { useMacrosSideTitleState , useHidStore, useMouseStore, useSettingsStore } from "@/hooks/stores";
import MobileTerminal from "@/layout/components_bottom/terminal/index.mobile"; import MobileTerminal from "@/layout/components_bottom/terminal/index.mobile";
import { dark_bg_desktop, dark_bg_style_fun } from "@/layout/theme_color"; import { dark_bg_desktop, dark_bg_style_fun } from "@/layout/theme_color";
import PowerControl from "@/layout/components_side/Power"; import PowerControl from "@/layout/components_side/Power";
@@ -37,52 +37,10 @@ import SettingsMacros from "@/layout/components_side/Macros";
import { useTouchZoom } from "@/layout/core/desktop/hooks/useTouchZoom"; import { useTouchZoom } from "@/layout/core/desktop/hooks/useTouchZoom";
import { usePasteHandler } from "@/layout/core/desktop/hooks/usePasteHandler"; import { usePasteHandler } from "@/layout/core/desktop/hooks/usePasteHandler";
import UsbEpModeSelect from "@/layout/components_bottom/usbepmode/UsbEpModeSelect"; 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 VirtualMediaSource from "@/layout/components_side/VirtualMediaSource";
import { useJsonRpc } from "@/hooks/useJsonRpc"; 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 }) { export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number }) {
const joystickSpeedLevels = [1.4, 1.05, 0.7];
const { $at } = useReactAt(); const { $at } = useReactAt();
const { isDark } = useTheme(); const { isDark } = useTheme();
const videoElm = useRef<HTMLVideoElement>(null); const videoElm = useRef<HTMLVideoElement>(null);
@@ -91,126 +49,56 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
const zoomContainerRef = useRef<HTMLDivElement>(null); const zoomContainerRef = useRef<HTMLDivElement>(null);
const pasteCaptureRef = useRef<HTMLTextAreaElement>(null); const pasteCaptureRef = useRef<HTMLTextAreaElement>(null);
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget); const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
const isOcrMode = useUiStore(state => state.isOcrMode);
const macrosSideTitle = useMacrosSideTitleState(state => state.sideTitle); const macrosSideTitle = useMacrosSideTitleState(state => state.sideTitle);
const videoEffects = useVideoEffects(); const videoEffects = useVideoEffects();
const videoStream = useVideoStream(videoElm as React.RefObject<HTMLVideoElement>, audioElm as React.RefObject<HTMLAudioElement>); const videoStream = useVideoStream(videoElm as React.RefObject<HTMLVideoElement>, audioElm as React.RefObject<HTMLAudioElement>);
const pointerLock = usePointerLock(videoElm as React.RefObject<HTMLVideoElement>); const pointerLock = usePointerLock(videoElm as React.RefObject<HTMLVideoElement>);
useFullscreen(videoElm as React.RefObject<HTMLVideoElement>, pointerLock, isFullscreen); useFullscreen(videoElm as React.RefObject<HTMLVideoElement>, pointerLock, isFullscreen);
const [isTouchGestureEnabled, setIsTouchGestureEnabled] = useState(true); const touchZoom = useTouchZoom(zoomContainerRef as React.RefObject<HTMLDivElement>);
const touchZoom = useTouchZoom(zoomContainerRef as React.RefObject<HTMLDivElement>, isTouchGestureEnabled);
const { handleGlobalPaste } = usePasteHandler(pasteCaptureRef as React.RefObject<HTMLTextAreaElement>); const { handleGlobalPaste } = usePasteHandler(pasteCaptureRef as React.RefObject<HTMLTextAreaElement>);
const keyboardEvents = useKeyboardEvents(pasteCaptureRef as React.RefObject<HTMLTextAreaElement>, isReinitializingGadget); const keyboardEvents = useKeyboardEvents(pasteCaptureRef as React.RefObject<HTMLTextAreaElement>, isReinitializingGadget);
const [showVirtualMouseButtons, setShowVirtualMouseButtons] = useState(false); 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 [lockedButtons, setLockedButtons] = useState(0);
const mouseEvents = useMouseEvents(videoElm as React.RefObject<HTMLVideoElement>, pointerLock, touchZoom, showVirtualMouseButtons, lockedButtons); const mouseEvents = useMouseEvents(videoElm as React.RefObject<HTMLVideoElement>, pointerLock, touchZoom, showVirtualMouseButtons, lockedButtons);
const overlays = useVideoOverlays(videoStream, pointerLock, videoEffects); const overlays = useVideoOverlays(videoStream, pointerLock, videoEffects);
const forceHttp = useSettingsStore(state => state.forceHttp); const forceHttp = useSettingsStore(state => state.forceHttp);
const mouseMode = useSettingsStore(state => state.mouseMode);
const mouseX = useMouseStore(state => state.mouseX); const mouseX = useMouseStore(state => state.mouseX);
const mouseY = useMouseStore(state => state.mouseY); const mouseY = useMouseStore(state => state.mouseY);
const allowTapToOpenVirtualKeyboard = useHidStore(state => state.allowTapToOpenVirtualKeyboard);
const setAllowTapToOpenVirtualKeyboard = useHidStore(state => state.setAllowTapToOpenVirtualKeyboard);
const [send] = useJsonRpc(); const [send] = useJsonRpc();
const [leftBtnPos, setLeftBtnPos] = useState({ x: 40, y: 40 }); const [leftBtnPos, setLeftBtnPos] = useState({ x: 40, y: 40 });
const [rightBtnPos, setRightBtnPos] = useState({ x: 120, y: 40 }); const [rightBtnPos, setRightBtnPos] = useState({ x: 120, y: 40 });
const [wheelPos, setWheelPos] = useState({ x: 184, y: 140 }); const [leftLockPos, setLeftLockPos] = useState({ x: 40, y: 110 });
const [rightLockPos, setRightLockPos] = useState({ x: 120, y: 110 });
const [draggingBtn, setDraggingBtn] = useState<"left" | "right" | "wheel" | null>(null); const [draggingBtn, setDraggingBtn] = useState<"left" | "right" | "leftLock" | "rightLock" | null>(null);
const dragOffset = useRef({ x: 0, y: 0 }); 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); const activeButtonsRef = useRef(0);
useEffect(() => { useEffect(() => {
if (isFullscreen) { if (isFullscreen) {
setShowVirtualMouseButtons(false); setShowVirtualMouseButtons(false);
setShowVirtualJoystick(false);
} }
}, [isFullscreen]); }, [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) => { const updateButtons = (mask: number, isDown: boolean) => {
if (isReinitializingGadget) return; if (isReinitializingGadget) return;
let newButtons = activeButtonsRef.current; let newButtons = activeButtonsRef.current;
if (isDown) { if (isDown) {
newButtons |= mask; newButtons |= mask;
} else if (lockedButtons & mask) {
// Keep pressed while lock is enabled.
newButtons |= mask;
} else { } else {
newButtons &= ~mask; newButtons &= ~mask;
} }
activeButtonsRef.current = newButtons; if (!isDown && (lockedButtons & mask)) {
if (mouseMode === "relative") { setLockedButtons(prev => prev & ~mask);
mouseEvents.sendVirtualRelativeMovement(0, 0, newButtons);
} else {
send("absMouseReport", { x: mouseX, y: mouseY, buttons: newButtons });
} }
activeButtonsRef.current = newButtons;
send("absMouseReport", { x: mouseX, y: mouseY, buttons: newButtons });
}; };
const toggleLock = (mask: number) => { const toggleLock = (mask: number) => {
@@ -227,14 +115,10 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
} }
activeButtonsRef.current = newButtons; activeButtonsRef.current = newButtons;
if (mouseMode === "relative") { send("absMouseReport", { x: mouseX, y: mouseY, buttons: newButtons });
mouseEvents.sendVirtualRelativeMovement(0, 0, newButtons);
} else {
send("absMouseReport", { x: mouseX, y: mouseY, buttons: newButtons });
}
}; };
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>, type: "left" | "right" | "wheel") => { const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>, type: "left" | "right" | "leftLock" | "rightLock") => {
const target = e.currentTarget; const target = e.currentTarget;
const rect = target.getBoundingClientRect(); const rect = target.getBoundingClientRect();
dragOffset.current = { dragOffset.current = {
@@ -252,121 +136,26 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
const x = e.clientX - containerRect.left - dragOffset.current.x; const x = e.clientX - containerRect.left - dragOffset.current.x;
const y = e.clientY - containerRect.top - dragOffset.current.y; const y = e.clientY - containerRect.top - dragOffset.current.y;
const dragWidth = draggingBtn === "wheel" ? 32 : 56; const clampedX = Math.max(0, Math.min(containerRect.width - 56, x));
const dragHeight = draggingBtn === "wheel" ? 68 : 56; const clampedY = Math.max(0, Math.min(containerRect.height - 56, y));
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") { if (draggingBtn === "left") {
setLeftBtnPos({ x: clampedX, y: clampedY }); setLeftBtnPos({ x: clampedX, y: clampedY });
} else if (draggingBtn === "right") { } else if (draggingBtn === "right") {
setRightBtnPos({ x: clampedX, y: clampedY }); setRightBtnPos({ x: clampedX, y: clampedY });
} else if (draggingBtn === "wheel") { } else if (draggingBtn === "leftLock") {
setWheelPos({ x: clampedX, y: clampedY }); setLeftLockPos({ x: clampedX, y: clampedY });
} else if (draggingBtn === "rightLock") {
setRightLockPos({ x: clampedX, y: clampedY });
} }
}; };
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>, type: "left" | "right" | "wheel") => { const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>, type: "left" | "right" | "leftLock" | "rightLock") => {
const wasDragging = draggingBtn === type; const wasDragging = draggingBtn === type;
setDraggingBtn(null); setDraggingBtn(null);
(e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId); (e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
if (!wasDragging) return; 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(() => { useEffect(() => {
const keyboardCleanup = keyboardEvents.setupKeyboardEvents(); const keyboardCleanup = keyboardEvents.setupKeyboardEvents();
const videoCleanup = videoStream.setupVideoEventListeners(); const videoCleanup = videoStream.setupVideoEventListeners();
@@ -424,11 +213,6 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
drawerRender={() => (<UsbEpModeSelect/>)} drawerRender={() => (<UsbEpModeSelect/>)}
className={"px-[20px]"} className={"px-[20px]"}
/> />
<EnhancedDrawer
targetView={"UsbStatusPanel"}
placement={"bottom"}
drawerRender={() => (<UsbStatusPanel/>)}
/>
<EnhancedDrawer <EnhancedDrawer
title={$at("Virtual Media Source")} title={$at("Virtual Media Source")}
@@ -483,7 +267,6 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
`h-full w-full ${dark_bg_style_fun(isDark)} object-contain transition-all duration-1000`, `h-full w-full ${dark_bg_style_fun(isDark)} object-contain transition-all duration-1000`,
{ {
"cursor-none": videoEffects.settings.isCursorHidden, "cursor-none": videoEffects.settings.isCursorHidden,
"pointer-events-none": isOcrMode,
"opacity-0": overlays.shouldHideVideo, "opacity-0": overlays.shouldHideVideo,
"opacity-60!": overlays.showPointerLockBar, "opacity-60!": overlays.showPointerLockBar,
"animate-slideUpFade dark:border-slate-300/20": "animate-slideUpFade dark:border-slate-300/20":
@@ -491,10 +274,6 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
}, },
)} )}
/> />
<OcrOverlay
videoRef={videoElm as React.RefObject<HTMLVideoElement>}
containerRef={zoomContainerRef as React.RefObject<HTMLDivElement>}
/>
{(videoStream.peerConnectionState === "connected" || forceHttp) && ( {(videoStream.peerConnectionState === "connected" || forceHttp) && (
<div <div
@@ -517,292 +296,107 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
className="pointer-events-none absolute inset-0" className="pointer-events-none absolute inset-0"
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
> >
<div className="pointer-events-auto absolute right-3 top-3 grid grid-cols-[auto_auto_auto] grid-rows-2 gap-1.5"> <div
<div className={cx(
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",
"flex h-8 w-8 items-center justify-center rounded-full text-white", isDark ? "bg-gray-500/70" : "bg-black/30",
isTouchGestureEnabled )}
? "bg-green-600/80" style={{
: "bg-gray-500/70", touchAction: "none",
)} }}
style={{ onClick={() => setShowVirtualMouseButtons(prev => !prev)}
touchAction: "none", >
}} <BsMouseFill className="h-4 w-4" />
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> </div>
{showVirtualMouseButtons && ( {showVirtualMouseButtons && (
<> <>
<div <div
className={cx( 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 relative", "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"), isDark ? "bg-gray-500/70" : "bg-black/30",
)} )}
style={{ style={{
left: leftBtnPos.x, left: leftBtnPos.x,
top: leftBtnPos.y, top: leftBtnPos.y,
touchAction: "none", touchAction: "none",
}} }}
onPointerDown={() => { onPointerDown={e => {
handlePointerDown(e, "left");
updateButtons(1, true); updateButtons(1, true);
}} }}
onPointerUp={() => { onPointerUp={e => {
handlePointerUp(e, "left");
updateButtons(1, false); updateButtons(1, false);
}} }}
> >
L 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>
<div <div
className={cx( 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 relative", "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"), (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",
)} )}
style={{ style={{
left: rightBtnPos.x, left: rightBtnPos.x,
top: rightBtnPos.y, top: rightBtnPos.y,
touchAction: "none", touchAction: "none",
}} }}
onPointerDown={() => { onPointerDown={e => {
handlePointerDown(e, "right");
updateButtons(2, true); updateButtons(2, true);
}} }}
onPointerUp={() => { onPointerUp={e => {
handlePointerUp(e, "right");
updateButtons(2, false); updateButtons(2, false);
}} }}
> >
R 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>
<div <div
className="pointer-events-auto absolute" 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"),
)}
style={{ style={{
left: wheelPos.x, left: rightLockPos.x,
top: wheelPos.y, top: rightLockPos.y,
touchAction: "none", touchAction: "none",
}} }}
onPointerDown={e => {
handlePointerDown(e, "rightLock");
}}
onPointerUp={e => {
handlePointerUp(e, "rightLock");
}}
onClick={() => toggleLock(2)}
> >
<div {(lockedButtons & 2) ? <BsLockFill /> : <BsUnlockFill />} R
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> </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>
</div> </div>
<VirtualKeyboard /> <VirtualKeyboard />

View File

@@ -25,7 +25,6 @@ import { MacroMoreList } from "@/layout/components_side/Macros/MacroTopBar";
import { useUiStore, useHidStore, useSettingsStore } from "@/hooks/stores"; import { useUiStore, useHidStore, useSettingsStore } from "@/hooks/stores";
import { useTouchZoom } from "@/layout/core/desktop/hooks/useTouchZoom"; import { useTouchZoom } from "@/layout/core/desktop/hooks/useTouchZoom";
import { usePasteHandler } from "@/layout/core/desktop/hooks/usePasteHandler"; import { usePasteHandler } from "@/layout/core/desktop/hooks/usePasteHandler";
import OcrOverlay from "@components/OcrOverlay";
export default function PCDesktop({ isFullscreen }: { isFullscreen?: number }) { export default function PCDesktop({ isFullscreen }: { isFullscreen?: number }) {
const videoElm = useRef<HTMLVideoElement>(null); const videoElm = useRef<HTMLVideoElement>(null);
@@ -39,7 +38,6 @@ export default function PCDesktop({ isFullscreen }: { isFullscreen?: number }) {
const setTerminalType = useUiStore(state => state.setTerminalType); const setTerminalType = useUiStore(state => state.setTerminalType);
const terminalType = useUiStore(state => state.terminalType); const terminalType = useUiStore(state => state.terminalType);
const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled); const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled);
const isOcrMode = useUiStore(state => state.isOcrMode);
const forceHttp = useSettingsStore(state => state.forceHttp); const forceHttp = useSettingsStore(state => state.forceHttp);
@@ -118,7 +116,6 @@ export default function PCDesktop({ isFullscreen }: { isFullscreen?: number }) {
`max-h-full min-h-[384px] max-w-full min-w-[512px] object-contain transition-all duration-1000`, `max-h-full min-h-[384px] max-w-full min-w-[512px] object-contain transition-all duration-1000`,
{ {
"cursor-none": videoEffects.settings.isCursorHidden, "cursor-none": videoEffects.settings.isCursorHidden,
"pointer-events-none": isOcrMode,
"opacity-0": overlays.shouldHideVideo, "opacity-0": overlays.shouldHideVideo,
"opacity-60!": overlays.showPointerLockBar, "opacity-60!": overlays.showPointerLockBar,
"animate-slideUpFade shadow-xs ": "animate-slideUpFade shadow-xs ":
@@ -126,10 +123,6 @@ export default function PCDesktop({ isFullscreen }: { isFullscreen?: number }) {
}, },
)} )}
/> />
<OcrOverlay
videoRef={videoElm as React.RefObject<HTMLVideoElement>}
containerRef={zoomContainerRef as React.RefObject<HTMLDivElement>}
/>
{(videoStream.peerConnectionState === "connected" || forceHttp) && ( {(videoStream.peerConnectionState === "connected" || forceHttp) && (
<div <div

View File

@@ -1,10 +1,8 @@
import { useCallback } from "react"; import { useCallback } from "react";
import useKeyboard from "@/hooks/useKeyboard"; import useKeyboard from "@/hooks/useKeyboard";
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores"; import { useHidStore, useSettingsStore } from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings"; import { keys, modifiers } from "@/keyboardMappings";
import { keyboards } from "@/keyboardLayouts";
import { eventMatchesShortcut } from "@/utils/shortcuts";
export const useKeyboardEvents = ( export const useKeyboardEvents = (
pasteCaptureRef?: React.RefObject<HTMLTextAreaElement>, pasteCaptureRef?: React.RefObject<HTMLTextAreaElement>,
@@ -16,31 +14,7 @@ export const useKeyboardEvents = (
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable); const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync); const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const isKeyboardLedManagedByHost = keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable; const isKeyboardLedManagedByHost = keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable;
const pasteShortcutEnabled = useSettingsStore(state => state.pasteShortcutEnabled); const overrideCtrlV = useSettingsStore(state => state.overrideCtrlV);
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 handleModifierKeys = useCallback((e: KeyboardEvent, activeModifiers: number[]) => {
const { shiftKey, ctrlKey, altKey, metaKey } = e; const { shiftKey, ctrlKey, altKey, metaKey } = e;
@@ -54,22 +28,17 @@ export const useKeyboardEvents = (
}, []); }, []);
const keyDownHandler = useCallback(async (e: KeyboardEvent) => { const keyDownHandler = useCallback(async (e: KeyboardEvent) => {
if (isOcrMode) return; if (overrideCtrlV && (e.code === "KeyV" || e.key.toLowerCase() === "v") && (e.ctrlKey || e.metaKey)) {
if (pasteShortcutEnabled && eventMatchesShortcut(e, pasteShortcut)) { console.log("Override Ctrl V");
if (isReinitializingGadget) return; if (isReinitializingGadget) return;
if (pasteCaptureRef && pasteCaptureRef.current) { if (pasteCaptureRef && pasteCaptureRef.current) {
pasteCaptureRef.current.value = ""; pasteCaptureRef.current.value = "";
pasteCaptureRef.current.focus(); pasteCaptureRef.current.focus();
}
return;
} }
return;
}
if (isReinitializingGadget) return; if (isReinitializingGadget) return;
if (e.repeat) {
e.preventDefault();
return;
}
e.preventDefault(); e.preventDefault();
const prev = useHidStore.getState(); const prev = useHidStore.getState();
let code = e.code; let code = e.code;
@@ -87,8 +56,6 @@ export const useKeyboardEvents = (
code = "IntlBackslash"; code = "IntlBackslash";
} }
code = remapCode(code, key);
const newKeys = [...prev.activeKeys, keys[code]].filter(Boolean); const newKeys = [...prev.activeKeys, keys[code]].filter(Boolean);
const newModifiers = handleModifierKeys(e, [...prev.activeModifiers, modifiers[code]]); const newModifiers = handleModifierKeys(e, [...prev.activeModifiers, modifiers[code]]);
@@ -100,15 +67,12 @@ export const useKeyboardEvents = (
} }
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]); sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
}, [handleModifierKeys, remapCode, sendKeyboardEvent, isKeyboardLedManagedByHost, setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive, pasteShortcutEnabled, pasteShortcut, pasteCaptureRef, isReinitializingGadget, isOcrMode]); }, [handleModifierKeys, sendKeyboardEvent, isKeyboardLedManagedByHost, setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive, overrideCtrlV, pasteCaptureRef, isReinitializingGadget]);
const keyUpHandler = useCallback((e: KeyboardEvent) => { const keyUpHandler = useCallback((e: KeyboardEvent) => {
if (isOcrMode) return;
if (isReinitializingGadget) return; if (isReinitializingGadget) return;
e.preventDefault(); e.preventDefault();
const prev = useHidStore.getState(); const prev = useHidStore.getState();
const key = e.key;
let code = remapCode(e.code, key);
if (!isKeyboardLedManagedByHost) { if (!isKeyboardLedManagedByHost) {
setIsNumLockActive(e.getModifierState("NumLock")); setIsNumLockActive(e.getModifierState("NumLock"));
@@ -116,14 +80,14 @@ export const useKeyboardEvents = (
setIsScrollLockActive(e.getModifierState("ScrollLock")); setIsScrollLockActive(e.getModifierState("ScrollLock"));
} }
const newKeys = prev.activeKeys.filter(k => k !== keys[code]).filter(Boolean); const newKeys = prev.activeKeys.filter(k => k !== keys[e.code]).filter(Boolean);
const newModifiers = handleModifierKeys( const newModifiers = handleModifierKeys(
e, e,
prev.activeModifiers.filter(k => k !== modifiers[code]), prev.activeModifiers.filter(k => k !== modifiers[e.code]),
); );
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]); sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
}, [handleModifierKeys, remapCode, sendKeyboardEvent, isKeyboardLedManagedByHost, setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive, isOcrMode, isReinitializingGadget]); }, [handleModifierKeys, sendKeyboardEvent, isKeyboardLedManagedByHost, setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive]);
const setupKeyboardEvents = useCallback(() => { const setupKeyboardEvents = useCallback(() => {
const abortController = new AbortController(); const abortController = new AbortController();

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,7 +33,6 @@ import {
useSettingsStore, useSettingsStore,
useVpnStore } from "@/hooks/stores"; useVpnStore } from "@/hooks/stores";
import { JsonRpcRequest, useJsonRpc, resetHttpSessionId } from "@/hooks/useJsonRpc"; import { JsonRpcRequest, useJsonRpc, resetHttpSessionId } from "@/hooks/useJsonRpc";
import api from "@/api";
import Modal from "@components/Modal"; import Modal from "@components/Modal";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { import {
@@ -352,18 +351,6 @@ export default function MobileHome() {
try { try {
console.log("[setupPeerConnection] Creating peer connection"); console.log("[setupPeerConnection] Creating peer connection");
setLoadingMessage("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({ pc = new RTCPeerConnection({
// We only use STUN or TURN servers if we're in the cloud // We only use STUN or TURN servers if we're in the cloud
//...(isInCloud && iceConfig?.iceServers //...(isInCloud && iceConfig?.iceServers
@@ -371,7 +358,13 @@ export default function MobileHome() {
// : {}), // : {}),
...(iceConfig?.iceServers ...(iceConfig?.iceServers
? { iceServers: [iceConfig?.iceServers] } ? { iceServers: [iceConfig?.iceServers] }
: { iceServers: fetchedIceServers }), : {
iceServers: [
{
urls: ['stun:stun.l.google.com:19302']
}
]
}),
}); });
setPeerConnectionState(pc.connectionState); setPeerConnectionState(pc.connectionState);

View File

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

View File

@@ -104,14 +104,6 @@
"f9099fc033": "Renew DHCP Lease", "f9099fc033": "Renew DHCP Lease",
"6e188f5984": "IPv6 Information", "6e188f5984": "IPv6 Information",
"cfdffa4fc7": "Link-local", "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", "d6e24388b4": "Continue Uploading",
"62a5e49088": "Hide", "62a5e49088": "Hide",
"b34e855501": "USB Expansion Function", "b34e855501": "USB Expansion Function",
@@ -181,24 +173,6 @@
"c40a72ad93": "KVM Terminal", "c40a72ad93": "KVM Terminal",
"dd110018d2": "Serial Console", "dd110018d2": "Serial Console",
"7b277018e4": "Not connected", "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", "bf733d8a93": "Access",
"70e23e7d6f": "Manage the Access Control of the device", "70e23e7d6f": "Manage the Access Control of the device",
"509820290d": "Local", "509820290d": "Local",
@@ -221,8 +195,6 @@
"efd3cc702e": "Enable Password", "efd3cc702e": "Enable Password",
"8f1e77e0d2": "Change Password", "8f1e77e0d2": "Change Password",
"3d0de21428": "Update your device access password", "3d0de21428": "Update your device access password",
"d6d56d5972": "WebRTC Servers",
"8bea04c48e": "STUN and TURN servers used for peer connections",
"f8508f576c": "Remote", "f8508f576c": "Remote",
"a8bb6f5f9f": "Manage the mode of Remote access to the device", "a8bb6f5f9f": "Manage the mode of Remote access to the device",
"0b86461350": "TailScale use xEdge server", "0b86461350": "TailScale use xEdge server",
@@ -295,6 +267,7 @@
"324118a672": "Input", "324118a672": "Input",
"29c2c02a36": "Output", "29c2c02a36": "Output",
"67d2f6740a": "Forward", "67d2f6740a": "Forward",
"63a6a88c06": "Refresh",
"a4d3b161ce": "Submit", "a4d3b161ce": "Submit",
"ec211f7c20": "Add", "ec211f7c20": "Add",
"5320550175": "Chain", "5320550175": "Chain",
@@ -315,22 +288,6 @@
"64ae5dd047": "Destination Port", "64ae5dd047": "Destination Port",
"fbb3878eb7": "Submit Firewall Policies?", "fbb3878eb7": "Submit Firewall Policies?",
"1c17a7e15b": "Warning: Adjusting some policies may cause network address loss, leading to device unavailability.", "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", "867cee98fd": "Passwords do not match",
"9864ff9420": "Please enter your old password", "9864ff9420": "Please enter your old password",
"14a714ab22": "Please enter a new password", "14a714ab22": "Please enter a new password",
@@ -371,12 +328,6 @@
"3369c97f56": "Enter your SSH public key", "3369c97f56": "Enter your SSH public key",
"81bafb2833": "The default SSH user is ", "81bafb2833": "The default SSH user is ",
"7a941a0f87": "Update SSH Key", "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", "fdad65b7be": "Force HTTP Transmission",
"e033b5e2a3": "Force using HTTP for video streaming instead of WebRTC", "e033b5e2a3": "Force using HTTP for video streaming instead of WebRTC",
"bda22ca687": "USB detection enhancement", "bda22ca687": "USB detection enhancement",
@@ -387,6 +338,7 @@
"020b92cfbb": "Enable USB Emulation", "020b92cfbb": "Enable USB Emulation",
"9f55f64b0f": "USB Gadget Reinitialize", "9f55f64b0f": "USB Gadget Reinitialize",
"40dc677a89": "Reinitialize USB gadget configuration", "40dc677a89": "Reinitialize USB gadget configuration",
"02d2f33ec9": "Reinitialize USB Gadget",
"f5ddf02991": "Reboot System", "f5ddf02991": "Reboot System",
"1dbbf194af": "Restart the device system", "1dbbf194af": "Restart the device system",
"1de72c4fc6": "Reboot", "1de72c4fc6": "Reboot",
@@ -395,10 +347,6 @@
"f43c0398a4": "Reset Configuration", "f43c0398a4": "Reset Configuration",
"0031dbef48": "Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.", "0031dbef48": "Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.",
"0d784092e8": "Reset Config", "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?", "a776e925bf": "Reboot System?",
"1f070051ff": "Are you sure you want to reboot the 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.", "f1a79f466e": "The device will restart and you will be disconnected from the web interface.",
@@ -455,12 +403,6 @@
"ef64a3770e": "Control mDNS (multicast DNS) operational mode", "ef64a3770e": "Control mDNS (multicast DNS) operational mode",
"be8226fe0c": "Time synchronization", "be8226fe0c": "Time synchronization",
"7e06bd28a6": "Configure time synchronization settings", "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", "d4dccb8ca2": "Save settings",
"8750a898cb": "IPv4 Mode", "8750a898cb": "IPv4 Mode",
"72c2543791": "Configure IPv4 mode", "72c2543791": "Configure IPv4 mode",
@@ -502,16 +444,7 @@
"5b404b3c98": "Update in Background", "5b404b3c98": "Update in Background",
"3723a3f846": "System is up to date", "3723a3f846": "System is up to date",
"a6b8796d51": "Your system is running the latest version. No updates are currently available.", "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", "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", "217b416896": "Update available",
"f00af8c98f": "A new update is available to enhance system performance and improve compatibility. We recommend updating to ensure everything runs smoothly.", "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", "8977c3f0b7": "Download Proxy Prefix",
@@ -520,22 +453,11 @@
"cffae9918d": "Update Error", "cffae9918d": "Update Error",
"49cba7cadf": "An error occurred while updating your device. Please try again later.", "49cba7cadf": "An error occurred while updating your device. Please try again later.",
"d849d5b330": "Error details:", "d849d5b330": "Error details:",
"fcfc8da1a3": "Verifying signature...", "2b2f7a6d7c": "Use Ctrl+V to paste clipboard to remote",
"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", "2b1a1676d1": "Copy text from your client to the remote host",
"604c45fbf2": "The following characters will not be pasted:", "604c45fbf2": "The following characters will not be pasted:",
"a7eb9efa0b": "Sending text using keyboard layout:", "a7eb9efa0b": "Sending text using keyboard layout:",
"2593d0a3d5": "Confirm paste", "2593d0a3d5": "Confirm paste",
"ac7e2a9783": "Enable OCR shortcut",
"f529c51ee6": "OCR",
"607440855c": "Open OCR selection mode on the video area",
"c3bf447eab": "Open",
"a0e3947a02": "Macros", "a0e3947a02": "Macros",
"276043dbf0": "Create a new keyboard macro", "276043dbf0": "Create a new keyboard macro",
"9605ef9593": "Modify your keyboard macro", "9605ef9593": "Modify your keyboard macro",
@@ -565,7 +487,6 @@
"21d104a54f": "Processing...", "21d104a54f": "Processing...",
"9844086d90": "No SD Card Detected", "9844086d90": "No SD Card Detected",
"b56e598918": "SD Card Mount Failed", "b56e598918": "SD Card Mount Failed",
"d646589704": "Choose the file system for MicroSD formatting",
"16eb8ed6c8": "Format MicroSD Card", "16eb8ed6c8": "Format MicroSD Card",
"a63d5e0260": "Manage Shared Folders in KVM MicroSD Card", "a63d5e0260": "Manage Shared Folders in KVM MicroSD Card",
"1d50425f88": "Manage Shared Folders in KVM Storage", "1d50425f88": "Manage Shared Folders in KVM Storage",
@@ -577,15 +498,10 @@
"8bf8854beb": "of", "8bf8854beb": "of",
"53e61336bb": "results", "53e61336bb": "results",
"d1f5a81904": "Unmount MicroSD Card", "d1f5a81904": "Unmount MicroSD Card",
"827048afc2": "Formatting the SD card will erase all data. Continue?",
"5874cf46ff": "SD card formatted successfully", "5874cf46ff": "SD card formatted successfully",
"7dc25305d4": "Encodec Type", "7dc25305d4": "Encodec Type",
"41d263a988": "Stream Quality", "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", "41f5283941": "NPU Application",
"466990072d": "Enable NPU to Object Detection", "466990072d": "Enable NPU to Object Detection",
"a6c2e30b8e": "Video Enhancement", "a6c2e30b8e": "Video Enhancement",
@@ -602,8 +518,6 @@
"85a003df9b": "EDID File", "85a003df9b": "EDID File",
"5c7a666766": "Set Custom EDID", "5c7a666766": "Set Custom EDID",
"7dad0ba758": "Restore to default", "7dad0ba758": "Restore to default",
"7b2fb72a68": "RC Advanced Config",
"827048afc2": "Formatting the SD card will erase all data. Continue?",
"556c7553f1": "KVM MicroSD Card Mount", "556c7553f1": "KVM MicroSD Card Mount",
"b99cf1ecb7": "Manage and mount images from MicroSD card", "b99cf1ecb7": "Manage and mount images from MicroSD card",
"0a01e80566": "Mounted from KVM storage", "0a01e80566": "Mounted from KVM storage",

View File

@@ -104,14 +104,6 @@
"f9099fc033": "重订 DHCP 租约", "f9099fc033": "重订 DHCP 租约",
"6e188f5984": "IPv6 信息", "6e188f5984": "IPv6 信息",
"cfdffa4fc7": "本地链路", "cfdffa4fc7": "本地链路",
"a588b1abc5": "已复制",
"c5505be6c3": "未检测到文本",
"a29aaf6603": "OCR 失败",
"dc67ba5f40": "拖动选择文本区域",
"b603b043e8": "复制文本",
"f5f78c501b": "正在识别文本...",
"5df17e67f5": "复制前请检查识别出的文本。",
"4dfb4a614f": "请等待 OCR 运行完成。",
"d6e24388b4": "继续上传", "d6e24388b4": "继续上传",
"62a5e49088": "隐藏", "62a5e49088": "隐藏",
"b34e855501": "USB拓展功能", "b34e855501": "USB拓展功能",
@@ -181,24 +173,6 @@
"c40a72ad93": "KVM 终端", "c40a72ad93": "KVM 终端",
"dd110018d2": "串口控制台", "dd110018d2": "串口控制台",
"7b277018e4": "未连接", "7b277018e4": "未连接",
"02d2f33ec9": "重新初始化 USB 设备",
"c8e875e1c9": "版本管理器",
"63a6a88c06": "刷新",
"d15f6e3312": "隐藏安装操作",
"2b9b84a087": "显示安装操作",
"81dfff9d6f": "安装状态",
"98dd43dfae": "已安装",
"ddd8eef6f8": "未安装",
"e2dc83997b": "检测到的版本",
"e3cc7e7df9": "系统架构",
"bc56777902": "发布版本",
"c319e3982f": "发布资源",
"06933067aa": "更新",
"a27dfe7717": "卸载",
"349838fb1d": "安装",
"4a0764788d": "安装任务",
"46b5f8c58b": "进度",
"5e4caae72b": "已安装版本",
"bf733d8a93": "访问", "bf733d8a93": "访问",
"70e23e7d6f": "管理设备的访问控制", "70e23e7d6f": "管理设备的访问控制",
"509820290d": "本地", "509820290d": "本地",
@@ -221,8 +195,6 @@
"efd3cc702e": "启用密码", "efd3cc702e": "启用密码",
"8f1e77e0d2": "更改密码", "8f1e77e0d2": "更改密码",
"3d0de21428": "更新设备访问密码", "3d0de21428": "更新设备访问密码",
"d6d56d5972": "WebRTC 服务器",
"8bea04c48e": "用于点对点连接的 STUN 和 TURN 服务器",
"f8508f576c": "远程", "f8508f576c": "远程",
"a8bb6f5f9f": "管理远程访问设备的方式", "a8bb6f5f9f": "管理远程访问设备的方式",
"0b86461350": "TailScale 使用 xEdge 服务器", "0b86461350": "TailScale 使用 xEdge 服务器",
@@ -295,6 +267,7 @@
"324118a672": "输入", "324118a672": "输入",
"29c2c02a36": "输出", "29c2c02a36": "输出",
"67d2f6740a": "转发", "67d2f6740a": "转发",
"63a6a88c06": "刷新",
"a4d3b161ce": "提交", "a4d3b161ce": "提交",
"ec211f7c20": "添加", "ec211f7c20": "添加",
"5320550175": "链", "5320550175": "链",
@@ -315,22 +288,6 @@
"64ae5dd047": "目标端口", "64ae5dd047": "目标端口",
"fbb3878eb7": "提交防火墙策略?", "fbb3878eb7": "提交防火墙策略?",
"1c17a7e15b": "警告:调整某些策略可能会导致网络地址丢失,导致设备不可用。", "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": "密码不一致", "867cee98fd": "密码不一致",
"9864ff9420": "请输入旧密码", "9864ff9420": "请输入旧密码",
"14a714ab22": "请输入新密码", "14a714ab22": "请输入新密码",
@@ -371,12 +328,6 @@
"3369c97f56": "输入您的 SSH 公钥", "3369c97f56": "输入您的 SSH 公钥",
"81bafb2833": "默认 SSH 用户是 ", "81bafb2833": "默认 SSH 用户是 ",
"7a941a0f87": "更新 SSH 密钥", "7a941a0f87": "更新 SSH 密钥",
"d876ff8da6": "API 密钥",
"96164f17cf": "用于 MCP 和 REST API 认证的 API 密钥",
"538ce1893b": "输入 API 密钥或留空以自动生成",
"a55c6d580b": "用于认证 MCP 和 REST API 请求。",
"e85c388332": "保存 API 密钥",
"927c25953b": "生成新密钥",
"fdad65b7be": "强制 HTTP 传输", "fdad65b7be": "强制 HTTP 传输",
"e033b5e2a3": "强制使用 HTTP 传输数据替代 WebRTC", "e033b5e2a3": "强制使用 HTTP 传输数据替代 WebRTC",
"bda22ca687": "USB 检测增强", "bda22ca687": "USB 检测增强",
@@ -387,6 +338,7 @@
"020b92cfbb": "启用 USB 复用", "020b92cfbb": "启用 USB 复用",
"9f55f64b0f": "USB 设备重新初始化", "9f55f64b0f": "USB 设备重新初始化",
"40dc677a89": "重新初始化 USB 设备配置", "40dc677a89": "重新初始化 USB 设备配置",
"02d2f33ec9": "重新初始化 USB 设备",
"f5ddf02991": "重启系统", "f5ddf02991": "重启系统",
"1dbbf194af": "重启设备系统", "1dbbf194af": "重启设备系统",
"1de72c4fc6": "重启", "1de72c4fc6": "重启",
@@ -395,10 +347,6 @@
"f43c0398a4": "重置配置", "f43c0398a4": "重置配置",
"0031dbef48": "重置配置,这将使你退出登录。部分配置重启后生效。", "0031dbef48": "重置配置,这将使你退出登录。部分配置重启后生效。",
"0d784092e8": "重置配置", "0d784092e8": "重置配置",
"115082e888": "清除 API 密钥?",
"78fcaed30d": "将 API 密钥留空将自动生成一个新的随机密钥。",
"81e8b4cd6b": "请确保在保存后使用新密钥更新您的客户端。",
"211730be68": "生成新密钥",
"a776e925bf": "重启系统?", "a776e925bf": "重启系统?",
"1f070051ff": "你确定重启系统吗?", "1f070051ff": "你确定重启系统吗?",
"f1a79f466e": "设备将重启,你将从 Web 界面断开连接。", "f1a79f466e": "设备将重启,你将从 Web 界面断开连接。",
@@ -455,12 +403,6 @@
"ef64a3770e": "控制 mDNS多播 DNS运行模式", "ef64a3770e": "控制 mDNS多播 DNS运行模式",
"be8226fe0c": "时间同步", "be8226fe0c": "时间同步",
"7e06bd28a6": "配置时间同步设置", "7e06bd28a6": "配置时间同步设置",
"a2323452ba": "HTTP 代理",
"7fedd1ea53": "配置程序 HTTP 代理(可选)",
"a30d487cba": "HTTPS 代理",
"3f4c7e23cb": "配置程序 HTTPS 代理(可选)",
"48d941841f": "ALL 代理",
"3a2cd1e4a7": "配置程序 ALL 代理(可选)",
"d4dccb8ca2": "保存设置", "d4dccb8ca2": "保存设置",
"8750a898cb": "IPv4 模式", "8750a898cb": "IPv4 模式",
"72c2543791": "配置 IPv4 模式", "72c2543791": "配置 IPv4 模式",
@@ -502,16 +444,7 @@
"5b404b3c98": "后台更新", "5b404b3c98": "后台更新",
"3723a3f846": "系统已更新到最新版本", "3723a3f846": "系统已更新到最新版本",
"a6b8796d51": "您的系统已运行最新版本。当前没有可用的更新。", "a6b8796d51": "您的系统已运行最新版本。当前没有可用的更新。",
"a8d2a89696": "签名文件缺失",
"480f05b41b": "当前固件缺少签名文件,无法完全验证完整性。",
"323ebc9620": "签名验证失败",
"21355c9ecb": "签名文件存在但与固件不匹配,可能存在篡改。",
"0b54a6c322": "未嵌入公钥",
"9beb932d5a": "此版本未嵌入 OTA 公钥,无法进行签名验证。",
"7c81553358": "再次检查", "7c81553358": "再次检查",
"3b5f9a1f01": "更新签名",
"d25bfbb978": "更新 kvm_app 的签名到最新版本。如果当前版本不是最新版本,签名验证将会失败。",
"1bdd158a19": "签名更新结果",
"217b416896": "更新可用", "217b416896": "更新可用",
"f00af8c98f": "新的更新可用,以增强系统性能和提高兼容性。我们建议更新以确保一切正常运行。", "f00af8c98f": "新的更新可用,以增强系统性能和提高兼容性。我们建议更新以确保一切正常运行。",
"8977c3f0b7": "下载代理加速前缀", "8977c3f0b7": "下载代理加速前缀",
@@ -520,22 +453,11 @@
"cffae9918d": "更新错误", "cffae9918d": "更新错误",
"49cba7cadf": "更新设备时发生错误。请稍后重试。", "49cba7cadf": "更新设备时发生错误。请稍后重试。",
"d849d5b330": "错误详情:", "d849d5b330": "错误详情:",
"fcfc8da1a3": "正在验证签名...", "2b2f7a6d7c": "按下 Ctrl+V 直接将本地剪贴板内容发送到远端主机",
"4e16083ef8": "请等待固件签名验证完成。",
"5367acff78": "签名状态不可用",
"634aac26af": "无法获取签名验证状态。",
"77de342f39": "签名已验证",
"20ac4a17cc": "固件签名已验证且有效。",
"36a41c937c": "无视频信号",
"d34da01de2": "启用粘贴快捷键",
"2b1a1676d1": "将文本从您的客户端复制到远程主机", "2b1a1676d1": "将文本从您的客户端复制到远程主机",
"604c45fbf2": "以下字符将不会被粘贴:", "604c45fbf2": "以下字符将不会被粘贴:",
"a7eb9efa0b": "使用键盘布局发送文本:", "a7eb9efa0b": "使用键盘布局发送文本:",
"2593d0a3d5": "确认粘贴", "2593d0a3d5": "确认粘贴",
"ac7e2a9783": "启用 OCR 快捷键",
"f529c51ee6": "OCR",
"607440855c": "在视频区域打开 OCR 选择模式",
"c3bf447eab": "打开",
"a0e3947a02": "宏", "a0e3947a02": "宏",
"276043dbf0": "创建新的键盘宏", "276043dbf0": "创建新的键盘宏",
"9605ef9593": "修改键盘宏", "9605ef9593": "修改键盘宏",
@@ -565,7 +487,6 @@
"21d104a54f": "处理中...", "21d104a54f": "处理中...",
"9844086d90": "未检测到 SD 卡", "9844086d90": "未检测到 SD 卡",
"b56e598918": "SD 卡挂载失败", "b56e598918": "SD 卡挂载失败",
"d646589704": "选择 MicroSD 格式化的文件系统",
"16eb8ed6c8": "格式化 MicroSD 卡", "16eb8ed6c8": "格式化 MicroSD 卡",
"a63d5e0260": "管理 KVM MicroSD Card 的共享文件夹", "a63d5e0260": "管理 KVM MicroSD Card 的共享文件夹",
"1d50425f88": "管理 KVM 存储中的共享文件夹", "1d50425f88": "管理 KVM 存储中的共享文件夹",
@@ -577,15 +498,10 @@
"8bf8854beb": "共", "8bf8854beb": "共",
"53e61336bb": "条结果", "53e61336bb": "条结果",
"d1f5a81904": "卸载 MicroSD 卡", "d1f5a81904": "卸载 MicroSD 卡",
"827048afc2": "格式化 SD 卡会清除所有数据。是否继续?",
"5874cf46ff": "SD 卡格式化成功", "5874cf46ff": "SD 卡格式化成功",
"7dc25305d4": "视频编码类型", "7dc25305d4": "视频编码类型",
"41d263a988": "视频质量", "41d263a988": "视频质量",
"d8d7a5377b": "RC 控制",
"a09ff5b350": "调整码率控制 QP 参数,在质量和码率之间取得更好的平衡",
"b1f9d95e72": "StepQp",
"bf1af7559b": "MinQp",
"6e4e284fcc": "MinIQp",
"df1b8f72f2": "DetlpQp",
"41f5283941": "NPU 应用", "41f5283941": "NPU 应用",
"466990072d": "启用 NPU 以进行物体检测", "466990072d": "启用 NPU 以进行物体检测",
"a6c2e30b8e": "视频增强", "a6c2e30b8e": "视频增强",
@@ -602,8 +518,6 @@
"85a003df9b": "EDID 文件", "85a003df9b": "EDID 文件",
"5c7a666766": "设置自定义 EDID", "5c7a666766": "设置自定义 EDID",
"7dad0ba758": "恢复默认", "7dad0ba758": "恢复默认",
"7b2fb72a68": "RC 高级配置",
"827048afc2": "格式化 SD 卡会清除所有数据。是否继续?",
"556c7553f1": "从 KVM MicroSD 卡挂载", "556c7553f1": "从 KVM MicroSD 卡挂载",
"b99cf1ecb7": "从 MicroSD 卡中管理和挂载镜像", "b99cf1ecb7": "从 MicroSD 卡中管理和挂载镜像",
"0a01e80566": "已从 KVM 存储挂载", "0a01e80566": "已从 KVM 存储挂载",

View File

@@ -1,118 +0,0 @@
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("+");
}

10
usb.go
View File

@@ -37,7 +37,7 @@ func initUsbGadget() {
go func() { go func() {
for { for {
checkUSBState() checkUSBState()
time.Sleep(2500 * time.Millisecond) time.Sleep(500 * time.Millisecond)
} }
}() }()
@@ -111,14 +111,6 @@ func rpcKeyboardReport(modifier uint8, keys []uint8) error {
return gadget.KeyboardReport(modifier, keys) 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 { func rpcAbsMouseReport(x, y int, buttons uint8) error {
return gadget.AbsMouseReport(x, y, buttons) return gadget.AbsMouseReport(x, y, buttons)
} }

View File

@@ -782,11 +782,7 @@ func rpcUnmountSDStorage() error {
return nil return nil
} }
func rpcFormatSDStorage(confirm bool, fsType string) error { func rpcFormatSDStorage(confirm bool) error {
validFsTypes := map[string]bool{"exfat": true, "fat32": true}
if !validFsTypes[fsType] {
fsType = "fat32"
}
if !confirm { if !confirm {
return fmt.Errorf("format not confirmed") return fmt.Errorf("format not confirmed")
} }
@@ -868,13 +864,7 @@ func rpcFormatSDStorage(confirm bool, fsType string) error {
return fmt.Errorf("failed to stat sd partition: %w", err) return fmt.Errorf("failed to stat sd partition: %w", err)
} }
var mkfsCmd *exec.Cmd mkfsOut, mkfsErr := exec.Command("mkfs.vfat", "-F", "32", "-n", "PICOKVM", "/dev/mmcblk1p1").CombinedOutput()
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 { if mkfsErr != nil {
return fmt.Errorf("failed to format sdcard: %w: %s", mkfsErr, strings.TrimSpace(string(mkfsOut))) return fmt.Errorf("failed to format sdcard: %w: %s", mkfsErr, strings.TrimSpace(string(mkfsOut)))
} }

View File

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

16
vpn.go
View File

@@ -332,7 +332,7 @@ func rpcStartFrpc(frpcToml string) error {
if err := os.WriteFile(frpcTomlPath, []byte(frpcToml), 0600); err != nil { if err := os.WriteFile(frpcTomlPath, []byte(frpcToml), 0600); err != nil {
return err return err
} }
cmd := exec.Command(resolveVpnToolBinary("frpc", "frpc"), "-c", frpcTomlPath) cmd := exec.Command("frpc", "-c", frpcTomlPath)
cmd.Stdout = nil cmd.Stdout = nil
cmd.Stderr = nil cmd.Stderr = nil
logFile, err := os.OpenFile(frpcLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) logFile, err := os.OpenFile(frpcLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
@@ -386,9 +386,7 @@ type CloudflaredStatus struct {
} }
func cloudflaredRunning() bool { func cloudflaredRunning() bool {
// Only treat long-running tunnel process as running. cmd := exec.Command("pgrep", "-x", "cloudflared")
// This avoids false positives from short-lived version checks like `cloudflared -v`.
cmd := exec.Command("pgrep", "-f", `cloudflared.*tunnel.*run`)
return cmd.Run() == nil return cmd.Run() == nil
} }
@@ -403,7 +401,7 @@ func rpcStartCloudflared(token string) error {
if token == "" { if token == "" {
return fmt.Errorf("cloudflared token is empty") return fmt.Errorf("cloudflared token is empty")
} }
cmd := exec.Command(resolveVpnToolBinary("cloudflared", "cloudflared"), "tunnel", "run", "--token", token) cmd := exec.Command("cloudflared", "tunnel", "run", "--token", token)
logFile, err := os.OpenFile(cloudflaredLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) logFile, err := os.OpenFile(cloudflaredLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil { if err != nil {
return err return err
@@ -523,7 +521,7 @@ func rpcGetEasyTierLog() (string, error) {
} }
func rpcGetEasyTierNodeInfo() (string, error) { func rpcGetEasyTierNodeInfo() (string, error) {
cmd := exec.Command(resolveVpnToolBinary("easytier", "easytier-cli"), "node") cmd := exec.Command("easytier-cli", "node")
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get easytier node info: %w", err) return "", fmt.Errorf("failed to get easytier node info: %w", err)
@@ -545,7 +543,7 @@ func rpcStartEasyTier(name, secret, node string) error {
return fmt.Errorf("easytier config is invalid") return fmt.Errorf("easytier config is invalid")
} }
cmd := exec.Command(resolveVpnToolBinary("easytier", "easytier-core"), "-d", "--network-name", name, "--network-secret", secret, "-p", node) cmd := exec.Command("easytier-core", "-d", "--network-name", name, "--network-secret", secret, "-p", node)
cmd.Stdout = nil cmd.Stdout = nil
cmd.Stderr = nil cmd.Stderr = nil
logFile, err := os.OpenFile(easytierLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) logFile, err := os.OpenFile(easytierLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
@@ -641,7 +639,7 @@ func rpcGetVntLog() (string, error) {
} }
func rpcGetVntInfo() (string, error) { func rpcGetVntInfo() (string, error) {
cmd := exec.Command(resolveVpnToolBinary("vnt", "vnt-cli"), "--info") cmd := exec.Command("vnt-cli", "--info")
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get vnt info: %w", err) return "", fmt.Errorf("failed to get vnt info: %w", err)
@@ -709,7 +707,7 @@ func rpcStartVnt(configMode, token, deviceId, name, serverAddr, configFile strin
args = append(args, "--compressor", "lz4") args = append(args, "--compressor", "lz4")
} }
cmd := exec.Command(resolveVpnToolBinary("vnt", "vnt-cli"), args...) cmd := exec.Command("vnt-cli", args...)
cmd.Stdout = nil cmd.Stdout = nil
cmd.Stderr = nil cmd.Stderr = nil
logFile, err := os.OpenFile(vntLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) logFile, err := os.OpenFile(vntLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)

16
web.go
View File

@@ -168,7 +168,6 @@ func setupRouter() *gin.Engine {
protected.GET("/storage/download", handleDownloadHttp) protected.GET("/storage/download", handleDownloadHttp)
protected.GET("/storage/sd-download", handleSDDownloadHttp) protected.GET("/storage/sd-download", handleSDDownloadHttp)
protected.POST("/api/rpc", handleRpcRequest) protected.POST("/api/rpc", handleRpcRequest)
protected.GET("/api/ice-servers", handleGetIceServers)
protected.GET("/terminal/ws", handleTerminalWS) protected.GET("/terminal/ws", handleTerminalWS)
protected.GET("/serial/ws", handleSerialWS) protected.GET("/serial/ws", handleSerialWS)
protected.GET("/video/stream", handleVideoStream) protected.GET("/video/stream", handleVideoStream)
@@ -908,16 +907,6 @@ func handleRpcRequest(c *gin.Context) {
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
} }
func handleGetIceServers(c *gin.Context) {
LoadConfig()
servers, err := rpcGetIceServers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"iceServers": servers})
}
func handleVideoStream(c *gin.Context) { func handleVideoStream(c *gin.Context) {
logger.Info().Msg("HTTP video stream request received") logger.Info().Msg("HTTP video stream request received")
@@ -942,23 +931,20 @@ func handleVideoStream(c *gin.Context) {
for { for {
select { select {
case frame, ok := <-ch: case data, ok := <-ch:
if !ok { if !ok {
logger.Info().Int("total_frames", frameCount).Msg("video broadcaster channel closed") logger.Info().Int("total_frames", frameCount).Msg("video broadcaster channel closed")
return return
} }
data := frame.Data()
frameCount++ frameCount++
if frameCount == 1 { if frameCount == 1 {
logger.Info().Int("size", len(data)).Msg("first video frame received") logger.Info().Int("size", len(data)).Msg("first video frame received")
} }
if _, err := c.Writer.Write(data); err != nil { if _, err := c.Writer.Write(data); err != nil {
logger.Warn().Err(err).Int("total_frames", frameCount).Msg("error writing video data") logger.Warn().Err(err).Int("total_frames", frameCount).Msg("error writing video data")
frame.Release()
return return
} }
c.Writer.Flush() c.Writer.Flush()
frame.Release()
case <-ctx.Done(): case <-ctx.Done():
logger.Info().Int("total_frames", frameCount).Msg("client disconnected") logger.Info().Int("total_frames", frameCount).Msg("client disconnected")
return return

View File

@@ -22,6 +22,7 @@ type Session struct {
//AudioTrack *webrtc.TrackLocalStaticSample //AudioTrack *webrtc.TrackLocalStaticSample
ControlChannel *webrtc.DataChannel ControlChannel *webrtc.DataChannel
RPCChannel *webrtc.DataChannel RPCChannel *webrtc.DataChannel
HidChannel *webrtc.DataChannel
DiskChannel *webrtc.DataChannel DiskChannel *webrtc.DataChannel
shouldUmountVirtualMedia bool shouldUmountVirtualMedia bool
} }
@@ -33,33 +34,6 @@ type SessionConfig struct {
Logger *zerolog.Logger Logger *zerolog.Logger
} }
const DefaultSTUN = "stun:stun.l.google.com:19302"
func buildICEServers() []webrtc.ICEServer {
if config == nil {
LoadConfig()
}
stunURL := config.STUN
if stunURL == "" {
stunURL = DefaultSTUN
}
servers := []webrtc.ICEServer{{URLs: []string{stunURL}}}
for _, turnServer := range config.TurnServers {
if turnServer.URL == "" {
continue
}
servers = append(servers, webrtc.ICEServer{
URLs: []string{turnServer.URL},
Username: turnServer.Username,
Credential: turnServer.Credential,
})
}
return servers
}
func (s *Session) ExchangeOffer(offerStr string) (string, error) { func (s *Session) ExchangeOffer(offerStr string) (string, error) {
b, err := base64.StdEncoding.DecodeString(offerStr) b, err := base64.StdEncoding.DecodeString(offerStr)
if err != nil { if err != nil {
@@ -112,7 +86,16 @@ func newSession(sessionConfig SessionConfig) (*Session, error) {
scopedLogger = webrtcLogger scopedLogger = webrtcLogger
} }
iceServers := buildICEServers() iceServers := []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
}
if config.STUN != "" {
iceServers = append(iceServers, webrtc.ICEServer{
URLs: []string{config.STUN},
})
}
api := webrtc.NewAPI(webrtc.WithSettingEngine(webrtcSettingEngine)) api := webrtc.NewAPI(webrtc.WithSettingEngine(webrtcSettingEngine))
peerConnection, err := api.NewPeerConnection(webrtc.Configuration{ peerConnection, err := api.NewPeerConnection(webrtc.Configuration{