mirror of
https://github.com/luckfox-eng29/kvm.git
synced 2026-06-09 03:23:38 +02:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec0581c1f4 | ||
|
|
2a2890e7b3 | ||
|
|
40f5af2120 | ||
|
|
b1090c9493 | ||
|
|
18f7d8425f | ||
|
|
233e6e9cd6 | ||
|
|
d47bca1940 | ||
|
|
bf84660c8b | ||
|
|
7cef8baa0d | ||
|
|
95f2b6bada | ||
|
|
225ee790d2 | ||
|
|
2a5c0e585a | ||
|
|
4798bde987 | ||
|
|
21fa9533d1 | ||
|
|
b7cf769cb2 | ||
|
|
a3f65e4893 | ||
|
|
d3c7f6e01b | ||
|
|
fda0138dd1 | ||
|
|
6292537c23 | ||
|
|
a1da483b27 | ||
|
|
97faba9dac | ||
|
|
141c16b9f7 | ||
|
|
461516665c | ||
|
|
f1a6c75fc0 | ||
|
|
d5bfaffd86 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ node_modules/
|
||||
ui/.i18n_extractor.json
|
||||
|
||||
device-tests.tar.gz
|
||||
.worktrees/
|
||||
|
||||
30
Makefile
30
Makefile
@@ -2,12 +2,19 @@ BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
|
||||
BUILDDATE ?= $(shell date -u +%FT%T%z)
|
||||
BUILDTS ?= $(shell date -u +%s)
|
||||
REVISION ?= $(shell git rev-parse HEAD)
|
||||
VERSION_DEV ?= 0.1.2-dev
|
||||
VERSION ?= 0.1.2
|
||||
VERSION_DEV ?= 0.1.3-dev
|
||||
VERSION ?= 0.1.3
|
||||
|
||||
PROMETHEUS_TAG := github.com/prometheus/common/version
|
||||
KVM_PKG_NAME := kvm
|
||||
|
||||
# OTA signing key path (Ed25519 private key for auto-signing at build time)
|
||||
OTA_SIGNING_KEY ?=
|
||||
|
||||
# OTA signing public key (hex-encoded Ed25519 public key, 64 hex chars)
|
||||
# Default empty = signature verification disabled (backward compatible)
|
||||
OTA_PUBLIC_KEY ?=
|
||||
|
||||
GO_BUILD_ARGS := -tags netgo
|
||||
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
|
||||
GO_LDFLAGS := \
|
||||
@@ -15,7 +22,8 @@ GO_LDFLAGS := \
|
||||
-X $(PROMETHEUS_TAG).Branch=$(BRANCH) \
|
||||
-X $(PROMETHEUS_TAG).BuildDate=$(BUILDDATE) \
|
||||
-X $(PROMETHEUS_TAG).Revision=$(REVISION) \
|
||||
-X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS)
|
||||
-X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS) \
|
||||
-X $(KVM_PKG_NAME).builtOtaPublicKey=$(OTA_PUBLIC_KEY)
|
||||
|
||||
GO_CMD := GOOS=linux GOARCH=arm GOARM=7 go
|
||||
BIN_DIR := $(shell pwd)/bin
|
||||
@@ -28,6 +36,12 @@ build_dev:
|
||||
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
|
||||
$(GO_RELEASE_BUILD_ARGS) \
|
||||
-o $(BIN_DIR)/kvm_app cmd/main.go
|
||||
@if [ -n "$(OTA_SIGNING_KEY)" ]; then \
|
||||
echo "Signing $(BIN_DIR)/kvm_app..."; \
|
||||
go run cmd/main.go cli signer sign --key "$(OTA_SIGNING_KEY)" $(BIN_DIR)/kvm_app; \
|
||||
else \
|
||||
echo "OTA_SIGNING_KEY not set, skipping signing."; \
|
||||
fi
|
||||
|
||||
frontend:
|
||||
cd ui && npm ci && npm run build:device
|
||||
@@ -38,3 +52,13 @@ build_release: frontend
|
||||
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" \
|
||||
$(GO_RELEASE_BUILD_ARGS) \
|
||||
-o bin/kvm_app cmd/main.go
|
||||
@if [ -n "$(OTA_SIGNING_KEY)" ]; then \
|
||||
echo "Signing bin/kvm_app..."; \
|
||||
go run cmd/main.go cli signer sign --key "$(OTA_SIGNING_KEY)" bin/kvm_app; \
|
||||
else \
|
||||
echo "OTA_SIGNING_KEY not set, skipping signing."; \
|
||||
fi
|
||||
|
||||
sign:
|
||||
@echo "Signing firmware files..."
|
||||
go run cmd/main.go cli signer sign --key $(KEY) $(FILES)
|
||||
|
||||
389
api.go
Normal file
389
api.go
Normal file
@@ -0,0 +1,389 @@
|
||||
package kvm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func StartAPIServer(port int) {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(corsMiddleware())
|
||||
r.Use(apiKeyAuthMiddleware(config.APIKey))
|
||||
|
||||
// Health check (no auth required)
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"status": "ok",
|
||||
"device": GetDeviceID(),
|
||||
"version": builtAppVersion,
|
||||
})
|
||||
})
|
||||
|
||||
// API routes
|
||||
api := r.Group("/api/lan")
|
||||
{
|
||||
// Mouse
|
||||
api.POST("/mouse/absolute", handleAPIMouseAbsolute)
|
||||
api.POST("/mouse/relative", handleAPIMouseRelative)
|
||||
api.POST("/mouse/click", handleAPIMouseClick)
|
||||
api.POST("/mouse/scroll", handleAPIMouseScroll)
|
||||
|
||||
// Keyboard
|
||||
api.POST("/keyboard/key", handleAPIKeyboardKey)
|
||||
api.POST("/keyboard/combo", handleAPIKeyboardCombo)
|
||||
api.POST("/keyboard/type", handleAPIKeyboardType)
|
||||
|
||||
// Capture
|
||||
api.GET("/capture", handleAPICapture)
|
||||
|
||||
// Status
|
||||
api.GET("/status", handleAPIStatus)
|
||||
api.GET("/video/state", handleAPIVideoState)
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf(":%d", port)
|
||||
logger.Info().Str("addr", addr).Msg("Starting LAN API server")
|
||||
if err := r.Run(addr); err != nil {
|
||||
logger.Error().Err(err).Msg("LAN API server failed")
|
||||
}
|
||||
}
|
||||
|
||||
// === Middleware ===
|
||||
|
||||
func corsMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func apiKeyAuthMiddleware(expectedKey string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Skip auth for health endpoint
|
||||
if c.Request.URL.Path == "/health" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Skip auth for localhost
|
||||
remoteAddr := c.Request.RemoteAddr
|
||||
if strings.HasPrefix(remoteAddr, "127.0.0.1:") ||
|
||||
strings.HasPrefix(remoteAddr, "[::1]:") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// If no API key configured, reject LAN requests
|
||||
if expectedKey == "" {
|
||||
c.AbortWithStatusJSON(401, gin.H{"error": "API key not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
auth := c.GetHeader("Authorization")
|
||||
var key string
|
||||
if _, err := fmt.Sscanf(auth, "Bearer %s", &key); err != nil {
|
||||
c.AbortWithStatusJSON(401, gin.H{"error": "missing or invalid authorization"})
|
||||
return
|
||||
}
|
||||
if !strings.EqualFold(key, expectedKey) {
|
||||
c.AbortWithStatusJSON(401, gin.H{"error": "invalid api key"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// === API Handlers: Mouse ===
|
||||
|
||||
func handleAPIMouseAbsolute(c *gin.Context) {
|
||||
var req struct {
|
||||
X int `json:"x" binding:"required"`
|
||||
Y int `json:"y" binding:"required"`
|
||||
Buttons uint8 `json:"buttons"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_, err := callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
|
||||
"x": req.X, "y": req.Y, "buttons": req.Buttons,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "x": req.X, "y": req.Y})
|
||||
}
|
||||
|
||||
func handleAPIMouseRelative(c *gin.Context) {
|
||||
var req struct {
|
||||
Dx int8 `json:"dx" binding:"required"`
|
||||
Dy int8 `json:"dy" binding:"required"`
|
||||
Buttons uint8 `json:"buttons"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_, err := callRPCHandler(rpcHandlers["relMouseReport"], map[string]interface{}{
|
||||
"dx": req.Dx, "dy": req.Dy, "buttons": req.Buttons,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "dx": req.Dx, "dy": req.Dy})
|
||||
}
|
||||
|
||||
func handleAPIMouseClick(c *gin.Context) {
|
||||
var req struct {
|
||||
Button string `json:"button" binding:"required"`
|
||||
Double bool `json:"double"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var buttons uint8
|
||||
switch req.Button {
|
||||
case "left":
|
||||
buttons = 1
|
||||
case "right":
|
||||
buttons = 2
|
||||
case "middle":
|
||||
buttons = 4
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("unknown button: %s", req.Button)})
|
||||
return
|
||||
}
|
||||
clickCount := 1
|
||||
if req.Double {
|
||||
clickCount = 2
|
||||
}
|
||||
for i := 0; i < clickCount; i++ {
|
||||
_, err := callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
|
||||
"x": 0, "y": 0, "buttons": buttons,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_, err = callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
|
||||
"x": 0, "y": 0, "buttons": 0,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "button": req.Button, "clicks": clickCount})
|
||||
}
|
||||
|
||||
func handleAPIMouseScroll(c *gin.Context) {
|
||||
var req struct {
|
||||
Delta int8 `json:"delta" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_, err := callRPCHandler(rpcHandlers["wheelReport"], map[string]interface{}{
|
||||
"wheelY": req.Delta,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "delta": req.Delta})
|
||||
}
|
||||
|
||||
// === API Handlers: Keyboard ===
|
||||
|
||||
func handleAPIKeyboardKey(c *gin.Context) {
|
||||
var req struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
keyCode, ok := keyNameToCode[req.Key]
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("unknown key: %s", req.Key)})
|
||||
return
|
||||
}
|
||||
action := strings.ToLower(req.Action)
|
||||
if action == "" {
|
||||
action = "press"
|
||||
}
|
||||
switch action {
|
||||
case "press":
|
||||
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
||||
"modifier": 0, "keys": []uint8{keyCode},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
||||
"modifier": 0, "keys": []uint8{},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
case "down":
|
||||
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
||||
"modifier": 0, "keys": []uint8{keyCode},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
case "up":
|
||||
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
||||
"modifier": 0, "keys": []uint8{},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("unknown action: %s", req.Action)})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "key": req.Key, "action": action})
|
||||
}
|
||||
|
||||
func handleAPIKeyboardCombo(c *gin.Context) {
|
||||
var req struct {
|
||||
Keys []string `json:"keys" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var keys []uint8
|
||||
var modifier uint8
|
||||
for _, keyName := range req.Keys {
|
||||
switch strings.ToLower(keyName) {
|
||||
case "ctrl", "control":
|
||||
modifier |= 0x01
|
||||
continue
|
||||
case "shift":
|
||||
modifier |= 0x02
|
||||
continue
|
||||
case "alt":
|
||||
modifier |= 0x04
|
||||
continue
|
||||
case "meta", "win", "cmd":
|
||||
modifier |= 0x08
|
||||
continue
|
||||
}
|
||||
keyCode, ok := keyNameToCode[keyName]
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("unknown key: %s", keyName)})
|
||||
return
|
||||
}
|
||||
keys = append(keys, keyCode)
|
||||
}
|
||||
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
||||
"modifier": modifier, "keys": keys,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
||||
"modifier": 0, "keys": []uint8{},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "keys": req.Keys})
|
||||
}
|
||||
|
||||
func handleAPIKeyboardType(c *gin.Context) {
|
||||
var req struct {
|
||||
Text string `json:"text" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
for _, char := range req.Text {
|
||||
keyCode, modifier, ok := charToKeyCode(uint8(char))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
||||
"modifier": modifier, "keys": []uint8{keyCode},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
||||
"modifier": 0, "keys": []uint8{},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "text": req.Text})
|
||||
}
|
||||
|
||||
// === API Handlers: Capture ===
|
||||
|
||||
func handleAPICapture(c *gin.Context) {
|
||||
data, err := captureScreenshot("jpeg")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "image/jpeg", data)
|
||||
}
|
||||
|
||||
// === API Handlers: Status ===
|
||||
|
||||
func handleAPIStatus(c *gin.Context) {
|
||||
state := lastVideoState
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"video": gin.H{
|
||||
"ready": state.Ready,
|
||||
"width": state.Width,
|
||||
"height": state.Height,
|
||||
"fps": state.FramePerSecond,
|
||||
"error": state.Error,
|
||||
},
|
||||
"device": gin.H{
|
||||
"name": "PicoKVM",
|
||||
"version": builtAppVersion,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func handleAPIVideoState(c *gin.Context) {
|
||||
state := lastVideoState
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ready": state.Ready,
|
||||
"width": state.Width,
|
||||
"height": state.Height,
|
||||
"fps": state.FramePerSecond,
|
||||
"error": state.Error,
|
||||
})
|
||||
}
|
||||
552
cli.go
Normal file
552
cli.go
Normal file
@@ -0,0 +1,552 @@
|
||||
package kvm
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var cliRootCmd = &cobra.Command{
|
||||
Use: "kvm_app cli",
|
||||
Short: "PicoKVM CLI tools",
|
||||
Long: `Command line interface for controlling HID devices via API`,
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
func RunCLI(args []string) {
|
||||
cliRootCmd.SetArgs(args)
|
||||
if err := cliRootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var apiBaseURL = "http://localhost:8080/api/lan"
|
||||
|
||||
func apiPost(path string, body interface{}) error {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := http.Post(apiBaseURL+path, "application/json", strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to API server: %w\nIs kvm_app running?", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
var errResp map[string]interface{}
|
||||
if json.Unmarshal(bodyBytes, &errResp) == nil {
|
||||
if msg, ok := errResp["error"].(string); ok {
|
||||
return fmt.Errorf("API error: %s", msg)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("API error: HTTP %d - %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiGetBytes(path string) ([]byte, error) {
|
||||
resp, err := http.Get(apiBaseURL + path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to API server: %w\nIs kvm_app running?", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("API error: HTTP %d - %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func apiGetJSON(path string) (map[string]interface{}, error) {
|
||||
resp, err := http.Get(apiBaseURL + path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to API server: %w\nIs kvm_app running?", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("API error: HTTP %d - %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ShowHelp() {
|
||||
fmt.Println(`PicoKVM - Remote KVM over IP
|
||||
|
||||
Usage:
|
||||
kvm_app Start the main web service (default)
|
||||
kvm_app cli <command> Run CLI commands for HID control
|
||||
|
||||
Commands:
|
||||
cli HID control via API (mouse, keyboard, capture, status, signer)
|
||||
|
||||
Examples:
|
||||
kvm_app cli mouse move 100 200
|
||||
kvm_app cli mouse click left
|
||||
kvm_app cli keyboard type "Hello World"
|
||||
kvm_app cli capture
|
||||
kvm_app cli status
|
||||
kvm_app cli signer keygen
|
||||
kvm_app cli signer sign --key ota_ed25519.key firmware.bin
|
||||
kvm_app cli signer verify --pubkey ota_ed25519.pub firmware.bin
|
||||
|
||||
For more help on a specific command, use:
|
||||
kvm_app cli --help`)
|
||||
}
|
||||
|
||||
// === Mouse Commands ===
|
||||
|
||||
var mouseCmd = &cobra.Command{
|
||||
Use: "mouse",
|
||||
Short: "Mouse control commands",
|
||||
}
|
||||
|
||||
var mouseMoveCmd = &cobra.Command{
|
||||
Use: "move [x] [y]",
|
||||
Short: "Move mouse to position",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
x, err := strconv.Atoi(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid x coordinate: %w", err)
|
||||
}
|
||||
y, err := strconv.Atoi(args[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid y coordinate: %w", err)
|
||||
}
|
||||
|
||||
relative, _ := cmd.Flags().GetBool("relative")
|
||||
if relative {
|
||||
return apiPost("/mouse/relative", map[string]interface{}{
|
||||
"dx": x, "dy": y,
|
||||
})
|
||||
}
|
||||
return apiPost("/mouse/absolute", map[string]interface{}{
|
||||
"x": x, "y": y,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var mouseClickCmd = &cobra.Command{
|
||||
Use: "click [button]",
|
||||
Short: "Click a mouse button",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return apiPost("/mouse/click", map[string]interface{}{
|
||||
"button": args[0],
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var mouseScrollCmd = &cobra.Command{
|
||||
Use: "scroll [delta]",
|
||||
Short: "Scroll mouse wheel",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
delta, err := strconv.Atoi(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid delta: %w", err)
|
||||
}
|
||||
return apiPost("/mouse/scroll", map[string]interface{}{
|
||||
"delta": delta,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var mouseDownCmd = &cobra.Command{
|
||||
Use: "down [button]",
|
||||
Short: "Press and hold a mouse button",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// API doesn't support hold yet, simulate with click
|
||||
return apiPost("/mouse/click", map[string]interface{}{
|
||||
"button": args[0],
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var mouseUpCmd = &cobra.Command{
|
||||
Use: "up",
|
||||
Short: "Release all mouse buttons",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// API doesn't support hold yet, no-op
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// === Keyboard Commands ===
|
||||
|
||||
var keyboardCmd = &cobra.Command{
|
||||
Use: "keyboard",
|
||||
Short: "Keyboard control commands",
|
||||
}
|
||||
|
||||
var keyboardKeyCmd = &cobra.Command{
|
||||
Use: "key [keyname]",
|
||||
Short: "Press a key",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return apiPost("/keyboard/key", map[string]interface{}{
|
||||
"key": args[0],
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var keyboardComboCmd = &cobra.Command{
|
||||
Use: "combo [keys...]",
|
||||
Short: "Press a key combination",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return apiPost("/keyboard/combo", map[string]interface{}{
|
||||
"keys": args,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var keyboardTypeCmd = &cobra.Command{
|
||||
Use: "type [text]",
|
||||
Short: "Type a text string",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return apiPost("/keyboard/type", map[string]interface{}{
|
||||
"text": args[0],
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// === Capture Command ===
|
||||
|
||||
var captureScreenshotPath = "/userdata/picokvm/screenshot/kvm_screenshot.jpg"
|
||||
|
||||
var captureCmd = &cobra.Command{
|
||||
Use: "capture",
|
||||
Short: "Capture a JPEG screenshot",
|
||||
Long: "Capture screenshot using hardware JPEG encoder and save to fixed path",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
data, err := apiGetBytes("/capture")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(captureScreenshotPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write: %w", err)
|
||||
}
|
||||
fmt.Printf("Screenshot saved to %s (%d bytes)\n", captureScreenshotPath, len(data))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// === Status Command ===
|
||||
|
||||
var statusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show device status",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
resp, err := apiGetJSON("/video/state")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Video State:\n")
|
||||
fmt.Printf(" Ready: %v\n", resp["ready"])
|
||||
fmt.Printf(" Error: %s\n", resp["error"])
|
||||
fmt.Printf(" Size: %dx%d\n", int(resp["width"].(float64)), int(resp["height"].(float64)))
|
||||
fmt.Printf(" FPS: %.1f\n", resp["fps"])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// === Key Maps ===
|
||||
|
||||
var keyNameToCode = map[string]uint8{
|
||||
"a": 0x04, "b": 0x05, "c": 0x06, "d": 0x07,
|
||||
"e": 0x08, "f": 0x09, "g": 0x0A, "h": 0x0B,
|
||||
"i": 0x0C, "j": 0x0D, "k": 0x0E, "l": 0x0F,
|
||||
"m": 0x10, "n": 0x11, "o": 0x12, "p": 0x13,
|
||||
"q": 0x14, "r": 0x15, "s": 0x16, "t": 0x17,
|
||||
"u": 0x18, "v": 0x19, "w": 0x1A, "x": 0x1B,
|
||||
"y": 0x1C, "z": 0x1D,
|
||||
"1": 0x1E, "2": 0x1F, "3": 0x20, "4": 0x21,
|
||||
"5": 0x22, "6": 0x23, "7": 0x24, "8": 0x25,
|
||||
"9": 0x26, "0": 0x27,
|
||||
"Enter": 0x28,
|
||||
"Escape": 0x29,
|
||||
"Backspace": 0x2A,
|
||||
"Tab": 0x2B,
|
||||
"Space": 0x2C,
|
||||
"Minus": 0x2D,
|
||||
"Equal": 0x2E,
|
||||
"LeftBrace": 0x2F,
|
||||
"RightBrace": 0x30,
|
||||
"Backslash": 0x31,
|
||||
"Semicolon": 0x33,
|
||||
"Quote": 0x34,
|
||||
"Grave": 0x35,
|
||||
"Comma": 0x36,
|
||||
"Dot": 0x37,
|
||||
"Slash": 0x38,
|
||||
"CapsLock": 0x39,
|
||||
"F1": 0x3A, "F2": 0x3B, "F3": 0x3C, "F4": 0x3D,
|
||||
"F5": 0x3E, "F6": 0x3F, "F7": 0x40, "F8": 0x41,
|
||||
"F9": 0x42, "F10": 0x43, "F11": 0x44, "F12": 0x45,
|
||||
"PrintScreen": 0x46,
|
||||
"ScrollLock": 0x47,
|
||||
"Pause": 0x48,
|
||||
"Insert": 0x49,
|
||||
"Home": 0x4A,
|
||||
"PageUp": 0x4B,
|
||||
"Delete": 0x4C,
|
||||
"End": 0x4D,
|
||||
"PageDown": 0x4E,
|
||||
"Right": 0x4F,
|
||||
"Left": 0x50,
|
||||
"Down": 0x51,
|
||||
"Up": 0x52,
|
||||
}
|
||||
|
||||
func charToKeyCode(char uint8) (keyCode uint8, modifier uint8, ok bool) {
|
||||
if char >= 'a' && char <= 'z' {
|
||||
return keyNameToCode[string(char)], 0, true
|
||||
}
|
||||
if char >= 'A' && char <= 'Z' {
|
||||
return keyNameToCode[strings.ToLower(string(char))], 0x02, true
|
||||
}
|
||||
if char >= '0' && char <= '9' {
|
||||
return keyNameToCode[string(char)], 0, true
|
||||
}
|
||||
if char == ' ' {
|
||||
return 0x2C, 0, true
|
||||
}
|
||||
switch char {
|
||||
case '-':
|
||||
return 0x2D, 0, true
|
||||
case '=':
|
||||
return 0x2E, 0, true
|
||||
case '[':
|
||||
return 0x2F, 0, true
|
||||
case ']':
|
||||
return 0x30, 0, true
|
||||
case '\\':
|
||||
return 0x31, 0, true
|
||||
case ';':
|
||||
return 0x33, 0, true
|
||||
case '\'':
|
||||
return 0x34, 0, true
|
||||
case '`':
|
||||
return 0x35, 0, true
|
||||
case ',':
|
||||
return 0x36, 0, true
|
||||
case '.':
|
||||
return 0x37, 0, true
|
||||
case '/':
|
||||
return 0x38, 0, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
var signerCmd = &cobra.Command{
|
||||
Use: "signer",
|
||||
Short: "OTA firmware signing tool",
|
||||
Long: "Generate keys, sign, and verify OTA firmware using Ed25519",
|
||||
}
|
||||
|
||||
var signerKeygenCmd = &cobra.Command{
|
||||
Use: "keygen",
|
||||
Short: "Generate Ed25519 key pair",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
outputDir, _ := cmd.Flags().GetString("output-dir")
|
||||
|
||||
pubKeyPath := filepath.Join(outputDir, "ota_ed25519.pub")
|
||||
privKeyPath := filepath.Join(outputDir, "ota_ed25519.key")
|
||||
|
||||
if _, err := os.Stat(privKeyPath); err == nil {
|
||||
return fmt.Errorf("private key already exists at %s, refusing to overwrite", privKeyPath)
|
||||
}
|
||||
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating key pair: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating output directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(privKeyPath, privateKey, 0600); err != nil {
|
||||
return fmt.Errorf("writing private key: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(pubKeyPath, publicKey, 0644); err != nil {
|
||||
return fmt.Errorf("writing public key: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(hex.EncodeToString(publicKey))
|
||||
fmt.Fprintf(os.Stderr, "Private key: %s\n", privKeyPath)
|
||||
fmt.Fprintf(os.Stderr, "Public key: %s\n", pubKeyPath)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var signerSignCmd = &cobra.Command{
|
||||
Use: "sign --key <private-key> <firmware-file>",
|
||||
Short: "Sign a firmware file",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
keyArg, _ := cmd.Flags().GetString("key")
|
||||
filePath := args[0]
|
||||
|
||||
if keyArg == "" {
|
||||
return fmt.Errorf("--key is required")
|
||||
}
|
||||
|
||||
privateKey, err := os.ReadFile(keyArg)
|
||||
if err != nil {
|
||||
privateKey, err = hex.DecodeString(keyArg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid private key: not a valid file path or hex string")
|
||||
}
|
||||
}
|
||||
|
||||
if len(privateKey) != ed25519.PrivateKeySize {
|
||||
return fmt.Errorf("invalid private key size: got %d bytes, expected %d", len(privateKey), ed25519.PrivateKeySize)
|
||||
}
|
||||
|
||||
fileData, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading firmware file: %w", err)
|
||||
}
|
||||
|
||||
fileHash := sha256.Sum256(fileData)
|
||||
signature := ed25519.Sign(ed25519.PrivateKey(privateKey), fileHash[:])
|
||||
|
||||
sigPath := filePath + ".sig"
|
||||
if err := os.WriteFile(sigPath, signature, 0644); err != nil {
|
||||
return fmt.Errorf("writing signature: %w", err)
|
||||
}
|
||||
|
||||
hash := sha256.Sum256(fileData)
|
||||
fmt.Println(hex.EncodeToString(hash[:]))
|
||||
fmt.Fprintf(os.Stderr, "Signature written to: %s\n", sigPath)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var signerVerifyCmd = &cobra.Command{
|
||||
Use: "verify [--pubkey <pubkey-path-or-hex>] <firmware-file> [<sig-file>]",
|
||||
Short: "Verify firmware signature",
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pubKeyArg, _ := cmd.Flags().GetString("pubkey")
|
||||
filePath := args[0]
|
||||
sigPath := ""
|
||||
if len(args) > 1 {
|
||||
sigPath = args[1]
|
||||
}
|
||||
|
||||
if sigPath == "" {
|
||||
sigPath = filePath + ".sig"
|
||||
}
|
||||
|
||||
var publicKey ed25519.PublicKey
|
||||
|
||||
if pubKeyArg == "" {
|
||||
keyStr := strings.TrimSpace(builtOtaPublicKey)
|
||||
if keyStr == "" {
|
||||
return fmt.Errorf("no --pubkey provided and no OTA public key embedded in binary")
|
||||
}
|
||||
keyBytes, err := hex.DecodeString(keyStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decoding embedded public key hex: %w", err)
|
||||
}
|
||||
publicKey = ed25519.PublicKey(keyBytes)
|
||||
} else if _, err := os.Stat(pubKeyArg); err == nil {
|
||||
keyBytes, err := os.ReadFile(pubKeyArg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading public key file: %w", err)
|
||||
}
|
||||
publicKey = ed25519.PublicKey(keyBytes)
|
||||
} else {
|
||||
keyBytes, err := hex.DecodeString(pubKeyArg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decoding public key hex: %w", err)
|
||||
}
|
||||
publicKey = ed25519.PublicKey(keyBytes)
|
||||
}
|
||||
|
||||
if len(publicKey) != ed25519.PublicKeySize {
|
||||
return fmt.Errorf("invalid public key size: got %d bytes, expected %d", len(publicKey), ed25519.PublicKeySize)
|
||||
}
|
||||
|
||||
fileData, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading firmware file: %w", err)
|
||||
}
|
||||
|
||||
sigBytes, err := os.ReadFile(sigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading signature file: %w", err)
|
||||
}
|
||||
|
||||
if len(sigBytes) != ed25519.SignatureSize {
|
||||
return fmt.Errorf("invalid signature size: got %d bytes, expected %d", len(sigBytes), ed25519.SignatureSize)
|
||||
}
|
||||
|
||||
fileHash := sha256.Sum256(fileData)
|
||||
if !ed25519.Verify(publicKey, fileHash[:], sigBytes) {
|
||||
return fmt.Errorf("VERIFICATION FAILED: signature is invalid")
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "VERIFICATION OK: signature is valid\n")
|
||||
fmt.Fprintf(os.Stderr, "SHA256: %s\n", hex.EncodeToString(fileHash[:]))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
mouseMoveCmd.Flags().Bool("relative", false, "Use relative positioning")
|
||||
mouseCmd.AddCommand(mouseMoveCmd)
|
||||
mouseCmd.AddCommand(mouseClickCmd)
|
||||
mouseCmd.AddCommand(mouseScrollCmd)
|
||||
mouseCmd.AddCommand(mouseDownCmd)
|
||||
mouseCmd.AddCommand(mouseUpCmd)
|
||||
cliRootCmd.AddCommand(mouseCmd)
|
||||
|
||||
keyboardCmd.AddCommand(keyboardKeyCmd)
|
||||
keyboardCmd.AddCommand(keyboardComboCmd)
|
||||
keyboardCmd.AddCommand(keyboardTypeCmd)
|
||||
cliRootCmd.AddCommand(keyboardCmd)
|
||||
|
||||
cliRootCmd.AddCommand(captureCmd)
|
||||
|
||||
signerKeygenCmd.Flags().String("output-dir", ".", "Directory for key output")
|
||||
signerCmd.AddCommand(signerKeygenCmd)
|
||||
signerSignCmd.Flags().String("key", "", "Path to private key")
|
||||
signerCmd.AddCommand(signerSignCmd)
|
||||
signerVerifyCmd.Flags().String("pubkey", "", "Path to public key file or hex-encoded public key")
|
||||
signerCmd.AddCommand(signerVerifyCmd)
|
||||
cliRootCmd.AddCommand(signerCmd)
|
||||
|
||||
cliRootCmd.AddCommand(statusCmd)
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"kvm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "cli" {
|
||||
kvm.RunCLI(os.Args[2:])
|
||||
return
|
||||
}
|
||||
kvm.Main()
|
||||
}
|
||||
|
||||
132
config.go
132
config.go
@@ -2,6 +2,9 @@ package kvm
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -21,6 +24,12 @@ type WakeOnLanDevice struct {
|
||||
MacAddress string `json:"macAddress"`
|
||||
}
|
||||
|
||||
type TurnServer struct {
|
||||
URL string `json:"url"`
|
||||
Username string `json:"username"`
|
||||
Credential string `json:"credential"`
|
||||
}
|
||||
|
||||
// Constants for keyboard macro limits
|
||||
const (
|
||||
MaxMacrosPerDevice = 25
|
||||
@@ -81,6 +90,7 @@ func (m *KeyboardMacro) Validate() error {
|
||||
|
||||
type Config struct {
|
||||
STUN string `json:"stun"`
|
||||
TurnServers []TurnServer `json:"turn_servers"`
|
||||
JigglerEnabled bool `json:"jiggler_enabled"`
|
||||
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
||||
IncludePreRelease bool `json:"include_pre_release"`
|
||||
@@ -129,6 +139,7 @@ type Config struct {
|
||||
WireguardConfig WireguardConfig `json:"wireguard_config"`
|
||||
NpuAppEnabled bool `json:"npu_app_enabled"`
|
||||
Firewall *FirewallConfig `json:"firewall"`
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
|
||||
type FirewallConfig struct {
|
||||
@@ -183,8 +194,13 @@ type WireguardConfig struct {
|
||||
const configPath = "/userdata/kvm_config.json"
|
||||
const sdConfigPath = "/mnt/sdcard/kvm_config.json"
|
||||
|
||||
// builtOtaPublicKey is the hex-encoded Ed25519 public key for OTA signature verification,
|
||||
// injected via -ldflags at build time. Empty string disables signature verification.
|
||||
var builtOtaPublicKey = ""
|
||||
|
||||
var defaultConfig = &Config{
|
||||
STUN: "stun:stun.l.google.com:19302",
|
||||
TurnServers: []TurnServer{},
|
||||
AutoUpdateEnabled: false, // Set a default value
|
||||
ActiveExtension: "",
|
||||
KeyboardMacros: []KeyboardMacro{},
|
||||
@@ -244,6 +260,75 @@ var (
|
||||
configLock = &sync.Mutex{}
|
||||
)
|
||||
|
||||
type ConfigMigrationFunc func(raw json.RawMessage) (json.RawMessage, error)
|
||||
|
||||
var configMigrations = []ConfigMigrationFunc{
|
||||
migrateLocalAuthMode,
|
||||
}
|
||||
|
||||
func migrateLocalAuthMode(raw json.RawMessage) (json.RawMessage, error) {
|
||||
var rawMap map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &rawMap); err != nil {
|
||||
return nil, fmt.Errorf("migrateLocalAuthMode: failed to parse config JSON: %w", err)
|
||||
}
|
||||
|
||||
if authModeRaw, exists := rawMap["localAuthMode"]; exists {
|
||||
var authMode string
|
||||
if err := json.Unmarshal(authModeRaw, &authMode); err == nil {
|
||||
if authMode != "" {
|
||||
validModes := map[string]bool{"password": true, "noPassword": true}
|
||||
if !validModes[authMode] {
|
||||
rawMap["localAuthMode"] = json.RawMessage(`"password"`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
delete(rawMap, "localAuthMode")
|
||||
}
|
||||
}
|
||||
|
||||
result, err := json.Marshal(rawMap)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("migrateLocalAuthMode: failed to marshal migrated config: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func runMigrations(raw json.RawMessage) (json.RawMessage, bool, error) {
|
||||
current := raw
|
||||
didMigrate := false
|
||||
for i, migration := range configMigrations {
|
||||
migrated, err := migration(current)
|
||||
if err != nil {
|
||||
return current, didMigrate, fmt.Errorf("migration %d failed: %w", i, err)
|
||||
}
|
||||
if string(migrated) != string(current) {
|
||||
didMigrate = true
|
||||
logger.Info().Int("migration", i).Msg("config migrated")
|
||||
}
|
||||
current = migrated
|
||||
}
|
||||
return current, didMigrate, nil
|
||||
}
|
||||
|
||||
func writeRawConfig(path string, raw json.RawMessage) error {
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create config file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var indented bytes.Buffer
|
||||
if err := json.Indent(&indented, raw, "", " "); err != nil {
|
||||
return fmt.Errorf("failed to indent config JSON: %w", err)
|
||||
}
|
||||
|
||||
if _, err := indented.WriteTo(file); err != nil {
|
||||
return fmt.Errorf("failed to write config file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func LoadConfig() {
|
||||
configLock.Lock()
|
||||
defer configLock.Unlock()
|
||||
@@ -253,7 +338,6 @@ func LoadConfig() {
|
||||
return
|
||||
}
|
||||
|
||||
// load the default config
|
||||
if defaultConfig.UsbConfig.SerialNumber == "" {
|
||||
serialNumber, err := extractSerialNumber()
|
||||
if err != nil {
|
||||
@@ -265,24 +349,38 @@ func LoadConfig() {
|
||||
loadedConfig := *defaultConfig
|
||||
config = &loadedConfig
|
||||
|
||||
file, err := os.Open(configPath)
|
||||
rawData, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
logger.Debug().Msg("default config file doesn't exist, using default")
|
||||
logger.Debug().Msg("config file does not exist, using default")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// load and merge the default config with the user config
|
||||
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
|
||||
logger.Warn().Err(err).Msg("config file JSON parsing failed")
|
||||
os.Remove(configPath)
|
||||
if _, err := os.Stat(sdConfigPath); err == nil {
|
||||
os.Remove(sdConfigPath)
|
||||
migrated, didMigrate, err := runMigrations(json.RawMessage(rawData))
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("config migration failed, preserving corrupt file")
|
||||
corruptPath := configPath + ".corrupt"
|
||||
_ = os.Rename(configPath, corruptPath)
|
||||
logger.Info().Str("corrupt_path", corruptPath).Msg("corrupt config preserved for diagnosis")
|
||||
return
|
||||
}
|
||||
|
||||
if didMigrate {
|
||||
if writeErr := writeRawConfig(configPath, migrated); writeErr != nil {
|
||||
logger.Warn().Err(writeErr).Msg("failed to write migrated config, continuing with in-memory version")
|
||||
} else {
|
||||
logger.Info().Msg("migrated config saved to disk")
|
||||
SyncConfigSD(false)
|
||||
}
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(migrated, &loadedConfig); err != nil {
|
||||
logger.Warn().Err(err).Msg("config file JSON parsing failed, preserving corrupt file")
|
||||
corruptPath := configPath + ".corrupt"
|
||||
_ = os.Rename(configPath, corruptPath)
|
||||
logger.Info().Str("corrupt_path", corruptPath).Msg("corrupt config preserved for diagnosis")
|
||||
return
|
||||
}
|
||||
|
||||
// merge the user config with the default config
|
||||
if loadedConfig.UsbConfig == nil {
|
||||
loadedConfig.UsbConfig = defaultConfig.UsbConfig
|
||||
}
|
||||
@@ -299,6 +397,10 @@ func LoadConfig() {
|
||||
loadedConfig.Firewall = defaultConfig.Firewall
|
||||
}
|
||||
|
||||
if loadedConfig.TurnServers == nil {
|
||||
loadedConfig.TurnServers = []TurnServer{}
|
||||
}
|
||||
|
||||
config = &loadedConfig
|
||||
|
||||
logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel)
|
||||
@@ -397,6 +499,14 @@ func SaveConfig() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateAPIKey() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func ensureConfigLoaded() {
|
||||
if config == nil {
|
||||
LoadConfig()
|
||||
|
||||
25
go.mod
25
go.mod
@@ -1,14 +1,11 @@
|
||||
module kvm
|
||||
|
||||
go 1.23.4
|
||||
|
||||
toolchain go1.24.3
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
github.com/Masterminds/semver/v3 v3.3.1
|
||||
github.com/beevik/ntp v1.4.3
|
||||
github.com/coder/websocket v1.8.13
|
||||
github.com/coreos/go-oidc/v3 v3.11.0
|
||||
github.com/creack/pty v1.1.23
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/gin-contrib/logger v1.2.6
|
||||
@@ -17,8 +14,10 @@ require (
|
||||
github.com/guregu/null/v6 v6.0.0
|
||||
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf
|
||||
github.com/hanwen/go-fuse/v2 v2.8.0
|
||||
github.com/mark3labs/mcp-go v0.52.0
|
||||
github.com/pion/logging v0.2.3
|
||||
github.com/pion/mdns/v2 v2.0.7
|
||||
github.com/pion/rtp v1.8.18
|
||||
github.com/pion/webrtc/v4 v4.0.16
|
||||
github.com/pojntfx/go-nbd v0.3.2
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
@@ -27,6 +26,7 @@ require (
|
||||
github.com/psanford/httpreadat v0.1.0
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/vishvananda/netlink v1.3.0
|
||||
go.bug.st/serial v1.6.2
|
||||
@@ -47,11 +47,12 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
@@ -63,35 +64,29 @@ require (
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pilebones/go-udev v0.9.0 // indirect
|
||||
github.com/pion/datachannel v1.5.10 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.12 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.6 // indirect
|
||||
github.com/pion/ice/v2 v2.3.36 // indirect
|
||||
github.com/pion/ice/v4 v4.0.10 // indirect
|
||||
github.com/pion/interceptor v0.1.40 // indirect
|
||||
github.com/pion/mdns v0.0.12 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.15 // indirect
|
||||
github.com/pion/rtp v1.8.18 // indirect
|
||||
github.com/pion/sctp v1.8.39 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.13 // indirect
|
||||
github.com/pion/srtp/v2 v2.0.20 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.5 // indirect
|
||||
github.com/pion/stun v0.6.1 // indirect
|
||||
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||
github.com/pion/transport/v2 v2.2.10 // indirect
|
||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||
github.com/pion/turn/v2 v2.1.6 // indirect
|
||||
github.com/pion/turn/v4 v4.0.2 // indirect
|
||||
github.com/pion/webrtc/v3 v3.3.5 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.11.0 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/vishvananda/netns v0.0.4 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/arch v0.17.0 // indirect
|
||||
golang.org/x/oauth2 v0.24.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
109
go.sum
109
go.sum
@@ -18,9 +18,8 @@ github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJ
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
|
||||
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
|
||||
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
|
||||
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
|
||||
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
|
||||
@@ -28,6 +27,10 @@ github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
@@ -38,8 +41,6 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -54,7 +55,8 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ=
|
||||
@@ -63,6 +65,8 @@ github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf h1:JO6ISZIvEUitto
|
||||
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
|
||||
github.com/hanwen/go-fuse/v2 v2.8.0 h1:wV8rG7rmCz8XHSOwBZhG5YcVqcYjkzivjmbaMafPlAs=
|
||||
github.com/hanwen/go-fuse/v2 v2.8.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
@@ -71,7 +75,6 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
@@ -83,6 +86,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mark3labs/mcp-go v0.52.0 h1:uRSzupNSUyPGDpF4owY5X4zEpACPwBnlM3FAFuXN6gQ=
|
||||
github.com/mark3labs/mcp-go v0.52.0/go.mod h1:Zg9cB2HdwdMMVgY0xtTzq3KvYIOJQDsaut+jWjwDaQY=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
@@ -105,59 +110,34 @@ github.com/pilebones/go-udev v0.9.0 h1:N1uEO/SxUwtIctc0WLU0t69JeBxIYEYnj8lT/Nabl
|
||||
github.com/pilebones/go-udev v0.9.0/go.mod h1:T2eI2tUSK0hA2WS5QLjXJUfQkluZQu+18Cqvem3CaXI=
|
||||
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
||||
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
|
||||
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||
github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk=
|
||||
github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=
|
||||
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
|
||||
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
|
||||
github.com/pion/ice/v2 v2.3.36 h1:SopeXiVbbcooUg2EIR8sq4b13RQ8gzrkkldOVg+bBsc=
|
||||
github.com/pion/ice/v2 v2.3.36/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ=
|
||||
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
|
||||
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
|
||||
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
|
||||
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
|
||||
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
|
||||
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
|
||||
github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8=
|
||||
github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk=
|
||||
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
|
||||
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
|
||||
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
|
||||
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
|
||||
github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
|
||||
github.com/pion/rtp v1.8.18 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU=
|
||||
github.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
|
||||
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
|
||||
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
|
||||
github.com/pion/sdp/v3 v3.0.13 h1:uN3SS2b+QDZnWXgdr69SM8KB4EbcnPnPf2Laxhty/l4=
|
||||
github.com/pion/sdp/v3 v3.0.13/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
|
||||
github.com/pion/srtp/v2 v2.0.20 h1:HNNny4s+OUmG280ETrCdgFndp4ufx3/uy85EawYEhTk=
|
||||
github.com/pion/srtp/v2 v2.0.20/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA=
|
||||
github.com/pion/srtp/v3 v3.0.5 h1:8XLB6Dt3QXkMkRFpoqC3314BemkpMQK2mZeJc4pUKqo=
|
||||
github.com/pion/srtp/v3 v3.0.5/go.mod h1:r1G7y5r1scZRLe2QJI/is+/O83W2d+JoEsuIexpw+uM=
|
||||
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
|
||||
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
|
||||
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
||||
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
||||
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
|
||||
github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
||||
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
||||
github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q=
|
||||
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
|
||||
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
|
||||
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||
github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||
github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc=
|
||||
github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||
github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps=
|
||||
github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
|
||||
github.com/pion/webrtc/v3 v3.3.5 h1:ZsSzaMz/i9nblPdiAkZoP+E6Kmjw+jnyq3bEmU3EtRg=
|
||||
github.com/pion/webrtc/v3 v3.3.5/go.mod h1:liNa+E1iwyzyXqNUwvoMRNQ10x8h8FOeJKL8RkIbamE=
|
||||
github.com/pion/webrtc/v4 v4.0.16 h1:5f8QMVIbNvJr2mPRGi2QamkPa/LVUB6NWolOCwphKHA=
|
||||
github.com/pion/webrtc/v4 v4.0.16/go.mod h1:C3uTCPzVafUA0eUzru9f47OgNt3nEO7ZJ6zNY6VSJno=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -178,8 +158,17 @@ github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUz
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f h1:VgoRCP1efSCEZIcF2THLQ46+pIBzzgNiaUBe9wEDwYU=
|
||||
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzro7BGorij2WgrjEammtrkbo3+xldxo+KaGLGUiD+Q=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -188,8 +177,6 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
@@ -200,81 +187,31 @@ github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQ
|
||||
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
|
||||
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
|
||||
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8=
|
||||
go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
|
||||
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
|
||||
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -33,24 +33,27 @@ type IPv6StaticConfig struct {
|
||||
DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"`
|
||||
}
|
||||
type NetworkConfig struct {
|
||||
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
|
||||
Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
|
||||
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
|
||||
Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
|
||||
HTTPProxy null.String `json:"http_proxy,omitempty"`
|
||||
HTTPSProxy null.String `json:"https_proxy,omitempty"`
|
||||
ALLProxy null.String `json:"all_proxy,omitempty"`
|
||||
|
||||
IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"`
|
||||
IPv4RequestAddress null.String `json:"ipv4_request_address,omitempty"`
|
||||
IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"`
|
||||
IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"`
|
||||
IPv4RequestAddress null.String `json:"ipv4_request_address,omitempty"`
|
||||
IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"`
|
||||
|
||||
IPv6Mode null.String `json:"ipv6_mode,omitempty" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"`
|
||||
IPv6Static *IPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"`
|
||||
IPv6Mode null.String `json:"ipv6_mode,omitempty" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"`
|
||||
IPv6Static *IPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"`
|
||||
|
||||
LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"`
|
||||
LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"`
|
||||
MDNSMode null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"`
|
||||
TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"`
|
||||
TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,ntp_fallback" default:"ntp,http"`
|
||||
TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
|
||||
TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"`
|
||||
PendingReboot null.Bool `json:"pending_reboot,omitempty" default:"false"`
|
||||
LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"`
|
||||
LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"`
|
||||
MDNSMode null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"`
|
||||
TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"`
|
||||
TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,ntp_fallback" default:"ntp,http"`
|
||||
TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
|
||||
TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"`
|
||||
PendingReboot null.Bool `json:"pending_reboot,omitempty" default:"false"`
|
||||
}
|
||||
|
||||
func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
|
||||
|
||||
@@ -131,10 +131,10 @@ func (u *UsbGadget) GetKeyboardState() KeyboardState {
|
||||
return u.keyboardState
|
||||
}
|
||||
|
||||
func (u *UsbGadget) listenKeyboardEvents() {
|
||||
func (u *UsbGadget) listenKeyboardEvents(ctx context.Context, file *os.File) {
|
||||
var path string
|
||||
if u.keyboardHidFile != nil {
|
||||
path = u.keyboardHidFile.Name()
|
||||
if file != nil {
|
||||
path = file.Name()
|
||||
}
|
||||
l := u.log.With().Str("listener", "keyboardEvents").Str("path", path).Logger()
|
||||
l.Trace().Msg("starting")
|
||||
@@ -143,12 +143,12 @@ func (u *UsbGadget) listenKeyboardEvents() {
|
||||
buf := make([]byte, hidReadBufferSize)
|
||||
for {
|
||||
select {
|
||||
case <-u.keyboardStateCtx.Done():
|
||||
case <-ctx.Done():
|
||||
l.Info().Msg("context done")
|
||||
return
|
||||
default:
|
||||
l.Trace().Msg("reading from keyboard")
|
||||
if u.keyboardHidFile == nil {
|
||||
if file == nil {
|
||||
u.logWithSupression("keyboardHidFileNil", 100, &l, nil, "keyboardHidFile is nil")
|
||||
// show the error every 100 times to avoid spamming the logs
|
||||
time.Sleep(time.Second)
|
||||
@@ -157,16 +157,26 @@ func (u *UsbGadget) listenKeyboardEvents() {
|
||||
// reset the counter
|
||||
u.resetLogSuppressionCounter("keyboardHidFileNil")
|
||||
|
||||
n, err := u.keyboardHidFile.Read(buf)
|
||||
n, err := file.Read(buf)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
l.Info().Msg("context canceled while reading keyboard HID file")
|
||||
return
|
||||
}
|
||||
|
||||
u.logWithSupression("keyboardHidFileRead", 100, &l, err, "failed to read")
|
||||
continue
|
||||
if reopenErr := u.reopenKeyboardHidFile(); reopenErr != nil {
|
||||
u.logWithSupression("keyboardHidFileReopen", 100, &l, reopenErr, "failed to reopen keyboard HID file")
|
||||
} else {
|
||||
u.resetLogSuppressionCounter("keyboardHidFileReopen")
|
||||
}
|
||||
return
|
||||
}
|
||||
u.resetLogSuppressionCounter("keyboardHidFileRead")
|
||||
|
||||
l.Trace().Int("n", n).Bytes("buf", buf).Msg("got data from keyboard")
|
||||
if n != 1 {
|
||||
l.Trace().Int("n", n).Msg("expected 1 byte, got")
|
||||
if n < 1 {
|
||||
l.Info().Int("n", n).Msg("expected at least 1 byte, got 0")
|
||||
continue
|
||||
}
|
||||
u.updateKeyboardState(buf[0])
|
||||
@@ -175,13 +185,52 @@ func (u *UsbGadget) listenKeyboardEvents() {
|
||||
}()
|
||||
}
|
||||
|
||||
func (u *UsbGadget) openKeyboardHidFile() error {
|
||||
func openWithTimeout(name string, flag int, perm os.FileMode, timeout time.Duration) (*os.File, error) {
|
||||
type result struct {
|
||||
file *os.File
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
f, err := os.OpenFile(name, flag, perm)
|
||||
ch <- result{f, err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case r := <-ch:
|
||||
return r.file, r.err
|
||||
case <-time.After(timeout):
|
||||
// Drain the channel in the background to close the leaked fd if the
|
||||
// open eventually succeeds.
|
||||
go func() {
|
||||
if r := <-ch; r.file != nil {
|
||||
r.file.Close()
|
||||
}
|
||||
}()
|
||||
return nil, fmt.Errorf("open %s: timed out after %s", name, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *UsbGadget) closeKeyboardHidFileLocked() {
|
||||
if u.keyboardStateCancel != nil {
|
||||
u.keyboardStateCancel()
|
||||
u.keyboardStateCancel = nil
|
||||
}
|
||||
|
||||
if u.keyboardHidFile != nil {
|
||||
u.keyboardHidFile.Close()
|
||||
u.keyboardHidFile = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (u *UsbGadget) openKeyboardHidFileLocked(forceReopen bool) error {
|
||||
if forceReopen {
|
||||
u.closeKeyboardHidFileLocked()
|
||||
} else if u.keyboardHidFile != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
u.keyboardHidFile, err = os.OpenFile("/dev/hidg0", os.O_RDWR, 0666)
|
||||
file, err := openWithTimeout("/dev/hidg0", os.O_RDWR, 0666, 3*time.Second)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) || strings.Contains(err.Error(), "no such file or directory") || strings.Contains(err.Error(), "no such device") {
|
||||
u.log.Error().
|
||||
@@ -196,33 +245,203 @@ func (u *UsbGadget) openKeyboardHidFile() error {
|
||||
return fmt.Errorf("failed to open hidg0: %w", err)
|
||||
}
|
||||
|
||||
if u.keyboardStateCancel != nil {
|
||||
u.keyboardStateCancel()
|
||||
}
|
||||
|
||||
u.keyboardStateCtx, u.keyboardStateCancel = context.WithCancel(context.Background())
|
||||
u.listenKeyboardEvents()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
u.keyboardHidFile = file
|
||||
u.keyboardStateCtx = ctx
|
||||
u.keyboardStateCancel = cancel
|
||||
u.listenKeyboardEvents(ctx, file)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *UsbGadget) openKeyboardHidFile() error {
|
||||
u.keyboardLock.Lock()
|
||||
defer u.keyboardLock.Unlock()
|
||||
|
||||
return u.openKeyboardHidFileLocked(false)
|
||||
}
|
||||
|
||||
func (u *UsbGadget) reopenKeyboardHidFile() error {
|
||||
u.keyboardLock.Lock()
|
||||
defer u.keyboardLock.Unlock()
|
||||
|
||||
return u.openKeyboardHidFileLocked(true)
|
||||
}
|
||||
|
||||
func (u *UsbGadget) OpenKeyboardHidFile() error {
|
||||
return u.openKeyboardHidFile()
|
||||
}
|
||||
|
||||
func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
|
||||
if err := u.openKeyboardHidFile(); err != nil {
|
||||
return err
|
||||
func (u *UsbGadget) ReopenKeyboardHidFile() error {
|
||||
return u.reopenKeyboardHidFile()
|
||||
}
|
||||
|
||||
func (u *UsbGadget) keyboardWriteHidFileLocked(modifier byte, keys []byte) error {
|
||||
if len(keys) > 6 {
|
||||
keys = keys[:6]
|
||||
}
|
||||
if len(keys) < 6 {
|
||||
keys = append(keys, make([]byte, 6-len(keys))...)
|
||||
}
|
||||
|
||||
_, err := u.keyboardHidFile.Write(data)
|
||||
data := []byte{modifier, 0, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]}
|
||||
|
||||
if u.keyboardHidFile == nil {
|
||||
if err := u.openKeyboardHidFileLocked(false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err := u.writeWithTimeout(u.keyboardHidFile, data)
|
||||
if err != nil {
|
||||
u.logWithSupression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0")
|
||||
u.keyboardHidFile.Close()
|
||||
u.keyboardHidFile = nil
|
||||
u.closeKeyboardHidFileLocked()
|
||||
return err
|
||||
}
|
||||
u.resetLogSuppressionCounter("keyboardWriteHidFile")
|
||||
|
||||
u.resetUserInputTime()
|
||||
return nil
|
||||
}
|
||||
|
||||
type autoReleaseTimer struct {
|
||||
timer *time.Timer
|
||||
key byte
|
||||
active bool
|
||||
}
|
||||
|
||||
type KeysDownState struct {
|
||||
Modifier byte
|
||||
Keys [6]byte
|
||||
}
|
||||
|
||||
func (u *UsbGadget) scheduleAutoRelease(key byte) {
|
||||
// Cancel existing timer for this key
|
||||
for i := range u.autoReleaseTimers {
|
||||
if u.autoReleaseTimers[i].key == key && u.autoReleaseTimers[i].active {
|
||||
u.autoReleaseTimers[i].timer.Stop()
|
||||
u.autoReleaseTimers[i].active = false
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule new timer
|
||||
timer := time.AfterFunc(100*time.Millisecond, func() {
|
||||
u.autoReleaseKey(key)
|
||||
})
|
||||
|
||||
u.autoReleaseTimers = append(u.autoReleaseTimers, autoReleaseTimer{
|
||||
timer: timer,
|
||||
key: key,
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (u *UsbGadget) autoReleaseKey(key byte) {
|
||||
u.keyboardLock.Lock()
|
||||
defer u.keyboardLock.Unlock()
|
||||
|
||||
// Remove key from buffer
|
||||
found := false
|
||||
for i := 0; i < len(u.keysDownState.Keys); i++ {
|
||||
if u.keysDownState.Keys[i] == key {
|
||||
found = true
|
||||
}
|
||||
if found && i < len(u.keysDownState.Keys)-1 {
|
||||
u.keysDownState.Keys[i] = u.keysDownState.Keys[i+1]
|
||||
}
|
||||
}
|
||||
if found {
|
||||
u.keysDownState.Keys[len(u.keysDownState.Keys)-1] = 0
|
||||
u.keyboardWriteHidFileLocked(u.keysDownState.Modifier, u.keysDownState.Keys[:])
|
||||
}
|
||||
|
||||
// Mark timer as inactive
|
||||
for i := range u.autoReleaseTimers {
|
||||
if u.autoReleaseTimers[i].key == key && u.autoReleaseTimers[i].active {
|
||||
u.autoReleaseTimers[i].active = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (u *UsbGadget) cancelAutoRelease(key byte) {
|
||||
for i := range u.autoReleaseTimers {
|
||||
if u.autoReleaseTimers[i].key == key && u.autoReleaseTimers[i].active {
|
||||
u.autoReleaseTimers[i].timer.Stop()
|
||||
u.autoReleaseTimers[i].active = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (u *UsbGadget) resetAllAutoReleaseTimers() {
|
||||
for i := range u.autoReleaseTimers {
|
||||
if u.autoReleaseTimers[i].active {
|
||||
u.autoReleaseTimers[i].timer.Stop()
|
||||
u.autoReleaseTimers[i].active = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (u *UsbGadget) KeypressReport(key byte, press bool) error {
|
||||
u.keyboardLock.Lock()
|
||||
defer u.keyboardLock.Unlock()
|
||||
|
||||
if press {
|
||||
// Check if key already in buffer
|
||||
for _, k := range u.keysDownState.Keys {
|
||||
if k == key {
|
||||
return nil // Already pressed
|
||||
}
|
||||
}
|
||||
|
||||
// Find empty slot
|
||||
emptySlot := -1
|
||||
for i, k := range u.keysDownState.Keys {
|
||||
if k == 0 {
|
||||
emptySlot = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if emptySlot == -1 {
|
||||
// Buffer full - ErrorRollOver
|
||||
u.keysDownState.Keys = [6]byte{0x01, 0x01, 0x01, 0x01, 0x01, 0x01}
|
||||
} else {
|
||||
u.keysDownState.Keys[emptySlot] = key
|
||||
}
|
||||
|
||||
u.scheduleAutoRelease(key)
|
||||
} else {
|
||||
// Remove key from buffer
|
||||
found := false
|
||||
for i := 0; i < len(u.keysDownState.Keys); i++ {
|
||||
if u.keysDownState.Keys[i] == key {
|
||||
found = true
|
||||
}
|
||||
if found && i < len(u.keysDownState.Keys)-1 {
|
||||
u.keysDownState.Keys[i] = u.keysDownState.Keys[i+1]
|
||||
}
|
||||
}
|
||||
if found {
|
||||
u.keysDownState.Keys[len(u.keysDownState.Keys)-1] = 0
|
||||
}
|
||||
|
||||
u.cancelAutoRelease(key)
|
||||
}
|
||||
|
||||
return u.keyboardWriteHidFileLocked(u.keysDownState.Modifier, u.keysDownState.Keys[:])
|
||||
}
|
||||
|
||||
func (u *UsbGadget) KeypressKeepAlive() error {
|
||||
u.keyboardLock.Lock()
|
||||
defer u.keyboardLock.Unlock()
|
||||
|
||||
// Reset auto-release timers for all currently held keys
|
||||
for _, key := range u.keysDownState.Keys {
|
||||
if key != 0 {
|
||||
u.scheduleAutoRelease(key)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -230,18 +449,8 @@ func (u *UsbGadget) KeyboardReport(modifier uint8, keys []uint8) error {
|
||||
u.keyboardLock.Lock()
|
||||
defer u.keyboardLock.Unlock()
|
||||
|
||||
if len(keys) > 6 {
|
||||
keys = keys[:6]
|
||||
}
|
||||
if len(keys) < 6 {
|
||||
keys = append(keys, make([]uint8, 6-len(keys))...)
|
||||
}
|
||||
u.keysDownState.Modifier = modifier
|
||||
copy(u.keysDownState.Keys[:], keys)
|
||||
|
||||
err := u.keyboardWriteHidFile([]byte{modifier, 0, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u.resetUserInputTime()
|
||||
return nil
|
||||
return u.keyboardWriteHidFileLocked(modifier, keys)
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@ type UsbGadget struct {
|
||||
onKeyboardStateChange *func(state KeyboardState)
|
||||
onHidDeviceMissing *func(device string, err error)
|
||||
|
||||
keysDownState KeysDownState
|
||||
autoReleaseTimers []autoReleaseTimer
|
||||
|
||||
log *zerolog.Logger
|
||||
|
||||
logSuppressionCounter map[string]int
|
||||
|
||||
@@ -2,10 +2,13 @@ package usbgadget
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
@@ -107,3 +110,36 @@ func (u *UsbGadget) resetLogSuppressionCounter(counterName string) {
|
||||
u.logSuppressionCounter[counterName] = 0
|
||||
}
|
||||
}
|
||||
|
||||
const hidWriteTimeout = 50 * time.Millisecond
|
||||
|
||||
func (u *UsbGadget) writeWithTimeout(file *os.File, data []byte) (n int, err error) {
|
||||
if err := file.SetWriteDeadline(time.Now().Add(hidWriteTimeout)); err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
n, err = file.Write(data)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
u.log.Trace().
|
||||
Str("file", file.Name()).
|
||||
Bytes("data", data).
|
||||
Err(err).
|
||||
Msg("write failed")
|
||||
|
||||
if errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
u.logWithSupression(
|
||||
fmt.Sprintf("writeWithTimeout_%s", file.Name()),
|
||||
1000,
|
||||
u.log,
|
||||
err,
|
||||
"write timed out: %s",
|
||||
file.Name(),
|
||||
)
|
||||
err = nil
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
9
io.go
9
io.go
@@ -105,6 +105,15 @@ func setLedMode(ledConfigPath string, mode string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set LED trigger: %v", err)
|
||||
}
|
||||
case "disabled":
|
||||
err := os.WriteFile(ledConfigPath+"/trigger", []byte("none"), 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set LED trigger: %v", err)
|
||||
}
|
||||
err = os.WriteFile(ledConfigPath+"/brightness", []byte("0"), 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set LED brightness: %v", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid LED mode: %s", mode)
|
||||
}
|
||||
|
||||
333
jsonrpc.go
333
jsonrpc.go
@@ -79,15 +79,12 @@ func writeJSONRPCEvent(event string, params interface{}, session *Session) {
|
||||
}
|
||||
|
||||
requestString := string(requestBytes)
|
||||
scopedLogger := jsonRpcLogger.With().
|
||||
Str("data", requestString).
|
||||
Logger()
|
||||
|
||||
scopedLogger.Info().Msg("sending JSONRPC event")
|
||||
jsonRpcLogger.Trace().Str("event", event).Msg("sending JSONRPC event")
|
||||
|
||||
err = session.RPCChannel.SendText(requestString)
|
||||
if err != nil {
|
||||
scopedLogger.Warn().Err(err).Msg("error sending JSONRPC event")
|
||||
jsonRpcLogger.Warn().Err(err).Str("event", event).Msg("error sending JSONRPC event")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -236,6 +233,124 @@ func rpcSetStreamEncodecType(encodecType string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type RcQpParams struct {
|
||||
S32FirstFrameStartQp int `json:"s32FirstFrameStartQp"`
|
||||
U32StepQp int `json:"u32StepQp"`
|
||||
U32MinQp int `json:"u32MinQp"`
|
||||
U32MaxQp int `json:"u32MaxQp"`
|
||||
U32MinIQp int `json:"u32MinIQp"`
|
||||
U32MaxIQp int `json:"u32MaxIQp"`
|
||||
S32DeltIpQp int `json:"s32DeltIpQp"`
|
||||
S32MaxReEncodeTimes int `json:"s32MaxReEncodeTimes"`
|
||||
U32FrmMaxQp int `json:"u32FrmMaxQp"`
|
||||
U32FrmMinQp int `json:"u32FrmMinQp"`
|
||||
U32FrmMinIQp int `json:"u32FrmMinIQp"`
|
||||
U32FrmMaxIQp int `json:"u32FrmMaxIQp"`
|
||||
U32MotionStaticSwitchFrmQp int `json:"u32MotionStaticSwitchFrmQp"`
|
||||
}
|
||||
|
||||
type VideoRcConfigParams struct {
|
||||
H264 RcQpParams `json:"h264"`
|
||||
H265 RcQpParams `json:"h265"`
|
||||
}
|
||||
|
||||
func rpcSetVideoRc(params VideoRcConfigParams) error {
|
||||
logger.Info().Interface("params", params).Msg("Setting video RC params")
|
||||
rcParams := map[string]interface{}{
|
||||
"h264": map[string]interface{}{
|
||||
"s32FirstFrameStartQp": params.H264.S32FirstFrameStartQp,
|
||||
"u32StepQp": params.H264.U32StepQp,
|
||||
"u32MinQp": params.H264.U32MinQp,
|
||||
"u32MaxQp": params.H264.U32MaxQp,
|
||||
"u32MinIQp": params.H264.U32MinIQp,
|
||||
"u32MaxIQp": params.H264.U32MaxIQp,
|
||||
"s32DeltIpQp": params.H264.S32DeltIpQp,
|
||||
"s32MaxReEncodeTimes": params.H264.S32MaxReEncodeTimes,
|
||||
"u32FrmMaxQp": params.H264.U32FrmMaxQp,
|
||||
"u32FrmMinQp": params.H264.U32FrmMinQp,
|
||||
"u32FrmMinIQp": params.H264.U32FrmMinIQp,
|
||||
"u32FrmMaxIQp": params.H264.U32FrmMaxIQp,
|
||||
"u32MotionStaticSwitchFrmQp": params.H264.U32MotionStaticSwitchFrmQp,
|
||||
},
|
||||
"h265": map[string]interface{}{
|
||||
"s32FirstFrameStartQp": params.H265.S32FirstFrameStartQp,
|
||||
"u32StepQp": params.H265.U32StepQp,
|
||||
"u32MinQp": params.H265.U32MinQp,
|
||||
"u32MaxQp": params.H265.U32MaxQp,
|
||||
"u32MinIQp": params.H265.U32MinIQp,
|
||||
"u32MaxIQp": params.H265.U32MaxIQp,
|
||||
"s32DeltIpQp": params.H265.S32DeltIpQp,
|
||||
"s32MaxReEncodeTimes": params.H265.S32MaxReEncodeTimes,
|
||||
"u32FrmMaxQp": params.H265.U32FrmMaxQp,
|
||||
"u32FrmMinQp": params.H265.U32FrmMinQp,
|
||||
"u32FrmMinIQp": params.H265.U32FrmMinIQp,
|
||||
"u32FrmMaxIQp": params.H265.U32FrmMaxIQp,
|
||||
"u32MotionStaticSwitchFrmQp": params.H265.U32MotionStaticSwitchFrmQp,
|
||||
},
|
||||
}
|
||||
var _, err = CallCtrlAction("set_video_rc", rcParams)
|
||||
return err
|
||||
}
|
||||
|
||||
func rpcGetVideoRc() (VideoRcConfigParams, error) {
|
||||
resp, err := CallCtrlAction("get_video_rc", nil)
|
||||
if err != nil {
|
||||
return VideoRcConfigParams{}, err
|
||||
}
|
||||
|
||||
result := resp.Result
|
||||
if result == nil {
|
||||
return VideoRcConfigParams{}, errors.New("invalid response format")
|
||||
}
|
||||
|
||||
h264Map, _ := result["h264"].(map[string]interface{})
|
||||
h265Map, _ := result["h265"].(map[string]interface{})
|
||||
|
||||
getInt := func(m map[string]interface{}, k string) int {
|
||||
if v, ok := m[k].(float64); ok {
|
||||
return int(v)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
getUint := func(m map[string]interface{}, k string) int {
|
||||
return getInt(m, k)
|
||||
}
|
||||
|
||||
rc := VideoRcConfigParams{
|
||||
H264: RcQpParams{
|
||||
S32FirstFrameStartQp: getInt(h264Map, "s32FirstFrameStartQp"),
|
||||
U32StepQp: getUint(h264Map, "u32StepQp"),
|
||||
U32MinQp: getUint(h264Map, "u32MinQp"),
|
||||
U32MaxQp: getUint(h264Map, "u32MaxQp"),
|
||||
U32MinIQp: getUint(h264Map, "u32MinIQp"),
|
||||
U32MaxIQp: getUint(h264Map, "u32MaxIQp"),
|
||||
S32DeltIpQp: getInt(h264Map, "s32DeltIpQp"),
|
||||
S32MaxReEncodeTimes: getInt(h264Map, "s32MaxReEncodeTimes"),
|
||||
U32FrmMaxQp: getUint(h264Map, "u32FrmMaxQp"),
|
||||
U32FrmMinQp: getUint(h264Map, "u32FrmMinQp"),
|
||||
U32FrmMinIQp: getUint(h264Map, "u32FrmMinIQp"),
|
||||
U32FrmMaxIQp: getUint(h264Map, "u32FrmMaxIQp"),
|
||||
U32MotionStaticSwitchFrmQp: getUint(h264Map, "u32MotionStaticSwitchFrmQp"),
|
||||
},
|
||||
H265: RcQpParams{
|
||||
S32FirstFrameStartQp: getInt(h265Map, "s32FirstFrameStartQp"),
|
||||
U32StepQp: getUint(h265Map, "u32StepQp"),
|
||||
U32MinQp: getUint(h265Map, "u32MinQp"),
|
||||
U32MaxQp: getUint(h265Map, "u32MaxQp"),
|
||||
U32MinIQp: getUint(h265Map, "u32MinIQp"),
|
||||
U32MaxIQp: getUint(h265Map, "u32MaxIQp"),
|
||||
S32DeltIpQp: getInt(h265Map, "s32DeltIpQp"),
|
||||
S32MaxReEncodeTimes: getInt(h265Map, "s32MaxReEncodeTimes"),
|
||||
U32FrmMaxQp: getUint(h265Map, "u32FrmMaxQp"),
|
||||
U32FrmMinQp: getUint(h265Map, "u32FrmMinQp"),
|
||||
U32FrmMinIQp: getUint(h265Map, "u32FrmMinIQp"),
|
||||
U32FrmMaxIQp: getUint(h265Map, "u32FrmMaxIQp"),
|
||||
U32MotionStaticSwitchFrmQp: getUint(h265Map, "u32MotionStaticSwitchFrmQp"),
|
||||
},
|
||||
}
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
func rpcSetNpuAppStatus(enable bool) error {
|
||||
logger.Info().Bool("enable", enable).Msg("Setting NPU app status")
|
||||
var _, err = CallCtrlAction("set_yolo_enable", map[string]interface{}{"enable": enable})
|
||||
@@ -379,6 +494,36 @@ func rpcGetUpdateStatus() (*UpdateStatus, error) {
|
||||
return updateStatus, nil
|
||||
}
|
||||
|
||||
type SelfSignatureStatus struct {
|
||||
AppSignatureAbsent bool `json:"appSignatureAbsent,omitempty"`
|
||||
AppSignatureInvalid bool `json:"appSignatureInvalid,omitempty"`
|
||||
AppNoPublicKey bool `json:"appNoPublicKey,omitempty"`
|
||||
}
|
||||
|
||||
func rpcGetSelfSignatureStatus() (*SelfSignatureStatus, error) {
|
||||
return getSelfSignatureStatus(), nil
|
||||
}
|
||||
|
||||
func getSelfSignatureStatus() *SelfSignatureStatus {
|
||||
status := &SelfSignatureStatus{}
|
||||
publicKey := getOTAPublicKey()
|
||||
|
||||
appBinPath := "/userdata/picokvm/bin/kvm_app"
|
||||
appSigPath := appBinPath + ".sig"
|
||||
|
||||
status.AppSignatureAbsent = isSigFileAbsent(appSigPath)
|
||||
|
||||
if !status.AppSignatureAbsent {
|
||||
if publicKey == nil {
|
||||
status.AppNoPublicKey = true
|
||||
} else {
|
||||
status.AppSignatureInvalid = !verifyLocalFileSignature(appBinPath, appSigPath, publicKey)
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
func rpcTryUpdate() error {
|
||||
includePreRelease := config.IncludePreRelease
|
||||
go func() {
|
||||
@@ -390,6 +535,14 @@ func rpcTryUpdate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func rpcUpdateSignatures() (*SignatureUpdateResult, error) {
|
||||
result, err := UpdateSignatures(context.Background())
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("failed to update signatures")
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func rpcGetCustomUpdateBaseURL() (string, error) {
|
||||
return customUpdateBaseURL, nil
|
||||
}
|
||||
@@ -852,6 +1005,60 @@ func rpcSetConfigRaw(configStr string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type RtcServersConfig struct {
|
||||
STUN string `json:"stun"`
|
||||
DefaultSTUN string `json:"defaultStun"`
|
||||
TurnServers []TurnServer `json:"turnServers"`
|
||||
}
|
||||
|
||||
func rpcGetRtcServersConfig() (RtcServersConfig, error) {
|
||||
return RtcServersConfig{
|
||||
STUN: config.STUN,
|
||||
DefaultSTUN: DefaultSTUN,
|
||||
TurnServers: config.TurnServers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func rpcSetStunServer(stun string) error {
|
||||
config.STUN = stun
|
||||
return SaveConfig()
|
||||
}
|
||||
|
||||
type SetTurnServersParams struct {
|
||||
Servers []TurnServer `json:"servers"`
|
||||
}
|
||||
|
||||
func rpcSetTurnServers(params SetTurnServersParams) error {
|
||||
config.TurnServers = params.Servers
|
||||
if config.TurnServers == nil {
|
||||
config.TurnServers = []TurnServer{}
|
||||
}
|
||||
return SaveConfig()
|
||||
}
|
||||
|
||||
type IceServerJSON struct {
|
||||
URLs []string `json:"urls"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Credential string `json:"credential,omitempty"`
|
||||
}
|
||||
|
||||
func rpcGetIceServers() ([]IceServerJSON, error) {
|
||||
raw := buildICEServers()
|
||||
out := make([]IceServerJSON, 0, len(raw))
|
||||
for _, server := range raw {
|
||||
credential := ""
|
||||
if server.Credential != nil {
|
||||
credential = fmt.Sprintf("%v", server.Credential)
|
||||
}
|
||||
out = append(out, IceServerJSON{
|
||||
URLs: server.URLs,
|
||||
Username: server.Username,
|
||||
Credential: credential,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func rpcGetActiveExtension() (string, error) {
|
||||
return config.ActiveExtension, nil
|
||||
}
|
||||
@@ -1126,6 +1333,30 @@ func rpcSetLocalLoopbackOnly(enabled bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func rpcGetApiKey() (string, error) {
|
||||
return config.APIKey, nil
|
||||
}
|
||||
|
||||
func rpcSetApiKey(apiKey string) error {
|
||||
config.APIKey = apiKey
|
||||
if err := SaveConfig(); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rpcGenerateApiKey() (string, error) {
|
||||
key, err := generateAPIKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate API key: %w", err)
|
||||
}
|
||||
config.APIKey = key
|
||||
if err := SaveConfig(); err != nil {
|
||||
return "", fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
type IOSettings struct {
|
||||
IO0Status bool `json:"io0Status"`
|
||||
IO1Status bool `json:"io1Status"`
|
||||
@@ -1324,6 +1555,77 @@ func rpcConfirmOtherSession() (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
const jpegScreenshotPath = "/userdata/picokvm/screenshot/kvm_screenshot.jpg"
|
||||
|
||||
// StartJpegCapture starts continuous JPEG capture mode.
|
||||
func StartJpegCapture() error {
|
||||
_, err := CallCtrlAction("jpeg_capture_start", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start JPEG capture: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopJpegCapture stops continuous JPEG capture mode.
|
||||
func StopJpegCapture() error {
|
||||
_, err := CallCtrlAction("jpeg_capture_stop", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stop JPEG capture: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// captureScreenshot captures a JPEG screenshot using hardware encoder.
|
||||
func captureScreenshot(format string) ([]byte, error) {
|
||||
if format != "jpeg" && format != "jpg" {
|
||||
return nil, fmt.Errorf("only JPEG format is supported")
|
||||
}
|
||||
|
||||
logger.Info().Msg("triggering JPEG snapshot via jpeg_take_snapshot")
|
||||
|
||||
if err := os.MkdirAll("/userdata/picokvm/screenshot", 0o755); err != nil {
|
||||
logger.Warn().Err(err).Msg("failed to create screenshot directory")
|
||||
}
|
||||
|
||||
os.Remove(jpegScreenshotPath)
|
||||
|
||||
// drain any stale signal before triggering
|
||||
select {
|
||||
case <-jpegReadyCh:
|
||||
default:
|
||||
}
|
||||
|
||||
_, err := CallCtrlAction("jpeg_take_snapshot", nil)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("jpeg_take_snapshot failed")
|
||||
return nil, fmt.Errorf("failed to trigger JPEG capture: %w", err)
|
||||
}
|
||||
|
||||
// wait for jpeg_ready event from native, fall back to polling on timeout
|
||||
timeout := time.NewTimer(2 * time.Second)
|
||||
defer timeout.Stop()
|
||||
select {
|
||||
case <-jpegReadyCh:
|
||||
case <-timeout.C:
|
||||
logger.Warn().Msg("jpeg_ready event not received within 2s, falling back to polling")
|
||||
for i := 0; i < 5; i++ {
|
||||
if data, err := os.ReadFile(jpegScreenshotPath); err == nil && len(data) > 0 {
|
||||
logger.Info().Int("size", len(data)).Msg("JPEG captured (fallback polling)")
|
||||
return data, nil
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
return nil, fmt.Errorf("JPEG file not found at %s", jpegScreenshotPath)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(jpegScreenshotPath)
|
||||
if err != nil || len(data) == 0 {
|
||||
return nil, fmt.Errorf("JPEG file not readable after jpeg_ready event: %w", err)
|
||||
}
|
||||
logger.Info().Int("size", len(data)).Msg("JPEG captured successfully")
|
||||
return data, nil
|
||||
}
|
||||
|
||||
var rpcHandlers = map[string]RPCHandler{
|
||||
"ping": {Func: rpcPing},
|
||||
"reboot": {Func: rpcReboot, Params: []string{"force"}},
|
||||
@@ -1361,7 +1663,9 @@ var rpcHandlers = map[string]RPCHandler{
|
||||
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
|
||||
"getLocalUpdateStatus": {Func: rpcGetLocalUpdateStatus},
|
||||
"getUpdateStatus": {Func: rpcGetUpdateStatus},
|
||||
"getSelfSignatureStatus": {Func: rpcGetSelfSignatureStatus},
|
||||
"tryUpdate": {Func: rpcTryUpdate},
|
||||
"updateSignatures": {Func: rpcUpdateSignatures},
|
||||
"getCustomUpdateBaseURL": {Func: rpcGetCustomUpdateBaseURL},
|
||||
"setCustomUpdateBaseURL": {Func: rpcSetCustomUpdateBaseURL, Params: []string{"baseURL"}},
|
||||
"getUpdateDownloadProxy": {Func: rpcGetUpdateDownloadProxy},
|
||||
@@ -1369,6 +1673,9 @@ var rpcHandlers = map[string]RPCHandler{
|
||||
"getDevModeState": {Func: rpcGetDevModeState},
|
||||
"getSSHKeyState": {Func: rpcGetSSHKeyState},
|
||||
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
|
||||
"getApiKey": {Func: rpcGetApiKey},
|
||||
"setApiKey": {Func: rpcSetApiKey, Params: []string{"apiKey"}},
|
||||
"generateApiKey": {Func: rpcGenerateApiKey},
|
||||
"getTLSState": {Func: rpcGetTLSState},
|
||||
"setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}},
|
||||
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
|
||||
@@ -1387,7 +1694,7 @@ var rpcHandlers = map[string]RPCHandler{
|
||||
"resetSDStorage": {Func: rpcResetSDStorage},
|
||||
"mountSDStorage": {Func: rpcMountSDStorage},
|
||||
"unmountSDStorage": {Func: rpcUnmountSDStorage},
|
||||
"formatSDStorage": {Func: rpcFormatSDStorage, Params: []string{"confirm"}},
|
||||
"formatSDStorage": {Func: rpcFormatSDStorage, Params: []string{"confirm", "fsType"}},
|
||||
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
|
||||
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
|
||||
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
|
||||
@@ -1406,6 +1713,10 @@ var rpcHandlers = map[string]RPCHandler{
|
||||
"resetConfig": {Func: rpcResetConfig},
|
||||
"getConfigRaw": {Func: rpcGetConfigRaw},
|
||||
"setConfigRaw": {Func: rpcSetConfigRaw, Params: []string{"configStr"}},
|
||||
"getRtcServersConfig": {Func: rpcGetRtcServersConfig},
|
||||
"setStunServer": {Func: rpcSetStunServer, Params: []string{"stun"}},
|
||||
"setTurnServers": {Func: rpcSetTurnServers, Params: []string{"params"}},
|
||||
"getIceServers": {Func: rpcGetIceServers},
|
||||
"setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}},
|
||||
"getDisplayRotation": {Func: rpcGetDisplayRotation},
|
||||
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
|
||||
@@ -1469,8 +1780,18 @@ var rpcHandlers = map[string]RPCHandler{
|
||||
"stopCloudflared": {Func: rpcStopCloudflared},
|
||||
"getCloudflaredStatus": {Func: rpcGetCloudflaredStatus},
|
||||
"getCloudflaredLog": {Func: rpcGetCloudflaredLog},
|
||||
"getVpnToolSystemInfo": {Func: rpcGetVpnToolSystemInfo},
|
||||
"getVpnToolStatus": {Func: rpcGetVpnToolStatus, Params: []string{"tool"}},
|
||||
"listVpnToolReleases": {Func: rpcListVpnToolReleases, Params: []string{"tool"}},
|
||||
"installVpnTool": {Func: rpcInstallVpnTool, Params: []string{"tool", "version", "assetName", "downloadURL"}},
|
||||
"startVpnToolInstall": {Func: rpcStartVpnToolInstall, Params: []string{"tool", "version", "assetName", "downloadURL"}},
|
||||
"getVpnToolInstallTask": {Func: rpcGetVpnToolInstallTask, Params: []string{"tool"}},
|
||||
"useVpnToolVersion": {Func: rpcUseVpnToolVersion, Params: []string{"tool", "version"}},
|
||||
"uninstallVpnToolVersion": {Func: rpcUninstallVpnToolVersion, Params: []string{"tool", "version"}},
|
||||
"getStreamEncodecType": {Func: rpcGetStreamEncodecType},
|
||||
"setStreamEncodecType": {Func: rpcSetStreamEncodecType, Params: []string{"encodecType"}},
|
||||
"setVideoRc": {Func: rpcSetVideoRc, Params: []string{"params"}},
|
||||
"getVideoRc": {Func: rpcGetVideoRc},
|
||||
"setNpuAppStatus": {Func: rpcSetNpuAppStatus, Params: []string{"enable"}},
|
||||
"getNpuAppStatus": {Func: rpcGetNpuAppStatus},
|
||||
"startWireguard": {Func: rpcStartWireguard, Params: []string{"configFile"}},
|
||||
|
||||
23
main.go
23
main.go
@@ -18,6 +18,20 @@ func Main() {
|
||||
SyncConfigSD(true)
|
||||
LoadConfig()
|
||||
|
||||
if config.APIKey == "" {
|
||||
key, err := generateAPIKey()
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("failed to generate API key")
|
||||
} else {
|
||||
config.APIKey = key
|
||||
if err := SaveConfig(); err != nil {
|
||||
logger.Warn().Err(err).Msg("failed to save API key to config")
|
||||
} else {
|
||||
logger.Info().Msg("generated new API key")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cancel context.CancelFunc
|
||||
appCtx, cancel = context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
@@ -185,6 +199,15 @@ func Main() {
|
||||
//go RunFuseServer()
|
||||
go RunWebServer()
|
||||
|
||||
// API and MCP services temporarily disabled for debugging
|
||||
go func() {
|
||||
StartAPIServer(8080)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
StartMCP(8081, false)
|
||||
}()
|
||||
|
||||
go RunWebSecureServer()
|
||||
// Web secure server is started only if TLS mode is enabled
|
||||
if config.TLSMode != "" {
|
||||
|
||||
313
mcp.go
Normal file
313
mcp.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package kvm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
func StartMCP(port int, stdio bool) {
|
||||
s := server.NewMCPServer("picokvm-mcp", "1.0.0")
|
||||
registerMCPTools(s)
|
||||
|
||||
if stdio {
|
||||
logger.Info().Msg("Starting MCP stdio server")
|
||||
if err := server.ServeStdio(s); err != nil {
|
||||
logger.Error().Err(err).Msg("MCP stdio server failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// SSE mode
|
||||
addr := fmt.Sprintf(":%d", port)
|
||||
sseServer := server.NewSSEServer(s)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/sse", sseServer.SSEHandler())
|
||||
mux.Handle("/message", sseServer.MessageHandler())
|
||||
|
||||
var handler http.Handler = mux
|
||||
if config.APIKey != "" {
|
||||
handler = withAPIKeyAuth(handler, config.APIKey)
|
||||
}
|
||||
handler = withCORS(handler)
|
||||
|
||||
logger.Info().Str("addr", addr).Msg("Starting MCP SSE server")
|
||||
if err := http.ListenAndServe(addr, handler); err != nil {
|
||||
logger.Error().Err(err).Msg("MCP SSE server failed")
|
||||
}
|
||||
}
|
||||
|
||||
// === Shared middleware helpers ===
|
||||
|
||||
func withCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(204)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func withAPIKeyAuth(next http.Handler, expectedKey string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip auth for localhost
|
||||
if strings.HasPrefix(r.RemoteAddr, "127.0.0.1:") ||
|
||||
strings.HasPrefix(r.RemoteAddr, "[::1]:") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
auth := r.Header.Get("Authorization")
|
||||
var key string
|
||||
if _, err := fmt.Sscanf(auth, "Bearer %s", &key); err != nil {
|
||||
http.Error(w, `{"error":"missing or invalid authorization"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !strings.EqualFold(key, expectedKey) {
|
||||
http.Error(w, `{"error":"invalid api key"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// === MCP Tool Registration ===
|
||||
|
||||
func registerMCPTools(s *server.MCPServer) {
|
||||
s.AddTool(mcp.NewTool("mouse_move_absolute",
|
||||
mcp.WithDescription("Move mouse to absolute coordinates (0-32767)"),
|
||||
mcp.WithNumber("x", mcp.Required(), mcp.Description("X coordinate")),
|
||||
mcp.WithNumber("y", mcp.Required(), mcp.Description("Y coordinate")),
|
||||
), handleMouseMoveAbsolute)
|
||||
|
||||
s.AddTool(mcp.NewTool("mouse_move_relative",
|
||||
mcp.WithDescription("Move mouse by relative offset"),
|
||||
mcp.WithNumber("dx", mcp.Required()),
|
||||
mcp.WithNumber("dy", mcp.Required()),
|
||||
), handleMouseMoveRelative)
|
||||
|
||||
s.AddTool(mcp.NewTool("mouse_click",
|
||||
mcp.WithDescription("Click mouse button"),
|
||||
mcp.WithString("button", mcp.Required(), mcp.Enum("left", "right", "middle")),
|
||||
), handleMouseClick)
|
||||
|
||||
s.AddTool(mcp.NewTool("mouse_scroll",
|
||||
mcp.WithDescription("Scroll mouse wheel"),
|
||||
mcp.WithNumber("delta", mcp.Required()),
|
||||
), handleMouseScroll)
|
||||
|
||||
s.AddTool(mcp.NewTool("keyboard_key",
|
||||
mcp.WithDescription("Press a key"),
|
||||
mcp.WithString("key", mcp.Required(), mcp.Description("Key name: Enter, Escape, Tab, etc.")),
|
||||
), handleKeyboardKey)
|
||||
|
||||
s.AddTool(mcp.NewTool("keyboard_combo",
|
||||
mcp.WithDescription("Press key combination"),
|
||||
mcp.WithArray("keys", mcp.Required(), mcp.Items(map[string]any{"type": "string"})),
|
||||
), handleKeyboardCombo)
|
||||
|
||||
s.AddTool(mcp.NewTool("type_text",
|
||||
mcp.WithDescription("Type text string"),
|
||||
mcp.WithString("text", mcp.Required()),
|
||||
), handleTypeText)
|
||||
|
||||
s.AddTool(mcp.NewTool("capture_screenshot",
|
||||
mcp.WithDescription("Capture JPEG screenshot using hardware encoder"),
|
||||
), handleCaptureScreenshot)
|
||||
|
||||
s.AddTool(mcp.NewTool("get_video_state",
|
||||
mcp.WithDescription("Get screen resolution and video status"),
|
||||
), handleGetVideoState)
|
||||
}
|
||||
|
||||
// === MCP Handlers ===
|
||||
|
||||
func handleMouseMoveAbsolute(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := req.GetArguments()
|
||||
x, _ := args["x"].(float64)
|
||||
y, _ := args["y"].(float64)
|
||||
_, err := callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
|
||||
"x": x, "y": y, "buttons": 0,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mcp.NewToolResultText(fmt.Sprintf("Mouse moved to (%d, %d)", int(x), int(y))), nil
|
||||
}
|
||||
|
||||
func handleMouseMoveRelative(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := req.GetArguments()
|
||||
dx, _ := args["dx"].(float64)
|
||||
dy, _ := args["dy"].(float64)
|
||||
_, err := callRPCHandler(rpcHandlers["relMouseReport"], map[string]interface{}{
|
||||
"dx": dx, "dy": dy, "buttons": 0,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mcp.NewToolResultText(fmt.Sprintf("Mouse moved by (%d, %d)", int(dx), int(dy))), nil
|
||||
}
|
||||
|
||||
func handleMouseClick(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := req.GetArguments()
|
||||
button, _ := args["button"].(string)
|
||||
var buttons uint8
|
||||
switch button {
|
||||
case "left":
|
||||
buttons = 1
|
||||
case "right":
|
||||
buttons = 2
|
||||
case "middle":
|
||||
buttons = 4
|
||||
}
|
||||
_, err := callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
|
||||
"x": 0, "y": 0, "buttons": buttons,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
|
||||
"x": 0, "y": 0, "buttons": 0,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mcp.NewToolResultText(fmt.Sprintf("Clicked %s button", button)), nil
|
||||
}
|
||||
|
||||
func handleMouseScroll(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := req.GetArguments()
|
||||
delta, _ := args["delta"].(float64)
|
||||
_, err := callRPCHandler(rpcHandlers["wheelReport"], map[string]interface{}{
|
||||
"wheelY": delta,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mcp.NewToolResultText(fmt.Sprintf("Scrolled by %d", int(delta))), nil
|
||||
}
|
||||
|
||||
func handleKeyboardKey(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := req.GetArguments()
|
||||
keyName, _ := args["key"].(string)
|
||||
keyCode, ok := keyNameToCode[keyName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown key: %s", keyName)
|
||||
}
|
||||
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
||||
"modifier": 0, "keys": []uint8{keyCode},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
||||
"modifier": 0, "keys": []uint8{},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mcp.NewToolResultText(fmt.Sprintf("Pressed key: %s", keyName)), nil
|
||||
}
|
||||
|
||||
func handleKeyboardCombo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := req.GetArguments()
|
||||
keysArg, _ := args["keys"].([]interface{})
|
||||
var keys []uint8
|
||||
var modifier uint8
|
||||
|
||||
for _, k := range keysArg {
|
||||
keyName, _ := k.(string)
|
||||
switch strings.ToLower(keyName) {
|
||||
case "ctrl", "control":
|
||||
modifier |= 0x01
|
||||
continue
|
||||
case "shift":
|
||||
modifier |= 0x02
|
||||
continue
|
||||
case "alt":
|
||||
modifier |= 0x04
|
||||
continue
|
||||
case "meta", "win", "cmd":
|
||||
modifier |= 0x08
|
||||
continue
|
||||
}
|
||||
keyCode, ok := keyNameToCode[keyName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown key: %s", keyName)
|
||||
}
|
||||
keys = append(keys, keyCode)
|
||||
}
|
||||
|
||||
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
||||
"modifier": modifier, "keys": keys,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
||||
"modifier": 0, "keys": []uint8{},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mcp.NewToolResultText(fmt.Sprintf("Pressed combo: %v", keysArg)), nil
|
||||
}
|
||||
|
||||
func handleTypeText(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := req.GetArguments()
|
||||
text, _ := args["text"].(string)
|
||||
for _, char := range text {
|
||||
keyCode, modifier, ok := charToKeyCode(uint8(char))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
||||
"modifier": modifier, "keys": []uint8{keyCode},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
||||
"modifier": 0, "keys": []uint8{},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return mcp.NewToolResultText(fmt.Sprintf("Typed: %s", text)), nil
|
||||
}
|
||||
|
||||
func handleCaptureScreenshot(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
data, err := captureScreenshot("jpeg")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base64Data := base64.StdEncoding.EncodeToString(data)
|
||||
return mcp.NewToolResultImage("JPEG screenshot captured", base64Data, "image/jpeg"), nil
|
||||
}
|
||||
|
||||
func handleGetVideoState(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
result, err := callRPCHandler(rpcHandlers["getVideoState"], nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state, ok := result.(VideoInputState)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected video state type")
|
||||
}
|
||||
text := fmt.Sprintf("Video: %dx%d @ %.1f fps (Ready: %v)", state.Width, state.Height, state.FramePerSecond, state.Ready)
|
||||
if state.Error != "" {
|
||||
text += fmt.Sprintf(" [Error: %s]", state.Error)
|
||||
}
|
||||
return mcp.NewToolResultText(text), nil
|
||||
}
|
||||
22
native.go
22
native.go
@@ -43,6 +43,10 @@ var (
|
||||
videoCmdLock = &sync.Mutex{}
|
||||
)
|
||||
|
||||
// jpegReadyCh is written by the ctrl socket event handler and read by captureScreenshot.
|
||||
// Buffered to 1 so the jpeg thread doesn't block if captureScreenshot has already timed out.
|
||||
var jpegReadyCh = make(chan struct{}, 1)
|
||||
|
||||
func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse, error) {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
@@ -76,7 +80,7 @@ func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse
|
||||
|
||||
select {
|
||||
case response := <-responseChan:
|
||||
delete(ongoingRequests, seq)
|
||||
delete(ongoingRequests, ctrlAction.Seq)
|
||||
if response.Error != "" {
|
||||
return nil, ErrorfL(
|
||||
&scopedLogger,
|
||||
@@ -87,7 +91,7 @@ func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse
|
||||
return response, nil
|
||||
case <-time.After(5 * time.Second):
|
||||
close(responseChan)
|
||||
delete(ongoingRequests, seq)
|
||||
delete(ongoingRequests, ctrlAction.Seq)
|
||||
return nil, ErrorfL(&scopedLogger, "timeout waiting for response", nil)
|
||||
}
|
||||
}
|
||||
@@ -194,12 +198,11 @@ func handleCtrlClient(conn net.Conn) {
|
||||
scopedLogger.Warn().Err(err).Msg("error reading from ctrl sock")
|
||||
break
|
||||
}
|
||||
readMsg := string(readBuf[:n])
|
||||
|
||||
ctrlResp := CtrlResponse{}
|
||||
err = json.Unmarshal([]byte(readMsg), &ctrlResp)
|
||||
err = json.Unmarshal(readBuf[:n], &ctrlResp)
|
||||
if err != nil {
|
||||
scopedLogger.Warn().Err(err).Str("data", readMsg).Msg("error parsing ctrl sock msg")
|
||||
scopedLogger.Warn().Err(err).Str("data", string(readBuf[:n])).Msg("error parsing ctrl sock msg")
|
||||
continue
|
||||
}
|
||||
scopedLogger.Trace().Interface("data", ctrlResp).Msg("ctrl sock msg")
|
||||
@@ -213,6 +216,11 @@ func handleCtrlClient(conn net.Conn) {
|
||||
switch ctrlResp.Event {
|
||||
case "video_input_state":
|
||||
HandleVideoStateMessage(ctrlResp)
|
||||
case "jpeg_ready":
|
||||
select {
|
||||
case jpegReadyCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,9 +250,7 @@ func handleVideoClient(conn net.Conn) {
|
||||
lastFrame = now
|
||||
|
||||
// Broadcast to HTTP clients
|
||||
dataCopy := make([]byte, n)
|
||||
copy(dataCopy, inboundPacket[:n])
|
||||
videoBroadcaster.Broadcast(dataCopy)
|
||||
videoBroadcaster.Broadcast(inboundPacket[:n])
|
||||
|
||||
if currentSession != nil {
|
||||
err := currentSession.VideoTrack.WriteSample(media.Sample{Data: inboundPacket[:n], Duration: sinceLastFrame})
|
||||
|
||||
@@ -163,12 +163,11 @@ func handleDisplayCtrlClient(conn net.Conn) {
|
||||
scopedLogger.Warn().Err(err).Msg("error reading from display sock")
|
||||
break
|
||||
}
|
||||
readMsg := string(readBuf[:n])
|
||||
|
||||
displayResp := CtrlResponse{}
|
||||
err = json.Unmarshal([]byte(readMsg), &displayResp)
|
||||
err = json.Unmarshal(readBuf[:n], &displayResp)
|
||||
if err != nil {
|
||||
scopedLogger.Warn().Err(err).Str("data", readMsg).Msg("error parsing display sock msg")
|
||||
scopedLogger.Warn().Err(err).Str("data", string(readBuf[:n])).Msg("error parsing display sock msg")
|
||||
continue
|
||||
}
|
||||
scopedLogger.Trace().Interface("data", displayResp).Msg("display sock msg")
|
||||
|
||||
@@ -163,12 +163,11 @@ func handleVpnCtrlClient(conn net.Conn) {
|
||||
scopedLogger.Warn().Err(err).Msg("error reading from vpn sock")
|
||||
break
|
||||
}
|
||||
readMsg := string(readBuf[:n])
|
||||
|
||||
vpnResp := CtrlResponse{}
|
||||
err = json.Unmarshal([]byte(readMsg), &vpnResp)
|
||||
err = json.Unmarshal(readBuf[:n], &vpnResp)
|
||||
if err != nil {
|
||||
scopedLogger.Warn().Err(err).Str("data", readMsg).Msg("error parsing vpn sock msg")
|
||||
scopedLogger.Warn().Err(err).Str("data", string(readBuf[:n])).Msg("error parsing vpn sock msg")
|
||||
continue
|
||||
}
|
||||
scopedLogger.Trace().Interface("data", vpnResp).Msg("vpn sock msg")
|
||||
|
||||
38
network.go
38
network.go
@@ -2,6 +2,8 @@ package kvm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"kvm/internal/network"
|
||||
"kvm/internal/udhcpc"
|
||||
@@ -17,6 +19,27 @@ var (
|
||||
networkState *network.NetworkInterfaceState
|
||||
)
|
||||
|
||||
func setProxyEnvVar(key, value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
upperKey := strings.ToUpper(key)
|
||||
if value == "" {
|
||||
_ = os.Unsetenv(key)
|
||||
_ = os.Unsetenv(upperKey)
|
||||
return
|
||||
}
|
||||
_ = os.Setenv(key, value)
|
||||
_ = os.Setenv(upperKey, value)
|
||||
}
|
||||
|
||||
func applyProxyEnvironment(networkConfig *network.NetworkConfig) {
|
||||
if networkConfig == nil {
|
||||
return
|
||||
}
|
||||
setProxyEnvVar("http_proxy", networkConfig.HTTPProxy.String)
|
||||
setProxyEnvVar("https_proxy", networkConfig.HTTPSProxy.String)
|
||||
setProxyEnvVar("all_proxy", networkConfig.ALLProxy.String)
|
||||
}
|
||||
|
||||
func networkStateChanged() {
|
||||
// do not block the main thread
|
||||
go waitCtrlAndRequestDisplayUpdate(true)
|
||||
@@ -33,6 +56,7 @@ func networkStateChanged() {
|
||||
|
||||
func initNetwork() error {
|
||||
ensureConfigLoaded()
|
||||
applyProxyEnvironment(config.NetworkConfig)
|
||||
|
||||
state, err := network.NewNetworkInterfaceState(&network.NetworkInterfaceOptions{
|
||||
DefaultHostname: GetDefaultHostname(),
|
||||
@@ -131,6 +155,7 @@ func rpcSetNetworkSettings(settings network.RpcNetworkSettings) (*network.RpcNet
|
||||
if err := SaveConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applyProxyEnvironment(config.NetworkConfig)
|
||||
|
||||
return &network.RpcNetworkSettings{NetworkConfig: *config.NetworkConfig}, nil
|
||||
}
|
||||
@@ -142,3 +167,16 @@ func rpcRenewDHCPLease() error {
|
||||
func rpcRequestDHCPAddress(ip string) error {
|
||||
return networkState.RpcRequestDHCPAddress(ip)
|
||||
}
|
||||
|
||||
const ethernetMacAddressPath = "/userdata/ethaddr.txt"
|
||||
|
||||
func rpcSetEthernetMacAddress(macAddress string) (interface{}, error) {
|
||||
normalized, err := networkState.SetMACAddress(macAddress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(ethernetMacAddressPath, []byte(normalized+"\n"), 0644); err != nil {
|
||||
return nil, fmt.Errorf("failed to write %s: %w", ethernetMacAddressPath, err)
|
||||
}
|
||||
return networkState.RpcGetNetworkState(), nil
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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
336
ota.go
@@ -4,6 +4,7 @@ import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
@@ -42,8 +43,10 @@ type RemoteMetadata struct {
|
||||
AppVersion string `json:"appVersion"`
|
||||
AppUrl string `json:"appUrl"`
|
||||
AppHash string `json:"appHash"`
|
||||
AppSigUrl string `json:"appSigUrl,omitempty"`
|
||||
SystemUrl string `json:"systemUrl"`
|
||||
SystemHash string `json:"systemHash,omitempty"`
|
||||
SystemSigUrl string `json:"systemSigUrl,omitempty"`
|
||||
SystemVersion string `json:"systemVersion"`
|
||||
}
|
||||
|
||||
@@ -53,6 +56,8 @@ type UpdateStatus struct {
|
||||
Remote *RemoteMetadata `json:"remote"`
|
||||
SystemUpdateAvailable bool `json:"systemUpdateAvailable"`
|
||||
AppUpdateAvailable bool `json:"appUpdateAvailable"`
|
||||
AppSignatureMissing bool `json:"appSignatureMissing,omitempty"`
|
||||
SystemSignatureMissing bool `json:"systemSignatureMissing,omitempty"`
|
||||
|
||||
// for backwards compatibility
|
||||
Error string `json:"error,omitempty"`
|
||||
@@ -89,10 +94,12 @@ var UpdateGiteeSystemZipUrls = []string{
|
||||
|
||||
const cdnUpdateBaseURL = "https://cdn.picokvm.top/luckfox_picokvm_firmware/lastest/"
|
||||
|
||||
var builtAppVersion = "0.1.2+dev"
|
||||
var builtAppVersion = "0.1.3+dev"
|
||||
|
||||
var updateSource = "github"
|
||||
var customUpdateBaseURL string
|
||||
var (
|
||||
updateSource = "github"
|
||||
customUpdateBaseURL string
|
||||
)
|
||||
|
||||
const (
|
||||
updateSourceGithub = "github"
|
||||
@@ -144,12 +151,12 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
|
||||
|
||||
_, _ = deviceId, includePreRelease
|
||||
|
||||
appVersionRemote, appURL, appSha256, err := fetchKvmAppLatestRelease(ctx)
|
||||
appVersionRemote, appURL, appSha256, appSigURL, err := fetchKvmAppLatestRelease(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
systemVersionRemote, systemZipURL, err := fetchKvmSystemLatestRelease(ctx)
|
||||
systemVersionRemote, systemZipURL, systemSigURL, err := fetchKvmSystemLatestRelease(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -158,12 +165,14 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
|
||||
AppUrl: appURL,
|
||||
AppVersion: appVersionRemote,
|
||||
AppHash: appSha256,
|
||||
AppSigUrl: appSigURL,
|
||||
SystemUrl: systemZipURL,
|
||||
SystemVersion: systemVersionRemote,
|
||||
SystemSigUrl: systemSigURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL string, sha256 string, err error) {
|
||||
func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL string, sha256 string, sigURL string, err error) {
|
||||
apiURLs := UpdateGithubAppReleaseUrls
|
||||
fallbackToGithub := false
|
||||
if updateSource == updateSourceGitee {
|
||||
@@ -171,7 +180,7 @@ func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL stri
|
||||
fallbackToGithub = true
|
||||
}
|
||||
|
||||
tryFetch := func(urls []string) (string, string, string, error) {
|
||||
tryFetch := func(urls []string) (string, string, string, string, error) {
|
||||
var lastErr error
|
||||
for _, apiURL := range urls {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
@@ -218,44 +227,54 @@ func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL stri
|
||||
continue
|
||||
}
|
||||
|
||||
var downloadURL string
|
||||
var sha256 string
|
||||
if len(release.Assets) > 0 {
|
||||
downloadURL = release.Assets[0].BrowserDownloadURL
|
||||
sha256 = release.Assets[0].Digest
|
||||
var downloadURL, sha256, sigURL string
|
||||
for _, asset := range release.Assets {
|
||||
name := strings.ToLower(strings.TrimSpace(asset.Name))
|
||||
u := strings.TrimSpace(asset.BrowserDownloadURL)
|
||||
if strings.HasSuffix(name, ".sig") || strings.HasSuffix(name, ".sha256") || strings.HasSuffix(name, ".sha2565") {
|
||||
if strings.HasSuffix(name, ".sig") && sigURL == "" {
|
||||
sigURL = u
|
||||
}
|
||||
continue
|
||||
}
|
||||
if downloadURL == "" {
|
||||
downloadURL = u
|
||||
sha256 = strings.TrimPrefix(strings.TrimSpace(asset.Digest), "sha256:")
|
||||
}
|
||||
}
|
||||
sha256 = strings.TrimPrefix(strings.TrimSpace(sha256), "sha256:")
|
||||
|
||||
if strings.TrimSpace(downloadURL) == "" {
|
||||
lastErr = fmt.Errorf("empty app download url from %s", apiURL)
|
||||
continue
|
||||
}
|
||||
|
||||
return tag, downloadURL, sha256, nil
|
||||
return tag, downloadURL, sha256, sigURL, nil
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no app release API URLs configured")
|
||||
}
|
||||
return "", "", "", lastErr
|
||||
return "", "", "", "", lastErr
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
tag, downloadURL, sha256, err = tryFetch(apiURLs)
|
||||
tag, downloadURL, sha256, sigURL, err = tryFetch(apiURLs)
|
||||
if err == nil {
|
||||
return tag, downloadURL, sha256, nil
|
||||
return tag, downloadURL, sha256, sigURL, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
if updateSource == updateSourceGitee && fallbackToGithub {
|
||||
tag, downloadURL, sha256, err = tryFetch(UpdateGithubAppReleaseUrls)
|
||||
var ghSigURL string
|
||||
tag, downloadURL, sha256, ghSigURL, err = tryFetch(UpdateGithubAppReleaseUrls)
|
||||
if err == nil {
|
||||
downloadURL = strings.Replace(downloadURL, "github.com", "gitee.com", 1)
|
||||
return tag, downloadURL, sha256, nil
|
||||
ghSigURL = strings.Replace(ghSigURL, "github.com", "gitee.com", 1)
|
||||
return tag, downloadURL, sha256, ghSigURL, nil
|
||||
}
|
||||
lastErr = fmt.Errorf("gitee app release fetch failed (%v); github fallback failed (%w)", lastErr, err)
|
||||
}
|
||||
return "", "", "", lastErr
|
||||
return "", "", "", "", lastErr
|
||||
}
|
||||
|
||||
type releaseAsset struct {
|
||||
@@ -281,7 +300,7 @@ func pickZipAssetURL(assets []releaseAsset) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string, err error) {
|
||||
func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string, sigURL string, err error) {
|
||||
apiURLs := UpdateGithubSystemReleaseUrls
|
||||
fallbackToGithub := false
|
||||
if updateSource == updateSourceGitee {
|
||||
@@ -336,11 +355,18 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
|
||||
continue
|
||||
}
|
||||
|
||||
var sysSigURL string
|
||||
for _, asset := range release.Assets {
|
||||
name := strings.ToLower(strings.TrimSpace(asset.Name))
|
||||
if strings.HasSuffix(name, ".sig") && sysSigURL == "" {
|
||||
sysSigURL = strings.TrimSpace(asset.BrowserDownloadURL)
|
||||
}
|
||||
}
|
||||
if u := pickZipAssetURL(release.Assets); strings.TrimSpace(u) != "" {
|
||||
return tag, strings.TrimSpace(u), nil
|
||||
return tag, strings.TrimSpace(u), sysSigURL, nil
|
||||
}
|
||||
if strings.TrimSpace(release.ZipballURL) != "" {
|
||||
return tag, strings.TrimSpace(release.ZipballURL), nil
|
||||
return tag, strings.TrimSpace(release.ZipballURL), sysSigURL, nil
|
||||
}
|
||||
|
||||
lastErr = fmt.Errorf("no usable system archive url in release response from %s", apiURL)
|
||||
@@ -355,22 +381,22 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
|
||||
var githubTag string
|
||||
var githubZipURL string
|
||||
for i, apiURL := range UpdateGithubSystemReleaseUrls {
|
||||
githubTag, githubZipURL, githubErr = func(apiURL string) (string, string, error) {
|
||||
githubTag, githubZipURL, _, githubErr = func(apiURL string) (string, string, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("error creating system release request: %w", err)
|
||||
return "", "", "", fmt.Errorf("error creating system release request: %w", err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("error fetching system release: %w", err)
|
||||
return "", "", "", fmt.Errorf("error fetching system release: %w", err)
|
||||
}
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if readErr != nil {
|
||||
return "", "", fmt.Errorf("error reading system release response: %w", readErr)
|
||||
return "", "", "", fmt.Errorf("error reading system release response: %w", readErr)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", fmt.Errorf(
|
||||
return "", "", "", fmt.Errorf(
|
||||
"unexpected status code fetching system release from %s: %d, %s",
|
||||
apiURL,
|
||||
resp.StatusCode,
|
||||
@@ -383,19 +409,26 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
|
||||
Assets []releaseAsset `json:"assets"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &release); err != nil {
|
||||
return "", "", fmt.Errorf("error parsing system release JSON from %s: %w", apiURL, err)
|
||||
return "", "", "", fmt.Errorf("error parsing system release JSON from %s: %w", apiURL, err)
|
||||
}
|
||||
tag := strings.TrimSpace(release.TagName)
|
||||
if tag == "" {
|
||||
return "", "", fmt.Errorf("empty system tag_name from %s", apiURL)
|
||||
return "", "", "", fmt.Errorf("empty system tag_name from %s", apiURL)
|
||||
}
|
||||
var sigURL string
|
||||
for _, asset := range release.Assets {
|
||||
name := strings.ToLower(strings.TrimSpace(asset.Name))
|
||||
if strings.HasSuffix(name, ".sig") && sigURL == "" {
|
||||
sigURL = strings.TrimSpace(asset.BrowserDownloadURL)
|
||||
}
|
||||
}
|
||||
if u := pickZipAssetURL(release.Assets); strings.TrimSpace(u) != "" {
|
||||
return tag, strings.TrimSpace(u), nil
|
||||
return tag, strings.TrimSpace(u), sigURL, nil
|
||||
}
|
||||
if strings.TrimSpace(release.ZipballURL) != "" {
|
||||
return tag, strings.TrimSpace(release.ZipballURL), nil
|
||||
return tag, strings.TrimSpace(release.ZipballURL), sigURL, nil
|
||||
}
|
||||
return "", "", fmt.Errorf("no usable system archive url in release response from %s", apiURL)
|
||||
return "", "", "", fmt.Errorf("no usable system archive url in release response from %s", apiURL)
|
||||
}(apiURL)
|
||||
if githubErr == nil && strings.TrimSpace(githubTag) != "" {
|
||||
_ = githubZipURL
|
||||
@@ -414,15 +447,15 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
|
||||
zipTag = strings.TrimPrefix(zipTag, "V")
|
||||
}
|
||||
zipURL := strings.TrimRight(selectedZipURL, "/") + "/" + zipTag + ".zip"
|
||||
return githubTag, zipURL, nil
|
||||
return githubTag, zipURL, "", nil
|
||||
}
|
||||
githubErr = fmt.Errorf("no gitee system zip urls configured")
|
||||
break
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("gitee system release fetch failed (%v); github fallback failed (%w)", lastErr, githubErr)
|
||||
return "", "", "", fmt.Errorf("gitee system release fetch failed (%v); github fallback failed (%w)", lastErr, githubErr)
|
||||
}
|
||||
return "", "", lastErr
|
||||
return "", "", "", lastErr
|
||||
}
|
||||
|
||||
func fetchUpdateMetadataFromBaseURL(ctx context.Context, baseURL string) (*RemoteMetadata, error) {
|
||||
@@ -496,13 +529,21 @@ func fetchUpdateMetadataFromBaseURL(ctx context.Context, baseURL string) (*Remot
|
||||
}
|
||||
}
|
||||
|
||||
appSigURL, _ := resolveURL(baseURL, "kvm_app.sig")
|
||||
systemSigURL, _ := resolveURL(baseURL, "update_system.zip.sig")
|
||||
if strings.HasSuffix(systemURL, ".tar") {
|
||||
systemSigURL, _ = resolveURL(baseURL, "update_system.tar.sig")
|
||||
}
|
||||
|
||||
return &RemoteMetadata{
|
||||
AppVersion: appVersion,
|
||||
AppUrl: appURL,
|
||||
AppHash: appHash,
|
||||
AppSigUrl: appSigURL,
|
||||
SystemVersion: systemVersion,
|
||||
SystemUrl: systemURL,
|
||||
SystemHash: systemHash,
|
||||
SystemSigUrl: systemSigURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -885,7 +926,7 @@ func downloadFile(
|
||||
}
|
||||
|
||||
// Clear the filesystem caches to force a read from disk
|
||||
err = os.WriteFile("/proc/sys/vm/drop_caches", []byte("1"), 0644)
|
||||
err = os.WriteFile("/proc/sys/vm/drop_caches", []byte("1"), 0o644)
|
||||
if err != nil {
|
||||
otaLogger.Warn().Err(err).Msg("Failed to clear filesystem caches")
|
||||
}
|
||||
@@ -909,6 +950,8 @@ func prepareSystemUpdateTarFromKvmSystemZip(
|
||||
downloadProgress *float32,
|
||||
downloadSpeedBps *float32,
|
||||
verificationProgress *float32,
|
||||
sigURL string,
|
||||
expectedHash string,
|
||||
scopedLogger *zerolog.Logger,
|
||||
) error {
|
||||
if scopedLogger == nil {
|
||||
@@ -920,14 +963,14 @@ func prepareSystemUpdateTarFromKvmSystemZip(
|
||||
extractDir := filepath.Join(workDir, "extract")
|
||||
zipPath := filepath.Join(workDir, "master.zip")
|
||||
|
||||
if err := os.MkdirAll(workDir, 0755); err != nil {
|
||||
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
||||
return fmt.Errorf("error creating work dir: %w", err)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(extractDir); err != nil {
|
||||
return fmt.Errorf("error cleaning extract dir: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(extractDir, 0755); err != nil {
|
||||
if err := os.MkdirAll(extractDir, 0o755); err != nil {
|
||||
return fmt.Errorf("error creating extract dir: %w", err)
|
||||
}
|
||||
|
||||
@@ -955,19 +998,26 @@ func prepareSystemUpdateTarFromKvmSystemZip(
|
||||
zipUnverifiedPath := zipPath + ".unverified"
|
||||
if _, err := os.Stat(zipUnverifiedPath); err != nil {
|
||||
lastErr = fmt.Errorf("downloaded zip not found: %s: %w", zipUnverifiedPath, err)
|
||||
} else {
|
||||
if err := unzipArchive(zipUnverifiedPath, extractDir); err != nil {
|
||||
} else if sigURL != "" || expectedHash != "" {
|
||||
if err := verifyFile(ctx, zipPath, expectedHash, sigURL, verificationProgress, scopedLogger); err != nil {
|
||||
lastErr = fmt.Errorf("system zip verification failed: %w", err)
|
||||
} else if err := unzipArchive(zipUnverifiedPath, extractDir); err != nil {
|
||||
lastErr = err
|
||||
} else {
|
||||
lastErr = nil
|
||||
break
|
||||
}
|
||||
} else if err := unzipArchive(zipUnverifiedPath, extractDir); err != nil {
|
||||
lastErr = err
|
||||
} else {
|
||||
lastErr = nil
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
_ = os.Remove(zipPath + ".unverified")
|
||||
_ = os.RemoveAll(extractDir)
|
||||
_ = os.MkdirAll(extractDir, 0755)
|
||||
_ = os.MkdirAll(extractDir, 0o755)
|
||||
if attempt < maxAttempts {
|
||||
time.Sleep(time.Duration(attempt*2) * time.Second)
|
||||
}
|
||||
@@ -999,7 +1049,7 @@ func prepareSystemUpdateTarFromKvmSystemZip(
|
||||
if _, err := os.Stat(scriptPath); err != nil {
|
||||
return fmt.Errorf("split_and_check_md5.sh not found: %w", err)
|
||||
}
|
||||
if err := os.Chmod(scriptPath, 0755); err != nil {
|
||||
if err := os.Chmod(scriptPath, 0o755); err != nil {
|
||||
return fmt.Errorf("error chmod split_and_check_md5.sh: %w", err)
|
||||
}
|
||||
|
||||
@@ -1064,13 +1114,13 @@ func unzipArchive(zipPath string, destDir string) error {
|
||||
}
|
||||
|
||||
if file.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(cleanTargetPath, 0755); err != nil {
|
||||
if err := os.MkdirAll(cleanTargetPath, 0o755); err != nil {
|
||||
return fmt.Errorf("error creating dir: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(cleanTargetPath), 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(cleanTargetPath), 0o755); err != nil {
|
||||
return fmt.Errorf("error creating dir: %w", err)
|
||||
}
|
||||
|
||||
@@ -1079,7 +1129,7 @@ func unzipArchive(zipPath string, destDir string) error {
|
||||
return fmt.Errorf("error opening zipped file: %w", err)
|
||||
}
|
||||
|
||||
outFile, err := os.OpenFile(cleanTargetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
|
||||
outFile, err := os.OpenFile(cleanTargetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return fmt.Errorf("error creating file: %w", err)
|
||||
@@ -1102,17 +1152,42 @@ func unzipArchive(zipPath string, destDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyFile(path string, expectedHash string, verifyProgress *float32, scopedLogger *zerolog.Logger) error {
|
||||
func verifyFile(ctx context.Context, path string, expectedHash string, sigURL string, verifyProgress *float32, scopedLogger *zerolog.Logger) error {
|
||||
if scopedLogger == nil {
|
||||
scopedLogger = otaLogger
|
||||
}
|
||||
|
||||
unverifiedPath := path + ".unverified"
|
||||
|
||||
if strings.TrimSpace(sigURL) == "" && strings.TrimSpace(expectedHash) == "" {
|
||||
return fmt.Errorf("refusing to flash unverified firmware: no signature URL and no hash provided")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(sigURL) != "" {
|
||||
sigBasePath := path + ".sig"
|
||||
sigDownloadErr := downloadFile(ctx, sigBasePath, sigURL, nil, nil)
|
||||
sigPath := sigBasePath + ".unverified"
|
||||
if sigDownloadErr != nil {
|
||||
scopedLogger.Warn().Err(sigDownloadErr).Str("sigURL", sigURL).Msg("failed to download signature file, falling back to hash-only verification")
|
||||
} else {
|
||||
sigPresent, sigErr := verifyFileSignature(unverifiedPath, sigPath, scopedLogger)
|
||||
_ = os.Remove(sigPath)
|
||||
if sigPresent && sigErr != nil {
|
||||
return fmt.Errorf("signature verification failed: %w", sigErr)
|
||||
}
|
||||
if sigPresent && sigErr == nil {
|
||||
scopedLogger.Info().Str("path", path).Msg("firmware signature verified, proceeding to hash check")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scopedLogger.Info().Str("path", path).Msg("no signature URL provided, skipping signature verification")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(expectedHash) == "" {
|
||||
if err := os.Rename(unverifiedPath, path); err != nil {
|
||||
return fmt.Errorf("error renaming file: %w", err)
|
||||
}
|
||||
if err := os.Chmod(path, 0755); err != nil {
|
||||
if err := os.Chmod(path, 0o755); err != nil {
|
||||
return fmt.Errorf("error making file executable: %w", err)
|
||||
}
|
||||
return nil
|
||||
@@ -1170,7 +1245,7 @@ func verifyFile(path string, expectedHash string, verifyProgress *float32, scope
|
||||
return fmt.Errorf("error renaming file: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(path, 0755); err != nil {
|
||||
if err := os.Chmod(path, 0o755); err != nil {
|
||||
return fmt.Errorf("error making file executable: %w", err)
|
||||
}
|
||||
|
||||
@@ -1183,19 +1258,23 @@ type OTAState struct {
|
||||
MetadataFetchedAt *time.Time `json:"metadataFetchedAt,omitempty"`
|
||||
AppUpdatePending bool `json:"appUpdatePending"`
|
||||
SystemUpdatePending bool `json:"systemUpdatePending"`
|
||||
AppDownloadProgress float32 `json:"appDownloadProgress,omitempty"` //TODO: implement for progress bar
|
||||
AppDownloadProgress float32 `json:"appDownloadProgress,omitempty"` // TODO: implement for progress bar
|
||||
AppDownloadSpeedBps float32 `json:"appDownloadSpeedBps"`
|
||||
AppDownloadFinishedAt *time.Time `json:"appDownloadFinishedAt,omitempty"`
|
||||
SystemDownloadProgress float32 `json:"systemDownloadProgress,omitempty"` //TODO: implement for progress bar
|
||||
SystemDownloadProgress float32 `json:"systemDownloadProgress,omitempty"` // TODO: implement for progress bar
|
||||
SystemDownloadSpeedBps float32 `json:"systemDownloadSpeedBps"`
|
||||
SystemDownloadFinishedAt *time.Time `json:"systemDownloadFinishedAt,omitempty"`
|
||||
AppVerificationProgress float32 `json:"appVerificationProgress,omitempty"`
|
||||
AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"`
|
||||
SystemVerificationProgress float32 `json:"systemVerificationProgress,omitempty"`
|
||||
SystemVerifiedAt *time.Time `json:"systemVerifiedAt,omitempty"`
|
||||
AppUpdateProgress float32 `json:"appUpdateProgress,omitempty"` //TODO: implement for progress bar
|
||||
AppSignatureVerified bool `json:"appSignatureVerified,omitempty"`
|
||||
SystemSignatureVerified bool `json:"systemSignatureVerified,omitempty"`
|
||||
AppSignatureMissing bool `json:"appSignatureMissing,omitempty"`
|
||||
SystemSignatureMissing bool `json:"systemSignatureMissing,omitempty"`
|
||||
AppUpdateProgress float32 `json:"appUpdateProgress,omitempty"` // TODO: implement for progress bar
|
||||
AppUpdatedAt *time.Time `json:"appUpdatedAt,omitempty"`
|
||||
SystemUpdateProgress float32 `json:"systemUpdateProgress,omitempty"` //TODO: port rk_ota, then implement
|
||||
SystemUpdateProgress float32 `json:"systemUpdateProgress,omitempty"` // TODO: port rk_ota, then implement
|
||||
SystemUpdatedAt *time.Time `json:"systemUpdatedAt,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1214,7 +1293,12 @@ func triggerOTAStateUpdate() {
|
||||
func cleanupUpdateTempFiles(logger *zerolog.Logger) {
|
||||
paths := []string{
|
||||
"/userdata/picokvm/bin/kvm_app.unverified",
|
||||
"/userdata/picokvm/bin/kvm_app.sig.unverified",
|
||||
"/userdata/picokvm/update_system.zip.unverified",
|
||||
"/userdata/picokvm/update_system.zip.sig.unverified",
|
||||
"/userdata/picokvm/update_system.zip",
|
||||
"/userdata/picokvm/update_system.tar.unverified",
|
||||
"/userdata/picokvm/update_system.tar.sig.unverified",
|
||||
"/userdata/picokvm/update_system.tar",
|
||||
"/userdata/picokvm/kvm_system_work",
|
||||
}
|
||||
@@ -1298,8 +1382,10 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
||||
triggerOTAStateUpdate()
|
||||
|
||||
err = verifyFile(
|
||||
ctx,
|
||||
"/userdata/picokvm/bin/kvm_app",
|
||||
remote.AppHash,
|
||||
remote.AppSigUrl,
|
||||
&otaState.AppVerificationProgress,
|
||||
&scopedLogger,
|
||||
)
|
||||
@@ -1312,6 +1398,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
||||
verifyFinished := time.Now()
|
||||
otaState.AppVerifiedAt = &verifyFinished
|
||||
otaState.AppVerificationProgress = 1
|
||||
otaState.AppSignatureVerified = strings.TrimSpace(remote.AppSigUrl) != ""
|
||||
otaState.AppUpdatedAt = &verifyFinished
|
||||
otaState.AppUpdateProgress = 1
|
||||
triggerOTAStateUpdate()
|
||||
@@ -1337,6 +1424,8 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
||||
&otaState.SystemDownloadProgress,
|
||||
&otaState.SystemDownloadSpeedBps,
|
||||
&otaState.SystemVerificationProgress,
|
||||
remote.SystemSigUrl,
|
||||
remote.SystemHash,
|
||||
&scopedLogger,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -1361,7 +1450,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
||||
return err
|
||||
}
|
||||
|
||||
err = verifyFile(systemZipPath, remote.SystemHash, &otaState.SystemVerificationProgress, &scopedLogger)
|
||||
err = verifyFile(ctx, systemZipPath, remote.SystemHash, remote.SystemSigUrl, &otaState.SystemVerificationProgress, &scopedLogger)
|
||||
if err != nil {
|
||||
otaState.Error = fmt.Sprintf("Error preparing system update archive: %v", err)
|
||||
scopedLogger.Error().Err(err).Msg("Error preparing system update archive")
|
||||
@@ -1385,6 +1474,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
||||
verifyFinished := time.Now()
|
||||
otaState.SystemVerifiedAt = &verifyFinished
|
||||
otaState.SystemVerificationProgress = 1
|
||||
otaState.SystemSignatureVerified = strings.TrimSpace(remote.SystemSigUrl) != ""
|
||||
triggerOTAStateUpdate()
|
||||
|
||||
scopedLogger.Info().Msg("Starting rk_ota command")
|
||||
@@ -1454,13 +1544,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
||||
}
|
||||
|
||||
if rebootNeeded {
|
||||
configPath := "/userdata/kvm_config.json"
|
||||
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
|
||||
scopedLogger.Warn().Err(err).Str("path", configPath).Msg("failed to delete config before reboot")
|
||||
} else {
|
||||
scopedLogger.Info().Str("path", configPath).Msg("deleted config before reboot")
|
||||
}
|
||||
|
||||
cleanupUpdateTempFiles(&scopedLogger)
|
||||
scopedLogger.Info().Msg("System Rebooting in 10s")
|
||||
time.Sleep(10 * time.Second)
|
||||
cmd := exec.Command("reboot")
|
||||
@@ -1521,6 +1605,9 @@ func GetUpdateStatus(ctx context.Context, deviceId string, includePreRelease boo
|
||||
updateStatus.AppUpdateAvailable = false
|
||||
}
|
||||
|
||||
updateStatus.AppSignatureMissing = strings.TrimSpace(remoteMetadata.AppSigUrl) == ""
|
||||
updateStatus.SystemSignatureMissing = strings.TrimSpace(remoteMetadata.SystemSigUrl) == ""
|
||||
|
||||
return updateStatus, nil
|
||||
}
|
||||
|
||||
@@ -1535,3 +1622,130 @@ func confirmCurrentSystem() {
|
||||
logger.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup")
|
||||
}
|
||||
}
|
||||
|
||||
func getOTAPublicKey() ed25519.PublicKey {
|
||||
keyStr := strings.TrimSpace(builtOtaPublicKey)
|
||||
if keyStr == "" {
|
||||
return nil
|
||||
}
|
||||
keyBytes, err := hex.DecodeString(keyStr)
|
||||
if err != nil {
|
||||
otaLogger.Warn().Err(err).Msg("invalid OTA public key hex in binary")
|
||||
return nil
|
||||
}
|
||||
if len(keyBytes) != ed25519.PublicKeySize {
|
||||
otaLogger.Warn().Int("size", len(keyBytes)).Msg("OTA public key wrong size, expected 32 bytes")
|
||||
return nil
|
||||
}
|
||||
return ed25519.PublicKey(keyBytes)
|
||||
}
|
||||
|
||||
func verifyFileSignature(
|
||||
unverifiedPath string,
|
||||
sigPath string,
|
||||
scopedLogger *zerolog.Logger,
|
||||
) (signaturePresent bool, err error) {
|
||||
if scopedLogger == nil {
|
||||
scopedLogger = otaLogger
|
||||
}
|
||||
|
||||
if _, err := os.Stat(sigPath); os.IsNotExist(err) {
|
||||
scopedLogger.Info().Str("path", sigPath).Msg("signature file not found, skipping signature verification")
|
||||
return false, nil
|
||||
}
|
||||
|
||||
sigBytes, err := os.ReadFile(sigPath)
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("error reading signature file: %w", err)
|
||||
}
|
||||
|
||||
if len(sigBytes) != ed25519.SignatureSize {
|
||||
return true, fmt.Errorf("invalid signature file size: got %d bytes, expected %d", len(sigBytes), ed25519.SignatureSize)
|
||||
}
|
||||
|
||||
publicKey := getOTAPublicKey()
|
||||
if publicKey == nil {
|
||||
return true, fmt.Errorf("signature present but no public key embedded in binary")
|
||||
}
|
||||
|
||||
fileBytes, err := os.ReadFile(unverifiedPath)
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("error reading file for signature verification: %w", err)
|
||||
}
|
||||
|
||||
fileHash := sha256.Sum256(fileBytes)
|
||||
if !ed25519.Verify(publicKey, fileHash[:], sigBytes) {
|
||||
return true, fmt.Errorf("Ed25519 signature verification failed for %s", unverifiedPath)
|
||||
}
|
||||
|
||||
scopedLogger.Info().Str("path", unverifiedPath).Msg("Ed25519 signature verification passed")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func isSigFileAbsent(sigPath string) bool {
|
||||
_, err := os.Stat(sigPath)
|
||||
return os.IsNotExist(err)
|
||||
}
|
||||
|
||||
func verifyLocalFileSignature(filePath string, sigPath string, publicKey ed25519.PublicKey) bool {
|
||||
sigBytes, err := os.ReadFile(sigPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if len(sigBytes) != ed25519.SignatureSize {
|
||||
return false
|
||||
}
|
||||
fileBytes, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
fileHash := sha256.Sum256(fileBytes)
|
||||
return ed25519.Verify(publicKey, fileHash[:], sigBytes)
|
||||
}
|
||||
|
||||
type SignatureUpdateResult struct {
|
||||
AppSignatureUpdated bool `json:"appSignatureUpdated"`
|
||||
SystemSignatureUpdated bool `json:"systemSignatureUpdated"`
|
||||
AppSignatureValid bool `json:"appSignatureValid"`
|
||||
SystemSignatureValid bool `json:"systemSignatureValid"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func UpdateSignatures(ctx context.Context) (*SignatureUpdateResult, error) {
|
||||
result := &SignatureUpdateResult{}
|
||||
|
||||
remoteMetadata, err := fetchUpdateMetadata(ctx, "", false)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("failed to fetch remote metadata: %v", err)
|
||||
return result, fmt.Errorf("failed to fetch remote metadata: %w", err)
|
||||
}
|
||||
|
||||
publicKey := getOTAPublicKey()
|
||||
|
||||
appBinPath := "/userdata/picokvm/bin/kvm_app"
|
||||
appSigPath := appBinPath + ".sig"
|
||||
|
||||
if strings.TrimSpace(remoteMetadata.AppSigUrl) != "" {
|
||||
err := downloadFile(ctx, appSigPath, remoteMetadata.AppSigUrl, nil, nil)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("failed to download app signature: %v", err)
|
||||
return result, fmt.Errorf("failed to download app signature: %w", err)
|
||||
}
|
||||
result.AppSignatureUpdated = true
|
||||
|
||||
sigUnverified := appSigPath + ".unverified"
|
||||
if _, statErr := os.Stat(sigUnverified); statErr == nil {
|
||||
_ = os.Remove(appSigPath)
|
||||
if renameErr := os.Rename(sigUnverified, appSigPath); renameErr != nil {
|
||||
result.Error = fmt.Sprintf("failed to rename app signature: %v", renameErr)
|
||||
return result, fmt.Errorf("failed to rename app signature: %w", renameErr)
|
||||
}
|
||||
}
|
||||
|
||||
if publicKey != nil {
|
||||
result.AppSignatureValid = verifyLocalFileSignature(appBinPath, appSigPath, publicKey)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -2,30 +2,64 @@ package kvm
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type VideoFrame struct {
|
||||
data []byte
|
||||
refs atomic.Int32
|
||||
pool *sync.Pool
|
||||
}
|
||||
|
||||
func (f *VideoFrame) Data() []byte {
|
||||
return f.data
|
||||
}
|
||||
|
||||
func (f *VideoFrame) Release() {
|
||||
if f.refs.Add(-1) == 0 {
|
||||
f.data = f.data[:cap(f.data)]
|
||||
f.pool.Put(f.data)
|
||||
}
|
||||
}
|
||||
|
||||
var framePool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
return make([]byte, maxFrameSize)
|
||||
},
|
||||
}
|
||||
|
||||
type VideoBroadcaster struct {
|
||||
subscribers map[string]chan []byte
|
||||
lock sync.RWMutex
|
||||
onFirstSubscribe func()
|
||||
subscribers map[string]chan *VideoFrame
|
||||
subscriberList []chan *VideoFrame // cached flat slice, rebuilt on Subscribe/Unsubscribe
|
||||
count atomic.Int32 // len(subscribers) as atomic for fast Broadcast check
|
||||
lock sync.RWMutex
|
||||
onFirstSubscribe func()
|
||||
onLastUnsubscribe func()
|
||||
}
|
||||
|
||||
var videoBroadcaster = &VideoBroadcaster{
|
||||
subscribers: make(map[string]chan []byte),
|
||||
subscribers: make(map[string]chan *VideoFrame),
|
||||
}
|
||||
|
||||
func (b *VideoBroadcaster) Subscribe() (string, chan []byte) {
|
||||
func (b *VideoBroadcaster) rebuildList() {
|
||||
list := make([]chan *VideoFrame, 0, len(b.subscribers))
|
||||
for _, ch := range b.subscribers {
|
||||
list = append(list, ch)
|
||||
}
|
||||
b.subscriberList = list
|
||||
}
|
||||
|
||||
func (b *VideoBroadcaster) Subscribe() (string, chan *VideoFrame) {
|
||||
b.lock.Lock()
|
||||
defer b.lock.Unlock()
|
||||
id := uuid.New().String()
|
||||
// Buffer a bit to avoid dropping frames too easily,
|
||||
// but not too much to avoid latency build-up
|
||||
ch := make(chan []byte, 200)
|
||||
ch := make(chan *VideoFrame, 200)
|
||||
wasEmpty := len(b.subscribers) == 0
|
||||
b.subscribers[id] = ch
|
||||
b.rebuildList()
|
||||
b.count.Store(int32(len(b.subscribers)))
|
||||
if wasEmpty && b.onFirstSubscribe != nil {
|
||||
b.onFirstSubscribe()
|
||||
}
|
||||
@@ -38,6 +72,8 @@ func (b *VideoBroadcaster) Unsubscribe(id string) {
|
||||
if ch, ok := b.subscribers[id]; ok {
|
||||
close(ch)
|
||||
delete(b.subscribers, id)
|
||||
b.rebuildList()
|
||||
b.count.Store(int32(len(b.subscribers)))
|
||||
if len(b.subscribers) == 0 && b.onLastUnsubscribe != nil {
|
||||
b.onLastUnsubscribe()
|
||||
}
|
||||
@@ -45,15 +81,38 @@ func (b *VideoBroadcaster) Unsubscribe(id string) {
|
||||
}
|
||||
|
||||
func (b *VideoBroadcaster) Broadcast(data []byte) {
|
||||
// atomic check avoids acquiring RLock on every video frame when no HTTP clients are connected
|
||||
if b.count.Load() == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
b.lock.RLock()
|
||||
defer b.lock.RUnlock()
|
||||
for _, ch := range b.subscribers {
|
||||
// Non-blocking send
|
||||
subscribers := b.subscriberList
|
||||
subscriberCount := len(subscribers)
|
||||
if subscriberCount == 0 {
|
||||
b.lock.RUnlock()
|
||||
return
|
||||
}
|
||||
|
||||
buf := framePool.Get().([]byte)
|
||||
if cap(buf) < len(data) {
|
||||
buf = make([]byte, len(data))
|
||||
}
|
||||
n := copy(buf, data)
|
||||
|
||||
frame := &VideoFrame{
|
||||
data: buf[:n],
|
||||
pool: &framePool,
|
||||
}
|
||||
frame.refs.Store(int32(subscriberCount + 1))
|
||||
|
||||
for _, ch := range subscribers {
|
||||
select {
|
||||
case ch <- data:
|
||||
case ch <- frame:
|
||||
default:
|
||||
// Drop frame if channel is full to avoid blocking other subscribers
|
||||
// Ideally we should have a ring buffer or similar, but this is simple
|
||||
frame.Release()
|
||||
}
|
||||
}
|
||||
b.lock.RUnlock()
|
||||
frame.Release()
|
||||
}
|
||||
|
||||
906
tools.go
Normal file
906
tools.go
Normal file
@@ -0,0 +1,906 @@
|
||||
package kvm
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const vpnToolsRoot = "/userdata/vpn-tools"
|
||||
const vntPinnedVersion = "v1.2.16"
|
||||
|
||||
type vpnToolSpec struct {
|
||||
Name string
|
||||
Repo string
|
||||
Binaries []string
|
||||
VersionBinary string
|
||||
VersionFlags [][]string
|
||||
}
|
||||
|
||||
var vpnToolSpecs = map[string]vpnToolSpec{
|
||||
"frpc": {
|
||||
Name: "frpc",
|
||||
Repo: "fatedier/frp",
|
||||
Binaries: []string{"frpc"},
|
||||
VersionBinary: "frpc",
|
||||
VersionFlags: [][]string{{"-v"}, {"--version"}, {"version"}},
|
||||
},
|
||||
"easytier": {
|
||||
Name: "easytier",
|
||||
Repo: "EasyTier/EasyTier",
|
||||
Binaries: []string{"easytier-core", "easytier-cli"},
|
||||
VersionBinary: "easytier-cli",
|
||||
VersionFlags: [][]string{{"--version"}, {"-V"}, {"version"}},
|
||||
},
|
||||
"vnt": {
|
||||
Name: "vnt",
|
||||
Repo: "vnt-dev/vnt",
|
||||
Binaries: []string{"vnt-cli"},
|
||||
VersionBinary: "vnt-cli",
|
||||
VersionFlags: [][]string{{}, {"--version"}, {"-V"}, {"version"}},
|
||||
},
|
||||
"cloudflared": {
|
||||
Name: "cloudflared",
|
||||
Repo: "cloudflare/cloudflared",
|
||||
Binaries: []string{"cloudflared"},
|
||||
VersionBinary: "cloudflared",
|
||||
VersionFlags: [][]string{{"-v"}, {"version"}, {"--version"}},
|
||||
},
|
||||
}
|
||||
|
||||
type VpnToolSystemInfo struct {
|
||||
GOOS string `json:"goos"`
|
||||
GOARCH string `json:"goarch"`
|
||||
UnameArch string `json:"uname_arch"`
|
||||
ArchLabel string `json:"arch_label"`
|
||||
ArchKeywords []string `json:"arch_keywords"`
|
||||
}
|
||||
|
||||
type VpnToolStatus struct {
|
||||
Tool string `json:"tool"`
|
||||
Installed bool `json:"installed"`
|
||||
Source string `json:"source"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
DetectedVersion string `json:"detected_version"`
|
||||
ManagedVersions []string `json:"managed_versions"`
|
||||
}
|
||||
|
||||
type VpnToolReleaseAsset struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
ArchMatch bool `json:"arch_match"`
|
||||
}
|
||||
|
||||
type VpnToolRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []VpnToolReleaseAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type githubReleaseAsset struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
type githubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Draft bool `json:"draft"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Assets []githubReleaseAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type VpnToolInstallTask struct {
|
||||
Tool string `json:"tool"`
|
||||
Running bool `json:"running"`
|
||||
Progress float64 `json:"progress"`
|
||||
Message string `json:"message"`
|
||||
Logs []string `json:"logs"`
|
||||
Error string `json:"error"`
|
||||
Version string `json:"version"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
var (
|
||||
vpnToolInstallTaskMu sync.Mutex
|
||||
vpnToolInstallTasks = map[string]*VpnToolInstallTask{}
|
||||
)
|
||||
|
||||
func getVpnToolSpec(tool string) (vpnToolSpec, error) {
|
||||
spec, ok := vpnToolSpecs[strings.ToLower(strings.TrimSpace(tool))]
|
||||
if !ok {
|
||||
return vpnToolSpec{}, fmt.Errorf("unsupported vpn tool: %s", tool)
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func normalizeArchLabel(arch string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(arch)) {
|
||||
case "x86_64", "amd64":
|
||||
return "amd64"
|
||||
case "aarch64", "arm64":
|
||||
return "arm64"
|
||||
case "armv7l", "armv7", "armhf":
|
||||
return "armv7"
|
||||
case "armv6l", "armv6":
|
||||
return "armv6"
|
||||
case "i386", "i686", "386", "x86":
|
||||
return "386"
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(arch))
|
||||
}
|
||||
}
|
||||
|
||||
func archKeywords(archLabel string) []string {
|
||||
switch archLabel {
|
||||
case "amd64":
|
||||
return []string{"amd64", "x86_64", "x64"}
|
||||
case "arm64":
|
||||
return []string{"arm64", "aarch64"}
|
||||
case "armv7":
|
||||
return []string{"armv7", "armv7l", "armhf", "arm"}
|
||||
case "armv6":
|
||||
return []string{"armv6", "armv6l", "arm"}
|
||||
case "386":
|
||||
return []string{"386", "i386", "x86"}
|
||||
default:
|
||||
if archLabel == "" {
|
||||
return []string{}
|
||||
}
|
||||
return []string{archLabel}
|
||||
}
|
||||
}
|
||||
|
||||
func rpcGetVpnToolSystemInfo() (VpnToolSystemInfo, error) {
|
||||
unameArch := runtime.GOARCH
|
||||
if out, err := exec.Command("uname", "-m").Output(); err == nil {
|
||||
unameArch = strings.TrimSpace(string(out))
|
||||
}
|
||||
archLabel := normalizeArchLabel(unameArch)
|
||||
if archLabel == "" {
|
||||
archLabel = normalizeArchLabel(runtime.GOARCH)
|
||||
}
|
||||
|
||||
return VpnToolSystemInfo{
|
||||
GOOS: runtime.GOOS,
|
||||
GOARCH: runtime.GOARCH,
|
||||
UnameArch: unameArch,
|
||||
ArchLabel: archLabel,
|
||||
ArchKeywords: archKeywords(archLabel),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func vpnToolDir(spec vpnToolSpec) string {
|
||||
return filepath.Join(vpnToolsRoot, spec.Name)
|
||||
}
|
||||
|
||||
func vpnToolVersionsDir(spec vpnToolSpec) string {
|
||||
return filepath.Join(vpnToolDir(spec), "versions")
|
||||
}
|
||||
|
||||
func vpnToolCurrentDir(spec vpnToolSpec) string {
|
||||
return filepath.Join(vpnToolDir(spec), "current")
|
||||
}
|
||||
|
||||
func managedBinaryPath(spec vpnToolSpec, binaryName string) string {
|
||||
return filepath.Join(vpnToolCurrentDir(spec), binaryName)
|
||||
}
|
||||
|
||||
func findExecutablePath(binary string) (string, error) {
|
||||
if strings.Contains(binary, "/") {
|
||||
info, err := os.Stat(binary)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info.Mode().IsRegular() || (info.Mode()&os.ModeSymlink) != 0 {
|
||||
return binary, nil
|
||||
}
|
||||
return "", fmt.Errorf("not executable file: %s", binary)
|
||||
}
|
||||
return exec.LookPath(binary)
|
||||
}
|
||||
|
||||
func resolveVpnToolBinary(tool, defaultBinary string) string {
|
||||
spec, err := getVpnToolSpec(tool)
|
||||
if err != nil {
|
||||
return defaultBinary
|
||||
}
|
||||
managed := managedBinaryPath(spec, defaultBinary)
|
||||
if _, err := os.Stat(managed); err == nil {
|
||||
return managed
|
||||
}
|
||||
return defaultBinary
|
||||
}
|
||||
|
||||
func detectCommandVersion(binaryPath string, flags [][]string) string {
|
||||
for _, args := range flags {
|
||||
cmd := exec.Command(binaryPath, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil && len(out) == 0 {
|
||||
continue
|
||||
}
|
||||
line := extractVersionLine(string(out))
|
||||
if line != "" {
|
||||
return line
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractVersionLine(output string) string {
|
||||
normalized := strings.ReplaceAll(output, "\r\n", "\n")
|
||||
lines := strings.Split(normalized, "\n")
|
||||
|
||||
// Prefer explicit version lines to handle tools like vnt-cli
|
||||
// where the first line is usage text.
|
||||
for _, raw := range lines {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(line)
|
||||
if strings.Contains(lower, "version:") {
|
||||
return line
|
||||
}
|
||||
}
|
||||
|
||||
for _, raw := range lines {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(line)
|
||||
if strings.Contains(lower, "version") || strings.HasPrefix(lower, "v") {
|
||||
return line
|
||||
}
|
||||
}
|
||||
|
||||
for _, raw := range lines {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line != "" {
|
||||
return line
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func listManagedVersions(spec vpnToolSpec) []string {
|
||||
versionsDir := vpnToolVersionsDir(spec)
|
||||
entries, err := os.ReadDir(versionsDir)
|
||||
if err != nil {
|
||||
return []string{}
|
||||
}
|
||||
versions := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
versions = append(versions, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Slice(versions, func(i, j int) bool { return versions[i] > versions[j] })
|
||||
return versions
|
||||
}
|
||||
|
||||
func currentManagedVersion(spec vpnToolSpec) string {
|
||||
currentDir := vpnToolCurrentDir(spec)
|
||||
for _, binary := range spec.Binaries {
|
||||
p := filepath.Join(currentDir, binary)
|
||||
target, err := os.Readlink(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
abs := target
|
||||
if !filepath.IsAbs(target) {
|
||||
abs = filepath.Clean(filepath.Join(filepath.Dir(p), target))
|
||||
}
|
||||
versionsRoot := vpnToolVersionsDir(spec) + string(os.PathSeparator)
|
||||
if strings.HasPrefix(abs, versionsRoot) {
|
||||
rel := strings.TrimPrefix(abs, versionsRoot)
|
||||
parts := strings.Split(rel, string(os.PathSeparator))
|
||||
if len(parts) > 0 && parts[0] != "" {
|
||||
return parts[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func initVpnToolInstallTask(tool, version string) {
|
||||
vpnToolInstallTaskMu.Lock()
|
||||
defer vpnToolInstallTaskMu.Unlock()
|
||||
vpnToolInstallTasks[tool] = &VpnToolInstallTask{
|
||||
Tool: tool,
|
||||
Running: true,
|
||||
Progress: 0,
|
||||
Message: "preparing",
|
||||
Logs: []string{"Preparing install task..."},
|
||||
Error: "",
|
||||
Version: version,
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
func updateVpnToolInstallTask(tool string, progress float64, message string) {
|
||||
vpnToolInstallTaskMu.Lock()
|
||||
defer vpnToolInstallTaskMu.Unlock()
|
||||
task, ok := vpnToolInstallTasks[tool]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if progress < 0 {
|
||||
progress = 0
|
||||
}
|
||||
if progress > 1 {
|
||||
progress = 1
|
||||
}
|
||||
task.Progress = progress
|
||||
if strings.TrimSpace(message) != "" {
|
||||
task.Message = message
|
||||
}
|
||||
task.UpdatedAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
func appendVpnToolInstallLog(tool, line string) {
|
||||
vpnToolInstallTaskMu.Lock()
|
||||
defer vpnToolInstallTaskMu.Unlock()
|
||||
task, ok := vpnToolInstallTasks[tool]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return
|
||||
}
|
||||
task.Logs = append(task.Logs, line)
|
||||
if len(task.Logs) > 200 {
|
||||
task.Logs = task.Logs[len(task.Logs)-200:]
|
||||
}
|
||||
task.UpdatedAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
func finishVpnToolInstallTask(tool string, err error) {
|
||||
vpnToolInstallTaskMu.Lock()
|
||||
defer vpnToolInstallTaskMu.Unlock()
|
||||
task, ok := vpnToolInstallTasks[tool]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
task.Running = false
|
||||
task.UpdatedAt = time.Now().Unix()
|
||||
if err != nil {
|
||||
task.Error = err.Error()
|
||||
task.Message = "failed"
|
||||
task.Logs = append(task.Logs, "Install failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
task.Progress = 1
|
||||
task.Message = "completed"
|
||||
task.Error = ""
|
||||
task.Logs = append(task.Logs, "Install completed.")
|
||||
}
|
||||
|
||||
func rpcGetVpnToolInstallTask(tool string) (VpnToolInstallTask, error) {
|
||||
spec, err := getVpnToolSpec(tool)
|
||||
if err != nil {
|
||||
return VpnToolInstallTask{}, err
|
||||
}
|
||||
_ = spec
|
||||
vpnToolInstallTaskMu.Lock()
|
||||
defer vpnToolInstallTaskMu.Unlock()
|
||||
task, ok := vpnToolInstallTasks[tool]
|
||||
if !ok {
|
||||
return VpnToolInstallTask{
|
||||
Tool: tool,
|
||||
Running: false,
|
||||
Progress: 0,
|
||||
Message: "",
|
||||
Logs: []string{},
|
||||
Error: "",
|
||||
Version: "",
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
cp := *task
|
||||
cp.Logs = append([]string{}, task.Logs...)
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
func rpcGetVpnToolStatus(tool string) (VpnToolStatus, error) {
|
||||
spec, err := getVpnToolSpec(tool)
|
||||
if err != nil {
|
||||
return VpnToolStatus{}, err
|
||||
}
|
||||
|
||||
status := VpnToolStatus{
|
||||
Tool: spec.Name,
|
||||
Installed: false,
|
||||
Source: "none",
|
||||
CurrentVersion: currentManagedVersion(spec),
|
||||
DetectedVersion: "",
|
||||
ManagedVersions: listManagedVersions(spec),
|
||||
}
|
||||
|
||||
primaryBinary := spec.Binaries[0]
|
||||
versionBinary := spec.VersionBinary
|
||||
if strings.TrimSpace(versionBinary) == "" {
|
||||
versionBinary = primaryBinary
|
||||
}
|
||||
|
||||
managedPrimary := managedBinaryPath(spec, primaryBinary)
|
||||
managedVersionBinary := managedBinaryPath(spec, versionBinary)
|
||||
if _, err := os.Stat(managedPrimary); err == nil {
|
||||
status.Installed = true
|
||||
status.Source = "managed"
|
||||
status.DetectedVersion = detectCommandVersion(managedVersionBinary, spec.VersionFlags)
|
||||
if status.CurrentVersion == "" {
|
||||
status.CurrentVersion = "managed"
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
primaryLookedUp, primaryErr := findExecutablePath(primaryBinary)
|
||||
if primaryErr == nil {
|
||||
status.Installed = true
|
||||
status.Source = "system"
|
||||
_ = primaryLookedUp
|
||||
versionLookedUp, versionErr := findExecutablePath(versionBinary)
|
||||
if versionErr == nil {
|
||||
status.DetectedVersion = detectCommandVersion(versionLookedUp, spec.VersionFlags)
|
||||
}
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func rpcListVpnToolReleases(tool string) ([]VpnToolRelease, error) {
|
||||
spec, err := getVpnToolSpec(tool)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
systemInfo, err := rpcGetVpnToolSystemInfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=20", spec.Repo)
|
||||
req, err := http.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet,
|
||||
apiURL,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build release request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "kvm-vpn-tool-manager")
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch releases: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("fetch releases failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var ghReleases []githubRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ghReleases); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse release json: %w", err)
|
||||
}
|
||||
|
||||
keywords := systemInfo.ArchKeywords
|
||||
results := make([]VpnToolRelease, 0, len(ghReleases))
|
||||
for _, rel := range ghReleases {
|
||||
if rel.Draft {
|
||||
continue
|
||||
}
|
||||
if spec.Name == "vnt" {
|
||||
tag := strings.TrimSpace(rel.TagName)
|
||||
if tag != vntPinnedVersion && tag != strings.TrimPrefix(vntPinnedVersion, "v") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
releaseItem := VpnToolRelease{
|
||||
TagName: strings.TrimSpace(rel.TagName),
|
||||
Assets: make([]VpnToolReleaseAsset, 0, len(rel.Assets)),
|
||||
}
|
||||
for _, asset := range rel.Assets {
|
||||
nameLower := strings.ToLower(strings.TrimSpace(asset.Name))
|
||||
isLinux := strings.Contains(nameLower, "linux")
|
||||
archMatch := false
|
||||
for _, kw := range keywords {
|
||||
if strings.Contains(nameLower, strings.ToLower(kw)) {
|
||||
archMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isLinux {
|
||||
archMatch = false
|
||||
}
|
||||
releaseItem.Assets = append(releaseItem.Assets, VpnToolReleaseAsset{
|
||||
Name: asset.Name,
|
||||
URL: strings.TrimSpace(asset.BrowserDownloadURL),
|
||||
ArchMatch: archMatch,
|
||||
})
|
||||
}
|
||||
if releaseItem.TagName != "" {
|
||||
results = append(results, releaseItem)
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func downloadFileToPath(url, targetPath string, onProgress func(downloaded int64, total int64)) error {
|
||||
if strings.TrimSpace(url) == "" {
|
||||
return fmt.Errorf("empty download url")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet,
|
||||
url,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create download request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "kvm-vpn-tool-manager")
|
||||
client := &http.Client{Timeout: 2 * time.Minute}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return fmt.Errorf("download failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
out, err := os.Create(targetPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create target file: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
total := resp.ContentLength
|
||||
var downloaded int64
|
||||
buf := make([]byte, 64*1024)
|
||||
for {
|
||||
n, readErr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
written, writeErr := out.Write(buf[:n])
|
||||
if writeErr != nil {
|
||||
return fmt.Errorf("failed to write target file: %w", writeErr)
|
||||
}
|
||||
if written != n {
|
||||
return fmt.Errorf("short write while saving downloaded file")
|
||||
}
|
||||
downloaded += int64(n)
|
||||
if onProgress != nil {
|
||||
onProgress(downloaded, total)
|
||||
}
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("failed to read download stream: %w", readErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractFromTarGz(archivePath string, targetBinaryNames []string, outDir string) (map[string]bool, error) {
|
||||
f, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
gzReader, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer gzReader.Close()
|
||||
tr := tar.NewReader(gzReader)
|
||||
|
||||
need := make(map[string]bool, len(targetBinaryNames))
|
||||
found := make(map[string]bool, len(targetBinaryNames))
|
||||
for _, b := range targetBinaryNames {
|
||||
need[b] = true
|
||||
found[b] = false
|
||||
}
|
||||
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return found, err
|
||||
}
|
||||
if hdr.Typeflag != tar.TypeReg {
|
||||
continue
|
||||
}
|
||||
base := filepath.Base(hdr.Name)
|
||||
if !need[base] {
|
||||
continue
|
||||
}
|
||||
dstPath := filepath.Join(outDir, base)
|
||||
dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
||||
if err != nil {
|
||||
return found, err
|
||||
}
|
||||
if _, err := io.Copy(dst, tr); err != nil {
|
||||
dst.Close()
|
||||
return found, err
|
||||
}
|
||||
if err := dst.Close(); err != nil {
|
||||
return found, err
|
||||
}
|
||||
found[base] = true
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func extractFromZip(archivePath string, targetBinaryNames []string, outDir string) (map[string]bool, error) {
|
||||
zr, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
need := make(map[string]bool, len(targetBinaryNames))
|
||||
found := make(map[string]bool, len(targetBinaryNames))
|
||||
for _, b := range targetBinaryNames {
|
||||
need[b] = true
|
||||
found[b] = false
|
||||
}
|
||||
|
||||
for _, f := range zr.File {
|
||||
base := filepath.Base(f.Name)
|
||||
if !need[base] {
|
||||
continue
|
||||
}
|
||||
src, err := f.Open()
|
||||
if err != nil {
|
||||
return found, err
|
||||
}
|
||||
dstPath := filepath.Join(outDir, base)
|
||||
dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
||||
if err != nil {
|
||||
src.Close()
|
||||
return found, err
|
||||
}
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
src.Close()
|
||||
dst.Close()
|
||||
return found, err
|
||||
}
|
||||
src.Close()
|
||||
if err := dst.Close(); err != nil {
|
||||
return found, err
|
||||
}
|
||||
found[base] = true
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func ensureBinariesInstalled(found map[string]bool, required []string) error {
|
||||
missing := make([]string, 0)
|
||||
for _, b := range required {
|
||||
if !found[b] {
|
||||
missing = append(missing, b)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("required binaries are missing in package: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupCurrentSymlinks(spec vpnToolSpec, version string) error {
|
||||
currentDir := vpnToolCurrentDir(spec)
|
||||
versionDir := filepath.Join(vpnToolVersionsDir(spec), version)
|
||||
if err := os.MkdirAll(currentDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, b := range spec.Binaries {
|
||||
currentLink := filepath.Join(currentDir, b)
|
||||
_ = os.Remove(currentLink)
|
||||
target := filepath.Join(versionDir, b)
|
||||
if err := os.Symlink(target, currentLink); err != nil {
|
||||
return fmt.Errorf("failed to update current binary symlink for %s: %w", b, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rpcInstallVpnTool(tool, version, assetName, downloadURL string) error {
|
||||
spec, err := getVpnToolSpec(tool)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version = strings.TrimSpace(version)
|
||||
if version == "" {
|
||||
return fmt.Errorf("version is required")
|
||||
}
|
||||
if spec.Name == "vnt" && version != vntPinnedVersion && version != strings.TrimPrefix(vntPinnedVersion, "v") {
|
||||
return fmt.Errorf("vnt install is temporarily pinned to %s", vntPinnedVersion)
|
||||
}
|
||||
downloadURL = strings.TrimSpace(downloadURL)
|
||||
if downloadURL == "" {
|
||||
return fmt.Errorf("downloadURL is required")
|
||||
}
|
||||
|
||||
versionDir := filepath.Join(vpnToolVersionsDir(spec), version)
|
||||
appendVpnToolInstallLog(tool, "Preparing version directory...")
|
||||
updateVpnToolInstallTask(tool, 0.05, "preparing")
|
||||
if err := os.MkdirAll(versionDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create version directory: %w", err)
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "vpn-tool-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
tmpFile.Close()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
appendVpnToolInstallLog(tool, "Downloading release package...")
|
||||
if err := downloadFileToPath(downloadURL, tmpPath, func(downloaded int64, total int64) {
|
||||
if total > 0 {
|
||||
ratio := float64(downloaded) / float64(total)
|
||||
updateVpnToolInstallTask(tool, 0.1+ratio*0.65, "downloading")
|
||||
return
|
||||
}
|
||||
// Unknown total size, keep showing progress moving slowly.
|
||||
updateVpnToolInstallTask(tool, 0.4, "downloading")
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
appendVpnToolInstallLog(tool, "Download completed.")
|
||||
|
||||
assetLower := strings.ToLower(strings.TrimSpace(assetName))
|
||||
if assetLower == "" {
|
||||
assetLower = strings.ToLower(strings.TrimSpace(downloadURL))
|
||||
}
|
||||
|
||||
found := map[string]bool{}
|
||||
updateVpnToolInstallTask(tool, 0.8, "extracting")
|
||||
appendVpnToolInstallLog(tool, "Extracting package...")
|
||||
switch {
|
||||
case strings.HasSuffix(assetLower, ".tar.gz") || strings.HasSuffix(assetLower, ".tgz"):
|
||||
found, err = extractFromTarGz(tmpPath, spec.Binaries, versionDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract tar.gz: %w", err)
|
||||
}
|
||||
case strings.HasSuffix(assetLower, ".zip"):
|
||||
found, err = extractFromZip(tmpPath, spec.Binaries, versionDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract zip: %w", err)
|
||||
}
|
||||
default:
|
||||
if len(spec.Binaries) != 1 {
|
||||
return fmt.Errorf("single binary install is not supported for %s", spec.Name)
|
||||
}
|
||||
dstPath := filepath.Join(versionDir, spec.Binaries[0])
|
||||
input, err := os.Open(tmpPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer input.Close()
|
||||
output, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(output, input); err != nil {
|
||||
output.Close()
|
||||
return err
|
||||
}
|
||||
if err := output.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
found[spec.Binaries[0]] = true
|
||||
}
|
||||
|
||||
appendVpnToolInstallLog(tool, "Validating installed binaries...")
|
||||
updateVpnToolInstallTask(tool, 0.92, "validating")
|
||||
if err := ensureBinariesInstalled(found, spec.Binaries); err != nil {
|
||||
return err
|
||||
}
|
||||
appendVpnToolInstallLog(tool, "Switching to installed version...")
|
||||
updateVpnToolInstallTask(tool, 0.97, "activating")
|
||||
if err := setupCurrentSymlinks(spec, version); err != nil {
|
||||
return err
|
||||
}
|
||||
updateVpnToolInstallTask(tool, 1, "completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func rpcStartVpnToolInstall(tool, version, assetName, downloadURL string) error {
|
||||
_, err := getVpnToolSpec(tool)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vpnToolInstallTaskMu.Lock()
|
||||
if task, ok := vpnToolInstallTasks[tool]; ok && task.Running {
|
||||
vpnToolInstallTaskMu.Unlock()
|
||||
return fmt.Errorf("install task is already running for %s", tool)
|
||||
}
|
||||
vpnToolInstallTaskMu.Unlock()
|
||||
|
||||
initVpnToolInstallTask(tool, version)
|
||||
go func() {
|
||||
err := rpcInstallVpnTool(tool, version, assetName, downloadURL)
|
||||
finishVpnToolInstallTask(tool, err)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func rpcUseVpnToolVersion(tool, version string) error {
|
||||
spec, err := getVpnToolSpec(tool)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version = strings.TrimSpace(version)
|
||||
if version == "" {
|
||||
return fmt.Errorf("version is required")
|
||||
}
|
||||
versionDir := filepath.Join(vpnToolVersionsDir(spec), version)
|
||||
info, err := os.Stat(versionDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("version is not installed: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("invalid version directory")
|
||||
}
|
||||
for _, b := range spec.Binaries {
|
||||
if _, err := os.Stat(filepath.Join(versionDir, b)); err != nil {
|
||||
return fmt.Errorf("binary %s is missing in version %s", b, version)
|
||||
}
|
||||
}
|
||||
return setupCurrentSymlinks(spec, version)
|
||||
}
|
||||
|
||||
func rpcUninstallVpnToolVersion(tool, version string) error {
|
||||
spec, err := getVpnToolSpec(tool)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version = strings.TrimSpace(version)
|
||||
if version == "" {
|
||||
return fmt.Errorf("version is required")
|
||||
}
|
||||
versionDir := filepath.Join(vpnToolVersionsDir(spec), version)
|
||||
if err := os.RemoveAll(versionDir); err != nil {
|
||||
return fmt.Errorf("failed to uninstall version: %w", err)
|
||||
}
|
||||
if currentManagedVersion(spec) == version {
|
||||
currentDir := vpnToolCurrentDir(spec)
|
||||
for _, b := range spec.Binaries {
|
||||
_ = os.Remove(filepath.Join(currentDir, b))
|
||||
}
|
||||
}
|
||||
|
||||
remaining := listManagedVersions(spec)
|
||||
if len(remaining) > 0 {
|
||||
// Automatically switch to latest remaining managed version.
|
||||
if err := setupCurrentSymlinks(spec, remaining[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
390
ui/package-lock.json
generated
390
ui/package-lock.json
generated
@@ -43,6 +43,7 @@
|
||||
"recharts": "^2.15.3",
|
||||
"styled-components": "^6.1.19",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"validator": "^13.15.0",
|
||||
"w-touch": "^2.0.0",
|
||||
@@ -508,12 +509,6 @@
|
||||
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
|
||||
"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": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-1.9.0.tgz",
|
||||
@@ -539,12 +534,6 @@
|
||||
"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": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.2.0.tgz",
|
||||
@@ -571,18 +560,18 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/is-prop-valid": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz",
|
||||
"integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
|
||||
"integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emotion/memoize": "^0.8.1"
|
||||
"@emotion/memoize": "^0.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/memoize": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/@emotion/memoize/-/memoize-0.8.1.tgz",
|
||||
"integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==",
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
|
||||
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/react": {
|
||||
@@ -628,12 +617,6 @@
|
||||
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
|
||||
"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": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.10.0.tgz",
|
||||
@@ -1777,9 +1760,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/pluginutils/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2821,6 +2804,66 @@
|
||||
"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": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.7.tgz",
|
||||
@@ -3084,12 +3127,6 @@
|
||||
"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": {
|
||||
"version": "13.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.0.tgz",
|
||||
@@ -3262,9 +3299,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3833,9 +3870,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-macros/node_modules/yaml": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmmirror.com/yaml/-/yaml-1.10.2.tgz",
|
||||
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
|
||||
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -3847,10 +3884,16 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"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": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -4193,9 +4236,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cva": {
|
||||
@@ -5336,9 +5379,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/focus-trap": {
|
||||
@@ -5777,6 +5820,12 @@
|
||||
"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": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -5799,9 +5848,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "3.8.2",
|
||||
"resolved": "https://registry.npmmirror.com/immutable/-/immutable-3.8.2.tgz",
|
||||
"integrity": "sha512-15gZoQ38eYjEjxkorfbcgBKBL6R7T459OuK+CpcWt7O3KF4uPCx2tD0uFETlUDIyo+1789crbMhTvQBSR5yBMg==",
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.3.tgz",
|
||||
"integrity": "sha512-AUY/VyX0E5XlibOmWt10uabJzam1zlYjwiEgQSDc5+UIkFNaF9WM0JxXKaNMGf+F/ffUF+7kRKXM9A7C0xXqMg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -6199,6 +6248,12 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
||||
@@ -6664,9 +6719,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.castarray": {
|
||||
@@ -6963,6 +7018,26 @@
|
||||
"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": {
|
||||
"version": "2.0.19",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
||||
@@ -7112,6 +7187,15 @@
|
||||
"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": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -7312,9 +7396,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -7334,9 +7418,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.3",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
|
||||
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -7353,7 +7437,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.8",
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -8430,6 +8514,12 @@
|
||||
"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": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||
@@ -8707,12 +8797,6 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -9044,20 +9128,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/styled-components": {
|
||||
"version": "6.1.19",
|
||||
"resolved": "https://registry.npmmirror.com/styled-components/-/styled-components-6.1.19.tgz",
|
||||
"integrity": "sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA==",
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.4.1.tgz",
|
||||
"integrity": "sha512-ADu2dF53esUzzM4I0ewxhxFtsDd6v4V6dNkg3vG0iFKhnt06sJneTZnRvujAosZwW0XD58IKgGMQoqri4wHRqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emotion/is-prop-valid": "1.2.2",
|
||||
"@emotion/unitless": "0.8.1",
|
||||
"@types/stylis": "4.2.5",
|
||||
"@emotion/is-prop-valid": "1.4.0",
|
||||
"css-to-react-native": "3.2.0",
|
||||
"csstype": "3.1.3",
|
||||
"postcss": "8.4.49",
|
||||
"shallowequal": "1.1.0",
|
||||
"stylis": "4.3.2",
|
||||
"tslib": "2.6.2"
|
||||
"csstype": "3.2.3",
|
||||
"stylis": "4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -9067,56 +9146,23 @@
|
||||
"url": "https://opencollective.com/styled-components"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"css-to-react-native": ">= 3.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"react-dom": ">= 16.8.0"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
"react-dom": ">= 16.8.0",
|
||||
"react-native": ">= 0.68.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
"peerDependenciesMeta": {
|
||||
"css-to-react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.3.6.tgz",
|
||||
@@ -9187,9 +9233,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.9",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz",
|
||||
"integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==",
|
||||
"version": "7.5.15",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
|
||||
"integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
@@ -9203,6 +9249,30 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz",
|
||||
@@ -9254,9 +9324,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -9284,6 +9354,12 @@
|
||||
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
||||
@@ -9599,9 +9675,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
@@ -9722,9 +9798,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/picomatch": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -9739,12 +9815,34 @@
|
||||
"integrity": "sha512-PYnngF+KHzZRBFI6qsm5PHwVNO5Lx3dYDrvNv//Ei9fvnYlaOWr9sKAriD/crlshWee9ZPsyvDMA4UnoR1LUHw==",
|
||||
"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": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz",
|
||||
"integrity": "sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng==",
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -9865,9 +9963,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.1.tgz",
|
||||
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
|
||||
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
@@ -9876,6 +9974,9 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
@@ -9890,6 +9991,15 @@
|
||||
"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": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"recharts": "^2.15.3",
|
||||
"styled-components": "^6.1.19",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"validator": "^13.15.0",
|
||||
"w-touch": "^2.0.0",
|
||||
|
||||
@@ -18,6 +18,7 @@ interface ConfirmDialogProps {
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
description: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
variant?: Variant;
|
||||
confirmText?: string;
|
||||
cancelText?: string | null;
|
||||
@@ -65,6 +66,7 @@ export function ConfirmDialog({
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
variant = "info",
|
||||
confirmText = "Confirm",
|
||||
cancelText = "Cancel",
|
||||
@@ -107,9 +109,10 @@ export function ConfirmDialog({
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
|
||||
<div className={cx("mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3", isConfirming && "pointer-events-none")}>
|
||||
<Button
|
||||
size="LG"
|
||||
theme={buttonTheme}
|
||||
@@ -157,10 +160,11 @@ export function ConfirmDialog({
|
||||
<div className="mt-2 text-sm leading-snug text-slate-600 dark:text-[#ffffff]">
|
||||
{description}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-x-2">
|
||||
<div className={cx("flex justify-end gap-x-2", isConfirming && "pointer-events-none")}>
|
||||
{cancelText && (
|
||||
<Button size="SM" theme="light" text={cancelText} onClick={onClose} />
|
||||
)}
|
||||
@@ -177,4 +181,4 @@ export function ConfirmDialog({
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const comboboxVariants = cva({
|
||||
variants: { size: sizes },
|
||||
});
|
||||
|
||||
type BaseProps = React.ComponentProps<typeof HeadlessCombobox>;
|
||||
type BaseProps = React.ComponentProps<typeof HeadlessCombobox<ComboboxOption>>;
|
||||
|
||||
interface ComboboxProps extends Omit<BaseProps, "displayValue"> {
|
||||
displayValue: (option: ComboboxOption) => string;
|
||||
|
||||
@@ -205,7 +205,8 @@ export function MacroStepCard({
|
||||
)}
|
||||
<div className="relative w-full">
|
||||
<Combobox
|
||||
onChange={(value: { value: string; label: string }) => {
|
||||
onChange={value => {
|
||||
if (!value) return;
|
||||
onKeySelect(value);
|
||||
onKeyQueryChange('');
|
||||
}}
|
||||
@@ -239,4 +240,4 @@ export function MacroStepCard({
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
458
ui/src/components/OcrOverlay.tsx
Normal file
458
ui/src/components/OcrOverlay.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useReactAt } from "i18n-auto-extractor/react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { useSettingsStore, useUiStore, useVideoStore } from "@/hooks/stores";
|
||||
import Card from "@components/Card";
|
||||
import { ConfirmDialog } from "@components/ConfirmDialog";
|
||||
import TextArea from "@components/TextArea";
|
||||
import notifications from "@/notifications";
|
||||
import { eventMatchesShortcut } from "@/utils/shortcuts";
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
type OcrStatus = "idle" | "selecting" | "processing" | "result";
|
||||
|
||||
type TesseractWorker = {
|
||||
recognize: (image: HTMLCanvasElement, options?: unknown, output?: unknown) => Promise<{
|
||||
data: {
|
||||
text?: string;
|
||||
blocks?: Array<{
|
||||
paragraphs: Array<{
|
||||
lines: Array<{
|
||||
text: string;
|
||||
bbox: { x0: number };
|
||||
words: Array<{ text: string; bbox: { x0: number; x1: number } }>;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
terminate: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
async function loadTesseract() {
|
||||
const { createWorker } = await import("tesseract.js");
|
||||
return createWorker;
|
||||
}
|
||||
|
||||
let workerPromise: Promise<TesseractWorker> | null = null;
|
||||
let cleanupTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async function initWorker() {
|
||||
const createWorker = await loadTesseract();
|
||||
return createWorker("eng", 1) as Promise<TesseractWorker>;
|
||||
}
|
||||
|
||||
async function terminateWorker() {
|
||||
if (!workerPromise) return;
|
||||
try {
|
||||
const worker = await workerPromise;
|
||||
await worker.terminate();
|
||||
} catch {
|
||||
// Ignore termination errors.
|
||||
}
|
||||
workerPromise = null;
|
||||
}
|
||||
|
||||
function getWorker() {
|
||||
if (cleanupTimer) {
|
||||
clearTimeout(cleanupTimer);
|
||||
cleanupTimer = null;
|
||||
}
|
||||
|
||||
if (!workerPromise) {
|
||||
workerPromise = initWorker().catch(err => {
|
||||
workerPromise = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-terminate worker after inactivity to reduce memory footprint.
|
||||
cleanupTimer = setTimeout(() => {
|
||||
void terminateWorker();
|
||||
cleanupTimer = null;
|
||||
}, 60_000);
|
||||
|
||||
return workerPromise;
|
||||
}
|
||||
|
||||
async function performOcr(canvas: HTMLCanvasElement): Promise<string> {
|
||||
const worker = await getWorker();
|
||||
const { data } = await worker.recognize(canvas, {}, { text: true, blocks: true });
|
||||
const lines = data.blocks?.flatMap(b => b.paragraphs.flatMap(p => p.lines)) ?? [];
|
||||
|
||||
if (lines.length === 0) return (data.text || "").trim();
|
||||
|
||||
// Estimate character width from OCR words so left indentation is preserved.
|
||||
let totalCharWidth = 0;
|
||||
let samples = 0;
|
||||
for (const line of lines) {
|
||||
for (const word of line.words) {
|
||||
const len = word.text.trim().length;
|
||||
if (len > 0) {
|
||||
totalCharWidth += (word.bbox.x1 - word.bbox.x0) / len;
|
||||
samples++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (samples === 0) return (data.text || "").trim();
|
||||
|
||||
const charWidth = totalCharWidth / samples;
|
||||
const minX = Math.min(...lines.map(l => l.bbox.x0));
|
||||
|
||||
return lines
|
||||
.map(line => {
|
||||
const indent = Math.round((line.bbox.x0 - minX) / charWidth);
|
||||
return " ".repeat(indent) + line.text.trim();
|
||||
})
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function captureRegion(videoEl: HTMLVideoElement, rect: Rect): HTMLCanvasElement {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Failed to acquire 2D canvas context");
|
||||
ctx.drawImage(videoEl, rect.x, rect.y, rect.width, rect.height, 0, 0, rect.width, rect.height);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function getVideoDisplayRect(videoElement: HTMLVideoElement, videoWidth: number, videoHeight: number) {
|
||||
const videoRect = videoElement.getBoundingClientRect();
|
||||
const elementAspectRatio = videoRect.width / videoRect.height;
|
||||
const streamAspectRatio = videoWidth / videoHeight;
|
||||
|
||||
let effectiveWidth = videoRect.width;
|
||||
let effectiveHeight = videoRect.height;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
if (elementAspectRatio > streamAspectRatio) {
|
||||
effectiveWidth = videoRect.height * streamAspectRatio;
|
||||
offsetX = (videoRect.width - effectiveWidth) / 2;
|
||||
} else if (elementAspectRatio < streamAspectRatio) {
|
||||
effectiveHeight = videoRect.width / streamAspectRatio;
|
||||
offsetY = (videoRect.height - effectiveHeight) / 2;
|
||||
}
|
||||
|
||||
return { videoRect, effectiveWidth, effectiveHeight, offsetX, offsetY };
|
||||
}
|
||||
|
||||
interface OcrOverlayProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export default function OcrOverlay({ videoRef, containerRef }: OcrOverlayProps) {
|
||||
const { width: videoWidth, height: videoHeight } = useVideoStore();
|
||||
const isOcrMode = useUiStore(state => state.isOcrMode);
|
||||
const setOcrMode = useUiStore(state => state.setOcrMode);
|
||||
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||
const ocrShortcutEnabled = useSettingsStore(state => state.ocrShortcutEnabled);
|
||||
const ocrShortcut = useSettingsStore(state => state.ocrShortcut);
|
||||
const { $at } = useReactAt();
|
||||
|
||||
const mountedRef = useRef(true);
|
||||
const resultRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [status, setStatus] = useState<OcrStatus>("idle");
|
||||
const [selectionStart, setSelectionStart] = useState<{ x: number; y: number } | null>(null);
|
||||
const [selectionRect, setSelectionRect] = useState<Rect | null>(null);
|
||||
const [ocrResult, setOcrResult] = useState("");
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!ocrShortcutEnabled) return;
|
||||
if (!eventMatchesShortcut(e, ocrShortcut)) return;
|
||||
const activeElement = document.activeElement as HTMLElement | null;
|
||||
const isEditable =
|
||||
!!activeElement
|
||||
&& (activeElement.tagName === "INPUT"
|
||||
|| activeElement.tagName === "TEXTAREA"
|
||||
|| activeElement.isContentEditable);
|
||||
if (isEditable) return;
|
||||
if (videoWidth === 0 || videoHeight === 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOcrMode(!isOcrMode);
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown, { capture: true });
|
||||
return () => document.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||
}, [isOcrMode, ocrShortcut, ocrShortcutEnabled, setOcrMode, videoWidth, videoHeight]);
|
||||
|
||||
const closeOverlay = useCallback(() => {
|
||||
if (status === "processing" || status === "result") {
|
||||
setIsClosing(true);
|
||||
setSelectionRect(null);
|
||||
setSelectionStart(null);
|
||||
setTimeout(() => setOcrMode(false), 200);
|
||||
return;
|
||||
}
|
||||
setOcrMode(false);
|
||||
}, [setOcrMode, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOcrMode) {
|
||||
setStatus("idle");
|
||||
setSelectionRect(null);
|
||||
setSelectionStart(null);
|
||||
setOcrResult("");
|
||||
setIsClosing(false);
|
||||
}
|
||||
}, [isOcrMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOcrMode) return;
|
||||
if (status === "processing" || status === "result") {
|
||||
setDisableVideoFocusTrap(true);
|
||||
return () => setDisableVideoFocusTrap(false);
|
||||
}
|
||||
}, [isOcrMode, setDisableVideoFocusTrap, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOcrMode) return;
|
||||
if (status === "processing" || status === "result") return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOcrMode(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown, { capture: true });
|
||||
return () => document.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||
}, [isOcrMode, setOcrMode, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOcrMode) return;
|
||||
if (status !== "result") return;
|
||||
const handleCopy = () => {
|
||||
notifications.success($at("Copied"), { duration: 4000 });
|
||||
closeOverlay();
|
||||
};
|
||||
document.addEventListener("copy", handleCopy);
|
||||
return () => document.removeEventListener("copy", handleCopy);
|
||||
}, [closeOverlay, isOcrMode, status, $at]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "result" && resultRef.current) {
|
||||
resultRef.current.focus();
|
||||
resultRef.current.select();
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const toVideoCoords = useCallback((clientX: number, clientY: number) => {
|
||||
const videoElement = videoRef.current;
|
||||
if (!videoElement) return { x: 0, y: 0 };
|
||||
const { videoRect, effectiveWidth, effectiveHeight, offsetX, offsetY } = getVideoDisplayRect(
|
||||
videoElement,
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
);
|
||||
const relX = clientX - videoRect.left - offsetX;
|
||||
const relY = clientY - videoRect.top - offsetY;
|
||||
const scaleX = videoWidth / effectiveWidth;
|
||||
const scaleY = videoHeight / effectiveHeight;
|
||||
return {
|
||||
x: Math.max(0, Math.min(videoWidth, Math.round(relX * scaleX))),
|
||||
y: Math.max(0, Math.min(videoHeight, Math.round(relY * scaleY))),
|
||||
};
|
||||
}, [videoHeight, videoRef, videoWidth]);
|
||||
|
||||
const finishSelection = useCallback(async () => {
|
||||
const videoElement = videoRef.current;
|
||||
if (status !== "selecting") return;
|
||||
if (!videoElement || !selectionRect) {
|
||||
setStatus("idle");
|
||||
setSelectionStart(null);
|
||||
setSelectionRect(null);
|
||||
return;
|
||||
}
|
||||
if (selectionRect.width < 10 || selectionRect.height < 10) {
|
||||
setStatus("idle");
|
||||
setSelectionStart(null);
|
||||
setSelectionRect(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("processing");
|
||||
try {
|
||||
const canvas = captureRegion(videoElement, selectionRect);
|
||||
const text = await performOcr(canvas);
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
|
||||
if (!mountedRef.current) return;
|
||||
if (text) {
|
||||
setOcrResult(text);
|
||||
setStatus("result");
|
||||
} else {
|
||||
notifications.error($at("No text detected"));
|
||||
closeOverlay();
|
||||
}
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
console.error("OCR failed:", error);
|
||||
notifications.error($at("OCR failed"));
|
||||
closeOverlay();
|
||||
}
|
||||
}, [closeOverlay, selectionRect, status, videoRef, $at]);
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (status === "processing" || status === "result") return;
|
||||
if (!videoRef.current || videoWidth === 0 || videoHeight === 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
const coords = toVideoCoords(e.clientX, e.clientY);
|
||||
setSelectionStart(coords);
|
||||
setSelectionRect(null);
|
||||
setStatus("selecting");
|
||||
}, [status, toVideoCoords, videoHeight, videoRef, videoWidth]);
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (status !== "selecting" || !selectionStart) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const coords = toVideoCoords(e.clientX, e.clientY);
|
||||
const x = Math.min(selectionStart.x, coords.x);
|
||||
const y = Math.min(selectionStart.y, coords.y);
|
||||
const width = Math.abs(coords.x - selectionStart.x);
|
||||
const height = Math.abs(coords.y - selectionStart.y);
|
||||
setSelectionRect({ x, y, width, height });
|
||||
}, [selectionStart, status, toVideoCoords]);
|
||||
|
||||
const onPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (status !== "selecting") return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
void finishSelection();
|
||||
}, [finishSelection, status]);
|
||||
|
||||
const selectionStyle = useMemo(() => {
|
||||
const videoElement = videoRef.current;
|
||||
const containerElement = containerRef.current;
|
||||
if (!selectionRect || !videoElement || !containerElement || videoWidth === 0 || videoHeight === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { videoRect, effectiveWidth, effectiveHeight, offsetX, offsetY } = getVideoDisplayRect(
|
||||
videoElement,
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
);
|
||||
const containerRect = containerElement.getBoundingClientRect();
|
||||
const baseX = videoRect.left - containerRect.left + offsetX;
|
||||
const baseY = videoRect.top - containerRect.top + offsetY;
|
||||
|
||||
return {
|
||||
left: `${baseX + (selectionRect.x / videoWidth) * effectiveWidth}px`,
|
||||
top: `${baseY + (selectionRect.y / videoHeight) * effectiveHeight}px`,
|
||||
width: `${(selectionRect.width / videoWidth) * effectiveWidth}px`,
|
||||
height: `${(selectionRect.height / videoHeight) * effectiveHeight}px`,
|
||||
};
|
||||
}, [containerRef, selectionRect, videoHeight, videoRef, videoWidth]);
|
||||
|
||||
if (!isOcrMode) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute inset-0 z-20 touch-none"
|
||||
style={{ cursor: status === "result" ? "default" : "crosshair" }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/20" />
|
||||
|
||||
{status === "idle" && (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-4 flex justify-center">
|
||||
<div className="rounded-md bg-black/70 px-3 py-1.5 text-xs font-medium text-white">
|
||||
{$at("Drag to select text area")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectionRect && selectionStyle && status !== "result" && (
|
||||
<div
|
||||
className="absolute border-2 border-dashed border-blue-400 bg-blue-400/10"
|
||||
style={selectionStyle}
|
||||
>
|
||||
{selectionRect.width >= 10 && selectionRect.height >= 10 && (
|
||||
<Card className="absolute right-0 -bottom-6 w-auto px-1.5 py-0.5 text-[10px] font-medium tabular-nums dark:text-white">
|
||||
{selectionRect.width} × {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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ interface PopoverButtonProps {
|
||||
align?: "left" | "right";
|
||||
buttonClassName?: string;
|
||||
panelClassName?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const BottomPopoverButton: React.FC<PopoverButtonProps> = ({
|
||||
@@ -18,6 +19,7 @@ const BottomPopoverButton: React.FC<PopoverButtonProps> = ({
|
||||
buttonIconNode,
|
||||
panelContent,
|
||||
align = "left",
|
||||
style,
|
||||
}) => {
|
||||
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||
return (
|
||||
@@ -28,7 +30,7 @@ const BottomPopoverButton: React.FC<PopoverButtonProps> = ({
|
||||
<>
|
||||
<PopoverButton
|
||||
as="div"
|
||||
style={{ display: "flex", justifyContent: "center", alignItems: "center" }}>
|
||||
style={{ display: "flex", justifyContent: "center", alignItems: "center", ...style }}>
|
||||
<div
|
||||
onClick={() => {
|
||||
setDisableFocusTrap(true);
|
||||
|
||||
@@ -13,9 +13,10 @@ export const VideoElement = forwardRef<HTMLVideoElement, VideoElementProps>(
|
||||
({ onPlaying, style, className }, ref) => {
|
||||
const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled);
|
||||
const isVirtualKeyboardEnabled = useHidStore(state => state.isVirtualKeyboardEnabled);
|
||||
const allowTapToOpenVirtualKeyboard = useHidStore(state => state.allowTapToOpenVirtualKeyboard);
|
||||
|
||||
const handleClick = () => {
|
||||
if (isMobile && !isVirtualKeyboardEnabled) {
|
||||
if (isMobile && allowTapToOpenVirtualKeyboard && !isVirtualKeyboardEnabled) {
|
||||
setVirtualKeyboardEnabled(true);
|
||||
}
|
||||
};
|
||||
@@ -46,4 +47,4 @@ export const VideoElement = forwardRef<HTMLVideoElement, VideoElementProps>(
|
||||
}
|
||||
);
|
||||
|
||||
VideoElement.displayName = "VideoElement";
|
||||
VideoElement.displayName = "VideoElement";
|
||||
|
||||
@@ -84,7 +84,7 @@ export function LoadingConnectionOverlay({ show, text }: LoadingConnectionOverla
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
className="aspect-video h-full w-full"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||
@@ -124,7 +124,7 @@ export function ConnectionFailedOverlay({
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
className="aspect-video h-full w-full"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||
@@ -215,7 +215,7 @@ export function PeerConnectionDisconnectedOverlay({
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
className="aspect-video h-full w-full"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||
@@ -278,9 +278,10 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
||||
|
||||
const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled);
|
||||
const isVirtualKeyboardEnabled = useHidStore(state => state.isVirtualKeyboardEnabled);
|
||||
const allowTapToOpenVirtualKeyboard = useHidStore(state => state.allowTapToOpenVirtualKeyboard);
|
||||
|
||||
const handleClick = () => {
|
||||
if (isMobile && !isVirtualKeyboardEnabled) {
|
||||
if (isMobile && allowTapToOpenVirtualKeyboard && !isVirtualKeyboardEnabled) {
|
||||
setVirtualKeyboardEnabled(true);
|
||||
}
|
||||
};
|
||||
@@ -301,7 +302,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
||||
<AnimatePresence>
|
||||
{show && isNoSignal && (
|
||||
<motion.div
|
||||
className="absolute inset-0 aspect-video h-full w-full "
|
||||
className="absolute inset-0 h-full w-full "
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
@@ -385,7 +386,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
||||
<AnimatePresence>
|
||||
{show && isOtherError && (
|
||||
<motion.div
|
||||
className="absolute inset-0 aspect-video h-full w-full"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
@@ -445,7 +446,7 @@ export function NoAutoplayPermissionsOverlay({
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
className="absolute inset-0 z-10 aspect-video h-full w-full"
|
||||
className="absolute inset-0 z-10 h-full w-full"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
|
||||
@@ -14,7 +14,8 @@ import DetachIconRaw from "@/assets/detach-icon.svg";
|
||||
import { cx } from "@/cva.config";
|
||||
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
|
||||
import useKeyboard from "@/hooks/useKeyboard";
|
||||
import { keyDisplayMap, keyDisplayMap2, keys, modifiers, sKeyDisplayMap } from "@/keyboardMappings";
|
||||
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
|
||||
import { keyDisplayMap2, keys, modifiers, sKeyDisplayMap, latchingKeys } from "@/keyboardMappings";
|
||||
import { dark_bg2_style} from "@/layout/theme_color";
|
||||
|
||||
import GoBottomSvg from "@/assets/second/gobottom.svg?react";
|
||||
@@ -33,6 +34,15 @@ function KeyboardWrapper() {
|
||||
);
|
||||
|
||||
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
|
||||
const { selectedKeyboard } = useKeyboardLayout();
|
||||
|
||||
const keyDisplayMap = useMemo(() => {
|
||||
return selectedKeyboard.keyDisplayMap;
|
||||
}, [selectedKeyboard]);
|
||||
|
||||
const virtualKeyboardLayout = useMemo(() => {
|
||||
return selectedKeyboard.virtualKeyboard;
|
||||
}, [selectedKeyboard]);
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
@@ -260,6 +270,7 @@ function KeyboardWrapper() {
|
||||
setIsCapsLockActive(false);
|
||||
}
|
||||
sendKeyboardEvent([keys["CapsLock"]], []);
|
||||
setTimeout(resetKeyboardState, 100);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -837,24 +848,7 @@ function KeyboardWrapper() {
|
||||
? [{ class: "modifier-locked", buttons: modifierLockButtons }]
|
||||
: []
|
||||
}
|
||||
layout={{
|
||||
default: [
|
||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
||||
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
|
||||
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
|
||||
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter",
|
||||
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
|
||||
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
|
||||
],
|
||||
shift: [
|
||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
||||
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
|
||||
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
|
||||
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
|
||||
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
|
||||
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
|
||||
],
|
||||
}}
|
||||
layout={virtualKeyboardLayout.main}
|
||||
disableButtonHold={true}
|
||||
syncInstanceInputs={true}
|
||||
debug={false}
|
||||
@@ -874,10 +868,7 @@ function KeyboardWrapper() {
|
||||
layoutName={layoutName}
|
||||
onKeyPress={onKeyDown}
|
||||
display={keyDisplayMap}
|
||||
layout={{
|
||||
default: ["PrintScreen ScrollLock Pause", "Insert Home Pageup", "Delete End Pagedown"],
|
||||
shift: ["(PrintScreen) ScrollLock (Pause)", "Insert Home Pageup", "Delete End Pagedown"],
|
||||
}}
|
||||
layout={virtualKeyboardLayout.control}
|
||||
syncInstanceInputs={true}
|
||||
debug={false}
|
||||
/>
|
||||
@@ -902,7 +893,7 @@ function KeyboardWrapper() {
|
||||
onKeyPress={onKeyDown}
|
||||
display={keyDisplayMap}
|
||||
layout={{
|
||||
default: ["ArrowUp"],
|
||||
default: [virtualKeyboardLayout.arrows?.default?.[0] || "ArrowUp"],
|
||||
}}
|
||||
syncInstanceInputs={true}
|
||||
debug={false}
|
||||
@@ -916,7 +907,7 @@ function KeyboardWrapper() {
|
||||
onKeyPress={onKeyDown}
|
||||
display={keyDisplayMap}
|
||||
layout={{
|
||||
default: ["ArrowLeft ArrowDown ArrowRight"],
|
||||
default: [virtualKeyboardLayout.arrows?.default?.[1] || "ArrowLeft ArrowDown ArrowRight"],
|
||||
}}
|
||||
syncInstanceInputs={true}
|
||||
debug={false}
|
||||
|
||||
@@ -38,8 +38,8 @@ const appendStatToMap = <T extends { timestamp: number }>(
|
||||
};
|
||||
|
||||
// Constants and types
|
||||
export type AvailableSidebarViews ="ConsoleLogViewer"|"MacroMoreList"|"Fullscreen"|"TerminalTabsMobile"|"SettingsModal"|"ClipboardMobile"|"KeyboardPanel"|"MousePanel"|"SettingsVideo"
|
||||
|"connection-stats"|"Clipboard"|"PowerControl"|"Macros"|"VirtualMedia"|"SharedFolders"|null;
|
||||
export type AvailableSidebarViews = "ConsoleLogViewer" | "MacroMoreList" | "Fullscreen" | "TerminalTabsMobile" | "SettingsModal" | "ClipboardMobile" | "KeyboardPanel" | "MousePanel" | "SettingsVideo"
|
||||
| "connection-stats" | "Clipboard" | "PowerControl" | "Macros" | "VirtualMedia" | "SharedFolders" | "UsbEpModeSelect" | "UsbStatusPanel" | null;
|
||||
export type AvailableTerminalTypes = "kvm" | "serial" | "none";
|
||||
|
||||
export interface User {
|
||||
@@ -64,6 +64,8 @@ interface UIState {
|
||||
|
||||
disableVideoFocusTrap: boolean;
|
||||
setDisableVideoFocusTrap: (enabled: boolean) => void;
|
||||
isOcrMode: boolean;
|
||||
setOcrMode: (enabled: boolean) => void;
|
||||
|
||||
isWakeOnLanModalVisible: boolean;
|
||||
setWakeOnLanModalVisibility: (enabled: boolean) => void;
|
||||
@@ -91,6 +93,8 @@ export const useUiStore = create<UIState>(set => ({
|
||||
|
||||
disableVideoFocusTrap: false,
|
||||
setDisableVideoFocusTrap: enabled => set({ disableVideoFocusTrap: enabled }),
|
||||
isOcrMode: false,
|
||||
setOcrMode: enabled => set({ isOcrMode: enabled }),
|
||||
isAnimationComplete: false,
|
||||
setIsAnimationComplete: enabled => set({ isAnimationComplete: enabled }),
|
||||
|
||||
@@ -386,6 +390,14 @@ interface SettingsState {
|
||||
|
||||
overrideCtrlV: boolean;
|
||||
setOverrideCtrlV: (enabled: boolean) => void;
|
||||
pasteShortcutEnabled: boolean;
|
||||
setPasteShortcutEnabled: (enabled: boolean) => void;
|
||||
pasteShortcut: string;
|
||||
setPasteShortcut: (shortcut: string) => void;
|
||||
ocrShortcutEnabled: boolean;
|
||||
setOcrShortcutEnabled: (enabled: boolean) => void;
|
||||
ocrShortcut: string;
|
||||
setOcrShortcut: (shortcut: string) => void;
|
||||
|
||||
// Video enhancement settings
|
||||
videoSaturation: number;
|
||||
@@ -455,6 +467,14 @@ export const useSettingsStore = create(
|
||||
|
||||
overrideCtrlV: false,
|
||||
setOverrideCtrlV: enabled => set({ overrideCtrlV: enabled }),
|
||||
pasteShortcutEnabled: true,
|
||||
setPasteShortcutEnabled: enabled => set({ pasteShortcutEnabled: enabled }),
|
||||
pasteShortcut: "Ctrl+V",
|
||||
setPasteShortcut: shortcut => set({ pasteShortcut: shortcut }),
|
||||
ocrShortcutEnabled: true,
|
||||
setOcrShortcutEnabled: enabled => set({ ocrShortcutEnabled: enabled }),
|
||||
ocrShortcut: "Ctrl+C",
|
||||
setOcrShortcut: shortcut => set({ ocrShortcut: shortcut }),
|
||||
|
||||
// Video enhancement settings with default values (1.0 = normal)
|
||||
videoSaturation: 1.0,
|
||||
@@ -566,8 +586,13 @@ export interface HidState {
|
||||
keyboardLedStateSyncAvailable: boolean;
|
||||
setKeyboardLedStateSyncAvailable: (available: boolean) => void;
|
||||
|
||||
keysDownState?: { modifier: number; keys: number[] };
|
||||
setKeysDownState: (state: { modifier: number; keys: number[] }) => void;
|
||||
|
||||
isVirtualKeyboardEnabled: boolean;
|
||||
setVirtualKeyboardEnabled: (enabled: boolean) => void;
|
||||
allowTapToOpenVirtualKeyboard: boolean;
|
||||
setAllowTapToOpenVirtualKeyboard: (enabled: boolean) => void;
|
||||
|
||||
isPasteModeEnabled: boolean;
|
||||
setPasteModeEnabled: (enabled: boolean) => void;
|
||||
@@ -622,11 +647,16 @@ export const useHidStore = create<HidState>((set, get) => ({
|
||||
set({ keyboardLedState });
|
||||
},
|
||||
|
||||
keysDownState: undefined,
|
||||
setKeysDownState: state => set({ keysDownState: state }),
|
||||
|
||||
keyboardLedStateSyncAvailable: false,
|
||||
setKeyboardLedStateSyncAvailable: available => set({ keyboardLedStateSyncAvailable: available }),
|
||||
|
||||
isVirtualKeyboardEnabled: false,
|
||||
setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }),
|
||||
allowTapToOpenVirtualKeyboard: true,
|
||||
setAllowTapToOpenVirtualKeyboard: enabled => set({ allowTapToOpenVirtualKeyboard: enabled }),
|
||||
|
||||
isPasteModeEnabled: false,
|
||||
setPasteModeEnabled: enabled => set({ isPasteModeEnabled: enabled }),
|
||||
@@ -699,6 +729,13 @@ export interface UpdateState {
|
||||
|
||||
systemUpdateProgress: number;
|
||||
systemUpdatedAt: string | null;
|
||||
|
||||
appSignatureMissing: boolean;
|
||||
systemSignatureMissing: boolean;
|
||||
appSignatureAbsent: boolean;
|
||||
appSignatureInvalid: boolean;
|
||||
appNoPublicKey: boolean;
|
||||
signatureVerified: boolean;
|
||||
};
|
||||
setOtaState: (state: UpdateState["otaState"]) => void;
|
||||
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
|
||||
@@ -747,6 +784,12 @@ export const useUpdateStore = create<UpdateState>(set => ({
|
||||
appUpdatedAt: null,
|
||||
systemUpdateProgress: 0,
|
||||
systemUpdatedAt: null,
|
||||
appSignatureMissing: false,
|
||||
systemSignatureMissing: false,
|
||||
appSignatureAbsent: false,
|
||||
appSignatureInvalid: false,
|
||||
appNoPublicKey: false,
|
||||
signatureVerified: false,
|
||||
},
|
||||
|
||||
updateDialogHasBeenMinimized: false,
|
||||
@@ -896,6 +939,9 @@ export interface IPv4StaticConfig {
|
||||
export interface NetworkSettings {
|
||||
hostname: string;
|
||||
domain: string;
|
||||
http_proxy?: string;
|
||||
https_proxy?: string;
|
||||
all_proxy?: string;
|
||||
ipv4_mode: IPv4Mode;
|
||||
ipv4_request_address?: string;
|
||||
ipv4_static?: IPv4StaticConfig;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import notifications from "@/notifications";
|
||||
import { useHidStore, useRTCStore, useSettingsStore } from "@/hooks/stores";
|
||||
@@ -16,6 +16,10 @@ export default function useKeyboard() {
|
||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||
const usbState = useHidStore(state => state.usbState);
|
||||
|
||||
// Track held keys for keepalive
|
||||
const heldKeysRef = useRef<Set<number>>(new Set());
|
||||
const keepaliveIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const sendKeyboardEvent = useCallback(
|
||||
(keys: number[], modifiers: number[]) => {
|
||||
if (!forceHttp && rpcDataChannel?.readyState !== "open") return;
|
||||
@@ -24,6 +28,7 @@ export default function useKeyboard() {
|
||||
if (usbState !== "configured") return;
|
||||
const accModifier = modifiers.reduce((acc, val) => acc + val, 0);
|
||||
|
||||
// Fallback to JSON-RPC
|
||||
send("keyboardReport", { keys, modifier: accModifier }, resp => {
|
||||
if ("error" in resp) {
|
||||
const msg = (resp.error.data as string) || resp.error.message || "";
|
||||
@@ -39,10 +44,37 @@ export default function useKeyboard() {
|
||||
[forceHttp, rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers, isReinitializingGadget, usbState],
|
||||
);
|
||||
|
||||
// Send per-key press/release
|
||||
const sendKeypress = useCallback(
|
||||
(key: number, press: boolean) => {
|
||||
if (isReinitializingGadget || usbState !== "configured") return;
|
||||
|
||||
// Legacy: simulate device-side key handling
|
||||
// This maintains the 6-key buffer on the frontend for legacy compatibility
|
||||
// For simplicity in migration, we fall back to full state reports
|
||||
const modifier = press ? 0 : 0; // Simplified - would need proper modifier tracking
|
||||
sendKeyboardEvent(press ? [key] : [], [modifier]);
|
||||
},
|
||||
[isReinitializingGadget, usbState, sendKeyboardEvent]
|
||||
);
|
||||
|
||||
const resetKeyboardState = useCallback(() => {
|
||||
// Release all held keys
|
||||
sendKeyboardEvent([], []);
|
||||
heldKeysRef.current.clear();
|
||||
if (keepaliveIntervalRef.current) {
|
||||
clearInterval(keepaliveIntervalRef.current);
|
||||
keepaliveIntervalRef.current = null;
|
||||
}
|
||||
}, [sendKeyboardEvent]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
resetKeyboardState();
|
||||
};
|
||||
}, [resetKeyboardState]);
|
||||
|
||||
const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => {
|
||||
for (const [index, step] of steps.entries()) {
|
||||
const keyValues = step.keys?.map(key => keys[key]).filter(Boolean) || [];
|
||||
@@ -66,5 +98,5 @@ export default function useKeyboard() {
|
||||
}
|
||||
};
|
||||
|
||||
return { sendKeyboardEvent, resetKeyboardState, executeMacro };
|
||||
return { sendKeyboardEvent, sendKeypress, resetKeyboardState, executeMacro };
|
||||
}
|
||||
|
||||
29
ui/src/hooks/useKeyboardLayout.ts
Normal file
29
ui/src/hooks/useKeyboardLayout.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useSettingsStore } from "@/hooks/stores";
|
||||
import { keyboards } from "@/keyboardLayouts";
|
||||
|
||||
export default function useKeyboardLayout() {
|
||||
const { keyboardLayout } = useSettingsStore();
|
||||
|
||||
const keyboardOptions = useMemo(() => {
|
||||
return keyboards.map(keyboard => {
|
||||
return { label: keyboard.name, value: keyboard.isoCode };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isoCode = useMemo(() => {
|
||||
if (keyboardLayout && keyboardLayout.length > 0)
|
||||
return keyboardLayout.replace("en_US", "en-US");
|
||||
return "en-US";
|
||||
}, [keyboardLayout]);
|
||||
|
||||
const selectedKeyboard = useMemo(() => {
|
||||
return (
|
||||
keyboards.find(keyboard => keyboard.isoCode === isoCode) ??
|
||||
keyboards.find(keyboard => keyboard.isoCode === "en-US")!
|
||||
);
|
||||
}, [isoCode]);
|
||||
|
||||
return { keyboardOptions, isoCode, selectedKeyboard };
|
||||
}
|
||||
@@ -1,45 +1,85 @@
|
||||
import { chars as chars_fr_BE, name as name_fr_BE } from "@/keyboardLayouts/fr_BE"
|
||||
import { chars as chars_cs_CZ, name as name_cs_CZ } from "@/keyboardLayouts/cs_CZ"
|
||||
import { chars as chars_en_UK, name as name_en_UK } from "@/keyboardLayouts/en_UK"
|
||||
import { chars as chars_en_US, name as name_en_US } from "@/keyboardLayouts/en_US"
|
||||
import { chars as chars_fr_FR, name as name_fr_FR } from "@/keyboardLayouts/fr_FR"
|
||||
import { chars as chars_de_DE, name as name_de_DE } from "@/keyboardLayouts/de_DE"
|
||||
import { chars as chars_it_IT, name as name_it_IT } from "@/keyboardLayouts/it_IT"
|
||||
import { chars as chars_nb_NO, name as name_nb_NO } from "@/keyboardLayouts/nb_NO"
|
||||
import { chars as chars_es_ES, name as name_es_ES } from "@/keyboardLayouts/es_ES"
|
||||
import { chars as chars_sv_SE, name as name_sv_SE } from "@/keyboardLayouts/sv_SE"
|
||||
import { chars as chars_fr_CH, name as name_fr_CH } from "@/keyboardLayouts/fr_CH"
|
||||
import { chars as chars_de_CH, name as name_de_CH } from "@/keyboardLayouts/de_CH"
|
||||
|
||||
interface KeyInfo { key: string | number; shift?: boolean, altRight?: boolean }
|
||||
export type KeyCombo = KeyInfo & { deadKey?: boolean, accentKey?: KeyInfo }
|
||||
|
||||
export const layouts: Record<string, string> = {
|
||||
en_UK: name_en_UK,
|
||||
en_US: name_en_US,
|
||||
fr_FR: name_fr_FR,
|
||||
be_FR: name_fr_BE,
|
||||
cs_CZ: name_cs_CZ,
|
||||
de_DE: name_de_DE,
|
||||
it_IT: name_it_IT,
|
||||
nb_NO: name_nb_NO,
|
||||
es_ES: name_es_ES,
|
||||
sv_SE: name_sv_SE,
|
||||
fr_CH: name_fr_CH,
|
||||
de_CH: name_de_CH,
|
||||
export interface KeyStroke {
|
||||
modifier: number;
|
||||
keys: number[];
|
||||
}
|
||||
|
||||
export const chars: Record<string, Record<string, KeyCombo>> = {
|
||||
be_FR: chars_fr_BE,
|
||||
cs_CZ: chars_cs_CZ,
|
||||
en_UK: chars_en_UK,
|
||||
en_US: chars_en_US,
|
||||
fr_FR: chars_fr_FR,
|
||||
de_DE: chars_de_DE,
|
||||
it_IT: chars_it_IT,
|
||||
nb_NO: chars_nb_NO,
|
||||
es_ES: chars_es_ES,
|
||||
sv_SE: chars_sv_SE,
|
||||
fr_CH: chars_fr_CH,
|
||||
de_CH: chars_de_CH,
|
||||
};
|
||||
export interface KeyInfo {
|
||||
key: string | number;
|
||||
shift?: boolean;
|
||||
altRight?: boolean;
|
||||
}
|
||||
|
||||
export interface KeyCombo extends KeyInfo {
|
||||
deadKey?: boolean;
|
||||
accentKey?: KeyInfo;
|
||||
}
|
||||
|
||||
export interface KeyboardLayout {
|
||||
isoCode: string;
|
||||
name: string;
|
||||
chars: Record<string, KeyCombo>;
|
||||
modifierDisplayMap: Record<string, string>;
|
||||
keyDisplayMap: Record<string, string>;
|
||||
virtualKeyboard: {
|
||||
main: { default: string[]; shift: string[] };
|
||||
control?: { default: string[]; shift?: string[] };
|
||||
arrows?: { default: string[] };
|
||||
numpad?: {
|
||||
numlocked: string[];
|
||||
default: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Import all layouts
|
||||
import { cs_CZ } from "./keyboardLayouts/cs_CZ";
|
||||
import { da_DK } from "./keyboardLayouts/da_DK";
|
||||
import { de_CH } from "./keyboardLayouts/de_CH";
|
||||
import { de_DE } from "./keyboardLayouts/de_DE";
|
||||
import { en_US } from "./keyboardLayouts/en_US";
|
||||
import { en_UK } from "./keyboardLayouts/en_UK";
|
||||
import { es_ES } from "./keyboardLayouts/es_ES";
|
||||
import { fr_BE } from "./keyboardLayouts/fr_BE";
|
||||
import { fr_CH } from "./keyboardLayouts/fr_CH";
|
||||
import { fr_FR } from "./keyboardLayouts/fr_FR";
|
||||
import { hu_HU } from "./keyboardLayouts/hu_HU";
|
||||
import { it_IT } from "./keyboardLayouts/it_IT";
|
||||
import { ja_JP } from "./keyboardLayouts/ja_JP";
|
||||
import { nb_NO } from "./keyboardLayouts/nb_NO";
|
||||
import { pl_PL } from "./keyboardLayouts/pl_PL";
|
||||
import { pt_PT } from "./keyboardLayouts/pt_PT";
|
||||
import { sv_SE } from "./keyboardLayouts/sv_SE";
|
||||
import { sl_SI } from "./keyboardLayouts/sl_SI";
|
||||
import { ru_RU } from "./keyboardLayouts/ru_RU";
|
||||
|
||||
export const keyboards: KeyboardLayout[] = [
|
||||
cs_CZ,
|
||||
da_DK,
|
||||
de_CH,
|
||||
de_DE,
|
||||
en_UK,
|
||||
en_US,
|
||||
es_ES,
|
||||
fr_BE,
|
||||
fr_CH,
|
||||
fr_FR,
|
||||
hu_HU,
|
||||
it_IT,
|
||||
ja_JP,
|
||||
nb_NO,
|
||||
pl_PL,
|
||||
pt_PT,
|
||||
sv_SE,
|
||||
sl_SI,
|
||||
ru_RU,
|
||||
];
|
||||
|
||||
// Backward-compatible maps
|
||||
export const layouts: Record<string, string> = {};
|
||||
export const chars: Record<string, Record<string, KeyCombo>> = {};
|
||||
|
||||
keyboards.forEach(kb => {
|
||||
const oldCode = kb.isoCode.replace("-", "_");
|
||||
layouts[oldCode] = kb.name;
|
||||
chars[oldCode] = kb.chars;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { KeyCombo } from "../keyboardLayouts"
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
||||
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
|
||||
|
||||
export const name = "Čeština";
|
||||
const name = "Čeština";
|
||||
const isoCode = "cs-CZ";
|
||||
|
||||
const keyTrema = { key: "Backslash" } // tréma (umlaut), two dots placed above a vowel
|
||||
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
|
||||
@@ -97,11 +99,11 @@ export const chars = {
|
||||
"Ẇ": { key: "KeyW", shift: true, accentKey: keyOverdot },
|
||||
X: { key: "KeyX", shift: true },
|
||||
"Ẋ": { key: "KeyX", shift: true, accentKey: keyOverdot },
|
||||
Y: { key: "KeyY", shift: true },
|
||||
"Ý": { key: "KeyY", shift: true, accentKey: keyAcute },
|
||||
"Ẏ": { key: "KeyY", shift: true, accentKey: keyOverdot },
|
||||
Z: { key: "KeyZ", shift: true },
|
||||
"Ż": { key: "KeyZ", shift: true, accentKey: keyOverdot },
|
||||
Y: { key: "KeyZ", shift: true },
|
||||
"Ý": { key: "KeyZ", shift: true, accentKey: keyAcute },
|
||||
"Ẏ": { key: "KeyZ", shift: true, accentKey: keyOverdot },
|
||||
Z: { key: "KeyY", shift: true },
|
||||
"Ż": { key: "KeyY", shift: true, accentKey: keyOverdot },
|
||||
a: { key: "KeyA" },
|
||||
"ä": { key: "KeyA", accentKey: keyTrema },
|
||||
"â": { key: "KeyA", accentKey: keyHat },
|
||||
@@ -189,10 +191,10 @@ export const chars = {
|
||||
x: { key: "KeyX" },
|
||||
"#": { key: "KeyX", altRight: true },
|
||||
"ẋ": { key: "KeyX", accentKey: keyOverdot },
|
||||
y: { key: "KeyY" },
|
||||
"ẏ": { key: "KeyY", accentKey: keyOverdot },
|
||||
z: { key: "KeyZ" },
|
||||
"ż": { key: "KeyZ", accentKey: keyOverdot },
|
||||
y: { key: "KeyZ" },
|
||||
"ẏ": { key: "KeyZ", accentKey: keyOverdot },
|
||||
z: { key: "KeyY" },
|
||||
"ż": { key: "KeyY", accentKey: keyOverdot },
|
||||
";": { key: "Backquote" },
|
||||
"°": { key: "Backquote", shift: true, deadKey: true },
|
||||
"+": { key: "Digit1" },
|
||||
@@ -242,3 +244,20 @@ export const chars = {
|
||||
Enter: { key: "Enter" },
|
||||
Tab: { key: "Tab" },
|
||||
} as Record<string, KeyCombo>;
|
||||
|
||||
const cs_CZ_keyDisplayMap = {
|
||||
...keyDisplayMap,
|
||||
KeyY: "z",
|
||||
KeyZ: "y",
|
||||
"(KeyY)": "Z",
|
||||
"(KeyZ)": "Y",
|
||||
} as Record<string, string>;
|
||||
|
||||
export const cs_CZ: KeyboardLayout = {
|
||||
isoCode,
|
||||
name,
|
||||
chars,
|
||||
keyDisplayMap: cs_CZ_keyDisplayMap,
|
||||
modifierDisplayMap,
|
||||
virtualKeyboard,
|
||||
};
|
||||
|
||||
186
ui/src/keyboardLayouts/da_DK.ts
Normal file
186
ui/src/keyboardLayouts/da_DK.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts";
|
||||
|
||||
import { en_US } from "./en_US"; // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
|
||||
|
||||
export const name = "Dansk";
|
||||
const isoCode = "da-DK";
|
||||
|
||||
const keyTrema = { key: "BracketRight" };
|
||||
const keyAcute = { key: "Equal", altRight: true };
|
||||
const keyHat = { key: "BracketRight", shift: true };
|
||||
const keyGrave = { key: "Equal", shift: true };
|
||||
const keyTilde = { key: "BracketRight", altRight: true };
|
||||
|
||||
export const chars = {
|
||||
A: { key: "KeyA", shift: true },
|
||||
Ä: { key: "KeyA", shift: true, accentKey: keyTrema },
|
||||
Á: { key: "KeyA", shift: true, accentKey: keyAcute },
|
||||
Â: { key: "KeyA", shift: true, accentKey: keyHat },
|
||||
À: { key: "KeyA", shift: true, accentKey: keyGrave },
|
||||
Ã: { key: "KeyA", shift: true, accentKey: keyTilde },
|
||||
B: { key: "KeyB", shift: true },
|
||||
C: { key: "KeyC", shift: true },
|
||||
D: { key: "KeyD", shift: true },
|
||||
E: { key: "KeyE", shift: true },
|
||||
Ë: { key: "KeyE", shift: true, accentKey: keyTrema },
|
||||
É: { key: "KeyE", shift: true, accentKey: keyAcute },
|
||||
Ê: { key: "KeyE", shift: true, accentKey: keyHat },
|
||||
È: { key: "KeyE", shift: true, accentKey: keyGrave },
|
||||
Ẽ: { key: "KeyE", shift: true, accentKey: keyTilde },
|
||||
F: { key: "KeyF", shift: true },
|
||||
G: { key: "KeyG", shift: true },
|
||||
H: { key: "KeyH", shift: true },
|
||||
I: { key: "KeyI", shift: true },
|
||||
Ï: { key: "KeyI", shift: true, accentKey: keyTrema },
|
||||
Í: { key: "KeyI", shift: true, accentKey: keyAcute },
|
||||
Î: { key: "KeyI", shift: true, accentKey: keyHat },
|
||||
Ì: { key: "KeyI", shift: true, accentKey: keyGrave },
|
||||
Ĩ: { key: "KeyI", shift: true, accentKey: keyTilde },
|
||||
J: { key: "KeyJ", shift: true },
|
||||
K: { key: "KeyK", shift: true },
|
||||
L: { key: "KeyL", shift: true },
|
||||
M: { key: "KeyM", shift: true },
|
||||
N: { key: "KeyN", shift: true },
|
||||
O: { key: "KeyO", shift: true },
|
||||
Ö: { key: "KeyO", shift: true, accentKey: keyTrema },
|
||||
Ó: { key: "KeyO", shift: true, accentKey: keyAcute },
|
||||
Ô: { key: "KeyO", shift: true, accentKey: keyHat },
|
||||
Ò: { key: "KeyO", shift: true, accentKey: keyGrave },
|
||||
Õ: { key: "KeyO", shift: true, accentKey: keyTilde },
|
||||
P: { key: "KeyP", shift: true },
|
||||
Q: { key: "KeyQ", shift: true },
|
||||
R: { key: "KeyR", shift: true },
|
||||
S: { key: "KeyS", shift: true },
|
||||
T: { key: "KeyT", shift: true },
|
||||
U: { key: "KeyU", shift: true },
|
||||
Ü: { key: "KeyU", shift: true, accentKey: keyTrema },
|
||||
Ú: { key: "KeyU", shift: true, accentKey: keyAcute },
|
||||
Û: { key: "KeyU", shift: true, accentKey: keyHat },
|
||||
Ù: { key: "KeyU", shift: true, accentKey: keyGrave },
|
||||
Ũ: { key: "KeyU", shift: true, accentKey: keyTilde },
|
||||
V: { key: "KeyV", shift: true },
|
||||
W: { key: "KeyW", shift: true },
|
||||
X: { key: "KeyX", shift: true },
|
||||
Y: { key: "KeyY", shift: true },
|
||||
Z: { key: "KeyZ", shift: true },
|
||||
a: { key: "KeyA" },
|
||||
ä: { key: "KeyA", accentKey: keyTrema },
|
||||
á: { key: "KeyA", accentKey: keyAcute },
|
||||
â: { key: "KeyA", accentKey: keyHat },
|
||||
à: { key: "KeyA", accentKey: keyGrave },
|
||||
ã: { key: "KeyA", accentKey: keyTilde },
|
||||
b: { key: "KeyB" },
|
||||
c: { key: "KeyC" },
|
||||
d: { key: "KeyD" },
|
||||
e: { key: "KeyE" },
|
||||
ë: { key: "KeyE", accentKey: keyTrema },
|
||||
é: { key: "KeyE", accentKey: keyAcute },
|
||||
ê: { key: "KeyE", accentKey: keyHat },
|
||||
è: { key: "KeyE", accentKey: keyGrave },
|
||||
ẽ: { key: "KeyE", accentKey: keyTilde },
|
||||
"€": { key: "KeyE", altRight: true },
|
||||
f: { key: "KeyF" },
|
||||
g: { key: "KeyG" },
|
||||
h: { key: "KeyH" },
|
||||
i: { key: "KeyI" },
|
||||
ï: { key: "KeyI", accentKey: keyTrema },
|
||||
í: { key: "KeyI", accentKey: keyAcute },
|
||||
î: { key: "KeyI", accentKey: keyHat },
|
||||
ì: { key: "KeyI", accentKey: keyGrave },
|
||||
ĩ: { key: "KeyI", accentKey: keyTilde },
|
||||
j: { key: "KeyJ" },
|
||||
k: { key: "KeyK" },
|
||||
l: { key: "KeyL" },
|
||||
m: { key: "KeyM" },
|
||||
n: { key: "KeyN" },
|
||||
o: { key: "KeyO" },
|
||||
ö: { key: "KeyO", accentKey: keyTrema },
|
||||
ó: { key: "KeyO", accentKey: keyAcute },
|
||||
ô: { key: "KeyO", accentKey: keyHat },
|
||||
ò: { key: "KeyO", accentKey: keyGrave },
|
||||
õ: { key: "KeyO", accentKey: keyTilde },
|
||||
p: { key: "KeyP" },
|
||||
q: { key: "KeyQ" },
|
||||
r: { key: "KeyR" },
|
||||
s: { key: "KeyS" },
|
||||
t: { key: "KeyT" },
|
||||
u: { key: "KeyU" },
|
||||
ü: { key: "KeyU", accentKey: keyTrema },
|
||||
ú: { key: "KeyU", accentKey: keyAcute },
|
||||
û: { key: "KeyU", accentKey: keyHat },
|
||||
ù: { key: "KeyU", accentKey: keyGrave },
|
||||
ũ: { key: "KeyU", accentKey: keyTilde },
|
||||
v: { key: "KeyV" },
|
||||
w: { key: "KeyW" },
|
||||
x: { key: "KeyX" },
|
||||
y: { key: "KeyY" }, // <-- corrected
|
||||
z: { key: "KeyZ" }, // <-- corrected
|
||||
"½": { key: "Backquote" },
|
||||
"§": { key: "Backquote", shift: true },
|
||||
1: { key: "Digit1" },
|
||||
"!": { key: "Digit1", shift: true },
|
||||
2: { key: "Digit2" },
|
||||
'"': { key: "Digit2", shift: true },
|
||||
"@": { key: "Digit2", altRight: true },
|
||||
3: { key: "Digit3" },
|
||||
"#": { key: "Digit3", shift: true },
|
||||
"£": { key: "Digit3", altRight: true },
|
||||
4: { key: "Digit4" },
|
||||
"¤": { key: "Digit4", shift: true },
|
||||
$: { key: "Digit4", altRight: true },
|
||||
5: { key: "Digit5" },
|
||||
"%": { key: "Digit5", shift: true },
|
||||
6: { key: "Digit6" },
|
||||
"&": { key: "Digit6", shift: true },
|
||||
7: { key: "Digit7" },
|
||||
"/": { key: "Digit7", shift: true },
|
||||
"{": { key: "Digit7", altRight: true },
|
||||
8: { key: "Digit8" },
|
||||
"(": { key: "Digit8", shift: true },
|
||||
"[": { key: "Digit8", altRight: true },
|
||||
9: { key: "Digit9" },
|
||||
")": { key: "Digit9", shift: true },
|
||||
"]": { key: "Digit9", altRight: true },
|
||||
0: { key: "Digit0" },
|
||||
"=": { key: "Digit0", shift: true },
|
||||
"}": { key: "Digit0", altRight: true },
|
||||
"+": { key: "Minus" },
|
||||
"?": { key: "Minus", shift: true },
|
||||
"\\": { key: "Equal" },
|
||||
å: { key: "BracketLeft" },
|
||||
Å: { key: "BracketLeft", shift: true },
|
||||
ø: { key: "Semicolon" },
|
||||
Ø: { key: "Semicolon", shift: true },
|
||||
æ: { key: "Quote" },
|
||||
Æ: { key: "Quote", shift: true },
|
||||
"'": { key: "Backslash" },
|
||||
"*": { key: "Backslash", shift: true },
|
||||
",": { key: "Comma" },
|
||||
";": { key: "Comma", shift: true },
|
||||
".": { key: "Period" },
|
||||
":": { key: "Period", shift: true },
|
||||
"-": { key: "Slash" },
|
||||
_: { key: "Slash", shift: true },
|
||||
"<": { key: "IntlBackslash" },
|
||||
">": { key: "IntlBackslash", shift: true },
|
||||
"~": { key: "BracketRight", deadKey: true, altRight: true },
|
||||
"^": { key: "BracketRight", deadKey: true, shift: true },
|
||||
"¨": { key: "BracketRight", deadKey: true },
|
||||
"|": { key: "Equal", deadKey: true, altRight: true },
|
||||
"`": { key: "Equal", deadKey: true, shift: true },
|
||||
"´": { key: "Equal", deadKey: true },
|
||||
" ": { key: "Space" },
|
||||
"\n": { key: "Enter" },
|
||||
Enter: { key: "Enter" },
|
||||
Tab: { key: "Tab" },
|
||||
} as Record<string, KeyCombo>;
|
||||
|
||||
export const da_DK: KeyboardLayout = {
|
||||
isoCode: isoCode,
|
||||
name: name,
|
||||
chars: chars,
|
||||
// TODO need to localize these maps and layouts
|
||||
keyDisplayMap: en_US.keyDisplayMap,
|
||||
modifierDisplayMap: en_US.modifierDisplayMap,
|
||||
virtualKeyboard: en_US.virtualKeyboard,
|
||||
};
|
||||
@@ -1,6 +1,9 @@
|
||||
import { KeyCombo } from "../keyboardLayouts"
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
||||
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
|
||||
export { keyDisplayMap } from "./en_US";
|
||||
|
||||
export const name = "Schwiizerdütsch";
|
||||
const name = "Schwiizerdütsch";
|
||||
const isoCode = "de-CH";
|
||||
|
||||
const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
|
||||
const keyAcute = { key: "Minus", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
|
||||
@@ -163,3 +166,20 @@ export const chars = {
|
||||
Enter: { key: "Enter" },
|
||||
Tab: { key: "Tab" },
|
||||
} as Record<string, KeyCombo>;
|
||||
|
||||
export const de_CH_keyDisplayMap = {
|
||||
...keyDisplayMap,
|
||||
KeyY: "z",
|
||||
KeyZ: "y",
|
||||
"(KeyY)": "Z",
|
||||
"(KeyZ)": "Y",
|
||||
} as Record<string, string>;
|
||||
|
||||
export const de_CH: KeyboardLayout = {
|
||||
isoCode,
|
||||
name,
|
||||
chars,
|
||||
keyDisplayMap: de_CH_keyDisplayMap,
|
||||
modifierDisplayMap,
|
||||
virtualKeyboard,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { KeyCombo } from "../keyboardLayouts"
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
||||
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
|
||||
|
||||
export const name = "Deutsch";
|
||||
const name = "Deutsch";
|
||||
const isoCode = "de-DE";
|
||||
|
||||
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
|
||||
const keyHat = { key: "Backquote" } // accent circonflexe (accent hat), mark ^ placed above the letter
|
||||
@@ -150,3 +152,20 @@ export const chars = {
|
||||
Enter: { key: "Enter" },
|
||||
Tab: { key: "Tab" },
|
||||
} as Record<string, KeyCombo>;
|
||||
|
||||
const de_DE_keyDisplayMap = {
|
||||
...keyDisplayMap,
|
||||
KeyY: "z",
|
||||
KeyZ: "y",
|
||||
"(KeyY)": "Z",
|
||||
"(KeyZ)": "Y",
|
||||
} as Record<string, string>;
|
||||
|
||||
export const de_DE: KeyboardLayout = {
|
||||
isoCode,
|
||||
name,
|
||||
chars,
|
||||
keyDisplayMap: de_DE_keyDisplayMap,
|
||||
modifierDisplayMap,
|
||||
virtualKeyboard,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { KeyCombo } from "../keyboardLayouts"
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
||||
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
|
||||
|
||||
export const name = "English (UK)";
|
||||
const name = "English (UK)";
|
||||
const isoCode = "en-GB";
|
||||
|
||||
export const chars = {
|
||||
A: { key: "KeyA", shift: true },
|
||||
@@ -104,4 +106,13 @@ export const chars = {
|
||||
"\n": { key: "Enter" },
|
||||
Enter: { key: "Enter" },
|
||||
Tab: { key: "Tab" },
|
||||
} as Record<string, KeyCombo>
|
||||
} as Record<string, KeyCombo>;
|
||||
|
||||
export const en_UK: KeyboardLayout = {
|
||||
isoCode,
|
||||
name,
|
||||
chars,
|
||||
keyDisplayMap,
|
||||
modifierDisplayMap,
|
||||
virtualKeyboard,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { KeyCombo } from "../keyboardLayouts"
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
||||
|
||||
export const name = "English (US)";
|
||||
const name = "English (US)";
|
||||
const isoCode = "en-US";
|
||||
|
||||
export const chars = {
|
||||
A: { key: "KeyA", shift: true },
|
||||
@@ -89,25 +90,283 @@ export const chars = {
|
||||
">": { key: "Period", shift: true },
|
||||
";": { key: "Semicolon" },
|
||||
":": { key: "Semicolon", shift: true },
|
||||
"¶": { key: "Semicolon", altRight: true }, // pilcrow sign
|
||||
"[": { key: "BracketLeft" },
|
||||
"{": { key: "BracketLeft", shift: true },
|
||||
"«": { key: "BracketLeft", altRight: true }, // double left quote sign
|
||||
"]": { key: "BracketRight" },
|
||||
"}": { key: "BracketRight", shift: true },
|
||||
"»": { key: "BracketRight", altRight: true }, // double right quote sign
|
||||
"\\": { key: "Backslash" },
|
||||
"|": { key: "Backslash", shift: true },
|
||||
"¬": { key: "Backslash", altRight: true }, // not sign
|
||||
"`": { key: "Backquote" },
|
||||
"~": { key: "Backquote", shift: true },
|
||||
"§": { key: "IntlBackslash" },
|
||||
"±": { key: "IntlBackslash", shift: true },
|
||||
" ": { key: "Space", shift: false },
|
||||
"\n": { key: "Enter", shift: false },
|
||||
Enter: { key: "Enter", shift: false },
|
||||
Tab: { key: "Tab", shift: false },
|
||||
PrintScreen: { key: "Prt Sc", shift: false },
|
||||
" ": { key: "Space" },
|
||||
"\n": { key: "Enter" },
|
||||
Enter: { key: "Enter" },
|
||||
Escape: { key: "Escape" },
|
||||
Tab: { key: "Tab" },
|
||||
PrintScreen: { key: "Prt Sc" },
|
||||
SystemRequest: { key: "Prt Sc", shift: true },
|
||||
ScrollLock: { key: "ScrollLock", shift: false},
|
||||
Pause: { key: "Pause", shift: false },
|
||||
ScrollLock: { key: "ScrollLock" },
|
||||
Pause: { key: "Pause" },
|
||||
Break: { key: "Pause", shift: true },
|
||||
Insert: { key: "Insert", shift: false },
|
||||
Delete: { key: "Delete", shift: false },
|
||||
} as Record<string, KeyCombo>
|
||||
Insert: { key: "Insert" },
|
||||
Delete: { key: "Delete" },
|
||||
} as Record<string, KeyCombo>;
|
||||
|
||||
export const modifierDisplayMap: Record<string, string> = {
|
||||
ControlLeft: "Left Ctrl",
|
||||
ControlRight: "Right Ctrl",
|
||||
ShiftLeft: "Left Shift",
|
||||
ShiftRight: "Right Shift",
|
||||
AltLeft: "Left Alt",
|
||||
AltRight: "Right Alt",
|
||||
MetaLeft: "Left Meta",
|
||||
MetaRight: "Right Meta",
|
||||
AltGr: "AltGr",
|
||||
} as Record<string, string>;
|
||||
|
||||
export const keyDisplayMap: Record<string, string> = {
|
||||
CtrlAltDelete: "Ctrl + Alt + Delete",
|
||||
AltMetaEscape: "Alt + Meta + Escape",
|
||||
CtrlAltBackspace: "Ctrl + Alt + Backspace",
|
||||
AltGr: "AltGr",
|
||||
AltLeft: "Alt ⌥",
|
||||
AltRight: "⌥ Alt",
|
||||
ArrowDown: "↓",
|
||||
ArrowLeft: "←",
|
||||
ArrowRight: "→",
|
||||
ArrowUp: "↑",
|
||||
Backspace: "Backspace",
|
||||
"(Backspace)": "Backspace",
|
||||
CapsLock: "Caps Lock ⇪",
|
||||
Clear: "Clear",
|
||||
ControlLeft: "Ctrl ⌃",
|
||||
ControlRight: "⌃ Ctrl",
|
||||
Delete: "Delete ⌦",
|
||||
End: "End",
|
||||
Enter: "Enter",
|
||||
Escape: "Esc",
|
||||
Home: "Home",
|
||||
Insert: "Insert",
|
||||
Menu: "Menu",
|
||||
MetaLeft: "Meta ⌘",
|
||||
MetaRight: "⌘ Meta",
|
||||
PageDown: "PgDn",
|
||||
PageUp: "PgUp",
|
||||
ShiftLeft: "Shift ⇧",
|
||||
ShiftRight: "⇧ Shift",
|
||||
Space: " ",
|
||||
Tab: "Tab ⇥",
|
||||
|
||||
// Letters
|
||||
KeyA: "a",
|
||||
KeyB: "b",
|
||||
KeyC: "c",
|
||||
KeyD: "d",
|
||||
KeyE: "e",
|
||||
KeyF: "f",
|
||||
KeyG: "g",
|
||||
KeyH: "h",
|
||||
KeyI: "i",
|
||||
KeyJ: "j",
|
||||
KeyK: "k",
|
||||
KeyL: "l",
|
||||
KeyM: "m",
|
||||
KeyN: "n",
|
||||
KeyO: "o",
|
||||
KeyP: "p",
|
||||
KeyQ: "q",
|
||||
KeyR: "r",
|
||||
KeyS: "s",
|
||||
KeyT: "t",
|
||||
KeyU: "u",
|
||||
KeyV: "v",
|
||||
KeyW: "w",
|
||||
KeyX: "x",
|
||||
KeyY: "y",
|
||||
KeyZ: "z",
|
||||
|
||||
// Capital letters
|
||||
"(KeyA)": "A",
|
||||
"(KeyB)": "B",
|
||||
"(KeyC)": "C",
|
||||
"(KeyD)": "D",
|
||||
"(KeyE)": "E",
|
||||
"(KeyF)": "F",
|
||||
"(KeyG)": "G",
|
||||
"(KeyH)": "H",
|
||||
"(KeyI)": "I",
|
||||
"(KeyJ)": "J",
|
||||
"(KeyK)": "K",
|
||||
"(KeyL)": "L",
|
||||
"(KeyM)": "M",
|
||||
"(KeyN)": "N",
|
||||
"(KeyO)": "O",
|
||||
"(KeyP)": "P",
|
||||
"(KeyQ)": "Q",
|
||||
"(KeyR)": "R",
|
||||
"(KeyS)": "S",
|
||||
"(KeyT)": "T",
|
||||
"(KeyU)": "U",
|
||||
"(KeyV)": "V",
|
||||
"(KeyW)": "W",
|
||||
"(KeyX)": "X",
|
||||
"(KeyY)": "Y",
|
||||
"(KeyZ)": "Z",
|
||||
|
||||
// Numbers
|
||||
Digit1: "1",
|
||||
Digit2: "2",
|
||||
Digit3: "3",
|
||||
Digit4: "4",
|
||||
Digit5: "5",
|
||||
Digit6: "6",
|
||||
Digit7: "7",
|
||||
Digit8: "8",
|
||||
Digit9: "9",
|
||||
Digit0: "0",
|
||||
|
||||
// Shifted Numbers
|
||||
"(Digit1)": "!",
|
||||
"(Digit2)": "@",
|
||||
"(Digit3)": "#",
|
||||
"(Digit4)": "$",
|
||||
"(Digit5)": "%",
|
||||
"(Digit6)": "^",
|
||||
"(Digit7)": "&",
|
||||
"(Digit8)": "*",
|
||||
"(Digit9)": "(",
|
||||
"(Digit0)": ")",
|
||||
|
||||
// Symbols
|
||||
Minus: "-",
|
||||
"(Minus)": "_",
|
||||
Equal: "=",
|
||||
"(Equal)": "+",
|
||||
BracketLeft: "[",
|
||||
"(BracketLeft)": "{",
|
||||
BracketRight: "]",
|
||||
"(BracketRight)": "}",
|
||||
Backslash: "\\",
|
||||
"(Backslash)": "|",
|
||||
Semicolon: ";",
|
||||
"(Semicolon)": ":",
|
||||
Quote: "'",
|
||||
"(Quote)": '"',
|
||||
Comma: ",",
|
||||
"(Comma)": "<",
|
||||
Period: ".",
|
||||
"(Period)": ">",
|
||||
Slash: "/",
|
||||
"(Slash)": "?",
|
||||
Backquote: "`",
|
||||
"(Backquote)": "~",
|
||||
IntlBackslash: "\\",
|
||||
|
||||
// Function keys
|
||||
F1: "F1",
|
||||
F2: "F2",
|
||||
F3: "F3",
|
||||
F4: "F4",
|
||||
F5: "F5",
|
||||
F6: "F6",
|
||||
F7: "F7",
|
||||
F8: "F8",
|
||||
F9: "F9",
|
||||
F10: "F10",
|
||||
F11: "F11",
|
||||
F12: "F12",
|
||||
|
||||
// Numpad
|
||||
Numpad0: "Num 0",
|
||||
Numpad1: "Num 1",
|
||||
Numpad2: "Num 2",
|
||||
Numpad3: "Num 3",
|
||||
Numpad4: "Num 4",
|
||||
Numpad5: "Num 5",
|
||||
Numpad6: "Num 6",
|
||||
Numpad7: "Num 7",
|
||||
Numpad8: "Num 8",
|
||||
Numpad9: "Num 9",
|
||||
NumpadAdd: "Num +",
|
||||
NumpadSubtract: "Num -",
|
||||
NumpadMultiply: "Num *",
|
||||
NumpadDivide: "Num /",
|
||||
NumpadDecimal: "Num .",
|
||||
NumpadEqual: "Num =",
|
||||
NumpadEnter: "Num Enter",
|
||||
NumpadInsert: "Ins",
|
||||
NumpadDelete: "Del",
|
||||
NumLock: "Num Lock",
|
||||
|
||||
// Modals
|
||||
PrintScreen: "Prt Sc",
|
||||
ScrollLock: "Scr Lk",
|
||||
Pause: "Pause",
|
||||
"(PrintScreen)": "Sys Rq",
|
||||
"(Pause)": "Break",
|
||||
SystemRequest: "Sys Rq",
|
||||
Break: "Break",
|
||||
};
|
||||
|
||||
export const virtualKeyboard = {
|
||||
main: {
|
||||
default: [
|
||||
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
|
||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
||||
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
|
||||
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
|
||||
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter",
|
||||
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
|
||||
"ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight",
|
||||
],
|
||||
shift: [
|
||||
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
|
||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
||||
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
|
||||
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
|
||||
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
|
||||
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
|
||||
"ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight",
|
||||
],
|
||||
},
|
||||
control: {
|
||||
default: ["PrintScreen ScrollLock Pause", "Insert Home PageUp", "Delete End PageDown"],
|
||||
shift: ["(PrintScreen) ScrollLock (Pause)", "Insert Home PageUp", "Delete End PageDown"],
|
||||
},
|
||||
|
||||
arrows: {
|
||||
default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
|
||||
},
|
||||
|
||||
numpad: {
|
||||
numlocked: [
|
||||
"NumLock NumpadDivide NumpadMultiply NumpadSubtract",
|
||||
"Numpad7 Numpad8 Numpad9 NumpadAdd",
|
||||
"Numpad4 Numpad5 Numpad6",
|
||||
"Numpad1 Numpad2 Numpad3 NumpadEnter",
|
||||
"Numpad0 NumpadDecimal",
|
||||
],
|
||||
default: [
|
||||
"NumLock NumpadDivide NumpadMultiply NumpadSubtract",
|
||||
"Home ArrowUp PageUp NumpadAdd",
|
||||
"ArrowLeft Clear ArrowRight",
|
||||
"End ArrowDown PageDown NumpadEnter",
|
||||
"NumpadInsert NumpadDelete",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const en_US: KeyboardLayout = {
|
||||
isoCode,
|
||||
name,
|
||||
chars,
|
||||
keyDisplayMap,
|
||||
modifierDisplayMap,
|
||||
virtualKeyboard,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { KeyCombo } from "../keyboardLayouts"
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
||||
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
|
||||
|
||||
export const name = "Español";
|
||||
const name = "Español";
|
||||
const isoCode = "es-ES";
|
||||
|
||||
const keyTrema = { key: "Quote", shift: true } // tréma (umlaut), two dots placed above a vowel
|
||||
const keyAcute = { key: "Quote" } // accent aigu (acute accent), mark ´ placed above the letter
|
||||
@@ -166,3 +168,12 @@ export const chars = {
|
||||
Enter: { key: "Enter" },
|
||||
Tab: { key: "Tab" },
|
||||
} as Record<string, KeyCombo>;
|
||||
|
||||
export const es_ES: KeyboardLayout = {
|
||||
isoCode,
|
||||
name,
|
||||
chars,
|
||||
keyDisplayMap,
|
||||
modifierDisplayMap,
|
||||
virtualKeyboard,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { KeyCombo } from "../keyboardLayouts"
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
||||
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
|
||||
|
||||
export const name = "Belgisch Nederlands";
|
||||
const name = "Belgisch Nederlands";
|
||||
const isoCode = "fr-BE";
|
||||
|
||||
const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
|
||||
const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
|
||||
@@ -56,10 +58,10 @@ export const chars = {
|
||||
"Ù": { key: "KeyU", shift: true, accentKey: keyGrave },
|
||||
"Ũ": { key: "KeyU", shift: true, accentKey: keyTilde },
|
||||
V: { key: "KeyV", shift: true },
|
||||
W: { key: "KeyW", shift: true },
|
||||
W: { key: "KeyZ", shift: true },
|
||||
X: { key: "KeyX", shift: true },
|
||||
Y: { key: "KeyZ", shift: true },
|
||||
Z: { key: "KeyY", shift: true },
|
||||
Y: { key: "KeyY", shift: true },
|
||||
Z: { key: "KeyW", shift: true },
|
||||
a: { key: "KeyQ" },
|
||||
"ä": { key: "KeyQ", accentKey: keyTrema },
|
||||
"â": { key: "KeyQ", accentKey: keyHat },
|
||||
@@ -104,10 +106,10 @@ export const chars = {
|
||||
"ú": { key: "KeyU", accentKey: keyAcute },
|
||||
"ũ": { key: "KeyU", accentKey: keyTilde },
|
||||
v: { key: "KeyV" },
|
||||
w: { key: "KeyW" },
|
||||
w: { key: "KeyZ" },
|
||||
x: { key: "KeyX" },
|
||||
y: { key: "KeyZ" },
|
||||
z: { key: "KeyY" },
|
||||
y: { key: "KeyY" },
|
||||
z: { key: "KeyW" },
|
||||
"²": { key: "Backquote" },
|
||||
"³": { key: "Backquote", shift: true },
|
||||
"&": { key: "Digit1" },
|
||||
@@ -165,3 +167,28 @@ export const chars = {
|
||||
Enter: { key: "Enter" },
|
||||
Tab: { key: "Tab" },
|
||||
} as Record<string, KeyCombo>;
|
||||
|
||||
const fr_BE_keyDisplayMap = {
|
||||
...keyDisplayMap,
|
||||
KeyA: "q",
|
||||
KeyQ: "a",
|
||||
KeyW: "z",
|
||||
KeyZ: "w",
|
||||
Semicolon: "m",
|
||||
KeyM: ",",
|
||||
"(KeyA)": "Q",
|
||||
"(KeyQ)": "A",
|
||||
"(KeyW)": "Z",
|
||||
"(KeyZ)": "W",
|
||||
"(Semicolon)": "M",
|
||||
"(KeyM)": "?",
|
||||
} as Record<string, string>;
|
||||
|
||||
export const fr_BE: KeyboardLayout = {
|
||||
isoCode,
|
||||
name,
|
||||
chars,
|
||||
keyDisplayMap: fr_BE_keyDisplayMap,
|
||||
modifierDisplayMap,
|
||||
virtualKeyboard,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { KeyCombo } from "../keyboardLayouts"
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
||||
import { chars as chars_de_CH, de_CH_keyDisplayMap } from "./de_CH"
|
||||
import { modifierDisplayMap, virtualKeyboard } from "./en_US"
|
||||
|
||||
import { chars as chars_de_CH } from "./de_CH"
|
||||
|
||||
export const name = "Français de Suisse";
|
||||
const name = "Français de Suisse";
|
||||
const isoCode = "fr-CH";
|
||||
|
||||
export const chars = {
|
||||
...chars_de_CH,
|
||||
@@ -13,3 +14,22 @@ export const chars = {
|
||||
"à": { key: "Quote" },
|
||||
"ä": { key: "Quote", shift: true },
|
||||
} as Record<string, KeyCombo>;
|
||||
|
||||
const fr_CH_keyDisplayMap = {
|
||||
...de_CH_keyDisplayMap,
|
||||
BracketLeft: "è",
|
||||
"(BracketLeft)": "ü",
|
||||
Semicolon: "é",
|
||||
"(Semicolon)": "ö",
|
||||
Quote: "à",
|
||||
"(Quote)": "ä",
|
||||
} as Record<string, string>;
|
||||
|
||||
export const fr_CH: KeyboardLayout = {
|
||||
isoCode,
|
||||
name,
|
||||
chars,
|
||||
keyDisplayMap: fr_CH_keyDisplayMap,
|
||||
modifierDisplayMap,
|
||||
virtualKeyboard,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { KeyCombo } from "../keyboardLayouts"
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
||||
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
|
||||
|
||||
export const name = "Français";
|
||||
const name = "Français";
|
||||
const isoCode = "fr-FR";
|
||||
|
||||
const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
|
||||
const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
|
||||
@@ -137,3 +139,28 @@ export const chars = {
|
||||
Enter: { key: "Enter" },
|
||||
Tab: { key: "Tab" },
|
||||
} as Record<string, KeyCombo>;
|
||||
|
||||
const fr_FR_keyDisplayMap = {
|
||||
...keyDisplayMap,
|
||||
KeyA: "q",
|
||||
KeyQ: "a",
|
||||
KeyW: "z",
|
||||
KeyZ: "w",
|
||||
Semicolon: "m",
|
||||
KeyM: ",",
|
||||
"(KeyA)": "Q",
|
||||
"(KeyQ)": "A",
|
||||
"(KeyW)": "Z",
|
||||
"(KeyZ)": "W",
|
||||
"(Semicolon)": "M",
|
||||
"(KeyM)": "?",
|
||||
} as Record<string, string>;
|
||||
|
||||
export const fr_FR: KeyboardLayout = {
|
||||
isoCode,
|
||||
name,
|
||||
chars,
|
||||
keyDisplayMap: fr_FR_keyDisplayMap,
|
||||
modifierDisplayMap,
|
||||
virtualKeyboard,
|
||||
};
|
||||
|
||||
177
ui/src/keyboardLayouts/hu_HU.ts
Normal file
177
ui/src/keyboardLayouts/hu_HU.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts";
|
||||
import { en_US } from "./en_US";
|
||||
|
||||
const name = "Magyar";
|
||||
const isoCode = "hu-HU";
|
||||
|
||||
const keyAcute: KeyCombo = { key: "Digit9", altRight: true };
|
||||
const keyDoubleAcute: KeyCombo = { key: "Equal", shift: true };
|
||||
const keyTrema: KeyCombo = { key: "Equal", altRight: true };
|
||||
|
||||
const chars = {
|
||||
A: { key: "KeyA", shift: true },
|
||||
Á: { key: "Semicolon", shift: true, accentKey: keyAcute },
|
||||
B: { key: "KeyB", shift: true },
|
||||
C: { key: "KeyC", shift: true },
|
||||
D: { key: "KeyD", shift: true },
|
||||
E: { key: "KeyE", shift: true },
|
||||
É: { key: "Quote", shift: true, accentKey: keyAcute },
|
||||
F: { key: "KeyF", shift: true },
|
||||
G: { key: "KeyG", shift: true },
|
||||
H: { key: "KeyH", shift: true },
|
||||
I: { key: "KeyI", shift: true },
|
||||
Í: { key: "IntlBackslash", shift: true, accentKey: keyAcute },
|
||||
J: { key: "KeyJ", shift: true },
|
||||
K: { key: "KeyK", shift: true },
|
||||
L: { key: "KeyL", shift: true },
|
||||
M: { key: "KeyM", shift: true },
|
||||
N: { key: "KeyN", shift: true },
|
||||
O: { key: "KeyO", shift: true },
|
||||
Ó: { key: "BracketLeft", shift: true, accentKey: keyAcute },
|
||||
Ö: { key: "Minus", shift: true, accentKey: keyTrema },
|
||||
Ő: { key: "BracketRight", shift: true, accentKey: keyDoubleAcute },
|
||||
P: { key: "KeyP", shift: true },
|
||||
Q: { key: "KeyQ", shift: true },
|
||||
R: { key: "KeyR", shift: true },
|
||||
S: { key: "KeyS", shift: true },
|
||||
T: { key: "KeyT", shift: true },
|
||||
U: { key: "KeyU", shift: true },
|
||||
Ú: { key: "Backslash", shift: true, accentKey: keyAcute },
|
||||
Ü: { key: "Equal", shift: true, accentKey: keyTrema },
|
||||
Ű: { key: "Backquote", shift: true, accentKey: keyDoubleAcute },
|
||||
V: { key: "KeyV", shift: true },
|
||||
W: { key: "KeyW", shift: true },
|
||||
X: { key: "KeyX", shift: true },
|
||||
Y: { key: "KeyZ", shift: true },
|
||||
Z: { key: "KeyY", shift: true },
|
||||
a: { key: "KeyA" },
|
||||
á: { key: "Semicolon", accentKey: keyAcute },
|
||||
b: { key: "KeyB" },
|
||||
c: { key: "KeyC" },
|
||||
d: { key: "KeyD" },
|
||||
e: { key: "KeyE" },
|
||||
é: { key: "Quote", accentKey: keyAcute },
|
||||
f: { key: "KeyF" },
|
||||
g: { key: "KeyG" },
|
||||
h: { key: "KeyH" },
|
||||
i: { key: "KeyI" },
|
||||
í: { key: "IntlBackslash", accentKey: keyAcute },
|
||||
j: { key: "KeyJ" },
|
||||
k: { key: "KeyK" },
|
||||
l: { key: "KeyL" },
|
||||
m: { key: "KeyM" },
|
||||
n: { key: "KeyN" },
|
||||
o: { key: "KeyO" },
|
||||
ó: { key: "BracketLeft", accentKey: keyAcute },
|
||||
ö: { key: "Minus", accentKey: keyTrema },
|
||||
ő: { key: "BracketRight", accentKey: keyDoubleAcute },
|
||||
p: { key: "KeyP" },
|
||||
q: { key: "KeyQ" },
|
||||
r: { key: "KeyR" },
|
||||
s: { key: "KeyS" },
|
||||
t: { key: "KeyT" },
|
||||
u: { key: "KeyU" },
|
||||
ú: { key: "Backslash", accentKey: keyAcute },
|
||||
ü: { key: "Equal", accentKey: keyTrema },
|
||||
ű: { key: "Backquote", accentKey: keyDoubleAcute },
|
||||
v: { key: "KeyV" },
|
||||
w: { key: "KeyW" },
|
||||
x: { key: "KeyX" },
|
||||
y: { key: "KeyZ" },
|
||||
z: { key: "KeyY" },
|
||||
|
||||
// Numbers and top row symbols
|
||||
0: { key: "Digit0" },
|
||||
"§": { key: "Digit0", shift: true },
|
||||
1: { key: "Digit1" },
|
||||
"'": { key: "Digit1", shift: true },
|
||||
2: { key: "Digit2" },
|
||||
'"': { key: "Digit2", shift: true },
|
||||
3: { key: "Digit3" },
|
||||
"+": { key: "Digit3", shift: true },
|
||||
4: { key: "Digit4" },
|
||||
"!": { key: "Digit4", shift: true },
|
||||
5: { key: "Digit5" },
|
||||
"%": { key: "Digit5", shift: true },
|
||||
6: { key: "Digit6" },
|
||||
"/": { key: "Digit6", shift: true },
|
||||
7: { key: "Digit7" },
|
||||
"=": { key: "Digit7", shift: true },
|
||||
8: { key: "Digit8" },
|
||||
"(": { key: "Digit8", shift: true },
|
||||
9: { key: "Digit9" },
|
||||
")": { key: "Digit9", shift: true },
|
||||
|
||||
// AltGr symbols
|
||||
"~": { key: "Digit1", altRight: true },
|
||||
ˇ: { key: "Digit2", altRight: true },
|
||||
"^": { key: "Digit3", altRight: true },
|
||||
"˘": { key: "Digit4", altRight: true },
|
||||
"°": { key: "Digit5", altRight: true },
|
||||
"˛": { key: "Digit6", altRight: true },
|
||||
"`": { key: "Digit7", altRight: true },
|
||||
"˙": { key: "Digit8", altRight: true },
|
||||
"´": { key: "Digit9", altRight: true },
|
||||
"˝": { key: "Digit0", altRight: true },
|
||||
"„": { key: "KeyO", altRight: true },
|
||||
"\\": { key: "KeyQ", altRight: true },
|
||||
"|": { key: "KeyW", altRight: true },
|
||||
"€": { key: "KeyU", altRight: true },
|
||||
đ: { key: "KeyS", altRight: true },
|
||||
"[": { key: "KeyF", altRight: true },
|
||||
"]": { key: "KeyG", altRight: true },
|
||||
ß: { key: "Semicolon", altRight: true },
|
||||
$: { key: "Quote", altRight: true },
|
||||
"¤": { key: "Backquote", altRight: true },
|
||||
"@": { key: "KeyV", altRight: true },
|
||||
"{": { key: "KeyB", altRight: true },
|
||||
"}": { key: "KeyN", altRight: true },
|
||||
"<": { key: "IntlBackslash", altRight: true },
|
||||
">": { key: "KeyZ", altRight: true },
|
||||
"#": { key: "KeyX", altRight: true },
|
||||
"&": { key: "KeyC", altRight: true },
|
||||
";": { key: "Comma", altRight: true },
|
||||
"*": { key: "Period", altRight: true },
|
||||
"÷": { key: "BracketRight", altRight: true },
|
||||
"×": { key: "Backslash", altRight: true },
|
||||
|
||||
// Punctuation
|
||||
",": { key: "Comma" },
|
||||
"?": { key: "Comma", shift: true },
|
||||
".": { key: "Period" },
|
||||
":": { key: "Period", shift: true },
|
||||
"-": { key: "Slash" },
|
||||
_: { key: "Slash", shift: true },
|
||||
" ": { key: "Space" },
|
||||
"\n": { key: "Enter" },
|
||||
Enter: { key: "Enter" },
|
||||
Tab: { key: "Tab" },
|
||||
} as Record<string, KeyCombo>;
|
||||
|
||||
const keyDisplayMap = {
|
||||
...en_US.keyDisplayMap,
|
||||
Digit0: "0",
|
||||
Backquote: "ű",
|
||||
Minus: "ö",
|
||||
Equal: "ü",
|
||||
BracketLeft: "ó",
|
||||
BracketRight: "ő",
|
||||
Semicolon: "á",
|
||||
Quote: "é",
|
||||
Backslash: "ú",
|
||||
IntlBackslash: "í",
|
||||
KeyY: "Z",
|
||||
KeyZ: "Y",
|
||||
} as Record<string, string>;
|
||||
|
||||
export const hu_HU: KeyboardLayout = {
|
||||
isoCode,
|
||||
name,
|
||||
chars,
|
||||
keyDisplayMap,
|
||||
modifierDisplayMap: {
|
||||
...en_US.modifierDisplayMap,
|
||||
altRight: "AltGr",
|
||||
},
|
||||
virtualKeyboard: en_US.virtualKeyboard,
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import { KeyCombo } from "../keyboardLayouts"
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
||||
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
|
||||
|
||||
export const name = "Italiano";
|
||||
const name = "Italiano";
|
||||
const isoCode = "it-IT";
|
||||
|
||||
export const chars = {
|
||||
A: { key: "KeyA", shift: true },
|
||||
@@ -111,3 +113,12 @@ export const chars = {
|
||||
Enter: { key: "Enter" },
|
||||
Tab: { key: "Tab" },
|
||||
} as Record<string, KeyCombo>;
|
||||
|
||||
export const it_IT: KeyboardLayout = {
|
||||
isoCode,
|
||||
name,
|
||||
chars,
|
||||
keyDisplayMap,
|
||||
modifierDisplayMap,
|
||||
virtualKeyboard,
|
||||
};
|
||||
|
||||
124
ui/src/keyboardLayouts/ja_JP.ts
Normal file
124
ui/src/keyboardLayouts/ja_JP.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts";
|
||||
|
||||
import { en_US } from "./en_US";
|
||||
|
||||
const name = "Japanese";
|
||||
const isoCode = "ja-JP";
|
||||
|
||||
// NOTE:
|
||||
// This layout is primarily implemented with primarily targets Windows/Linux in mind on common JIS 106/109 keyboards.
|
||||
// Across Windows, Linux, and macOS, there are small but important differences in:
|
||||
// - how backslash ("\\") vs yen ("¥") are produced / interpreted, and
|
||||
// - how Japanese IME mode switching keys behave (e.g. Henkan/Muhenkan/KatakanaHiragana).
|
||||
//
|
||||
// For Windows/Linux friendliness, we intentionally map both "\\" and "¥" to the Yen key,
|
||||
// since many environments/applications render the Yen key as a backslash.
|
||||
//
|
||||
// TODO:
|
||||
// If macOS-specific behavior is required, consider adding a dedicated macOS JIS layout
|
||||
// (e.g. ja_JP_mac) and adjust mappings (often mapping "\\" to Backslash instead of Yen),
|
||||
// plus any IME-key semantics differences as needed.
|
||||
|
||||
export const chars = {
|
||||
...en_US.chars,
|
||||
'"': { key: "Digit2", shift: true },
|
||||
"&": { key: "Digit6", shift: true },
|
||||
"'": { key: "Digit7", shift: true },
|
||||
"(": { key: "Digit8", shift: true },
|
||||
")": { key: "Digit9", shift: true },
|
||||
"=": { key: "Minus", shift: true },
|
||||
"^": { key: "Equal" },
|
||||
"~": { key: "Equal", shift: true },
|
||||
"\\": { key: "Yen" },
|
||||
"¥": { key: "Yen" },
|
||||
"|": { key: "Yen", shift: true },
|
||||
"@": { key: "BracketLeft" },
|
||||
"`": { key: "BracketLeft", shift: true },
|
||||
"[": { key: "BracketRight" },
|
||||
"{": { key: "BracketRight", shift: true },
|
||||
";": { key: "Semicolon" },
|
||||
"+": { key: "Semicolon", shift: true },
|
||||
":": { key: "Quote" },
|
||||
"*": { key: "Quote", shift: true },
|
||||
"]": { key: "Backslash" },
|
||||
"}": { key: "Backslash", shift: true },
|
||||
_: { key: "KeyRO", shift: true },
|
||||
} as Record<string, KeyCombo>;
|
||||
|
||||
// NOTE:
|
||||
// We intentionally avoid providing Hiragana glyph labels on keycaps in the UI.
|
||||
// Only about 5.1% of users typed with Kana input as of 2015; thus Kana legends are
|
||||
// generally omitted to reduce visual clutter while keeping IME-related keys functional
|
||||
// (Henkan/Muhenkan/KatakanaHiragana) for users who need them.
|
||||
// Source: https://ja.wikipedia.org/wiki/%E3%81%8B%E3%81%AA%E5%85%A5%E5%8A%9B#%E3%81%8B%E3%81%AA%E5%85%A5%E5%8A%9B%E3%81%AE%E5%88%A9%E7%94%A8%E7%8A%B6%E6%B3%81
|
||||
export const keyDisplayMap: Record<string, string> = {
|
||||
...en_US.keyDisplayMap,
|
||||
"(Digit2)": '"',
|
||||
"(Digit6)": "&",
|
||||
"(Digit7)": "'",
|
||||
"(Digit8)": "(",
|
||||
"(Digit9)": ")",
|
||||
"(Minus)": "=",
|
||||
Equal: "^",
|
||||
"(Equal)": "~",
|
||||
Yen: "¥",
|
||||
"(Yen)": "|",
|
||||
KeyRO: "\\",
|
||||
"(KeyRO)": "_",
|
||||
Henkan: "変換",
|
||||
Muhenkan: "無変換",
|
||||
KatakanaHiragana: "ひらがな",
|
||||
Backquote: "半角/全角",
|
||||
"(KatakanaHiragana)": "ローマ字",
|
||||
BracketLeft: "@",
|
||||
"(BracketLeft)": "`",
|
||||
BracketRight: "[",
|
||||
"(BracketRight)": "{",
|
||||
Semicolon: ";",
|
||||
"(Semicolon)": "+",
|
||||
Quote: ":",
|
||||
"(Quote)": "*",
|
||||
Backslash: "]",
|
||||
"(Backslash)": "}",
|
||||
ContextMenu: "Menu",
|
||||
|
||||
// UI-only notes:
|
||||
// - Keep a placeholder label for shifted Digit0 to avoid a "missing" keycap in the UI.
|
||||
// - Use "⏎" to hint at the tall, JIS/ISO-style L-shaped Enter key in the UI,
|
||||
// while internally representing it with two virtual buttons.
|
||||
"(Digit0)": " ",
|
||||
"(Enter)": "⏎",
|
||||
};
|
||||
|
||||
export const virtualKeyboard = {
|
||||
...en_US.virtualKeyboard,
|
||||
main: {
|
||||
default: [
|
||||
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
|
||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
||||
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Yen Backspace",
|
||||
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Enter",
|
||||
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Backslash (Enter)",
|
||||
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash KeyRO ShiftRight",
|
||||
"ControlLeft MetaLeft AltLeft Muhenkan Space Henkan KatakanaHiragana AltRight MetaRight ContextMenu ControlRight",
|
||||
],
|
||||
shift: [
|
||||
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
|
||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
||||
"Backquote (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Yen) (Backspace)",
|
||||
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) Enter",
|
||||
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) (Backslash) (Enter)",
|
||||
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) (KeyRO) ShiftRight",
|
||||
"ControlLeft MetaLeft AltLeft Muhenkan Space Henkan (KatakanaHiragana) AltRight MetaRight ContextMenu ControlRight",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const ja_JP: KeyboardLayout = {
|
||||
isoCode,
|
||||
name,
|
||||
chars,
|
||||
keyDisplayMap,
|
||||
modifierDisplayMap: en_US.modifierDisplayMap,
|
||||
virtualKeyboard,
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import { KeyCombo } from "../keyboardLayouts"
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
||||
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
|
||||
|
||||
export const name = "Norsk bokmål";
|
||||
const name = "Norsk bokmål";
|
||||
const isoCode = "nb-NO";
|
||||
|
||||
const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
|
||||
const keyAcute = { key: "Equal", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
|
||||
@@ -58,8 +60,8 @@ export const chars = {
|
||||
V: { key: "KeyV", shift: true },
|
||||
W: { key: "KeyW", shift: true },
|
||||
X: { key: "KeyX", shift: true },
|
||||
Y: { key: "KeyZ", shift: true },
|
||||
Z: { key: "KeyY", shift: true },
|
||||
Y: { key: "KeyY", shift: true },
|
||||
Z: { key: "KeyZ", shift: true },
|
||||
a: { key: "KeyA" },
|
||||
"ä": { key: "KeyA", accentKey: keyTrema },
|
||||
"á": { key: "KeyA", accentKey: keyAcute },
|
||||
@@ -110,8 +112,8 @@ export const chars = {
|
||||
v: { key: "KeyV" },
|
||||
w: { key: "KeyW" },
|
||||
x: { key: "KeyX" },
|
||||
y: { key: "KeyZ" },
|
||||
z: { key: "KeyY" },
|
||||
y: { key: "KeyY" },
|
||||
z: { key: "KeyZ" },
|
||||
"|": { key: "Backquote" },
|
||||
"§": { key: "Backquote", shift: true },
|
||||
1: { key: "Digit1" },
|
||||
@@ -165,3 +167,12 @@ export const chars = {
|
||||
Enter: { key: "Enter" },
|
||||
Tab: { key: "Tab" },
|
||||
} as Record<string, KeyCombo>;
|
||||
|
||||
export const nb_NO: KeyboardLayout = {
|
||||
isoCode,
|
||||
name,
|
||||
chars,
|
||||
keyDisplayMap,
|
||||
modifierDisplayMap,
|
||||
virtualKeyboard,
|
||||
};
|
||||
|
||||
40
ui/src/keyboardLayouts/pl_PL.ts
Normal file
40
ui/src/keyboardLayouts/pl_PL.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts";
|
||||
|
||||
import { en_US, chars as en_US_chars } from "./en_US";
|
||||
|
||||
const name = "Polski";
|
||||
const isoCode = "pl-PL";
|
||||
|
||||
// Polish Programmer layout (kbdpl1): QWERTY + AltGr diacritics, no dead keys
|
||||
const chars: Record<string, KeyCombo> = {
|
||||
...en_US_chars,
|
||||
// lowercase diacritics (AltGr + letter)
|
||||
ą: { key: "KeyA", altRight: true },
|
||||
ć: { key: "KeyC", altRight: true },
|
||||
ę: { key: "KeyE", altRight: true },
|
||||
ł: { key: "KeyL", altRight: true },
|
||||
ń: { key: "KeyN", altRight: true },
|
||||
ó: { key: "KeyO", altRight: true },
|
||||
ś: { key: "KeyS", altRight: true },
|
||||
ż: { key: "KeyZ", altRight: true },
|
||||
ź: { key: "KeyX", altRight: true },
|
||||
// uppercase diacritics (Shift + AltGr + letter)
|
||||
Ą: { key: "KeyA", shift: true, altRight: true },
|
||||
Ć: { key: "KeyC", shift: true, altRight: true },
|
||||
Ę: { key: "KeyE", shift: true, altRight: true },
|
||||
Ł: { key: "KeyL", shift: true, altRight: true },
|
||||
Ń: { key: "KeyN", shift: true, altRight: true },
|
||||
Ó: { key: "KeyO", shift: true, altRight: true },
|
||||
Ś: { key: "KeyS", shift: true, altRight: true },
|
||||
Ż: { key: "KeyZ", shift: true, altRight: true },
|
||||
Ź: { key: "KeyX", shift: true, altRight: true },
|
||||
};
|
||||
|
||||
export const pl_PL: KeyboardLayout = {
|
||||
isoCode: isoCode,
|
||||
name: name,
|
||||
chars: chars,
|
||||
keyDisplayMap: en_US.keyDisplayMap,
|
||||
modifierDisplayMap: en_US.modifierDisplayMap,
|
||||
virtualKeyboard: en_US.virtualKeyboard,
|
||||
};
|
||||
209
ui/src/keyboardLayouts/pt_PT.ts
Normal file
209
ui/src/keyboardLayouts/pt_PT.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts";
|
||||
|
||||
import { en_US } from "./en_US"; // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
|
||||
|
||||
const name = "Português";
|
||||
const isoCode = "pt-PT";
|
||||
|
||||
// Dead keys
|
||||
const keyAcute: KeyCombo = { key: "BracketRight" }; // ´ (dead) on SC 1B base
|
||||
const keyGrave: KeyCombo = { key: "BracketRight", shift: true }; // ` (dead) on SC 1B shift
|
||||
const keyTrema: KeyCombo = { key: "BracketLeft", altRight: true }; // ¨ (dead) on SC 1A AltGr
|
||||
const keyTilde: KeyCombo = { key: "Backslash" }; // ~ (dead) on SC 2B base
|
||||
const keyHat: KeyCombo = { key: "Backslash", shift: true }; // ^ (dead) on SC 2B shift
|
||||
|
||||
const chars = {
|
||||
// Uppercase letters
|
||||
A: { key: "KeyA", shift: true },
|
||||
Á: { key: "KeyA", shift: true, accentKey: keyAcute },
|
||||
À: { key: "KeyA", shift: true, accentKey: keyGrave },
|
||||
Ä: { key: "KeyA", shift: true, accentKey: keyTrema },
|
||||
Ã: { key: "KeyA", shift: true, accentKey: keyTilde },
|
||||
Â: { key: "KeyA", shift: true, accentKey: keyHat },
|
||||
B: { key: "KeyB", shift: true },
|
||||
C: { key: "KeyC", shift: true },
|
||||
D: { key: "KeyD", shift: true },
|
||||
E: { key: "KeyE", shift: true },
|
||||
É: { key: "KeyE", shift: true, accentKey: keyAcute },
|
||||
È: { key: "KeyE", shift: true, accentKey: keyGrave },
|
||||
Ë: { key: "KeyE", shift: true, accentKey: keyTrema },
|
||||
Ê: { key: "KeyE", shift: true, accentKey: keyHat },
|
||||
F: { key: "KeyF", shift: true },
|
||||
G: { key: "KeyG", shift: true },
|
||||
H: { key: "KeyH", shift: true },
|
||||
I: { key: "KeyI", shift: true },
|
||||
Í: { key: "KeyI", shift: true, accentKey: keyAcute },
|
||||
Ì: { key: "KeyI", shift: true, accentKey: keyGrave },
|
||||
Ï: { key: "KeyI", shift: true, accentKey: keyTrema },
|
||||
Î: { key: "KeyI", shift: true, accentKey: keyHat },
|
||||
J: { key: "KeyJ", shift: true },
|
||||
K: { key: "KeyK", shift: true },
|
||||
L: { key: "KeyL", shift: true },
|
||||
M: { key: "KeyM", shift: true },
|
||||
N: { key: "KeyN", shift: true },
|
||||
Ñ: { key: "KeyN", shift: true, accentKey: keyTilde },
|
||||
O: { key: "KeyO", shift: true },
|
||||
Ó: { key: "KeyO", shift: true, accentKey: keyAcute },
|
||||
Ò: { key: "KeyO", shift: true, accentKey: keyGrave },
|
||||
Ö: { key: "KeyO", shift: true, accentKey: keyTrema },
|
||||
Õ: { key: "KeyO", shift: true, accentKey: keyTilde },
|
||||
Ô: { key: "KeyO", shift: true, accentKey: keyHat },
|
||||
P: { key: "KeyP", shift: true },
|
||||
Q: { key: "KeyQ", shift: true },
|
||||
R: { key: "KeyR", shift: true },
|
||||
S: { key: "KeyS", shift: true },
|
||||
T: { key: "KeyT", shift: true },
|
||||
U: { key: "KeyU", shift: true },
|
||||
Ú: { key: "KeyU", shift: true, accentKey: keyAcute },
|
||||
Ù: { key: "KeyU", shift: true, accentKey: keyGrave },
|
||||
Ü: { key: "KeyU", shift: true, accentKey: keyTrema },
|
||||
Û: { key: "KeyU", shift: true, accentKey: keyHat },
|
||||
V: { key: "KeyV", shift: true },
|
||||
W: { key: "KeyW", shift: true },
|
||||
X: { key: "KeyX", shift: true },
|
||||
Y: { key: "KeyY", shift: true },
|
||||
Ý: { key: "KeyY", shift: true, accentKey: keyAcute },
|
||||
Z: { key: "KeyZ", shift: true },
|
||||
|
||||
// Lowercase letters
|
||||
a: { key: "KeyA" },
|
||||
á: { key: "KeyA", accentKey: keyAcute },
|
||||
à: { key: "KeyA", accentKey: keyGrave },
|
||||
ä: { key: "KeyA", accentKey: keyTrema },
|
||||
ã: { key: "KeyA", accentKey: keyTilde },
|
||||
â: { key: "KeyA", accentKey: keyHat },
|
||||
b: { key: "KeyB" },
|
||||
c: { key: "KeyC" },
|
||||
d: { key: "KeyD" },
|
||||
e: { key: "KeyE" },
|
||||
é: { key: "KeyE", accentKey: keyAcute },
|
||||
è: { key: "KeyE", accentKey: keyGrave },
|
||||
ë: { key: "KeyE", accentKey: keyTrema },
|
||||
ê: { key: "KeyE", accentKey: keyHat },
|
||||
"€": { key: "KeyE", altRight: true },
|
||||
f: { key: "KeyF" },
|
||||
g: { key: "KeyG" },
|
||||
h: { key: "KeyH" },
|
||||
i: { key: "KeyI" },
|
||||
í: { key: "KeyI", accentKey: keyAcute },
|
||||
ì: { key: "KeyI", accentKey: keyGrave },
|
||||
ï: { key: "KeyI", accentKey: keyTrema },
|
||||
î: { key: "KeyI", accentKey: keyHat },
|
||||
j: { key: "KeyJ" },
|
||||
k: { key: "KeyK" },
|
||||
l: { key: "KeyL" },
|
||||
m: { key: "KeyM" },
|
||||
n: { key: "KeyN" },
|
||||
ñ: { key: "KeyN", accentKey: keyTilde },
|
||||
o: { key: "KeyO" },
|
||||
ó: { key: "KeyO", accentKey: keyAcute },
|
||||
ò: { key: "KeyO", accentKey: keyGrave },
|
||||
ö: { key: "KeyO", accentKey: keyTrema },
|
||||
õ: { key: "KeyO", accentKey: keyTilde },
|
||||
ô: { key: "KeyO", accentKey: keyHat },
|
||||
p: { key: "KeyP" },
|
||||
q: { key: "KeyQ" },
|
||||
r: { key: "KeyR" },
|
||||
s: { key: "KeyS" },
|
||||
t: { key: "KeyT" },
|
||||
u: { key: "KeyU" },
|
||||
ú: { key: "KeyU", accentKey: keyAcute },
|
||||
ù: { key: "KeyU", accentKey: keyGrave },
|
||||
ü: { key: "KeyU", accentKey: keyTrema },
|
||||
û: { key: "KeyU", accentKey: keyHat },
|
||||
v: { key: "KeyV" },
|
||||
w: { key: "KeyW" },
|
||||
x: { key: "KeyX" },
|
||||
y: { key: "KeyY" },
|
||||
ý: { key: "KeyY", accentKey: keyAcute },
|
||||
ÿ: { key: "KeyY", accentKey: keyTrema },
|
||||
z: { key: "KeyZ" },
|
||||
|
||||
// SC 29 (OEM_5) → Backquote: \ |
|
||||
"\\": { key: "Backquote" },
|
||||
"|": { key: "Backquote", shift: true },
|
||||
|
||||
// Number row
|
||||
1: { key: "Digit1" },
|
||||
"!": { key: "Digit1", shift: true },
|
||||
2: { key: "Digit2" },
|
||||
'"': { key: "Digit2", shift: true },
|
||||
"@": { key: "Digit2", altRight: true },
|
||||
3: { key: "Digit3" },
|
||||
"#": { key: "Digit3", shift: true },
|
||||
"£": { key: "Digit3", altRight: true },
|
||||
4: { key: "Digit4" },
|
||||
$: { key: "Digit4", shift: true },
|
||||
"§": { key: "Digit4", altRight: true },
|
||||
5: { key: "Digit5" },
|
||||
"%": { key: "Digit5", shift: true },
|
||||
6: { key: "Digit6" },
|
||||
"&": { key: "Digit6", shift: true },
|
||||
7: { key: "Digit7" },
|
||||
"/": { key: "Digit7", shift: true },
|
||||
"{": { key: "Digit7", altRight: true },
|
||||
8: { key: "Digit8" },
|
||||
"(": { key: "Digit8", shift: true },
|
||||
"[": { key: "Digit8", altRight: true },
|
||||
9: { key: "Digit9" },
|
||||
")": { key: "Digit9", shift: true },
|
||||
"]": { key: "Digit9", altRight: true },
|
||||
0: { key: "Digit0" },
|
||||
"=": { key: "Digit0", shift: true },
|
||||
"}": { key: "Digit0", altRight: true },
|
||||
|
||||
// SC 0C (OEM_4) → Minus: ' ?
|
||||
"'": { key: "Minus" },
|
||||
"?": { key: "Minus", shift: true },
|
||||
|
||||
// SC 0D (OEM_6) → Equal: « »
|
||||
"«": { key: "Equal" },
|
||||
"»": { key: "Equal", shift: true },
|
||||
|
||||
// SC 1A (OEM_PLUS) → BracketLeft: + * ¨(dead)
|
||||
"+": { key: "BracketLeft" },
|
||||
"*": { key: "BracketLeft", shift: true },
|
||||
"¨": { key: "BracketLeft", altRight: true, deadKey: true },
|
||||
|
||||
// SC 1B (OEM_1) → BracketRight: ´(dead) `(dead)
|
||||
"´": { key: "BracketRight", deadKey: true },
|
||||
"`": { key: "BracketRight", shift: true, deadKey: true },
|
||||
|
||||
// SC 27 (OEM_3) → Semicolon: ç Ç
|
||||
ç: { key: "Semicolon" },
|
||||
Ç: { key: "Semicolon", shift: true },
|
||||
|
||||
// SC 28 (OEM_7) → Quote: º ª
|
||||
º: { key: "Quote" },
|
||||
ª: { key: "Quote", shift: true },
|
||||
|
||||
// SC 2B (OEM_2) → Backslash: ~(dead) ^(dead)
|
||||
"~": { key: "Backslash", deadKey: true },
|
||||
"^": { key: "Backslash", shift: true, deadKey: true },
|
||||
|
||||
// SC 33-35: Comma, Period, Slash
|
||||
",": { key: "Comma" },
|
||||
";": { key: "Comma", shift: true },
|
||||
".": { key: "Period" },
|
||||
":": { key: "Period", shift: true },
|
||||
"-": { key: "Slash" },
|
||||
_: { key: "Slash", shift: true },
|
||||
|
||||
// SC 56 (OEM_102) → IntlBackslash: < >
|
||||
"<": { key: "IntlBackslash" },
|
||||
">": { key: "IntlBackslash", shift: true },
|
||||
|
||||
" ": { key: "Space" },
|
||||
"\n": { key: "Enter" },
|
||||
Enter: { key: "Enter" },
|
||||
Tab: { key: "Tab" },
|
||||
} as Record<string, KeyCombo>;
|
||||
|
||||
export const pt_PT: KeyboardLayout = {
|
||||
isoCode: isoCode,
|
||||
name: name,
|
||||
chars: chars,
|
||||
keyDisplayMap: en_US.keyDisplayMap,
|
||||
modifierDisplayMap: en_US.modifierDisplayMap,
|
||||
virtualKeyboard: en_US.virtualKeyboard,
|
||||
};
|
||||
171
ui/src/keyboardLayouts/ru_RU.ts
Normal file
171
ui/src/keyboardLayouts/ru_RU.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts";
|
||||
import { en_US } from "./en_US";
|
||||
|
||||
const name = "Русская";
|
||||
const isoCode = "ru-RU";
|
||||
|
||||
export const chars = {
|
||||
...en_US.chars,
|
||||
А: { key: "KeyF", shift: true },
|
||||
Б: { key: "Comma", shift: true },
|
||||
В: { key: "KeyD", shift: true },
|
||||
Г: { key: "KeyU", shift: true },
|
||||
Д: { key: "KeyL", shift: true },
|
||||
Е: { key: "KeyT", shift: true },
|
||||
Ё: { key: "Backquote", shift: true },
|
||||
Ж: { key: "Semicolon", shift: true },
|
||||
З: { key: "KeyP", shift: true },
|
||||
И: { key: "KeyB", shift: true },
|
||||
Й: { key: "KeyQ", shift: true },
|
||||
К: { key: "KeyR", shift: true },
|
||||
Л: { key: "KeyK", shift: true },
|
||||
М: { key: "KeyV", shift: true },
|
||||
Н: { key: "KeyY", shift: true },
|
||||
О: { key: "KeyJ", shift: true },
|
||||
П: { key: "KeyG", shift: true },
|
||||
Р: { key: "KeyH", shift: true },
|
||||
С: { key: "KeyC", shift: true },
|
||||
Т: { key: "KeyN", shift: true },
|
||||
У: { key: "KeyE", shift: true },
|
||||
Ф: { key: "KeyA", shift: true },
|
||||
Х: { key: "BracketLeft", shift: true },
|
||||
Ц: { key: "KeyW", shift: true },
|
||||
Ч: { key: "KeyX", shift: true },
|
||||
Ш: { key: "KeyI", shift: true },
|
||||
Щ: { key: "KeyO", shift: true },
|
||||
Ъ: { key: "BracketRight", shift: true },
|
||||
Ы: { key: "KeyS", shift: true },
|
||||
Ь: { key: "KeyM", shift: true },
|
||||
Э: { key: "Quote", shift: true },
|
||||
Ю: { key: "Period", shift: true },
|
||||
Я: { key: "KeyZ", shift: true },
|
||||
а: { key: "KeyF" },
|
||||
б: { key: "Comma" },
|
||||
в: { key: "KeyD" },
|
||||
г: { key: "KeyU" },
|
||||
д: { key: "KeyL" },
|
||||
е: { key: "KeyT" },
|
||||
ё: { key: "Backquote" },
|
||||
ж: { key: "Semicolon" },
|
||||
з: { key: "KeyP" },
|
||||
и: { key: "KeyB" },
|
||||
й: { key: "KeyQ" },
|
||||
к: { key: "KeyR" },
|
||||
л: { key: "KeyK" },
|
||||
м: { key: "KeyV" },
|
||||
н: { key: "KeyY" },
|
||||
о: { key: "KeyJ" },
|
||||
п: { key: "KeyG" },
|
||||
р: { key: "KeyH" },
|
||||
с: { key: "KeyC" },
|
||||
т: { key: "KeyN" },
|
||||
у: { key: "KeyE" },
|
||||
ф: { key: "KeyA" },
|
||||
х: { key: "BracketLeft" },
|
||||
ц: { key: "KeyW" },
|
||||
ч: { key: "KeyX" },
|
||||
ш: { key: "KeyI" },
|
||||
щ: { key: "KeyO" },
|
||||
ъ: { key: "BracketRight" },
|
||||
ы: { key: "KeyS" },
|
||||
ь: { key: "KeyM" },
|
||||
э: { key: "Quote" },
|
||||
ю: { key: "Period" },
|
||||
я: { key: "KeyZ" },
|
||||
'"': { key: "Digit2", shift: true },
|
||||
"№": { key: "Digit3", shift: true },
|
||||
";": { key: "Digit4", shift: true },
|
||||
":": { key: "Digit6", shift: true },
|
||||
"?": { key: "Digit7", shift: true },
|
||||
".": { key: "Slash" },
|
||||
",": { key: "Slash", shift: true },
|
||||
} as Record<string, KeyCombo>;
|
||||
|
||||
export const keyDisplayMap = {
|
||||
...en_US.keyDisplayMap,
|
||||
KeyF: "а",
|
||||
Comma: "б",
|
||||
KeyD: "в",
|
||||
KeyU: "г",
|
||||
KeyL: "д",
|
||||
KeyT: "е",
|
||||
Backquote: "ё",
|
||||
Semicolon: "ж",
|
||||
KeyP: "з",
|
||||
KeyB: "и",
|
||||
KeyQ: "й",
|
||||
KeyR: "к",
|
||||
KeyK: "л",
|
||||
KeyV: "м",
|
||||
KeyY: "н",
|
||||
KeyJ: "о",
|
||||
KeyG: "п",
|
||||
KeyH: "р",
|
||||
KeyC: "с",
|
||||
KeyN: "т",
|
||||
KeyE: "у",
|
||||
KeyA: "ф",
|
||||
BracketLeft: "х",
|
||||
KeyW: "ц",
|
||||
KeyX: "ч",
|
||||
KeyI: "ш",
|
||||
KeyO: "щ",
|
||||
BracketRight: "ъ",
|
||||
KeyS: "ы",
|
||||
KeyM: "ь",
|
||||
Quote: "э",
|
||||
Period: "ю",
|
||||
KeyZ: "я",
|
||||
Slash: ".",
|
||||
"(KeyF)": "А",
|
||||
"(Comma)": "Б",
|
||||
"(KeyD)": "В",
|
||||
"(KeyU)": "Г",
|
||||
"(KeyL)": "Д",
|
||||
"(KeyT)": "Е",
|
||||
"(Backquote)": "Ё",
|
||||
"(Semicolon)": "Ж",
|
||||
"(KeyP)": "З",
|
||||
"(KeyB)": "И",
|
||||
"(KeyQ)": "Й",
|
||||
"(KeyR)": "К",
|
||||
"(KeyK)": "Л",
|
||||
"(KeyV)": "М",
|
||||
"(KeyY)": "Н",
|
||||
"(KeyJ)": "О",
|
||||
"(KeyG)": "П",
|
||||
"(KeyH)": "Р",
|
||||
"(KeyC)": "С",
|
||||
"(KeyN)": "Т",
|
||||
"(KeyE)": "У",
|
||||
"(KeyA)": "Ф",
|
||||
"(BracketLeft)": "Х",
|
||||
"(KeyW)": "Ц",
|
||||
"(KeyX)": "Ч",
|
||||
"(KeyI)": "Ш",
|
||||
"(KeyO)": "Щ",
|
||||
"(BracketRight)": "Ъ",
|
||||
"(KeyS)": "Ы",
|
||||
"(KeyM)": "Ь",
|
||||
"(Quote)": "Э",
|
||||
"(Period)": "Ю",
|
||||
"(KeyZ)": "Я",
|
||||
"(Digit2)": '"',
|
||||
"(Digit3)": "№",
|
||||
"(Digit4)": ";",
|
||||
"(Digit6)": ":",
|
||||
"(Digit7)": "?",
|
||||
"(Slash)": ",",
|
||||
};
|
||||
|
||||
export const modifierDisplayMap = en_US.modifierDisplayMap;
|
||||
export const virtualKeyboard = en_US.virtualKeyboard;
|
||||
|
||||
export const ru_RU: KeyboardLayout = {
|
||||
isoCode,
|
||||
name,
|
||||
chars,
|
||||
keyDisplayMap,
|
||||
modifierDisplayMap,
|
||||
virtualKeyboard,
|
||||
};
|
||||
164
ui/src/keyboardLayouts/sl_SI.ts
Normal file
164
ui/src/keyboardLayouts/sl_SI.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts";
|
||||
|
||||
import { en_US } from "./en_US"; // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
|
||||
|
||||
const name = "Slovenian";
|
||||
const isoCode = "sl-SI";
|
||||
|
||||
export const chars = {
|
||||
A: { key: "KeyA", shift: true },
|
||||
B: { key: "KeyB", shift: true },
|
||||
C: { key: "KeyC", shift: true },
|
||||
Č: { key: "Semicolon", shift: true },
|
||||
Ć: { key: "Quote", shift: true },
|
||||
D: { key: "KeyD", shift: true },
|
||||
Đ: { key: "BracketRight", shift: true },
|
||||
E: { key: "KeyE", shift: true },
|
||||
F: { key: "KeyF", shift: true },
|
||||
G: { key: "KeyG", shift: true },
|
||||
H: { key: "KeyH", shift: true },
|
||||
I: { key: "KeyI", shift: true },
|
||||
J: { key: "KeyJ", shift: true },
|
||||
K: { key: "KeyK", shift: true },
|
||||
L: { key: "KeyL", shift: true },
|
||||
M: { key: "KeyM", shift: true },
|
||||
N: { key: "KeyN", shift: true },
|
||||
O: { key: "KeyO", shift: true },
|
||||
P: { key: "KeyP", shift: true },
|
||||
Q: { key: "KeyQ", shift: true },
|
||||
R: { key: "KeyR", shift: true },
|
||||
S: { key: "KeyS", shift: true },
|
||||
Š: { key: "BracketLeft", shift: true },
|
||||
T: { key: "KeyT", shift: true },
|
||||
U: { key: "KeyU", shift: true },
|
||||
V: { key: "KeyV", shift: true },
|
||||
W: { key: "KeyW", shift: true },
|
||||
X: { key: "KeyX", shift: true },
|
||||
Y: { key: "KeyZ", shift: true },
|
||||
Z: { key: "KeyY", shift: true },
|
||||
Ž: { key: "Backslash", shift: true },
|
||||
a: { key: "KeyA" },
|
||||
b: { key: "KeyB" },
|
||||
c: { key: "KeyC" },
|
||||
č: { key: "Semicolon" },
|
||||
ć: { key: "Quote" },
|
||||
d: { key: "KeyD" },
|
||||
đ: { key: "BracketRight" },
|
||||
e: { key: "KeyE" },
|
||||
f: { key: "KeyF" },
|
||||
g: { key: "KeyG" },
|
||||
h: { key: "KeyH" },
|
||||
i: { key: "KeyI" },
|
||||
j: { key: "KeyJ" },
|
||||
k: { key: "KeyK" },
|
||||
l: { key: "KeyL" },
|
||||
m: { key: "KeyM" },
|
||||
n: { key: "KeyN" },
|
||||
o: { key: "KeyO" },
|
||||
p: { key: "KeyP" },
|
||||
q: { key: "KeyQ" },
|
||||
r: { key: "KeyR" },
|
||||
s: { key: "KeyS" },
|
||||
š: { key: "BracketLeft" },
|
||||
t: { key: "KeyT" },
|
||||
u: { key: "KeyU" },
|
||||
v: { key: "KeyV" },
|
||||
w: { key: "KeyW" },
|
||||
x: { key: "KeyX" },
|
||||
y: { key: "KeyZ" },
|
||||
z: { key: "KeyY" },
|
||||
ž: { key: "Backslash" },
|
||||
1: { key: "Digit1" },
|
||||
"!": { key: "Digit1", shift: true },
|
||||
2: { key: "Digit2" },
|
||||
'"': { key: "Digit2", shift: true },
|
||||
3: { key: "Digit3" },
|
||||
"#": { key: "Digit3", shift: true },
|
||||
4: { key: "Digit4" },
|
||||
$: { key: "Digit4", shift: true },
|
||||
5: { key: "Digit5" },
|
||||
"%": { key: "Digit5", shift: true },
|
||||
6: { key: "Digit6" },
|
||||
"&": { key: "Digit6", shift: true },
|
||||
7: { key: "Digit7" },
|
||||
"/": { key: "Digit7", shift: true },
|
||||
8: { key: "Digit8" },
|
||||
"(": { key: "Digit8", shift: true },
|
||||
9: { key: "Digit9" },
|
||||
")": { key: "Digit9", shift: true },
|
||||
0: { key: "Digit0" },
|
||||
"=": { key: "Digit0", shift: true },
|
||||
"'": { key: "Minus" },
|
||||
"?": { key: "Minus", shift: true },
|
||||
"+": { key: "Equal" },
|
||||
"*": { key: "Equal", shift: true },
|
||||
|
||||
"<": { key: "IntlBackslash" },
|
||||
">": { key: "IntlBackslash", shift: true },
|
||||
",": { key: "Comma" },
|
||||
";": { key: "Comma", shift: true },
|
||||
".": { key: "Period" },
|
||||
":": { key: "Period", shift: true },
|
||||
"-": { key: "Slash" },
|
||||
_: { key: "Slash", shift: true },
|
||||
|
||||
"~": { key: "Digit1", shift: true },
|
||||
ˇ: { key: "Digit2", shift: true },
|
||||
"^": { key: "Digit3", shift: true },
|
||||
"˘": { key: "Digit4", shift: true },
|
||||
"°": { key: "Digit5", shift: true },
|
||||
"˛": { key: "Digit6", shift: true },
|
||||
"`": { key: "Digit7", shift: true },
|
||||
"˙": { key: "Digit8", shift: true },
|
||||
"´": { key: "Digit9", shift: true },
|
||||
"˝": { key: "Digit0", shift: true },
|
||||
"¨": { key: "Minus", shift: true },
|
||||
"¸": { key: "Equal", shift: true },
|
||||
"\\": { key: "KeyQ", AltGr: true },
|
||||
"|": { key: "KeyW", AltGr: true },
|
||||
"€": { key: "KeyE", AltGr: true },
|
||||
"÷": { key: "BracketLeft", AltGr: true },
|
||||
"×": { key: "BracketRight", AltGr: true },
|
||||
"[": { key: "KeyF", AltGr: true },
|
||||
"]": { key: "KeyG", AltGr: true },
|
||||
ł: { key: "KeyK", AltGr: true },
|
||||
Ł: { key: "KeyL", AltGr: true },
|
||||
ß: { key: "Quote", AltGr: true },
|
||||
"¤": { key: "Backslash", AltGr: true },
|
||||
"@": { key: "KeyV", AltGr: true },
|
||||
"{": { key: "KeyB", AltGr: true },
|
||||
"}": { key: "KeyN", AltGr: true },
|
||||
"§": { key: "KeyM", AltGr: true },
|
||||
// "<": { key: "Comma", AltGr: true }, // Can be typed in two different locations (`IntlBackslash`)
|
||||
// ">": { key: "Period", AltGr: true }, // Can be typed in two different locations (`IntlBackslash+Shift`)
|
||||
|
||||
" ": { key: "Space" },
|
||||
"\n": { key: "Enter" },
|
||||
Enter: { key: "Enter" },
|
||||
Escape: { key: "Escape" },
|
||||
Tab: { key: "Tab" },
|
||||
PrintScreen: { key: "Prt Sc" },
|
||||
SystemRequest: { key: "Prt Sc", shift: true },
|
||||
ScrollLock: { key: "ScrollLock" },
|
||||
Pause: { key: "Pause" },
|
||||
Break: { key: "Pause", shift: true },
|
||||
Insert: { key: "Insert" },
|
||||
Delete: { key: "Delete" },
|
||||
} as Record<string, KeyCombo>;
|
||||
|
||||
const sl_SI_keyDisplayMap = {
|
||||
...en_US.keyDisplayMap,
|
||||
KeyY: "z",
|
||||
KeyZ: "y",
|
||||
"(KeyY)": "Z",
|
||||
"(KeyZ)": "Y",
|
||||
} as Record<string, string>;
|
||||
|
||||
export const sl_SI: KeyboardLayout = {
|
||||
isoCode: isoCode,
|
||||
name: name,
|
||||
chars: chars,
|
||||
keyDisplayMap: sl_SI_keyDisplayMap,
|
||||
modifierDisplayMap: en_US.modifierDisplayMap,
|
||||
virtualKeyboard: en_US.virtualKeyboard,
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import { KeyCombo } from "../keyboardLayouts"
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
||||
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
|
||||
|
||||
export const name = "Svenska";
|
||||
const name = "Svenska";
|
||||
const isoCode = "sv-SE";
|
||||
|
||||
const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
|
||||
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
|
||||
@@ -162,3 +164,12 @@ export const chars = {
|
||||
Enter: { key: "Enter" },
|
||||
Tab: { key: "Tab" },
|
||||
} as Record<string, KeyCombo>;
|
||||
|
||||
export const sv_SE: KeyboardLayout = {
|
||||
isoCode,
|
||||
name,
|
||||
chars,
|
||||
keyDisplayMap,
|
||||
modifierDisplayMap,
|
||||
virtualKeyboard,
|
||||
};
|
||||
|
||||
@@ -105,6 +105,11 @@ export const keys = {
|
||||
Space: 0x2c,
|
||||
SystemRequest: 0x9a,
|
||||
Tab: 0x2b,
|
||||
Yen: 0x89,
|
||||
KeyRO: 0x87,
|
||||
Henkan: 0x8a,
|
||||
Muhenkan: 0x8b,
|
||||
KatakanaHiragana: 0x88,
|
||||
} as Record<string, number>;
|
||||
|
||||
export const modifiers = {
|
||||
@@ -228,6 +233,17 @@ export const keyDisplayMap: Record<string, string> = {
|
||||
|
||||
};
|
||||
|
||||
export const latchingKeys = ["CapsLock", "ScrollLock", "NumLock", "MetaLeft", "MetaRight", "Compose", "Kana"];
|
||||
|
||||
export function decodeModifiers(modifier: number) {
|
||||
return {
|
||||
isShiftActive: (modifier & (modifiers.ShiftLeft | modifiers.ShiftRight)) !== 0,
|
||||
isControlActive: (modifier & (modifiers.ControlLeft | modifiers.ControlRight)) !== 0,
|
||||
isAltActive: (modifier & (modifiers.AltLeft | modifiers.AltRight)) !== 0,
|
||||
isMetaActive: (modifier & (modifiers.MetaLeft | modifiers.MetaRight)) !== 0,
|
||||
};
|
||||
}
|
||||
|
||||
export const keyDisplayMap2: Record<string, string> = {
|
||||
...keyDisplayMap,
|
||||
...{
|
||||
|
||||
@@ -6,7 +6,7 @@ import { CloseOutlined } from '@ant-design/icons';
|
||||
import { useReactAt } from "i18n-auto-extractor/react";
|
||||
|
||||
import ScrollThrottlingSelect, { Option } from "@components/ScrollThrottlingSelect";
|
||||
import { layouts } from "@/keyboardLayouts";
|
||||
import { layouts, keyboards } from "@/keyboardLayouts";
|
||||
import { KeyboardLedSync, useSettingsStore } from "@/hooks/stores";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import notifications from "@/notifications";
|
||||
@@ -24,11 +24,21 @@ const KeyboardPanel: React.FC = () => {
|
||||
|
||||
const [layoutOptions, setLayoutOptions] = useState<Option[]>();
|
||||
const [maxShowCount, setMaxShowCount] = useState(3);
|
||||
|
||||
const layoutAbbrevMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
keyboards.forEach(kb => {
|
||||
const oldCode = kb.isoCode.replace("-", "_");
|
||||
map[oldCode] = oldCode;
|
||||
});
|
||||
return map;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const curLayoutOptions = (() => {
|
||||
const options = Object.entries(layouts).map(([code, language]) => ({
|
||||
value: code,
|
||||
label: language,
|
||||
label: `${language} (${layoutAbbrevMap[code] || code})`,
|
||||
}));
|
||||
|
||||
const currentLayout = keyboardLayout ?? "";
|
||||
@@ -47,7 +57,7 @@ const KeyboardPanel: React.FC = () => {
|
||||
return options;
|
||||
})();
|
||||
setLayoutOptions(curLayoutOptions);
|
||||
}, [layouts, keyboardLayout]);
|
||||
}, [layouts, keyboardLayout, layoutAbbrevMap]);
|
||||
|
||||
const safeKeyboardLayout = useMemo(() => {
|
||||
if (keyboardLayout && keyboardLayout.length > 0)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { useReactAt } from "i18n-auto-extractor/react";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import notifications from "@/notifications";
|
||||
import { dark_bg2_style, dark_bd_style, dark_line_style, dark_bg_style_fun } from "@/layout/theme_color";
|
||||
import { useThemeSettings } from "@routes/login_page/useLocalAuth";
|
||||
import { isMobile } from "react-device-detect";
|
||||
|
||||
const UsbStatusPanel: React.FC = () => {
|
||||
const { $at } = useReactAt();
|
||||
const { isDark } = useThemeSettings();
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
const handleReinitializeUsbGadget = useCallback(() => {
|
||||
send("reinitializeUsbGadget", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to reinitialize USB gadget: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
notifications.success("USB Gadget reinitialized successfully");
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className={`w-full h-full flex flex-col ${dark_bg_style_fun(isDark)} p-4`}>
|
||||
<div className={`flex flex-col w-full mx-auto ${isDark ? 'text-white' : 'text-black'}`}>
|
||||
<div
|
||||
className={`
|
||||
flex items-center justify-between py-4 w-full
|
||||
cursor-pointer transition-all duration-200 ease-in-out
|
||||
${isDark ? 'text-white' : 'text-black'}
|
||||
`}
|
||||
onClick={handleReinitializeUsbGadget}
|
||||
>
|
||||
<span className="font-normal tracking-[0.5px]">
|
||||
{$at("Reinitialize USB Gadget")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)' }}
|
||||
className={`p-1.5 w-[200px] rounded font-sans ${dark_bg2_style} border ${dark_bd_style}`}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'transparent',
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.85)' : 'rgba(0, 0, 0, 0.85)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
onClick={handleReinitializeUsbGadget}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.04)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "12px" }}>{$at("Reinitialize USB Gadget")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsbStatusPanel;
|
||||
@@ -23,6 +23,7 @@ import { LogDialog } from "@components/LogDialog";
|
||||
import { Dialog } from "@/layout/components_setting/access/auth";
|
||||
import AutoHeight from "@components/AutoHeight";
|
||||
import FirewallSettings from "./FirewallSettings";
|
||||
import WebRtcServersSettings from "./WebRtcServers";
|
||||
|
||||
export interface TailScaleResponse {
|
||||
state: string;
|
||||
@@ -70,6 +71,47 @@ export interface CloudflaredRunningResponse {
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
type ManagedVpnTool = "frpc" | "easytier" | "vnt" | "cloudflared";
|
||||
|
||||
export interface VpnToolSystemInfo {
|
||||
goos: string;
|
||||
goarch: string;
|
||||
uname_arch: string;
|
||||
arch_label: string;
|
||||
arch_keywords: string[];
|
||||
}
|
||||
|
||||
export interface VpnToolStatus {
|
||||
tool: string;
|
||||
installed: boolean;
|
||||
source: string;
|
||||
current_version: string;
|
||||
detected_version: string;
|
||||
managed_versions: string[];
|
||||
}
|
||||
|
||||
export interface VpnToolReleaseAsset {
|
||||
name: string;
|
||||
url: string;
|
||||
arch_match: boolean;
|
||||
}
|
||||
|
||||
export interface VpnToolRelease {
|
||||
tag_name: string;
|
||||
assets: VpnToolReleaseAsset[];
|
||||
}
|
||||
|
||||
export interface VpnToolInstallTask {
|
||||
tool: string;
|
||||
running: boolean;
|
||||
progress: number;
|
||||
message: string;
|
||||
logs: string[];
|
||||
error: string;
|
||||
version: string;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface WireguardStatus {
|
||||
running: boolean;
|
||||
}
|
||||
@@ -197,6 +239,20 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
||||
const [cloudflaredLog, setCloudflaredLog] = useState<string>("");
|
||||
const [showCloudflaredLogModal, setShowCloudflaredLogModal] = useState(false);
|
||||
|
||||
const [vpnToolSystemInfo, setVpnToolSystemInfo] = useState<VpnToolSystemInfo | null>(null);
|
||||
const [vpnToolStatusMap, setVpnToolStatusMap] = useState<Record<string, VpnToolStatus>>({});
|
||||
const [vpnToolReleasesMap, setVpnToolReleasesMap] = useState<Record<string, VpnToolRelease[]>>({});
|
||||
const [vpnToolSelectedVersionMap, setVpnToolSelectedVersionMap] = useState<Record<string, string>>({});
|
||||
const [vpnToolSelectedAssetMap, setVpnToolSelectedAssetMap] = useState<Record<string, string>>({});
|
||||
const [vpnToolBusyMap, setVpnToolBusyMap] = useState<Record<string, boolean>>({});
|
||||
const [vpnToolInstallTaskMap, setVpnToolInstallTaskMap] = useState<Record<string, VpnToolInstallTask>>({});
|
||||
const [vpnToolInstallPanelOpenMap, setVpnToolInstallPanelOpenMap] = useState<Record<string, boolean>>({
|
||||
frpc: false,
|
||||
easytier: false,
|
||||
vnt: false,
|
||||
cloudflared: false,
|
||||
});
|
||||
|
||||
|
||||
const getTLSState = useCallback(() => {
|
||||
send("getTLSState", {}, resp => {
|
||||
@@ -281,10 +337,156 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const managedTools: ManagedVpnTool[] = ["frpc", "easytier", "vnt", "cloudflared"];
|
||||
|
||||
const getVpnToolSystemInfo = useCallback(() => {
|
||||
send("getVpnToolSystemInfo", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to get system architecture info: ${resp.error.data || "Unknown error"}`);
|
||||
return;
|
||||
}
|
||||
setVpnToolSystemInfo(resp.result as VpnToolSystemInfo);
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const getVpnToolStatus = useCallback((tool: ManagedVpnTool) => {
|
||||
send("getVpnToolStatus", { tool }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to get ${tool} status: ${resp.error.data || "Unknown error"}`);
|
||||
return;
|
||||
}
|
||||
setVpnToolStatusMap(prev => ({ ...prev, [tool]: resp.result as VpnToolStatus }));
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const listVpnToolReleases = useCallback((tool: ManagedVpnTool) => {
|
||||
send("listVpnToolReleases", { tool }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to list ${tool} releases: ${resp.error.data || "Unknown error"}`);
|
||||
return;
|
||||
}
|
||||
const releases = resp.result as VpnToolRelease[];
|
||||
setVpnToolReleasesMap(prev => ({ ...prev, [tool]: releases }));
|
||||
if (!releases.length) {
|
||||
return;
|
||||
}
|
||||
setVpnToolSelectedVersionMap(prev => {
|
||||
if (prev[tool]) return prev;
|
||||
return { ...prev, [tool]: releases[0].tag_name };
|
||||
});
|
||||
setVpnToolSelectedAssetMap(prev => {
|
||||
if (prev[tool]) return prev;
|
||||
const firstRelease = releases[0];
|
||||
const preferred = firstRelease.assets.find(asset => asset.arch_match) || firstRelease.assets[0];
|
||||
return preferred ? { ...prev, [tool]: preferred.url } : prev;
|
||||
});
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const refreshVpnToolManager = useCallback((tool: ManagedVpnTool, withReleases: boolean) => {
|
||||
getVpnToolStatus(tool);
|
||||
if (withReleases) {
|
||||
listVpnToolReleases(tool);
|
||||
}
|
||||
}, [getVpnToolStatus, listVpnToolReleases]);
|
||||
|
||||
const getVpnToolInstallTask = useCallback((tool: ManagedVpnTool) => {
|
||||
send("getVpnToolInstallTask", { tool }, resp => {
|
||||
if ("error" in resp) {
|
||||
return;
|
||||
}
|
||||
setVpnToolInstallTaskMap(prev => ({ ...prev, [tool]: resp.result as VpnToolInstallTask }));
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const handleVpnToolVersionChange = useCallback((tool: ManagedVpnTool, version: string) => {
|
||||
setVpnToolSelectedVersionMap(prev => ({ ...prev, [tool]: version }));
|
||||
const releases = vpnToolReleasesMap[tool] || [];
|
||||
const selectedRelease = releases.find(release => release.tag_name === version);
|
||||
if (!selectedRelease || !selectedRelease.assets.length) {
|
||||
setVpnToolSelectedAssetMap(prev => ({ ...prev, [tool]: "" }));
|
||||
return;
|
||||
}
|
||||
const preferred = selectedRelease.assets.find(asset => asset.arch_match) || selectedRelease.assets[0];
|
||||
setVpnToolSelectedAssetMap(prev => ({ ...prev, [tool]: preferred?.url || "" }));
|
||||
}, [vpnToolReleasesMap]);
|
||||
|
||||
const handleInstallVpnTool = useCallback((tool: ManagedVpnTool) => {
|
||||
const version = vpnToolSelectedVersionMap[tool];
|
||||
const downloadURL = vpnToolSelectedAssetMap[tool];
|
||||
const releases = vpnToolReleasesMap[tool] || [];
|
||||
const release = releases.find(item => item.tag_name === version);
|
||||
const selectedAsset = release?.assets.find(asset => asset.url === downloadURL);
|
||||
if (!version || !downloadURL || !selectedAsset) {
|
||||
notifications.error(`Please select version and release asset for ${tool}`);
|
||||
return;
|
||||
}
|
||||
setVpnToolBusyMap(prev => ({ ...prev, [tool]: true }));
|
||||
send("startVpnToolInstall", { tool, version, assetName: selectedAsset.name, downloadURL }, resp => {
|
||||
setVpnToolBusyMap(prev => ({ ...prev, [tool]: false }));
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to install ${tool}: ${resp.error.data || "Unknown error"}`);
|
||||
return;
|
||||
}
|
||||
notifications.success(`${tool} install task started (${version})`);
|
||||
getVpnToolInstallTask(tool);
|
||||
});
|
||||
}, [send, vpnToolSelectedVersionMap, vpnToolSelectedAssetMap, vpnToolReleasesMap, getVpnToolInstallTask]);
|
||||
|
||||
const handleUninstallVpnToolVersion = useCallback((tool: ManagedVpnTool, version: string) => {
|
||||
if (!version) {
|
||||
notifications.error("Please select installed version");
|
||||
return;
|
||||
}
|
||||
setVpnToolBusyMap(prev => ({ ...prev, [tool]: true }));
|
||||
send("uninstallVpnToolVersion", { tool, version }, resp => {
|
||||
setVpnToolBusyMap(prev => ({ ...prev, [tool]: false }));
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to uninstall ${tool} version ${version}: ${resp.error.data || "Unknown error"}`);
|
||||
return;
|
||||
}
|
||||
notifications.success(`${tool} ${version} uninstalled`);
|
||||
refreshVpnToolManager(tool, true);
|
||||
});
|
||||
}, [send, refreshVpnToolManager]);
|
||||
|
||||
useEffect(() => {
|
||||
getCloudflaredStatus();
|
||||
}, [getCloudflaredStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
getVpnToolSystemInfo();
|
||||
managedTools.forEach(tool => {
|
||||
refreshVpnToolManager(tool, false);
|
||||
getVpnToolInstallTask(tool);
|
||||
});
|
||||
}, [getVpnToolSystemInfo, refreshVpnToolManager, getVpnToolInstallTask]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
managedTools.forEach(tool => {
|
||||
getVpnToolInstallTask(tool);
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [getVpnToolInstallTask]);
|
||||
|
||||
useEffect(() => {
|
||||
const tabToolMap: Partial<Record<string, ManagedVpnTool>> = {
|
||||
frp: "frpc",
|
||||
easytier: "easytier",
|
||||
vnt: "vnt",
|
||||
cloudflared: "cloudflared",
|
||||
};
|
||||
const tool = tabToolMap[activeTab];
|
||||
if (tool) {
|
||||
refreshVpnToolManager(tool, false);
|
||||
}
|
||||
if (activeTab === "cloudflared") {
|
||||
getCloudflaredStatus();
|
||||
}
|
||||
}, [activeTab, refreshVpnToolManager, getCloudflaredStatus]);
|
||||
|
||||
// Handle TLS mode change
|
||||
const handleTlsModeChange = (value: string) => {
|
||||
setTlsMode(value);
|
||||
@@ -869,6 +1071,154 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
||||
getVntConfigFile();
|
||||
}, [getVntStatus, getVntConfig]);
|
||||
|
||||
const renderVpnToolManager = (tool: ManagedVpnTool, label: string) => {
|
||||
const status = vpnToolStatusMap[tool];
|
||||
const releases = vpnToolReleasesMap[tool] || [];
|
||||
const selectedVersion = vpnToolSelectedVersionMap[tool] || "";
|
||||
const selectedRelease = releases.find(release => release.tag_name === selectedVersion);
|
||||
const selectedAsset = vpnToolSelectedAssetMap[tool] || "";
|
||||
const assets = selectedRelease?.assets || [];
|
||||
const busy = vpnToolBusyMap[tool] || false;
|
||||
const installTask = vpnToolInstallTaskMap[tool];
|
||||
const installRunning = installTask?.running === true;
|
||||
const installPanelOpen = vpnToolInstallPanelOpenMap[tool] || false;
|
||||
const isInstalled = status?.installed === true;
|
||||
const uninstallVersion = status?.current_version || status?.managed_versions?.[0] || "";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-700">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{label} {$at("Version Manager")}
|
||||
</span>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text={$at("Refresh")}
|
||||
onClick={() => refreshVpnToolManager(tool, installPanelOpen)}
|
||||
disabled={busy || installRunning}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text={installPanelOpen ? $at("Hide Install Actions") : $at("Show Install Actions")}
|
||||
onClick={() => {
|
||||
const nextOpen = !installPanelOpen;
|
||||
setVpnToolInstallPanelOpenMap(prev => ({ ...prev, [tool]: nextOpen }));
|
||||
if (nextOpen) {
|
||||
listVpnToolReleases(tool);
|
||||
}
|
||||
}}
|
||||
disabled={busy || installRunning}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{$at("Install Status")}: {status?.installed ? $at("Installed") : $at("Not Installed")}
|
||||
{status?.source ? ` (${status.source})` : ""}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{$at("Detected Version")}: {status?.detected_version || "-"}
|
||||
</div>
|
||||
|
||||
{installPanelOpen ? (
|
||||
<>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{$at("System Architecture")}: {vpnToolSystemInfo?.arch_label || "unknown"}
|
||||
{vpnToolSystemInfo?.arch_keywords?.length
|
||||
? ` (${vpnToolSystemInfo.arch_keywords.join(", ")})`
|
||||
: ""}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<SettingsItem title={$at("Release Version")} description="">
|
||||
<Select
|
||||
className={isMobile ? "!w-full !h-[36px]" : "!w-[28%] !h-[36px]"}
|
||||
value={selectedVersion}
|
||||
onChange={value => handleVpnToolVersionChange(tool, value)}
|
||||
options={releases.map(release => ({
|
||||
value: release.tag_name,
|
||||
label: release.tag_name,
|
||||
}))}
|
||||
/>
|
||||
</SettingsItem>
|
||||
<SettingsItem title={$at("Release Asset")} description="">
|
||||
<Select
|
||||
className={isMobile ? "!w-full !h-[36px]" : "!w-[60%] !h-[36px]"}
|
||||
value={selectedAsset}
|
||||
onChange={value => setVpnToolSelectedAssetMap(prev => ({ ...prev, [tool]: value }))}
|
||||
options={assets.map(asset => ({
|
||||
value: asset.url,
|
||||
label: `${asset.arch_match ? "[ARCH OK] " : ""}${asset.name}`,
|
||||
}))}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
{isInstalled ? (
|
||||
<>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text={$at("Update")}
|
||||
onClick={() => handleInstallVpnTool(tool)}
|
||||
disabled={busy || installRunning || !selectedVersion || !selectedAsset}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="danger"
|
||||
text={$at("Uninstall")}
|
||||
onClick={() => handleUninstallVpnToolVersion(tool, uninstallVersion)}
|
||||
disabled={busy || installRunning || !uninstallVersion}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text={$at("Install")}
|
||||
onClick={() => handleInstallVpnTool(tool)}
|
||||
disabled={busy || installRunning || !selectedVersion || !selectedAsset}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{installRunning && installTask ? (
|
||||
<div className="space-y-2 rounded border border-slate-200 p-2 dark:border-slate-700">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{$at("Install Task")}: {installTask.message || "-"}
|
||||
{installTask.error ? ` (${installTask.error})` : ""}
|
||||
</div>
|
||||
<div className="h-2 w-full rounded bg-slate-200 dark:bg-slate-700">
|
||||
<div
|
||||
className="h-2 rounded bg-blue-500 transition-all"
|
||||
style={{ width: `${Math.max(0, Math.min(100, Math.round((installTask.progress || 0) * 100)))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{$at("Progress")}: {Math.max(0, Math.min(100, Math.round((installTask.progress || 0) * 100)))}%
|
||||
</div>
|
||||
{!!installTask.logs?.length && (
|
||||
<pre className="max-h-36 overflow-auto rounded bg-slate-50 p-2 text-[11px] text-slate-600 dark:bg-slate-900 dark:text-slate-300">
|
||||
{installTask.logs.join("\n")}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{status?.managed_versions?.length ? (
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{$at("Installed Versions")}: {status.managed_versions.join(", ")}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -997,6 +1347,20 @@ 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">
|
||||
<SettingsSectionHeader
|
||||
title={$at("Remote")}
|
||||
@@ -1292,6 +1656,7 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
||||
<GridCard>
|
||||
<div className="p-4">
|
||||
<div className="space-y-4">
|
||||
{renderVpnToolManager("easytier", "EasyTier")}
|
||||
{ easyTierRunningStatus.running ? (
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
@@ -1409,6 +1774,7 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
||||
<GridCard>
|
||||
<div className="p-4">
|
||||
<div className="space-y-4">
|
||||
{renderVpnToolManager("vnt", "Vnt")}
|
||||
{ vntRunningStatus.running ? (
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
@@ -1621,6 +1987,7 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
||||
<GridCard>
|
||||
<div className="p-4">
|
||||
<div className="space-y-4">
|
||||
{renderVpnToolManager("cloudflared", "Cloudflare")}
|
||||
{cloudflaredRunningStatus.running ? (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
@@ -1669,6 +2036,7 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
||||
<GridCard>
|
||||
<div className="p-4">
|
||||
<div className="space-y-4">
|
||||
{renderVpnToolManager("frpc", "frpc")}
|
||||
<TextAreaWithLabel
|
||||
label={$at("Edit frpc.toml")}
|
||||
placeholder={$at("Enter frpc configuration")}
|
||||
|
||||
159
ui/src/layout/components_setting/access/WebRtcServers.tsx
Normal file
159
ui/src/layout/components_setting/access/WebRtcServers.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useReactAt } from "i18n-auto-extractor/react";
|
||||
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import notifications from "@/notifications";
|
||||
import { Button } from "@components/Button";
|
||||
import { InputField } from "@components/InputField";
|
||||
import { SettingsItem } from "@components/Settings/SettingsView";
|
||||
|
||||
interface TurnServer {
|
||||
url: string;
|
||||
username: string;
|
||||
credential: string;
|
||||
}
|
||||
|
||||
interface RtcServersConfig {
|
||||
stun: string;
|
||||
defaultStun: string;
|
||||
turnServers: TurnServer[] | null;
|
||||
}
|
||||
|
||||
export default function WebRtcServersSettings() {
|
||||
const { $at } = useReactAt();
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
const [stun, setStun] = useState("");
|
||||
const [defaultStun, setDefaultStun] = useState("");
|
||||
const [turnServers, setTurnServers] = useState<TurnServer[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
send("getRtcServersConfig", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`${$at("Failed to load WebRTC servers")}: ${resp.error.data || $at("Unknown error")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg = resp.result as RtcServersConfig;
|
||||
setDefaultStun(cfg.defaultStun);
|
||||
setStun(cfg.stun || cfg.defaultStun);
|
||||
setTurnServers(cfg.turnServers ?? []);
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const saveStun = (value: string) => {
|
||||
send("setStunServer", { stun: value }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`${$at("Failed to save STUN")}: ${resp.error.data || $at("Unknown error")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setStun(value);
|
||||
notifications.success($at("STUN server saved"));
|
||||
});
|
||||
};
|
||||
|
||||
const persistTurnServers = (servers: TurnServer[]) => {
|
||||
send("setTurnServers", { params: { servers } }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`${$at("Failed to save TURN")}: ${resp.error.data || $at("Unknown error")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setTurnServers(servers);
|
||||
notifications.success($at("TURN servers saved"));
|
||||
});
|
||||
};
|
||||
|
||||
const updateTurnRow = (index: number, field: keyof TurnServer, value: string) => {
|
||||
setTurnServers(prev => prev.map((server, i) => (i === index ? { ...server, [field]: value } : server)));
|
||||
};
|
||||
|
||||
const addTurnRow = () => {
|
||||
setTurnServers(prev => [...prev, { url: "", username: "", credential: "" }]);
|
||||
};
|
||||
|
||||
const deleteTurnRow = (index: number) => {
|
||||
persistTurnServers(turnServers.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingsItem
|
||||
title={$at("STUN Server")}
|
||||
description={$at("Public STUN server for NAT traversal")}
|
||||
noCol
|
||||
>
|
||||
<div className="flex w-full max-w-2xl flex-col gap-2 sm:flex-row">
|
||||
<InputField
|
||||
value={stun}
|
||||
onChange={e => setStun(e.target.value)}
|
||||
placeholder={defaultStun}
|
||||
className="min-w-0"
|
||||
/>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button size="MD" theme="primary" text={$at("Save")} onClick={() => saveStun(stun)} />
|
||||
<Button
|
||||
size="MD"
|
||||
theme="light"
|
||||
text={$at("Restore Default")}
|
||||
onClick={() => saveStun(defaultStun)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsItem>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-base font-semibold text-black dark:text-white">
|
||||
{$at("TURN Servers")}
|
||||
</div>
|
||||
<div className="text-sm text-slate-700 dark:text-slate-300">
|
||||
{$at("Used as relay when direct peer-to-peer connection fails")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{turnServers.length === 0 && (
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">{$at("No TURN servers configured")}</div>
|
||||
)}
|
||||
|
||||
{turnServers.map((server, index) => (
|
||||
<div key={index} className="grid gap-2 lg:grid-cols-[minmax(220px,1fr)_minmax(120px,180px)_minmax(120px,180px)_auto]">
|
||||
<InputField
|
||||
value={server.url}
|
||||
onChange={e => updateTurnRow(index, "url", e.target.value)}
|
||||
placeholder="turn:turn.example.com:3478"
|
||||
/>
|
||||
<InputField
|
||||
value={server.username}
|
||||
onChange={e => updateTurnRow(index, "username", e.target.value)}
|
||||
placeholder={$at("Username")}
|
||||
/>
|
||||
<InputField
|
||||
value={server.credential}
|
||||
onChange={e => updateTurnRow(index, "credential", e.target.value)}
|
||||
placeholder={$at("Credential")}
|
||||
type="password"
|
||||
/>
|
||||
<Button
|
||||
size="MD"
|
||||
theme="lightDanger"
|
||||
text={$at("Delete")}
|
||||
onClick={() => deleteTurnRow(index)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="MD" theme="light" text={$at("Add TURN Server")} onClick={addTurnRow} />
|
||||
<Button
|
||||
size="MD"
|
||||
theme="primary"
|
||||
text={$at("Save TURN Servers")}
|
||||
onClick={() => persistTurnServers(turnServers)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,9 @@ export default function SettingsAdvanced() {
|
||||
const [configContent, setConfigContent] = useState("");
|
||||
const [isSavingConfig, setIsSavingConfig] = useState(false);
|
||||
const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
|
||||
const [apiKey, setApiKey] = useState<string>("");
|
||||
const [apiKeyInput, setApiKeyInput] = useState<string>("");
|
||||
const [showApiKeyClearWarning, setShowApiKeyClearWarning] = useState(false);
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||
@@ -53,6 +56,13 @@ export default function SettingsAdvanced() {
|
||||
if ("error" in resp) return;
|
||||
setLocalLoopbackOnly(resp.result as boolean);
|
||||
});
|
||||
|
||||
send("getApiKey", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
const key = resp.result as string;
|
||||
setApiKey(key);
|
||||
setApiKeyInput(key);
|
||||
});
|
||||
}, [send, setDeveloperMode]);
|
||||
|
||||
const getUsbEmulationState = useCallback(() => {
|
||||
@@ -108,6 +118,54 @@ export default function SettingsAdvanced() {
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const handleUpdateApiKey = useCallback(() => {
|
||||
if (apiKeyInput === "") {
|
||||
setShowApiKeyClearWarning(true);
|
||||
return;
|
||||
}
|
||||
send("setApiKey", { apiKey: apiKeyInput }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to update API key: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setApiKey(apiKeyInput);
|
||||
notifications.success("API key updated successfully");
|
||||
});
|
||||
}, [send, apiKeyInput]);
|
||||
|
||||
const handleGenerateApiKey = useCallback(() => {
|
||||
send("generateApiKey", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to generate API key: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const newKey = resp.result as string;
|
||||
setApiKey(newKey);
|
||||
setApiKeyInput(newKey);
|
||||
notifications.success("New API key generated and saved");
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const confirmClearApiKey = useCallback(() => {
|
||||
send("generateApiKey", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to generate API key: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const newKey = resp.result as string;
|
||||
setApiKey(newKey);
|
||||
setApiKeyInput(newKey);
|
||||
notifications.success("New API key generated and saved");
|
||||
});
|
||||
setShowApiKeyClearWarning(false);
|
||||
}, [send]);
|
||||
|
||||
const handleUpdateSSHKey = useCallback(() => {
|
||||
send("setSSHKeyState", { sshKey }, resp => {
|
||||
if ("error" in resp) {
|
||||
@@ -240,6 +298,42 @@ export default function SettingsAdvanced() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOnDevice && (
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title={$at("API Key")}
|
||||
description={$at("API key for MCP and REST API authentication")}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<TextAreaWithLabel
|
||||
label={$at("API Key")}
|
||||
value={apiKeyInput || ""}
|
||||
rows={2}
|
||||
onChange={e => setApiKeyInput(e.target.value)}
|
||||
placeholder={$at("Enter API key or leave empty to auto-generate")}
|
||||
/>
|
||||
<p className="text-xs text-slate-600 dark:text-[#ffffff]">
|
||||
{$at("Used for authenticating MCP and REST API requests.")}
|
||||
</p>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<AntdButton
|
||||
type="primary"
|
||||
onClick={handleUpdateApiKey}
|
||||
className={isMobile?"w-full":""}
|
||||
>
|
||||
{$at("Save API Key")}
|
||||
</AntdButton>
|
||||
<AntdButton
|
||||
onClick={handleGenerateApiKey}
|
||||
className={isMobile?"w-full":""}
|
||||
>
|
||||
{$at("Generate New")}
|
||||
</AntdButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingsItem
|
||||
title={$at("Force HTTP Transmission")}
|
||||
badge="Experimental"
|
||||
@@ -379,6 +473,28 @@ export default function SettingsAdvanced() {
|
||||
onConfirm={confirmLoopbackModeEnable}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showApiKeyClearWarning}
|
||||
onClose={() => {
|
||||
setShowApiKeyClearWarning(false);
|
||||
}}
|
||||
title={$at("Clear API Key?")}
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
{$at("Setting the API key to empty will auto-generate a new random key.")}
|
||||
</p>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400 mt-2">
|
||||
{$at("Make sure to update your clients with the new key after saving.")}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
variant="warning"
|
||||
cancelText={$at("Cancel")}
|
||||
confirmText={$at("Generate New Key")}
|
||||
onConfirm={confirmClearApiKey}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showRebootConfirm}
|
||||
onClose={() => {
|
||||
|
||||
@@ -290,6 +290,7 @@ export default function SettingsHardware() {
|
||||
value={settings.ledGreenMode.toString()}
|
||||
className={`${isMobile?"w-full":"h-[36px] w-[22%]"}`}
|
||||
options={[
|
||||
{ value: "disabled", label: $at("Disabled") },
|
||||
{ value: "network-link", label: $at("network-link") },
|
||||
{ value: "network-tx", label: $at("network-tx") },
|
||||
{ value: "network-rx", label: $at("network-rx") },
|
||||
@@ -309,8 +310,9 @@ export default function SettingsHardware() {
|
||||
|
||||
<SelectMenuBasic
|
||||
value={settings.ledYellowMode.toString()}
|
||||
className={`${isMobile?"w-full":""}`}
|
||||
className={`${isMobile?"w-full":"h-[36px] w-[22%]"}`}
|
||||
options={[
|
||||
{ value: "disabled", label: $at("Disabled") },
|
||||
{ value: "network-link", label: $at("network-link") },
|
||||
{ value: "network-tx", label: $at("network-tx") },
|
||||
{ value: "network-rx", label: $at("network-rx") },
|
||||
|
||||
@@ -39,6 +39,9 @@ dayjs.extend(relativeTime);
|
||||
const defaultNetworkSettings: NetworkSettings = {
|
||||
hostname: "",
|
||||
domain: "",
|
||||
http_proxy: "",
|
||||
https_proxy: "",
|
||||
all_proxy: "",
|
||||
ipv4_mode: "unknown",
|
||||
ipv6_mode: "unknown",
|
||||
lldp_mode: "unknown",
|
||||
@@ -307,6 +310,10 @@ export default function SettingsNetwork() {
|
||||
setNetworkSettings({ ...networkSettings, domain: value });
|
||||
};
|
||||
|
||||
const handleProxyChange = (field: "http_proxy" | "https_proxy" | "all_proxy", value: string) => {
|
||||
setNetworkSettings({ ...networkSettings, [field]: value });
|
||||
};
|
||||
|
||||
const handleDomainOptionChange = (value: string) => {
|
||||
setSelectedDomainOption(value);
|
||||
if (value !== "custom") {
|
||||
@@ -537,6 +544,47 @@ export default function SettingsNetwork() {
|
||||
</SettingsItem>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title={$at("HTTP Proxy")}
|
||||
description={$at("Configure program HTTP proxy (optional)")}
|
||||
className={`${isMobile ? "w-full flex-col" : ""}`}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={networkSettings.http_proxy || ""}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
onChange={e => handleProxyChange("http_proxy", e.target.value)}
|
||||
className={isMobile ? "!w-full !h-[36px]" : "!w-[37%] !h-[36px]"}
|
||||
/>
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
title={$at("HTTPS Proxy")}
|
||||
description={$at("Configure program HTTPS proxy (optional)")}
|
||||
className={`${isMobile ? "w-full flex-col" : ""}`}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={networkSettings.https_proxy || ""}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
onChange={e => handleProxyChange("https_proxy", e.target.value)}
|
||||
className={isMobile ? "!w-full !h-[36px]" : "!w-[37%] !h-[36px]"}
|
||||
/>
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
title={$at("ALL Proxy")}
|
||||
description={$at("Configure program ALL proxy (optional)")}
|
||||
className={`${isMobile ? "w-full flex-col" : ""}`}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={networkSettings.all_proxy || ""}
|
||||
placeholder="socks5://127.0.0.1:7890"
|
||||
onChange={e => handleProxyChange("all_proxy", e.target.value)}
|
||||
className={isMobile ? "!w-full !h-[36px]" : "!w-[37%] !h-[36px]"}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
<AntdButton
|
||||
type="primary"
|
||||
disabled={
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CheckCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { isMobile } from "react-device-detect";
|
||||
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { useBootStorageType } from "@/hooks/useBootStorage";
|
||||
import { SettingsPageHeader } from "@components/Settings/SettingsPageheader";
|
||||
import { SettingsItem } from "@components/Settings/SettingsView";
|
||||
import Card from "@components/Card";
|
||||
@@ -21,6 +22,15 @@ export interface SystemVersionInfo {
|
||||
remote?: { appVersion: string; systemVersion: string };
|
||||
systemUpdateAvailable: boolean;
|
||||
appUpdateAvailable: boolean;
|
||||
appSignatureMissing?: boolean;
|
||||
systemSignatureMissing?: boolean;
|
||||
appSignatureAbsent?: boolean;
|
||||
systemSignatureAbsent?: boolean;
|
||||
appSignatureInvalid?: boolean;
|
||||
systemSignatureInvalid?: boolean;
|
||||
appNoPublicKey?: boolean;
|
||||
systemNoPublicKey?: boolean;
|
||||
signatureVerified?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -34,7 +44,16 @@ export default function SettingsVersion() {
|
||||
const [autoUpdate, setAutoUpdate] = useState(true);
|
||||
const { $at } = useReactAt();
|
||||
const { setModalView, otaState } = useUpdateStore();
|
||||
const { bootStorageType } = useBootStorageType();
|
||||
const isBootFromSD = bootStorageType === "sd";
|
||||
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false);
|
||||
const [signatureStatusLoading, setSignatureStatusLoading] = useState(true);
|
||||
const [signatureStatus, setSignatureStatus] = useState<{
|
||||
appSignatureAbsent: boolean;
|
||||
appSignatureInvalid: boolean;
|
||||
appNoPublicKey: boolean;
|
||||
signatureVerified: boolean;
|
||||
} | null>(null);
|
||||
const updatePanelRef = useRef<HTMLDivElement | null>(null);
|
||||
const [updateSource, setUpdateSource] = useState("github");
|
||||
const [customUpdateBaseURL, setCustomUpdateBaseURL] = useState("");
|
||||
@@ -79,6 +98,23 @@ export default function SettingsVersion() {
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSignatureStatusLoading(true);
|
||||
send("getSelfSignatureStatus", {}, resp => {
|
||||
setSignatureStatusLoading(false);
|
||||
if ("error" in resp) return;
|
||||
const sigStatus = resp.result as {
|
||||
appSignatureAbsent: boolean;
|
||||
appSignatureInvalid: boolean;
|
||||
appNoPublicKey: boolean;
|
||||
};
|
||||
const hasSigFiles = !sigStatus.appSignatureAbsent;
|
||||
const noPublicKey = sigStatus.appNoPublicKey;
|
||||
const signatureVerified = hasSigFiles && !noPublicKey && !sigStatus.appSignatureInvalid;
|
||||
setSignatureStatus({ ...sigStatus, signatureVerified });
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const applyUpdateSource = useCallback(
|
||||
(source: string) => {
|
||||
send("setUpdateSource", { source }, resp => {
|
||||
@@ -202,19 +238,28 @@ export default function SettingsVersion() {
|
||||
}
|
||||
/>
|
||||
|
||||
<UpdateSourceSettings
|
||||
updateSource={updateSource}
|
||||
onUpdateSourceChange={applyUpdateSource}
|
||||
customUpdateBaseURL={customUpdateBaseURL}
|
||||
onCustomUpdateBaseURLChange={setCustomUpdateBaseURL}
|
||||
onSaveCustomUpdateBaseURL={applyCustomUpdateBaseURL}
|
||||
<SignatureStatusCard
|
||||
signatureStatus={signatureStatus}
|
||||
signatureStatusLoading={signatureStatusLoading}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-start">
|
||||
<AntdButton type="primary" onClick={checkForUpdates} className={isMobile ? "w-full" : ""}>
|
||||
{$at("Check for Updates")}
|
||||
</AntdButton>
|
||||
</div>
|
||||
{!isBootFromSD && (
|
||||
<>
|
||||
<UpdateSourceSettings
|
||||
updateSource={updateSource}
|
||||
onUpdateSourceChange={applyUpdateSource}
|
||||
customUpdateBaseURL={customUpdateBaseURL}
|
||||
onCustomUpdateBaseURLChange={setCustomUpdateBaseURL}
|
||||
onSaveCustomUpdateBaseURL={applyCustomUpdateBaseURL}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-start">
|
||||
<AntdButton type="primary" onClick={checkForUpdates} className={isMobile ? "w-full" : ""}>
|
||||
{$at("Check for Updates")}
|
||||
</AntdButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="hidden">
|
||||
<SettingsItem
|
||||
@@ -375,6 +420,7 @@ function UpdateContent({
|
||||
<SystemUpToDateState
|
||||
checkUpdate={() => setModalView("loading")}
|
||||
onClose={onClose}
|
||||
versionInfo={versionInfo}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -400,23 +446,58 @@ function LoadingState({
|
||||
|
||||
const getVersionInfo = useCallback(() => {
|
||||
return new Promise<SystemVersionInfo>((resolve, reject) => {
|
||||
send("getUpdateStatus", {}, async resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to check for updates: ${resp.error}`);
|
||||
reject(new Error("Failed to check for updates"));
|
||||
} else {
|
||||
const result = resp.result as SystemVersionInfo;
|
||||
setAppVersion(result.local.appVersion);
|
||||
setSystemVersion(result.local.systemVersion);
|
||||
|
||||
if (result.error) {
|
||||
notifications.error(`Failed to check for updates: ${result.error}`);
|
||||
reject(new Error("Failed to check for updates"));
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
}
|
||||
});
|
||||
Promise.all([
|
||||
new Promise<SystemVersionInfo>((res, rej) => {
|
||||
send("getUpdateStatus", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to check for updates: ${resp.error}`);
|
||||
rej(new Error("Failed to check for updates"));
|
||||
} else {
|
||||
const result = resp.result as SystemVersionInfo;
|
||||
setAppVersion(result.local.appVersion);
|
||||
setSystemVersion(result.local.systemVersion);
|
||||
if (result.error) {
|
||||
notifications.error(`Failed to check for updates: ${result.error}`);
|
||||
rej(new Error("Failed to check for updates"));
|
||||
} else {
|
||||
res(result);
|
||||
}
|
||||
}
|
||||
});
|
||||
}),
|
||||
new Promise<SystemVersionInfo>((res, rej) => {
|
||||
send("getSelfSignatureStatus", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
rej(new Error("Failed to get signature status"));
|
||||
} else {
|
||||
const sigStatus = resp.result as {
|
||||
appSignatureAbsent: boolean;
|
||||
appSignatureInvalid: boolean;
|
||||
appNoPublicKey: boolean;
|
||||
};
|
||||
const hasSigFiles = !sigStatus.appSignatureAbsent;
|
||||
const signatureVerified = hasSigFiles && !sigStatus.appNoPublicKey && !sigStatus.appSignatureInvalid;
|
||||
const partial: Partial<SystemVersionInfo> = {
|
||||
appSignatureAbsent: sigStatus.appSignatureAbsent,
|
||||
appSignatureInvalid: sigStatus.appSignatureInvalid,
|
||||
appNoPublicKey: sigStatus.appNoPublicKey,
|
||||
signatureVerified,
|
||||
};
|
||||
res(partial as SystemVersionInfo);
|
||||
}
|
||||
});
|
||||
}),
|
||||
])
|
||||
.then(([versionResult, sigResult]) => {
|
||||
resolve({
|
||||
...versionResult,
|
||||
appSignatureAbsent: sigResult.appSignatureAbsent,
|
||||
appSignatureInvalid: sigResult.appSignatureInvalid,
|
||||
appNoPublicKey: sigResult.appNoPublicKey,
|
||||
signatureVerified: sigResult.signatureVerified,
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}, [send, setAppVersion, setSystemVersion]);
|
||||
|
||||
@@ -662,11 +743,50 @@ function UpdatingDeviceState({
|
||||
function SystemUpToDateState({
|
||||
checkUpdate,
|
||||
onClose,
|
||||
versionInfo,
|
||||
}: {
|
||||
checkUpdate: () => void;
|
||||
onClose: () => void;
|
||||
versionInfo: SystemVersionInfo | null;
|
||||
}) {
|
||||
const { $at } = useReactAt();
|
||||
const [send] = useJsonRpc();
|
||||
const [sigUpdateLoading, setSigUpdateLoading] = useState(false);
|
||||
const [sigUpdateResult, setSigUpdateResult] = useState<string | null>(null);
|
||||
const hasAbsentSig = versionInfo?.appSignatureAbsent;
|
||||
const hasInvalidSig = versionInfo?.appSignatureInvalid;
|
||||
const hasNoPublicKey = versionInfo?.appNoPublicKey;
|
||||
|
||||
const handleUpdateSignatures = useCallback(() => {
|
||||
setSigUpdateLoading(true);
|
||||
setSigUpdateResult(null);
|
||||
send("updateSignatures", {}, resp => {
|
||||
setSigUpdateLoading(false);
|
||||
if ("error" in resp) {
|
||||
setSigUpdateResult(`Failed: ${resp.error.data || "Unknown error"}`);
|
||||
notifications.error(`Signature update failed: ${resp.error.data || "Unknown error"}`);
|
||||
} else {
|
||||
const result = resp.result as {
|
||||
appSignatureUpdated: boolean;
|
||||
systemSignatureUpdated: boolean;
|
||||
appSignatureValid: boolean;
|
||||
systemSignatureValid: boolean;
|
||||
error?: string;
|
||||
};
|
||||
if (result.error) {
|
||||
setSigUpdateResult(`Failed: ${result.error}`);
|
||||
notifications.error(`Signature update failed: ${result.error}`);
|
||||
} else {
|
||||
const parts: string[] = [];
|
||||
if (result.appSignatureUpdated) parts.push("App signature updated");
|
||||
if (result.systemSignatureUpdated) parts.push("System signature updated");
|
||||
setSigUpdateResult(parts.join(", ") || "No signatures to update");
|
||||
notifications.success("Signatures updated successfully");
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<div className="text-left">
|
||||
@@ -677,6 +797,39 @@ function SystemUpToDateState({
|
||||
{$at("Your system is running the latest version. No updates are currently available.")}
|
||||
</p>
|
||||
|
||||
{hasAbsentSig && (
|
||||
<div className="mt-4 rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
|
||||
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
{$at("Missing Signature File")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
|
||||
{$at("The current firmware is missing signature files. Integrity cannot be fully verified.")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasInvalidSig && (
|
||||
<div className="mt-4 rounded-md border border-red-500 bg-red-50 p-3 dark:border-red-600 dark:bg-red-900/30">
|
||||
<p className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
{$at("Signature Verification Failed")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-red-700 dark:text-red-300">
|
||||
{$at("The signature file exists but does not match the firmware. This may indicate tampering.")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasNoPublicKey && (
|
||||
<div className="mt-4 rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
|
||||
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
{$at("No Embedded Public Key")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
|
||||
{$at("This build does not have an OTA public key embedded. Signature verification is unavailable.")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex gap-x-2">
|
||||
<AntdButton type="primary" onClick={checkUpdate}>
|
||||
{$at("Check Again")}
|
||||
@@ -685,6 +838,32 @@ function SystemUpToDateState({
|
||||
{$at("Back")}
|
||||
</AntdButton>
|
||||
</div>
|
||||
|
||||
<p className="text-base font-semibold text-black dark:text-white">
|
||||
{$at("Update Signatures")}
|
||||
</p>
|
||||
<p className="mb-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
{$at("Update the signature of kvm_app to the latest version. If the current version is not up to date, signature verification will fail.")}
|
||||
</p>
|
||||
|
||||
{sigUpdateResult && (
|
||||
<div className="rounded-md border border-blue-500 bg-blue-50 p-3 dark:border-blue-600 dark:bg-blue-900/30">
|
||||
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">
|
||||
{$at("Signature Update Result")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-blue-700 dark:text-blue-300">
|
||||
{sigUpdateResult}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-start gap-x-2">
|
||||
<AntdButton type="primary" loading={sigUpdateLoading} onClick={handleUpdateSignatures}>
|
||||
{$at("Update")}
|
||||
</AntdButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -708,6 +887,40 @@ function UpdateAvailableState({
|
||||
onSaveUpdateDownloadProxy: () => void;
|
||||
}) {
|
||||
const { $at } = useReactAt();
|
||||
const [send] = useJsonRpc();
|
||||
const [sigUpdateLoading, setSigUpdateLoading] = useState(false);
|
||||
const [sigUpdateResult, setSigUpdateResult] = useState<string | null>(null);
|
||||
|
||||
const handleUpdateSignatures = useCallback(() => {
|
||||
setSigUpdateLoading(true);
|
||||
setSigUpdateResult(null);
|
||||
send("updateSignatures", {}, resp => {
|
||||
setSigUpdateLoading(false);
|
||||
if ("error" in resp) {
|
||||
setSigUpdateResult(`Failed: ${resp.error.data || "Unknown error"}`);
|
||||
notifications.error(`Signature update failed: ${resp.error.data || "Unknown error"}`);
|
||||
} else {
|
||||
const result = resp.result as {
|
||||
appSignatureUpdated: boolean;
|
||||
systemSignatureUpdated: boolean;
|
||||
appSignatureValid: boolean;
|
||||
systemSignatureValid: boolean;
|
||||
error?: string;
|
||||
};
|
||||
if (result.error) {
|
||||
setSigUpdateResult(`Failed: ${result.error}`);
|
||||
notifications.error(`Signature update failed: ${result.error}`);
|
||||
} else {
|
||||
const parts: string[] = [];
|
||||
if (result.appSignatureUpdated) parts.push("App signature updated");
|
||||
if (result.systemSignatureUpdated) parts.push("System signature updated");
|
||||
setSigUpdateResult(parts.join(", ") || "No signatures to update");
|
||||
notifications.success("Signatures updated successfully");
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<div className="w-full space-y-4">
|
||||
@@ -757,6 +970,32 @@ function UpdateAvailableState({
|
||||
</AntdButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-base font-semibold text-black dark:text-white">
|
||||
{$at("Update Signatures")}
|
||||
</p>
|
||||
<p className="mb-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
{$at("Update the signature of kvm_app to the latest version. If the current version is not up to date, signature verification will fail.")}
|
||||
</p>
|
||||
|
||||
{sigUpdateResult && (
|
||||
<div className="rounded-md border border-blue-500 bg-blue-50 p-3 dark:border-blue-600 dark:bg-blue-900/30">
|
||||
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">
|
||||
{$at("Signature Update Result")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-blue-700 dark:text-blue-300">
|
||||
{sigUpdateResult}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-start gap-x-2">
|
||||
<AntdButton type="primary" loading={sigUpdateLoading} onClick={handleUpdateSignatures}>
|
||||
{$at("Update")}
|
||||
</AntdButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -818,3 +1057,101 @@ function UpdateErrorState({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignatureStatusCard({
|
||||
signatureStatus,
|
||||
signatureStatusLoading,
|
||||
}: {
|
||||
signatureStatus: {
|
||||
appSignatureAbsent: boolean;
|
||||
appSignatureInvalid: boolean;
|
||||
appNoPublicKey: boolean;
|
||||
signatureVerified: boolean;
|
||||
} | null;
|
||||
signatureStatusLoading: boolean;
|
||||
}) {
|
||||
const { $at } = useReactAt();
|
||||
if (signatureStatusLoading) {
|
||||
return (
|
||||
<div className="rounded-md border border-slate-300 bg-slate-50 p-3 dark:border-slate-600 dark:bg-slate-900/30">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<LoadingSpinner className="h-4 w-4 text-[rgba(22,152,217,1)] dark:text-[rgba(45,106,229,1)]" />
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
|
||||
{$at("Verifying signature...")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-600 dark:text-slate-300">
|
||||
{$at("Please wait while verifying firmware signature.")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!signatureStatus) {
|
||||
return (
|
||||
<div className="rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
|
||||
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
{$at("Signature Status Unavailable")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
|
||||
{$at("Unable to retrieve signature verification status.")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (signatureStatus.signatureVerified) {
|
||||
return (
|
||||
<div className="rounded-md border border-green-500 bg-green-50 p-3 dark:border-green-600 dark:bg-green-900/30">
|
||||
<p className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
<CheckCircleIcon className="inline h-4 w-4 mr-1" />
|
||||
{$at("Signature Verified")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-green-700 dark:text-green-300">
|
||||
{$at("Firmware signature has been verified and is valid.")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (signatureStatus.appSignatureAbsent) {
|
||||
return (
|
||||
<div className="rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
|
||||
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
{$at("Missing Signature File")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
|
||||
{$at("The current firmware is missing signature files. Integrity cannot be fully verified.")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (signatureStatus.appSignatureInvalid) {
|
||||
return (
|
||||
<div className="rounded-md border border-red-500 bg-red-50 p-3 dark:border-red-600 dark:bg-red-900/30">
|
||||
<p className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
{$at("Signature Verification Failed")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-red-700 dark:text-red-300">
|
||||
{$at("The signature file exists but does not match the firmware. This may indicate tampering.")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (signatureStatus.appNoPublicKey) {
|
||||
return (
|
||||
<div className="rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
|
||||
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
{$at("No Embedded Public Key")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
|
||||
{$at("This build does not have an OTA public key embedded. Signature verification is unavailable.")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
|
||||
import { useClose } from "@headlessui/react";
|
||||
import { Checkbox, Button } from "antd";
|
||||
import { Checkbox, Button, Input } from "antd";
|
||||
import { useReactAt } from "i18n-auto-extractor/react";
|
||||
import { isMobile } from "react-device-detect";
|
||||
|
||||
import { TextAreaWithLabel } from "@components/TextArea";
|
||||
import { SettingsItem } from "@components/Settings/SettingsView";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { useHidStore, useRTCStore, useUiStore, useSettingsStore } from "@/hooks/stores";
|
||||
import { useHidStore, useRTCStore, useUiStore, useSettingsStore, useVideoStore } from "@/hooks/stores";
|
||||
import { keys, modifiers } from "@/keyboardMappings";
|
||||
import { layouts, chars } from "@/keyboardLayouts";
|
||||
import notifications from "@/notifications";
|
||||
import { eventMatchesShortcut, shortcutFromKeyboardEvent } from "@/utils/shortcuts";
|
||||
|
||||
const hidKeyboardPayload = (keys: number[], modifier: number) => {
|
||||
return { keys, modifier };
|
||||
@@ -28,15 +30,24 @@ export default function Clipboard() {
|
||||
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||
const setSidebarView = useUiStore(state => state.setSidebarView);
|
||||
const toggleTopBarView = useUiStore(state => state.toggleTopBarView);
|
||||
const isOcrMode = useUiStore(state => state.isOcrMode);
|
||||
const setOcrMode = useUiStore(state => state.setOcrMode);
|
||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||
const videoWidth = useVideoStore(state => state.width);
|
||||
const videoHeight = useVideoStore(state => state.height);
|
||||
const [send] = useJsonRpc();
|
||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||
|
||||
const [invalidChars, setInvalidChars] = useState<string[]>([]);
|
||||
const close = useClose();
|
||||
const overrideCtrlV = useSettingsStore(state => state.overrideCtrlV);
|
||||
const setOverrideCtrlV = useSettingsStore(state => state.setOverrideCtrlV);
|
||||
const [pasteBuffer, setPasteBuffer] = useState<string>("");
|
||||
const pasteShortcutEnabled = useSettingsStore(state => state.pasteShortcutEnabled);
|
||||
const setPasteShortcutEnabled = useSettingsStore(state => state.setPasteShortcutEnabled);
|
||||
const pasteShortcut = useSettingsStore(state => state.pasteShortcut);
|
||||
const setPasteShortcut = useSettingsStore(state => state.setPasteShortcut);
|
||||
const ocrShortcutEnabled = useSettingsStore(state => state.ocrShortcutEnabled);
|
||||
const setOcrShortcutEnabled = useSettingsStore(state => state.setOcrShortcutEnabled);
|
||||
const ocrShortcut = useSettingsStore(state => state.ocrShortcut);
|
||||
const setOcrShortcut = useSettingsStore(state => state.setOcrShortcut);
|
||||
const [readyToRender, setReadyToRender] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -127,7 +138,6 @@ export default function Clipboard() {
|
||||
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, safeKeyboardLayout]);
|
||||
|
||||
const handleTextSend = useCallback(async (text: string) => {
|
||||
setPasteBuffer(text);
|
||||
const segInvalid = [
|
||||
...new Set(
|
||||
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
|
||||
@@ -192,12 +202,35 @@ export default function Clipboard() {
|
||||
}, [handleTextSend]);
|
||||
|
||||
useEffect(() => {
|
||||
// When overrideCtrlV is true, we want to focus the container div to capture paste events
|
||||
// When it is false, we want to focus the textarea if it exists
|
||||
if (!overrideCtrlV && TextAreaRef.current) {
|
||||
if (readyToRender && TextAreaRef.current) {
|
||||
TextAreaRef.current.focus();
|
||||
}
|
||||
}, [readyToRender, overrideCtrlV]);
|
||||
}, [readyToRender]);
|
||||
|
||||
const handleShortcutInput = useCallback(
|
||||
(setter: (shortcut: string) => void) => (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const shortcut = shortcutFromKeyboardEvent(e.nativeEvent);
|
||||
if (!shortcut) return;
|
||||
setter(shortcut);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleOpenOcr = useCallback(() => {
|
||||
if (videoWidth === 0 || videoHeight === 0) {
|
||||
notifications.error($at("No video signal"));
|
||||
return;
|
||||
}
|
||||
setOcrMode(!isOcrMode);
|
||||
close();
|
||||
if (isMobile) {
|
||||
toggleTopBarView("ClipboardMobile");
|
||||
} else {
|
||||
setSidebarView(null);
|
||||
}
|
||||
}, [videoWidth, videoHeight, $at, setOcrMode, isOcrMode, close, toggleTopBarView, setSidebarView]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-3" >
|
||||
@@ -205,32 +238,42 @@ export default function Clipboard() {
|
||||
<div className="h-full space-y-4">
|
||||
<div className="space-y-4">
|
||||
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={overrideCtrlV}
|
||||
onChange={e => setOverrideCtrlV(e.target.checked)}
|
||||
>
|
||||
{$at("Use Ctrl+V to paste clipboard to remote")}
|
||||
</Checkbox>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_140px] items-center gap-2 sm:grid-cols-[minmax(0,1fr)_180px]">
|
||||
<Checkbox
|
||||
className="min-w-0"
|
||||
checked={pasteShortcutEnabled}
|
||||
onChange={e => setPasteShortcutEnabled(e.target.checked)}
|
||||
>
|
||||
<span className="whitespace-normal break-words">
|
||||
{$at("Enable paste shortcut")}
|
||||
</span>
|
||||
</Checkbox>
|
||||
<Input
|
||||
size="small"
|
||||
value={pasteShortcut}
|
||||
onKeyDown={handleShortcutInput(setPasteShortcut)}
|
||||
onChange={() => void 0}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full px-1 outline-none"
|
||||
tabIndex={overrideCtrlV ? 0 : -1}
|
||||
tabIndex={pasteShortcutEnabled ? 0 : -1}
|
||||
ref={(el) => {
|
||||
if (el && overrideCtrlV && readyToRender) {
|
||||
if (el && pasteShortcutEnabled && readyToRender) {
|
||||
el.focus();
|
||||
}
|
||||
}}
|
||||
onKeyUp={e => e.stopPropagation()}
|
||||
onKeyDown={e => {
|
||||
e.stopPropagation();
|
||||
if (overrideCtrlV && (e.key.toLowerCase() === "v" || e.code === "KeyV") && (e.metaKey || e.ctrlKey)) {
|
||||
if (pasteShortcutEnabled && eventMatchesShortcut(e.nativeEvent, pasteShortcut)) {
|
||||
e.preventDefault();
|
||||
readClipboardToBufferAndSend();
|
||||
}
|
||||
}}
|
||||
onPaste={e => {
|
||||
if (overrideCtrlV) {
|
||||
if (pasteShortcutEnabled) {
|
||||
e.preventDefault();
|
||||
const txt = e.clipboardData?.getData("text") || "";
|
||||
if (txt) {
|
||||
@@ -240,7 +283,7 @@ export default function Clipboard() {
|
||||
}
|
||||
}
|
||||
}}>
|
||||
{!overrideCtrlV && readyToRender && <TextAreaWithLabel
|
||||
{readyToRender && <TextAreaWithLabel
|
||||
ref={TextAreaRef}
|
||||
label={$at("Copy text from your client to the remote host")}
|
||||
rows={4}
|
||||
@@ -295,32 +338,50 @@ export default function Clipboard() {
|
||||
|
||||
</div>
|
||||
<div
|
||||
className="flex animate-fadeIn opacity-0 items-center justify-start gap-x-2"
|
||||
className="flex animate-fadeIn opacity-0 flex-col gap-y-2"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.2s",
|
||||
}}
|
||||
>
|
||||
|
||||
<Button
|
||||
|
||||
type="primary"
|
||||
className={isMobile ? "w-[49%]" : ""}
|
||||
className="w-full"
|
||||
onClick={onConfirmPaste}
|
||||
>
|
||||
{$at("Confirm paste")}</Button>
|
||||
<Button
|
||||
className={isMobile ? "w-[49%]" : ""}
|
||||
onClick={() => {
|
||||
onCancelPasteMode();
|
||||
close();
|
||||
if(isMobile){
|
||||
toggleTopBarView("ClipboardMobile");
|
||||
}else{
|
||||
setSidebarView(null)
|
||||
}
|
||||
}}
|
||||
>{$at("Cancel")}</Button>
|
||||
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_140px] items-center gap-2 sm:grid-cols-[minmax(0,1fr)_180px]">
|
||||
<Checkbox
|
||||
className="min-w-0"
|
||||
checked={ocrShortcutEnabled}
|
||||
onChange={e => setOcrShortcutEnabled(e.target.checked)}
|
||||
>
|
||||
<span className="whitespace-normal break-words">
|
||||
{$at("Enable OCR shortcut")}
|
||||
</span>
|
||||
</Checkbox>
|
||||
<Input
|
||||
size="small"
|
||||
value={ocrShortcut}
|
||||
onKeyDown={handleShortcutInput(setOcrShortcut)}
|
||||
onChange={() => void 0}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsItem
|
||||
title={$at("OCR")}
|
||||
description={$at("Open OCR selection mode on the video area")}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
className={`${isMobile ? "w-full" : ""}`}
|
||||
onClick={handleOpenOcr}
|
||||
>
|
||||
{$at("Open")}
|
||||
</Button>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -117,16 +117,17 @@ const SettingsMacrosEdit: React.FC<MenuComponentProps> = ({ onMenuSelect,macroId
|
||||
disabled={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
<MacroForm
|
||||
initialData={macro}
|
||||
onSubmit={handleUpdateMacro}
|
||||
onCancel={() => {
|
||||
console.log("MacroForm onCancel")
|
||||
onMenuSelect("index");
|
||||
}}
|
||||
isSubmitting={isUpdating}
|
||||
|
||||
/>
|
||||
<div onKeyUp={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}>
|
||||
<MacroForm
|
||||
initialData={macro}
|
||||
onSubmit={handleUpdateMacro}
|
||||
onCancel={() => {
|
||||
console.log("MacroForm onCancel")
|
||||
onMenuSelect("index");
|
||||
}}
|
||||
isSubmitting={isUpdating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDeleteConfirm}
|
||||
@@ -145,4 +146,4 @@ const SettingsMacrosEdit: React.FC<MenuComponentProps> = ({ onMenuSelect,macroId
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default SettingsMacrosEdit;
|
||||
export default SettingsMacrosEdit;
|
||||
|
||||
@@ -67,6 +67,8 @@ interface StorageFilePageProps {
|
||||
onUnmountSDStorage?: () => void;
|
||||
onFormatSDStorage?: () => void;
|
||||
onMountSDStorage?: () => void;
|
||||
fsType?: 'exfat' | 'fat32';
|
||||
onFsTypeChange?: (value: 'exfat' | 'fat32') => void;
|
||||
}
|
||||
|
||||
export const FileManager: React.FC<StorageFilePageProps> = ({
|
||||
@@ -79,6 +81,8 @@ export const FileManager: React.FC<StorageFilePageProps> = ({
|
||||
onResetSDStorage,
|
||||
onUnmountSDStorage,
|
||||
onFormatSDStorage,
|
||||
fsType,
|
||||
onFsTypeChange,
|
||||
}) => {
|
||||
const { $at } = useReactAt();
|
||||
|
||||
@@ -251,15 +255,28 @@ export const FileManager: React.FC<StorageFilePageProps> = ({
|
||||
</p>
|
||||
{sdMountStatus !== "none" && (
|
||||
<div className="pt-2">
|
||||
<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")}
|
||||
</AntdButton>
|
||||
<div className="w-full space-y-2">
|
||||
<p className="w-full text-left text-xs text-slate-700 dark:text-slate-300">
|
||||
{$at("Choose the file system for MicroSD formatting")}
|
||||
</p>
|
||||
<select
|
||||
value={fsType || "fat32"}
|
||||
onChange={(e) => onFsTypeChange?.(e.target.value as 'exfat' | 'fat32')}
|
||||
style={{ width: "100%", padding: "8px", borderRadius: "4px" }}
|
||||
>
|
||||
<option value="fat32">FAT32</option>
|
||||
<option value="exfat">exFAT</option>
|
||||
</select>
|
||||
<AntdButton
|
||||
disabled={loading}
|
||||
danger={true}
|
||||
type="primary"
|
||||
onClick={handleFormatWrapper}
|
||||
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
|
||||
>
|
||||
{$at("Format MicroSD Card")} ({(fsType || "fat32")})
|
||||
</AntdButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -318,6 +335,8 @@ export const FileManager: React.FC<StorageFilePageProps> = ({
|
||||
onUnmountSDStorage={handleUnmountWrapper}
|
||||
onFormatSDStorage={handleFormatWrapper}
|
||||
syncStorage={syncStorage}
|
||||
fsType={fsType}
|
||||
onFsTypeChange={onFsTypeChange}
|
||||
/>
|
||||
|
||||
{uploadFile ? (
|
||||
@@ -504,29 +523,46 @@ interface ActionButtonsSectionProps {
|
||||
onUnmountSDStorage?: () => void;
|
||||
onFormatSDStorage?: () => void;
|
||||
syncStorage: () => void;
|
||||
fsType?: 'exfat' | 'fat32';
|
||||
onFsTypeChange?: (value: 'exfat' | 'fat32') => void;
|
||||
}
|
||||
|
||||
const ActionButtonsSection: React.FC<ActionButtonsSectionProps> = ({
|
||||
mediaType,
|
||||
loading,
|
||||
showSDManagement,
|
||||
onUnmountSDStorage,
|
||||
onFormatSDStorage,
|
||||
}) => {
|
||||
mediaType,
|
||||
loading,
|
||||
showSDManagement,
|
||||
onUnmountSDStorage,
|
||||
onFormatSDStorage,
|
||||
fsType,
|
||||
onFsTypeChange,
|
||||
}) => {
|
||||
const { $at } = useReactAt();
|
||||
|
||||
if (mediaType === "sd" && showSDManagement) {
|
||||
return (
|
||||
<div className="flex animate-fadeIn justify-between gap-2 opacity-0"
|
||||
<div className="animate-fadeIn space-y-2 opacity-0"
|
||||
style={{ animationDuration: "0.7s", animationDelay: "0.25s" }}
|
||||
>
|
||||
<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")}</AntdButton>
|
||||
<div className="w-full space-y-2">
|
||||
<p className="w-full text-left text-xs text-slate-700 dark:text-slate-300">
|
||||
{$at("Choose the file system for MicroSD formatting")}
|
||||
</p>
|
||||
<select
|
||||
value={fsType || "fat32"}
|
||||
onChange={(e) => onFsTypeChange?.(e.target.value as 'exfat' | 'fat32')}
|
||||
style={{ width: "100%", padding: "8px", borderRadius: "4px" }}
|
||||
>
|
||||
<option value="fat32">FAT32</option>
|
||||
<option value="exfat">exFAT</option>
|
||||
</select>
|
||||
<AntdButton
|
||||
disabled={loading}
|
||||
type="primary"
|
||||
danger={true}
|
||||
onClick={onFormatSDStorage}
|
||||
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
|
||||
>{$at("Format MicroSD Card")} ({(fsType || "fat32")})</AntdButton>
|
||||
</div>
|
||||
<AntdButton
|
||||
disabled={loading}
|
||||
type="primary"
|
||||
|
||||
@@ -9,6 +9,7 @@ export default function SDFilePage() {
|
||||
const { $at } = useReactAt();
|
||||
const [send] = useJsonRpc();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fsType, setFsType] = useState<'exfat' | 'fat32'>('fat32');
|
||||
|
||||
const handleResetSDStorage = async () => {
|
||||
setLoading(true);
|
||||
@@ -37,11 +38,11 @@ export default function SDFilePage() {
|
||||
};
|
||||
|
||||
const handleFormatSDStorage = async () => {
|
||||
if (!window.confirm($at("Formatting the SD card will erase all data. Continue?"))) {
|
||||
if (!window.confirm($at(`Formatting the SD card as ${fsType.toUpperCase()} will erase all data. Continue?`))) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
send("formatSDStorage", { confirm: true }, res => {
|
||||
send("formatSDStorage", { confirm: true, fsType }, res => {
|
||||
if ("error" in res) {
|
||||
notifications.error(res.error.data || res.error.message);
|
||||
setLoading(false);
|
||||
@@ -54,17 +55,21 @@ export default function SDFilePage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<FileManager
|
||||
mediaType="sd"
|
||||
returnTo="/sd-files"
|
||||
listFilesMethod="listSDStorageFiles"
|
||||
getSpaceMethod="getSDStorageSpace"
|
||||
deleteFileMethod="deleteSDStorageFile"
|
||||
downloadUrlPrefix="/storage/sd-download"
|
||||
showSDManagement={true}
|
||||
onResetSDStorage={handleResetSDStorage}
|
||||
onUnmountSDStorage={handleUnmountSDStorage}
|
||||
onFormatSDStorage={handleFormatSDStorage}
|
||||
/>
|
||||
<>
|
||||
<FileManager
|
||||
mediaType="sd"
|
||||
returnTo="/sd-files"
|
||||
listFilesMethod="listSDStorageFiles"
|
||||
getSpaceMethod="getSDStorageSpace"
|
||||
deleteFileMethod="deleteSDStorageFile"
|
||||
downloadUrlPrefix="/storage/sd-download"
|
||||
showSDManagement={true}
|
||||
onResetSDStorage={handleResetSDStorage}
|
||||
onUnmountSDStorage={handleUnmountSDStorage}
|
||||
onFormatSDStorage={handleFormatSDStorage}
|
||||
fsType={fsType}
|
||||
onFsTypeChange={setFsType}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button as AntdButton , Slider , Checkbox, Select } from "antd";
|
||||
import { Button as AntdButton , Slider , Checkbox, Select, Modal, InputNumber, Tabs, Typography } from "antd";
|
||||
import { useReactAt } from "i18n-auto-extractor/react";
|
||||
import { isMobile } from "react-device-detect";
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SettingsItem, SettingsItemNew } from "@components/Settings/SettingsView
|
||||
|
||||
import notifications from "../../../notifications";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
|
||||
|
||||
@@ -45,6 +46,133 @@ const streamQualityOptions = [
|
||||
{ value: "0.1", label: "Low" },
|
||||
];
|
||||
|
||||
type RcQpParams = {
|
||||
s32FirstFrameStartQp: number;
|
||||
u32StepQp: number;
|
||||
u32MinQp: number;
|
||||
u32MaxQp: number;
|
||||
u32MinIQp: number;
|
||||
u32MaxIQp: number;
|
||||
s32DeltIpQp: number;
|
||||
s32MaxReEncodeTimes: number;
|
||||
u32FrmMaxQp: number;
|
||||
u32FrmMinQp: number;
|
||||
u32FrmMinIQp: number;
|
||||
u32FrmMaxIQp: number;
|
||||
u32MotionStaticSwitchFrmQp: number;
|
||||
};
|
||||
|
||||
type VideoRcConfig = {
|
||||
h264: RcQpParams;
|
||||
h265: RcQpParams;
|
||||
};
|
||||
|
||||
type RcSliderValues = {
|
||||
stepQp: number;
|
||||
minQp: number;
|
||||
minIQp: number;
|
||||
deltIpQp: number;
|
||||
};
|
||||
|
||||
type RcSliderState = {
|
||||
h264: RcSliderValues;
|
||||
h265: RcSliderValues;
|
||||
};
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
|
||||
const sliderValueToNumber = (value: number | number[]) =>
|
||||
Array.isArray(value) ? value[0] : value;
|
||||
|
||||
const DEFAULT_RC_CODEC: RcQpParams = {
|
||||
s32FirstFrameStartQp: 0,
|
||||
u32StepQp: 48,
|
||||
u32MinQp: 48,
|
||||
u32MaxQp: 51,
|
||||
u32MinIQp: 48,
|
||||
u32MaxIQp: 51,
|
||||
s32DeltIpQp: 7,
|
||||
s32MaxReEncodeTimes: 2,
|
||||
u32FrmMaxQp: 51,
|
||||
u32FrmMinQp: 48,
|
||||
u32FrmMinIQp: 51,
|
||||
u32FrmMaxIQp: 48,
|
||||
u32MotionStaticSwitchFrmQp: 50,
|
||||
};
|
||||
|
||||
const DEFAULT_VIDEO_RC_CONFIG: VideoRcConfig = {
|
||||
h264: { ...DEFAULT_RC_CODEC },
|
||||
h265: { ...DEFAULT_RC_CODEC },
|
||||
};
|
||||
|
||||
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null;
|
||||
|
||||
const toVideoRcConfigOrDefault = (value: unknown): VideoRcConfig => {
|
||||
if (!isObjectRecord(value)) return DEFAULT_VIDEO_RC_CONFIG;
|
||||
if (!("h264" in value) || !("h265" in value)) return DEFAULT_VIDEO_RC_CONFIG;
|
||||
return value as VideoRcConfig;
|
||||
};
|
||||
|
||||
const RC_LIMITS = {
|
||||
stepQp: { min: 1, max: 51 },
|
||||
maxQp: { min: 1, max: 51 },
|
||||
maxIQp: { min: 1, max: 51 },
|
||||
deltIpQp: { min: -7, max: 7 },
|
||||
maxReEncodeTimes: { min: 0, max: 3 },
|
||||
frmQp: { min: 1, max: 51 },
|
||||
};
|
||||
|
||||
const normalizeRcCodec = (codec: RcQpParams): RcQpParams => {
|
||||
const maxQp = clamp(Number(codec.u32MaxQp), RC_LIMITS.maxQp.min, RC_LIMITS.maxQp.max);
|
||||
const maxIQp = clamp(Number(codec.u32MaxIQp), RC_LIMITS.maxIQp.min, RC_LIMITS.maxIQp.max);
|
||||
const minQp = clamp(Number(codec.u32MinQp), RC_LIMITS.maxQp.min, maxQp);
|
||||
const minIQp = clamp(Number(codec.u32MinIQp), RC_LIMITS.maxIQp.min, maxIQp);
|
||||
|
||||
return {
|
||||
...codec,
|
||||
s32FirstFrameStartQp: clamp(Number(codec.s32FirstFrameStartQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max),
|
||||
u32StepQp: clamp(Number(codec.u32StepQp), RC_LIMITS.stepQp.min, RC_LIMITS.stepQp.max),
|
||||
u32MaxQp: maxQp,
|
||||
u32MinQp: minQp,
|
||||
u32MaxIQp: maxIQp,
|
||||
u32MinIQp: minIQp,
|
||||
s32DeltIpQp: clamp(Number(codec.s32DeltIpQp), RC_LIMITS.deltIpQp.min, RC_LIMITS.deltIpQp.max),
|
||||
s32MaxReEncodeTimes: clamp(
|
||||
Number(codec.s32MaxReEncodeTimes),
|
||||
RC_LIMITS.maxReEncodeTimes.min,
|
||||
RC_LIMITS.maxReEncodeTimes.max,
|
||||
),
|
||||
u32FrmMaxQp: clamp(Number(codec.u32FrmMaxQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max),
|
||||
u32FrmMinQp: clamp(Number(codec.u32FrmMinQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max),
|
||||
u32FrmMinIQp: clamp(Number(codec.u32FrmMinIQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max),
|
||||
u32FrmMaxIQp: clamp(Number(codec.u32FrmMaxIQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max),
|
||||
u32MotionStaticSwitchFrmQp: clamp(
|
||||
Number(codec.u32MotionStaticSwitchFrmQp),
|
||||
RC_LIMITS.frmQp.min,
|
||||
RC_LIMITS.frmQp.max,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeVideoRcConfig = (config: VideoRcConfig): VideoRcConfig => ({
|
||||
h264: normalizeRcCodec(config.h264),
|
||||
h265: normalizeRcCodec(config.h265),
|
||||
});
|
||||
|
||||
const sliderValuesFromCodec = (codec: RcQpParams): RcSliderValues => ({
|
||||
stepQp: clamp(Number(codec.u32StepQp), 1, 50),
|
||||
minQp: clamp(Number(codec.u32MinQp), 1, 50),
|
||||
minIQp: clamp(Number(codec.u32MinIQp), 1, 50),
|
||||
deltIpQp: clamp(Number(codec.s32DeltIpQp), -7, 7),
|
||||
});
|
||||
|
||||
const sliderStateFromConfig = (config: VideoRcConfig): RcSliderState => ({
|
||||
h264: sliderValuesFromCodec(config.h264),
|
||||
h265: sliderValuesFromCodec(config.h265),
|
||||
});
|
||||
|
||||
export default function SettingsVideoSide() {
|
||||
const { $at } = useReactAt();
|
||||
const [send] = useJsonRpc();
|
||||
@@ -54,6 +182,12 @@ export default function SettingsVideoSide() {
|
||||
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
|
||||
const [edid, setEdid] = useState<string | null>(null);
|
||||
const [forceHpd, setForceHpd] = useState(false);
|
||||
const [videoRcConfig, setVideoRcConfig] = useState<VideoRcConfig>(DEFAULT_VIDEO_RC_CONFIG);
|
||||
const [rcSliderValues, setRcSliderValues] = useState<RcSliderState>(
|
||||
sliderStateFromConfig(DEFAULT_VIDEO_RC_CONFIG),
|
||||
);
|
||||
const [showRcAdvanced, setShowRcAdvanced] = useState(false);
|
||||
const [rcDraftConfig, setRcDraftConfig] = useState<VideoRcConfig | null>(null);
|
||||
|
||||
// Video enhancement settings from store
|
||||
const videoSaturation = useSettingsStore(state => state.videoSaturation);
|
||||
@@ -63,6 +197,129 @@ export default function SettingsVideoSide() {
|
||||
const videoContrast = useSettingsStore(state => state.videoContrast);
|
||||
const setVideoContrast = useSettingsStore(state => state.setVideoContrast);
|
||||
|
||||
const currentCodec: "h264" | "h265" = streamEncodecType === "hevc" ? "h265" : "h264";
|
||||
const currentSliders = rcSliderValues[currentCodec];
|
||||
|
||||
const applySliderToCodec = (codec: RcQpParams, sliders: RcSliderValues): RcQpParams => ({
|
||||
...codec,
|
||||
u32StepQp: sliders.stepQp,
|
||||
u32MinQp: sliders.minQp,
|
||||
u32MinIQp: sliders.minIQp,
|
||||
s32DeltIpQp: sliders.deltIpQp,
|
||||
});
|
||||
|
||||
const applyRcBasicConfig = () => {
|
||||
const nextRcConfig = normalizeVideoRcConfig({
|
||||
...videoRcConfig,
|
||||
[currentCodec]: applySliderToCodec(videoRcConfig[currentCodec], currentSliders),
|
||||
});
|
||||
send("setVideoRc", { params: nextRcConfig }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to set video RC: ${resp.error.data || "Unknown error"}`);
|
||||
return;
|
||||
}
|
||||
setVideoRcConfig(nextRcConfig);
|
||||
setRcDraftConfig(nextRcConfig);
|
||||
setRcSliderValues(sliderStateFromConfig(nextRcConfig));
|
||||
notifications.success("Video RC updated");
|
||||
});
|
||||
};
|
||||
|
||||
const updateRcDraftField = (
|
||||
codec: "h264" | "h265",
|
||||
field: keyof RcQpParams,
|
||||
value: number,
|
||||
) => {
|
||||
setRcDraftConfig(prev => {
|
||||
if (!prev) return prev;
|
||||
const nextCodec = { ...prev[codec], [field]: value };
|
||||
const normalizedCodec = normalizeRcCodec(nextCodec);
|
||||
return {
|
||||
...prev,
|
||||
[codec]: normalizedCodec,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const openRcAdvancedModal = () => {
|
||||
const nextDraft = normalizeVideoRcConfig({
|
||||
...videoRcConfig,
|
||||
[currentCodec]: applySliderToCodec(videoRcConfig[currentCodec], currentSliders),
|
||||
});
|
||||
setRcDraftConfig(nextDraft);
|
||||
setShowRcAdvanced(true);
|
||||
};
|
||||
|
||||
const applyRcAdvancedConfig = () => {
|
||||
if (!rcDraftConfig) {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeVideoRcConfig(rcDraftConfig);
|
||||
send("setVideoRc", { params: normalized }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to set video RC: ${resp.error.data || "Unknown error"}`);
|
||||
return;
|
||||
}
|
||||
setVideoRcConfig(normalized);
|
||||
setRcDraftConfig(normalized);
|
||||
setRcSliderValues(sliderStateFromConfig(normalized));
|
||||
notifications.success("Video RC updated");
|
||||
setShowRcAdvanced(false);
|
||||
});
|
||||
};
|
||||
|
||||
const renderRcAdvancedForm = (codec: "h264" | "h265") => {
|
||||
const current = rcDraftConfig?.[codec];
|
||||
if (!current) return null;
|
||||
|
||||
const fields: Array<{
|
||||
key: keyof RcQpParams;
|
||||
label: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}> = [
|
||||
{ key: "s32FirstFrameStartQp", label: "FirstFrameStartQp", min: 1, max: 51 },
|
||||
{ key: "u32StepQp", label: "StepQp", min: 1, max: 51 },
|
||||
{ key: "u32MaxQp", label: "MaxQp", min: 1, max: 51 },
|
||||
{ key: "u32MinQp", label: "MinQp", min: 1, max: Number(current.u32MaxQp) || 51 },
|
||||
{ key: "u32MaxIQp", label: "MaxIQp", min: 1, max: 51 },
|
||||
{ key: "u32MinIQp", label: "MinIQp", min: 1, max: Number(current.u32MaxIQp) || 51 },
|
||||
{ key: "s32DeltIpQp", label: "DeltIpQp", min: -7, max: 7 },
|
||||
{ key: "s32MaxReEncodeTimes", label: "MaxReEncodeTimes", min: 0, max: 3 },
|
||||
{ key: "u32FrmMaxQp", label: "FrmMaxQp", min: 1, max: 51 },
|
||||
{ key: "u32FrmMinQp", label: "FrmMinQp", min: 1, max: 51 },
|
||||
{ key: "u32FrmMaxIQp", label: "FrmMaxIQp", min: 1, max: 51 },
|
||||
{ key: "u32FrmMinIQp", label: "FrmMinIQp", min: 1, max: 51 },
|
||||
{ key: "u32MotionStaticSwitchFrmQp", label: "MotionStaticSwitchFrmQp", min: 1, max: 51 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{fields.map(field => (
|
||||
<div key={`${codec}-${field.key}`} className="flex items-center justify-between gap-3">
|
||||
<Text className="text-xs">{field.label}</Text>
|
||||
<InputNumber
|
||||
size="small"
|
||||
value={current[field.key]}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={1}
|
||||
style={{ width: 140 }}
|
||||
onChange={val => {
|
||||
if (typeof val !== "number") return;
|
||||
const safeValue =
|
||||
field.min !== undefined && field.max !== undefined
|
||||
? clamp(Number(val), field.min, field.max)
|
||||
: Number(val);
|
||||
updateRcDraftField(codec, field.key, safeValue);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
send("getNpuAppStatus", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
@@ -79,6 +336,20 @@ export default function SettingsVideoSide() {
|
||||
setStreamQuality(String(resp.result));
|
||||
});
|
||||
|
||||
send("getVideoRc", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to get video RC: ${resp.error.data || "Unknown error"}`);
|
||||
const fallbackRc = normalizeVideoRcConfig(DEFAULT_VIDEO_RC_CONFIG);
|
||||
setVideoRcConfig(fallbackRc);
|
||||
setRcSliderValues(sliderStateFromConfig(fallbackRc));
|
||||
return;
|
||||
}
|
||||
|
||||
const rc = normalizeVideoRcConfig(toVideoRcConfigOrDefault(resp.result));
|
||||
setVideoRcConfig(rc);
|
||||
setRcSliderValues(sliderStateFromConfig(rc));
|
||||
});
|
||||
|
||||
send("getEDID", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`);
|
||||
@@ -218,6 +489,116 @@ export default function SettingsVideoSide() {
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
title={$at("RC Control")}
|
||||
description={$at("Adjust rate control QP settings for better balance between quality and bitrate")}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<SettingsItemNew
|
||||
title={$at("StepQp")}
|
||||
description={String(currentSliders.stepQp)}
|
||||
className={"flex-col w-full h-[40px]"}
|
||||
>
|
||||
<Slider
|
||||
min={1}
|
||||
max={51}
|
||||
step={1}
|
||||
value={currentSliders.stepQp}
|
||||
onChange={value => {
|
||||
const nextStepQp = clamp(sliderValueToNumber(value), 1, 50);
|
||||
setRcSliderValues(prev => ({
|
||||
...prev,
|
||||
[currentCodec]: {
|
||||
...prev[currentCodec],
|
||||
stepQp: nextStepQp,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
className={"w-full"}
|
||||
/>
|
||||
</SettingsItemNew>
|
||||
|
||||
<SettingsItemNew
|
||||
title={$at("MinQp")}
|
||||
description={String(currentSliders.minQp)}
|
||||
className={"flex-col w-full h-[40px]"}
|
||||
>
|
||||
<Slider
|
||||
min={1}
|
||||
max={50}
|
||||
step={1}
|
||||
value={currentSliders.minQp}
|
||||
onChange={value => {
|
||||
const nextMinQp = clamp(sliderValueToNumber(value), 1, 50);
|
||||
setRcSliderValues(prev => ({
|
||||
...prev,
|
||||
[currentCodec]: {
|
||||
...prev[currentCodec],
|
||||
minQp: nextMinQp,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
className={"w-full"}
|
||||
/>
|
||||
</SettingsItemNew>
|
||||
|
||||
<SettingsItemNew
|
||||
title={$at("MinIQp")}
|
||||
description={String(currentSliders.minIQp)}
|
||||
className={"flex-col w-full h-[40px]"}
|
||||
>
|
||||
<Slider
|
||||
min={1}
|
||||
max={50}
|
||||
step={1}
|
||||
value={currentSliders.minIQp}
|
||||
onChange={value => {
|
||||
const nextMinIQp = clamp(sliderValueToNumber(value), 1, 50);
|
||||
setRcSliderValues(prev => ({
|
||||
...prev,
|
||||
[currentCodec]: {
|
||||
...prev[currentCodec],
|
||||
minIQp: nextMinIQp,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
className={"w-full"}
|
||||
/>
|
||||
</SettingsItemNew>
|
||||
|
||||
<SettingsItemNew
|
||||
title={$at("DetlpQp")}
|
||||
description={String(currentSliders.deltIpQp)}
|
||||
className={"flex-col w-full h-[40px]"}
|
||||
>
|
||||
<Slider
|
||||
min={-7}
|
||||
max={7}
|
||||
step={1}
|
||||
value={currentSliders.deltIpQp}
|
||||
onChange={value => {
|
||||
const nextDeltIpQp = clamp(sliderValueToNumber(value), -7, 7);
|
||||
setRcSliderValues(prev => ({
|
||||
...prev,
|
||||
[currentCodec]: {
|
||||
...prev[currentCodec],
|
||||
deltIpQp: nextDeltIpQp,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
className={"w-full"}
|
||||
/>
|
||||
</SettingsItemNew>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<AntdButton onClick={openRcAdvancedModal}>
|
||||
{$at("Advanced")}
|
||||
</AntdButton>
|
||||
<AntdButton type="primary" onClick={applyRcBasicConfig}>
|
||||
{$at("Apply")}
|
||||
</AntdButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsItem
|
||||
title={$at("NPU Application")}
|
||||
@@ -362,25 +743,7 @@ export default function SettingsVideoSide() {
|
||||
}}
|
||||
options={[...edids, { value: "custom", label: "Custom" }]}
|
||||
/>
|
||||
{/* options={[...edids, { value: "custom", label: "Custom" }]}*/}
|
||||
</SettingsItem>
|
||||
{/*<SelectMenuBasic*/}
|
||||
{/* size="SM"*/}
|
||||
{/* label=""*/}
|
||||
{/* fullWidth*/}
|
||||
{/* value={customEdidValue ? "custom" : edid || "asd"}*/}
|
||||
{/* onChange={e => {*/}
|
||||
{/* console.log(e.target.value)*/}
|
||||
{/* if (e.target.value === "custom") {*/}
|
||||
{/* setEdid("custom");*/}
|
||||
{/* setCustomEdidValue("");*/}
|
||||
{/* } else {*/}
|
||||
{/* setCustomEdidValue(null);*/}
|
||||
{/* handleEDIDChange(e.target.value as string);*/}
|
||||
{/* }*/}
|
||||
{/* }}*/}
|
||||
{/* options={[...edids, { value: "custom", label: "Custom" }]}*/}
|
||||
{/*/>*/}
|
||||
|
||||
{customEdidValue !== null && (
|
||||
<>
|
||||
@@ -418,6 +781,39 @@ export default function SettingsVideoSide() {
|
||||
)}
|
||||
</div>
|
||||
<div className={"h-[10vh]"}></div>
|
||||
|
||||
<Modal
|
||||
title={
|
||||
<Text strong style={{ fontSize: "16px" }}>
|
||||
{$at("RC Advanced Config")}
|
||||
</Text>
|
||||
}
|
||||
open={showRcAdvanced}
|
||||
onCancel={() => setShowRcAdvanced(false)}
|
||||
onOk={applyRcAdvancedConfig}
|
||||
okText={$at("Apply")}
|
||||
cancelText={$at("Cancel")}
|
||||
maskClosable={true}
|
||||
keyboard={true}
|
||||
width={520}
|
||||
styles={{
|
||||
body: {
|
||||
padding: "20px 24px",
|
||||
},
|
||||
header: {
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
padding: "16px 24px",
|
||||
marginBottom: 0,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
items={[
|
||||
{ key: "h264", label: "H264", children: renderRcAdvancedForm("h264") },
|
||||
{ key: "h265", label: "H265", children: renderRcAdvancedForm("h265") },
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ export default function ImageManager({
|
||||
const [sdMountStatus, setSDMountStatus] = useState<"ok" | "none" | "fail" | null>(storageType === 'sd' ? null : 'ok');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadFile, setUploadFile] = useState<string | null>(null);
|
||||
const [fsType, setFsType] = useState<'exfat' | 'fat32'>('fat32');
|
||||
const filesPerPage = 5;
|
||||
|
||||
const percentageUsed = useMemo(() => {
|
||||
@@ -170,7 +171,7 @@ export default function ImageManager({
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
send("formatSDStorage", { confirm: true }, res => {
|
||||
send("formatSDStorage", { confirm: true, fsType }, res => {
|
||||
if ("error" in res) {
|
||||
notifications.error(res.error.data || res.error.message);
|
||||
setLoading(false);
|
||||
@@ -347,15 +348,28 @@ export default function ImageManager({
|
||||
</p>
|
||||
{sdMountStatus !== "none" && (
|
||||
<div className="pt-2">
|
||||
<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")}
|
||||
</AntdButton>
|
||||
<div className="mx-auto w-full max-w-[360px] space-y-2">
|
||||
<p className="w-full text-left text-xs text-slate-700 dark:text-slate-300">
|
||||
{$at("Choose the file system for MicroSD formatting")}
|
||||
</p>
|
||||
<select
|
||||
value={fsType}
|
||||
onChange={(e) => setFsType(e.target.value as 'exfat' | 'fat32')}
|
||||
style={{ width: "100%", padding: "8px", borderRadius: "4px" }}
|
||||
>
|
||||
<option value="fat32">FAT32</option>
|
||||
<option value="exfat">exFAT</option>
|
||||
</select>
|
||||
<AntdButton
|
||||
disabled={loading}
|
||||
danger={true}
|
||||
type="primary"
|
||||
onClick={handleFormatSDStorage}
|
||||
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
|
||||
>
|
||||
{$at("Format MicroSD Card")} ({fsType})
|
||||
</AntdButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -493,16 +507,29 @@ export default function ImageManager({
|
||||
</div>
|
||||
|
||||
{unmountApi && storageType === 'sd' && (
|
||||
<div className="flex animate-fadeIn justify-between gap-2 opacity-0"
|
||||
<div className="animate-fadeIn space-y-2 opacity-0"
|
||||
style={{ animationDuration: "0.7s", animationDelay: "0.25s" }}
|
||||
>
|
||||
<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")}</AntdButton>
|
||||
<div className="w-full space-y-2">
|
||||
<p className="w-full text-left text-xs text-slate-700 dark:text-slate-300">
|
||||
{$at("Choose the file system for MicroSD formatting")}
|
||||
</p>
|
||||
<select
|
||||
value={fsType}
|
||||
onChange={(e) => setFsType(e.target.value as 'exfat' | 'fat32')}
|
||||
style={{ width: "100%", padding: "8px", borderRadius: "4px" }}
|
||||
>
|
||||
<option value="fat32">FAT32</option>
|
||||
<option value="exfat">exFAT</option>
|
||||
</select>
|
||||
<AntdButton
|
||||
disabled={loading}
|
||||
type="primary"
|
||||
danger={true}
|
||||
onClick={handleFormatSDStorage}
|
||||
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
|
||||
>{$at("Format MicroSD Card")} ({fsType})</AntdButton>
|
||||
</div>
|
||||
<AntdButton
|
||||
disabled={loading}
|
||||
type="primary"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button as AntdButton, Typography } from "antd";
|
||||
import { useReactAt } from "i18n-auto-extractor/react";
|
||||
import KeyboardSVG from "@assets/second/keyboard.svg?react";
|
||||
@@ -22,10 +22,10 @@ import {
|
||||
useRTCStore,
|
||||
useSettingsStore,
|
||||
useUiStore,
|
||||
useVideoStore,
|
||||
useVpnStore,
|
||||
} from "@/hooks/stores";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { keyboards } from "@/keyboardLayouts";
|
||||
import {
|
||||
button_primary_color,
|
||||
dark_bd_style,
|
||||
@@ -48,10 +48,13 @@ const views = [
|
||||
export default function BottomBarMobile() {
|
||||
const { $at } = useReactAt();
|
||||
const keyboardLedState = useHidStore(state => state.keyboardLedState);
|
||||
const videoSize = useVideoStore(
|
||||
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
|
||||
);
|
||||
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const layoutAbbrev = useMemo(() => {
|
||||
if (!keyboardLayout) return "en_US";
|
||||
return keyboardLayout;
|
||||
}, [keyboardLayout]);
|
||||
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
|
||||
const forceHttp = useSettingsStore(state => state.forceHttp);
|
||||
@@ -79,10 +82,7 @@ export default function BottomBarMobile() {
|
||||
|
||||
stats?.forEach(report => {
|
||||
if (report.type === "inbound-rtp") {
|
||||
if(report.framesPerSecond){
|
||||
setFps(report.framesPerSecond)
|
||||
}
|
||||
|
||||
setFps(report.framesPerSecond ?? 0);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -117,7 +117,7 @@ export default function BottomBarMobile() {
|
||||
{ icon: isDark ? Video2SVG : VideoSVG, label: $at("video") },
|
||||
{ icon: StateSvg, label: $at("status") },
|
||||
];
|
||||
const videoButtonLabel = forceHttp ? `${videoSize}` : `${videoSize} ${fps}fps `;
|
||||
const videoButtonLabel = forceHttp ? "N/A fps" : `${Math.round(fps)}fps`;
|
||||
if(isVirtualKeyboardEnabled){
|
||||
return <></>
|
||||
}
|
||||
@@ -151,6 +151,7 @@ export default function BottomBarMobile() {
|
||||
<LedStatusButton ledState={keyboardLedState?.num_lock} text={$at("Num")} />
|
||||
<LedStatusButton ledState={keyboardLedState?.caps_lock} text={$at("Caps")} />
|
||||
<LedStatusButton ledState={keyboardLedState?.scroll_lock} text={$at("Scroll")} />
|
||||
<span className="pl-2 text-xs opacity-70">{layoutAbbrev}</span>
|
||||
</div>
|
||||
<div className="w-[20%] flex flex-row flex-wrap items-center justify-end">
|
||||
<AntdButton
|
||||
@@ -175,11 +176,13 @@ export default function BottomBarMobile() {
|
||||
text={$at("HDMI")}
|
||||
isActive={!!peerConnectionState}
|
||||
/>
|
||||
<ConnectionStatusButton
|
||||
icon={usbState === "configured" ? <Usb2SVG /> : <UsbSVG />}
|
||||
text={$at("USB")}
|
||||
isActive={usbState === "configured"}
|
||||
/>
|
||||
<div onClick={() => toggleSidebarView("UsbStatusPanel")}>
|
||||
<ConnectionStatusButton
|
||||
icon={usbState === "configured" ? <Usb2SVG /> : <UsbSVG />}
|
||||
text={$at("USB")}
|
||||
isActive={usbState === "configured"}
|
||||
/>
|
||||
</div>
|
||||
<VpnStatusButton
|
||||
text={$at("TailScale")}
|
||||
peerState={peerConnectionState}
|
||||
|
||||
@@ -27,10 +27,12 @@ import {
|
||||
useVpnStore,
|
||||
} from "@/hooks/stores";
|
||||
import { keys, modifiers } from "@/keyboardMappings";
|
||||
import { keyboards } from "@/keyboardLayouts";
|
||||
import BottomPopoverButton from "@components/PopoverButton";
|
||||
import MousePanel from "@components/MousePanel";
|
||||
import KeyboardPanel from "@/layout/components_bottom/keyboard/KeyboardPanel";
|
||||
import UsbEpModeSelect from "@/layout/components_bottom/usbepmode/UsbEpModeSelect";
|
||||
import UsbStatusPanel from "@/layout/components_bottom/usb_status/UsbStatusPanel";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { dark_bg2_style, selected_bt_bg } from "@/layout/theme_color";
|
||||
import { useThemeSettings } from "@routes/login_page/useLocalAuth";
|
||||
@@ -44,12 +46,6 @@ export default function BottomBarPC() {
|
||||
const activeModifiers = useHidStore(state => state.activeModifiers);
|
||||
const audioMode = useAudioModeStore(state => state.audioMode);
|
||||
const usbEpMode = useUsbEpModeStore(state => state.usbEpMode);
|
||||
// const videoSize = useVideoStore(
|
||||
// state => `${Math.round(state.width)}x${Math.round(state.height)}`,
|
||||
// );
|
||||
const videoSize = useVideoStore(
|
||||
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
|
||||
);
|
||||
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
|
||||
const showPressedKeys = useSettingsStore(state => state.showPressedKeys);
|
||||
@@ -63,8 +59,14 @@ export default function BottomBarPC() {
|
||||
|
||||
|
||||
const keyboardLedState = useHidStore(state => state.keyboardLedState);
|
||||
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
|
||||
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
|
||||
|
||||
const layoutAbbrev = useMemo(() => {
|
||||
if (!keyboardLayout) return "en_US";
|
||||
return keyboardLayout;
|
||||
}, [keyboardLayout]);
|
||||
|
||||
const [hostname, setHostname] = useState("");
|
||||
const [send] = useJsonRpc();
|
||||
const peerConnection = useRTCStore(state => state.peerConnection);
|
||||
@@ -89,10 +91,10 @@ export default function BottomBarPC() {
|
||||
|
||||
const videoButtonLabel = useMemo(() => {
|
||||
if (forceHttp) {
|
||||
return `${videoSize}`;
|
||||
return "N/A fps";
|
||||
}
|
||||
return `${videoSize} ${fps}fps `;
|
||||
}, [forceHttp, videoSize, fps]);
|
||||
return `${Math.round(fps)}fps`;
|
||||
}, [forceHttp, fps]);
|
||||
useEffect(() => {
|
||||
send("getNetworkSettings", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
@@ -114,10 +116,12 @@ export default function BottomBarPC() {
|
||||
isActive={hdmiState === "ready"}
|
||||
/>
|
||||
|
||||
<ConnectionStatusButton
|
||||
icon={usbState === "configured" ? <Usb2SVG fontSize={16} /> : <UsbSVG fontSize={16} />}
|
||||
text={$at("USB")}
|
||||
isActive={usbState === "configured"}
|
||||
<BottomPopoverButton
|
||||
buttonIconNode={usbState === "configured" ? <Usb2SVG fontSize={16} /> : <UsbSVG fontSize={16} />}
|
||||
buttonText={$at("USB")}
|
||||
style={{ color: usbState === "configured" ? "rgba(0, 205, 27, 1)" : "inherit" }}
|
||||
panelContent={<UsbStatusPanel />}
|
||||
align="left"
|
||||
/>
|
||||
|
||||
<VpnStatusButton
|
||||
@@ -170,6 +174,7 @@ export default function BottomBarPC() {
|
||||
ledState={keyboardLedState?.scroll_lock}
|
||||
text={$at("Scroll")}
|
||||
/>
|
||||
<span className="pl-1 text-xs opacity-70" style={{ fontSize: 12 }}>{layoutAbbrev}</span>
|
||||
</div>
|
||||
}
|
||||
align="left"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { BsMouseFill, BsLockFill, BsUnlockFill } from "react-icons/bs";
|
||||
import { BsKeyboardFill, BsLockFill, BsUnlockFill } from "react-icons/bs";
|
||||
import { useReactAt } from "i18n-auto-extractor/react";
|
||||
|
||||
import VirtualKeyboard from "@components/VirtualKeyboard";
|
||||
@@ -24,7 +24,7 @@ import KeyboardPanel from "@/layout/components_bottom/keyboard/KeyboardPanel";
|
||||
import Clipboard from "@/layout/components_side/Clipboard/Clipboard";
|
||||
import SettingsModal from "@/layout/components_setting";
|
||||
import { MacroMoreList } from "@/layout/components_side/Macros/MacroTopBar";
|
||||
import { useMacrosSideTitleState , useHidStore, useMouseStore, useSettingsStore } from "@/hooks/stores";
|
||||
import { useMacrosSideTitleState , useHidStore, useMouseStore, useSettingsStore, useUiStore } from "@/hooks/stores";
|
||||
import MobileTerminal from "@/layout/components_bottom/terminal/index.mobile";
|
||||
import { dark_bg_desktop, dark_bg_style_fun } from "@/layout/theme_color";
|
||||
import PowerControl from "@/layout/components_side/Power";
|
||||
@@ -37,10 +37,52 @@ import SettingsMacros from "@/layout/components_side/Macros";
|
||||
import { useTouchZoom } from "@/layout/core/desktop/hooks/useTouchZoom";
|
||||
import { usePasteHandler } from "@/layout/core/desktop/hooks/usePasteHandler";
|
||||
import UsbEpModeSelect from "@/layout/components_bottom/usbepmode/UsbEpModeSelect";
|
||||
import UsbStatusPanel from "@/layout/components_bottom/usb_status/UsbStatusPanel";
|
||||
import VirtualMediaSource from "@/layout/components_side/VirtualMediaSource";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import OcrOverlay from "@components/OcrOverlay";
|
||||
|
||||
const GestureIcon = ({ className = "h-4 w-4" }: { className?: string }) => (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="10" cy="10" r="6.5" />
|
||||
<path d="M14.8 14.8L21 21" />
|
||||
<path d="M10 7.5V12.5" />
|
||||
<path d="M7.5 10H12.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ResetViewIcon = ({ className = "h-4 w-4" }: { className?: string }) => (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M19 12A7 7 0 1 1 12 5" />
|
||||
<path d="M12 2L12 6L16 6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MouseStickIcon = ({ className = "h-4 w-4" }: { className?: string }) => (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<rect x="7" y="3" width="10" height="18" rx="5" />
|
||||
<path d="M12 3V8" />
|
||||
<circle cx="12" cy="13" r="1.5" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FourWayMoveIcon = ({ className = "h-5 w-5" }: { className?: string }) => (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 3V21" />
|
||||
<path d="M3 12H21" />
|
||||
<path d="M12 3L9 6" />
|
||||
<path d="M12 3L15 6" />
|
||||
<path d="M12 21L9 18" />
|
||||
<path d="M12 21L15 18" />
|
||||
<path d="M3 12L6 9" />
|
||||
<path d="M3 12L6 15" />
|
||||
<path d="M21 12L18 9" />
|
||||
<path d="M21 12L18 15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number }) {
|
||||
const joystickSpeedLevels = [1.4, 1.05, 0.7];
|
||||
const { $at } = useReactAt();
|
||||
const { isDark } = useTheme();
|
||||
const videoElm = useRef<HTMLVideoElement>(null);
|
||||
@@ -49,56 +91,126 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
|
||||
const zoomContainerRef = useRef<HTMLDivElement>(null);
|
||||
const pasteCaptureRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||
const isOcrMode = useUiStore(state => state.isOcrMode);
|
||||
const macrosSideTitle = useMacrosSideTitleState(state => state.sideTitle);
|
||||
|
||||
const videoEffects = useVideoEffects();
|
||||
const videoStream = useVideoStream(videoElm as React.RefObject<HTMLVideoElement>, audioElm as React.RefObject<HTMLAudioElement>);
|
||||
const pointerLock = usePointerLock(videoElm as React.RefObject<HTMLVideoElement>);
|
||||
useFullscreen(videoElm as React.RefObject<HTMLVideoElement>, pointerLock, isFullscreen);
|
||||
const touchZoom = useTouchZoom(zoomContainerRef as React.RefObject<HTMLDivElement>);
|
||||
const [isTouchGestureEnabled, setIsTouchGestureEnabled] = useState(true);
|
||||
const touchZoom = useTouchZoom(zoomContainerRef as React.RefObject<HTMLDivElement>, isTouchGestureEnabled);
|
||||
const { handleGlobalPaste } = usePasteHandler(pasteCaptureRef as React.RefObject<HTMLTextAreaElement>);
|
||||
const keyboardEvents = useKeyboardEvents(pasteCaptureRef as React.RefObject<HTMLTextAreaElement>, isReinitializingGadget);
|
||||
const [showVirtualMouseButtons, setShowVirtualMouseButtons] = useState(false);
|
||||
const [showVirtualJoystick, setShowVirtualJoystick] = useState(false);
|
||||
const [joystickVector, setJoystickVector] = useState({ x: 0, y: 0 });
|
||||
const [joystickSensitivity, setJoystickSensitivity] = useState(1);
|
||||
const [joystickPos, setJoystickPos] = useState({ x: 16, y: 24 });
|
||||
const [lockedButtons, setLockedButtons] = useState(0);
|
||||
const mouseEvents = useMouseEvents(videoElm as React.RefObject<HTMLVideoElement>, pointerLock, touchZoom, showVirtualMouseButtons, lockedButtons);
|
||||
const overlays = useVideoOverlays(videoStream, pointerLock, videoEffects);
|
||||
|
||||
const forceHttp = useSettingsStore(state => state.forceHttp);
|
||||
const mouseMode = useSettingsStore(state => state.mouseMode);
|
||||
const mouseX = useMouseStore(state => state.mouseX);
|
||||
const mouseY = useMouseStore(state => state.mouseY);
|
||||
const allowTapToOpenVirtualKeyboard = useHidStore(state => state.allowTapToOpenVirtualKeyboard);
|
||||
const setAllowTapToOpenVirtualKeyboard = useHidStore(state => state.setAllowTapToOpenVirtualKeyboard);
|
||||
const [send] = useJsonRpc();
|
||||
const [leftBtnPos, setLeftBtnPos] = useState({ x: 40, y: 40 });
|
||||
const [rightBtnPos, setRightBtnPos] = useState({ x: 120, y: 40 });
|
||||
const [leftLockPos, setLeftLockPos] = useState({ x: 40, y: 110 });
|
||||
const [rightLockPos, setRightLockPos] = useState({ x: 120, y: 110 });
|
||||
const [wheelPos, setWheelPos] = useState({ x: 184, y: 140 });
|
||||
|
||||
const [draggingBtn, setDraggingBtn] = useState<"left" | "right" | "leftLock" | "rightLock" | null>(null);
|
||||
const [draggingBtn, setDraggingBtn] = useState<"left" | "right" | "wheel" | null>(null);
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
const joystickAreaRef = useRef<HTMLDivElement>(null);
|
||||
const joystickPointerIdRef = useRef<number | null>(null);
|
||||
const joystickVectorRef = useRef({ x: 0, y: 0 });
|
||||
const joystickFrameRef = useRef<number | null>(null);
|
||||
const joystickLastTsRef = useRef<number | null>(null);
|
||||
const joystickMovePointerIdRef = useRef<number | null>(null);
|
||||
const joystickMoveHoldTimerRef = useRef<number | null>(null);
|
||||
const joystickMoveEnabledRef = useRef(false);
|
||||
|
||||
const activeButtonsRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFullscreen) {
|
||||
setShowVirtualMouseButtons(false);
|
||||
setShowVirtualJoystick(false);
|
||||
}
|
||||
}, [isFullscreen]);
|
||||
|
||||
useEffect(() => {
|
||||
joystickVectorRef.current = joystickVector;
|
||||
}, [joystickVector]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showVirtualJoystick) {
|
||||
joystickPointerIdRef.current = null;
|
||||
joystickMovePointerIdRef.current = null;
|
||||
if (joystickMoveHoldTimerRef.current !== null) {
|
||||
window.clearTimeout(joystickMoveHoldTimerRef.current);
|
||||
joystickMoveHoldTimerRef.current = null;
|
||||
}
|
||||
joystickMoveEnabledRef.current = false;
|
||||
joystickLastTsRef.current = null;
|
||||
setJoystickVector({ x: 0, y: 0 });
|
||||
if (joystickFrameRef.current !== null) {
|
||||
cancelAnimationFrame(joystickFrameRef.current);
|
||||
joystickFrameRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const tick = (timestamp: number) => {
|
||||
const prevTs = joystickLastTsRef.current ?? timestamp;
|
||||
joystickLastTsRef.current = timestamp;
|
||||
const frameScale = Math.min(2, Math.max(0.5, (timestamp - prevTs) / 16.67));
|
||||
const vector = joystickVectorRef.current;
|
||||
const container = containerRef.current;
|
||||
const containerWidth = container?.clientWidth ?? 1280;
|
||||
// Reduce sensitivity on small screens to avoid over-shooting.
|
||||
const resolutionFactor = Math.max(0.35, Math.min(1, containerWidth / 1280));
|
||||
const speed = 12 * resolutionFactor * joystickSensitivity;
|
||||
const dx = Math.round(vector.x * speed * frameScale);
|
||||
const dy = Math.round(vector.y * speed * frameScale);
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
mouseEvents.sendVirtualRelativeMovement(dx, dy, 0);
|
||||
}
|
||||
joystickFrameRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
joystickFrameRef.current = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
if (joystickFrameRef.current !== null) {
|
||||
cancelAnimationFrame(joystickFrameRef.current);
|
||||
joystickFrameRef.current = null;
|
||||
}
|
||||
joystickLastTsRef.current = null;
|
||||
};
|
||||
}, [showVirtualJoystick, mouseEvents, joystickSensitivity]);
|
||||
|
||||
const updateButtons = (mask: number, isDown: boolean) => {
|
||||
if (isReinitializingGadget) return;
|
||||
|
||||
let newButtons = activeButtonsRef.current;
|
||||
if (isDown) {
|
||||
newButtons |= mask;
|
||||
} else if (lockedButtons & mask) {
|
||||
// Keep pressed while lock is enabled.
|
||||
newButtons |= mask;
|
||||
} else {
|
||||
newButtons &= ~mask;
|
||||
}
|
||||
|
||||
if (!isDown && (lockedButtons & mask)) {
|
||||
setLockedButtons(prev => prev & ~mask);
|
||||
}
|
||||
|
||||
activeButtonsRef.current = newButtons;
|
||||
send("absMouseReport", { x: mouseX, y: mouseY, buttons: newButtons });
|
||||
if (mouseMode === "relative") {
|
||||
mouseEvents.sendVirtualRelativeMovement(0, 0, newButtons);
|
||||
} else {
|
||||
send("absMouseReport", { x: mouseX, y: mouseY, buttons: newButtons });
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLock = (mask: number) => {
|
||||
@@ -115,10 +227,14 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
|
||||
}
|
||||
|
||||
activeButtonsRef.current = newButtons;
|
||||
send("absMouseReport", { x: mouseX, y: mouseY, buttons: newButtons });
|
||||
if (mouseMode === "relative") {
|
||||
mouseEvents.sendVirtualRelativeMovement(0, 0, newButtons);
|
||||
} else {
|
||||
send("absMouseReport", { x: mouseX, y: mouseY, buttons: newButtons });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>, type: "left" | "right" | "leftLock" | "rightLock") => {
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>, type: "left" | "right" | "wheel") => {
|
||||
const target = e.currentTarget;
|
||||
const rect = target.getBoundingClientRect();
|
||||
dragOffset.current = {
|
||||
@@ -136,26 +252,121 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const x = e.clientX - containerRect.left - dragOffset.current.x;
|
||||
const y = e.clientY - containerRect.top - dragOffset.current.y;
|
||||
const clampedX = Math.max(0, Math.min(containerRect.width - 56, x));
|
||||
const clampedY = Math.max(0, Math.min(containerRect.height - 56, y));
|
||||
const dragWidth = draggingBtn === "wheel" ? 32 : 56;
|
||||
const dragHeight = draggingBtn === "wheel" ? 68 : 56;
|
||||
const clampedX = Math.max(0, Math.min(containerRect.width - dragWidth, x));
|
||||
const clampedY = Math.max(0, Math.min(containerRect.height - dragHeight, y));
|
||||
if (draggingBtn === "left") {
|
||||
setLeftBtnPos({ x: clampedX, y: clampedY });
|
||||
} else if (draggingBtn === "right") {
|
||||
setRightBtnPos({ x: clampedX, y: clampedY });
|
||||
} else if (draggingBtn === "leftLock") {
|
||||
setLeftLockPos({ x: clampedX, y: clampedY });
|
||||
} else if (draggingBtn === "rightLock") {
|
||||
setRightLockPos({ x: clampedX, y: clampedY });
|
||||
} else if (draggingBtn === "wheel") {
|
||||
setWheelPos({ x: clampedX, y: clampedY });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>, type: "left" | "right" | "leftLock" | "rightLock") => {
|
||||
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>, type: "left" | "right" | "wheel") => {
|
||||
const wasDragging = draggingBtn === type;
|
||||
setDraggingBtn(null);
|
||||
(e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
|
||||
if (!wasDragging) return;
|
||||
};
|
||||
|
||||
const updateJoystickVector = (clientX: number, clientY: number) => {
|
||||
const joystickElm = joystickAreaRef.current;
|
||||
if (!joystickElm) return;
|
||||
const rect = joystickElm.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
const rawX = clientX - centerX;
|
||||
const rawY = clientY - centerY;
|
||||
const maxRadius = 32;
|
||||
const length = Math.hypot(rawX, rawY);
|
||||
if (!length || length <= maxRadius) {
|
||||
setJoystickVector({ x: rawX / maxRadius, y: rawY / maxRadius });
|
||||
return;
|
||||
}
|
||||
const scale = maxRadius / length;
|
||||
setJoystickVector({ x: (rawX * scale) / maxRadius, y: (rawY * scale) / maxRadius });
|
||||
};
|
||||
|
||||
const handleJoystickPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (joystickMovePointerIdRef.current !== null) return;
|
||||
e.preventDefault();
|
||||
joystickPointerIdRef.current = e.pointerId;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
updateJoystickVector(e.clientX, e.clientY);
|
||||
};
|
||||
|
||||
const handleJoystickPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (joystickPointerIdRef.current !== e.pointerId) return;
|
||||
e.preventDefault();
|
||||
updateJoystickVector(e.clientX, e.clientY);
|
||||
};
|
||||
|
||||
const handleJoystickPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (joystickPointerIdRef.current !== e.pointerId) return;
|
||||
joystickPointerIdRef.current = null;
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
setJoystickVector({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
const handleJoystickMoveStart = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
joystickMovePointerIdRef.current = e.pointerId;
|
||||
joystickMoveEnabledRef.current = false;
|
||||
if (joystickMoveHoldTimerRef.current !== null) {
|
||||
window.clearTimeout(joystickMoveHoldTimerRef.current);
|
||||
}
|
||||
joystickMoveHoldTimerRef.current = window.setTimeout(() => {
|
||||
joystickMoveEnabledRef.current = true;
|
||||
}, 350);
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleJoystickMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (joystickMovePointerIdRef.current !== e.pointerId) return;
|
||||
if (!joystickMoveEnabledRef.current) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const joystickSize = 80;
|
||||
const nextLeft = e.clientX - containerRect.left - joystickSize / 2;
|
||||
const nextTop = e.clientY - containerRect.top - joystickSize / 2;
|
||||
const clampedLeft = Math.max(0, Math.min(containerRect.width - joystickSize, nextLeft));
|
||||
const clampedTop = Math.max(0, Math.min(containerRect.height - joystickSize, nextTop));
|
||||
const nextBottom = containerRect.height - joystickSize - clampedTop;
|
||||
setJoystickPos({ x: clampedLeft, y: nextBottom });
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleJoystickMoveEnd = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (joystickMovePointerIdRef.current !== e.pointerId) return;
|
||||
if (joystickMoveHoldTimerRef.current !== null) {
|
||||
window.clearTimeout(joystickMoveHoldTimerRef.current);
|
||||
joystickMoveHoldTimerRef.current = null;
|
||||
}
|
||||
joystickMoveEnabledRef.current = false;
|
||||
joystickMovePointerIdRef.current = null;
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const isMouseControlEnabled = showVirtualJoystick || showVirtualMouseButtons;
|
||||
const joystickSpeedIndex = joystickSpeedLevels.reduce((bestIndex, value, index, arr) => {
|
||||
const bestDistance = Math.abs(arr[bestIndex] - joystickSensitivity);
|
||||
const currentDistance = Math.abs(value - joystickSensitivity);
|
||||
return currentDistance < bestDistance ? index : bestIndex;
|
||||
}, 0);
|
||||
const toggleMouseControl = () => {
|
||||
const nextEnabled = !isMouseControlEnabled;
|
||||
setShowVirtualJoystick(nextEnabled);
|
||||
setShowVirtualMouseButtons(nextEnabled);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const keyboardCleanup = keyboardEvents.setupKeyboardEvents();
|
||||
const videoCleanup = videoStream.setupVideoEventListeners();
|
||||
@@ -213,6 +424,11 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
|
||||
drawerRender={() => (<UsbEpModeSelect/>)}
|
||||
className={"px-[20px]"}
|
||||
/>
|
||||
<EnhancedDrawer
|
||||
targetView={"UsbStatusPanel"}
|
||||
placement={"bottom"}
|
||||
drawerRender={() => (<UsbStatusPanel/>)}
|
||||
/>
|
||||
|
||||
<EnhancedDrawer
|
||||
title={$at("Virtual Media Source")}
|
||||
@@ -267,6 +483,7 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
|
||||
`h-full w-full ${dark_bg_style_fun(isDark)} object-contain transition-all duration-1000`,
|
||||
{
|
||||
"cursor-none": videoEffects.settings.isCursorHidden,
|
||||
"pointer-events-none": isOcrMode,
|
||||
"opacity-0": overlays.shouldHideVideo,
|
||||
"opacity-60!": overlays.showPointerLockBar,
|
||||
"animate-slideUpFade dark:border-slate-300/20":
|
||||
@@ -274,6 +491,10 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
|
||||
},
|
||||
)}
|
||||
/>
|
||||
<OcrOverlay
|
||||
videoRef={videoElm as React.RefObject<HTMLVideoElement>}
|
||||
containerRef={zoomContainerRef as React.RefObject<HTMLDivElement>}
|
||||
/>
|
||||
|
||||
{(videoStream.peerConnectionState === "connected" || forceHttp) && (
|
||||
<div
|
||||
@@ -296,107 +517,292 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
|
||||
className="pointer-events-none absolute inset-0"
|
||||
onPointerMove={handlePointerMove}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
"pointer-events-auto absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full text-white text-xs",
|
||||
isDark ? "bg-gray-500/70" : "bg-black/30",
|
||||
)}
|
||||
style={{
|
||||
touchAction: "none",
|
||||
}}
|
||||
onClick={() => setShowVirtualMouseButtons(prev => !prev)}
|
||||
>
|
||||
<BsMouseFill className="h-4 w-4" />
|
||||
<div className="pointer-events-auto absolute right-3 top-3 grid grid-cols-[auto_auto_auto] grid-rows-2 gap-1.5">
|
||||
<div
|
||||
className={cx(
|
||||
"flex h-8 w-8 items-center justify-center rounded-full text-white",
|
||||
isTouchGestureEnabled
|
||||
? "bg-green-600/80"
|
||||
: "bg-gray-500/70",
|
||||
)}
|
||||
style={{
|
||||
touchAction: "none",
|
||||
}}
|
||||
onClick={() => setIsTouchGestureEnabled(prev => !prev)}
|
||||
>
|
||||
<GestureIcon />
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
"flex h-8 w-8 items-center justify-center rounded-full text-white",
|
||||
isMouseControlEnabled
|
||||
? "bg-green-600/80"
|
||||
: "bg-gray-500/70",
|
||||
)}
|
||||
style={{
|
||||
touchAction: "none",
|
||||
}}
|
||||
onClick={toggleMouseControl}
|
||||
>
|
||||
<MouseStickIcon />
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
"flex h-8 w-8 items-center justify-center rounded-full text-white",
|
||||
allowTapToOpenVirtualKeyboard
|
||||
? "bg-green-600/80"
|
||||
: "bg-gray-500/70",
|
||||
)}
|
||||
style={{
|
||||
touchAction: "none",
|
||||
}}
|
||||
onClick={() => setAllowTapToOpenVirtualKeyboard(!allowTapToOpenVirtualKeyboard)}
|
||||
>
|
||||
<BsKeyboardFill className="h-4 w-4" />
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
"col-span-3 flex h-8 items-center justify-center rounded-full text-white",
|
||||
isDark ? "bg-gray-500/70" : "bg-black/30",
|
||||
)}
|
||||
style={{
|
||||
touchAction: "none",
|
||||
}}
|
||||
onClick={() => touchZoom.resetTransform()}
|
||||
>
|
||||
<ResetViewIcon />
|
||||
</div>
|
||||
</div>
|
||||
{showVirtualMouseButtons && (
|
||||
<>
|
||||
<div
|
||||
className={cx(
|
||||
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100",
|
||||
isDark ? "bg-gray-500/70" : "bg-black/30",
|
||||
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100 relative",
|
||||
(lockedButtons & 1) ? "bg-green-600/80" : (isDark ? "bg-gray-500/70" : "bg-black/30"),
|
||||
)}
|
||||
style={{
|
||||
left: leftBtnPos.x,
|
||||
top: leftBtnPos.y,
|
||||
touchAction: "none",
|
||||
}}
|
||||
onPointerDown={e => {
|
||||
handlePointerDown(e, "left");
|
||||
onPointerDown={() => {
|
||||
updateButtons(1, true);
|
||||
}}
|
||||
onPointerUp={e => {
|
||||
handlePointerUp(e, "left");
|
||||
onPointerUp={() => {
|
||||
updateButtons(1, false);
|
||||
}}
|
||||
>
|
||||
L
|
||||
<div
|
||||
className="absolute -left-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/45 text-white"
|
||||
onPointerDown={e => {
|
||||
e.stopPropagation();
|
||||
handlePointerDown(e, "left");
|
||||
}}
|
||||
onPointerMove={e => {
|
||||
e.stopPropagation();
|
||||
handlePointerMove(e);
|
||||
}}
|
||||
onPointerUp={e => {
|
||||
e.stopPropagation();
|
||||
handlePointerUp(e, "left");
|
||||
}}
|
||||
>
|
||||
<FourWayMoveIcon className="h-3 w-3" />
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
"absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full text-white",
|
||||
(lockedButtons & 1) ? "bg-green-600/90" : "bg-black/45",
|
||||
)}
|
||||
onPointerDown={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
toggleLock(1);
|
||||
}}
|
||||
>
|
||||
{(lockedButtons & 1) ? <BsLockFill className="h-3 w-3" /> : <BsUnlockFill className="h-3 w-3" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100",
|
||||
(lockedButtons & 1) ? "bg-green-600/80" : (isDark ? "bg-gray-500/70" : "bg-black/30"),
|
||||
)}
|
||||
style={{
|
||||
left: leftLockPos.x,
|
||||
top: leftLockPos.y,
|
||||
touchAction: "none",
|
||||
}}
|
||||
onPointerDown={e => {
|
||||
handlePointerDown(e, "leftLock");
|
||||
}}
|
||||
onPointerUp={e => {
|
||||
handlePointerUp(e, "leftLock");
|
||||
}}
|
||||
onClick={() => toggleLock(1)}
|
||||
>
|
||||
{(lockedButtons & 1) ? <BsLockFill /> : <BsUnlockFill />} L
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100",
|
||||
isDark ? "bg-gray-500/70" : "bg-black/30",
|
||||
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100 relative",
|
||||
(lockedButtons & 2) ? "bg-green-600/80" : (isDark ? "bg-gray-500/70" : "bg-black/30"),
|
||||
)}
|
||||
style={{
|
||||
left: rightBtnPos.x,
|
||||
top: rightBtnPos.y,
|
||||
touchAction: "none",
|
||||
}}
|
||||
onPointerDown={e => {
|
||||
handlePointerDown(e, "right");
|
||||
onPointerDown={() => {
|
||||
updateButtons(2, true);
|
||||
}}
|
||||
onPointerUp={e => {
|
||||
handlePointerUp(e, "right");
|
||||
onPointerUp={() => {
|
||||
updateButtons(2, false);
|
||||
}}
|
||||
>
|
||||
R
|
||||
<div
|
||||
className="absolute -left-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/45 text-white"
|
||||
onPointerDown={e => {
|
||||
e.stopPropagation();
|
||||
handlePointerDown(e, "right");
|
||||
}}
|
||||
onPointerMove={e => {
|
||||
e.stopPropagation();
|
||||
handlePointerMove(e);
|
||||
}}
|
||||
onPointerUp={e => {
|
||||
e.stopPropagation();
|
||||
handlePointerUp(e, "right");
|
||||
}}
|
||||
>
|
||||
<FourWayMoveIcon className="h-3 w-3" />
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
"absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full text-white",
|
||||
(lockedButtons & 2) ? "bg-green-600/90" : "bg-black/45",
|
||||
)}
|
||||
onPointerDown={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
toggleLock(2);
|
||||
}}
|
||||
>
|
||||
{(lockedButtons & 2) ? <BsLockFill className="h-3 w-3" /> : <BsUnlockFill className="h-3 w-3" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100",
|
||||
(lockedButtons & 2) ? "bg-green-600/80" : (isDark ? "bg-gray-500/70" : "bg-black/30"),
|
||||
)}
|
||||
className="pointer-events-auto absolute"
|
||||
style={{
|
||||
left: rightLockPos.x,
|
||||
top: rightLockPos.y,
|
||||
left: wheelPos.x,
|
||||
top: wheelPos.y,
|
||||
touchAction: "none",
|
||||
}}
|
||||
onPointerDown={e => {
|
||||
handlePointerDown(e, "rightLock");
|
||||
}}
|
||||
onPointerUp={e => {
|
||||
handlePointerUp(e, "rightLock");
|
||||
}}
|
||||
onClick={() => toggleLock(2)}
|
||||
>
|
||||
{(lockedButtons & 2) ? <BsLockFill /> : <BsUnlockFill />} R
|
||||
<div
|
||||
className={cx(
|
||||
"flex h-8 w-8 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100",
|
||||
isDark ? "bg-gray-500/70" : "bg-black/30",
|
||||
)}
|
||||
onClick={() => { send("wheelReport", { wheelY: 1 }); }}
|
||||
>
|
||||
▲
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 flex h-5 w-8 items-center justify-center rounded-full bg-black/45 text-white"
|
||||
onPointerDown={e => {
|
||||
e.stopPropagation();
|
||||
handlePointerDown(e, "wheel");
|
||||
}}
|
||||
onPointerMove={e => {
|
||||
e.stopPropagation();
|
||||
handlePointerMove(e);
|
||||
}}
|
||||
onPointerUp={e => {
|
||||
e.stopPropagation();
|
||||
handlePointerUp(e, "wheel");
|
||||
}}
|
||||
>
|
||||
<FourWayMoveIcon className="h-3 w-3" />
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
"mt-1 flex h-8 w-8 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100",
|
||||
isDark ? "bg-gray-500/70" : "bg-black/30",
|
||||
)}
|
||||
onClick={() => { send("wheelReport", { wheelY: -1 }); }}
|
||||
>
|
||||
▼
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{showVirtualJoystick && (
|
||||
<div
|
||||
className="pointer-events-auto absolute"
|
||||
style={{ left: joystickPos.x, bottom: joystickPos.y }}
|
||||
>
|
||||
<div className="absolute -top-7 left-0 flex items-center gap-1">
|
||||
<div
|
||||
className={cx(
|
||||
"flex h-6 w-6 items-center justify-center rounded-full text-white transition-transform duration-100 active:scale-95",
|
||||
isDark ? "bg-gray-500/70" : "bg-black/30",
|
||||
)}
|
||||
style={{ touchAction: "none" }}
|
||||
onPointerDown={handleJoystickMoveStart}
|
||||
onPointerMove={handleJoystickMove}
|
||||
onPointerUp={handleJoystickMoveEnd}
|
||||
onPointerCancel={handleJoystickMoveEnd}
|
||||
>
|
||||
<FourWayMoveIcon className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute right-[-36px] top-0 flex h-20 items-center">
|
||||
<div
|
||||
className={cx(
|
||||
"relative flex h-16 w-3 flex-col justify-between rounded-full py-1",
|
||||
isDark ? "bg-white/25" : "bg-black/20",
|
||||
)}
|
||||
style={{ touchAction: "none" }}
|
||||
>
|
||||
{joystickSpeedLevels.map((level, index) => (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
className={cx(
|
||||
"relative z-10 h-3 w-3 rounded-full border",
|
||||
joystickSpeedIndex === index
|
||||
? "border-blue-300 bg-blue-400"
|
||||
: (isDark ? "border-white/60 bg-white/40" : "border-black/40 bg-black/20"),
|
||||
)}
|
||||
onClick={() => setJoystickSensitivity(level)}
|
||||
aria-label={`Set joystick speed ${level.toFixed(2)}`}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className="pointer-events-none absolute left-full top-1/2 ml-[1px] -translate-y-1/2 border-y-[4px] border-l-[6px] border-y-transparent border-l-blue-400"
|
||||
style={{
|
||||
top: `${(joystickSpeedIndex / (joystickSpeedLevels.length - 1)) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-1 flex h-16 flex-col justify-between text-[8px] text-white/80">
|
||||
<span>Fast</span>
|
||||
<span>Slow</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={joystickAreaRef}
|
||||
className={cx(
|
||||
"flex h-20 w-20 items-center justify-center rounded-full border",
|
||||
isDark ? "border-white/40 bg-black/20" : "border-black/30 bg-white/20",
|
||||
)}
|
||||
style={{ touchAction: "none" }}
|
||||
onPointerDown={handleJoystickPointerDown}
|
||||
onPointerMove={handleJoystickPointerMove}
|
||||
onPointerUp={handleJoystickPointerUp}
|
||||
onPointerCancel={handleJoystickPointerUp}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
"h-9 w-9 rounded-full",
|
||||
isDark ? "bg-white/70" : "bg-black/50",
|
||||
)}
|
||||
style={{
|
||||
transform: `translate(${joystickVector.x * 24}px, ${joystickVector.y * 24}px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<VirtualKeyboard />
|
||||
|
||||
@@ -25,6 +25,7 @@ import { MacroMoreList } from "@/layout/components_side/Macros/MacroTopBar";
|
||||
import { useUiStore, useHidStore, useSettingsStore } from "@/hooks/stores";
|
||||
import { useTouchZoom } from "@/layout/core/desktop/hooks/useTouchZoom";
|
||||
import { usePasteHandler } from "@/layout/core/desktop/hooks/usePasteHandler";
|
||||
import OcrOverlay from "@components/OcrOverlay";
|
||||
|
||||
export default function PCDesktop({ isFullscreen }: { isFullscreen?: number }) {
|
||||
const videoElm = useRef<HTMLVideoElement>(null);
|
||||
@@ -38,6 +39,7 @@ export default function PCDesktop({ isFullscreen }: { isFullscreen?: number }) {
|
||||
const setTerminalType = useUiStore(state => state.setTerminalType);
|
||||
const terminalType = useUiStore(state => state.terminalType);
|
||||
const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled);
|
||||
const isOcrMode = useUiStore(state => state.isOcrMode);
|
||||
|
||||
const forceHttp = useSettingsStore(state => state.forceHttp);
|
||||
|
||||
@@ -116,6 +118,7 @@ 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`,
|
||||
{
|
||||
"cursor-none": videoEffects.settings.isCursorHidden,
|
||||
"pointer-events-none": isOcrMode,
|
||||
"opacity-0": overlays.shouldHideVideo,
|
||||
"opacity-60!": overlays.showPointerLockBar,
|
||||
"animate-slideUpFade shadow-xs ":
|
||||
@@ -123,6 +126,10 @@ export default function PCDesktop({ isFullscreen }: { isFullscreen?: number }) {
|
||||
},
|
||||
)}
|
||||
/>
|
||||
<OcrOverlay
|
||||
videoRef={videoElm as React.RefObject<HTMLVideoElement>}
|
||||
containerRef={zoomContainerRef as React.RefObject<HTMLDivElement>}
|
||||
/>
|
||||
|
||||
{(videoStream.peerConnectionState === "connected" || forceHttp) && (
|
||||
<div
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import useKeyboard from "@/hooks/useKeyboard";
|
||||
import { useHidStore, useSettingsStore } from "@/hooks/stores";
|
||||
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
|
||||
import { keys, modifiers } from "@/keyboardMappings";
|
||||
import { keyboards } from "@/keyboardLayouts";
|
||||
import { eventMatchesShortcut } from "@/utils/shortcuts";
|
||||
|
||||
export const useKeyboardEvents = (
|
||||
pasteCaptureRef?: React.RefObject<HTMLTextAreaElement>,
|
||||
@@ -14,7 +16,31 @@ export const useKeyboardEvents = (
|
||||
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
|
||||
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
|
||||
const isKeyboardLedManagedByHost = keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable;
|
||||
const overrideCtrlV = useSettingsStore(state => state.overrideCtrlV);
|
||||
const pasteShortcutEnabled = useSettingsStore(state => state.pasteShortcutEnabled);
|
||||
const pasteShortcut = useSettingsStore(state => state.pasteShortcut);
|
||||
const isOcrMode = useUiStore(state => state.isOcrMode);
|
||||
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
|
||||
|
||||
const remapCode = useCallback((code: string, key: string): string => {
|
||||
const modifierCodes = ["ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight", "AltLeft", "AltRight", "MetaLeft", "MetaRight", "CapsLock", "Tab", "Enter", "Backspace", "Delete", "Insert", "Home", "End", "PageUp", "PageDown", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Escape", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12", "PrintScreen", "ScrollLock", "Pause", "ContextMenu", "Menu"];
|
||||
if (modifierCodes.includes(code)) return code;
|
||||
if (code.startsWith("Digit") || code.startsWith("Numpad")) return code;
|
||||
if (code.startsWith("Key") && code.length === 4) {
|
||||
const letter = code.charAt(3);
|
||||
if (letter >= "A" && letter <= "Z") {
|
||||
const isoCode = (keyboardLayout || "en-US").replace("_", "-");
|
||||
const layout = keyboards.find(k => k.isoCode === isoCode);
|
||||
if (layout && layout.chars) {
|
||||
const charLower = key.toLowerCase();
|
||||
const charEntry = layout.chars[charLower] || layout.chars[key];
|
||||
if (charEntry && charEntry.key && typeof charEntry.key === "string" && charEntry.key !== code) {
|
||||
return charEntry.key;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return code;
|
||||
}, [keyboardLayout]);
|
||||
|
||||
const handleModifierKeys = useCallback((e: KeyboardEvent, activeModifiers: number[]) => {
|
||||
const { shiftKey, ctrlKey, altKey, metaKey } = e;
|
||||
@@ -28,17 +54,22 @@ export const useKeyboardEvents = (
|
||||
}, []);
|
||||
|
||||
const keyDownHandler = useCallback(async (e: KeyboardEvent) => {
|
||||
if (overrideCtrlV && (e.code === "KeyV" || e.key.toLowerCase() === "v") && (e.ctrlKey || e.metaKey)) {
|
||||
console.log("Override Ctrl V");
|
||||
if (isReinitializingGadget) return;
|
||||
if (pasteCaptureRef && pasteCaptureRef.current) {
|
||||
pasteCaptureRef.current.value = "";
|
||||
pasteCaptureRef.current.focus();
|
||||
}
|
||||
return;
|
||||
if (isOcrMode) return;
|
||||
if (pasteShortcutEnabled && eventMatchesShortcut(e, pasteShortcut)) {
|
||||
if (isReinitializingGadget) return;
|
||||
if (pasteCaptureRef && pasteCaptureRef.current) {
|
||||
pasteCaptureRef.current.value = "";
|
||||
pasteCaptureRef.current.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isReinitializingGadget) return;
|
||||
|
||||
if (e.repeat) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const prev = useHidStore.getState();
|
||||
let code = e.code;
|
||||
@@ -56,6 +87,8 @@ export const useKeyboardEvents = (
|
||||
code = "IntlBackslash";
|
||||
}
|
||||
|
||||
code = remapCode(code, key);
|
||||
|
||||
const newKeys = [...prev.activeKeys, keys[code]].filter(Boolean);
|
||||
const newModifiers = handleModifierKeys(e, [...prev.activeModifiers, modifiers[code]]);
|
||||
|
||||
@@ -67,12 +100,15 @@ export const useKeyboardEvents = (
|
||||
}
|
||||
|
||||
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
|
||||
}, [handleModifierKeys, sendKeyboardEvent, isKeyboardLedManagedByHost, setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive, overrideCtrlV, pasteCaptureRef, isReinitializingGadget]);
|
||||
}, [handleModifierKeys, remapCode, sendKeyboardEvent, isKeyboardLedManagedByHost, setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive, pasteShortcutEnabled, pasteShortcut, pasteCaptureRef, isReinitializingGadget, isOcrMode]);
|
||||
|
||||
const keyUpHandler = useCallback((e: KeyboardEvent) => {
|
||||
if (isOcrMode) return;
|
||||
if (isReinitializingGadget) return;
|
||||
e.preventDefault();
|
||||
const prev = useHidStore.getState();
|
||||
const key = e.key;
|
||||
let code = remapCode(e.code, key);
|
||||
|
||||
if (!isKeyboardLedManagedByHost) {
|
||||
setIsNumLockActive(e.getModifierState("NumLock"));
|
||||
@@ -80,14 +116,14 @@ export const useKeyboardEvents = (
|
||||
setIsScrollLockActive(e.getModifierState("ScrollLock"));
|
||||
}
|
||||
|
||||
const newKeys = prev.activeKeys.filter(k => k !== keys[e.code]).filter(Boolean);
|
||||
const newKeys = prev.activeKeys.filter(k => k !== keys[code]).filter(Boolean);
|
||||
const newModifiers = handleModifierKeys(
|
||||
e,
|
||||
prev.activeModifiers.filter(k => k !== modifiers[e.code]),
|
||||
prev.activeModifiers.filter(k => k !== modifiers[code]),
|
||||
);
|
||||
|
||||
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
|
||||
}, [handleModifierKeys, sendKeyboardEvent, isKeyboardLedManagedByHost, setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive]);
|
||||
}, [handleModifierKeys, remapCode, sendKeyboardEvent, isKeyboardLedManagedByHost, setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive, isOcrMode, isReinitializingGadget]);
|
||||
|
||||
const setupKeyboardEvents = useCallback(() => {
|
||||
const abortController = new AbortController();
|
||||
@@ -104,4 +140,4 @@ export const useKeyboardEvents = (
|
||||
return {
|
||||
setupKeyboardEvents,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -25,15 +25,18 @@ export const useMouseEvents = (
|
||||
const { setMousePosition, setMouseMove } = useMouseStore();
|
||||
const { width: videoWidth, height: videoHeight } = useVideoStore();
|
||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||
const touchDragActiveRef = useRef(false);
|
||||
|
||||
const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos);
|
||||
|
||||
const sendRelMouseMovement = useCallback(
|
||||
(x: number, y: number, buttons: number) => {
|
||||
if (settings.mouseMode !== "relative") return;
|
||||
(x: number, y: number, buttons: number, force = false) => {
|
||||
if (!force && settings.mouseMode !== "relative") return;
|
||||
// Don't send mouse events while reinitializing gadget
|
||||
if (isReinitializingGadget) return;
|
||||
send("relMouseReport", { dx: calcDelta(x), dy: calcDelta(y), buttons });
|
||||
const dx = calcDelta(x);
|
||||
const dy = calcDelta(y);
|
||||
send("relMouseReport", { dx, dy, buttons });
|
||||
setMouseMove({ x, y, buttons });
|
||||
},
|
||||
[send, setMouseMove, settings.mouseMode, isReinitializingGadget],
|
||||
@@ -50,6 +53,13 @@ export const useMouseEvents = (
|
||||
[send, setMousePosition, settings.mouseMode, isReinitializingGadget],
|
||||
);
|
||||
|
||||
const sendVirtualRelativeMovement = useCallback(
|
||||
(x: number, y: number, buttons = 0) => {
|
||||
sendRelMouseMovement(x, y, buttons, true);
|
||||
},
|
||||
[sendRelMouseMovement],
|
||||
);
|
||||
|
||||
const relMouseMoveHandler = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
const pt = (e as unknown as PointerEvent).pointerType as unknown as string;
|
||||
@@ -76,11 +86,17 @@ export const useMouseEvents = (
|
||||
const absMouseMoveHandler = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
const pt = (e as unknown as PointerEvent).pointerType as unknown as string;
|
||||
const pointerEvent = e as unknown as PointerEvent;
|
||||
const eventType = pointerEvent.type;
|
||||
if (pt === "touch") {
|
||||
if (touchZoom) {
|
||||
const touchCount = touchZoom.activeTouchPointers.current.size;
|
||||
const eventType = (e as unknown as PointerEvent).type;
|
||||
if (touchCount >= 2 && eventType !== "pointerup") return;
|
||||
const touchCount = touchZoom.activeTouchPointers.current.size;
|
||||
if (touchCount >= 2) {
|
||||
if (eventType === "pointerup" || eventType === "pointercancel") {
|
||||
touchDragActiveRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,28 +151,20 @@ export const useMouseEvents = (
|
||||
let buttons = e.buttons;
|
||||
|
||||
if (pt === "touch") {
|
||||
const touchCount = touchZoom ? touchZoom.activeTouchPointers.current.size : 1;
|
||||
const pointerEvent = e as unknown as PointerEvent;
|
||||
const eventType = pointerEvent.type;
|
||||
|
||||
if (eventType === "pointerup") {
|
||||
if (touchCount >= 2 || disableTouchClick) {
|
||||
buttons = 0;
|
||||
} else {
|
||||
buttons = 1;
|
||||
}
|
||||
} else {
|
||||
if (eventType === "pointerdown") {
|
||||
touchDragActiveRef.current = !disableTouchClick;
|
||||
}
|
||||
if (eventType === "pointerup" || eventType === "pointercancel") {
|
||||
buttons = 0;
|
||||
touchDragActiveRef.current = false;
|
||||
} else {
|
||||
buttons = touchDragActiveRef.current ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
buttons |= externalButtons;
|
||||
|
||||
sendAbsMouseMovement(x, y, buttons);
|
||||
|
||||
if (pt === "touch" && buttons !== externalButtons && (e as unknown as PointerEvent).type === "pointerup") {
|
||||
sendAbsMouseMovement(x, y, externalButtons);
|
||||
}
|
||||
},
|
||||
[settings.mouseMode, videoElm, videoWidth, videoHeight, sendAbsMouseMovement, touchZoom, disableTouchClick, externalButtons],
|
||||
);
|
||||
@@ -216,8 +224,10 @@ export const useMouseEvents = (
|
||||
};
|
||||
|
||||
videoElmRefValue.addEventListener("mousemove", eventHandler, { signal });
|
||||
videoElmRefValue.addEventListener("pointermove", eventHandler, { signal });
|
||||
videoElmRefValue.addEventListener("pointerdown", eventHandler, { signal });
|
||||
videoElmRefValue.addEventListener("pointerup", eventHandler, { signal });
|
||||
videoElmRefValue.addEventListener("pointercancel", eventHandler, { signal });
|
||||
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
|
||||
signal,
|
||||
passive: true,
|
||||
@@ -255,5 +265,6 @@ export const useMouseEvents = (
|
||||
|
||||
return {
|
||||
setupMouseEvents,
|
||||
sendVirtualRelativeMovement,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,10 +5,12 @@ import { useSettingsStore, useHidStore, useUiStore } from "@/hooks/stores";
|
||||
import { keys, modifiers } from "@/keyboardMappings";
|
||||
import { chars } from "@/keyboardLayouts";
|
||||
import notifications from "@/notifications";
|
||||
import { eventMatchesShortcut } from "@/utils/shortcuts";
|
||||
|
||||
export const usePasteHandler = (pasteCaptureRef?: React.RefObject<HTMLTextAreaElement>) => {
|
||||
const [send] = useJsonRpc();
|
||||
const overrideCtrlV = useSettingsStore(state => state.overrideCtrlV);
|
||||
const pasteShortcutEnabled = useSettingsStore(state => state.pasteShortcutEnabled);
|
||||
const pasteShortcut = useSettingsStore(state => state.pasteShortcut);
|
||||
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
|
||||
const setKeyboardLayout = useSettingsStore(state => state.setKeyboardLayout);
|
||||
const debugMode = useSettingsStore(state => state.debugMode);
|
||||
@@ -140,12 +142,11 @@ export const usePasteHandler = (pasteCaptureRef?: React.RefObject<HTMLTextAreaEl
|
||||
}, [log, send, setKeyboardLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!overrideCtrlV) return;
|
||||
if (!pasteShortcutEnabled) return;
|
||||
|
||||
const onKeyDownCapture = (e: KeyboardEvent) => {
|
||||
if (!overrideCtrlV) return;
|
||||
if (!(e.ctrlKey || e.metaKey)) return;
|
||||
if (e.code !== "KeyV" && e.key.toLowerCase() !== "v") return;
|
||||
if (!pasteShortcutEnabled) return;
|
||||
if (!eventMatchesShortcut(e, pasteShortcut)) return;
|
||||
if (isReinitializingGadget) return;
|
||||
|
||||
const activeElement = document.activeElement as HTMLElement | null;
|
||||
@@ -196,20 +197,20 @@ export const usePasteHandler = (pasteCaptureRef?: React.RefObject<HTMLTextAreaEl
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDownCapture, { capture: true });
|
||||
};
|
||||
}, [ensureFocusTrapPaused, isReinitializingGadget, log, overrideCtrlV, pasteCaptureRef, safeKeyboardLayout, sendTextToRemote]);
|
||||
}, [ensureFocusTrapPaused, isReinitializingGadget, log, pasteShortcutEnabled, pasteShortcut, pasteCaptureRef, safeKeyboardLayout, sendTextToRemote]);
|
||||
|
||||
const handleGlobalPaste = useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement> | ClipboardEvent) => {
|
||||
if (!overrideCtrlV) return;
|
||||
if (!pasteShortcutEnabled) return;
|
||||
e.preventDefault();
|
||||
|
||||
const clipboardData = (e as React.ClipboardEvent).clipboardData || (e as ClipboardEvent).clipboardData;
|
||||
const txt = clipboardData?.getData("text") || "";
|
||||
|
||||
await sendTextToRemote(txt);
|
||||
}, [log, overrideCtrlV, safeKeyboardLayout, sendTextToRemote]);
|
||||
}, [log, pasteShortcutEnabled, safeKeyboardLayout, sendTextToRemote]);
|
||||
|
||||
return {
|
||||
handleGlobalPaste,
|
||||
overrideCtrlV,
|
||||
pasteShortcutEnabled,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,31 +7,17 @@ export const usePointerLock = (videoElm: React.RefObject<HTMLVideoElement>) => {
|
||||
const settings = useSettingsStore();
|
||||
const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost";
|
||||
|
||||
const checkNavigatorPermissions = useCallback(async (permissionName: string) => {
|
||||
if (!navigator.permissions?.query) return false;
|
||||
const requestPointerLock = useCallback(async () => {
|
||||
if (!isPointerLockPossible || !videoElm.current || document.pointerLockElement || settings.mouseMode !== "relative") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { state } = await navigator.permissions.query({
|
||||
name: permissionName as PermissionName
|
||||
});
|
||||
return state === "granted";
|
||||
} catch {
|
||||
return false;
|
||||
await videoElm.current.requestPointerLock();
|
||||
} catch (err) {
|
||||
console.warn("[pointer-lock] requestPointerLock failed:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const requestPointerLock = useCallback(async () => {
|
||||
if (!isPointerLockPossible || !videoElm.current || document.pointerLockElement) return;
|
||||
|
||||
const isPointerLockGranted = await checkNavigatorPermissions("pointer-lock");
|
||||
if (isPointerLockGranted && settings.mouseMode === "relative") {
|
||||
try {
|
||||
await videoElm.current.requestPointerLock();
|
||||
} catch {
|
||||
// ignore errors
|
||||
}
|
||||
}
|
||||
}, [checkNavigatorPermissions, isPointerLockPossible, settings.mouseMode, videoElm]);
|
||||
}, [isPointerLockPossible, settings.mouseMode, videoElm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPointerLockPossible || !videoElm.current) return;
|
||||
@@ -40,6 +26,7 @@ export const usePointerLock = (videoElm: React.RefObject<HTMLVideoElement>) => {
|
||||
setIsPointerLockActive(!!document.pointerLockElement);
|
||||
};
|
||||
|
||||
handlePointerLockChange();
|
||||
document.addEventListener("pointerlockchange", handlePointerLockChange);
|
||||
return () => document.removeEventListener("pointerlockchange", handlePointerLockChange);
|
||||
}, [isPointerLockPossible, videoElm]);
|
||||
@@ -49,4 +36,4 @@ export const usePointerLock = (videoElm: React.RefObject<HTMLVideoElement>) => {
|
||||
isPointerLockPossible,
|
||||
requestPointerLock,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export const useTouchZoom = (
|
||||
containerRef: React.RefObject<HTMLDivElement>
|
||||
containerRef: React.RefObject<HTMLDivElement>,
|
||||
gestureEnabled = true,
|
||||
) => {
|
||||
const [mobileScale, setMobileScale] = useState(1);
|
||||
const [mobileTx, setMobileTx] = useState(0);
|
||||
@@ -17,67 +18,83 @@ export const useTouchZoom = (
|
||||
if (!el) return;
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
const isPointInVideo = (x: number, y: number) => {
|
||||
const video = el.querySelector("video") as HTMLVideoElement | null;
|
||||
if (!video) return false;
|
||||
const vRect = video.getBoundingClientRect();
|
||||
return x >= vRect.left && x <= vRect.right && y >= vRect.top && y <= vRect.bottom;
|
||||
};
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (e.pointerType !== "touch") return;
|
||||
activeTouchPointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (!gestureEnabled) return;
|
||||
let shouldHandleLocalGesture = false;
|
||||
|
||||
if (activeTouchPointers.current.size === 1) {
|
||||
const now = Date.now();
|
||||
let isInVideo = false;
|
||||
const video = el.querySelector("video") as HTMLVideoElement | null;
|
||||
if (video) {
|
||||
const vRect = video.getBoundingClientRect();
|
||||
if (
|
||||
e.clientX >= vRect.left &&
|
||||
e.clientX <= vRect.right &&
|
||||
e.clientY >= vRect.top &&
|
||||
e.clientY <= vRect.bottom
|
||||
) {
|
||||
isInVideo = true;
|
||||
}
|
||||
}
|
||||
const isInVideo = isPointInVideo(e.clientX, e.clientY);
|
||||
if (!isInVideo) {
|
||||
if (now - lastTapAt.current < 300) {
|
||||
setMobileScale(1);
|
||||
setMobileTx(0);
|
||||
setMobileTy(0);
|
||||
}
|
||||
shouldHandleLocalGesture = true;
|
||||
}
|
||||
lastTapAt.current = now;
|
||||
lastPanPoint.current = { x: e.clientX, y: e.clientY };
|
||||
if (mobileScale > 1) {
|
||||
lastPanPoint.current = { x: e.clientX, y: e.clientY };
|
||||
shouldHandleLocalGesture = true;
|
||||
} else {
|
||||
lastPanPoint.current = null;
|
||||
}
|
||||
} else if (activeTouchPointers.current.size === 2) {
|
||||
const pts = Array.from(activeTouchPointers.current.values());
|
||||
const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
||||
initialPinchDistance.current = d;
|
||||
initialPinchScale.current = mobileScale;
|
||||
shouldHandleLocalGesture = true;
|
||||
}
|
||||
|
||||
if (shouldHandleLocalGesture) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (e.pointerType !== "touch") return;
|
||||
const prev = activeTouchPointers.current.get(e.pointerId);
|
||||
activeTouchPointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (!gestureEnabled) return;
|
||||
const pts = Array.from(activeTouchPointers.current.values());
|
||||
let shouldHandleLocalGesture = false;
|
||||
|
||||
if (pts.length === 2 && initialPinchDistance.current) {
|
||||
const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
||||
const factor = d / initialPinchDistance.current;
|
||||
const next = Math.max(1, Math.min(4, initialPinchScale.current * factor));
|
||||
setMobileScale(next);
|
||||
} else if (pts.length === 1 && lastPanPoint.current && prev) {
|
||||
shouldHandleLocalGesture = true;
|
||||
} else if (pts.length === 1 && mobileScale > 1 && lastPanPoint.current && prev) {
|
||||
const dx = e.clientX - lastPanPoint.current.x;
|
||||
const dy = e.clientY - lastPanPoint.current.y;
|
||||
lastPanPoint.current = { x: e.clientX, y: e.clientY };
|
||||
setMobileTx(v => v + dx);
|
||||
setMobileTy(v => v + dy);
|
||||
shouldHandleLocalGesture = true;
|
||||
}
|
||||
|
||||
if (shouldHandleLocalGesture) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
if (e.pointerType !== "touch") return;
|
||||
const wasHandlingLocalGesture = gestureEnabled && (initialPinchDistance.current !== null || mobileScale > 1);
|
||||
activeTouchPointers.current.delete(e.pointerId);
|
||||
if (activeTouchPointers.current.size < 2) {
|
||||
initialPinchDistance.current = null;
|
||||
@@ -85,8 +102,11 @@ export const useTouchZoom = (
|
||||
if (activeTouchPointers.current.size === 0) {
|
||||
lastPanPoint.current = null;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (wasHandlingLocalGesture) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
el.addEventListener("pointerdown", onPointerDown, { signal });
|
||||
@@ -95,7 +115,15 @@ export const useTouchZoom = (
|
||||
el.addEventListener("pointercancel", onPointerUp, { signal });
|
||||
|
||||
return () => abortController.abort();
|
||||
}, [mobileScale, containerRef]);
|
||||
}, [mobileScale, containerRef, gestureEnabled]);
|
||||
|
||||
const resetTransform = () => {
|
||||
initialPinchDistance.current = null;
|
||||
lastPanPoint.current = null;
|
||||
setMobileScale(1);
|
||||
setMobileTx(0);
|
||||
setMobileTy(0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -115,5 +143,6 @@ export const useTouchZoom = (
|
||||
mobileTy,
|
||||
activeTouchPointers,
|
||||
lastPanPoint,
|
||||
resetTransform,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
useSettingsStore,
|
||||
useVpnStore } from "@/hooks/stores";
|
||||
import { JsonRpcRequest, useJsonRpc, resetHttpSessionId } from "@/hooks/useJsonRpc";
|
||||
import api from "@/api";
|
||||
import Modal from "@components/Modal";
|
||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||
import {
|
||||
@@ -351,6 +352,18 @@ export default function MobileHome() {
|
||||
try {
|
||||
console.log("[setupPeerConnection] Creating peer connection");
|
||||
setLoadingMessage("Creating peer connection...");
|
||||
let fetchedIceServers: RTCIceServer[] = [];
|
||||
if (!iceConfig?.iceServers) {
|
||||
try {
|
||||
const res = await api.GET("/api/ice-servers");
|
||||
const data = await res.json();
|
||||
fetchedIceServers = data.iceServers ?? [];
|
||||
} catch (e) {
|
||||
console.error("failed to fetch ICE servers, fallback", e);
|
||||
fetchedIceServers = [{ urls: ["stun:stun.l.google.com:19302"] }];
|
||||
}
|
||||
}
|
||||
|
||||
pc = new RTCPeerConnection({
|
||||
// We only use STUN or TURN servers if we're in the cloud
|
||||
//...(isInCloud && iceConfig?.iceServers
|
||||
@@ -358,13 +371,7 @@ export default function MobileHome() {
|
||||
// : {}),
|
||||
...(iceConfig?.iceServers
|
||||
? { iceServers: [iceConfig?.iceServers] }
|
||||
: {
|
||||
iceServers: [
|
||||
{
|
||||
urls: ['stun:stun.l.google.com:19302']
|
||||
}
|
||||
]
|
||||
}),
|
||||
: { iceServers: fetchedIceServers }),
|
||||
});
|
||||
|
||||
setPeerConnectionState(pc.connectionState);
|
||||
|
||||
@@ -363,6 +363,18 @@ export default function PCHome() {
|
||||
try {
|
||||
console.log("[setupPeerConnection] Creating peer connection");
|
||||
setLoadingMessage("Creating peer connection...");
|
||||
let fetchedIceServers: RTCIceServer[] = [];
|
||||
if (!iceConfig?.iceServers) {
|
||||
try {
|
||||
const res = await api.GET("/api/ice-servers");
|
||||
const data = await res.json();
|
||||
fetchedIceServers = data.iceServers ?? [];
|
||||
} catch (e) {
|
||||
console.error("failed to fetch ICE servers, fallback", e);
|
||||
fetchedIceServers = [{ urls: ["stun:stun.l.google.com:19302"] }];
|
||||
}
|
||||
}
|
||||
|
||||
pc = new RTCPeerConnection({
|
||||
// We only use STUN or TURN servers if we're in the cloud
|
||||
//...(isInCloud && iceConfig?.iceServers
|
||||
@@ -370,13 +382,7 @@ export default function PCHome() {
|
||||
// : {}),
|
||||
...(iceConfig?.iceServers
|
||||
? { iceServers: [iceConfig?.iceServers] }
|
||||
: {
|
||||
iceServers: [
|
||||
{
|
||||
urls: ['stun:stun.l.google.com:19302']
|
||||
}
|
||||
]
|
||||
}),
|
||||
: { iceServers: fetchedIceServers }),
|
||||
});
|
||||
|
||||
setPeerConnectionState(pc.connectionState);
|
||||
@@ -837,9 +843,9 @@ export default function PCHome() {
|
||||
<Desktop isFullscreen={isFullscreen} />
|
||||
<div
|
||||
style={{ animationDuration: "500ms" }}
|
||||
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center p-4"
|
||||
className="animate-slideUpFade pointer-events-none absolute inset-0 z-20 flex items-center justify-center"
|
||||
>
|
||||
<div className={`relative h-full max-h-[720px] w-full rounded-md`}>
|
||||
<div className="relative h-full w-full">
|
||||
{!!ConnectionStatusElement && ConnectionStatusElement}
|
||||
{/*<ConnectionFailedOverlay show={true} setupPeerConnection={setupPeerConnection} />*/}
|
||||
</div>
|
||||
|
||||
@@ -104,6 +104,14 @@
|
||||
"f9099fc033": "Renew DHCP Lease",
|
||||
"6e188f5984": "IPv6 Information",
|
||||
"cfdffa4fc7": "Link-local",
|
||||
"a588b1abc5": "Copied",
|
||||
"c5505be6c3": "No text detected",
|
||||
"a29aaf6603": "OCR failed",
|
||||
"dc67ba5f40": "Drag to select text area",
|
||||
"b603b043e8": "Copy text",
|
||||
"f5f78c501b": "Recognizing text...",
|
||||
"5df17e67f5": "Review the recognized text before copying.",
|
||||
"4dfb4a614f": "Please wait while OCR is running.",
|
||||
"d6e24388b4": "Continue Uploading",
|
||||
"62a5e49088": "Hide",
|
||||
"b34e855501": "USB Expansion Function",
|
||||
@@ -173,6 +181,24 @@
|
||||
"c40a72ad93": "KVM Terminal",
|
||||
"dd110018d2": "Serial Console",
|
||||
"7b277018e4": "Not connected",
|
||||
"02d2f33ec9": "Reinitialize USB Gadget",
|
||||
"c8e875e1c9": "Version Manager",
|
||||
"63a6a88c06": "Refresh",
|
||||
"d15f6e3312": "Hide Install Actions",
|
||||
"2b9b84a087": "Show Install Actions",
|
||||
"81dfff9d6f": "Install Status",
|
||||
"98dd43dfae": "Installed",
|
||||
"ddd8eef6f8": "Not Installed",
|
||||
"e2dc83997b": "Detected Version",
|
||||
"e3cc7e7df9": "System Architecture",
|
||||
"bc56777902": "Release Version",
|
||||
"c319e3982f": "Release Asset",
|
||||
"06933067aa": "Update",
|
||||
"a27dfe7717": "Uninstall",
|
||||
"349838fb1d": "Install",
|
||||
"4a0764788d": "Install Task",
|
||||
"46b5f8c58b": "Progress",
|
||||
"5e4caae72b": "Installed Versions",
|
||||
"bf733d8a93": "Access",
|
||||
"70e23e7d6f": "Manage the Access Control of the device",
|
||||
"509820290d": "Local",
|
||||
@@ -195,6 +221,8 @@
|
||||
"efd3cc702e": "Enable Password",
|
||||
"8f1e77e0d2": "Change Password",
|
||||
"3d0de21428": "Update your device access password",
|
||||
"d6d56d5972": "WebRTC Servers",
|
||||
"8bea04c48e": "STUN and TURN servers used for peer connections",
|
||||
"f8508f576c": "Remote",
|
||||
"a8bb6f5f9f": "Manage the mode of Remote access to the device",
|
||||
"0b86461350": "TailScale use xEdge server",
|
||||
@@ -267,7 +295,6 @@
|
||||
"324118a672": "Input",
|
||||
"29c2c02a36": "Output",
|
||||
"67d2f6740a": "Forward",
|
||||
"63a6a88c06": "Refresh",
|
||||
"a4d3b161ce": "Submit",
|
||||
"ec211f7c20": "Add",
|
||||
"5320550175": "Chain",
|
||||
@@ -288,6 +315,22 @@
|
||||
"64ae5dd047": "Destination Port",
|
||||
"fbb3878eb7": "Submit Firewall Policies?",
|
||||
"1c17a7e15b": "Warning: Adjusting some policies may cause network address loss, leading to device unavailability.",
|
||||
"6f20995c95": "Failed to load WebRTC servers",
|
||||
"aee9784c03": "Unknown error",
|
||||
"6ef7ce5b80": "Failed to save STUN",
|
||||
"4cd48aed7f": "STUN server saved",
|
||||
"4b5d050e51": "Failed to save TURN",
|
||||
"f6f10b4517": "TURN servers saved",
|
||||
"d235995f96": "STUN Server",
|
||||
"fc1bd2c935": "Public STUN server for NAT traversal",
|
||||
"289929755b": "Restore Default",
|
||||
"0268827609": "TURN Servers",
|
||||
"b3c14a0273": "Used as relay when direct peer-to-peer connection fails",
|
||||
"bbc48fb751": "No TURN servers configured",
|
||||
"f6039d44b2": "Username",
|
||||
"03bc142e64": "Credential",
|
||||
"ca3e8baee9": "Add TURN Server",
|
||||
"0acde1b3e3": "Save TURN Servers",
|
||||
"867cee98fd": "Passwords do not match",
|
||||
"9864ff9420": "Please enter your old password",
|
||||
"14a714ab22": "Please enter a new password",
|
||||
@@ -328,6 +371,12 @@
|
||||
"3369c97f56": "Enter your SSH public key",
|
||||
"81bafb2833": "The default SSH user is ",
|
||||
"7a941a0f87": "Update SSH Key",
|
||||
"d876ff8da6": "API Key",
|
||||
"96164f17cf": "API key for MCP and REST API authentication",
|
||||
"538ce1893b": "Enter API key or leave empty to auto-generate",
|
||||
"a55c6d580b": "Used for authenticating MCP and REST API requests.",
|
||||
"e85c388332": "Save API Key",
|
||||
"927c25953b": "Generate New",
|
||||
"fdad65b7be": "Force HTTP Transmission",
|
||||
"e033b5e2a3": "Force using HTTP for video streaming instead of WebRTC",
|
||||
"bda22ca687": "USB detection enhancement",
|
||||
@@ -338,7 +387,6 @@
|
||||
"020b92cfbb": "Enable USB Emulation",
|
||||
"9f55f64b0f": "USB Gadget Reinitialize",
|
||||
"40dc677a89": "Reinitialize USB gadget configuration",
|
||||
"02d2f33ec9": "Reinitialize USB Gadget",
|
||||
"f5ddf02991": "Reboot System",
|
||||
"1dbbf194af": "Restart the device system",
|
||||
"1de72c4fc6": "Reboot",
|
||||
@@ -347,6 +395,10 @@
|
||||
"f43c0398a4": "Reset Configuration",
|
||||
"0031dbef48": "Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.",
|
||||
"0d784092e8": "Reset Config",
|
||||
"115082e888": "Clear API Key?",
|
||||
"78fcaed30d": "Setting the API key to empty will auto-generate a new random key.",
|
||||
"81e8b4cd6b": "Make sure to update your clients with the new key after saving.",
|
||||
"211730be68": "Generate New Key",
|
||||
"a776e925bf": "Reboot System?",
|
||||
"1f070051ff": "Are you sure you want to reboot the system?",
|
||||
"f1a79f466e": "The device will restart and you will be disconnected from the web interface.",
|
||||
@@ -403,6 +455,12 @@
|
||||
"ef64a3770e": "Control mDNS (multicast DNS) operational mode",
|
||||
"be8226fe0c": "Time synchronization",
|
||||
"7e06bd28a6": "Configure time synchronization settings",
|
||||
"a2323452ba": "HTTP Proxy",
|
||||
"7fedd1ea53": "Configure program HTTP proxy (optional)",
|
||||
"a30d487cba": "HTTPS Proxy",
|
||||
"3f4c7e23cb": "Configure program HTTPS proxy (optional)",
|
||||
"48d941841f": "ALL Proxy",
|
||||
"3a2cd1e4a7": "Configure program ALL proxy (optional)",
|
||||
"d4dccb8ca2": "Save settings",
|
||||
"8750a898cb": "IPv4 Mode",
|
||||
"72c2543791": "Configure IPv4 mode",
|
||||
@@ -444,7 +502,16 @@
|
||||
"5b404b3c98": "Update in Background",
|
||||
"3723a3f846": "System is up to date",
|
||||
"a6b8796d51": "Your system is running the latest version. No updates are currently available.",
|
||||
"a8d2a89696": "Missing Signature File",
|
||||
"480f05b41b": "The current firmware is missing signature files. Integrity cannot be fully verified.",
|
||||
"323ebc9620": "Signature Verification Failed",
|
||||
"21355c9ecb": "The signature file exists but does not match the firmware. This may indicate tampering.",
|
||||
"0b54a6c322": "No Embedded Public Key",
|
||||
"9beb932d5a": "This build does not have an OTA public key embedded. Signature verification is unavailable.",
|
||||
"7c81553358": "Check Again",
|
||||
"3b5f9a1f01": "Update Signatures",
|
||||
"d25bfbb978": "Update the signature of kvm_app to the latest version. If the current version is not up to date, signature verification will fail.",
|
||||
"1bdd158a19": "Signature Update Result",
|
||||
"217b416896": "Update available",
|
||||
"f00af8c98f": "A new update is available to enhance system performance and improve compatibility. We recommend updating to ensure everything runs smoothly.",
|
||||
"8977c3f0b7": "Download Proxy Prefix",
|
||||
@@ -453,11 +520,22 @@
|
||||
"cffae9918d": "Update Error",
|
||||
"49cba7cadf": "An error occurred while updating your device. Please try again later.",
|
||||
"d849d5b330": "Error details:",
|
||||
"2b2f7a6d7c": "Use Ctrl+V to paste clipboard to remote",
|
||||
"fcfc8da1a3": "Verifying signature...",
|
||||
"4e16083ef8": "Please wait while verifying firmware signature.",
|
||||
"5367acff78": "Signature Status Unavailable",
|
||||
"634aac26af": "Unable to retrieve signature verification status.",
|
||||
"77de342f39": "Signature Verified",
|
||||
"20ac4a17cc": "Firmware signature has been verified and is valid.",
|
||||
"36a41c937c": "No video signal",
|
||||
"d34da01de2": "Enable paste shortcut",
|
||||
"2b1a1676d1": "Copy text from your client to the remote host",
|
||||
"604c45fbf2": "The following characters will not be pasted:",
|
||||
"a7eb9efa0b": "Sending text using keyboard layout:",
|
||||
"2593d0a3d5": "Confirm paste",
|
||||
"ac7e2a9783": "Enable OCR shortcut",
|
||||
"f529c51ee6": "OCR",
|
||||
"607440855c": "Open OCR selection mode on the video area",
|
||||
"c3bf447eab": "Open",
|
||||
"a0e3947a02": "Macros",
|
||||
"276043dbf0": "Create a new keyboard macro",
|
||||
"9605ef9593": "Modify your keyboard macro",
|
||||
@@ -487,6 +565,7 @@
|
||||
"21d104a54f": "Processing...",
|
||||
"9844086d90": "No SD Card Detected",
|
||||
"b56e598918": "SD Card Mount Failed",
|
||||
"d646589704": "Choose the file system for MicroSD formatting",
|
||||
"16eb8ed6c8": "Format MicroSD Card",
|
||||
"a63d5e0260": "Manage Shared Folders in KVM MicroSD Card",
|
||||
"1d50425f88": "Manage Shared Folders in KVM Storage",
|
||||
@@ -498,10 +577,15 @@
|
||||
"8bf8854beb": "of",
|
||||
"53e61336bb": "results",
|
||||
"d1f5a81904": "Unmount MicroSD Card",
|
||||
"827048afc2": "Formatting the SD card will erase all data. Continue?",
|
||||
"5874cf46ff": "SD card formatted successfully",
|
||||
"7dc25305d4": "Encodec Type",
|
||||
"41d263a988": "Stream Quality",
|
||||
"d8d7a5377b": "RC Control",
|
||||
"a09ff5b350": "Adjust rate control QP settings for better balance between quality and bitrate",
|
||||
"b1f9d95e72": "StepQp",
|
||||
"bf1af7559b": "MinQp",
|
||||
"6e4e284fcc": "MinIQp",
|
||||
"df1b8f72f2": "DetlpQp",
|
||||
"41f5283941": "NPU Application",
|
||||
"466990072d": "Enable NPU to Object Detection",
|
||||
"a6c2e30b8e": "Video Enhancement",
|
||||
@@ -518,6 +602,8 @@
|
||||
"85a003df9b": "EDID File",
|
||||
"5c7a666766": "Set Custom EDID",
|
||||
"7dad0ba758": "Restore to default",
|
||||
"7b2fb72a68": "RC Advanced Config",
|
||||
"827048afc2": "Formatting the SD card will erase all data. Continue?",
|
||||
"556c7553f1": "KVM MicroSD Card Mount",
|
||||
"b99cf1ecb7": "Manage and mount images from MicroSD card",
|
||||
"0a01e80566": "Mounted from KVM storage",
|
||||
|
||||
@@ -104,6 +104,14 @@
|
||||
"f9099fc033": "重订 DHCP 租约",
|
||||
"6e188f5984": "IPv6 信息",
|
||||
"cfdffa4fc7": "本地链路",
|
||||
"a588b1abc5": "已复制",
|
||||
"c5505be6c3": "未检测到文本",
|
||||
"a29aaf6603": "OCR 失败",
|
||||
"dc67ba5f40": "拖动选择文本区域",
|
||||
"b603b043e8": "复制文本",
|
||||
"f5f78c501b": "正在识别文本...",
|
||||
"5df17e67f5": "复制前请检查识别出的文本。",
|
||||
"4dfb4a614f": "请等待 OCR 运行完成。",
|
||||
"d6e24388b4": "继续上传",
|
||||
"62a5e49088": "隐藏",
|
||||
"b34e855501": "USB拓展功能",
|
||||
@@ -173,6 +181,24 @@
|
||||
"c40a72ad93": "KVM 终端",
|
||||
"dd110018d2": "串口控制台",
|
||||
"7b277018e4": "未连接",
|
||||
"02d2f33ec9": "重新初始化 USB 设备",
|
||||
"c8e875e1c9": "版本管理器",
|
||||
"63a6a88c06": "刷新",
|
||||
"d15f6e3312": "隐藏安装操作",
|
||||
"2b9b84a087": "显示安装操作",
|
||||
"81dfff9d6f": "安装状态",
|
||||
"98dd43dfae": "已安装",
|
||||
"ddd8eef6f8": "未安装",
|
||||
"e2dc83997b": "检测到的版本",
|
||||
"e3cc7e7df9": "系统架构",
|
||||
"bc56777902": "发布版本",
|
||||
"c319e3982f": "发布资源",
|
||||
"06933067aa": "更新",
|
||||
"a27dfe7717": "卸载",
|
||||
"349838fb1d": "安装",
|
||||
"4a0764788d": "安装任务",
|
||||
"46b5f8c58b": "进度",
|
||||
"5e4caae72b": "已安装版本",
|
||||
"bf733d8a93": "访问",
|
||||
"70e23e7d6f": "管理设备的访问控制",
|
||||
"509820290d": "本地",
|
||||
@@ -195,6 +221,8 @@
|
||||
"efd3cc702e": "启用密码",
|
||||
"8f1e77e0d2": "更改密码",
|
||||
"3d0de21428": "更新设备访问密码",
|
||||
"d6d56d5972": "WebRTC 服务器",
|
||||
"8bea04c48e": "用于点对点连接的 STUN 和 TURN 服务器",
|
||||
"f8508f576c": "远程",
|
||||
"a8bb6f5f9f": "管理远程访问设备的方式",
|
||||
"0b86461350": "TailScale 使用 xEdge 服务器",
|
||||
@@ -267,7 +295,6 @@
|
||||
"324118a672": "输入",
|
||||
"29c2c02a36": "输出",
|
||||
"67d2f6740a": "转发",
|
||||
"63a6a88c06": "刷新",
|
||||
"a4d3b161ce": "提交",
|
||||
"ec211f7c20": "添加",
|
||||
"5320550175": "链",
|
||||
@@ -288,6 +315,22 @@
|
||||
"64ae5dd047": "目标端口",
|
||||
"fbb3878eb7": "提交防火墙策略?",
|
||||
"1c17a7e15b": "警告:调整某些策略可能会导致网络地址丢失,导致设备不可用。",
|
||||
"6f20995c95": "加载 WebRTC 服务器失败",
|
||||
"aee9784c03": "未知错误",
|
||||
"6ef7ce5b80": "保存 STUN 失败",
|
||||
"4cd48aed7f": "STUN 服务器已保存",
|
||||
"4b5d050e51": "保存 TURN 失败",
|
||||
"f6f10b4517": "TURN 服务器已保存",
|
||||
"d235995f96": "STUN 服务器",
|
||||
"fc1bd2c935": "用于 NAT 穿透的公共 STUN 服务器",
|
||||
"289929755b": "恢复默认",
|
||||
"0268827609": "TURN 服务器",
|
||||
"b3c14a0273": "当直接点对点连接失败时用作中继",
|
||||
"bbc48fb751": "未配置 TURN 服务器",
|
||||
"f6039d44b2": "用户名",
|
||||
"03bc142e64": "凭据",
|
||||
"ca3e8baee9": "添加 TURN 服务器",
|
||||
"0acde1b3e3": "保存 TURN 服务器",
|
||||
"867cee98fd": "密码不一致",
|
||||
"9864ff9420": "请输入旧密码",
|
||||
"14a714ab22": "请输入新密码",
|
||||
@@ -328,6 +371,12 @@
|
||||
"3369c97f56": "输入您的 SSH 公钥",
|
||||
"81bafb2833": "默认 SSH 用户是 ",
|
||||
"7a941a0f87": "更新 SSH 密钥",
|
||||
"d876ff8da6": "API 密钥",
|
||||
"96164f17cf": "用于 MCP 和 REST API 认证的 API 密钥",
|
||||
"538ce1893b": "输入 API 密钥或留空以自动生成",
|
||||
"a55c6d580b": "用于认证 MCP 和 REST API 请求。",
|
||||
"e85c388332": "保存 API 密钥",
|
||||
"927c25953b": "生成新密钥",
|
||||
"fdad65b7be": "强制 HTTP 传输",
|
||||
"e033b5e2a3": "强制使用 HTTP 传输数据替代 WebRTC",
|
||||
"bda22ca687": "USB 检测增强",
|
||||
@@ -338,7 +387,6 @@
|
||||
"020b92cfbb": "启用 USB 复用",
|
||||
"9f55f64b0f": "USB 设备重新初始化",
|
||||
"40dc677a89": "重新初始化 USB 设备配置",
|
||||
"02d2f33ec9": "重新初始化 USB 设备",
|
||||
"f5ddf02991": "重启系统",
|
||||
"1dbbf194af": "重启设备系统",
|
||||
"1de72c4fc6": "重启",
|
||||
@@ -347,6 +395,10 @@
|
||||
"f43c0398a4": "重置配置",
|
||||
"0031dbef48": "重置配置,这将使你退出登录。部分配置重启后生效。",
|
||||
"0d784092e8": "重置配置",
|
||||
"115082e888": "清除 API 密钥?",
|
||||
"78fcaed30d": "将 API 密钥留空将自动生成一个新的随机密钥。",
|
||||
"81e8b4cd6b": "请确保在保存后使用新密钥更新您的客户端。",
|
||||
"211730be68": "生成新密钥",
|
||||
"a776e925bf": "重启系统?",
|
||||
"1f070051ff": "你确定重启系统吗?",
|
||||
"f1a79f466e": "设备将重启,你将从 Web 界面断开连接。",
|
||||
@@ -403,6 +455,12 @@
|
||||
"ef64a3770e": "控制 mDNS(多播 DNS)运行模式",
|
||||
"be8226fe0c": "时间同步",
|
||||
"7e06bd28a6": "配置时间同步设置",
|
||||
"a2323452ba": "HTTP 代理",
|
||||
"7fedd1ea53": "配置程序 HTTP 代理(可选)",
|
||||
"a30d487cba": "HTTPS 代理",
|
||||
"3f4c7e23cb": "配置程序 HTTPS 代理(可选)",
|
||||
"48d941841f": "ALL 代理",
|
||||
"3a2cd1e4a7": "配置程序 ALL 代理(可选)",
|
||||
"d4dccb8ca2": "保存设置",
|
||||
"8750a898cb": "IPv4 模式",
|
||||
"72c2543791": "配置 IPv4 模式",
|
||||
@@ -444,7 +502,16 @@
|
||||
"5b404b3c98": "后台更新",
|
||||
"3723a3f846": "系统已更新到最新版本",
|
||||
"a6b8796d51": "您的系统已运行最新版本。当前没有可用的更新。",
|
||||
"a8d2a89696": "签名文件缺失",
|
||||
"480f05b41b": "当前固件缺少签名文件,无法完全验证完整性。",
|
||||
"323ebc9620": "签名验证失败",
|
||||
"21355c9ecb": "签名文件存在但与固件不匹配,可能存在篡改。",
|
||||
"0b54a6c322": "未嵌入公钥",
|
||||
"9beb932d5a": "此版本未嵌入 OTA 公钥,无法进行签名验证。",
|
||||
"7c81553358": "再次检查",
|
||||
"3b5f9a1f01": "更新签名",
|
||||
"d25bfbb978": "更新 kvm_app 的签名到最新版本。如果当前版本不是最新版本,签名验证将会失败。",
|
||||
"1bdd158a19": "签名更新结果",
|
||||
"217b416896": "更新可用",
|
||||
"f00af8c98f": "新的更新可用,以增强系统性能和提高兼容性。我们建议更新以确保一切正常运行。",
|
||||
"8977c3f0b7": "下载代理加速前缀",
|
||||
@@ -453,11 +520,22 @@
|
||||
"cffae9918d": "更新错误",
|
||||
"49cba7cadf": "更新设备时发生错误。请稍后重试。",
|
||||
"d849d5b330": "错误详情:",
|
||||
"2b2f7a6d7c": "按下 Ctrl+V 直接将本地剪贴板内容发送到远端主机",
|
||||
"fcfc8da1a3": "正在验证签名...",
|
||||
"4e16083ef8": "请等待固件签名验证完成。",
|
||||
"5367acff78": "签名状态不可用",
|
||||
"634aac26af": "无法获取签名验证状态。",
|
||||
"77de342f39": "签名已验证",
|
||||
"20ac4a17cc": "固件签名已验证且有效。",
|
||||
"36a41c937c": "无视频信号",
|
||||
"d34da01de2": "启用粘贴快捷键",
|
||||
"2b1a1676d1": "将文本从您的客户端复制到远程主机",
|
||||
"604c45fbf2": "以下字符将不会被粘贴:",
|
||||
"a7eb9efa0b": "使用键盘布局发送文本:",
|
||||
"2593d0a3d5": "确认粘贴",
|
||||
"ac7e2a9783": "启用 OCR 快捷键",
|
||||
"f529c51ee6": "OCR",
|
||||
"607440855c": "在视频区域打开 OCR 选择模式",
|
||||
"c3bf447eab": "打开",
|
||||
"a0e3947a02": "宏",
|
||||
"276043dbf0": "创建新的键盘宏",
|
||||
"9605ef9593": "修改键盘宏",
|
||||
@@ -487,6 +565,7 @@
|
||||
"21d104a54f": "处理中...",
|
||||
"9844086d90": "未检测到 SD 卡",
|
||||
"b56e598918": "SD 卡挂载失败",
|
||||
"d646589704": "选择 MicroSD 格式化的文件系统",
|
||||
"16eb8ed6c8": "格式化 MicroSD 卡",
|
||||
"a63d5e0260": "管理 KVM MicroSD Card 的共享文件夹",
|
||||
"1d50425f88": "管理 KVM 存储中的共享文件夹",
|
||||
@@ -498,10 +577,15 @@
|
||||
"8bf8854beb": "共",
|
||||
"53e61336bb": "条结果",
|
||||
"d1f5a81904": "卸载 MicroSD 卡",
|
||||
"827048afc2": "格式化 SD 卡会清除所有数据。是否继续?",
|
||||
"5874cf46ff": "SD 卡格式化成功",
|
||||
"7dc25305d4": "视频编码类型",
|
||||
"41d263a988": "视频质量",
|
||||
"d8d7a5377b": "RC 控制",
|
||||
"a09ff5b350": "调整码率控制 QP 参数,在质量和码率之间取得更好的平衡",
|
||||
"b1f9d95e72": "StepQp",
|
||||
"bf1af7559b": "MinQp",
|
||||
"6e4e284fcc": "MinIQp",
|
||||
"df1b8f72f2": "DetlpQp",
|
||||
"41f5283941": "NPU 应用",
|
||||
"466990072d": "启用 NPU 以进行物体检测",
|
||||
"a6c2e30b8e": "视频增强",
|
||||
@@ -518,6 +602,8 @@
|
||||
"85a003df9b": "EDID 文件",
|
||||
"5c7a666766": "设置自定义 EDID",
|
||||
"7dad0ba758": "恢复默认",
|
||||
"7b2fb72a68": "RC 高级配置",
|
||||
"827048afc2": "格式化 SD 卡会清除所有数据。是否继续?",
|
||||
"556c7553f1": "从 KVM MicroSD 卡挂载",
|
||||
"b99cf1ecb7": "从 MicroSD 卡中管理和挂载镜像",
|
||||
"0a01e80566": "已从 KVM 存储挂载",
|
||||
|
||||
118
ui/src/utils/shortcuts.ts
Normal file
118
ui/src/utils/shortcuts.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
const MODIFIER_KEYS = new Set([
|
||||
"Control",
|
||||
"Shift",
|
||||
"Alt",
|
||||
"Meta",
|
||||
]);
|
||||
|
||||
const SPECIAL_CODE_TO_KEY: Record<string, string> = {
|
||||
Space: "Space",
|
||||
Enter: "Enter",
|
||||
Escape: "Esc",
|
||||
Tab: "Tab",
|
||||
Backspace: "Backspace",
|
||||
Delete: "Delete",
|
||||
ArrowUp: "Up",
|
||||
ArrowDown: "Down",
|
||||
ArrowLeft: "Left",
|
||||
ArrowRight: "Right",
|
||||
};
|
||||
|
||||
type ShortcutSpec = {
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
meta: boolean;
|
||||
key: string;
|
||||
};
|
||||
|
||||
function normalizeShortcutToken(token: string) {
|
||||
return token.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeKeyName(key: string) {
|
||||
const trimmed = key.trim();
|
||||
if (trimmed.length === 1) return trimmed.toUpperCase();
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower === "escape") return "Esc";
|
||||
if (lower === " ") return "Space";
|
||||
if (lower === "arrowup") return "Up";
|
||||
if (lower === "arrowdown") return "Down";
|
||||
if (lower === "arrowleft") return "Left";
|
||||
if (lower === "arrowright") return "Right";
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function keyFromEvent(e: KeyboardEvent) {
|
||||
const { code, key } = e;
|
||||
if (code.startsWith("Key")) return code.slice(3).toUpperCase();
|
||||
if (code.startsWith("Digit")) return code.slice(5);
|
||||
if (SPECIAL_CODE_TO_KEY[code]) return SPECIAL_CODE_TO_KEY[code];
|
||||
return normalizeKeyName(key);
|
||||
}
|
||||
|
||||
function parseShortcut(shortcut: string): ShortcutSpec | null {
|
||||
if (!shortcut) return null;
|
||||
const tokens = shortcut
|
||||
.split("+")
|
||||
.map(token => token.trim())
|
||||
.filter(Boolean);
|
||||
if (tokens.length === 0) return null;
|
||||
|
||||
const spec: ShortcutSpec = {
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
key: "",
|
||||
};
|
||||
|
||||
for (const token of tokens) {
|
||||
const normalized = normalizeShortcutToken(token);
|
||||
if (normalized === "ctrl" || normalized === "control") {
|
||||
spec.ctrl = true;
|
||||
continue;
|
||||
}
|
||||
if (normalized === "shift") {
|
||||
spec.shift = true;
|
||||
continue;
|
||||
}
|
||||
if (normalized === "alt" || normalized === "option") {
|
||||
spec.alt = true;
|
||||
continue;
|
||||
}
|
||||
if (normalized === "meta" || normalized === "cmd" || normalized === "command") {
|
||||
spec.meta = true;
|
||||
continue;
|
||||
}
|
||||
spec.key = normalizeKeyName(token);
|
||||
}
|
||||
|
||||
if (!spec.key) return null;
|
||||
return spec;
|
||||
}
|
||||
|
||||
export function eventMatchesShortcut(e: KeyboardEvent, shortcut: string) {
|
||||
const spec = parseShortcut(shortcut);
|
||||
if (!spec) return false;
|
||||
const eventKey = keyFromEvent(e);
|
||||
return (
|
||||
e.ctrlKey === spec.ctrl
|
||||
&& e.shiftKey === spec.shift
|
||||
&& e.altKey === spec.alt
|
||||
&& e.metaKey === spec.meta
|
||||
&& eventKey === spec.key
|
||||
);
|
||||
}
|
||||
|
||||
export function shortcutFromKeyboardEvent(e: KeyboardEvent) {
|
||||
if (MODIFIER_KEYS.has(e.key)) return null;
|
||||
const key = keyFromEvent(e);
|
||||
const modifiers: string[] = [];
|
||||
if (e.ctrlKey) modifiers.push("Ctrl");
|
||||
if (e.shiftKey) modifiers.push("Shift");
|
||||
if (e.altKey) modifiers.push("Alt");
|
||||
if (e.metaKey) modifiers.push("Meta");
|
||||
if (modifiers.length === 0) return null;
|
||||
return [...modifiers, key].join("+");
|
||||
}
|
||||
10
usb.go
10
usb.go
@@ -37,7 +37,7 @@ func initUsbGadget() {
|
||||
go func() {
|
||||
for {
|
||||
checkUSBState()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
time.Sleep(2500 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -111,6 +111,14 @@ func rpcKeyboardReport(modifier uint8, keys []uint8) error {
|
||||
return gadget.KeyboardReport(modifier, keys)
|
||||
}
|
||||
|
||||
func rpcKeypressReport(key uint8, press bool) error {
|
||||
return gadget.KeypressReport(key, press)
|
||||
}
|
||||
|
||||
func rpcKeypressKeepAlive() error {
|
||||
return gadget.KeypressKeepAlive()
|
||||
}
|
||||
|
||||
func rpcAbsMouseReport(x, y int, buttons uint8) error {
|
||||
return gadget.AbsMouseReport(x, y, buttons)
|
||||
}
|
||||
|
||||
@@ -782,7 +782,11 @@ func rpcUnmountSDStorage() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func rpcFormatSDStorage(confirm bool) error {
|
||||
func rpcFormatSDStorage(confirm bool, fsType string) error {
|
||||
validFsTypes := map[string]bool{"exfat": true, "fat32": true}
|
||||
if !validFsTypes[fsType] {
|
||||
fsType = "fat32"
|
||||
}
|
||||
if !confirm {
|
||||
return fmt.Errorf("format not confirmed")
|
||||
}
|
||||
@@ -864,7 +868,13 @@ func rpcFormatSDStorage(confirm bool) error {
|
||||
return fmt.Errorf("failed to stat sd partition: %w", err)
|
||||
}
|
||||
|
||||
mkfsOut, mkfsErr := exec.Command("mkfs.vfat", "-F", "32", "-n", "PICOKVM", "/dev/mmcblk1p1").CombinedOutput()
|
||||
var mkfsCmd *exec.Cmd
|
||||
if fsType == "exfat" {
|
||||
mkfsCmd = exec.Command("mkfs.exfat", "-n", "PICOKVM", "/dev/mmcblk1p1")
|
||||
} else {
|
||||
mkfsCmd = exec.Command("mkfs.vfat", "-F", "32", "-n", "PICOKVM", "/dev/mmcblk1p1")
|
||||
}
|
||||
mkfsOut, mkfsErr := mkfsCmd.CombinedOutput()
|
||||
if mkfsErr != nil {
|
||||
return fmt.Errorf("failed to format sdcard: %w: %s", mkfsErr, strings.TrimSpace(string(mkfsOut)))
|
||||
}
|
||||
|
||||
2
video.go
2
video.go
@@ -24,7 +24,7 @@ type VideoInputState struct {
|
||||
Error string `json:"error,omitempty"` //no_signal, no_lock, out_of_range
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
FramePerSecond float64 `json:"fps"`
|
||||
FramePerSecond float64 `json:"frame_per_second"`
|
||||
}
|
||||
|
||||
var lastVideoState VideoInputState
|
||||
|
||||
16
vpn.go
16
vpn.go
@@ -332,7 +332,7 @@ func rpcStartFrpc(frpcToml string) error {
|
||||
if err := os.WriteFile(frpcTomlPath, []byte(frpcToml), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command("frpc", "-c", frpcTomlPath)
|
||||
cmd := exec.Command(resolveVpnToolBinary("frpc", "frpc"), "-c", frpcTomlPath)
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
logFile, err := os.OpenFile(frpcLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
@@ -386,7 +386,9 @@ type CloudflaredStatus struct {
|
||||
}
|
||||
|
||||
func cloudflaredRunning() bool {
|
||||
cmd := exec.Command("pgrep", "-x", "cloudflared")
|
||||
// Only treat long-running tunnel process as running.
|
||||
// This avoids false positives from short-lived version checks like `cloudflared -v`.
|
||||
cmd := exec.Command("pgrep", "-f", `cloudflared.*tunnel.*run`)
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
@@ -401,7 +403,7 @@ func rpcStartCloudflared(token string) error {
|
||||
if token == "" {
|
||||
return fmt.Errorf("cloudflared token is empty")
|
||||
}
|
||||
cmd := exec.Command("cloudflared", "tunnel", "run", "--token", token)
|
||||
cmd := exec.Command(resolveVpnToolBinary("cloudflared", "cloudflared"), "tunnel", "run", "--token", token)
|
||||
logFile, err := os.OpenFile(cloudflaredLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -521,7 +523,7 @@ func rpcGetEasyTierLog() (string, error) {
|
||||
}
|
||||
|
||||
func rpcGetEasyTierNodeInfo() (string, error) {
|
||||
cmd := exec.Command("easytier-cli", "node")
|
||||
cmd := exec.Command(resolveVpnToolBinary("easytier", "easytier-cli"), "node")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get easytier node info: %w", err)
|
||||
@@ -543,7 +545,7 @@ func rpcStartEasyTier(name, secret, node string) error {
|
||||
return fmt.Errorf("easytier config is invalid")
|
||||
}
|
||||
|
||||
cmd := exec.Command("easytier-core", "-d", "--network-name", name, "--network-secret", secret, "-p", node)
|
||||
cmd := exec.Command(resolveVpnToolBinary("easytier", "easytier-core"), "-d", "--network-name", name, "--network-secret", secret, "-p", node)
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
logFile, err := os.OpenFile(easytierLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
@@ -639,7 +641,7 @@ func rpcGetVntLog() (string, error) {
|
||||
}
|
||||
|
||||
func rpcGetVntInfo() (string, error) {
|
||||
cmd := exec.Command("vnt-cli", "--info")
|
||||
cmd := exec.Command(resolveVpnToolBinary("vnt", "vnt-cli"), "--info")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get vnt info: %w", err)
|
||||
@@ -707,7 +709,7 @@ func rpcStartVnt(configMode, token, deviceId, name, serverAddr, configFile strin
|
||||
args = append(args, "--compressor", "lz4")
|
||||
}
|
||||
|
||||
cmd := exec.Command("vnt-cli", args...)
|
||||
cmd := exec.Command(resolveVpnToolBinary("vnt", "vnt-cli"), args...)
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
logFile, err := os.OpenFile(vntLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
|
||||
16
web.go
16
web.go
@@ -168,6 +168,7 @@ func setupRouter() *gin.Engine {
|
||||
protected.GET("/storage/download", handleDownloadHttp)
|
||||
protected.GET("/storage/sd-download", handleSDDownloadHttp)
|
||||
protected.POST("/api/rpc", handleRpcRequest)
|
||||
protected.GET("/api/ice-servers", handleGetIceServers)
|
||||
protected.GET("/terminal/ws", handleTerminalWS)
|
||||
protected.GET("/serial/ws", handleSerialWS)
|
||||
protected.GET("/video/stream", handleVideoStream)
|
||||
@@ -907,6 +908,16 @@ func handleRpcRequest(c *gin.Context) {
|
||||
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) {
|
||||
logger.Info().Msg("HTTP video stream request received")
|
||||
|
||||
@@ -931,20 +942,23 @@ func handleVideoStream(c *gin.Context) {
|
||||
|
||||
for {
|
||||
select {
|
||||
case data, ok := <-ch:
|
||||
case frame, ok := <-ch:
|
||||
if !ok {
|
||||
logger.Info().Int("total_frames", frameCount).Msg("video broadcaster channel closed")
|
||||
return
|
||||
}
|
||||
data := frame.Data()
|
||||
frameCount++
|
||||
if frameCount == 1 {
|
||||
logger.Info().Int("size", len(data)).Msg("first video frame received")
|
||||
}
|
||||
if _, err := c.Writer.Write(data); err != nil {
|
||||
logger.Warn().Err(err).Int("total_frames", frameCount).Msg("error writing video data")
|
||||
frame.Release()
|
||||
return
|
||||
}
|
||||
c.Writer.Flush()
|
||||
frame.Release()
|
||||
case <-ctx.Done():
|
||||
logger.Info().Int("total_frames", frameCount).Msg("client disconnected")
|
||||
return
|
||||
|
||||
39
webrtc.go
39
webrtc.go
@@ -22,7 +22,6 @@ type Session struct {
|
||||
//AudioTrack *webrtc.TrackLocalStaticSample
|
||||
ControlChannel *webrtc.DataChannel
|
||||
RPCChannel *webrtc.DataChannel
|
||||
HidChannel *webrtc.DataChannel
|
||||
DiskChannel *webrtc.DataChannel
|
||||
shouldUmountVirtualMedia bool
|
||||
}
|
||||
@@ -34,6 +33,33 @@ type SessionConfig struct {
|
||||
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) {
|
||||
b, err := base64.StdEncoding.DecodeString(offerStr)
|
||||
if err != nil {
|
||||
@@ -86,16 +112,7 @@ func newSession(sessionConfig SessionConfig) (*Session, error) {
|
||||
scopedLogger = webrtcLogger
|
||||
}
|
||||
|
||||
iceServers := []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
},
|
||||
}
|
||||
if config.STUN != "" {
|
||||
iceServers = append(iceServers, webrtc.ICEServer{
|
||||
URLs: []string{config.STUN},
|
||||
})
|
||||
}
|
||||
iceServers := buildICEServers()
|
||||
|
||||
api := webrtc.NewAPI(webrtc.WithSettingEngine(webrtcSettingEngine))
|
||||
peerConnection, err := api.NewPeerConnection(webrtc.Configuration{
|
||||
|
||||
Reference in New Issue
Block a user