mirror of
https://github.com/luckfox-eng29/kvm.git
synced 2026-06-09 11:33: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
|
ui/.i18n_extractor.json
|
||||||
|
|
||||||
device-tests.tar.gz
|
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)
|
BUILDDATE ?= $(shell date -u +%FT%T%z)
|
||||||
BUILDTS ?= $(shell date -u +%s)
|
BUILDTS ?= $(shell date -u +%s)
|
||||||
REVISION ?= $(shell git rev-parse HEAD)
|
REVISION ?= $(shell git rev-parse HEAD)
|
||||||
VERSION_DEV ?= 0.1.1-dev
|
VERSION_DEV ?= 0.1.3-dev
|
||||||
VERSION ?= 0.1.1
|
VERSION ?= 0.1.3
|
||||||
|
|
||||||
PROMETHEUS_TAG := github.com/prometheus/common/version
|
PROMETHEUS_TAG := github.com/prometheus/common/version
|
||||||
KVM_PKG_NAME := kvm
|
KVM_PKG_NAME := kvm
|
||||||
|
|
||||||
|
# OTA signing key path (Ed25519 private key for auto-signing at build time)
|
||||||
|
OTA_SIGNING_KEY ?=
|
||||||
|
|
||||||
|
# OTA signing public key (hex-encoded Ed25519 public key, 64 hex chars)
|
||||||
|
# Default empty = signature verification disabled (backward compatible)
|
||||||
|
OTA_PUBLIC_KEY ?=
|
||||||
|
|
||||||
GO_BUILD_ARGS := -tags netgo
|
GO_BUILD_ARGS := -tags netgo
|
||||||
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
|
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
|
||||||
GO_LDFLAGS := \
|
GO_LDFLAGS := \
|
||||||
@@ -15,7 +22,8 @@ GO_LDFLAGS := \
|
|||||||
-X $(PROMETHEUS_TAG).Branch=$(BRANCH) \
|
-X $(PROMETHEUS_TAG).Branch=$(BRANCH) \
|
||||||
-X $(PROMETHEUS_TAG).BuildDate=$(BUILDDATE) \
|
-X $(PROMETHEUS_TAG).BuildDate=$(BUILDDATE) \
|
||||||
-X $(PROMETHEUS_TAG).Revision=$(REVISION) \
|
-X $(PROMETHEUS_TAG).Revision=$(REVISION) \
|
||||||
-X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS)
|
-X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS) \
|
||||||
|
-X $(KVM_PKG_NAME).builtOtaPublicKey=$(OTA_PUBLIC_KEY)
|
||||||
|
|
||||||
GO_CMD := GOOS=linux GOARCH=arm GOARM=7 go
|
GO_CMD := GOOS=linux GOARCH=arm GOARM=7 go
|
||||||
BIN_DIR := $(shell pwd)/bin
|
BIN_DIR := $(shell pwd)/bin
|
||||||
@@ -28,6 +36,12 @@ build_dev:
|
|||||||
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
|
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
|
||||||
$(GO_RELEASE_BUILD_ARGS) \
|
$(GO_RELEASE_BUILD_ARGS) \
|
||||||
-o $(BIN_DIR)/kvm_app cmd/main.go
|
-o $(BIN_DIR)/kvm_app cmd/main.go
|
||||||
|
@if [ -n "$(OTA_SIGNING_KEY)" ]; then \
|
||||||
|
echo "Signing $(BIN_DIR)/kvm_app..."; \
|
||||||
|
go run cmd/main.go cli signer sign --key "$(OTA_SIGNING_KEY)" $(BIN_DIR)/kvm_app; \
|
||||||
|
else \
|
||||||
|
echo "OTA_SIGNING_KEY not set, skipping signing."; \
|
||||||
|
fi
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
cd ui && npm ci && npm run build:device
|
cd ui && npm ci && npm run build:device
|
||||||
@@ -38,3 +52,13 @@ build_release: frontend
|
|||||||
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" \
|
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" \
|
||||||
$(GO_RELEASE_BUILD_ARGS) \
|
$(GO_RELEASE_BUILD_ARGS) \
|
||||||
-o bin/kvm_app cmd/main.go
|
-o bin/kvm_app cmd/main.go
|
||||||
|
@if [ -n "$(OTA_SIGNING_KEY)" ]; then \
|
||||||
|
echo "Signing bin/kvm_app..."; \
|
||||||
|
go run cmd/main.go cli signer sign --key "$(OTA_SIGNING_KEY)" bin/kvm_app; \
|
||||||
|
else \
|
||||||
|
echo "OTA_SIGNING_KEY not set, skipping signing."; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
sign:
|
||||||
|
@echo "Signing firmware files..."
|
||||||
|
go run cmd/main.go cli signer sign --key $(KEY) $(FILES)
|
||||||
|
|||||||
389
api.go
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
57
boot_storage.go
Normal file
57
boot_storage.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package kvm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BootStorageType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
BootStorageUnknown BootStorageType = "unknown"
|
||||||
|
BootStorageEMMC BootStorageType = "emmc"
|
||||||
|
BootStorageSD BootStorageType = "sd"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
bootStorageOnce sync.Once
|
||||||
|
bootStorageType BootStorageType = BootStorageUnknown
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetBootStorageType() BootStorageType {
|
||||||
|
bootStorageOnce.Do(func() {
|
||||||
|
bootStorageType = detectBootStorageType()
|
||||||
|
})
|
||||||
|
return bootStorageType
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsBootFromSD() bool {
|
||||||
|
return GetBootStorageType() == BootStorageSD
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectBootStorageType() BootStorageType {
|
||||||
|
cmdlineBytes, err := os.ReadFile("/proc/cmdline")
|
||||||
|
if err != nil {
|
||||||
|
return BootStorageUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdline := strings.TrimSpace(string(cmdlineBytes))
|
||||||
|
for _, field := range strings.Fields(cmdline) {
|
||||||
|
if !strings.HasPrefix(field, "root=") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
root := strings.TrimPrefix(field, "root=")
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(root, "/dev/mmcblk0"):
|
||||||
|
return BootStorageEMMC
|
||||||
|
case strings.HasPrefix(root, "/dev/mmcblk1"):
|
||||||
|
return BootStorageSD
|
||||||
|
default:
|
||||||
|
return BootStorageUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return BootStorageUnknown
|
||||||
|
}
|
||||||
|
|
||||||
552
cli.go
Normal file
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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
"kvm"
|
"kvm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "cli" {
|
||||||
|
kvm.RunCLI(os.Args[2:])
|
||||||
|
return
|
||||||
|
}
|
||||||
kvm.Main()
|
kvm.Main()
|
||||||
}
|
}
|
||||||
|
|||||||
182
config.go
182
config.go
@@ -2,6 +2,9 @@ package kvm
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -21,6 +24,12 @@ type WakeOnLanDevice struct {
|
|||||||
MacAddress string `json:"macAddress"`
|
MacAddress string `json:"macAddress"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TurnServer struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Credential string `json:"credential"`
|
||||||
|
}
|
||||||
|
|
||||||
// Constants for keyboard macro limits
|
// Constants for keyboard macro limits
|
||||||
const (
|
const (
|
||||||
MaxMacrosPerDevice = 25
|
MaxMacrosPerDevice = 25
|
||||||
@@ -81,13 +90,16 @@ func (m *KeyboardMacro) Validate() error {
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
STUN string `json:"stun"`
|
STUN string `json:"stun"`
|
||||||
|
TurnServers []TurnServer `json:"turn_servers"`
|
||||||
JigglerEnabled bool `json:"jiggler_enabled"`
|
JigglerEnabled bool `json:"jiggler_enabled"`
|
||||||
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
||||||
IncludePreRelease bool `json:"include_pre_release"`
|
IncludePreRelease bool `json:"include_pre_release"`
|
||||||
|
UpdateDownloadProxy string `json:"update_download_proxy"`
|
||||||
HashedPassword string `json:"hashed_password"`
|
HashedPassword string `json:"hashed_password"`
|
||||||
LocalAuthToken string `json:"local_auth_token"`
|
LocalAuthToken string `json:"local_auth_token"`
|
||||||
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
|
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
|
||||||
LocalLoopbackOnly bool `json:"local_loopback_only"`
|
LocalLoopbackOnly bool `json:"local_loopback_only"`
|
||||||
|
UsbEnhancedDetection bool `json:"usb_enhanced_detection"`
|
||||||
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
|
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
|
||||||
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
|
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
|
||||||
KeyboardLayout string `json:"keyboard_layout"`
|
KeyboardLayout string `json:"keyboard_layout"`
|
||||||
@@ -126,6 +138,41 @@ type Config struct {
|
|||||||
WireguardAutoStart bool `json:"wireguard_autostart"`
|
WireguardAutoStart bool `json:"wireguard_autostart"`
|
||||||
WireguardConfig WireguardConfig `json:"wireguard_config"`
|
WireguardConfig WireguardConfig `json:"wireguard_config"`
|
||||||
NpuAppEnabled bool `json:"npu_app_enabled"`
|
NpuAppEnabled bool `json:"npu_app_enabled"`
|
||||||
|
Firewall *FirewallConfig `json:"firewall"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FirewallConfig struct {
|
||||||
|
Base FirewallBaseRule `json:"base"`
|
||||||
|
Rules []FirewallRule `json:"rules"`
|
||||||
|
PortForwards []FirewallPortRule `json:"portForwards"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FirewallBaseRule struct {
|
||||||
|
InputPolicy string `json:"inputPolicy"`
|
||||||
|
OutputPolicy string `json:"outputPolicy"`
|
||||||
|
ForwardPolicy string `json:"forwardPolicy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FirewallRule struct {
|
||||||
|
Chain string `json:"chain"`
|
||||||
|
SourceIP string `json:"sourceIP"`
|
||||||
|
SourcePort *int `json:"sourcePort,omitempty"`
|
||||||
|
Protocols []string `json:"protocols"`
|
||||||
|
DestinationIP string `json:"destinationIP"`
|
||||||
|
DestinationPort *int `json:"destinationPort,omitempty"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FirewallPortRule struct {
|
||||||
|
Chain string `json:"chain,omitempty"`
|
||||||
|
Managed *bool `json:"managed,omitempty"`
|
||||||
|
SourcePort int `json:"sourcePort"`
|
||||||
|
Protocols []string `json:"protocols"`
|
||||||
|
DestinationIP string `json:"destinationIP"`
|
||||||
|
DestinationPort int `json:"destinationPort"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type VntConfig struct {
|
type VntConfig struct {
|
||||||
@@ -147,8 +194,13 @@ type WireguardConfig struct {
|
|||||||
const configPath = "/userdata/kvm_config.json"
|
const configPath = "/userdata/kvm_config.json"
|
||||||
const sdConfigPath = "/mnt/sdcard/kvm_config.json"
|
const sdConfigPath = "/mnt/sdcard/kvm_config.json"
|
||||||
|
|
||||||
|
// builtOtaPublicKey is the hex-encoded Ed25519 public key for OTA signature verification,
|
||||||
|
// injected via -ldflags at build time. Empty string disables signature verification.
|
||||||
|
var builtOtaPublicKey = ""
|
||||||
|
|
||||||
var defaultConfig = &Config{
|
var defaultConfig = &Config{
|
||||||
STUN: "stun:stun.l.google.com:19302",
|
STUN: "stun:stun.l.google.com:19302",
|
||||||
|
TurnServers: []TurnServer{},
|
||||||
AutoUpdateEnabled: false, // Set a default value
|
AutoUpdateEnabled: false, // Set a default value
|
||||||
ActiveExtension: "",
|
ActiveExtension: "",
|
||||||
KeyboardMacros: []KeyboardMacro{},
|
KeyboardMacros: []KeyboardMacro{},
|
||||||
@@ -160,6 +212,7 @@ var defaultConfig = &Config{
|
|||||||
DisplayOffAfterSec: 1800, // 30 minutes
|
DisplayOffAfterSec: 1800, // 30 minutes
|
||||||
TLSMode: "",
|
TLSMode: "",
|
||||||
ForceHpd: false, // 默认不强制输出EDID
|
ForceHpd: false, // 默认不强制输出EDID
|
||||||
|
UsbEnhancedDetection: true,
|
||||||
UsbConfig: &usbgadget.Config{
|
UsbConfig: &usbgadget.Config{
|
||||||
VendorId: "0x1d6b", //The Linux Foundation
|
VendorId: "0x1d6b", //The Linux Foundation
|
||||||
ProductId: "0x0104", //Multifunction Composite Gadget
|
ProductId: "0x0104", //Multifunction Composite Gadget
|
||||||
@@ -191,6 +244,15 @@ var defaultConfig = &Config{
|
|||||||
AutoMountSystemInfo: true,
|
AutoMountSystemInfo: true,
|
||||||
WireguardAutoStart: false,
|
WireguardAutoStart: false,
|
||||||
NpuAppEnabled: false,
|
NpuAppEnabled: false,
|
||||||
|
Firewall: &FirewallConfig{
|
||||||
|
Base: FirewallBaseRule{
|
||||||
|
InputPolicy: "accept",
|
||||||
|
OutputPolicy: "accept",
|
||||||
|
ForwardPolicy: "accept",
|
||||||
|
},
|
||||||
|
Rules: []FirewallRule{},
|
||||||
|
PortForwards: []FirewallPortRule{},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -198,6 +260,75 @@ var (
|
|||||||
configLock = &sync.Mutex{}
|
configLock = &sync.Mutex{}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ConfigMigrationFunc func(raw json.RawMessage) (json.RawMessage, error)
|
||||||
|
|
||||||
|
var configMigrations = []ConfigMigrationFunc{
|
||||||
|
migrateLocalAuthMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateLocalAuthMode(raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var rawMap map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &rawMap); err != nil {
|
||||||
|
return nil, fmt.Errorf("migrateLocalAuthMode: failed to parse config JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if authModeRaw, exists := rawMap["localAuthMode"]; exists {
|
||||||
|
var authMode string
|
||||||
|
if err := json.Unmarshal(authModeRaw, &authMode); err == nil {
|
||||||
|
if authMode != "" {
|
||||||
|
validModes := map[string]bool{"password": true, "noPassword": true}
|
||||||
|
if !validModes[authMode] {
|
||||||
|
rawMap["localAuthMode"] = json.RawMessage(`"password"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delete(rawMap, "localAuthMode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := json.Marshal(rawMap)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("migrateLocalAuthMode: failed to marshal migrated config: %w", err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMigrations(raw json.RawMessage) (json.RawMessage, bool, error) {
|
||||||
|
current := raw
|
||||||
|
didMigrate := false
|
||||||
|
for i, migration := range configMigrations {
|
||||||
|
migrated, err := migration(current)
|
||||||
|
if err != nil {
|
||||||
|
return current, didMigrate, fmt.Errorf("migration %d failed: %w", i, err)
|
||||||
|
}
|
||||||
|
if string(migrated) != string(current) {
|
||||||
|
didMigrate = true
|
||||||
|
logger.Info().Int("migration", i).Msg("config migrated")
|
||||||
|
}
|
||||||
|
current = migrated
|
||||||
|
}
|
||||||
|
return current, didMigrate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRawConfig(path string, raw json.RawMessage) error {
|
||||||
|
file, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create config file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var indented bytes.Buffer
|
||||||
|
if err := json.Indent(&indented, raw, "", " "); err != nil {
|
||||||
|
return fmt.Errorf("failed to indent config JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := indented.WriteTo(file); err != nil {
|
||||||
|
return fmt.Errorf("failed to write config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func LoadConfig() {
|
func LoadConfig() {
|
||||||
configLock.Lock()
|
configLock.Lock()
|
||||||
defer configLock.Unlock()
|
defer configLock.Unlock()
|
||||||
@@ -207,7 +338,6 @@ func LoadConfig() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// load the default config
|
|
||||||
if defaultConfig.UsbConfig.SerialNumber == "" {
|
if defaultConfig.UsbConfig.SerialNumber == "" {
|
||||||
serialNumber, err := extractSerialNumber()
|
serialNumber, err := extractSerialNumber()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -219,24 +349,38 @@ func LoadConfig() {
|
|||||||
loadedConfig := *defaultConfig
|
loadedConfig := *defaultConfig
|
||||||
config = &loadedConfig
|
config = &loadedConfig
|
||||||
|
|
||||||
file, err := os.Open(configPath)
|
rawData, err := os.ReadFile(configPath)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
// load and merge the default config with the user config
|
migrated, didMigrate, err := runMigrations(json.RawMessage(rawData))
|
||||||
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
|
if err != nil {
|
||||||
logger.Warn().Err(err).Msg("config file JSON parsing failed")
|
logger.Warn().Err(err).Msg("config migration failed, preserving corrupt file")
|
||||||
os.Remove(configPath)
|
corruptPath := configPath + ".corrupt"
|
||||||
if _, err := os.Stat(sdConfigPath); err == nil {
|
_ = os.Rename(configPath, corruptPath)
|
||||||
os.Remove(sdConfigPath)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// merge the user config with the default config
|
|
||||||
if loadedConfig.UsbConfig == nil {
|
if loadedConfig.UsbConfig == nil {
|
||||||
loadedConfig.UsbConfig = defaultConfig.UsbConfig
|
loadedConfig.UsbConfig = defaultConfig.UsbConfig
|
||||||
}
|
}
|
||||||
@@ -249,6 +393,14 @@ func LoadConfig() {
|
|||||||
loadedConfig.NetworkConfig = defaultConfig.NetworkConfig
|
loadedConfig.NetworkConfig = defaultConfig.NetworkConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if loadedConfig.Firewall == nil {
|
||||||
|
loadedConfig.Firewall = defaultConfig.Firewall
|
||||||
|
}
|
||||||
|
|
||||||
|
if loadedConfig.TurnServers == nil {
|
||||||
|
loadedConfig.TurnServers = []TurnServer{}
|
||||||
|
}
|
||||||
|
|
||||||
config = &loadedConfig
|
config = &loadedConfig
|
||||||
|
|
||||||
logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel)
|
logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel)
|
||||||
@@ -347,6 +499,14 @@ func SaveConfig() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func generateAPIKey() (string, error) {
|
||||||
|
bytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
func ensureConfigLoaded() {
|
func ensureConfigLoaded() {
|
||||||
if config == nil {
|
if config == nil {
|
||||||
LoadConfig()
|
LoadConfig()
|
||||||
|
|||||||
1470
firewall.go
Normal file
1470
firewall.go
Normal file
File diff suppressed because it is too large
Load Diff
25
go.mod
25
go.mod
@@ -1,14 +1,11 @@
|
|||||||
module kvm
|
module kvm
|
||||||
|
|
||||||
go 1.23.4
|
go 1.25.5
|
||||||
|
|
||||||
toolchain go1.24.3
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Masterminds/semver/v3 v3.3.1
|
github.com/Masterminds/semver/v3 v3.3.1
|
||||||
github.com/beevik/ntp v1.4.3
|
github.com/beevik/ntp v1.4.3
|
||||||
github.com/coder/websocket v1.8.13
|
github.com/coder/websocket v1.8.13
|
||||||
github.com/coreos/go-oidc/v3 v3.11.0
|
|
||||||
github.com/creack/pty v1.1.23
|
github.com/creack/pty v1.1.23
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
github.com/gin-contrib/logger v1.2.6
|
github.com/gin-contrib/logger v1.2.6
|
||||||
@@ -17,8 +14,10 @@ require (
|
|||||||
github.com/guregu/null/v6 v6.0.0
|
github.com/guregu/null/v6 v6.0.0
|
||||||
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf
|
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf
|
||||||
github.com/hanwen/go-fuse/v2 v2.8.0
|
github.com/hanwen/go-fuse/v2 v2.8.0
|
||||||
|
github.com/mark3labs/mcp-go v0.52.0
|
||||||
github.com/pion/logging v0.2.3
|
github.com/pion/logging v0.2.3
|
||||||
github.com/pion/mdns/v2 v2.0.7
|
github.com/pion/mdns/v2 v2.0.7
|
||||||
|
github.com/pion/rtp v1.8.18
|
||||||
github.com/pion/webrtc/v4 v4.0.16
|
github.com/pion/webrtc/v4 v4.0.16
|
||||||
github.com/pojntfx/go-nbd v0.3.2
|
github.com/pojntfx/go-nbd v0.3.2
|
||||||
github.com/prometheus/client_golang v1.22.0
|
github.com/prometheus/client_golang v1.22.0
|
||||||
@@ -27,6 +26,7 @@ require (
|
|||||||
github.com/psanford/httpreadat v0.1.0
|
github.com/psanford/httpreadat v0.1.0
|
||||||
github.com/rs/zerolog v1.34.0
|
github.com/rs/zerolog v1.34.0
|
||||||
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f
|
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f
|
||||||
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
github.com/vishvananda/netlink v1.3.0
|
github.com/vishvananda/netlink v1.3.0
|
||||||
go.bug.st/serial v1.6.2
|
go.bug.st/serial v1.6.2
|
||||||
@@ -47,11 +47,12 @@ require (
|
|||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
@@ -63,35 +64,29 @@ require (
|
|||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/pilebones/go-udev v0.9.0 // indirect
|
github.com/pilebones/go-udev v0.9.0 // indirect
|
||||||
github.com/pion/datachannel v1.5.10 // indirect
|
github.com/pion/datachannel v1.5.10 // indirect
|
||||||
github.com/pion/dtls/v2 v2.2.12 // indirect
|
|
||||||
github.com/pion/dtls/v3 v3.0.6 // indirect
|
github.com/pion/dtls/v3 v3.0.6 // indirect
|
||||||
github.com/pion/ice/v2 v2.3.36 // indirect
|
|
||||||
github.com/pion/ice/v4 v4.0.10 // indirect
|
github.com/pion/ice/v4 v4.0.10 // indirect
|
||||||
github.com/pion/interceptor v0.1.40 // indirect
|
github.com/pion/interceptor v0.1.40 // indirect
|
||||||
github.com/pion/mdns v0.0.12 // indirect
|
|
||||||
github.com/pion/randutil v0.1.0 // indirect
|
github.com/pion/randutil v0.1.0 // indirect
|
||||||
github.com/pion/rtcp v1.2.15 // indirect
|
github.com/pion/rtcp v1.2.15 // indirect
|
||||||
github.com/pion/rtp v1.8.18 // indirect
|
|
||||||
github.com/pion/sctp v1.8.39 // indirect
|
github.com/pion/sctp v1.8.39 // indirect
|
||||||
github.com/pion/sdp/v3 v3.0.13 // indirect
|
github.com/pion/sdp/v3 v3.0.13 // indirect
|
||||||
github.com/pion/srtp/v2 v2.0.20 // indirect
|
|
||||||
github.com/pion/srtp/v3 v3.0.5 // indirect
|
github.com/pion/srtp/v3 v3.0.5 // indirect
|
||||||
github.com/pion/stun v0.6.1 // indirect
|
|
||||||
github.com/pion/stun/v3 v3.0.0 // indirect
|
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||||
github.com/pion/transport/v2 v2.2.10 // indirect
|
|
||||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||||
github.com/pion/turn/v2 v2.1.6 // indirect
|
|
||||||
github.com/pion/turn/v4 v4.0.2 // indirect
|
github.com/pion/turn/v4 v4.0.2 // indirect
|
||||||
github.com/pion/webrtc/v3 v3.3.5 // indirect
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/client_model v0.6.1 // indirect
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.11.0 // indirect
|
github.com/rogpeppe/go-internal v1.11.0 // indirect
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||||
|
github.com/spf13/cast v1.7.1 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
github.com/vishvananda/netns v0.0.4 // indirect
|
github.com/vishvananda/netns v0.0.4 // indirect
|
||||||
github.com/wlynxg/anet v0.0.5 // indirect
|
github.com/wlynxg/anet v0.0.5 // indirect
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
golang.org/x/arch v0.17.0 // indirect
|
golang.org/x/arch v0.17.0 // indirect
|
||||||
golang.org/x/oauth2 v0.24.0 // indirect
|
|
||||||
golang.org/x/text v0.26.0 // indirect
|
golang.org/x/text v0.26.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.6 // indirect
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|||||||
109
go.sum
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/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
|
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
|
||||||
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||||
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
|
|
||||||
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
|
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
|
||||||
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
|
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
|
||||||
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
|
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
|
||||||
@@ -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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||||
@@ -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-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@@ -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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ=
|
github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ=
|
||||||
@@ -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/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
|
||||||
github.com/hanwen/go-fuse/v2 v2.8.0 h1:wV8rG7rmCz8XHSOwBZhG5YcVqcYjkzivjmbaMafPlAs=
|
github.com/hanwen/go-fuse/v2 v2.8.0 h1:wV8rG7rmCz8XHSOwBZhG5YcVqcYjkzivjmbaMafPlAs=
|
||||||
github.com/hanwen/go-fuse/v2 v2.8.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
|
github.com/hanwen/go-fuse/v2 v2.8.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
@@ -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 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
@@ -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/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mark3labs/mcp-go v0.52.0 h1:uRSzupNSUyPGDpF4owY5X4zEpACPwBnlM3FAFuXN6gQ=
|
||||||
|
github.com/mark3labs/mcp-go v0.52.0/go.mod h1:Zg9cB2HdwdMMVgY0xtTzq3KvYIOJQDsaut+jWjwDaQY=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
@@ -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/pilebones/go-udev v0.9.0/go.mod h1:T2eI2tUSK0hA2WS5QLjXJUfQkluZQu+18Cqvem3CaXI=
|
||||||
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
||||||
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
|
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
|
||||||
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
|
||||||
github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk=
|
|
||||||
github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=
|
|
||||||
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
|
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
|
||||||
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
|
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
|
||||||
github.com/pion/ice/v2 v2.3.36 h1:SopeXiVbbcooUg2EIR8sq4b13RQ8gzrkkldOVg+bBsc=
|
|
||||||
github.com/pion/ice/v2 v2.3.36/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ=
|
|
||||||
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
|
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
|
||||||
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
|
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
|
||||||
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
|
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
|
||||||
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
|
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
|
||||||
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
|
||||||
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
|
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
|
||||||
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
|
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
|
||||||
github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8=
|
|
||||||
github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk=
|
|
||||||
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
|
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
|
||||||
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
|
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
|
||||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||||
github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
|
|
||||||
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
|
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
|
||||||
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
|
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
|
||||||
github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
|
|
||||||
github.com/pion/rtp v1.8.18 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU=
|
github.com/pion/rtp v1.8.18 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU=
|
||||||
github.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
|
github.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
|
||||||
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
|
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
|
||||||
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
|
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
|
||||||
github.com/pion/sdp/v3 v3.0.13 h1:uN3SS2b+QDZnWXgdr69SM8KB4EbcnPnPf2Laxhty/l4=
|
github.com/pion/sdp/v3 v3.0.13 h1:uN3SS2b+QDZnWXgdr69SM8KB4EbcnPnPf2Laxhty/l4=
|
||||||
github.com/pion/sdp/v3 v3.0.13/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
|
github.com/pion/sdp/v3 v3.0.13/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
|
||||||
github.com/pion/srtp/v2 v2.0.20 h1:HNNny4s+OUmG280ETrCdgFndp4ufx3/uy85EawYEhTk=
|
|
||||||
github.com/pion/srtp/v2 v2.0.20/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA=
|
|
||||||
github.com/pion/srtp/v3 v3.0.5 h1:8XLB6Dt3QXkMkRFpoqC3314BemkpMQK2mZeJc4pUKqo=
|
github.com/pion/srtp/v3 v3.0.5 h1:8XLB6Dt3QXkMkRFpoqC3314BemkpMQK2mZeJc4pUKqo=
|
||||||
github.com/pion/srtp/v3 v3.0.5/go.mod h1:r1G7y5r1scZRLe2QJI/is+/O83W2d+JoEsuIexpw+uM=
|
github.com/pion/srtp/v3 v3.0.5/go.mod h1:r1G7y5r1scZRLe2QJI/is+/O83W2d+JoEsuIexpw+uM=
|
||||||
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
|
|
||||||
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
|
|
||||||
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
||||||
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
||||||
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
|
|
||||||
github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
|
||||||
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
|
||||||
github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q=
|
|
||||||
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
|
|
||||||
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
|
|
||||||
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||||
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||||
github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
|
||||||
github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc=
|
|
||||||
github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
|
||||||
github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps=
|
github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps=
|
||||||
github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
|
github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
|
||||||
github.com/pion/webrtc/v3 v3.3.5 h1:ZsSzaMz/i9nblPdiAkZoP+E6Kmjw+jnyq3bEmU3EtRg=
|
|
||||||
github.com/pion/webrtc/v3 v3.3.5/go.mod h1:liNa+E1iwyzyXqNUwvoMRNQ10x8h8FOeJKL8RkIbamE=
|
|
||||||
github.com/pion/webrtc/v4 v4.0.16 h1:5f8QMVIbNvJr2mPRGi2QamkPa/LVUB6NWolOCwphKHA=
|
github.com/pion/webrtc/v4 v4.0.16 h1:5f8QMVIbNvJr2mPRGi2QamkPa/LVUB6NWolOCwphKHA=
|
||||||
github.com/pion/webrtc/v4 v4.0.16/go.mod h1:C3uTCPzVafUA0eUzru9f47OgNt3nEO7ZJ6zNY6VSJno=
|
github.com/pion/webrtc/v4 v4.0.16/go.mod h1:C3uTCPzVafUA0eUzru9f47OgNt3nEO7ZJ6zNY6VSJno=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
@@ -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/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||||
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f h1:VgoRCP1efSCEZIcF2THLQ46+pIBzzgNiaUBe9wEDwYU=
|
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f h1:VgoRCP1efSCEZIcF2THLQ46+pIBzzgNiaUBe9wEDwYU=
|
||||||
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzro7BGorij2WgrjEammtrkbo3+xldxo+KaGLGUiD+Q=
|
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzro7BGorij2WgrjEammtrkbo3+xldxo+KaGLGUiD+Q=
|
||||||
|
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||||
|
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
@@ -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/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
|
||||||
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
|
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
|
||||||
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||||
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
|
||||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
github.com/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 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8=
|
||||||
go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE=
|
go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
|
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
|
||||||
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
|
||||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
|
||||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
|
||||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
|
||||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
|
||||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
|
||||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
|
||||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
|
||||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||||
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
|
|
||||||
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
|
||||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
|
||||||
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
|
||||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
|
||||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
|
||||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ type IPv6StaticConfig struct {
|
|||||||
type NetworkConfig struct {
|
type NetworkConfig struct {
|
||||||
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
|
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
|
||||||
Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
|
Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
|
||||||
|
HTTPProxy null.String `json:"http_proxy,omitempty"`
|
||||||
|
HTTPSProxy null.String `json:"https_proxy,omitempty"`
|
||||||
|
ALLProxy null.String `json:"all_proxy,omitempty"`
|
||||||
|
|
||||||
IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"`
|
IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"`
|
||||||
IPv4RequestAddress null.String `json:"ipv4_request_address,omitempty"`
|
IPv4RequestAddress null.String `json:"ipv4_request_address,omitempty"`
|
||||||
|
|||||||
@@ -176,6 +176,49 @@ func (s *NetworkInterfaceState) MACString() string {
|
|||||||
return s.macAddr.String()
|
return s.macAddr.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *NetworkInterfaceState) SetMACAddress(macAddress string) (string, error) {
|
||||||
|
macAddress = strings.TrimSpace(macAddress)
|
||||||
|
if macAddress == "" {
|
||||||
|
return "", fmt.Errorf("mac address is empty")
|
||||||
|
}
|
||||||
|
hw, err := net.ParseMAC(macAddress)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid mac address")
|
||||||
|
}
|
||||||
|
if len(hw) != 6 {
|
||||||
|
return "", fmt.Errorf("invalid mac address length")
|
||||||
|
}
|
||||||
|
normalized := strings.ToLower(hw.String())
|
||||||
|
|
||||||
|
s.stateLock.Lock()
|
||||||
|
iface, err := netlink.LinkByName(s.interfaceName)
|
||||||
|
if err != nil {
|
||||||
|
s.stateLock.Unlock()
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := netlink.LinkSetDown(iface); err != nil {
|
||||||
|
s.stateLock.Unlock()
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := netlink.LinkSetHardwareAddr(iface, hw); err != nil {
|
||||||
|
s.stateLock.Unlock()
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := netlink.LinkSetUp(iface); err != nil {
|
||||||
|
s.stateLock.Unlock()
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s.stateLock.Unlock()
|
||||||
|
|
||||||
|
if s.dhcpClient != nil && strings.TrimSpace(s.config.IPv4Mode.String) == "dhcp" {
|
||||||
|
_ = s.dhcpClient.Renew()
|
||||||
|
}
|
||||||
|
if _, err := s.update(); err != nil {
|
||||||
|
return normalized, err
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
|
func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
|
||||||
s.stateLock.Lock()
|
s.stateLock.Lock()
|
||||||
defer s.stateLock.Unlock()
|
defer s.stateLock.Unlock()
|
||||||
|
|||||||
@@ -131,10 +131,10 @@ func (u *UsbGadget) GetKeyboardState() KeyboardState {
|
|||||||
return u.keyboardState
|
return u.keyboardState
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UsbGadget) listenKeyboardEvents() {
|
func (u *UsbGadget) listenKeyboardEvents(ctx context.Context, file *os.File) {
|
||||||
var path string
|
var path string
|
||||||
if u.keyboardHidFile != nil {
|
if file != nil {
|
||||||
path = u.keyboardHidFile.Name()
|
path = file.Name()
|
||||||
}
|
}
|
||||||
l := u.log.With().Str("listener", "keyboardEvents").Str("path", path).Logger()
|
l := u.log.With().Str("listener", "keyboardEvents").Str("path", path).Logger()
|
||||||
l.Trace().Msg("starting")
|
l.Trace().Msg("starting")
|
||||||
@@ -143,12 +143,12 @@ func (u *UsbGadget) listenKeyboardEvents() {
|
|||||||
buf := make([]byte, hidReadBufferSize)
|
buf := make([]byte, hidReadBufferSize)
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-u.keyboardStateCtx.Done():
|
case <-ctx.Done():
|
||||||
l.Info().Msg("context done")
|
l.Info().Msg("context done")
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
l.Trace().Msg("reading from keyboard")
|
l.Trace().Msg("reading from keyboard")
|
||||||
if u.keyboardHidFile == nil {
|
if file == nil {
|
||||||
u.logWithSupression("keyboardHidFileNil", 100, &l, nil, "keyboardHidFile is nil")
|
u.logWithSupression("keyboardHidFileNil", 100, &l, nil, "keyboardHidFile is nil")
|
||||||
// show the error every 100 times to avoid spamming the logs
|
// show the error every 100 times to avoid spamming the logs
|
||||||
time.Sleep(time.Second)
|
time.Sleep(time.Second)
|
||||||
@@ -157,16 +157,26 @@ func (u *UsbGadget) listenKeyboardEvents() {
|
|||||||
// reset the counter
|
// reset the counter
|
||||||
u.resetLogSuppressionCounter("keyboardHidFileNil")
|
u.resetLogSuppressionCounter("keyboardHidFileNil")
|
||||||
|
|
||||||
n, err := u.keyboardHidFile.Read(buf)
|
n, err := file.Read(buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
l.Info().Msg("context canceled while reading keyboard HID file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
u.logWithSupression("keyboardHidFileRead", 100, &l, err, "failed to read")
|
u.logWithSupression("keyboardHidFileRead", 100, &l, err, "failed to read")
|
||||||
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")
|
u.resetLogSuppressionCounter("keyboardHidFileRead")
|
||||||
|
|
||||||
l.Trace().Int("n", n).Bytes("buf", buf).Msg("got data from keyboard")
|
l.Trace().Int("n", n).Bytes("buf", buf).Msg("got data from keyboard")
|
||||||
if n != 1 {
|
if n < 1 {
|
||||||
l.Trace().Int("n", n).Msg("expected 1 byte, got")
|
l.Info().Int("n", n).Msg("expected at least 1 byte, got 0")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
u.updateKeyboardState(buf[0])
|
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 {
|
if u.keyboardHidFile != nil {
|
||||||
|
u.keyboardHidFile.Close()
|
||||||
|
u.keyboardHidFile = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) openKeyboardHidFileLocked(forceReopen bool) error {
|
||||||
|
if forceReopen {
|
||||||
|
u.closeKeyboardHidFileLocked()
|
||||||
|
} else if u.keyboardHidFile != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
file, err := openWithTimeout("/dev/hidg0", os.O_RDWR, 0666, 3*time.Second)
|
||||||
u.keyboardHidFile, err = os.OpenFile("/dev/hidg0", os.O_RDWR, 0666)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) || strings.Contains(err.Error(), "no such file or directory") || strings.Contains(err.Error(), "no such device") {
|
if errors.Is(err, os.ErrNotExist) || strings.Contains(err.Error(), "no such file or directory") || strings.Contains(err.Error(), "no such device") {
|
||||||
u.log.Error().
|
u.log.Error().
|
||||||
@@ -196,33 +245,203 @@ func (u *UsbGadget) openKeyboardHidFile() error {
|
|||||||
return fmt.Errorf("failed to open hidg0: %w", err)
|
return fmt.Errorf("failed to open hidg0: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.keyboardStateCancel != nil {
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
u.keyboardStateCancel()
|
u.keyboardHidFile = file
|
||||||
}
|
u.keyboardStateCtx = ctx
|
||||||
|
u.keyboardStateCancel = cancel
|
||||||
u.keyboardStateCtx, u.keyboardStateCancel = context.WithCancel(context.Background())
|
u.listenKeyboardEvents(ctx, file)
|
||||||
u.listenKeyboardEvents()
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) openKeyboardHidFile() error {
|
||||||
|
u.keyboardLock.Lock()
|
||||||
|
defer u.keyboardLock.Unlock()
|
||||||
|
|
||||||
|
return u.openKeyboardHidFileLocked(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) reopenKeyboardHidFile() error {
|
||||||
|
u.keyboardLock.Lock()
|
||||||
|
defer u.keyboardLock.Unlock()
|
||||||
|
|
||||||
|
return u.openKeyboardHidFileLocked(true)
|
||||||
|
}
|
||||||
|
|
||||||
func (u *UsbGadget) OpenKeyboardHidFile() error {
|
func (u *UsbGadget) OpenKeyboardHidFile() error {
|
||||||
return u.openKeyboardHidFile()
|
return u.openKeyboardHidFile()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
|
func (u *UsbGadget) ReopenKeyboardHidFile() error {
|
||||||
if err := u.openKeyboardHidFile(); err != nil {
|
return u.reopenKeyboardHidFile()
|
||||||
return err
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) keyboardWriteHidFileLocked(modifier byte, keys []byte) error {
|
||||||
|
if len(keys) > 6 {
|
||||||
|
keys = keys[:6]
|
||||||
|
}
|
||||||
|
if len(keys) < 6 {
|
||||||
|
keys = append(keys, make([]byte, 6-len(keys))...)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, 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 {
|
if err != nil {
|
||||||
u.logWithSupression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0")
|
u.logWithSupression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0")
|
||||||
u.keyboardHidFile.Close()
|
u.closeKeyboardHidFileLocked()
|
||||||
u.keyboardHidFile = nil
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u.resetLogSuppressionCounter("keyboardWriteHidFile")
|
u.resetLogSuppressionCounter("keyboardWriteHidFile")
|
||||||
|
|
||||||
|
u.resetUserInputTime()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type autoReleaseTimer struct {
|
||||||
|
timer *time.Timer
|
||||||
|
key byte
|
||||||
|
active bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeysDownState struct {
|
||||||
|
Modifier byte
|
||||||
|
Keys [6]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) scheduleAutoRelease(key byte) {
|
||||||
|
// Cancel existing timer for this key
|
||||||
|
for i := range u.autoReleaseTimers {
|
||||||
|
if u.autoReleaseTimers[i].key == key && u.autoReleaseTimers[i].active {
|
||||||
|
u.autoReleaseTimers[i].timer.Stop()
|
||||||
|
u.autoReleaseTimers[i].active = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule new timer
|
||||||
|
timer := time.AfterFunc(100*time.Millisecond, func() {
|
||||||
|
u.autoReleaseKey(key)
|
||||||
|
})
|
||||||
|
|
||||||
|
u.autoReleaseTimers = append(u.autoReleaseTimers, autoReleaseTimer{
|
||||||
|
timer: timer,
|
||||||
|
key: key,
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) autoReleaseKey(key byte) {
|
||||||
|
u.keyboardLock.Lock()
|
||||||
|
defer u.keyboardLock.Unlock()
|
||||||
|
|
||||||
|
// Remove key from buffer
|
||||||
|
found := false
|
||||||
|
for i := 0; i < len(u.keysDownState.Keys); i++ {
|
||||||
|
if u.keysDownState.Keys[i] == key {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
if found && i < len(u.keysDownState.Keys)-1 {
|
||||||
|
u.keysDownState.Keys[i] = u.keysDownState.Keys[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
u.keysDownState.Keys[len(u.keysDownState.Keys)-1] = 0
|
||||||
|
u.keyboardWriteHidFileLocked(u.keysDownState.Modifier, u.keysDownState.Keys[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark timer as inactive
|
||||||
|
for i := range u.autoReleaseTimers {
|
||||||
|
if u.autoReleaseTimers[i].key == key && u.autoReleaseTimers[i].active {
|
||||||
|
u.autoReleaseTimers[i].active = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) cancelAutoRelease(key byte) {
|
||||||
|
for i := range u.autoReleaseTimers {
|
||||||
|
if u.autoReleaseTimers[i].key == key && u.autoReleaseTimers[i].active {
|
||||||
|
u.autoReleaseTimers[i].timer.Stop()
|
||||||
|
u.autoReleaseTimers[i].active = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) resetAllAutoReleaseTimers() {
|
||||||
|
for i := range u.autoReleaseTimers {
|
||||||
|
if u.autoReleaseTimers[i].active {
|
||||||
|
u.autoReleaseTimers[i].timer.Stop()
|
||||||
|
u.autoReleaseTimers[i].active = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) KeypressReport(key byte, press bool) error {
|
||||||
|
u.keyboardLock.Lock()
|
||||||
|
defer u.keyboardLock.Unlock()
|
||||||
|
|
||||||
|
if press {
|
||||||
|
// Check if key already in buffer
|
||||||
|
for _, k := range u.keysDownState.Keys {
|
||||||
|
if k == key {
|
||||||
|
return nil // Already pressed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find empty slot
|
||||||
|
emptySlot := -1
|
||||||
|
for i, k := range u.keysDownState.Keys {
|
||||||
|
if k == 0 {
|
||||||
|
emptySlot = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if emptySlot == -1 {
|
||||||
|
// Buffer full - ErrorRollOver
|
||||||
|
u.keysDownState.Keys = [6]byte{0x01, 0x01, 0x01, 0x01, 0x01, 0x01}
|
||||||
|
} else {
|
||||||
|
u.keysDownState.Keys[emptySlot] = key
|
||||||
|
}
|
||||||
|
|
||||||
|
u.scheduleAutoRelease(key)
|
||||||
|
} else {
|
||||||
|
// Remove key from buffer
|
||||||
|
found := false
|
||||||
|
for i := 0; i < len(u.keysDownState.Keys); i++ {
|
||||||
|
if u.keysDownState.Keys[i] == key {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
if found && i < len(u.keysDownState.Keys)-1 {
|
||||||
|
u.keysDownState.Keys[i] = u.keysDownState.Keys[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
u.keysDownState.Keys[len(u.keysDownState.Keys)-1] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
u.cancelAutoRelease(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return u.keyboardWriteHidFileLocked(u.keysDownState.Modifier, u.keysDownState.Keys[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) KeypressKeepAlive() error {
|
||||||
|
u.keyboardLock.Lock()
|
||||||
|
defer u.keyboardLock.Unlock()
|
||||||
|
|
||||||
|
// Reset auto-release timers for all currently held keys
|
||||||
|
for _, key := range u.keysDownState.Keys {
|
||||||
|
if key != 0 {
|
||||||
|
u.scheduleAutoRelease(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,18 +449,8 @@ func (u *UsbGadget) KeyboardReport(modifier uint8, keys []uint8) error {
|
|||||||
u.keyboardLock.Lock()
|
u.keyboardLock.Lock()
|
||||||
defer u.keyboardLock.Unlock()
|
defer u.keyboardLock.Unlock()
|
||||||
|
|
||||||
if len(keys) > 6 {
|
u.keysDownState.Modifier = modifier
|
||||||
keys = keys[:6]
|
copy(u.keysDownState.Keys[:], keys)
|
||||||
}
|
|
||||||
if len(keys) < 6 {
|
|
||||||
keys = append(keys, make([]uint8, 6-len(keys))...)
|
|
||||||
}
|
|
||||||
|
|
||||||
err := u.keyboardWriteHidFile([]byte{modifier, 0, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]})
|
return u.keyboardWriteHidFileLocked(modifier, keys)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
u.resetUserInputTime()
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ func (u *UsbGadget) RebindUsb(ignoreUnbindError bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetUsbState returns the current state of the USB gadget
|
// GetUsbState returns the current state of the USB gadget
|
||||||
func (u *UsbGadget) GetUsbState() (state string) {
|
func (u *UsbGadget) GetUsbState(enhancedDetection bool) (state string) {
|
||||||
// Check the auxiliary disc node first
|
if enhancedDetection {
|
||||||
discFile := "/sys/devices/platform/ff3e0000.usb2-phy/disc"
|
discFile := "/sys/devices/platform/ff3e0000.usb2-phy/disc"
|
||||||
discBytes, err := os.ReadFile(discFile)
|
discBytes, err := os.ReadFile(discFile)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -61,6 +61,7 @@ func (u *UsbGadget) GetUsbState() (state string) {
|
|||||||
return "not attached"
|
return "not attached"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
stateFile := path.Join("/sys/class/udc", u.udc, "state")
|
stateFile := path.Join("/sys/class/udc", u.udc, "state")
|
||||||
stateBytes, err := os.ReadFile(stateFile)
|
stateBytes, err := os.ReadFile(stateFile)
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ type UsbGadget struct {
|
|||||||
onKeyboardStateChange *func(state KeyboardState)
|
onKeyboardStateChange *func(state KeyboardState)
|
||||||
onHidDeviceMissing *func(device string, err error)
|
onHidDeviceMissing *func(device string, err error)
|
||||||
|
|
||||||
|
keysDownState KeysDownState
|
||||||
|
autoReleaseTimers []autoReleaseTimer
|
||||||
|
|
||||||
log *zerolog.Logger
|
log *zerolog.Logger
|
||||||
|
|
||||||
logSuppressionCounter map[string]int
|
logSuppressionCounter map[string]int
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ package usbgadget
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
@@ -107,3 +110,36 @@ func (u *UsbGadget) resetLogSuppressionCounter(counterName string) {
|
|||||||
u.logSuppressionCounter[counterName] = 0
|
u.logSuppressionCounter[counterName] = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hidWriteTimeout = 50 * time.Millisecond
|
||||||
|
|
||||||
|
func (u *UsbGadget) writeWithTimeout(file *os.File, data []byte) (n int, err error) {
|
||||||
|
if err := file.SetWriteDeadline(time.Now().Add(hidWriteTimeout)); err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err = file.Write(data)
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.log.Trace().
|
||||||
|
Str("file", file.Name()).
|
||||||
|
Bytes("data", data).
|
||||||
|
Err(err).
|
||||||
|
Msg("write failed")
|
||||||
|
|
||||||
|
if errors.Is(err, os.ErrDeadlineExceeded) {
|
||||||
|
u.logWithSupression(
|
||||||
|
fmt.Sprintf("writeWithTimeout_%s", file.Name()),
|
||||||
|
1000,
|
||||||
|
u.log,
|
||||||
|
err,
|
||||||
|
"write timed out: %s",
|
||||||
|
file.Name(),
|
||||||
|
)
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|||||||
9
io.go
9
io.go
@@ -105,6 +105,15 @@ func setLedMode(ledConfigPath string, mode string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to set LED trigger: %v", err)
|
return fmt.Errorf("failed to set LED trigger: %v", err)
|
||||||
}
|
}
|
||||||
|
case "disabled":
|
||||||
|
err := os.WriteFile(ledConfigPath+"/trigger", []byte("none"), 0644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to set LED trigger: %v", err)
|
||||||
|
}
|
||||||
|
err = os.WriteFile(ledConfigPath+"/brightness", []byte("0"), 0644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to set LED brightness: %v", err)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("invalid LED mode: %s", mode)
|
return fmt.Errorf("invalid LED mode: %s", mode)
|
||||||
}
|
}
|
||||||
|
|||||||
468
jsonrpc.go
468
jsonrpc.go
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -78,15 +79,12 @@ func writeJSONRPCEvent(event string, params interface{}, session *Session) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
requestString := string(requestBytes)
|
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)
|
err = session.RPCChannel.SendText(requestString)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,6 +161,16 @@ func rpcPing() (string, error) {
|
|||||||
return "pong", nil
|
return "pong", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BootStorageTypeResponse struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcGetBootStorageType() (*BootStorageTypeResponse, error) {
|
||||||
|
return &BootStorageTypeResponse{
|
||||||
|
Type: string(GetBootStorageType()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func rpcGetDeviceID() (string, error) {
|
func rpcGetDeviceID() (string, error) {
|
||||||
return GetDeviceID(), nil
|
return GetDeviceID(), nil
|
||||||
}
|
}
|
||||||
@@ -225,6 +233,124 @@ func rpcSetStreamEncodecType(encodecType string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RcQpParams struct {
|
||||||
|
S32FirstFrameStartQp int `json:"s32FirstFrameStartQp"`
|
||||||
|
U32StepQp int `json:"u32StepQp"`
|
||||||
|
U32MinQp int `json:"u32MinQp"`
|
||||||
|
U32MaxQp int `json:"u32MaxQp"`
|
||||||
|
U32MinIQp int `json:"u32MinIQp"`
|
||||||
|
U32MaxIQp int `json:"u32MaxIQp"`
|
||||||
|
S32DeltIpQp int `json:"s32DeltIpQp"`
|
||||||
|
S32MaxReEncodeTimes int `json:"s32MaxReEncodeTimes"`
|
||||||
|
U32FrmMaxQp int `json:"u32FrmMaxQp"`
|
||||||
|
U32FrmMinQp int `json:"u32FrmMinQp"`
|
||||||
|
U32FrmMinIQp int `json:"u32FrmMinIQp"`
|
||||||
|
U32FrmMaxIQp int `json:"u32FrmMaxIQp"`
|
||||||
|
U32MotionStaticSwitchFrmQp int `json:"u32MotionStaticSwitchFrmQp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VideoRcConfigParams struct {
|
||||||
|
H264 RcQpParams `json:"h264"`
|
||||||
|
H265 RcQpParams `json:"h265"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcSetVideoRc(params VideoRcConfigParams) error {
|
||||||
|
logger.Info().Interface("params", params).Msg("Setting video RC params")
|
||||||
|
rcParams := map[string]interface{}{
|
||||||
|
"h264": map[string]interface{}{
|
||||||
|
"s32FirstFrameStartQp": params.H264.S32FirstFrameStartQp,
|
||||||
|
"u32StepQp": params.H264.U32StepQp,
|
||||||
|
"u32MinQp": params.H264.U32MinQp,
|
||||||
|
"u32MaxQp": params.H264.U32MaxQp,
|
||||||
|
"u32MinIQp": params.H264.U32MinIQp,
|
||||||
|
"u32MaxIQp": params.H264.U32MaxIQp,
|
||||||
|
"s32DeltIpQp": params.H264.S32DeltIpQp,
|
||||||
|
"s32MaxReEncodeTimes": params.H264.S32MaxReEncodeTimes,
|
||||||
|
"u32FrmMaxQp": params.H264.U32FrmMaxQp,
|
||||||
|
"u32FrmMinQp": params.H264.U32FrmMinQp,
|
||||||
|
"u32FrmMinIQp": params.H264.U32FrmMinIQp,
|
||||||
|
"u32FrmMaxIQp": params.H264.U32FrmMaxIQp,
|
||||||
|
"u32MotionStaticSwitchFrmQp": params.H264.U32MotionStaticSwitchFrmQp,
|
||||||
|
},
|
||||||
|
"h265": map[string]interface{}{
|
||||||
|
"s32FirstFrameStartQp": params.H265.S32FirstFrameStartQp,
|
||||||
|
"u32StepQp": params.H265.U32StepQp,
|
||||||
|
"u32MinQp": params.H265.U32MinQp,
|
||||||
|
"u32MaxQp": params.H265.U32MaxQp,
|
||||||
|
"u32MinIQp": params.H265.U32MinIQp,
|
||||||
|
"u32MaxIQp": params.H265.U32MaxIQp,
|
||||||
|
"s32DeltIpQp": params.H265.S32DeltIpQp,
|
||||||
|
"s32MaxReEncodeTimes": params.H265.S32MaxReEncodeTimes,
|
||||||
|
"u32FrmMaxQp": params.H265.U32FrmMaxQp,
|
||||||
|
"u32FrmMinQp": params.H265.U32FrmMinQp,
|
||||||
|
"u32FrmMinIQp": params.H265.U32FrmMinIQp,
|
||||||
|
"u32FrmMaxIQp": params.H265.U32FrmMaxIQp,
|
||||||
|
"u32MotionStaticSwitchFrmQp": params.H265.U32MotionStaticSwitchFrmQp,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var _, err = CallCtrlAction("set_video_rc", rcParams)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcGetVideoRc() (VideoRcConfigParams, error) {
|
||||||
|
resp, err := CallCtrlAction("get_video_rc", nil)
|
||||||
|
if err != nil {
|
||||||
|
return VideoRcConfigParams{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := resp.Result
|
||||||
|
if result == nil {
|
||||||
|
return VideoRcConfigParams{}, errors.New("invalid response format")
|
||||||
|
}
|
||||||
|
|
||||||
|
h264Map, _ := result["h264"].(map[string]interface{})
|
||||||
|
h265Map, _ := result["h265"].(map[string]interface{})
|
||||||
|
|
||||||
|
getInt := func(m map[string]interface{}, k string) int {
|
||||||
|
if v, ok := m[k].(float64); ok {
|
||||||
|
return int(v)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
getUint := func(m map[string]interface{}, k string) int {
|
||||||
|
return getInt(m, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
rc := VideoRcConfigParams{
|
||||||
|
H264: RcQpParams{
|
||||||
|
S32FirstFrameStartQp: getInt(h264Map, "s32FirstFrameStartQp"),
|
||||||
|
U32StepQp: getUint(h264Map, "u32StepQp"),
|
||||||
|
U32MinQp: getUint(h264Map, "u32MinQp"),
|
||||||
|
U32MaxQp: getUint(h264Map, "u32MaxQp"),
|
||||||
|
U32MinIQp: getUint(h264Map, "u32MinIQp"),
|
||||||
|
U32MaxIQp: getUint(h264Map, "u32MaxIQp"),
|
||||||
|
S32DeltIpQp: getInt(h264Map, "s32DeltIpQp"),
|
||||||
|
S32MaxReEncodeTimes: getInt(h264Map, "s32MaxReEncodeTimes"),
|
||||||
|
U32FrmMaxQp: getUint(h264Map, "u32FrmMaxQp"),
|
||||||
|
U32FrmMinQp: getUint(h264Map, "u32FrmMinQp"),
|
||||||
|
U32FrmMinIQp: getUint(h264Map, "u32FrmMinIQp"),
|
||||||
|
U32FrmMaxIQp: getUint(h264Map, "u32FrmMaxIQp"),
|
||||||
|
U32MotionStaticSwitchFrmQp: getUint(h264Map, "u32MotionStaticSwitchFrmQp"),
|
||||||
|
},
|
||||||
|
H265: RcQpParams{
|
||||||
|
S32FirstFrameStartQp: getInt(h265Map, "s32FirstFrameStartQp"),
|
||||||
|
U32StepQp: getUint(h265Map, "u32StepQp"),
|
||||||
|
U32MinQp: getUint(h265Map, "u32MinQp"),
|
||||||
|
U32MaxQp: getUint(h265Map, "u32MaxQp"),
|
||||||
|
U32MinIQp: getUint(h265Map, "u32MinIQp"),
|
||||||
|
U32MaxIQp: getUint(h265Map, "u32MaxIQp"),
|
||||||
|
S32DeltIpQp: getInt(h265Map, "s32DeltIpQp"),
|
||||||
|
S32MaxReEncodeTimes: getInt(h265Map, "s32MaxReEncodeTimes"),
|
||||||
|
U32FrmMaxQp: getUint(h265Map, "u32FrmMaxQp"),
|
||||||
|
U32FrmMinQp: getUint(h265Map, "u32FrmMinQp"),
|
||||||
|
U32FrmMinIQp: getUint(h265Map, "u32FrmMinIQp"),
|
||||||
|
U32FrmMaxIQp: getUint(h265Map, "u32FrmMaxIQp"),
|
||||||
|
U32MotionStaticSwitchFrmQp: getUint(h265Map, "u32MotionStaticSwitchFrmQp"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return rc, nil
|
||||||
|
}
|
||||||
|
|
||||||
func rpcSetNpuAppStatus(enable bool) error {
|
func rpcSetNpuAppStatus(enable bool) error {
|
||||||
logger.Info().Bool("enable", enable).Msg("Setting NPU app status")
|
logger.Info().Bool("enable", enable).Msg("Setting NPU app status")
|
||||||
var _, err = CallCtrlAction("set_yolo_enable", map[string]interface{}{"enable": enable})
|
var _, err = CallCtrlAction("set_yolo_enable", map[string]interface{}{"enable": enable})
|
||||||
@@ -368,6 +494,36 @@ func rpcGetUpdateStatus() (*UpdateStatus, error) {
|
|||||||
return updateStatus, nil
|
return updateStatus, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SelfSignatureStatus struct {
|
||||||
|
AppSignatureAbsent bool `json:"appSignatureAbsent,omitempty"`
|
||||||
|
AppSignatureInvalid bool `json:"appSignatureInvalid,omitempty"`
|
||||||
|
AppNoPublicKey bool `json:"appNoPublicKey,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcGetSelfSignatureStatus() (*SelfSignatureStatus, error) {
|
||||||
|
return getSelfSignatureStatus(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSelfSignatureStatus() *SelfSignatureStatus {
|
||||||
|
status := &SelfSignatureStatus{}
|
||||||
|
publicKey := getOTAPublicKey()
|
||||||
|
|
||||||
|
appBinPath := "/userdata/picokvm/bin/kvm_app"
|
||||||
|
appSigPath := appBinPath + ".sig"
|
||||||
|
|
||||||
|
status.AppSignatureAbsent = isSigFileAbsent(appSigPath)
|
||||||
|
|
||||||
|
if !status.AppSignatureAbsent {
|
||||||
|
if publicKey == nil {
|
||||||
|
status.AppNoPublicKey = true
|
||||||
|
} else {
|
||||||
|
status.AppSignatureInvalid = !verifyLocalFileSignature(appBinPath, appSigPath, publicKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
func rpcTryUpdate() error {
|
func rpcTryUpdate() error {
|
||||||
includePreRelease := config.IncludePreRelease
|
includePreRelease := config.IncludePreRelease
|
||||||
go func() {
|
go func() {
|
||||||
@@ -379,6 +535,14 @@ func rpcTryUpdate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcUpdateSignatures() (*SignatureUpdateResult, error) {
|
||||||
|
result, err := UpdateSignatures(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("failed to update signatures")
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
func rpcGetCustomUpdateBaseURL() (string, error) {
|
func rpcGetCustomUpdateBaseURL() (string, error) {
|
||||||
return customUpdateBaseURL, nil
|
return customUpdateBaseURL, nil
|
||||||
}
|
}
|
||||||
@@ -388,6 +552,32 @@ func rpcSetCustomUpdateBaseURL(baseURL string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcGetUpdateDownloadProxy() (string, error) {
|
||||||
|
return config.UpdateDownloadProxy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcSetUpdateDownloadProxy(proxy string) error {
|
||||||
|
proxy = strings.TrimSpace(proxy)
|
||||||
|
if proxy != "" {
|
||||||
|
parsed, err := url.Parse(proxy)
|
||||||
|
if err != nil || strings.TrimSpace(parsed.Scheme) == "" || strings.TrimSpace(parsed.Host) == "" {
|
||||||
|
return fmt.Errorf("invalid update download proxy")
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||||
|
return fmt.Errorf("update download proxy must use http or https")
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(proxy, "/") {
|
||||||
|
proxy += "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.UpdateDownloadProxy = proxy
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func rpcSetDisplayRotation(params DisplayRotationSettings) error {
|
func rpcSetDisplayRotation(params DisplayRotationSettings) error {
|
||||||
var err error
|
var err error
|
||||||
_, err = lvDispSetRotation(params.Rotation)
|
_, err = lvDispSetRotation(params.Rotation)
|
||||||
@@ -723,6 +913,29 @@ func rpcSetUsbEmulationState(enabled bool) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcGetUsbEnhancedDetection() (bool, error) {
|
||||||
|
ensureConfigLoaded()
|
||||||
|
return config.UsbEnhancedDetection, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcSetUsbEnhancedDetection(enabled bool) error {
|
||||||
|
ensureConfigLoaded()
|
||||||
|
if config.UsbEnhancedDetection == enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
config.UsbEnhancedDetection = enabled
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gadget != nil {
|
||||||
|
checkUSBState()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func rpcGetUsbConfig() (usbgadget.Config, error) {
|
func rpcGetUsbConfig() (usbgadget.Config, error) {
|
||||||
LoadConfig()
|
LoadConfig()
|
||||||
return *config.UsbConfig, nil
|
return *config.UsbConfig, nil
|
||||||
@@ -762,6 +975,90 @@ func rpcResetConfig() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcGetConfigRaw() (string, error) {
|
||||||
|
configLock.Lock()
|
||||||
|
defer configLock.Unlock()
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(config, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcSetConfigRaw(configStr string) error {
|
||||||
|
var newConfig Config
|
||||||
|
if err := json.Unmarshal([]byte(configStr), &newConfig); err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configLock.Lock()
|
||||||
|
config = &newConfig
|
||||||
|
configLock.Unlock()
|
||||||
|
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info().Msg("Configuration updated via raw JSON")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type RtcServersConfig struct {
|
||||||
|
STUN string `json:"stun"`
|
||||||
|
DefaultSTUN string `json:"defaultStun"`
|
||||||
|
TurnServers []TurnServer `json:"turnServers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcGetRtcServersConfig() (RtcServersConfig, error) {
|
||||||
|
return RtcServersConfig{
|
||||||
|
STUN: config.STUN,
|
||||||
|
DefaultSTUN: DefaultSTUN,
|
||||||
|
TurnServers: config.TurnServers,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcSetStunServer(stun string) error {
|
||||||
|
config.STUN = stun
|
||||||
|
return SaveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetTurnServersParams struct {
|
||||||
|
Servers []TurnServer `json:"servers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcSetTurnServers(params SetTurnServersParams) error {
|
||||||
|
config.TurnServers = params.Servers
|
||||||
|
if config.TurnServers == nil {
|
||||||
|
config.TurnServers = []TurnServer{}
|
||||||
|
}
|
||||||
|
return SaveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
type IceServerJSON struct {
|
||||||
|
URLs []string `json:"urls"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Credential string `json:"credential,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcGetIceServers() ([]IceServerJSON, error) {
|
||||||
|
raw := buildICEServers()
|
||||||
|
out := make([]IceServerJSON, 0, len(raw))
|
||||||
|
for _, server := range raw {
|
||||||
|
credential := ""
|
||||||
|
if server.Credential != nil {
|
||||||
|
credential = fmt.Sprintf("%v", server.Credential)
|
||||||
|
}
|
||||||
|
out = append(out, IceServerJSON{
|
||||||
|
URLs: server.URLs,
|
||||||
|
Username: server.Username,
|
||||||
|
Credential: credential,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func rpcGetActiveExtension() (string, error) {
|
func rpcGetActiveExtension() (string, error) {
|
||||||
return config.ActiveExtension, nil
|
return config.ActiveExtension, nil
|
||||||
}
|
}
|
||||||
@@ -1036,6 +1333,30 @@ func rpcSetLocalLoopbackOnly(enabled bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcGetApiKey() (string, error) {
|
||||||
|
return config.APIKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcSetApiKey(apiKey string) error {
|
||||||
|
config.APIKey = apiKey
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcGenerateApiKey() (string, error) {
|
||||||
|
key, err := generateAPIKey()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate API key: %w", err)
|
||||||
|
}
|
||||||
|
config.APIKey = key
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
type IOSettings struct {
|
type IOSettings struct {
|
||||||
IO0Status bool `json:"io0Status"`
|
IO0Status bool `json:"io0Status"`
|
||||||
IO1Status bool `json:"io1Status"`
|
IO1Status bool `json:"io1Status"`
|
||||||
@@ -1194,10 +1515,117 @@ func rpcSetAutoMountSystemInfo(enabled bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcGetFirewallConfig() (FirewallConfig, error) {
|
||||||
|
LoadConfig()
|
||||||
|
if systemCfg, err := ReadFirewallConfigFromSystem(); err == nil && systemCfg != nil {
|
||||||
|
return *systemCfg, nil
|
||||||
|
}
|
||||||
|
if config.Firewall == nil {
|
||||||
|
return *defaultConfig.Firewall, nil
|
||||||
|
}
|
||||||
|
return *config.Firewall, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcSetFirewallConfig(firewallCfg FirewallConfig) error {
|
||||||
|
LoadConfig()
|
||||||
|
managedCfg := firewallCfg
|
||||||
|
managedCfg.PortForwards = filterManagedPortForwards(firewallCfg.PortForwards)
|
||||||
|
if err := ApplyFirewallConfig(&managedCfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
config.Firewall = &managedCfg
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterManagedPortForwards(in []FirewallPortRule) []FirewallPortRule {
|
||||||
|
out := make([]FirewallPortRule, 0, len(in))
|
||||||
|
for _, r := range in {
|
||||||
|
if r.Managed != nil && !*r.Managed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func rpcConfirmOtherSession() (bool, error) {
|
func rpcConfirmOtherSession() (bool, error) {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const jpegScreenshotPath = "/userdata/picokvm/screenshot/kvm_screenshot.jpg"
|
||||||
|
|
||||||
|
// StartJpegCapture starts continuous JPEG capture mode.
|
||||||
|
func StartJpegCapture() error {
|
||||||
|
_, err := CallCtrlAction("jpeg_capture_start", nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to start JPEG capture: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopJpegCapture stops continuous JPEG capture mode.
|
||||||
|
func StopJpegCapture() error {
|
||||||
|
_, err := CallCtrlAction("jpeg_capture_stop", nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to stop JPEG capture: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureScreenshot captures a JPEG screenshot using hardware encoder.
|
||||||
|
func captureScreenshot(format string) ([]byte, error) {
|
||||||
|
if format != "jpeg" && format != "jpg" {
|
||||||
|
return nil, fmt.Errorf("only JPEG format is supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info().Msg("triggering JPEG snapshot via jpeg_take_snapshot")
|
||||||
|
|
||||||
|
if err := os.MkdirAll("/userdata/picokvm/screenshot", 0o755); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("failed to create screenshot directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Remove(jpegScreenshotPath)
|
||||||
|
|
||||||
|
// drain any stale signal before triggering
|
||||||
|
select {
|
||||||
|
case <-jpegReadyCh:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := CallCtrlAction("jpeg_take_snapshot", nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Err(err).Msg("jpeg_take_snapshot failed")
|
||||||
|
return nil, fmt.Errorf("failed to trigger JPEG capture: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wait for jpeg_ready event from native, fall back to polling on timeout
|
||||||
|
timeout := time.NewTimer(2 * time.Second)
|
||||||
|
defer timeout.Stop()
|
||||||
|
select {
|
||||||
|
case <-jpegReadyCh:
|
||||||
|
case <-timeout.C:
|
||||||
|
logger.Warn().Msg("jpeg_ready event not received within 2s, falling back to polling")
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if data, err := os.ReadFile(jpegScreenshotPath); err == nil && len(data) > 0 {
|
||||||
|
logger.Info().Int("size", len(data)).Msg("JPEG captured (fallback polling)")
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("JPEG file not found at %s", jpegScreenshotPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(jpegScreenshotPath)
|
||||||
|
if err != nil || len(data) == 0 {
|
||||||
|
return nil, fmt.Errorf("JPEG file not readable after jpeg_ready event: %w", err)
|
||||||
|
}
|
||||||
|
logger.Info().Int("size", len(data)).Msg("JPEG captured successfully")
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
var rpcHandlers = map[string]RPCHandler{
|
var rpcHandlers = map[string]RPCHandler{
|
||||||
"ping": {Func: rpcPing},
|
"ping": {Func: rpcPing},
|
||||||
"reboot": {Func: rpcReboot, Params: []string{"force"}},
|
"reboot": {Func: rpcReboot, Params: []string{"force"}},
|
||||||
@@ -1205,6 +1633,7 @@ var rpcHandlers = map[string]RPCHandler{
|
|||||||
"getNetworkState": {Func: rpcGetNetworkState},
|
"getNetworkState": {Func: rpcGetNetworkState},
|
||||||
"getNetworkSettings": {Func: rpcGetNetworkSettings},
|
"getNetworkSettings": {Func: rpcGetNetworkSettings},
|
||||||
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
|
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
|
||||||
|
"setEthernetMacAddress": {Func: rpcSetEthernetMacAddress, Params: []string{"macAddress"}},
|
||||||
"renewDHCPLease": {Func: rpcRenewDHCPLease},
|
"renewDHCPLease": {Func: rpcRenewDHCPLease},
|
||||||
"requestDHCPAddress": {Func: rpcRequestDHCPAddress, Params: []string{"ip"}},
|
"requestDHCPAddress": {Func: rpcRequestDHCPAddress, Params: []string{"ip"}},
|
||||||
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
|
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
|
||||||
@@ -1234,12 +1663,19 @@ var rpcHandlers = map[string]RPCHandler{
|
|||||||
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
|
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
|
||||||
"getLocalUpdateStatus": {Func: rpcGetLocalUpdateStatus},
|
"getLocalUpdateStatus": {Func: rpcGetLocalUpdateStatus},
|
||||||
"getUpdateStatus": {Func: rpcGetUpdateStatus},
|
"getUpdateStatus": {Func: rpcGetUpdateStatus},
|
||||||
|
"getSelfSignatureStatus": {Func: rpcGetSelfSignatureStatus},
|
||||||
"tryUpdate": {Func: rpcTryUpdate},
|
"tryUpdate": {Func: rpcTryUpdate},
|
||||||
|
"updateSignatures": {Func: rpcUpdateSignatures},
|
||||||
"getCustomUpdateBaseURL": {Func: rpcGetCustomUpdateBaseURL},
|
"getCustomUpdateBaseURL": {Func: rpcGetCustomUpdateBaseURL},
|
||||||
"setCustomUpdateBaseURL": {Func: rpcSetCustomUpdateBaseURL, Params: []string{"baseURL"}},
|
"setCustomUpdateBaseURL": {Func: rpcSetCustomUpdateBaseURL, Params: []string{"baseURL"}},
|
||||||
|
"getUpdateDownloadProxy": {Func: rpcGetUpdateDownloadProxy},
|
||||||
|
"setUpdateDownloadProxy": {Func: rpcSetUpdateDownloadProxy, Params: []string{"proxy"}},
|
||||||
"getDevModeState": {Func: rpcGetDevModeState},
|
"getDevModeState": {Func: rpcGetDevModeState},
|
||||||
"getSSHKeyState": {Func: rpcGetSSHKeyState},
|
"getSSHKeyState": {Func: rpcGetSSHKeyState},
|
||||||
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
|
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
|
||||||
|
"getApiKey": {Func: rpcGetApiKey},
|
||||||
|
"setApiKey": {Func: rpcSetApiKey, Params: []string{"apiKey"}},
|
||||||
|
"generateApiKey": {Func: rpcGenerateApiKey},
|
||||||
"getTLSState": {Func: rpcGetTLSState},
|
"getTLSState": {Func: rpcGetTLSState},
|
||||||
"setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}},
|
"setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}},
|
||||||
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
|
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
|
||||||
@@ -1247,6 +1683,8 @@ var rpcHandlers = map[string]RPCHandler{
|
|||||||
"isUpdatePending": {Func: rpcIsUpdatePending},
|
"isUpdatePending": {Func: rpcIsUpdatePending},
|
||||||
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
|
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
|
||||||
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
|
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
|
||||||
|
"getUsbEnhancedDetection": {Func: rpcGetUsbEnhancedDetection},
|
||||||
|
"setUsbEnhancedDetection": {Func: rpcSetUsbEnhancedDetection, Params: []string{"enabled"}},
|
||||||
"getUsbConfig": {Func: rpcGetUsbConfig},
|
"getUsbConfig": {Func: rpcGetUsbConfig},
|
||||||
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
|
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
|
||||||
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
|
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
|
||||||
@@ -1256,6 +1694,7 @@ var rpcHandlers = map[string]RPCHandler{
|
|||||||
"resetSDStorage": {Func: rpcResetSDStorage},
|
"resetSDStorage": {Func: rpcResetSDStorage},
|
||||||
"mountSDStorage": {Func: rpcMountSDStorage},
|
"mountSDStorage": {Func: rpcMountSDStorage},
|
||||||
"unmountSDStorage": {Func: rpcUnmountSDStorage},
|
"unmountSDStorage": {Func: rpcUnmountSDStorage},
|
||||||
|
"formatSDStorage": {Func: rpcFormatSDStorage, Params: []string{"confirm", "fsType"}},
|
||||||
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
|
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
|
||||||
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
|
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
|
||||||
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
|
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
|
||||||
@@ -1272,6 +1711,12 @@ var rpcHandlers = map[string]RPCHandler{
|
|||||||
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
|
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
|
||||||
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
|
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
|
||||||
"resetConfig": {Func: rpcResetConfig},
|
"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"}},
|
"setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}},
|
||||||
"getDisplayRotation": {Func: rpcGetDisplayRotation},
|
"getDisplayRotation": {Func: rpcGetDisplayRotation},
|
||||||
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
|
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
|
||||||
@@ -1335,8 +1780,18 @@ var rpcHandlers = map[string]RPCHandler{
|
|||||||
"stopCloudflared": {Func: rpcStopCloudflared},
|
"stopCloudflared": {Func: rpcStopCloudflared},
|
||||||
"getCloudflaredStatus": {Func: rpcGetCloudflaredStatus},
|
"getCloudflaredStatus": {Func: rpcGetCloudflaredStatus},
|
||||||
"getCloudflaredLog": {Func: rpcGetCloudflaredLog},
|
"getCloudflaredLog": {Func: rpcGetCloudflaredLog},
|
||||||
|
"getVpnToolSystemInfo": {Func: rpcGetVpnToolSystemInfo},
|
||||||
|
"getVpnToolStatus": {Func: rpcGetVpnToolStatus, Params: []string{"tool"}},
|
||||||
|
"listVpnToolReleases": {Func: rpcListVpnToolReleases, Params: []string{"tool"}},
|
||||||
|
"installVpnTool": {Func: rpcInstallVpnTool, Params: []string{"tool", "version", "assetName", "downloadURL"}},
|
||||||
|
"startVpnToolInstall": {Func: rpcStartVpnToolInstall, Params: []string{"tool", "version", "assetName", "downloadURL"}},
|
||||||
|
"getVpnToolInstallTask": {Func: rpcGetVpnToolInstallTask, Params: []string{"tool"}},
|
||||||
|
"useVpnToolVersion": {Func: rpcUseVpnToolVersion, Params: []string{"tool", "version"}},
|
||||||
|
"uninstallVpnToolVersion": {Func: rpcUninstallVpnToolVersion, Params: []string{"tool", "version"}},
|
||||||
"getStreamEncodecType": {Func: rpcGetStreamEncodecType},
|
"getStreamEncodecType": {Func: rpcGetStreamEncodecType},
|
||||||
"setStreamEncodecType": {Func: rpcSetStreamEncodecType, Params: []string{"encodecType"}},
|
"setStreamEncodecType": {Func: rpcSetStreamEncodecType, Params: []string{"encodecType"}},
|
||||||
|
"setVideoRc": {Func: rpcSetVideoRc, Params: []string{"params"}},
|
||||||
|
"getVideoRc": {Func: rpcGetVideoRc},
|
||||||
"setNpuAppStatus": {Func: rpcSetNpuAppStatus, Params: []string{"enable"}},
|
"setNpuAppStatus": {Func: rpcSetNpuAppStatus, Params: []string{"enable"}},
|
||||||
"getNpuAppStatus": {Func: rpcGetNpuAppStatus},
|
"getNpuAppStatus": {Func: rpcGetNpuAppStatus},
|
||||||
"startWireguard": {Func: rpcStartWireguard, Params: []string{"configFile"}},
|
"startWireguard": {Func: rpcStartWireguard, Params: []string{"configFile"}},
|
||||||
@@ -1345,4 +1800,7 @@ var rpcHandlers = map[string]RPCHandler{
|
|||||||
"getWireguardConfig": {Func: rpcGetWireguardConfig},
|
"getWireguardConfig": {Func: rpcGetWireguardConfig},
|
||||||
"getWireguardLog": {Func: rpcGetWireguardLog},
|
"getWireguardLog": {Func: rpcGetWireguardLog},
|
||||||
"getWireguardInfo": {Func: rpcGetWireguardInfo},
|
"getWireguardInfo": {Func: rpcGetWireguardInfo},
|
||||||
|
"getFirewallConfig": {Func: rpcGetFirewallConfig},
|
||||||
|
"setFirewallConfig": {Func: rpcSetFirewallConfig, Params: []string{"config"}},
|
||||||
|
"getBootStorageType": {Func: rpcGetBootStorageType},
|
||||||
}
|
}
|
||||||
|
|||||||
245
keys.go
Normal file
245
keys.go
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
package kvm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
evKey = 0x01
|
||||||
|
)
|
||||||
|
|
||||||
|
type keyHoldResetDetector struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
pressAt map[uint16]time.Time
|
||||||
|
threshold time.Duration
|
||||||
|
now func() time.Time
|
||||||
|
afterFunc func(d time.Duration, f func()) func() bool
|
||||||
|
stop map[uint16]func() bool
|
||||||
|
triggered bool
|
||||||
|
onTrigger func(code uint16, hold time.Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newKeyHoldResetDetector(threshold time.Duration, now func() time.Time, afterFunc func(d time.Duration, f func()) func() bool, onTrigger func(code uint16, hold time.Duration)) *keyHoldResetDetector {
|
||||||
|
if now == nil {
|
||||||
|
now = time.Now
|
||||||
|
}
|
||||||
|
if afterFunc == nil {
|
||||||
|
afterFunc = func(d time.Duration, f func()) func() bool {
|
||||||
|
t := time.AfterFunc(d, f)
|
||||||
|
return t.Stop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &keyHoldResetDetector{
|
||||||
|
pressAt: map[uint16]time.Time{},
|
||||||
|
threshold: threshold,
|
||||||
|
now: now,
|
||||||
|
afterFunc: afterFunc,
|
||||||
|
stop: map[uint16]func() bool{},
|
||||||
|
onTrigger: onTrigger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *keyHoldResetDetector) close() {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
for code, stop := range d.stop {
|
||||||
|
_ = stop()
|
||||||
|
delete(d.stop, code)
|
||||||
|
delete(d.pressAt, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *keyHoldResetDetector) fire(code uint16) {
|
||||||
|
d.mu.Lock()
|
||||||
|
if d.triggered {
|
||||||
|
d.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.triggered = true
|
||||||
|
t0, ok := d.pressAt[code]
|
||||||
|
now := d.now()
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
hold := d.threshold
|
||||||
|
if ok {
|
||||||
|
hold = now.Sub(t0)
|
||||||
|
}
|
||||||
|
if d.onTrigger != nil {
|
||||||
|
d.onTrigger(code, hold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *keyHoldResetDetector) onEvent(typ uint16, code uint16, val int32) {
|
||||||
|
if typ != evKey {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch val {
|
||||||
|
case 1, 2:
|
||||||
|
d.mu.Lock()
|
||||||
|
if d.triggered {
|
||||||
|
d.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := d.pressAt[code]; exists {
|
||||||
|
d.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.pressAt[code] = d.now()
|
||||||
|
d.stop[code] = d.afterFunc(d.threshold, func() { d.fire(code) })
|
||||||
|
d.mu.Unlock()
|
||||||
|
return
|
||||||
|
case 0:
|
||||||
|
d.mu.Lock()
|
||||||
|
if stop, ok := d.stop[code]; ok {
|
||||||
|
_ = stop()
|
||||||
|
delete(d.stop, code)
|
||||||
|
}
|
||||||
|
delete(d.pressAt, code)
|
||||||
|
d.mu.Unlock()
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultInputEventSize() int {
|
||||||
|
if strconv.IntSize == 64 {
|
||||||
|
return 24
|
||||||
|
}
|
||||||
|
return 16
|
||||||
|
}
|
||||||
|
|
||||||
|
func findInputEventDeviceByName(deviceName string) (string, error) {
|
||||||
|
entries, err := os.ReadDir("/sys/class/input")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !strings.HasPrefix(e.Name(), "event") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
namePath := filepath.Join("/sys/class/input", e.Name(), "device/name")
|
||||||
|
b, err := os.ReadFile(namePath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n := strings.TrimSpace(string(b))
|
||||||
|
if n == deviceName {
|
||||||
|
return filepath.Join("/dev/input", e.Name()), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errors.New("input device not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func watchAdcKeysLongPressReset(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
dev, err := findInputEventDeviceByName("adc-keys")
|
||||||
|
if err != nil {
|
||||||
|
keysLogger.Warn().Err(err).Msg("adc-keys device not found")
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.OpenFile(dev, os.O_RDONLY, 0)
|
||||||
|
if err != nil {
|
||||||
|
keysLogger.Warn().Err(err).Str("device", dev).Msg("failed to open adc-keys device")
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
keysLogger.Info().Str("device", dev).Msg("watching adc-keys events")
|
||||||
|
var resetOnce sync.Once
|
||||||
|
detector := newKeyHoldResetDetector(
|
||||||
|
5*time.Second,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
func(code uint16, hold time.Duration) {
|
||||||
|
resetOnce.Do(func() {
|
||||||
|
keysLogger.Warn().Uint16("code", code).Dur("hold", hold).Msg("adc-keys long press detected, resetting config")
|
||||||
|
resetConfigFileAndReboot()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
eventSize := defaultInputEventSize()
|
||||||
|
buf := make([]byte, eventSize)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
detector.close()
|
||||||
|
_ = f.Close()
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := io.ReadFull(f, buf)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, syscall.EINVAL) {
|
||||||
|
if eventSize == 24 {
|
||||||
|
eventSize = 16
|
||||||
|
} else {
|
||||||
|
eventSize = 24
|
||||||
|
}
|
||||||
|
buf = make([]byte, eventSize)
|
||||||
|
keysLogger.Info().Str("device", dev).Int("event_size", eventSize).Msg("adc-keys switched input_event size")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
detector.close()
|
||||||
|
_ = f.Close()
|
||||||
|
keysLogger.Warn().Err(err).Str("device", dev).Msg("adc-keys read failed, reopening")
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
typeOff, codeOff, valOff := 16, 18, 20
|
||||||
|
if eventSize == 16 {
|
||||||
|
typeOff, codeOff, valOff = 8, 10, 12
|
||||||
|
}
|
||||||
|
|
||||||
|
typ := binary.LittleEndian.Uint16(buf[typeOff : typeOff+2])
|
||||||
|
code := binary.LittleEndian.Uint16(buf[codeOff : codeOff+2])
|
||||||
|
val := int32(binary.LittleEndian.Uint32(buf[valOff : valOff+4]))
|
||||||
|
|
||||||
|
detector.onEvent(typ, code, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetConfigFileAndReboot() {
|
||||||
|
resetFirewallForFactory()
|
||||||
|
|
||||||
|
if err := os.Remove(configPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
keysLogger.Error().Err(err).Str("path", configPath).Msg("failed to delete config file")
|
||||||
|
} else {
|
||||||
|
keysLogger.Warn().Str("path", configPath).Msg("config file deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
unix.Sync()
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
if err := unix.Reboot(unix.LINUX_REBOOT_CMD_RESTART); err != nil {
|
||||||
|
keysLogger.Error().Err(err).Msg("syscall reboot failed, trying /sbin/reboot")
|
||||||
|
_ = exec.Command("/sbin/reboot", "-f").Run()
|
||||||
|
_ = exec.Command("reboot", "-f").Run()
|
||||||
|
}
|
||||||
|
}
|
||||||
1
log.go
1
log.go
@@ -30,6 +30,7 @@ var (
|
|||||||
displayLogger = logging.GetSubsystemLogger("display")
|
displayLogger = logging.GetSubsystemLogger("display")
|
||||||
wolLogger = logging.GetSubsystemLogger("wol")
|
wolLogger = logging.GetSubsystemLogger("wol")
|
||||||
usbLogger = logging.GetSubsystemLogger("usb")
|
usbLogger = logging.GetSubsystemLogger("usb")
|
||||||
|
keysLogger = logging.GetSubsystemLogger("keys")
|
||||||
// external components
|
// external components
|
||||||
ginLogger = logging.GetSubsystemLogger("gin")
|
ginLogger = logging.GetSubsystemLogger("gin")
|
||||||
)
|
)
|
||||||
|
|||||||
28
main.go
28
main.go
@@ -18,6 +18,20 @@ func Main() {
|
|||||||
SyncConfigSD(true)
|
SyncConfigSD(true)
|
||||||
LoadConfig()
|
LoadConfig()
|
||||||
|
|
||||||
|
if config.APIKey == "" {
|
||||||
|
key, err := generateAPIKey()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("failed to generate API key")
|
||||||
|
} else {
|
||||||
|
config.APIKey = key
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("failed to save API key to config")
|
||||||
|
} else {
|
||||||
|
logger.Info().Msg("generated new API key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var cancel context.CancelFunc
|
var cancel context.CancelFunc
|
||||||
appCtx, cancel = context.WithCancel(context.Background())
|
appCtx, cancel = context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -35,6 +49,7 @@ func Main() {
|
|||||||
Interface("app_version", appVersionLocal).
|
Interface("app_version", appVersionLocal).
|
||||||
Msg("starting KVM")
|
Msg("starting KVM")
|
||||||
|
|
||||||
|
go watchAdcKeysLongPressReset(appCtx)
|
||||||
go runWatchdog()
|
go runWatchdog()
|
||||||
go confirmCurrentSystem() //A/B system
|
go confirmCurrentSystem() //A/B system
|
||||||
if isNewEnoughSystem {
|
if isNewEnoughSystem {
|
||||||
@@ -57,6 +72,10 @@ func Main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := ApplyFirewallConfig(config.Firewall); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("failed to apply firewall config")
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize time sync
|
// Initialize time sync
|
||||||
initTimeSync()
|
initTimeSync()
|
||||||
timeSync.Start()
|
timeSync.Start()
|
||||||
@@ -180,6 +199,15 @@ func Main() {
|
|||||||
//go RunFuseServer()
|
//go RunFuseServer()
|
||||||
go RunWebServer()
|
go RunWebServer()
|
||||||
|
|
||||||
|
// API and MCP services temporarily disabled for debugging
|
||||||
|
go func() {
|
||||||
|
StartAPIServer(8080)
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
StartMCP(8081, false)
|
||||||
|
}()
|
||||||
|
|
||||||
go RunWebSecureServer()
|
go RunWebSecureServer()
|
||||||
// Web secure server is started only if TLS mode is enabled
|
// Web secure server is started only if TLS mode is enabled
|
||||||
if config.TLSMode != "" {
|
if config.TLSMode != "" {
|
||||||
|
|||||||
313
mcp.go
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{}
|
videoCmdLock = &sync.Mutex{}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// jpegReadyCh is written by the ctrl socket event handler and read by captureScreenshot.
|
||||||
|
// Buffered to 1 so the jpeg thread doesn't block if captureScreenshot has already timed out.
|
||||||
|
var jpegReadyCh = make(chan struct{}, 1)
|
||||||
|
|
||||||
func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse, error) {
|
func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse, error) {
|
||||||
lock.Lock()
|
lock.Lock()
|
||||||
defer lock.Unlock()
|
defer lock.Unlock()
|
||||||
@@ -76,7 +80,7 @@ func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case response := <-responseChan:
|
case response := <-responseChan:
|
||||||
delete(ongoingRequests, seq)
|
delete(ongoingRequests, ctrlAction.Seq)
|
||||||
if response.Error != "" {
|
if response.Error != "" {
|
||||||
return nil, ErrorfL(
|
return nil, ErrorfL(
|
||||||
&scopedLogger,
|
&scopedLogger,
|
||||||
@@ -87,7 +91,7 @@ func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse
|
|||||||
return response, nil
|
return response, nil
|
||||||
case <-time.After(5 * time.Second):
|
case <-time.After(5 * time.Second):
|
||||||
close(responseChan)
|
close(responseChan)
|
||||||
delete(ongoingRequests, seq)
|
delete(ongoingRequests, ctrlAction.Seq)
|
||||||
return nil, ErrorfL(&scopedLogger, "timeout waiting for response", nil)
|
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")
|
scopedLogger.Warn().Err(err).Msg("error reading from ctrl sock")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
readMsg := string(readBuf[:n])
|
|
||||||
|
|
||||||
ctrlResp := CtrlResponse{}
|
ctrlResp := CtrlResponse{}
|
||||||
err = json.Unmarshal([]byte(readMsg), &ctrlResp)
|
err = json.Unmarshal(readBuf[:n], &ctrlResp)
|
||||||
if err != nil {
|
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
|
continue
|
||||||
}
|
}
|
||||||
scopedLogger.Trace().Interface("data", ctrlResp).Msg("ctrl sock msg")
|
scopedLogger.Trace().Interface("data", ctrlResp).Msg("ctrl sock msg")
|
||||||
@@ -213,6 +216,11 @@ func handleCtrlClient(conn net.Conn) {
|
|||||||
switch ctrlResp.Event {
|
switch ctrlResp.Event {
|
||||||
case "video_input_state":
|
case "video_input_state":
|
||||||
HandleVideoStateMessage(ctrlResp)
|
HandleVideoStateMessage(ctrlResp)
|
||||||
|
case "jpeg_ready":
|
||||||
|
select {
|
||||||
|
case jpegReadyCh <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,9 +250,7 @@ func handleVideoClient(conn net.Conn) {
|
|||||||
lastFrame = now
|
lastFrame = now
|
||||||
|
|
||||||
// Broadcast to HTTP clients
|
// Broadcast to HTTP clients
|
||||||
dataCopy := make([]byte, n)
|
videoBroadcaster.Broadcast(inboundPacket[:n])
|
||||||
copy(dataCopy, inboundPacket[:n])
|
|
||||||
videoBroadcaster.Broadcast(dataCopy)
|
|
||||||
|
|
||||||
if currentSession != nil {
|
if currentSession != nil {
|
||||||
err := currentSession.VideoTrack.WriteSample(media.Sample{Data: inboundPacket[:n], Duration: sinceLastFrame})
|
err := currentSession.VideoTrack.WriteSample(media.Sample{Data: inboundPacket[:n], Duration: sinceLastFrame})
|
||||||
|
|||||||
@@ -163,12 +163,11 @@ func handleDisplayCtrlClient(conn net.Conn) {
|
|||||||
scopedLogger.Warn().Err(err).Msg("error reading from display sock")
|
scopedLogger.Warn().Err(err).Msg("error reading from display sock")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
readMsg := string(readBuf[:n])
|
|
||||||
|
|
||||||
displayResp := CtrlResponse{}
|
displayResp := CtrlResponse{}
|
||||||
err = json.Unmarshal([]byte(readMsg), &displayResp)
|
err = json.Unmarshal(readBuf[:n], &displayResp)
|
||||||
if err != nil {
|
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
|
continue
|
||||||
}
|
}
|
||||||
scopedLogger.Trace().Interface("data", displayResp).Msg("display sock msg")
|
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")
|
scopedLogger.Warn().Err(err).Msg("error reading from vpn sock")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
readMsg := string(readBuf[:n])
|
|
||||||
|
|
||||||
vpnResp := CtrlResponse{}
|
vpnResp := CtrlResponse{}
|
||||||
err = json.Unmarshal([]byte(readMsg), &vpnResp)
|
err = json.Unmarshal(readBuf[:n], &vpnResp)
|
||||||
if err != nil {
|
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
|
continue
|
||||||
}
|
}
|
||||||
scopedLogger.Trace().Interface("data", vpnResp).Msg("vpn sock msg")
|
scopedLogger.Trace().Interface("data", vpnResp).Msg("vpn sock msg")
|
||||||
|
|||||||
38
network.go
38
network.go
@@ -2,6 +2,8 @@ package kvm
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"kvm/internal/network"
|
"kvm/internal/network"
|
||||||
"kvm/internal/udhcpc"
|
"kvm/internal/udhcpc"
|
||||||
@@ -17,6 +19,27 @@ var (
|
|||||||
networkState *network.NetworkInterfaceState
|
networkState *network.NetworkInterfaceState
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func setProxyEnvVar(key, value string) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
upperKey := strings.ToUpper(key)
|
||||||
|
if value == "" {
|
||||||
|
_ = os.Unsetenv(key)
|
||||||
|
_ = os.Unsetenv(upperKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.Setenv(key, value)
|
||||||
|
_ = os.Setenv(upperKey, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyProxyEnvironment(networkConfig *network.NetworkConfig) {
|
||||||
|
if networkConfig == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setProxyEnvVar("http_proxy", networkConfig.HTTPProxy.String)
|
||||||
|
setProxyEnvVar("https_proxy", networkConfig.HTTPSProxy.String)
|
||||||
|
setProxyEnvVar("all_proxy", networkConfig.ALLProxy.String)
|
||||||
|
}
|
||||||
|
|
||||||
func networkStateChanged() {
|
func networkStateChanged() {
|
||||||
// do not block the main thread
|
// do not block the main thread
|
||||||
go waitCtrlAndRequestDisplayUpdate(true)
|
go waitCtrlAndRequestDisplayUpdate(true)
|
||||||
@@ -33,6 +56,7 @@ func networkStateChanged() {
|
|||||||
|
|
||||||
func initNetwork() error {
|
func initNetwork() error {
|
||||||
ensureConfigLoaded()
|
ensureConfigLoaded()
|
||||||
|
applyProxyEnvironment(config.NetworkConfig)
|
||||||
|
|
||||||
state, err := network.NewNetworkInterfaceState(&network.NetworkInterfaceOptions{
|
state, err := network.NewNetworkInterfaceState(&network.NetworkInterfaceOptions{
|
||||||
DefaultHostname: GetDefaultHostname(),
|
DefaultHostname: GetDefaultHostname(),
|
||||||
@@ -131,6 +155,7 @@ func rpcSetNetworkSettings(settings network.RpcNetworkSettings) (*network.RpcNet
|
|||||||
if err := SaveConfig(); err != nil {
|
if err := SaveConfig(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
applyProxyEnvironment(config.NetworkConfig)
|
||||||
|
|
||||||
return &network.RpcNetworkSettings{NetworkConfig: *config.NetworkConfig}, nil
|
return &network.RpcNetworkSettings{NetworkConfig: *config.NetworkConfig}, nil
|
||||||
}
|
}
|
||||||
@@ -142,3 +167,16 @@ func rpcRenewDHCPLease() error {
|
|||||||
func rpcRequestDHCPAddress(ip string) error {
|
func rpcRequestDHCPAddress(ip string) error {
|
||||||
return networkState.RpcRequestDHCPAddress(ip)
|
return networkState.RpcRequestDHCPAddress(ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ethernetMacAddressPath = "/userdata/ethaddr.txt"
|
||||||
|
|
||||||
|
func rpcSetEthernetMacAddress(macAddress string) (interface{}, error) {
|
||||||
|
normalized, err := networkState.SetMACAddress(macAddress)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(ethernetMacAddressPath, []byte(normalized+"\n"), 0644); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write %s: %w", ethernetMacAddressPath, err)
|
||||||
|
}
|
||||||
|
return networkState.RpcGetNetworkState(), nil
|
||||||
|
}
|
||||||
|
|||||||
473
ota.go
473
ota.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"archive/zip"
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -42,8 +43,10 @@ type RemoteMetadata struct {
|
|||||||
AppVersion string `json:"appVersion"`
|
AppVersion string `json:"appVersion"`
|
||||||
AppUrl string `json:"appUrl"`
|
AppUrl string `json:"appUrl"`
|
||||||
AppHash string `json:"appHash"`
|
AppHash string `json:"appHash"`
|
||||||
|
AppSigUrl string `json:"appSigUrl,omitempty"`
|
||||||
SystemUrl string `json:"systemUrl"`
|
SystemUrl string `json:"systemUrl"`
|
||||||
SystemHash string `json:"systemHash,omitempty"`
|
SystemHash string `json:"systemHash,omitempty"`
|
||||||
|
SystemSigUrl string `json:"systemSigUrl,omitempty"`
|
||||||
SystemVersion string `json:"systemVersion"`
|
SystemVersion string `json:"systemVersion"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +56,8 @@ type UpdateStatus struct {
|
|||||||
Remote *RemoteMetadata `json:"remote"`
|
Remote *RemoteMetadata `json:"remote"`
|
||||||
SystemUpdateAvailable bool `json:"systemUpdateAvailable"`
|
SystemUpdateAvailable bool `json:"systemUpdateAvailable"`
|
||||||
AppUpdateAvailable bool `json:"appUpdateAvailable"`
|
AppUpdateAvailable bool `json:"appUpdateAvailable"`
|
||||||
|
AppSignatureMissing bool `json:"appSignatureMissing,omitempty"`
|
||||||
|
SystemSignatureMissing bool `json:"systemSignatureMissing,omitempty"`
|
||||||
|
|
||||||
// for backwards compatibility
|
// for backwards compatibility
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
@@ -89,10 +94,12 @@ var UpdateGiteeSystemZipUrls = []string{
|
|||||||
|
|
||||||
const cdnUpdateBaseURL = "https://cdn.picokvm.top/luckfox_picokvm_firmware/lastest/"
|
const cdnUpdateBaseURL = "https://cdn.picokvm.top/luckfox_picokvm_firmware/lastest/"
|
||||||
|
|
||||||
var builtAppVersion = "0.1.1+dev"
|
var builtAppVersion = "0.1.3+dev"
|
||||||
|
|
||||||
var updateSource = "github"
|
var (
|
||||||
var customUpdateBaseURL string
|
updateSource = "github"
|
||||||
|
customUpdateBaseURL string
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
updateSourceGithub = "github"
|
updateSourceGithub = "github"
|
||||||
@@ -144,12 +151,12 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
|
|||||||
|
|
||||||
_, _ = deviceId, includePreRelease
|
_, _ = deviceId, includePreRelease
|
||||||
|
|
||||||
appVersionRemote, appURL, appSha256, err := fetchKvmAppLatestRelease(ctx)
|
appVersionRemote, appURL, appSha256, appSigURL, err := fetchKvmAppLatestRelease(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
systemVersionRemote, systemZipURL, err := fetchKvmSystemLatestRelease(ctx)
|
systemVersionRemote, systemZipURL, systemSigURL, err := fetchKvmSystemLatestRelease(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -158,12 +165,14 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
|
|||||||
AppUrl: appURL,
|
AppUrl: appURL,
|
||||||
AppVersion: appVersionRemote,
|
AppVersion: appVersionRemote,
|
||||||
AppHash: appSha256,
|
AppHash: appSha256,
|
||||||
|
AppSigUrl: appSigURL,
|
||||||
SystemUrl: systemZipURL,
|
SystemUrl: systemZipURL,
|
||||||
SystemVersion: systemVersionRemote,
|
SystemVersion: systemVersionRemote,
|
||||||
|
SystemSigUrl: systemSigURL,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL string, sha256 string, err error) {
|
func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL string, sha256 string, sigURL string, err error) {
|
||||||
apiURLs := UpdateGithubAppReleaseUrls
|
apiURLs := UpdateGithubAppReleaseUrls
|
||||||
fallbackToGithub := false
|
fallbackToGithub := false
|
||||||
if updateSource == updateSourceGitee {
|
if updateSource == updateSourceGitee {
|
||||||
@@ -171,7 +180,7 @@ func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL stri
|
|||||||
fallbackToGithub = true
|
fallbackToGithub = true
|
||||||
}
|
}
|
||||||
|
|
||||||
tryFetch := func(urls []string) (string, string, string, error) {
|
tryFetch := func(urls []string) (string, string, string, string, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for _, apiURL := range urls {
|
for _, apiURL := range urls {
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||||
@@ -218,44 +227,54 @@ func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL stri
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var downloadURL string
|
var downloadURL, sha256, sigURL string
|
||||||
var sha256 string
|
for _, asset := range release.Assets {
|
||||||
if len(release.Assets) > 0 {
|
name := strings.ToLower(strings.TrimSpace(asset.Name))
|
||||||
downloadURL = release.Assets[0].BrowserDownloadURL
|
u := strings.TrimSpace(asset.BrowserDownloadURL)
|
||||||
sha256 = release.Assets[0].Digest
|
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) == "" {
|
if strings.TrimSpace(downloadURL) == "" {
|
||||||
lastErr = fmt.Errorf("empty app download url from %s", apiURL)
|
lastErr = fmt.Errorf("empty app download url from %s", apiURL)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
return tag, downloadURL, sha256, nil
|
return tag, downloadURL, sha256, sigURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if lastErr == nil {
|
if lastErr == nil {
|
||||||
lastErr = fmt.Errorf("no app release API URLs configured")
|
lastErr = fmt.Errorf("no app release API URLs configured")
|
||||||
}
|
}
|
||||||
return "", "", "", lastErr
|
return "", "", "", "", lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
tag, downloadURL, sha256, err = tryFetch(apiURLs)
|
tag, downloadURL, sha256, sigURL, err = tryFetch(apiURLs)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return tag, downloadURL, sha256, nil
|
return tag, downloadURL, sha256, sigURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
lastErr = err
|
lastErr = err
|
||||||
if updateSource == updateSourceGitee && fallbackToGithub {
|
if updateSource == updateSourceGitee && fallbackToGithub {
|
||||||
tag, downloadURL, sha256, err = tryFetch(UpdateGithubAppReleaseUrls)
|
var ghSigURL string
|
||||||
|
tag, downloadURL, sha256, ghSigURL, err = tryFetch(UpdateGithubAppReleaseUrls)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
downloadURL = strings.Replace(downloadURL, "github.com", "gitee.com", 1)
|
downloadURL = strings.Replace(downloadURL, "github.com", "gitee.com", 1)
|
||||||
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)
|
lastErr = fmt.Errorf("gitee app release fetch failed (%v); github fallback failed (%w)", lastErr, err)
|
||||||
}
|
}
|
||||||
return "", "", "", lastErr
|
return "", "", "", "", lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
type releaseAsset struct {
|
type releaseAsset struct {
|
||||||
@@ -281,7 +300,7 @@ func pickZipAssetURL(assets []releaseAsset) string {
|
|||||||
return ""
|
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
|
apiURLs := UpdateGithubSystemReleaseUrls
|
||||||
fallbackToGithub := false
|
fallbackToGithub := false
|
||||||
if updateSource == updateSourceGitee {
|
if updateSource == updateSourceGitee {
|
||||||
@@ -336,11 +355,18 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var sysSigURL string
|
||||||
|
for _, asset := range release.Assets {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(asset.Name))
|
||||||
|
if strings.HasSuffix(name, ".sig") && sysSigURL == "" {
|
||||||
|
sysSigURL = strings.TrimSpace(asset.BrowserDownloadURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
if u := pickZipAssetURL(release.Assets); strings.TrimSpace(u) != "" {
|
if u := pickZipAssetURL(release.Assets); strings.TrimSpace(u) != "" {
|
||||||
return tag, strings.TrimSpace(u), nil
|
return tag, strings.TrimSpace(u), sysSigURL, nil
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(release.ZipballURL) != "" {
|
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)
|
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 githubTag string
|
||||||
var githubZipURL string
|
var githubZipURL string
|
||||||
for i, apiURL := range UpdateGithubSystemReleaseUrls {
|
for i, apiURL := range UpdateGithubSystemReleaseUrls {
|
||||||
githubTag, githubZipURL, githubErr = func(apiURL string) (string, string, error) {
|
githubTag, githubZipURL, _, githubErr = func(apiURL string) (string, string, string, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("error creating system release request: %w", err)
|
return "", "", "", fmt.Errorf("error creating system release request: %w", err)
|
||||||
}
|
}
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("error fetching system release: %w", err)
|
return "", "", "", fmt.Errorf("error fetching system release: %w", err)
|
||||||
}
|
}
|
||||||
body, readErr := io.ReadAll(resp.Body)
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
return "", "", fmt.Errorf("error reading system release response: %w", readErr)
|
return "", "", "", fmt.Errorf("error reading system release response: %w", readErr)
|
||||||
}
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", "", fmt.Errorf(
|
return "", "", "", fmt.Errorf(
|
||||||
"unexpected status code fetching system release from %s: %d, %s",
|
"unexpected status code fetching system release from %s: %d, %s",
|
||||||
apiURL,
|
apiURL,
|
||||||
resp.StatusCode,
|
resp.StatusCode,
|
||||||
@@ -383,19 +409,26 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
|
|||||||
Assets []releaseAsset `json:"assets"`
|
Assets []releaseAsset `json:"assets"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &release); err != nil {
|
if err := json.Unmarshal(body, &release); err != nil {
|
||||||
return "", "", fmt.Errorf("error parsing system release JSON from %s: %w", apiURL, err)
|
return "", "", "", fmt.Errorf("error parsing system release JSON from %s: %w", apiURL, err)
|
||||||
}
|
}
|
||||||
tag := strings.TrimSpace(release.TagName)
|
tag := strings.TrimSpace(release.TagName)
|
||||||
if tag == "" {
|
if tag == "" {
|
||||||
return "", "", fmt.Errorf("empty system tag_name from %s", apiURL)
|
return "", "", "", fmt.Errorf("empty system tag_name from %s", apiURL)
|
||||||
|
}
|
||||||
|
var sigURL string
|
||||||
|
for _, asset := range release.Assets {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(asset.Name))
|
||||||
|
if strings.HasSuffix(name, ".sig") && sigURL == "" {
|
||||||
|
sigURL = strings.TrimSpace(asset.BrowserDownloadURL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if u := pickZipAssetURL(release.Assets); strings.TrimSpace(u) != "" {
|
if u := pickZipAssetURL(release.Assets); strings.TrimSpace(u) != "" {
|
||||||
return tag, strings.TrimSpace(u), nil
|
return tag, strings.TrimSpace(u), sigURL, nil
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(release.ZipballURL) != "" {
|
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)
|
}(apiURL)
|
||||||
if githubErr == nil && strings.TrimSpace(githubTag) != "" {
|
if githubErr == nil && strings.TrimSpace(githubTag) != "" {
|
||||||
_ = githubZipURL
|
_ = githubZipURL
|
||||||
@@ -414,15 +447,15 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
|
|||||||
zipTag = strings.TrimPrefix(zipTag, "V")
|
zipTag = strings.TrimPrefix(zipTag, "V")
|
||||||
}
|
}
|
||||||
zipURL := strings.TrimRight(selectedZipURL, "/") + "/" + zipTag + ".zip"
|
zipURL := strings.TrimRight(selectedZipURL, "/") + "/" + zipTag + ".zip"
|
||||||
return githubTag, zipURL, nil
|
return githubTag, zipURL, "", nil
|
||||||
}
|
}
|
||||||
githubErr = fmt.Errorf("no gitee system zip urls configured")
|
githubErr = fmt.Errorf("no gitee system zip urls configured")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", "", fmt.Errorf("gitee system release fetch failed (%v); github fallback failed (%w)", lastErr, githubErr)
|
return "", "", "", fmt.Errorf("gitee system release fetch failed (%v); github fallback failed (%w)", lastErr, githubErr)
|
||||||
}
|
}
|
||||||
return "", "", lastErr
|
return "", "", "", lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchUpdateMetadataFromBaseURL(ctx context.Context, baseURL string) (*RemoteMetadata, error) {
|
func fetchUpdateMetadataFromBaseURL(ctx context.Context, baseURL string) (*RemoteMetadata, error) {
|
||||||
@@ -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{
|
return &RemoteMetadata{
|
||||||
AppVersion: appVersion,
|
AppVersion: appVersion,
|
||||||
AppUrl: appURL,
|
AppUrl: appURL,
|
||||||
AppHash: appHash,
|
AppHash: appHash,
|
||||||
|
AppSigUrl: appSigURL,
|
||||||
SystemVersion: systemVersion,
|
SystemVersion: systemVersion,
|
||||||
SystemUrl: systemURL,
|
SystemUrl: systemURL,
|
||||||
SystemHash: systemHash,
|
SystemHash: systemHash,
|
||||||
|
SystemSigUrl: systemSigURL,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -672,13 +713,62 @@ func parseVersionTxt(s string) (appVersion string, systemVersion string, err err
|
|||||||
return appVersion, systemVersion, nil
|
return appVersion, systemVersion, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func downloadFile(ctx context.Context, path string, url string, downloadProgress *float32) error {
|
func shouldProxyUpdateDownloadURL(u *url.URL) bool {
|
||||||
|
if u == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
|
||||||
|
if host == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if host == "github.com" || host == "api.github.com" || host == "codeload.github.com" || host == "raw.githubusercontent.com" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(host, ".github.com") || strings.HasSuffix(host, ".githubusercontent.com") || strings.HasSuffix(host, ".githubassets.com") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyUpdateDownloadProxyPrefix(rawURL string) string {
|
||||||
|
if config == nil {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
proxy := strings.TrimSpace(config.UpdateDownloadProxy)
|
||||||
|
if proxy == "" {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
proxy = strings.TrimRight(proxy, "/") + "/"
|
||||||
|
if strings.HasPrefix(rawURL, proxy) {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil || parsed == nil {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
if !shouldProxyUpdateDownloadURL(parsed) {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
return proxy + rawURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFile(
|
||||||
|
ctx context.Context,
|
||||||
|
path string,
|
||||||
|
url string,
|
||||||
|
downloadProgress *float32,
|
||||||
|
downloadSpeedBps *float32,
|
||||||
|
) error {
|
||||||
//if _, err := os.Stat(path); err == nil {
|
//if _, err := os.Stat(path); err == nil {
|
||||||
// if err := os.Remove(path); err != nil {
|
// if err := os.Remove(path); err != nil {
|
||||||
// return fmt.Errorf("error removing existing file: %w", err)
|
// return fmt.Errorf("error removing existing file: %w", err)
|
||||||
// }
|
// }
|
||||||
//}
|
//}
|
||||||
otaLogger.Info().Str("path", path).Str("url", url).Msg("downloading file")
|
finalURL := applyUpdateDownloadProxyPrefix(url)
|
||||||
|
otaLogger.Info().Str("path", path).Str("url", finalURL).Msg("downloading file")
|
||||||
|
|
||||||
unverifiedPath := path + ".unverified"
|
unverifiedPath := path + ".unverified"
|
||||||
if _, err := os.Stat(unverifiedPath); err == nil {
|
if _, err := os.Stat(unverifiedPath); err == nil {
|
||||||
@@ -693,7 +783,7 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", finalURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating request: %w", err)
|
return fmt.Errorf("error creating request: %w", err)
|
||||||
}
|
}
|
||||||
@@ -726,6 +816,19 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
|
|||||||
var lastProgressBytes int64
|
var lastProgressBytes int64
|
||||||
lastProgressAt := time.Now()
|
lastProgressAt := time.Now()
|
||||||
lastReportedProgress := float32(0)
|
lastReportedProgress := float32(0)
|
||||||
|
lastSpeedAt := time.Now()
|
||||||
|
var lastSpeedBytes int64
|
||||||
|
|
||||||
|
if downloadProgress != nil {
|
||||||
|
*downloadProgress = 0
|
||||||
|
}
|
||||||
|
if downloadSpeedBps != nil {
|
||||||
|
*downloadSpeedBps = 0
|
||||||
|
}
|
||||||
|
if downloadProgress != nil || downloadSpeedBps != nil {
|
||||||
|
triggerOTAStateUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
buf := make([]byte, 32*1024)
|
buf := make([]byte, 32*1024)
|
||||||
for {
|
for {
|
||||||
nr, er := resp.Body.Read(buf)
|
nr, er := resp.Body.Read(buf)
|
||||||
@@ -738,20 +841,40 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
|
|||||||
if ew != nil {
|
if ew != nil {
|
||||||
return fmt.Errorf("error writing to file: %w", ew)
|
return fmt.Errorf("error writing to file: %w", ew)
|
||||||
}
|
}
|
||||||
|
now := time.Now()
|
||||||
|
speedUpdated := false
|
||||||
|
progressUpdated := false
|
||||||
|
|
||||||
|
if downloadSpeedBps != nil {
|
||||||
|
dt := now.Sub(lastSpeedAt)
|
||||||
|
if dt >= 1*time.Second {
|
||||||
|
seconds := float32(dt.Seconds())
|
||||||
|
if seconds <= 0 {
|
||||||
|
*downloadSpeedBps = 0
|
||||||
|
} else {
|
||||||
|
*downloadSpeedBps = float32(written-lastSpeedBytes) / seconds
|
||||||
|
}
|
||||||
|
lastSpeedAt = now
|
||||||
|
lastSpeedBytes = written
|
||||||
|
speedUpdated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if hasKnownSize && downloadProgress != nil {
|
if hasKnownSize && downloadProgress != nil {
|
||||||
progress := float32(written) / float32(totalSize)
|
progress := float32(written) / float32(totalSize)
|
||||||
if progress-lastReportedProgress >= 0.001 || time.Since(lastProgressAt) >= 1*time.Second {
|
if progress-lastReportedProgress >= 0.001 || now.Sub(lastProgressAt) >= 1*time.Second {
|
||||||
lastReportedProgress = progress
|
lastReportedProgress = progress
|
||||||
*downloadProgress = lastReportedProgress
|
*downloadProgress = lastReportedProgress
|
||||||
triggerOTAStateUpdate()
|
lastProgressAt = now
|
||||||
lastProgressAt = time.Now()
|
progressUpdated = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !hasKnownSize && downloadProgress != nil {
|
if !hasKnownSize && downloadProgress != nil {
|
||||||
if *downloadProgress <= 0 {
|
if *downloadProgress <= 0 {
|
||||||
*downloadProgress = 0.01
|
*downloadProgress = 0.01
|
||||||
triggerOTAStateUpdate()
|
|
||||||
lastProgressBytes = written
|
lastProgressBytes = written
|
||||||
|
progressUpdated = true
|
||||||
} else if written-lastProgressBytes >= 1024*1024 {
|
} else if written-lastProgressBytes >= 1024*1024 {
|
||||||
next := *downloadProgress + 0.01
|
next := *downloadProgress + 0.01
|
||||||
if next > 0.99 {
|
if next > 0.99 {
|
||||||
@@ -759,11 +882,15 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
|
|||||||
}
|
}
|
||||||
if next-*downloadProgress >= 0.01 {
|
if next-*downloadProgress >= 0.01 {
|
||||||
*downloadProgress = next
|
*downloadProgress = next
|
||||||
triggerOTAStateUpdate()
|
|
||||||
lastProgressBytes = written
|
lastProgressBytes = written
|
||||||
|
progressUpdated = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if speedUpdated || progressUpdated {
|
||||||
|
triggerOTAStateUpdate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if er != nil {
|
if er != nil {
|
||||||
if er == io.EOF {
|
if er == io.EOF {
|
||||||
@@ -779,6 +906,14 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
|
|||||||
|
|
||||||
if downloadProgress != nil && !hasKnownSize {
|
if downloadProgress != nil && !hasKnownSize {
|
||||||
*downloadProgress = 1
|
*downloadProgress = 1
|
||||||
|
if downloadSpeedBps != nil {
|
||||||
|
*downloadSpeedBps = 0
|
||||||
|
}
|
||||||
|
triggerOTAStateUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
if downloadSpeedBps != nil && hasKnownSize {
|
||||||
|
*downloadSpeedBps = 0
|
||||||
triggerOTAStateUpdate()
|
triggerOTAStateUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -791,7 +926,7 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear the filesystem caches to force a read from disk
|
// 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 {
|
if err != nil {
|
||||||
otaLogger.Warn().Err(err).Msg("Failed to clear filesystem caches")
|
otaLogger.Warn().Err(err).Msg("Failed to clear filesystem caches")
|
||||||
}
|
}
|
||||||
@@ -813,7 +948,10 @@ func prepareSystemUpdateTarFromKvmSystemZip(
|
|||||||
zipURL string,
|
zipURL string,
|
||||||
outputTarPath string,
|
outputTarPath string,
|
||||||
downloadProgress *float32,
|
downloadProgress *float32,
|
||||||
|
downloadSpeedBps *float32,
|
||||||
verificationProgress *float32,
|
verificationProgress *float32,
|
||||||
|
sigURL string,
|
||||||
|
expectedHash string,
|
||||||
scopedLogger *zerolog.Logger,
|
scopedLogger *zerolog.Logger,
|
||||||
) error {
|
) error {
|
||||||
if scopedLogger == nil {
|
if scopedLogger == nil {
|
||||||
@@ -825,14 +963,14 @@ func prepareSystemUpdateTarFromKvmSystemZip(
|
|||||||
extractDir := filepath.Join(workDir, "extract")
|
extractDir := filepath.Join(workDir, "extract")
|
||||||
zipPath := filepath.Join(workDir, "master.zip")
|
zipPath := filepath.Join(workDir, "master.zip")
|
||||||
|
|
||||||
if err := os.MkdirAll(workDir, 0755); err != nil {
|
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
||||||
return fmt.Errorf("error creating work dir: %w", err)
|
return fmt.Errorf("error creating work dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.RemoveAll(extractDir); err != nil {
|
if err := os.RemoveAll(extractDir); err != nil {
|
||||||
return fmt.Errorf("error cleaning extract dir: %w", err)
|
return fmt.Errorf("error cleaning extract dir: %w", err)
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(extractDir, 0755); err != nil {
|
if err := os.MkdirAll(extractDir, 0o755); err != nil {
|
||||||
return fmt.Errorf("error creating extract dir: %w", err)
|
return fmt.Errorf("error creating extract dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -846,28 +984,40 @@ func prepareSystemUpdateTarFromKvmSystemZip(
|
|||||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||||
if downloadProgress != nil {
|
if downloadProgress != nil {
|
||||||
*downloadProgress = 0
|
*downloadProgress = 0
|
||||||
|
}
|
||||||
|
if downloadSpeedBps != nil {
|
||||||
|
*downloadSpeedBps = 0
|
||||||
|
}
|
||||||
|
if downloadProgress != nil || downloadSpeedBps != nil {
|
||||||
triggerOTAStateUpdate()
|
triggerOTAStateUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := downloadFile(ctx, zipPath, zipURL, downloadProgress); err != nil {
|
if err := downloadFile(ctx, zipPath, zipURL, downloadProgress, downloadSpeedBps); err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
} else {
|
} else {
|
||||||
zipUnverifiedPath := zipPath + ".unverified"
|
zipUnverifiedPath := zipPath + ".unverified"
|
||||||
if _, err := os.Stat(zipUnverifiedPath); err != nil {
|
if _, err := os.Stat(zipUnverifiedPath); err != nil {
|
||||||
lastErr = fmt.Errorf("downloaded zip not found: %s: %w", zipUnverifiedPath, err)
|
lastErr = fmt.Errorf("downloaded zip not found: %s: %w", zipUnverifiedPath, err)
|
||||||
|
} else if sigURL != "" || expectedHash != "" {
|
||||||
|
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 {
|
} else {
|
||||||
if err := unzipArchive(zipUnverifiedPath, extractDir); err != nil {
|
lastErr = nil
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else if err := unzipArchive(zipUnverifiedPath, extractDir); err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
} else {
|
} else {
|
||||||
lastErr = nil
|
lastErr = nil
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
_ = os.Remove(zipPath + ".unverified")
|
_ = os.Remove(zipPath + ".unverified")
|
||||||
_ = os.RemoveAll(extractDir)
|
_ = os.RemoveAll(extractDir)
|
||||||
_ = os.MkdirAll(extractDir, 0755)
|
_ = os.MkdirAll(extractDir, 0o755)
|
||||||
if attempt < maxAttempts {
|
if attempt < maxAttempts {
|
||||||
time.Sleep(time.Duration(attempt*2) * time.Second)
|
time.Sleep(time.Duration(attempt*2) * time.Second)
|
||||||
}
|
}
|
||||||
@@ -899,7 +1049,7 @@ func prepareSystemUpdateTarFromKvmSystemZip(
|
|||||||
if _, err := os.Stat(scriptPath); err != nil {
|
if _, err := os.Stat(scriptPath); err != nil {
|
||||||
return fmt.Errorf("split_and_check_md5.sh not found: %w", err)
|
return fmt.Errorf("split_and_check_md5.sh not found: %w", err)
|
||||||
}
|
}
|
||||||
if err := os.Chmod(scriptPath, 0755); err != nil {
|
if err := os.Chmod(scriptPath, 0o755); err != nil {
|
||||||
return fmt.Errorf("error chmod split_and_check_md5.sh: %w", err)
|
return fmt.Errorf("error chmod split_and_check_md5.sh: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -964,13 +1114,13 @@ func unzipArchive(zipPath string, destDir string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if file.FileInfo().IsDir() {
|
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)
|
return fmt.Errorf("error creating dir: %w", err)
|
||||||
}
|
}
|
||||||
continue
|
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)
|
return fmt.Errorf("error creating dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -979,7 +1129,7 @@ func unzipArchive(zipPath string, destDir string) error {
|
|||||||
return fmt.Errorf("error opening zipped file: %w", err)
|
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 {
|
if err != nil {
|
||||||
rc.Close()
|
rc.Close()
|
||||||
return fmt.Errorf("error creating file: %w", err)
|
return fmt.Errorf("error creating file: %w", err)
|
||||||
@@ -1002,17 +1152,42 @@ func unzipArchive(zipPath string, destDir string) error {
|
|||||||
return nil
|
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 {
|
if scopedLogger == nil {
|
||||||
scopedLogger = otaLogger
|
scopedLogger = otaLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
unverifiedPath := path + ".unverified"
|
unverifiedPath := path + ".unverified"
|
||||||
|
|
||||||
|
if strings.TrimSpace(sigURL) == "" && strings.TrimSpace(expectedHash) == "" {
|
||||||
|
return fmt.Errorf("refusing to flash unverified firmware: no signature URL and no hash provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(sigURL) != "" {
|
||||||
|
sigBasePath := path + ".sig"
|
||||||
|
sigDownloadErr := downloadFile(ctx, sigBasePath, sigURL, nil, nil)
|
||||||
|
sigPath := sigBasePath + ".unverified"
|
||||||
|
if sigDownloadErr != nil {
|
||||||
|
scopedLogger.Warn().Err(sigDownloadErr).Str("sigURL", sigURL).Msg("failed to download signature file, falling back to hash-only verification")
|
||||||
|
} else {
|
||||||
|
sigPresent, sigErr := verifyFileSignature(unverifiedPath, sigPath, scopedLogger)
|
||||||
|
_ = os.Remove(sigPath)
|
||||||
|
if sigPresent && sigErr != nil {
|
||||||
|
return fmt.Errorf("signature verification failed: %w", sigErr)
|
||||||
|
}
|
||||||
|
if sigPresent && sigErr == nil {
|
||||||
|
scopedLogger.Info().Str("path", path).Msg("firmware signature verified, proceeding to hash check")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
scopedLogger.Info().Str("path", path).Msg("no signature URL provided, skipping signature verification")
|
||||||
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(expectedHash) == "" {
|
if strings.TrimSpace(expectedHash) == "" {
|
||||||
if err := os.Rename(unverifiedPath, path); err != nil {
|
if err := os.Rename(unverifiedPath, path); err != nil {
|
||||||
return fmt.Errorf("error renaming file: %w", err)
|
return fmt.Errorf("error renaming file: %w", err)
|
||||||
}
|
}
|
||||||
if err := os.Chmod(path, 0755); err != nil {
|
if err := os.Chmod(path, 0o755); err != nil {
|
||||||
return fmt.Errorf("error making file executable: %w", err)
|
return fmt.Errorf("error making file executable: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -1070,7 +1245,7 @@ func verifyFile(path string, expectedHash string, verifyProgress *float32, scope
|
|||||||
return fmt.Errorf("error renaming file: %w", err)
|
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 fmt.Errorf("error making file executable: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1083,17 +1258,23 @@ type OTAState struct {
|
|||||||
MetadataFetchedAt *time.Time `json:"metadataFetchedAt,omitempty"`
|
MetadataFetchedAt *time.Time `json:"metadataFetchedAt,omitempty"`
|
||||||
AppUpdatePending bool `json:"appUpdatePending"`
|
AppUpdatePending bool `json:"appUpdatePending"`
|
||||||
SystemUpdatePending bool `json:"systemUpdatePending"`
|
SystemUpdatePending bool `json:"systemUpdatePending"`
|
||||||
AppDownloadProgress float32 `json:"appDownloadProgress,omitempty"` //TODO: implement for progress bar
|
AppDownloadProgress float32 `json:"appDownloadProgress,omitempty"` // TODO: implement for progress bar
|
||||||
|
AppDownloadSpeedBps float32 `json:"appDownloadSpeedBps"`
|
||||||
AppDownloadFinishedAt *time.Time `json:"appDownloadFinishedAt,omitempty"`
|
AppDownloadFinishedAt *time.Time `json:"appDownloadFinishedAt,omitempty"`
|
||||||
SystemDownloadProgress float32 `json:"systemDownloadProgress,omitempty"` //TODO: implement for progress bar
|
SystemDownloadProgress float32 `json:"systemDownloadProgress,omitempty"` // TODO: implement for progress bar
|
||||||
|
SystemDownloadSpeedBps float32 `json:"systemDownloadSpeedBps"`
|
||||||
SystemDownloadFinishedAt *time.Time `json:"systemDownloadFinishedAt,omitempty"`
|
SystemDownloadFinishedAt *time.Time `json:"systemDownloadFinishedAt,omitempty"`
|
||||||
AppVerificationProgress float32 `json:"appVerificationProgress,omitempty"`
|
AppVerificationProgress float32 `json:"appVerificationProgress,omitempty"`
|
||||||
AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"`
|
AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"`
|
||||||
SystemVerificationProgress float32 `json:"systemVerificationProgress,omitempty"`
|
SystemVerificationProgress float32 `json:"systemVerificationProgress,omitempty"`
|
||||||
SystemVerifiedAt *time.Time `json:"systemVerifiedAt,omitempty"`
|
SystemVerifiedAt *time.Time `json:"systemVerifiedAt,omitempty"`
|
||||||
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"`
|
AppUpdatedAt *time.Time `json:"appUpdatedAt,omitempty"`
|
||||||
SystemUpdateProgress float32 `json:"systemUpdateProgress,omitempty"` //TODO: port rk_ota, then implement
|
SystemUpdateProgress float32 `json:"systemUpdateProgress,omitempty"` // TODO: port rk_ota, then implement
|
||||||
SystemUpdatedAt *time.Time `json:"systemUpdatedAt,omitempty"`
|
SystemUpdatedAt *time.Time `json:"systemUpdatedAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1112,7 +1293,12 @@ func triggerOTAStateUpdate() {
|
|||||||
func cleanupUpdateTempFiles(logger *zerolog.Logger) {
|
func cleanupUpdateTempFiles(logger *zerolog.Logger) {
|
||||||
paths := []string{
|
paths := []string{
|
||||||
"/userdata/picokvm/bin/kvm_app.unverified",
|
"/userdata/picokvm/bin/kvm_app.unverified",
|
||||||
|
"/userdata/picokvm/bin/kvm_app.sig.unverified",
|
||||||
|
"/userdata/picokvm/update_system.zip.unverified",
|
||||||
|
"/userdata/picokvm/update_system.zip.sig.unverified",
|
||||||
|
"/userdata/picokvm/update_system.zip",
|
||||||
"/userdata/picokvm/update_system.tar.unverified",
|
"/userdata/picokvm/update_system.tar.unverified",
|
||||||
|
"/userdata/picokvm/update_system.tar.sig.unverified",
|
||||||
"/userdata/picokvm/update_system.tar",
|
"/userdata/picokvm/update_system.tar",
|
||||||
"/userdata/picokvm/kvm_system_work",
|
"/userdata/picokvm/kvm_system_work",
|
||||||
}
|
}
|
||||||
@@ -1177,7 +1363,13 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||||||
Str("remote", remote.AppVersion).
|
Str("remote", remote.AppVersion).
|
||||||
Msg("App update available")
|
Msg("App update available")
|
||||||
|
|
||||||
err := downloadFile(ctx, "/userdata/picokvm/bin/kvm_app", remote.AppUrl, &otaState.AppDownloadProgress)
|
err := downloadFile(
|
||||||
|
ctx,
|
||||||
|
"/userdata/picokvm/bin/kvm_app",
|
||||||
|
remote.AppUrl,
|
||||||
|
&otaState.AppDownloadProgress,
|
||||||
|
&otaState.AppDownloadSpeedBps,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
otaState.Error = fmt.Sprintf("Error downloading app update: %v", err)
|
otaState.Error = fmt.Sprintf("Error downloading app update: %v", err)
|
||||||
scopedLogger.Error().Err(err).Msg("Error downloading app update")
|
scopedLogger.Error().Err(err).Msg("Error downloading app update")
|
||||||
@@ -1190,8 +1382,10 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||||||
triggerOTAStateUpdate()
|
triggerOTAStateUpdate()
|
||||||
|
|
||||||
err = verifyFile(
|
err = verifyFile(
|
||||||
|
ctx,
|
||||||
"/userdata/picokvm/bin/kvm_app",
|
"/userdata/picokvm/bin/kvm_app",
|
||||||
remote.AppHash,
|
remote.AppHash,
|
||||||
|
remote.AppSigUrl,
|
||||||
&otaState.AppVerificationProgress,
|
&otaState.AppVerificationProgress,
|
||||||
&scopedLogger,
|
&scopedLogger,
|
||||||
)
|
)
|
||||||
@@ -1204,6 +1398,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||||||
verifyFinished := time.Now()
|
verifyFinished := time.Now()
|
||||||
otaState.AppVerifiedAt = &verifyFinished
|
otaState.AppVerifiedAt = &verifyFinished
|
||||||
otaState.AppVerificationProgress = 1
|
otaState.AppVerificationProgress = 1
|
||||||
|
otaState.AppSignatureVerified = strings.TrimSpace(remote.AppSigUrl) != ""
|
||||||
otaState.AppUpdatedAt = &verifyFinished
|
otaState.AppUpdatedAt = &verifyFinished
|
||||||
otaState.AppUpdateProgress = 1
|
otaState.AppUpdateProgress = 1
|
||||||
triggerOTAStateUpdate()
|
triggerOTAStateUpdate()
|
||||||
@@ -1227,7 +1422,10 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||||||
remote.SystemUrl,
|
remote.SystemUrl,
|
||||||
systemTarPath,
|
systemTarPath,
|
||||||
&otaState.SystemDownloadProgress,
|
&otaState.SystemDownloadProgress,
|
||||||
|
&otaState.SystemDownloadSpeedBps,
|
||||||
&otaState.SystemVerificationProgress,
|
&otaState.SystemVerificationProgress,
|
||||||
|
remote.SystemSigUrl,
|
||||||
|
remote.SystemHash,
|
||||||
&scopedLogger,
|
&scopedLogger,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1238,7 +1436,13 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
systemZipPath := "/userdata/picokvm/update_system.zip"
|
systemZipPath := "/userdata/picokvm/update_system.zip"
|
||||||
err := downloadFile(ctx, systemZipPath, remote.SystemUrl, &otaState.SystemDownloadProgress)
|
err := downloadFile(
|
||||||
|
ctx,
|
||||||
|
systemZipPath,
|
||||||
|
remote.SystemUrl,
|
||||||
|
&otaState.SystemDownloadProgress,
|
||||||
|
&otaState.SystemDownloadSpeedBps,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
otaState.Error = fmt.Sprintf("Error downloading system update: %v", err)
|
otaState.Error = fmt.Sprintf("Error downloading system update: %v", err)
|
||||||
scopedLogger.Error().Err(err).Msg("Error downloading system update")
|
scopedLogger.Error().Err(err).Msg("Error downloading system update")
|
||||||
@@ -1246,7 +1450,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = verifyFile(systemZipPath, remote.SystemHash, &otaState.SystemVerificationProgress, &scopedLogger)
|
err = verifyFile(ctx, systemZipPath, remote.SystemHash, remote.SystemSigUrl, &otaState.SystemVerificationProgress, &scopedLogger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
otaState.Error = fmt.Sprintf("Error preparing system update archive: %v", err)
|
otaState.Error = fmt.Sprintf("Error preparing system update archive: %v", err)
|
||||||
scopedLogger.Error().Err(err).Msg("Error preparing system update archive")
|
scopedLogger.Error().Err(err).Msg("Error preparing system update archive")
|
||||||
@@ -1270,6 +1474,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||||||
verifyFinished := time.Now()
|
verifyFinished := time.Now()
|
||||||
otaState.SystemVerifiedAt = &verifyFinished
|
otaState.SystemVerifiedAt = &verifyFinished
|
||||||
otaState.SystemVerificationProgress = 1
|
otaState.SystemVerificationProgress = 1
|
||||||
|
otaState.SystemSignatureVerified = strings.TrimSpace(remote.SystemSigUrl) != ""
|
||||||
triggerOTAStateUpdate()
|
triggerOTAStateUpdate()
|
||||||
|
|
||||||
scopedLogger.Info().Msg("Starting rk_ota command")
|
scopedLogger.Info().Msg("Starting rk_ota command")
|
||||||
@@ -1339,13 +1544,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rebootNeeded {
|
if rebootNeeded {
|
||||||
configPath := "/userdata/kvm_config.json"
|
cleanupUpdateTempFiles(&scopedLogger)
|
||||||
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
|
|
||||||
scopedLogger.Warn().Err(err).Str("path", configPath).Msg("failed to delete config before reboot")
|
|
||||||
} else {
|
|
||||||
scopedLogger.Info().Str("path", configPath).Msg("deleted config before reboot")
|
|
||||||
}
|
|
||||||
|
|
||||||
scopedLogger.Info().Msg("System Rebooting in 10s")
|
scopedLogger.Info().Msg("System Rebooting in 10s")
|
||||||
time.Sleep(10 * time.Second)
|
time.Sleep(10 * time.Second)
|
||||||
cmd := exec.Command("reboot")
|
cmd := exec.Command("reboot")
|
||||||
@@ -1406,6 +1605,9 @@ func GetUpdateStatus(ctx context.Context, deviceId string, includePreRelease boo
|
|||||||
updateStatus.AppUpdateAvailable = false
|
updateStatus.AppUpdateAvailable = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateStatus.AppSignatureMissing = strings.TrimSpace(remoteMetadata.AppSigUrl) == ""
|
||||||
|
updateStatus.SystemSignatureMissing = strings.TrimSpace(remoteMetadata.SystemSigUrl) == ""
|
||||||
|
|
||||||
return updateStatus, nil
|
return updateStatus, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1420,3 +1622,130 @@ func confirmCurrentSystem() {
|
|||||||
logger.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup")
|
logger.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getOTAPublicKey() ed25519.PublicKey {
|
||||||
|
keyStr := strings.TrimSpace(builtOtaPublicKey)
|
||||||
|
if keyStr == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
keyBytes, err := hex.DecodeString(keyStr)
|
||||||
|
if err != nil {
|
||||||
|
otaLogger.Warn().Err(err).Msg("invalid OTA public key hex in binary")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(keyBytes) != ed25519.PublicKeySize {
|
||||||
|
otaLogger.Warn().Int("size", len(keyBytes)).Msg("OTA public key wrong size, expected 32 bytes")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ed25519.PublicKey(keyBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyFileSignature(
|
||||||
|
unverifiedPath string,
|
||||||
|
sigPath string,
|
||||||
|
scopedLogger *zerolog.Logger,
|
||||||
|
) (signaturePresent bool, err error) {
|
||||||
|
if scopedLogger == nil {
|
||||||
|
scopedLogger = otaLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(sigPath); os.IsNotExist(err) {
|
||||||
|
scopedLogger.Info().Str("path", sigPath).Msg("signature file not found, skipping signature verification")
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sigBytes, err := os.ReadFile(sigPath)
|
||||||
|
if err != nil {
|
||||||
|
return true, fmt.Errorf("error reading signature file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sigBytes) != ed25519.SignatureSize {
|
||||||
|
return true, fmt.Errorf("invalid signature file size: got %d bytes, expected %d", len(sigBytes), ed25519.SignatureSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
publicKey := getOTAPublicKey()
|
||||||
|
if publicKey == nil {
|
||||||
|
return true, fmt.Errorf("signature present but no public key embedded in binary")
|
||||||
|
}
|
||||||
|
|
||||||
|
fileBytes, err := os.ReadFile(unverifiedPath)
|
||||||
|
if err != nil {
|
||||||
|
return true, fmt.Errorf("error reading file for signature verification: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileHash := sha256.Sum256(fileBytes)
|
||||||
|
if !ed25519.Verify(publicKey, fileHash[:], sigBytes) {
|
||||||
|
return true, fmt.Errorf("Ed25519 signature verification failed for %s", unverifiedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
scopedLogger.Info().Str("path", unverifiedPath).Msg("Ed25519 signature verification passed")
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSigFileAbsent(sigPath string) bool {
|
||||||
|
_, err := os.Stat(sigPath)
|
||||||
|
return os.IsNotExist(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyLocalFileSignature(filePath string, sigPath string, publicKey ed25519.PublicKey) bool {
|
||||||
|
sigBytes, err := os.ReadFile(sigPath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(sigBytes) != ed25519.SignatureSize {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
fileBytes, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
fileHash := sha256.Sum256(fileBytes)
|
||||||
|
return ed25519.Verify(publicKey, fileHash[:], sigBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignatureUpdateResult struct {
|
||||||
|
AppSignatureUpdated bool `json:"appSignatureUpdated"`
|
||||||
|
SystemSignatureUpdated bool `json:"systemSignatureUpdated"`
|
||||||
|
AppSignatureValid bool `json:"appSignatureValid"`
|
||||||
|
SystemSignatureValid bool `json:"systemSignatureValid"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateSignatures(ctx context.Context) (*SignatureUpdateResult, error) {
|
||||||
|
result := &SignatureUpdateResult{}
|
||||||
|
|
||||||
|
remoteMetadata, err := fetchUpdateMetadata(ctx, "", false)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = fmt.Sprintf("failed to fetch remote metadata: %v", err)
|
||||||
|
return result, fmt.Errorf("failed to fetch remote metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
publicKey := getOTAPublicKey()
|
||||||
|
|
||||||
|
appBinPath := "/userdata/picokvm/bin/kvm_app"
|
||||||
|
appSigPath := appBinPath + ".sig"
|
||||||
|
|
||||||
|
if strings.TrimSpace(remoteMetadata.AppSigUrl) != "" {
|
||||||
|
err := downloadFile(ctx, appSigPath, remoteMetadata.AppSigUrl, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = fmt.Sprintf("failed to download app signature: %v", err)
|
||||||
|
return result, fmt.Errorf("failed to download app signature: %w", err)
|
||||||
|
}
|
||||||
|
result.AppSignatureUpdated = true
|
||||||
|
|
||||||
|
sigUnverified := appSigPath + ".unverified"
|
||||||
|
if _, statErr := os.Stat(sigUnverified); statErr == nil {
|
||||||
|
_ = os.Remove(appSigPath)
|
||||||
|
if renameErr := os.Rename(sigUnverified, appSigPath); renameErr != nil {
|
||||||
|
result.Error = fmt.Sprintf("failed to rename app signature: %v", renameErr)
|
||||||
|
return result, fmt.Errorf("failed to rename app signature: %w", renameErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if publicKey != nil {
|
||||||
|
result.AppSignatureValid = verifyLocalFileSignature(appBinPath, appSigPath, publicKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,30 +2,64 @@ package kvm
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type VideoFrame struct {
|
||||||
|
data []byte
|
||||||
|
refs atomic.Int32
|
||||||
|
pool *sync.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *VideoFrame) Data() []byte {
|
||||||
|
return f.data
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *VideoFrame) Release() {
|
||||||
|
if f.refs.Add(-1) == 0 {
|
||||||
|
f.data = f.data[:cap(f.data)]
|
||||||
|
f.pool.Put(f.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var framePool = sync.Pool{
|
||||||
|
New: func() interface{} {
|
||||||
|
return make([]byte, maxFrameSize)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
type VideoBroadcaster struct {
|
type VideoBroadcaster struct {
|
||||||
subscribers map[string]chan []byte
|
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
|
lock sync.RWMutex
|
||||||
onFirstSubscribe func()
|
onFirstSubscribe func()
|
||||||
onLastUnsubscribe func()
|
onLastUnsubscribe func()
|
||||||
}
|
}
|
||||||
|
|
||||||
var videoBroadcaster = &VideoBroadcaster{
|
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()
|
b.lock.Lock()
|
||||||
defer b.lock.Unlock()
|
defer b.lock.Unlock()
|
||||||
id := uuid.New().String()
|
id := uuid.New().String()
|
||||||
// Buffer a bit to avoid dropping frames too easily,
|
ch := make(chan *VideoFrame, 200)
|
||||||
// but not too much to avoid latency build-up
|
|
||||||
ch := make(chan []byte, 200)
|
|
||||||
wasEmpty := len(b.subscribers) == 0
|
wasEmpty := len(b.subscribers) == 0
|
||||||
b.subscribers[id] = ch
|
b.subscribers[id] = ch
|
||||||
|
b.rebuildList()
|
||||||
|
b.count.Store(int32(len(b.subscribers)))
|
||||||
if wasEmpty && b.onFirstSubscribe != nil {
|
if wasEmpty && b.onFirstSubscribe != nil {
|
||||||
b.onFirstSubscribe()
|
b.onFirstSubscribe()
|
||||||
}
|
}
|
||||||
@@ -38,6 +72,8 @@ func (b *VideoBroadcaster) Unsubscribe(id string) {
|
|||||||
if ch, ok := b.subscribers[id]; ok {
|
if ch, ok := b.subscribers[id]; ok {
|
||||||
close(ch)
|
close(ch)
|
||||||
delete(b.subscribers, id)
|
delete(b.subscribers, id)
|
||||||
|
b.rebuildList()
|
||||||
|
b.count.Store(int32(len(b.subscribers)))
|
||||||
if len(b.subscribers) == 0 && b.onLastUnsubscribe != nil {
|
if len(b.subscribers) == 0 && b.onLastUnsubscribe != nil {
|
||||||
b.onLastUnsubscribe()
|
b.onLastUnsubscribe()
|
||||||
}
|
}
|
||||||
@@ -45,15 +81,38 @@ func (b *VideoBroadcaster) Unsubscribe(id string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *VideoBroadcaster) Broadcast(data []byte) {
|
func (b *VideoBroadcaster) Broadcast(data []byte) {
|
||||||
|
// atomic check avoids acquiring RLock on every video frame when no HTTP clients are connected
|
||||||
|
if b.count.Load() == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
b.lock.RLock()
|
b.lock.RLock()
|
||||||
defer b.lock.RUnlock()
|
subscribers := b.subscriberList
|
||||||
for _, ch := range b.subscribers {
|
subscriberCount := len(subscribers)
|
||||||
// Non-blocking send
|
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 {
|
select {
|
||||||
case ch <- data:
|
case ch <- frame:
|
||||||
default:
|
default:
|
||||||
// Drop frame if channel is full to avoid blocking other subscribers
|
frame.Release()
|
||||||
// Ideally we should have a ring buffer or similar, but this is simple
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
673
ui/package-lock.json
generated
673
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -54,6 +54,7 @@
|
|||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
"styled-components": "^6.1.19",
|
"styled-components": "^6.1.19",
|
||||||
"tailwind-merge": "^3.3.0",
|
"tailwind-merge": "^3.3.0",
|
||||||
|
"tesseract.js": "^7.0.0",
|
||||||
"usehooks-ts": "^3.1.1",
|
"usehooks-ts": "^3.1.1",
|
||||||
"validator": "^13.15.0",
|
"validator": "^13.15.0",
|
||||||
"w-touch": "^2.0.0",
|
"w-touch": "^2.0.0",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface ConfirmDialogProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
title: string;
|
title: string;
|
||||||
description: React.ReactNode;
|
description: React.ReactNode;
|
||||||
|
children?: React.ReactNode;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
cancelText?: string | null;
|
cancelText?: string | null;
|
||||||
@@ -65,6 +66,7 @@ export function ConfirmDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
children,
|
||||||
variant = "info",
|
variant = "info",
|
||||||
confirmText = "Confirm",
|
confirmText = "Confirm",
|
||||||
cancelText = "Cancel",
|
cancelText = "Cancel",
|
||||||
@@ -107,9 +109,10 @@ export function ConfirmDialog({
|
|||||||
{description}
|
{description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</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
|
<Button
|
||||||
size="LG"
|
size="LG"
|
||||||
theme={buttonTheme}
|
theme={buttonTheme}
|
||||||
@@ -157,10 +160,11 @@ export function ConfirmDialog({
|
|||||||
<div className="mt-2 text-sm leading-snug text-slate-600 dark:text-[#ffffff]">
|
<div className="mt-2 text-sm leading-snug text-slate-600 dark:text-[#ffffff]">
|
||||||
{description}
|
{description}
|
||||||
</div>
|
</div>
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-x-2">
|
<div className={cx("flex justify-end gap-x-2", isConfirming && "pointer-events-none")}>
|
||||||
{cancelText && (
|
{cancelText && (
|
||||||
<Button size="SM" theme="light" text={cancelText} onClick={onClose} />
|
<Button size="SM" theme="light" text={cancelText} onClick={onClose} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ export function FileUploader({
|
|||||||
onBack,
|
onBack,
|
||||||
incompleteFileName,
|
incompleteFileName,
|
||||||
media,
|
media,
|
||||||
|
accept,
|
||||||
}: {
|
}: {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
incompleteFileName?: string;
|
incompleteFileName?: string;
|
||||||
media?: string;
|
media?: string;
|
||||||
|
accept?: string;
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
const { $at }= useReactAt();
|
const { $at }= useReactAt();
|
||||||
@@ -39,9 +41,25 @@ export function FileUploader({
|
|||||||
|
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
const rtcDataChannelRef = useRef<RTCDataChannel | null>(null);
|
const rtcDataChannelRef = useRef<RTCDataChannel | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const xhrRef = useRef<XMLHttpRequest | null>(null);
|
const xhrRef = useRef<XMLHttpRequest | null>(null);
|
||||||
|
|
||||||
|
const validateSelectedFile = (file: File) => {
|
||||||
|
if (!accept) return null;
|
||||||
|
const allowedExts = accept
|
||||||
|
.split(",")
|
||||||
|
.map(s => s.trim().toLowerCase())
|
||||||
|
.filter(s => s.startsWith("."));
|
||||||
|
if (allowedExts.length === 0) return null;
|
||||||
|
const lowerName = file.name.toLowerCase();
|
||||||
|
if (allowedExts.some(ext => lowerName.endsWith(ext))) return null;
|
||||||
|
return $at("Only {{types}} files are supported").replace(
|
||||||
|
"{{types}}",
|
||||||
|
allowedExts.join(", "),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ref = rtcDataChannelRef.current;
|
const ref = rtcDataChannelRef.current;
|
||||||
return () => {
|
return () => {
|
||||||
@@ -262,6 +280,13 @@ export function FileUploader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setFileError(null);
|
setFileError(null);
|
||||||
|
const validationError = validateSelectedFile(file);
|
||||||
|
if (validationError) {
|
||||||
|
setFileError(validationError);
|
||||||
|
setUploadState("idle");
|
||||||
|
event.target.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.log(`File selected: ${file.name}, size: ${file.size} bytes`);
|
console.log(`File selected: ${file.name}, size: ${file.size} bytes`);
|
||||||
setUploadedFileName(file.name);
|
setUploadedFileName(file.name);
|
||||||
setUploadedFileSize(file.size);
|
setUploadedFileSize(file.size);
|
||||||
@@ -338,7 +363,7 @@ export function FileUploader({
|
|||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (uploadState === "idle") {
|
if (uploadState === "idle") {
|
||||||
document.getElementById("file-upload")?.click();
|
fileInputRef.current?.click();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="block select-none"
|
className="block select-none"
|
||||||
@@ -369,7 +394,7 @@ export function FileUploader({
|
|||||||
<span>{$at("Click here to select {{fileName}} to resume upload").replace("{{fileName}}", formatters.truncateMiddle(incompleteFileName.replace(".incomplete", ""), 30))}</span>
|
<span>{$at("Click here to select {{fileName}} to resume upload").replace("{{fileName}}", formatters.truncateMiddle(incompleteFileName.replace(".incomplete", ""), 30))}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
: $at("Click here to upload a new image")
|
: $at("Click here to upload")
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
{/*<p className="text-xs leading-none text-slate-700 dark:text-slate-300">*/}
|
{/*<p className="text-xs leading-none text-slate-700 dark:text-slate-300">*/}
|
||||||
@@ -435,10 +460,11 @@ export function FileUploader({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
id="file-upload"
|
|
||||||
type="file"
|
type="file"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
|
ref={fileInputRef}
|
||||||
|
accept={accept}
|
||||||
/>
|
/>
|
||||||
{fileError && (
|
{fileError && (
|
||||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{fileError}</p>
|
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{fileError}</p>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const comboboxVariants = cva({
|
|||||||
variants: { size: sizes },
|
variants: { size: sizes },
|
||||||
});
|
});
|
||||||
|
|
||||||
type BaseProps = React.ComponentProps<typeof HeadlessCombobox>;
|
type BaseProps = React.ComponentProps<typeof HeadlessCombobox<ComboboxOption>>;
|
||||||
|
|
||||||
interface ComboboxProps extends Omit<BaseProps, "displayValue"> {
|
interface ComboboxProps extends Omit<BaseProps, "displayValue"> {
|
||||||
displayValue: (option: ComboboxOption) => string;
|
displayValue: (option: ComboboxOption) => string;
|
||||||
|
|||||||
@@ -205,7 +205,8 @@ export function MacroStepCard({
|
|||||||
)}
|
)}
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<Combobox
|
<Combobox
|
||||||
onChange={(value: { value: string; label: string }) => {
|
onChange={value => {
|
||||||
|
if (!value) return;
|
||||||
onKeySelect(value);
|
onKeySelect(value);
|
||||||
onKeyQueryChange('');
|
onKeyQueryChange('');
|
||||||
}}
|
}}
|
||||||
|
|||||||
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";
|
align?: "left" | "right";
|
||||||
buttonClassName?: string;
|
buttonClassName?: string;
|
||||||
panelClassName?: string;
|
panelClassName?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BottomPopoverButton: React.FC<PopoverButtonProps> = ({
|
const BottomPopoverButton: React.FC<PopoverButtonProps> = ({
|
||||||
@@ -18,6 +19,7 @@ const BottomPopoverButton: React.FC<PopoverButtonProps> = ({
|
|||||||
buttonIconNode,
|
buttonIconNode,
|
||||||
panelContent,
|
panelContent,
|
||||||
align = "left",
|
align = "left",
|
||||||
|
style,
|
||||||
}) => {
|
}) => {
|
||||||
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||||
return (
|
return (
|
||||||
@@ -28,7 +30,7 @@ const BottomPopoverButton: React.FC<PopoverButtonProps> = ({
|
|||||||
<>
|
<>
|
||||||
<PopoverButton
|
<PopoverButton
|
||||||
as="div"
|
as="div"
|
||||||
style={{ display: "flex", justifyContent: "center", alignItems: "center" }}>
|
style={{ display: "flex", justifyContent: "center", alignItems: "center", ...style }}>
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDisableFocusTrap(true);
|
setDisableFocusTrap(true);
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ export const VideoElement = forwardRef<HTMLVideoElement, VideoElementProps>(
|
|||||||
({ onPlaying, style, className }, ref) => {
|
({ onPlaying, style, className }, ref) => {
|
||||||
const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled);
|
const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled);
|
||||||
const isVirtualKeyboardEnabled = useHidStore(state => state.isVirtualKeyboardEnabled);
|
const isVirtualKeyboardEnabled = useHidStore(state => state.isVirtualKeyboardEnabled);
|
||||||
|
const allowTapToOpenVirtualKeyboard = useHidStore(state => state.allowTapToOpenVirtualKeyboard);
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (isMobile && !isVirtualKeyboardEnabled) {
|
if (isMobile && allowTapToOpenVirtualKeyboard && !isVirtualKeyboardEnabled) {
|
||||||
setVirtualKeyboardEnabled(true);
|
setVirtualKeyboardEnabled(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function LoadingConnectionOverlay({ show, text }: LoadingConnectionOverla
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{show && (
|
{show && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="aspect-video h-full w-full"
|
className="absolute inset-0 h-full w-full"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0, transition: { duration: 0 } }}
|
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||||
@@ -124,7 +124,7 @@ export function ConnectionFailedOverlay({
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{show && (
|
{show && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="aspect-video h-full w-full"
|
className="absolute inset-0 h-full w-full"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0, transition: { duration: 0 } }}
|
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||||
@@ -215,7 +215,7 @@ export function PeerConnectionDisconnectedOverlay({
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{show && (
|
{show && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="aspect-video h-full w-full"
|
className="absolute inset-0 h-full w-full"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0, transition: { duration: 0 } }}
|
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||||
@@ -278,9 +278,10 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
|||||||
|
|
||||||
const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled);
|
const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled);
|
||||||
const isVirtualKeyboardEnabled = useHidStore(state => state.isVirtualKeyboardEnabled);
|
const isVirtualKeyboardEnabled = useHidStore(state => state.isVirtualKeyboardEnabled);
|
||||||
|
const allowTapToOpenVirtualKeyboard = useHidStore(state => state.allowTapToOpenVirtualKeyboard);
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (isMobile && !isVirtualKeyboardEnabled) {
|
if (isMobile && allowTapToOpenVirtualKeyboard && !isVirtualKeyboardEnabled) {
|
||||||
setVirtualKeyboardEnabled(true);
|
setVirtualKeyboardEnabled(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -301,7 +302,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{show && isNoSignal && (
|
{show && isNoSignal && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute inset-0 aspect-video h-full w-full "
|
className="absolute inset-0 h-full w-full "
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
@@ -385,7 +386,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{show && isOtherError && (
|
{show && isOtherError && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute inset-0 aspect-video h-full w-full"
|
className="absolute inset-0 h-full w-full"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
@@ -445,7 +446,7 @@ export function NoAutoplayPermissionsOverlay({
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{show && (
|
{show && (
|
||||||
<motion.div
|
<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 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import DetachIconRaw from "@/assets/detach-icon.svg";
|
|||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
|
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
|
||||||
import useKeyboard from "@/hooks/useKeyboard";
|
import useKeyboard from "@/hooks/useKeyboard";
|
||||||
import { 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 { dark_bg2_style} from "@/layout/theme_color";
|
||||||
|
|
||||||
import GoBottomSvg from "@/assets/second/gobottom.svg?react";
|
import GoBottomSvg from "@/assets/second/gobottom.svg?react";
|
||||||
@@ -33,6 +34,15 @@ function KeyboardWrapper() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
|
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
|
||||||
|
const { selectedKeyboard } = useKeyboardLayout();
|
||||||
|
|
||||||
|
const keyDisplayMap = useMemo(() => {
|
||||||
|
return selectedKeyboard.keyDisplayMap;
|
||||||
|
}, [selectedKeyboard]);
|
||||||
|
|
||||||
|
const virtualKeyboardLayout = useMemo(() => {
|
||||||
|
return selectedKeyboard.virtualKeyboard;
|
||||||
|
}, [selectedKeyboard]);
|
||||||
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
@@ -260,6 +270,7 @@ function KeyboardWrapper() {
|
|||||||
setIsCapsLockActive(false);
|
setIsCapsLockActive(false);
|
||||||
}
|
}
|
||||||
sendKeyboardEvent([keys["CapsLock"]], []);
|
sendKeyboardEvent([keys["CapsLock"]], []);
|
||||||
|
setTimeout(resetKeyboardState, 100);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -837,24 +848,7 @@ function KeyboardWrapper() {
|
|||||||
? [{ class: "modifier-locked", buttons: modifierLockButtons }]
|
? [{ class: "modifier-locked", buttons: modifierLockButtons }]
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
layout={{
|
layout={virtualKeyboardLayout.main}
|
||||||
default: [
|
|
||||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
|
||||||
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
|
|
||||||
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
|
|
||||||
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter",
|
|
||||||
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
|
|
||||||
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
|
|
||||||
],
|
|
||||||
shift: [
|
|
||||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
|
||||||
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
|
|
||||||
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
|
|
||||||
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
|
|
||||||
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
|
|
||||||
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
disableButtonHold={true}
|
disableButtonHold={true}
|
||||||
syncInstanceInputs={true}
|
syncInstanceInputs={true}
|
||||||
debug={false}
|
debug={false}
|
||||||
@@ -874,10 +868,7 @@ function KeyboardWrapper() {
|
|||||||
layoutName={layoutName}
|
layoutName={layoutName}
|
||||||
onKeyPress={onKeyDown}
|
onKeyPress={onKeyDown}
|
||||||
display={keyDisplayMap}
|
display={keyDisplayMap}
|
||||||
layout={{
|
layout={virtualKeyboardLayout.control}
|
||||||
default: ["PrintScreen ScrollLock Pause", "Insert Home Pageup", "Delete End Pagedown"],
|
|
||||||
shift: ["(PrintScreen) ScrollLock (Pause)", "Insert Home Pageup", "Delete End Pagedown"],
|
|
||||||
}}
|
|
||||||
syncInstanceInputs={true}
|
syncInstanceInputs={true}
|
||||||
debug={false}
|
debug={false}
|
||||||
/>
|
/>
|
||||||
@@ -902,7 +893,7 @@ function KeyboardWrapper() {
|
|||||||
onKeyPress={onKeyDown}
|
onKeyPress={onKeyDown}
|
||||||
display={keyDisplayMap}
|
display={keyDisplayMap}
|
||||||
layout={{
|
layout={{
|
||||||
default: ["ArrowUp"],
|
default: [virtualKeyboardLayout.arrows?.default?.[0] || "ArrowUp"],
|
||||||
}}
|
}}
|
||||||
syncInstanceInputs={true}
|
syncInstanceInputs={true}
|
||||||
debug={false}
|
debug={false}
|
||||||
@@ -916,7 +907,7 @@ function KeyboardWrapper() {
|
|||||||
onKeyPress={onKeyDown}
|
onKeyPress={onKeyDown}
|
||||||
display={keyDisplayMap}
|
display={keyDisplayMap}
|
||||||
layout={{
|
layout={{
|
||||||
default: ["ArrowLeft ArrowDown ArrowRight"],
|
default: [virtualKeyboardLayout.arrows?.default?.[1] || "ArrowLeft ArrowDown ArrowRight"],
|
||||||
}}
|
}}
|
||||||
syncInstanceInputs={true}
|
syncInstanceInputs={true}
|
||||||
debug={false}
|
debug={false}
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ const appendStatToMap = <T extends { timestamp: number }>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Constants and types
|
// Constants and types
|
||||||
export type AvailableSidebarViews ="ConsoleLogViewer"|"MacroMoreList"|"Fullscreen"|"TerminalTabsMobile"|"SettingsModal"|"ClipboardMobile"|"KeyboardPanel"|"MousePanel"|"SettingsVideo"
|
export type AvailableSidebarViews = "ConsoleLogViewer" | "MacroMoreList" | "Fullscreen" | "TerminalTabsMobile" | "SettingsModal" | "ClipboardMobile" | "KeyboardPanel" | "MousePanel" | "SettingsVideo"
|
||||||
|"connection-stats"|"Clipboard"|"PowerControl"|"Macros"|"VirtualMedia"|"SharedFolders"|null;
|
| "connection-stats" | "Clipboard" | "PowerControl" | "Macros" | "VirtualMedia" | "SharedFolders" | "UsbEpModeSelect" | "UsbStatusPanel" | null;
|
||||||
export type AvailableTerminalTypes = "kvm" | "serial" | "none";
|
export type AvailableTerminalTypes = "kvm" | "serial" | "none";
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
@@ -64,6 +64,8 @@ interface UIState {
|
|||||||
|
|
||||||
disableVideoFocusTrap: boolean;
|
disableVideoFocusTrap: boolean;
|
||||||
setDisableVideoFocusTrap: (enabled: boolean) => void;
|
setDisableVideoFocusTrap: (enabled: boolean) => void;
|
||||||
|
isOcrMode: boolean;
|
||||||
|
setOcrMode: (enabled: boolean) => void;
|
||||||
|
|
||||||
isWakeOnLanModalVisible: boolean;
|
isWakeOnLanModalVisible: boolean;
|
||||||
setWakeOnLanModalVisibility: (enabled: boolean) => void;
|
setWakeOnLanModalVisibility: (enabled: boolean) => void;
|
||||||
@@ -91,6 +93,8 @@ export const useUiStore = create<UIState>(set => ({
|
|||||||
|
|
||||||
disableVideoFocusTrap: false,
|
disableVideoFocusTrap: false,
|
||||||
setDisableVideoFocusTrap: enabled => set({ disableVideoFocusTrap: enabled }),
|
setDisableVideoFocusTrap: enabled => set({ disableVideoFocusTrap: enabled }),
|
||||||
|
isOcrMode: false,
|
||||||
|
setOcrMode: enabled => set({ isOcrMode: enabled }),
|
||||||
isAnimationComplete: false,
|
isAnimationComplete: false,
|
||||||
setIsAnimationComplete: enabled => set({ isAnimationComplete: enabled }),
|
setIsAnimationComplete: enabled => set({ isAnimationComplete: enabled }),
|
||||||
|
|
||||||
@@ -386,6 +390,14 @@ interface SettingsState {
|
|||||||
|
|
||||||
overrideCtrlV: boolean;
|
overrideCtrlV: boolean;
|
||||||
setOverrideCtrlV: (enabled: boolean) => void;
|
setOverrideCtrlV: (enabled: boolean) => void;
|
||||||
|
pasteShortcutEnabled: boolean;
|
||||||
|
setPasteShortcutEnabled: (enabled: boolean) => void;
|
||||||
|
pasteShortcut: string;
|
||||||
|
setPasteShortcut: (shortcut: string) => void;
|
||||||
|
ocrShortcutEnabled: boolean;
|
||||||
|
setOcrShortcutEnabled: (enabled: boolean) => void;
|
||||||
|
ocrShortcut: string;
|
||||||
|
setOcrShortcut: (shortcut: string) => void;
|
||||||
|
|
||||||
// Video enhancement settings
|
// Video enhancement settings
|
||||||
videoSaturation: number;
|
videoSaturation: number;
|
||||||
@@ -455,6 +467,14 @@ export const useSettingsStore = create(
|
|||||||
|
|
||||||
overrideCtrlV: false,
|
overrideCtrlV: false,
|
||||||
setOverrideCtrlV: enabled => set({ overrideCtrlV: enabled }),
|
setOverrideCtrlV: enabled => set({ overrideCtrlV: enabled }),
|
||||||
|
pasteShortcutEnabled: true,
|
||||||
|
setPasteShortcutEnabled: enabled => set({ pasteShortcutEnabled: enabled }),
|
||||||
|
pasteShortcut: "Ctrl+V",
|
||||||
|
setPasteShortcut: shortcut => set({ pasteShortcut: shortcut }),
|
||||||
|
ocrShortcutEnabled: true,
|
||||||
|
setOcrShortcutEnabled: enabled => set({ ocrShortcutEnabled: enabled }),
|
||||||
|
ocrShortcut: "Ctrl+C",
|
||||||
|
setOcrShortcut: shortcut => set({ ocrShortcut: shortcut }),
|
||||||
|
|
||||||
// Video enhancement settings with default values (1.0 = normal)
|
// Video enhancement settings with default values (1.0 = normal)
|
||||||
videoSaturation: 1.0,
|
videoSaturation: 1.0,
|
||||||
@@ -566,8 +586,13 @@ export interface HidState {
|
|||||||
keyboardLedStateSyncAvailable: boolean;
|
keyboardLedStateSyncAvailable: boolean;
|
||||||
setKeyboardLedStateSyncAvailable: (available: boolean) => void;
|
setKeyboardLedStateSyncAvailable: (available: boolean) => void;
|
||||||
|
|
||||||
|
keysDownState?: { modifier: number; keys: number[] };
|
||||||
|
setKeysDownState: (state: { modifier: number; keys: number[] }) => void;
|
||||||
|
|
||||||
isVirtualKeyboardEnabled: boolean;
|
isVirtualKeyboardEnabled: boolean;
|
||||||
setVirtualKeyboardEnabled: (enabled: boolean) => void;
|
setVirtualKeyboardEnabled: (enabled: boolean) => void;
|
||||||
|
allowTapToOpenVirtualKeyboard: boolean;
|
||||||
|
setAllowTapToOpenVirtualKeyboard: (enabled: boolean) => void;
|
||||||
|
|
||||||
isPasteModeEnabled: boolean;
|
isPasteModeEnabled: boolean;
|
||||||
setPasteModeEnabled: (enabled: boolean) => void;
|
setPasteModeEnabled: (enabled: boolean) => void;
|
||||||
@@ -622,11 +647,16 @@ export const useHidStore = create<HidState>((set, get) => ({
|
|||||||
set({ keyboardLedState });
|
set({ keyboardLedState });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
keysDownState: undefined,
|
||||||
|
setKeysDownState: state => set({ keysDownState: state }),
|
||||||
|
|
||||||
keyboardLedStateSyncAvailable: false,
|
keyboardLedStateSyncAvailable: false,
|
||||||
setKeyboardLedStateSyncAvailable: available => set({ keyboardLedStateSyncAvailable: available }),
|
setKeyboardLedStateSyncAvailable: available => set({ keyboardLedStateSyncAvailable: available }),
|
||||||
|
|
||||||
isVirtualKeyboardEnabled: false,
|
isVirtualKeyboardEnabled: false,
|
||||||
setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }),
|
setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }),
|
||||||
|
allowTapToOpenVirtualKeyboard: true,
|
||||||
|
setAllowTapToOpenVirtualKeyboard: enabled => set({ allowTapToOpenVirtualKeyboard: enabled }),
|
||||||
|
|
||||||
isPasteModeEnabled: false,
|
isPasteModeEnabled: false,
|
||||||
setPasteModeEnabled: enabled => set({ isPasteModeEnabled: enabled }),
|
setPasteModeEnabled: enabled => set({ isPasteModeEnabled: enabled }),
|
||||||
@@ -678,6 +708,7 @@ export interface UpdateState {
|
|||||||
appUpdatePending: boolean;
|
appUpdatePending: boolean;
|
||||||
|
|
||||||
appDownloadProgress: number;
|
appDownloadProgress: number;
|
||||||
|
appDownloadSpeedBps: number;
|
||||||
appDownloadFinishedAt: string | null;
|
appDownloadFinishedAt: string | null;
|
||||||
|
|
||||||
appVerificationProgress: number;
|
appVerificationProgress: number;
|
||||||
@@ -690,6 +721,7 @@ export interface UpdateState {
|
|||||||
systemUpdatePending: boolean;
|
systemUpdatePending: boolean;
|
||||||
|
|
||||||
systemDownloadProgress: number;
|
systemDownloadProgress: number;
|
||||||
|
systemDownloadSpeedBps: number;
|
||||||
systemDownloadFinishedAt: string | null;
|
systemDownloadFinishedAt: string | null;
|
||||||
|
|
||||||
systemVerificationProgress: number;
|
systemVerificationProgress: number;
|
||||||
@@ -697,6 +729,13 @@ export interface UpdateState {
|
|||||||
|
|
||||||
systemUpdateProgress: number;
|
systemUpdateProgress: number;
|
||||||
systemUpdatedAt: string | null;
|
systemUpdatedAt: string | null;
|
||||||
|
|
||||||
|
appSignatureMissing: boolean;
|
||||||
|
systemSignatureMissing: boolean;
|
||||||
|
appSignatureAbsent: boolean;
|
||||||
|
appSignatureInvalid: boolean;
|
||||||
|
appNoPublicKey: boolean;
|
||||||
|
signatureVerified: boolean;
|
||||||
};
|
};
|
||||||
setOtaState: (state: UpdateState["otaState"]) => void;
|
setOtaState: (state: UpdateState["otaState"]) => void;
|
||||||
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
|
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
|
||||||
@@ -732,10 +771,12 @@ export const useUpdateStore = create<UpdateState>(set => ({
|
|||||||
appUpdatePending: false,
|
appUpdatePending: false,
|
||||||
systemUpdatePending: false,
|
systemUpdatePending: false,
|
||||||
appDownloadProgress: 0,
|
appDownloadProgress: 0,
|
||||||
|
appDownloadSpeedBps: 0,
|
||||||
appDownloadFinishedAt: null,
|
appDownloadFinishedAt: null,
|
||||||
appVerificationProgress: 0,
|
appVerificationProgress: 0,
|
||||||
appVerifiedAt: null,
|
appVerifiedAt: null,
|
||||||
systemDownloadProgress: 0,
|
systemDownloadProgress: 0,
|
||||||
|
systemDownloadSpeedBps: 0,
|
||||||
systemDownloadFinishedAt: null,
|
systemDownloadFinishedAt: null,
|
||||||
systemVerificationProgress: 0,
|
systemVerificationProgress: 0,
|
||||||
systemVerifiedAt: null,
|
systemVerifiedAt: null,
|
||||||
@@ -743,6 +784,12 @@ export const useUpdateStore = create<UpdateState>(set => ({
|
|||||||
appUpdatedAt: null,
|
appUpdatedAt: null,
|
||||||
systemUpdateProgress: 0,
|
systemUpdateProgress: 0,
|
||||||
systemUpdatedAt: null,
|
systemUpdatedAt: null,
|
||||||
|
appSignatureMissing: false,
|
||||||
|
systemSignatureMissing: false,
|
||||||
|
appSignatureAbsent: false,
|
||||||
|
appSignatureInvalid: false,
|
||||||
|
appNoPublicKey: false,
|
||||||
|
signatureVerified: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
updateDialogHasBeenMinimized: false,
|
updateDialogHasBeenMinimized: false,
|
||||||
@@ -892,6 +939,9 @@ export interface IPv4StaticConfig {
|
|||||||
export interface NetworkSettings {
|
export interface NetworkSettings {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
|
http_proxy?: string;
|
||||||
|
https_proxy?: string;
|
||||||
|
all_proxy?: string;
|
||||||
ipv4_mode: IPv4Mode;
|
ipv4_mode: IPv4Mode;
|
||||||
ipv4_request_address?: string;
|
ipv4_request_address?: string;
|
||||||
ipv4_static?: IPv4StaticConfig;
|
ipv4_static?: IPv4StaticConfig;
|
||||||
@@ -1143,3 +1193,15 @@ export interface LogEntry {
|
|||||||
message: string;
|
message: string;
|
||||||
originalArgs: any[];
|
originalArgs: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BootStorageType = 'emmc' | 'sd' | 'unknown';
|
||||||
|
|
||||||
|
interface BootStorageState {
|
||||||
|
bootStorageType: BootStorageType;
|
||||||
|
setBootStorageType: (type: BootStorageType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBootStorageStore = create<BootStorageState>(set => ({
|
||||||
|
bootStorageType: 'unknown',
|
||||||
|
setBootStorageType: type => set({ bootStorageType: type }),
|
||||||
|
}));
|
||||||
|
|||||||
30
ui/src/hooks/useBootStorage.ts
Normal file
30
ui/src/hooks/useBootStorage.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useJsonRpc } from "./useJsonRpc";
|
||||||
|
import { useBootStorageStore, BootStorageType } from "./stores";
|
||||||
|
|
||||||
|
export const useBootStorageType = () => {
|
||||||
|
const [send] = useJsonRpc();
|
||||||
|
const bootStorageType = useBootStorageStore(state => state.bootStorageType);
|
||||||
|
const setBootStorageType = useBootStorageStore(state => state.setBootStorageType);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bootStorageType !== "unknown") {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
send("getBootStorageType", {}, res => {
|
||||||
|
setLoading(false);
|
||||||
|
if ("error" in res) {
|
||||||
|
console.error("Failed to get boot storage type:", res.error);
|
||||||
|
setBootStorageType("unknown");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { type } = res.result as { type: BootStorageType };
|
||||||
|
setBootStorageType(type);
|
||||||
|
});
|
||||||
|
}, [send, bootStorageType, setBootStorageType]);
|
||||||
|
|
||||||
|
return { bootStorageType, loading };
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import { useHidStore, useRTCStore, useSettingsStore } from "@/hooks/stores";
|
import { useHidStore, useRTCStore, useSettingsStore } from "@/hooks/stores";
|
||||||
@@ -16,6 +16,10 @@ export default function useKeyboard() {
|
|||||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||||
const usbState = useHidStore(state => state.usbState);
|
const usbState = useHidStore(state => state.usbState);
|
||||||
|
|
||||||
|
// Track held keys for keepalive
|
||||||
|
const heldKeysRef = useRef<Set<number>>(new Set());
|
||||||
|
const keepaliveIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
const sendKeyboardEvent = useCallback(
|
const sendKeyboardEvent = useCallback(
|
||||||
(keys: number[], modifiers: number[]) => {
|
(keys: number[], modifiers: number[]) => {
|
||||||
if (!forceHttp && rpcDataChannel?.readyState !== "open") return;
|
if (!forceHttp && rpcDataChannel?.readyState !== "open") return;
|
||||||
@@ -24,6 +28,7 @@ export default function useKeyboard() {
|
|||||||
if (usbState !== "configured") return;
|
if (usbState !== "configured") return;
|
||||||
const accModifier = modifiers.reduce((acc, val) => acc + val, 0);
|
const accModifier = modifiers.reduce((acc, val) => acc + val, 0);
|
||||||
|
|
||||||
|
// Fallback to JSON-RPC
|
||||||
send("keyboardReport", { keys, modifier: accModifier }, resp => {
|
send("keyboardReport", { keys, modifier: accModifier }, resp => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
const msg = (resp.error.data as string) || resp.error.message || "";
|
const msg = (resp.error.data as string) || resp.error.message || "";
|
||||||
@@ -39,10 +44,37 @@ export default function useKeyboard() {
|
|||||||
[forceHttp, rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers, isReinitializingGadget, usbState],
|
[forceHttp, rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers, isReinitializingGadget, usbState],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Send per-key press/release
|
||||||
|
const sendKeypress = useCallback(
|
||||||
|
(key: number, press: boolean) => {
|
||||||
|
if (isReinitializingGadget || usbState !== "configured") return;
|
||||||
|
|
||||||
|
// Legacy: simulate device-side key handling
|
||||||
|
// This maintains the 6-key buffer on the frontend for legacy compatibility
|
||||||
|
// For simplicity in migration, we fall back to full state reports
|
||||||
|
const modifier = press ? 0 : 0; // Simplified - would need proper modifier tracking
|
||||||
|
sendKeyboardEvent(press ? [key] : [], [modifier]);
|
||||||
|
},
|
||||||
|
[isReinitializingGadget, usbState, sendKeyboardEvent]
|
||||||
|
);
|
||||||
|
|
||||||
const resetKeyboardState = useCallback(() => {
|
const resetKeyboardState = useCallback(() => {
|
||||||
|
// Release all held keys
|
||||||
sendKeyboardEvent([], []);
|
sendKeyboardEvent([], []);
|
||||||
|
heldKeysRef.current.clear();
|
||||||
|
if (keepaliveIntervalRef.current) {
|
||||||
|
clearInterval(keepaliveIntervalRef.current);
|
||||||
|
keepaliveIntervalRef.current = null;
|
||||||
|
}
|
||||||
}, [sendKeyboardEvent]);
|
}, [sendKeyboardEvent]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
resetKeyboardState();
|
||||||
|
};
|
||||||
|
}, [resetKeyboardState]);
|
||||||
|
|
||||||
const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => {
|
const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => {
|
||||||
for (const [index, step] of steps.entries()) {
|
for (const [index, step] of steps.entries()) {
|
||||||
const keyValues = step.keys?.map(key => keys[key]).filter(Boolean) || [];
|
const keyValues = step.keys?.map(key => keys[key]).filter(Boolean) || [];
|
||||||
@@ -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"
|
export interface KeyStroke {
|
||||||
import { chars as chars_cs_CZ, name as name_cs_CZ } from "@/keyboardLayouts/cs_CZ"
|
modifier: number;
|
||||||
import { chars as chars_en_UK, name as name_en_UK } from "@/keyboardLayouts/en_UK"
|
keys: number[];
|
||||||
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 const chars: Record<string, Record<string, KeyCombo>> = {
|
export interface KeyInfo {
|
||||||
be_FR: chars_fr_BE,
|
key: string | number;
|
||||||
cs_CZ: chars_cs_CZ,
|
shift?: boolean;
|
||||||
en_UK: chars_en_UK,
|
altRight?: boolean;
|
||||||
en_US: chars_en_US,
|
}
|
||||||
fr_FR: chars_fr_FR,
|
|
||||||
de_DE: chars_de_DE,
|
export interface KeyCombo extends KeyInfo {
|
||||||
it_IT: chars_it_IT,
|
deadKey?: boolean;
|
||||||
nb_NO: chars_nb_NO,
|
accentKey?: KeyInfo;
|
||||||
es_ES: chars_es_ES,
|
}
|
||||||
sv_SE: chars_sv_SE,
|
|
||||||
fr_CH: chars_fr_CH,
|
export interface KeyboardLayout {
|
||||||
de_CH: chars_de_CH,
|
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 keyTrema = { key: "Backslash" } // tréma (umlaut), two dots placed above a vowel
|
||||||
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
|
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
|
||||||
@@ -97,11 +99,11 @@ export const chars = {
|
|||||||
"Ẇ": { key: "KeyW", shift: true, accentKey: keyOverdot },
|
"Ẇ": { key: "KeyW", shift: true, accentKey: keyOverdot },
|
||||||
X: { key: "KeyX", shift: true },
|
X: { key: "KeyX", shift: true },
|
||||||
"Ẋ": { key: "KeyX", shift: true, accentKey: keyOverdot },
|
"Ẋ": { key: "KeyX", shift: true, accentKey: keyOverdot },
|
||||||
Y: { key: "KeyY", shift: true },
|
Y: { key: "KeyZ", shift: true },
|
||||||
"Ý": { key: "KeyY", shift: true, accentKey: keyAcute },
|
"Ý": { key: "KeyZ", shift: true, accentKey: keyAcute },
|
||||||
"Ẏ": { key: "KeyY", shift: true, accentKey: keyOverdot },
|
"Ẏ": { key: "KeyZ", shift: true, accentKey: keyOverdot },
|
||||||
Z: { key: "KeyZ", shift: true },
|
Z: { key: "KeyY", shift: true },
|
||||||
"Ż": { key: "KeyZ", shift: true, accentKey: keyOverdot },
|
"Ż": { key: "KeyY", shift: true, accentKey: keyOverdot },
|
||||||
a: { key: "KeyA" },
|
a: { key: "KeyA" },
|
||||||
"ä": { key: "KeyA", accentKey: keyTrema },
|
"ä": { key: "KeyA", accentKey: keyTrema },
|
||||||
"â": { key: "KeyA", accentKey: keyHat },
|
"â": { key: "KeyA", accentKey: keyHat },
|
||||||
@@ -189,10 +191,10 @@ export const chars = {
|
|||||||
x: { key: "KeyX" },
|
x: { key: "KeyX" },
|
||||||
"#": { key: "KeyX", altRight: true },
|
"#": { key: "KeyX", altRight: true },
|
||||||
"ẋ": { key: "KeyX", accentKey: keyOverdot },
|
"ẋ": { key: "KeyX", accentKey: keyOverdot },
|
||||||
y: { key: "KeyY" },
|
y: { key: "KeyZ" },
|
||||||
"ẏ": { key: "KeyY", accentKey: keyOverdot },
|
"ẏ": { key: "KeyZ", accentKey: keyOverdot },
|
||||||
z: { key: "KeyZ" },
|
z: { key: "KeyY" },
|
||||||
"ż": { key: "KeyZ", accentKey: keyOverdot },
|
"ż": { key: "KeyY", accentKey: keyOverdot },
|
||||||
";": { key: "Backquote" },
|
";": { key: "Backquote" },
|
||||||
"°": { key: "Backquote", shift: true, deadKey: true },
|
"°": { key: "Backquote", shift: true, deadKey: true },
|
||||||
"+": { key: "Digit1" },
|
"+": { key: "Digit1" },
|
||||||
@@ -242,3 +244,20 @@ export const chars = {
|
|||||||
Enter: { key: "Enter" },
|
Enter: { key: "Enter" },
|
||||||
Tab: { key: "Tab" },
|
Tab: { key: "Tab" },
|
||||||
} as Record<string, KeyCombo>;
|
} as Record<string, KeyCombo>;
|
||||||
|
|
||||||
|
const cs_CZ_keyDisplayMap = {
|
||||||
|
...keyDisplayMap,
|
||||||
|
KeyY: "z",
|
||||||
|
KeyZ: "y",
|
||||||
|
"(KeyY)": "Z",
|
||||||
|
"(KeyZ)": "Y",
|
||||||
|
} as Record<string, string>;
|
||||||
|
|
||||||
|
export const cs_CZ: KeyboardLayout = {
|
||||||
|
isoCode,
|
||||||
|
name,
|
||||||
|
chars,
|
||||||
|
keyDisplayMap: cs_CZ_keyDisplayMap,
|
||||||
|
modifierDisplayMap,
|
||||||
|
virtualKeyboard,
|
||||||
|
};
|
||||||
|
|||||||
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 keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
|
||||||
const keyAcute = { key: "Minus", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
|
const keyAcute = { key: "Minus", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
|
||||||
@@ -163,3 +166,20 @@ export const chars = {
|
|||||||
Enter: { key: "Enter" },
|
Enter: { key: "Enter" },
|
||||||
Tab: { key: "Tab" },
|
Tab: { key: "Tab" },
|
||||||
} as Record<string, KeyCombo>;
|
} as Record<string, KeyCombo>;
|
||||||
|
|
||||||
|
export const de_CH_keyDisplayMap = {
|
||||||
|
...keyDisplayMap,
|
||||||
|
KeyY: "z",
|
||||||
|
KeyZ: "y",
|
||||||
|
"(KeyY)": "Z",
|
||||||
|
"(KeyZ)": "Y",
|
||||||
|
} as Record<string, string>;
|
||||||
|
|
||||||
|
export const de_CH: KeyboardLayout = {
|
||||||
|
isoCode,
|
||||||
|
name,
|
||||||
|
chars,
|
||||||
|
keyDisplayMap: de_CH_keyDisplayMap,
|
||||||
|
modifierDisplayMap,
|
||||||
|
virtualKeyboard,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
|
||||||
const keyHat = { key: "Backquote" } // accent circonflexe (accent hat), mark ^ placed above the letter
|
const keyHat = { key: "Backquote" } // accent circonflexe (accent hat), mark ^ placed above the letter
|
||||||
@@ -150,3 +152,20 @@ export const chars = {
|
|||||||
Enter: { key: "Enter" },
|
Enter: { key: "Enter" },
|
||||||
Tab: { key: "Tab" },
|
Tab: { key: "Tab" },
|
||||||
} as Record<string, KeyCombo>;
|
} as Record<string, KeyCombo>;
|
||||||
|
|
||||||
|
const de_DE_keyDisplayMap = {
|
||||||
|
...keyDisplayMap,
|
||||||
|
KeyY: "z",
|
||||||
|
KeyZ: "y",
|
||||||
|
"(KeyY)": "Z",
|
||||||
|
"(KeyZ)": "Y",
|
||||||
|
} as Record<string, string>;
|
||||||
|
|
||||||
|
export const de_DE: KeyboardLayout = {
|
||||||
|
isoCode,
|
||||||
|
name,
|
||||||
|
chars,
|
||||||
|
keyDisplayMap: de_DE_keyDisplayMap,
|
||||||
|
modifierDisplayMap,
|
||||||
|
virtualKeyboard,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 = {
|
export const chars = {
|
||||||
A: { key: "KeyA", shift: true },
|
A: { key: "KeyA", shift: true },
|
||||||
@@ -104,4 +106,13 @@ export const chars = {
|
|||||||
"\n": { key: "Enter" },
|
"\n": { key: "Enter" },
|
||||||
Enter: { key: "Enter" },
|
Enter: { key: "Enter" },
|
||||||
Tab: { key: "Tab" },
|
Tab: { key: "Tab" },
|
||||||
} as Record<string, KeyCombo>
|
} as Record<string, KeyCombo>;
|
||||||
|
|
||||||
|
export const en_UK: KeyboardLayout = {
|
||||||
|
isoCode,
|
||||||
|
name,
|
||||||
|
chars,
|
||||||
|
keyDisplayMap,
|
||||||
|
modifierDisplayMap,
|
||||||
|
virtualKeyboard,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 = {
|
export const chars = {
|
||||||
A: { key: "KeyA", shift: true },
|
A: { key: "KeyA", shift: true },
|
||||||
@@ -89,25 +90,283 @@ export const chars = {
|
|||||||
">": { key: "Period", shift: true },
|
">": { key: "Period", shift: true },
|
||||||
";": { key: "Semicolon" },
|
";": { key: "Semicolon" },
|
||||||
":": { key: "Semicolon", shift: true },
|
":": { key: "Semicolon", shift: true },
|
||||||
|
"¶": { key: "Semicolon", altRight: true }, // pilcrow sign
|
||||||
"[": { key: "BracketLeft" },
|
"[": { key: "BracketLeft" },
|
||||||
"{": { key: "BracketLeft", shift: true },
|
"{": { key: "BracketLeft", shift: true },
|
||||||
|
"«": { key: "BracketLeft", altRight: true }, // double left quote sign
|
||||||
"]": { key: "BracketRight" },
|
"]": { key: "BracketRight" },
|
||||||
"}": { key: "BracketRight", shift: true },
|
"}": { key: "BracketRight", shift: true },
|
||||||
|
"»": { key: "BracketRight", altRight: true }, // double right quote sign
|
||||||
"\\": { key: "Backslash" },
|
"\\": { key: "Backslash" },
|
||||||
"|": { key: "Backslash", shift: true },
|
"|": { key: "Backslash", shift: true },
|
||||||
|
"¬": { key: "Backslash", altRight: true }, // not sign
|
||||||
"`": { key: "Backquote" },
|
"`": { key: "Backquote" },
|
||||||
"~": { key: "Backquote", shift: true },
|
"~": { key: "Backquote", shift: true },
|
||||||
"§": { key: "IntlBackslash" },
|
"§": { key: "IntlBackslash" },
|
||||||
"±": { key: "IntlBackslash", shift: true },
|
"±": { key: "IntlBackslash", shift: true },
|
||||||
" ": { key: "Space", shift: false },
|
" ": { key: "Space" },
|
||||||
"\n": { key: "Enter", shift: false },
|
"\n": { key: "Enter" },
|
||||||
Enter: { key: "Enter", shift: false },
|
Enter: { key: "Enter" },
|
||||||
Tab: { key: "Tab", shift: false },
|
Escape: { key: "Escape" },
|
||||||
PrintScreen: { key: "Prt Sc", shift: false },
|
Tab: { key: "Tab" },
|
||||||
|
PrintScreen: { key: "Prt Sc" },
|
||||||
SystemRequest: { key: "Prt Sc", shift: true },
|
SystemRequest: { key: "Prt Sc", shift: true },
|
||||||
ScrollLock: { key: "ScrollLock", shift: false},
|
ScrollLock: { key: "ScrollLock" },
|
||||||
Pause: { key: "Pause", shift: false },
|
Pause: { key: "Pause" },
|
||||||
Break: { key: "Pause", shift: true },
|
Break: { key: "Pause", shift: true },
|
||||||
Insert: { key: "Insert", shift: false },
|
Insert: { key: "Insert" },
|
||||||
Delete: { key: "Delete", shift: false },
|
Delete: { key: "Delete" },
|
||||||
} as Record<string, KeyCombo>
|
} as Record<string, KeyCombo>;
|
||||||
|
|
||||||
|
export const modifierDisplayMap: Record<string, string> = {
|
||||||
|
ControlLeft: "Left Ctrl",
|
||||||
|
ControlRight: "Right Ctrl",
|
||||||
|
ShiftLeft: "Left Shift",
|
||||||
|
ShiftRight: "Right Shift",
|
||||||
|
AltLeft: "Left Alt",
|
||||||
|
AltRight: "Right Alt",
|
||||||
|
MetaLeft: "Left Meta",
|
||||||
|
MetaRight: "Right Meta",
|
||||||
|
AltGr: "AltGr",
|
||||||
|
} as Record<string, string>;
|
||||||
|
|
||||||
|
export const keyDisplayMap: Record<string, string> = {
|
||||||
|
CtrlAltDelete: "Ctrl + Alt + Delete",
|
||||||
|
AltMetaEscape: "Alt + Meta + Escape",
|
||||||
|
CtrlAltBackspace: "Ctrl + Alt + Backspace",
|
||||||
|
AltGr: "AltGr",
|
||||||
|
AltLeft: "Alt ⌥",
|
||||||
|
AltRight: "⌥ Alt",
|
||||||
|
ArrowDown: "↓",
|
||||||
|
ArrowLeft: "←",
|
||||||
|
ArrowRight: "→",
|
||||||
|
ArrowUp: "↑",
|
||||||
|
Backspace: "Backspace",
|
||||||
|
"(Backspace)": "Backspace",
|
||||||
|
CapsLock: "Caps Lock ⇪",
|
||||||
|
Clear: "Clear",
|
||||||
|
ControlLeft: "Ctrl ⌃",
|
||||||
|
ControlRight: "⌃ Ctrl",
|
||||||
|
Delete: "Delete ⌦",
|
||||||
|
End: "End",
|
||||||
|
Enter: "Enter",
|
||||||
|
Escape: "Esc",
|
||||||
|
Home: "Home",
|
||||||
|
Insert: "Insert",
|
||||||
|
Menu: "Menu",
|
||||||
|
MetaLeft: "Meta ⌘",
|
||||||
|
MetaRight: "⌘ Meta",
|
||||||
|
PageDown: "PgDn",
|
||||||
|
PageUp: "PgUp",
|
||||||
|
ShiftLeft: "Shift ⇧",
|
||||||
|
ShiftRight: "⇧ Shift",
|
||||||
|
Space: " ",
|
||||||
|
Tab: "Tab ⇥",
|
||||||
|
|
||||||
|
// Letters
|
||||||
|
KeyA: "a",
|
||||||
|
KeyB: "b",
|
||||||
|
KeyC: "c",
|
||||||
|
KeyD: "d",
|
||||||
|
KeyE: "e",
|
||||||
|
KeyF: "f",
|
||||||
|
KeyG: "g",
|
||||||
|
KeyH: "h",
|
||||||
|
KeyI: "i",
|
||||||
|
KeyJ: "j",
|
||||||
|
KeyK: "k",
|
||||||
|
KeyL: "l",
|
||||||
|
KeyM: "m",
|
||||||
|
KeyN: "n",
|
||||||
|
KeyO: "o",
|
||||||
|
KeyP: "p",
|
||||||
|
KeyQ: "q",
|
||||||
|
KeyR: "r",
|
||||||
|
KeyS: "s",
|
||||||
|
KeyT: "t",
|
||||||
|
KeyU: "u",
|
||||||
|
KeyV: "v",
|
||||||
|
KeyW: "w",
|
||||||
|
KeyX: "x",
|
||||||
|
KeyY: "y",
|
||||||
|
KeyZ: "z",
|
||||||
|
|
||||||
|
// Capital letters
|
||||||
|
"(KeyA)": "A",
|
||||||
|
"(KeyB)": "B",
|
||||||
|
"(KeyC)": "C",
|
||||||
|
"(KeyD)": "D",
|
||||||
|
"(KeyE)": "E",
|
||||||
|
"(KeyF)": "F",
|
||||||
|
"(KeyG)": "G",
|
||||||
|
"(KeyH)": "H",
|
||||||
|
"(KeyI)": "I",
|
||||||
|
"(KeyJ)": "J",
|
||||||
|
"(KeyK)": "K",
|
||||||
|
"(KeyL)": "L",
|
||||||
|
"(KeyM)": "M",
|
||||||
|
"(KeyN)": "N",
|
||||||
|
"(KeyO)": "O",
|
||||||
|
"(KeyP)": "P",
|
||||||
|
"(KeyQ)": "Q",
|
||||||
|
"(KeyR)": "R",
|
||||||
|
"(KeyS)": "S",
|
||||||
|
"(KeyT)": "T",
|
||||||
|
"(KeyU)": "U",
|
||||||
|
"(KeyV)": "V",
|
||||||
|
"(KeyW)": "W",
|
||||||
|
"(KeyX)": "X",
|
||||||
|
"(KeyY)": "Y",
|
||||||
|
"(KeyZ)": "Z",
|
||||||
|
|
||||||
|
// Numbers
|
||||||
|
Digit1: "1",
|
||||||
|
Digit2: "2",
|
||||||
|
Digit3: "3",
|
||||||
|
Digit4: "4",
|
||||||
|
Digit5: "5",
|
||||||
|
Digit6: "6",
|
||||||
|
Digit7: "7",
|
||||||
|
Digit8: "8",
|
||||||
|
Digit9: "9",
|
||||||
|
Digit0: "0",
|
||||||
|
|
||||||
|
// Shifted Numbers
|
||||||
|
"(Digit1)": "!",
|
||||||
|
"(Digit2)": "@",
|
||||||
|
"(Digit3)": "#",
|
||||||
|
"(Digit4)": "$",
|
||||||
|
"(Digit5)": "%",
|
||||||
|
"(Digit6)": "^",
|
||||||
|
"(Digit7)": "&",
|
||||||
|
"(Digit8)": "*",
|
||||||
|
"(Digit9)": "(",
|
||||||
|
"(Digit0)": ")",
|
||||||
|
|
||||||
|
// Symbols
|
||||||
|
Minus: "-",
|
||||||
|
"(Minus)": "_",
|
||||||
|
Equal: "=",
|
||||||
|
"(Equal)": "+",
|
||||||
|
BracketLeft: "[",
|
||||||
|
"(BracketLeft)": "{",
|
||||||
|
BracketRight: "]",
|
||||||
|
"(BracketRight)": "}",
|
||||||
|
Backslash: "\\",
|
||||||
|
"(Backslash)": "|",
|
||||||
|
Semicolon: ";",
|
||||||
|
"(Semicolon)": ":",
|
||||||
|
Quote: "'",
|
||||||
|
"(Quote)": '"',
|
||||||
|
Comma: ",",
|
||||||
|
"(Comma)": "<",
|
||||||
|
Period: ".",
|
||||||
|
"(Period)": ">",
|
||||||
|
Slash: "/",
|
||||||
|
"(Slash)": "?",
|
||||||
|
Backquote: "`",
|
||||||
|
"(Backquote)": "~",
|
||||||
|
IntlBackslash: "\\",
|
||||||
|
|
||||||
|
// Function keys
|
||||||
|
F1: "F1",
|
||||||
|
F2: "F2",
|
||||||
|
F3: "F3",
|
||||||
|
F4: "F4",
|
||||||
|
F5: "F5",
|
||||||
|
F6: "F6",
|
||||||
|
F7: "F7",
|
||||||
|
F8: "F8",
|
||||||
|
F9: "F9",
|
||||||
|
F10: "F10",
|
||||||
|
F11: "F11",
|
||||||
|
F12: "F12",
|
||||||
|
|
||||||
|
// Numpad
|
||||||
|
Numpad0: "Num 0",
|
||||||
|
Numpad1: "Num 1",
|
||||||
|
Numpad2: "Num 2",
|
||||||
|
Numpad3: "Num 3",
|
||||||
|
Numpad4: "Num 4",
|
||||||
|
Numpad5: "Num 5",
|
||||||
|
Numpad6: "Num 6",
|
||||||
|
Numpad7: "Num 7",
|
||||||
|
Numpad8: "Num 8",
|
||||||
|
Numpad9: "Num 9",
|
||||||
|
NumpadAdd: "Num +",
|
||||||
|
NumpadSubtract: "Num -",
|
||||||
|
NumpadMultiply: "Num *",
|
||||||
|
NumpadDivide: "Num /",
|
||||||
|
NumpadDecimal: "Num .",
|
||||||
|
NumpadEqual: "Num =",
|
||||||
|
NumpadEnter: "Num Enter",
|
||||||
|
NumpadInsert: "Ins",
|
||||||
|
NumpadDelete: "Del",
|
||||||
|
NumLock: "Num Lock",
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
PrintScreen: "Prt Sc",
|
||||||
|
ScrollLock: "Scr Lk",
|
||||||
|
Pause: "Pause",
|
||||||
|
"(PrintScreen)": "Sys Rq",
|
||||||
|
"(Pause)": "Break",
|
||||||
|
SystemRequest: "Sys Rq",
|
||||||
|
Break: "Break",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const virtualKeyboard = {
|
||||||
|
main: {
|
||||||
|
default: [
|
||||||
|
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
|
||||||
|
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
||||||
|
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
|
||||||
|
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
|
||||||
|
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter",
|
||||||
|
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
|
||||||
|
"ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight",
|
||||||
|
],
|
||||||
|
shift: [
|
||||||
|
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
|
||||||
|
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
||||||
|
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
|
||||||
|
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
|
||||||
|
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
|
||||||
|
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
|
||||||
|
"ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
control: {
|
||||||
|
default: ["PrintScreen ScrollLock Pause", "Insert Home PageUp", "Delete End PageDown"],
|
||||||
|
shift: ["(PrintScreen) ScrollLock (Pause)", "Insert Home PageUp", "Delete End PageDown"],
|
||||||
|
},
|
||||||
|
|
||||||
|
arrows: {
|
||||||
|
default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
|
||||||
|
},
|
||||||
|
|
||||||
|
numpad: {
|
||||||
|
numlocked: [
|
||||||
|
"NumLock NumpadDivide NumpadMultiply NumpadSubtract",
|
||||||
|
"Numpad7 Numpad8 Numpad9 NumpadAdd",
|
||||||
|
"Numpad4 Numpad5 Numpad6",
|
||||||
|
"Numpad1 Numpad2 Numpad3 NumpadEnter",
|
||||||
|
"Numpad0 NumpadDecimal",
|
||||||
|
],
|
||||||
|
default: [
|
||||||
|
"NumLock NumpadDivide NumpadMultiply NumpadSubtract",
|
||||||
|
"Home ArrowUp PageUp NumpadAdd",
|
||||||
|
"ArrowLeft Clear ArrowRight",
|
||||||
|
"End ArrowDown PageDown NumpadEnter",
|
||||||
|
"NumpadInsert NumpadDelete",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const en_US: KeyboardLayout = {
|
||||||
|
isoCode,
|
||||||
|
name,
|
||||||
|
chars,
|
||||||
|
keyDisplayMap,
|
||||||
|
modifierDisplayMap,
|
||||||
|
virtualKeyboard,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 keyTrema = { key: "Quote", shift: true } // tréma (umlaut), two dots placed above a vowel
|
||||||
const keyAcute = { key: "Quote" } // accent aigu (acute accent), mark ´ placed above the letter
|
const keyAcute = { key: "Quote" } // accent aigu (acute accent), mark ´ placed above the letter
|
||||||
@@ -166,3 +168,12 @@ export const chars = {
|
|||||||
Enter: { key: "Enter" },
|
Enter: { key: "Enter" },
|
||||||
Tab: { key: "Tab" },
|
Tab: { key: "Tab" },
|
||||||
} as Record<string, KeyCombo>;
|
} as Record<string, KeyCombo>;
|
||||||
|
|
||||||
|
export const es_ES: KeyboardLayout = {
|
||||||
|
isoCode,
|
||||||
|
name,
|
||||||
|
chars,
|
||||||
|
keyDisplayMap,
|
||||||
|
modifierDisplayMap,
|
||||||
|
virtualKeyboard,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
|
||||||
const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
|
const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
|
||||||
@@ -56,10 +58,10 @@ export const chars = {
|
|||||||
"Ù": { key: "KeyU", shift: true, accentKey: keyGrave },
|
"Ù": { key: "KeyU", shift: true, accentKey: keyGrave },
|
||||||
"Ũ": { key: "KeyU", shift: true, accentKey: keyTilde },
|
"Ũ": { key: "KeyU", shift: true, accentKey: keyTilde },
|
||||||
V: { key: "KeyV", shift: true },
|
V: { key: "KeyV", shift: true },
|
||||||
W: { key: "KeyW", shift: true },
|
W: { key: "KeyZ", shift: true },
|
||||||
X: { key: "KeyX", shift: true },
|
X: { key: "KeyX", shift: true },
|
||||||
Y: { key: "KeyZ", shift: true },
|
Y: { key: "KeyY", shift: true },
|
||||||
Z: { key: "KeyY", shift: true },
|
Z: { key: "KeyW", shift: true },
|
||||||
a: { key: "KeyQ" },
|
a: { key: "KeyQ" },
|
||||||
"ä": { key: "KeyQ", accentKey: keyTrema },
|
"ä": { key: "KeyQ", accentKey: keyTrema },
|
||||||
"â": { key: "KeyQ", accentKey: keyHat },
|
"â": { key: "KeyQ", accentKey: keyHat },
|
||||||
@@ -104,10 +106,10 @@ export const chars = {
|
|||||||
"ú": { key: "KeyU", accentKey: keyAcute },
|
"ú": { key: "KeyU", accentKey: keyAcute },
|
||||||
"ũ": { key: "KeyU", accentKey: keyTilde },
|
"ũ": { key: "KeyU", accentKey: keyTilde },
|
||||||
v: { key: "KeyV" },
|
v: { key: "KeyV" },
|
||||||
w: { key: "KeyW" },
|
w: { key: "KeyZ" },
|
||||||
x: { key: "KeyX" },
|
x: { key: "KeyX" },
|
||||||
y: { key: "KeyZ" },
|
y: { key: "KeyY" },
|
||||||
z: { key: "KeyY" },
|
z: { key: "KeyW" },
|
||||||
"²": { key: "Backquote" },
|
"²": { key: "Backquote" },
|
||||||
"³": { key: "Backquote", shift: true },
|
"³": { key: "Backquote", shift: true },
|
||||||
"&": { key: "Digit1" },
|
"&": { key: "Digit1" },
|
||||||
@@ -165,3 +167,28 @@ export const chars = {
|
|||||||
Enter: { key: "Enter" },
|
Enter: { key: "Enter" },
|
||||||
Tab: { key: "Tab" },
|
Tab: { key: "Tab" },
|
||||||
} as Record<string, KeyCombo>;
|
} as Record<string, KeyCombo>;
|
||||||
|
|
||||||
|
const fr_BE_keyDisplayMap = {
|
||||||
|
...keyDisplayMap,
|
||||||
|
KeyA: "q",
|
||||||
|
KeyQ: "a",
|
||||||
|
KeyW: "z",
|
||||||
|
KeyZ: "w",
|
||||||
|
Semicolon: "m",
|
||||||
|
KeyM: ",",
|
||||||
|
"(KeyA)": "Q",
|
||||||
|
"(KeyQ)": "A",
|
||||||
|
"(KeyW)": "Z",
|
||||||
|
"(KeyZ)": "W",
|
||||||
|
"(Semicolon)": "M",
|
||||||
|
"(KeyM)": "?",
|
||||||
|
} as Record<string, string>;
|
||||||
|
|
||||||
|
export const fr_BE: KeyboardLayout = {
|
||||||
|
isoCode,
|
||||||
|
name,
|
||||||
|
chars,
|
||||||
|
keyDisplayMap: fr_BE_keyDisplayMap,
|
||||||
|
modifierDisplayMap,
|
||||||
|
virtualKeyboard,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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"
|
const name = "Français de Suisse";
|
||||||
|
const isoCode = "fr-CH";
|
||||||
export const name = "Français de Suisse";
|
|
||||||
|
|
||||||
export const chars = {
|
export const chars = {
|
||||||
...chars_de_CH,
|
...chars_de_CH,
|
||||||
@@ -13,3 +14,22 @@ export const chars = {
|
|||||||
"à": { key: "Quote" },
|
"à": { key: "Quote" },
|
||||||
"ä": { key: "Quote", shift: true },
|
"ä": { key: "Quote", shift: true },
|
||||||
} as Record<string, KeyCombo>;
|
} as Record<string, KeyCombo>;
|
||||||
|
|
||||||
|
const fr_CH_keyDisplayMap = {
|
||||||
|
...de_CH_keyDisplayMap,
|
||||||
|
BracketLeft: "è",
|
||||||
|
"(BracketLeft)": "ü",
|
||||||
|
Semicolon: "é",
|
||||||
|
"(Semicolon)": "ö",
|
||||||
|
Quote: "à",
|
||||||
|
"(Quote)": "ä",
|
||||||
|
} as Record<string, string>;
|
||||||
|
|
||||||
|
export const fr_CH: KeyboardLayout = {
|
||||||
|
isoCode,
|
||||||
|
name,
|
||||||
|
chars,
|
||||||
|
keyDisplayMap: fr_CH_keyDisplayMap,
|
||||||
|
modifierDisplayMap,
|
||||||
|
virtualKeyboard,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
|
||||||
const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
|
const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
|
||||||
@@ -137,3 +139,28 @@ export const chars = {
|
|||||||
Enter: { key: "Enter" },
|
Enter: { key: "Enter" },
|
||||||
Tab: { key: "Tab" },
|
Tab: { key: "Tab" },
|
||||||
} as Record<string, KeyCombo>;
|
} as Record<string, KeyCombo>;
|
||||||
|
|
||||||
|
const fr_FR_keyDisplayMap = {
|
||||||
|
...keyDisplayMap,
|
||||||
|
KeyA: "q",
|
||||||
|
KeyQ: "a",
|
||||||
|
KeyW: "z",
|
||||||
|
KeyZ: "w",
|
||||||
|
Semicolon: "m",
|
||||||
|
KeyM: ",",
|
||||||
|
"(KeyA)": "Q",
|
||||||
|
"(KeyQ)": "A",
|
||||||
|
"(KeyW)": "Z",
|
||||||
|
"(KeyZ)": "W",
|
||||||
|
"(Semicolon)": "M",
|
||||||
|
"(KeyM)": "?",
|
||||||
|
} as Record<string, string>;
|
||||||
|
|
||||||
|
export const fr_FR: KeyboardLayout = {
|
||||||
|
isoCode,
|
||||||
|
name,
|
||||||
|
chars,
|
||||||
|
keyDisplayMap: fr_FR_keyDisplayMap,
|
||||||
|
modifierDisplayMap,
|
||||||
|
virtualKeyboard,
|
||||||
|
};
|
||||||
|
|||||||
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 = {
|
export const chars = {
|
||||||
A: { key: "KeyA", shift: true },
|
A: { key: "KeyA", shift: true },
|
||||||
@@ -111,3 +113,12 @@ export const chars = {
|
|||||||
Enter: { key: "Enter" },
|
Enter: { key: "Enter" },
|
||||||
Tab: { key: "Tab" },
|
Tab: { key: "Tab" },
|
||||||
} as Record<string, KeyCombo>;
|
} as Record<string, KeyCombo>;
|
||||||
|
|
||||||
|
export const it_IT: KeyboardLayout = {
|
||||||
|
isoCode,
|
||||||
|
name,
|
||||||
|
chars,
|
||||||
|
keyDisplayMap,
|
||||||
|
modifierDisplayMap,
|
||||||
|
virtualKeyboard,
|
||||||
|
};
|
||||||
|
|||||||
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 keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
|
||||||
const keyAcute = { key: "Equal", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
|
const keyAcute = { key: "Equal", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
|
||||||
@@ -58,8 +60,8 @@ export const chars = {
|
|||||||
V: { key: "KeyV", shift: true },
|
V: { key: "KeyV", shift: true },
|
||||||
W: { key: "KeyW", shift: true },
|
W: { key: "KeyW", shift: true },
|
||||||
X: { key: "KeyX", shift: true },
|
X: { key: "KeyX", shift: true },
|
||||||
Y: { key: "KeyZ", shift: true },
|
Y: { key: "KeyY", shift: true },
|
||||||
Z: { key: "KeyY", shift: true },
|
Z: { key: "KeyZ", shift: true },
|
||||||
a: { key: "KeyA" },
|
a: { key: "KeyA" },
|
||||||
"ä": { key: "KeyA", accentKey: keyTrema },
|
"ä": { key: "KeyA", accentKey: keyTrema },
|
||||||
"á": { key: "KeyA", accentKey: keyAcute },
|
"á": { key: "KeyA", accentKey: keyAcute },
|
||||||
@@ -110,8 +112,8 @@ export const chars = {
|
|||||||
v: { key: "KeyV" },
|
v: { key: "KeyV" },
|
||||||
w: { key: "KeyW" },
|
w: { key: "KeyW" },
|
||||||
x: { key: "KeyX" },
|
x: { key: "KeyX" },
|
||||||
y: { key: "KeyZ" },
|
y: { key: "KeyY" },
|
||||||
z: { key: "KeyY" },
|
z: { key: "KeyZ" },
|
||||||
"|": { key: "Backquote" },
|
"|": { key: "Backquote" },
|
||||||
"§": { key: "Backquote", shift: true },
|
"§": { key: "Backquote", shift: true },
|
||||||
1: { key: "Digit1" },
|
1: { key: "Digit1" },
|
||||||
@@ -165,3 +167,12 @@ export const chars = {
|
|||||||
Enter: { key: "Enter" },
|
Enter: { key: "Enter" },
|
||||||
Tab: { key: "Tab" },
|
Tab: { key: "Tab" },
|
||||||
} as Record<string, KeyCombo>;
|
} as Record<string, KeyCombo>;
|
||||||
|
|
||||||
|
export const nb_NO: KeyboardLayout = {
|
||||||
|
isoCode,
|
||||||
|
name,
|
||||||
|
chars,
|
||||||
|
keyDisplayMap,
|
||||||
|
modifierDisplayMap,
|
||||||
|
virtualKeyboard,
|
||||||
|
};
|
||||||
|
|||||||
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 keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
|
||||||
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
|
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
|
||||||
@@ -162,3 +164,12 @@ export const chars = {
|
|||||||
Enter: { key: "Enter" },
|
Enter: { key: "Enter" },
|
||||||
Tab: { key: "Tab" },
|
Tab: { key: "Tab" },
|
||||||
} as Record<string, KeyCombo>;
|
} as Record<string, KeyCombo>;
|
||||||
|
|
||||||
|
export const sv_SE: KeyboardLayout = {
|
||||||
|
isoCode,
|
||||||
|
name,
|
||||||
|
chars,
|
||||||
|
keyDisplayMap,
|
||||||
|
modifierDisplayMap,
|
||||||
|
virtualKeyboard,
|
||||||
|
};
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ export const keys = {
|
|||||||
Space: 0x2c,
|
Space: 0x2c,
|
||||||
SystemRequest: 0x9a,
|
SystemRequest: 0x9a,
|
||||||
Tab: 0x2b,
|
Tab: 0x2b,
|
||||||
|
Yen: 0x89,
|
||||||
|
KeyRO: 0x87,
|
||||||
|
Henkan: 0x8a,
|
||||||
|
Muhenkan: 0x8b,
|
||||||
|
KatakanaHiragana: 0x88,
|
||||||
} as Record<string, number>;
|
} as Record<string, number>;
|
||||||
|
|
||||||
export const modifiers = {
|
export const modifiers = {
|
||||||
@@ -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> = {
|
export const keyDisplayMap2: Record<string, string> = {
|
||||||
...keyDisplayMap,
|
...keyDisplayMap,
|
||||||
...{
|
...{
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { CloseOutlined } from '@ant-design/icons';
|
|||||||
import { useReactAt } from "i18n-auto-extractor/react";
|
import { useReactAt } from "i18n-auto-extractor/react";
|
||||||
|
|
||||||
import ScrollThrottlingSelect, { Option } from "@components/ScrollThrottlingSelect";
|
import ScrollThrottlingSelect, { Option } from "@components/ScrollThrottlingSelect";
|
||||||
import { layouts } from "@/keyboardLayouts";
|
import { layouts, keyboards } from "@/keyboardLayouts";
|
||||||
import { KeyboardLedSync, useSettingsStore } from "@/hooks/stores";
|
import { KeyboardLedSync, useSettingsStore } from "@/hooks/stores";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
@@ -24,11 +24,21 @@ const KeyboardPanel: React.FC = () => {
|
|||||||
|
|
||||||
const [layoutOptions, setLayoutOptions] = useState<Option[]>();
|
const [layoutOptions, setLayoutOptions] = useState<Option[]>();
|
||||||
const [maxShowCount, setMaxShowCount] = useState(3);
|
const [maxShowCount, setMaxShowCount] = useState(3);
|
||||||
|
|
||||||
|
const layoutAbbrevMap = useMemo(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
keyboards.forEach(kb => {
|
||||||
|
const oldCode = kb.isoCode.replace("-", "_");
|
||||||
|
map[oldCode] = oldCode;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const curLayoutOptions = (() => {
|
const curLayoutOptions = (() => {
|
||||||
const options = Object.entries(layouts).map(([code, language]) => ({
|
const options = Object.entries(layouts).map(([code, language]) => ({
|
||||||
value: code,
|
value: code,
|
||||||
label: language,
|
label: `${language} (${layoutAbbrevMap[code] || code})`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const currentLayout = keyboardLayout ?? "";
|
const currentLayout = keyboardLayout ?? "";
|
||||||
@@ -47,7 +57,7 @@ const KeyboardPanel: React.FC = () => {
|
|||||||
return options;
|
return options;
|
||||||
})();
|
})();
|
||||||
setLayoutOptions(curLayoutOptions);
|
setLayoutOptions(curLayoutOptions);
|
||||||
}, [layouts, keyboardLayout]);
|
}, [layouts, keyboardLayout, layoutAbbrevMap]);
|
||||||
|
|
||||||
const safeKeyboardLayout = useMemo(() => {
|
const safeKeyboardLayout = useMemo(() => {
|
||||||
if (keyboardLayout && keyboardLayout.length > 0)
|
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;
|
||||||
@@ -22,6 +22,8 @@ import { useVpnStore, useLocalAuthModalStore } from "@/hooks/stores";
|
|||||||
import { LogDialog } from "@components/LogDialog";
|
import { LogDialog } from "@components/LogDialog";
|
||||||
import { Dialog } from "@/layout/components_setting/access/auth";
|
import { Dialog } from "@/layout/components_setting/access/auth";
|
||||||
import AutoHeight from "@components/AutoHeight";
|
import AutoHeight from "@components/AutoHeight";
|
||||||
|
import FirewallSettings from "./FirewallSettings";
|
||||||
|
import WebRtcServersSettings from "./WebRtcServers";
|
||||||
|
|
||||||
export interface TailScaleResponse {
|
export interface TailScaleResponse {
|
||||||
state: string;
|
state: string;
|
||||||
@@ -69,6 +71,47 @@ export interface CloudflaredRunningResponse {
|
|||||||
running: boolean;
|
running: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ManagedVpnTool = "frpc" | "easytier" | "vnt" | "cloudflared";
|
||||||
|
|
||||||
|
export interface VpnToolSystemInfo {
|
||||||
|
goos: string;
|
||||||
|
goarch: string;
|
||||||
|
uname_arch: string;
|
||||||
|
arch_label: string;
|
||||||
|
arch_keywords: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VpnToolStatus {
|
||||||
|
tool: string;
|
||||||
|
installed: boolean;
|
||||||
|
source: string;
|
||||||
|
current_version: string;
|
||||||
|
detected_version: string;
|
||||||
|
managed_versions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VpnToolReleaseAsset {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
arch_match: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VpnToolRelease {
|
||||||
|
tag_name: string;
|
||||||
|
assets: VpnToolReleaseAsset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VpnToolInstallTask {
|
||||||
|
tool: string;
|
||||||
|
running: boolean;
|
||||||
|
progress: number;
|
||||||
|
message: string;
|
||||||
|
logs: string[];
|
||||||
|
error: string;
|
||||||
|
version: string;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WireguardStatus {
|
export interface WireguardStatus {
|
||||||
running: boolean;
|
running: boolean;
|
||||||
}
|
}
|
||||||
@@ -196,6 +239,20 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
|||||||
const [cloudflaredLog, setCloudflaredLog] = useState<string>("");
|
const [cloudflaredLog, setCloudflaredLog] = useState<string>("");
|
||||||
const [showCloudflaredLogModal, setShowCloudflaredLogModal] = useState(false);
|
const [showCloudflaredLogModal, setShowCloudflaredLogModal] = useState(false);
|
||||||
|
|
||||||
|
const [vpnToolSystemInfo, setVpnToolSystemInfo] = useState<VpnToolSystemInfo | null>(null);
|
||||||
|
const [vpnToolStatusMap, setVpnToolStatusMap] = useState<Record<string, VpnToolStatus>>({});
|
||||||
|
const [vpnToolReleasesMap, setVpnToolReleasesMap] = useState<Record<string, VpnToolRelease[]>>({});
|
||||||
|
const [vpnToolSelectedVersionMap, setVpnToolSelectedVersionMap] = useState<Record<string, string>>({});
|
||||||
|
const [vpnToolSelectedAssetMap, setVpnToolSelectedAssetMap] = useState<Record<string, string>>({});
|
||||||
|
const [vpnToolBusyMap, setVpnToolBusyMap] = useState<Record<string, boolean>>({});
|
||||||
|
const [vpnToolInstallTaskMap, setVpnToolInstallTaskMap] = useState<Record<string, VpnToolInstallTask>>({});
|
||||||
|
const [vpnToolInstallPanelOpenMap, setVpnToolInstallPanelOpenMap] = useState<Record<string, boolean>>({
|
||||||
|
frpc: false,
|
||||||
|
easytier: false,
|
||||||
|
vnt: false,
|
||||||
|
cloudflared: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
const getTLSState = useCallback(() => {
|
const getTLSState = useCallback(() => {
|
||||||
send("getTLSState", {}, resp => {
|
send("getTLSState", {}, resp => {
|
||||||
@@ -280,10 +337,156 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
|||||||
});
|
});
|
||||||
}, [send]);
|
}, [send]);
|
||||||
|
|
||||||
|
const managedTools: ManagedVpnTool[] = ["frpc", "easytier", "vnt", "cloudflared"];
|
||||||
|
|
||||||
|
const getVpnToolSystemInfo = useCallback(() => {
|
||||||
|
send("getVpnToolSystemInfo", {}, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(`Failed to get system architecture info: ${resp.error.data || "Unknown error"}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVpnToolSystemInfo(resp.result as VpnToolSystemInfo);
|
||||||
|
});
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
|
const getVpnToolStatus = useCallback((tool: ManagedVpnTool) => {
|
||||||
|
send("getVpnToolStatus", { tool }, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(`Failed to get ${tool} status: ${resp.error.data || "Unknown error"}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVpnToolStatusMap(prev => ({ ...prev, [tool]: resp.result as VpnToolStatus }));
|
||||||
|
});
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
|
const listVpnToolReleases = useCallback((tool: ManagedVpnTool) => {
|
||||||
|
send("listVpnToolReleases", { tool }, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(`Failed to list ${tool} releases: ${resp.error.data || "Unknown error"}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const releases = resp.result as VpnToolRelease[];
|
||||||
|
setVpnToolReleasesMap(prev => ({ ...prev, [tool]: releases }));
|
||||||
|
if (!releases.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVpnToolSelectedVersionMap(prev => {
|
||||||
|
if (prev[tool]) return prev;
|
||||||
|
return { ...prev, [tool]: releases[0].tag_name };
|
||||||
|
});
|
||||||
|
setVpnToolSelectedAssetMap(prev => {
|
||||||
|
if (prev[tool]) return prev;
|
||||||
|
const firstRelease = releases[0];
|
||||||
|
const preferred = firstRelease.assets.find(asset => asset.arch_match) || firstRelease.assets[0];
|
||||||
|
return preferred ? { ...prev, [tool]: preferred.url } : prev;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
|
const refreshVpnToolManager = useCallback((tool: ManagedVpnTool, withReleases: boolean) => {
|
||||||
|
getVpnToolStatus(tool);
|
||||||
|
if (withReleases) {
|
||||||
|
listVpnToolReleases(tool);
|
||||||
|
}
|
||||||
|
}, [getVpnToolStatus, listVpnToolReleases]);
|
||||||
|
|
||||||
|
const getVpnToolInstallTask = useCallback((tool: ManagedVpnTool) => {
|
||||||
|
send("getVpnToolInstallTask", { tool }, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVpnToolInstallTaskMap(prev => ({ ...prev, [tool]: resp.result as VpnToolInstallTask }));
|
||||||
|
});
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
|
const handleVpnToolVersionChange = useCallback((tool: ManagedVpnTool, version: string) => {
|
||||||
|
setVpnToolSelectedVersionMap(prev => ({ ...prev, [tool]: version }));
|
||||||
|
const releases = vpnToolReleasesMap[tool] || [];
|
||||||
|
const selectedRelease = releases.find(release => release.tag_name === version);
|
||||||
|
if (!selectedRelease || !selectedRelease.assets.length) {
|
||||||
|
setVpnToolSelectedAssetMap(prev => ({ ...prev, [tool]: "" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const preferred = selectedRelease.assets.find(asset => asset.arch_match) || selectedRelease.assets[0];
|
||||||
|
setVpnToolSelectedAssetMap(prev => ({ ...prev, [tool]: preferred?.url || "" }));
|
||||||
|
}, [vpnToolReleasesMap]);
|
||||||
|
|
||||||
|
const handleInstallVpnTool = useCallback((tool: ManagedVpnTool) => {
|
||||||
|
const version = vpnToolSelectedVersionMap[tool];
|
||||||
|
const downloadURL = vpnToolSelectedAssetMap[tool];
|
||||||
|
const releases = vpnToolReleasesMap[tool] || [];
|
||||||
|
const release = releases.find(item => item.tag_name === version);
|
||||||
|
const selectedAsset = release?.assets.find(asset => asset.url === downloadURL);
|
||||||
|
if (!version || !downloadURL || !selectedAsset) {
|
||||||
|
notifications.error(`Please select version and release asset for ${tool}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVpnToolBusyMap(prev => ({ ...prev, [tool]: true }));
|
||||||
|
send("startVpnToolInstall", { tool, version, assetName: selectedAsset.name, downloadURL }, resp => {
|
||||||
|
setVpnToolBusyMap(prev => ({ ...prev, [tool]: false }));
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(`Failed to install ${tool}: ${resp.error.data || "Unknown error"}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifications.success(`${tool} install task started (${version})`);
|
||||||
|
getVpnToolInstallTask(tool);
|
||||||
|
});
|
||||||
|
}, [send, vpnToolSelectedVersionMap, vpnToolSelectedAssetMap, vpnToolReleasesMap, getVpnToolInstallTask]);
|
||||||
|
|
||||||
|
const handleUninstallVpnToolVersion = useCallback((tool: ManagedVpnTool, version: string) => {
|
||||||
|
if (!version) {
|
||||||
|
notifications.error("Please select installed version");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVpnToolBusyMap(prev => ({ ...prev, [tool]: true }));
|
||||||
|
send("uninstallVpnToolVersion", { tool, version }, resp => {
|
||||||
|
setVpnToolBusyMap(prev => ({ ...prev, [tool]: false }));
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(`Failed to uninstall ${tool} version ${version}: ${resp.error.data || "Unknown error"}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifications.success(`${tool} ${version} uninstalled`);
|
||||||
|
refreshVpnToolManager(tool, true);
|
||||||
|
});
|
||||||
|
}, [send, refreshVpnToolManager]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getCloudflaredStatus();
|
getCloudflaredStatus();
|
||||||
}, [getCloudflaredStatus]);
|
}, [getCloudflaredStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getVpnToolSystemInfo();
|
||||||
|
managedTools.forEach(tool => {
|
||||||
|
refreshVpnToolManager(tool, false);
|
||||||
|
getVpnToolInstallTask(tool);
|
||||||
|
});
|
||||||
|
}, [getVpnToolSystemInfo, refreshVpnToolManager, getVpnToolInstallTask]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
managedTools.forEach(tool => {
|
||||||
|
getVpnToolInstallTask(tool);
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [getVpnToolInstallTask]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tabToolMap: Partial<Record<string, ManagedVpnTool>> = {
|
||||||
|
frp: "frpc",
|
||||||
|
easytier: "easytier",
|
||||||
|
vnt: "vnt",
|
||||||
|
cloudflared: "cloudflared",
|
||||||
|
};
|
||||||
|
const tool = tabToolMap[activeTab];
|
||||||
|
if (tool) {
|
||||||
|
refreshVpnToolManager(tool, false);
|
||||||
|
}
|
||||||
|
if (activeTab === "cloudflared") {
|
||||||
|
getCloudflaredStatus();
|
||||||
|
}
|
||||||
|
}, [activeTab, refreshVpnToolManager, getCloudflaredStatus]);
|
||||||
|
|
||||||
// Handle TLS mode change
|
// Handle TLS mode change
|
||||||
const handleTlsModeChange = (value: string) => {
|
const handleTlsModeChange = (value: string) => {
|
||||||
setTlsMode(value);
|
setTlsMode(value);
|
||||||
@@ -868,6 +1071,154 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
|||||||
getVntConfigFile();
|
getVntConfigFile();
|
||||||
}, [getVntStatus, getVntConfig]);
|
}, [getVntStatus, getVntConfig]);
|
||||||
|
|
||||||
|
const renderVpnToolManager = (tool: ManagedVpnTool, label: string) => {
|
||||||
|
const status = vpnToolStatusMap[tool];
|
||||||
|
const releases = vpnToolReleasesMap[tool] || [];
|
||||||
|
const selectedVersion = vpnToolSelectedVersionMap[tool] || "";
|
||||||
|
const selectedRelease = releases.find(release => release.tag_name === selectedVersion);
|
||||||
|
const selectedAsset = vpnToolSelectedAssetMap[tool] || "";
|
||||||
|
const assets = selectedRelease?.assets || [];
|
||||||
|
const busy = vpnToolBusyMap[tool] || false;
|
||||||
|
const installTask = vpnToolInstallTaskMap[tool];
|
||||||
|
const installRunning = installTask?.running === true;
|
||||||
|
const installPanelOpen = vpnToolInstallPanelOpenMap[tool] || false;
|
||||||
|
const isInstalled = status?.installed === true;
|
||||||
|
const uninstallVersion = status?.current_version || status?.managed_versions?.[0] || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-700">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{label} {$at("Version Manager")}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<Button
|
||||||
|
size="SM"
|
||||||
|
theme="light"
|
||||||
|
text={$at("Refresh")}
|
||||||
|
onClick={() => refreshVpnToolManager(tool, installPanelOpen)}
|
||||||
|
disabled={busy || installRunning}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="SM"
|
||||||
|
theme="light"
|
||||||
|
text={installPanelOpen ? $at("Hide Install Actions") : $at("Show Install Actions")}
|
||||||
|
onClick={() => {
|
||||||
|
const nextOpen = !installPanelOpen;
|
||||||
|
setVpnToolInstallPanelOpenMap(prev => ({ ...prev, [tool]: nextOpen }));
|
||||||
|
if (nextOpen) {
|
||||||
|
listVpnToolReleases(tool);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={busy || installRunning}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{$at("Install Status")}: {status?.installed ? $at("Installed") : $at("Not Installed")}
|
||||||
|
{status?.source ? ` (${status.source})` : ""}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{$at("Detected Version")}: {status?.detected_version || "-"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{installPanelOpen ? (
|
||||||
|
<>
|
||||||
|
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{$at("System Architecture")}: {vpnToolSystemInfo?.arch_label || "unknown"}
|
||||||
|
{vpnToolSystemInfo?.arch_keywords?.length
|
||||||
|
? ` (${vpnToolSystemInfo.arch_keywords.join(", ")})`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SettingsItem title={$at("Release Version")} description="">
|
||||||
|
<Select
|
||||||
|
className={isMobile ? "!w-full !h-[36px]" : "!w-[28%] !h-[36px]"}
|
||||||
|
value={selectedVersion}
|
||||||
|
onChange={value => handleVpnToolVersionChange(tool, value)}
|
||||||
|
options={releases.map(release => ({
|
||||||
|
value: release.tag_name,
|
||||||
|
label: release.tag_name,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
<SettingsItem title={$at("Release Asset")} description="">
|
||||||
|
<Select
|
||||||
|
className={isMobile ? "!w-full !h-[36px]" : "!w-[60%] !h-[36px]"}
|
||||||
|
value={selectedAsset}
|
||||||
|
onChange={value => setVpnToolSelectedAssetMap(prev => ({ ...prev, [tool]: value }))}
|
||||||
|
options={assets.map(asset => ({
|
||||||
|
value: asset.url,
|
||||||
|
label: `${asset.arch_match ? "[ARCH OK] " : ""}${asset.name}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
{isInstalled ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="SM"
|
||||||
|
theme="primary"
|
||||||
|
text={$at("Update")}
|
||||||
|
onClick={() => handleInstallVpnTool(tool)}
|
||||||
|
disabled={busy || installRunning || !selectedVersion || !selectedAsset}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="SM"
|
||||||
|
theme="danger"
|
||||||
|
text={$at("Uninstall")}
|
||||||
|
onClick={() => handleUninstallVpnToolVersion(tool, uninstallVersion)}
|
||||||
|
disabled={busy || installRunning || !uninstallVersion}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="SM"
|
||||||
|
theme="primary"
|
||||||
|
text={$at("Install")}
|
||||||
|
onClick={() => handleInstallVpnTool(tool)}
|
||||||
|
disabled={busy || installRunning || !selectedVersion || !selectedAsset}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{installRunning && installTask ? (
|
||||||
|
<div className="space-y-2 rounded border border-slate-200 p-2 dark:border-slate-700">
|
||||||
|
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{$at("Install Task")}: {installTask.message || "-"}
|
||||||
|
{installTask.error ? ` (${installTask.error})` : ""}
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full rounded bg-slate-200 dark:bg-slate-700">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded bg-blue-500 transition-all"
|
||||||
|
style={{ width: `${Math.max(0, Math.min(100, Math.round((installTask.progress || 0) * 100)))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{$at("Progress")}: {Math.max(0, Math.min(100, Math.round((installTask.progress || 0) * 100)))}%
|
||||||
|
</div>
|
||||||
|
{!!installTask.logs?.length && (
|
||||||
|
<pre className="max-h-36 overflow-auto rounded bg-slate-50 p-2 text-[11px] text-slate-600 dark:bg-slate-900 dark:text-slate-300">
|
||||||
|
{installTask.logs.join("\n")}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{status?.managed_versions?.length ? (
|
||||||
|
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{$at("Installed Versions")}: {status.managed_versions.join(", ")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -988,11 +1339,28 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
|||||||
</AntdButton>
|
</AntdButton>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<FirewallSettings />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SettingsSectionHeader
|
||||||
|
title={$at("WebRTC Servers")}
|
||||||
|
description={$at("STUN and TURN servers used for peer connections")}
|
||||||
|
/>
|
||||||
|
<GridCard>
|
||||||
|
<AutoHeight>
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<WebRtcServersSettings />
|
||||||
|
</div>
|
||||||
|
</AutoHeight>
|
||||||
|
</GridCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SettingsSectionHeader
|
<SettingsSectionHeader
|
||||||
title={$at("Remote")}
|
title={$at("Remote")}
|
||||||
@@ -1288,6 +1656,7 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
|||||||
<GridCard>
|
<GridCard>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{renderVpnToolManager("easytier", "EasyTier")}
|
||||||
{ easyTierRunningStatus.running ? (
|
{ easyTierRunningStatus.running ? (
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||||
@@ -1405,6 +1774,7 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
|||||||
<GridCard>
|
<GridCard>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{renderVpnToolManager("vnt", "Vnt")}
|
||||||
{ vntRunningStatus.running ? (
|
{ vntRunningStatus.running ? (
|
||||||
|
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
@@ -1617,6 +1987,7 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
|||||||
<GridCard>
|
<GridCard>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{renderVpnToolManager("cloudflared", "Cloudflare")}
|
||||||
{cloudflaredRunningStatus.running ? (
|
{cloudflaredRunningStatus.running ? (
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -1665,6 +2036,7 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
|||||||
<GridCard>
|
<GridCard>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{renderVpnToolManager("frpc", "frpc")}
|
||||||
<TextAreaWithLabel
|
<TextAreaWithLabel
|
||||||
label={$at("Edit frpc.toml")}
|
label={$at("Edit frpc.toml")}
|
||||||
placeholder={$at("Enter frpc configuration")}
|
placeholder={$at("Enter frpc configuration")}
|
||||||
|
|||||||
940
ui/src/layout/components_setting/access/FirewallSettings.tsx
Normal file
940
ui/src/layout/components_setting/access/FirewallSettings.tsx
Normal file
@@ -0,0 +1,940 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Button as AntdButton, Checkbox, Input, Modal, Select } from "antd";
|
||||||
|
import { useReactAt } from "i18n-auto-extractor/react";
|
||||||
|
import { SettingsSectionHeader } from "@components/Settings/SettingsSectionHeader";
|
||||||
|
import { isMobile } from "react-device-detect";
|
||||||
|
import { SettingsItem } from "@components/Settings/SettingsView";
|
||||||
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
import notifications from "@/notifications";
|
||||||
|
import AutoHeight from "@components/AutoHeight";
|
||||||
|
import { GridCard } from "@components/Card";
|
||||||
|
import { ConfirmDialog } from "@components/ConfirmDialog";
|
||||||
|
|
||||||
|
type FirewallChain = "input" | "output" | "forward";
|
||||||
|
type FirewallAction = "accept" | "drop" | "reject";
|
||||||
|
|
||||||
|
export interface FirewallConfig {
|
||||||
|
base: {
|
||||||
|
inputPolicy: FirewallAction;
|
||||||
|
outputPolicy: FirewallAction;
|
||||||
|
forwardPolicy: FirewallAction;
|
||||||
|
};
|
||||||
|
rules: FirewallRule[];
|
||||||
|
portForwards: FirewallPortRule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirewallRule {
|
||||||
|
chain: FirewallChain;
|
||||||
|
sourceIP: string;
|
||||||
|
sourcePort?: number | null;
|
||||||
|
protocols: string[];
|
||||||
|
destinationIP: string;
|
||||||
|
destinationPort?: number | null;
|
||||||
|
action: FirewallAction;
|
||||||
|
comment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirewallPortRule {
|
||||||
|
chain?: "output" | "prerouting" | "prerouting_redirect";
|
||||||
|
managed?: boolean;
|
||||||
|
sourcePort: number;
|
||||||
|
protocols: string[];
|
||||||
|
destinationIP: string;
|
||||||
|
destinationPort: number;
|
||||||
|
comment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultFirewallConfig: FirewallConfig = {
|
||||||
|
base: { inputPolicy: "accept", outputPolicy: "accept", forwardPolicy: "accept" },
|
||||||
|
rules: [],
|
||||||
|
portForwards: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionOptions: { value: FirewallAction; label: string }[] = [
|
||||||
|
{ value: "accept", label: "Accept" },
|
||||||
|
{ value: "drop", label: "Drop" },
|
||||||
|
{ value: "reject", label: "Reject" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const chainOptions: { value: FirewallChain; label: string }[] = [
|
||||||
|
{ value: "input", label: "Input" },
|
||||||
|
{ value: "output", label: "Output" },
|
||||||
|
{ value: "forward", label: "Forward" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const commProtocolOptions = [
|
||||||
|
{ key: "any", label: "Any" },
|
||||||
|
{ key: "tcp", label: "TCP" },
|
||||||
|
{ key: "udp", label: "UDP" },
|
||||||
|
{ key: "icmp", label: "ICMP" },
|
||||||
|
{ key: "igmp", label: "IGMP" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const portForwardProtocolOptions = [
|
||||||
|
{ key: "tcp", label: "TCP" },
|
||||||
|
{ key: "udp", label: "UDP" },
|
||||||
|
{ key: "sctp", label: "SCTP" },
|
||||||
|
{ key: "dccp", label: "DCCP" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatProtocols(protocols: string[]) {
|
||||||
|
if (!protocols?.length) return "-";
|
||||||
|
if (protocols.includes("any")) return "Any";
|
||||||
|
return protocols.map(p => p.toUpperCase()).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionLabel(action: FirewallAction) {
|
||||||
|
return actionOptions.find(o => o.value === action)?.label ?? action;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chainLabel(chain: FirewallChain) {
|
||||||
|
return chainOptions.find(o => o.value === chain)?.label ?? chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
function portForwardChainLabel(chain: FirewallPortRule["chain"]) {
|
||||||
|
switch (chain ?? "prerouting") {
|
||||||
|
case "output":
|
||||||
|
return "OUTPUT";
|
||||||
|
case "prerouting_redirect":
|
||||||
|
return "PREROUTING_REDIRECT";
|
||||||
|
default:
|
||||||
|
return "PREROUTING";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEndpoint(ip: string, port: number | null | undefined, anyText: string) {
|
||||||
|
const t = (ip || "").trim();
|
||||||
|
if (!t && (port === null || port === undefined)) return anyText;
|
||||||
|
if (!t && port !== null && port !== undefined) return `${anyText}:${port}`;
|
||||||
|
if (t && (port === null || port === undefined)) return t;
|
||||||
|
return `${t}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePort(v: string) {
|
||||||
|
const t = v.trim();
|
||||||
|
if (t === "") return null;
|
||||||
|
const n = Number(t);
|
||||||
|
if (!Number.isFinite(n)) return null;
|
||||||
|
if (n < 1 || n > 65535) return null;
|
||||||
|
return Math.trunc(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRuleProtocols(list: string[]) {
|
||||||
|
if (list.includes("any")) return ["any"];
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FirewallSettings() {
|
||||||
|
const { $at } = useReactAt();
|
||||||
|
const [send] = useJsonRpc();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<"base" | "rules" | "portForwards">(
|
||||||
|
"base",
|
||||||
|
);
|
||||||
|
const [appliedConfig, setAppliedConfig] = useState<FirewallConfig>(defaultFirewallConfig);
|
||||||
|
const [baseDraft, setBaseDraft] = useState<FirewallConfig["base"]>(
|
||||||
|
defaultFirewallConfig.base,
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [applying, setApplying] = useState(false);
|
||||||
|
const [showBaseSubmitConfirm, setShowBaseSubmitConfirm] = useState(false);
|
||||||
|
|
||||||
|
const [selectedRuleRows, setSelectedRuleRows] = useState<Set<number>>(new Set());
|
||||||
|
const [selectedPortForwardRows, setSelectedPortForwardRows] = useState<Set<number>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [ruleModalOpen, setRuleModalOpen] = useState(false);
|
||||||
|
const [ruleEditingIndex, setRuleEditingIndex] = useState<number | null>(null);
|
||||||
|
const [ruleDraft, setRuleDraft] = useState<FirewallRule>({
|
||||||
|
chain: "input",
|
||||||
|
sourceIP: "",
|
||||||
|
sourcePort: null,
|
||||||
|
protocols: ["any"],
|
||||||
|
destinationIP: "",
|
||||||
|
destinationPort: null,
|
||||||
|
action: "accept",
|
||||||
|
comment: "",
|
||||||
|
});
|
||||||
|
const [ruleSourcePortText, setRuleSourcePortText] = useState<string>("");
|
||||||
|
const [ruleDestinationPortText, setRuleDestinationPortText] = useState<string>("");
|
||||||
|
|
||||||
|
const [pfModalOpen, setPfModalOpen] = useState(false);
|
||||||
|
const [pfEditingIndex, setPfEditingIndex] = useState<number | null>(null);
|
||||||
|
const [pfDraft, setPfDraft] = useState<FirewallPortRule>({
|
||||||
|
chain: "prerouting",
|
||||||
|
sourcePort: 1,
|
||||||
|
protocols: ["tcp"],
|
||||||
|
destinationIP: "",
|
||||||
|
destinationPort: 1,
|
||||||
|
comment: "",
|
||||||
|
});
|
||||||
|
const [pfSourcePortText, setPfSourcePortText] = useState<string>("1");
|
||||||
|
const [pfDestinationPortText, setPfDestinationPortText] = useState<string>("1");
|
||||||
|
|
||||||
|
const fetchConfig = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
send("getFirewallConfig", {}, resp => {
|
||||||
|
setLoading(false);
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
`${$at("Failed to get firewall config")}: ${resp.error.data || resp.error.message}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cfg = resp.result as FirewallConfig;
|
||||||
|
setAppliedConfig(cfg);
|
||||||
|
setBaseDraft(cfg.base);
|
||||||
|
setSelectedRuleRows(new Set());
|
||||||
|
setSelectedPortForwardRows(new Set());
|
||||||
|
});
|
||||||
|
}, [send, $at]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
const hasBaseChanges = useMemo(() => {
|
||||||
|
return JSON.stringify(appliedConfig.base) !== JSON.stringify(baseDraft);
|
||||||
|
}, [appliedConfig.base, baseDraft]);
|
||||||
|
|
||||||
|
const applyFirewallConfig = useCallback(
|
||||||
|
(
|
||||||
|
nextConfig: FirewallConfig,
|
||||||
|
opts?: { onSuccess?: () => void; successText?: string },
|
||||||
|
) => {
|
||||||
|
setApplying(true);
|
||||||
|
send("setFirewallConfig", { config: nextConfig }, resp => {
|
||||||
|
setApplying(false);
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
`${$at("Failed to apply firewall config")}: ${resp.error.data || resp.error.message}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAppliedConfig(nextConfig);
|
||||||
|
if (opts?.successText) notifications.success(opts.successText);
|
||||||
|
if (opts?.onSuccess) opts.onSuccess();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[send, $at],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBaseSubmit = useCallback(() => {
|
||||||
|
const nextConfig: FirewallConfig = { ...appliedConfig, base: baseDraft };
|
||||||
|
applyFirewallConfig(nextConfig, {
|
||||||
|
successText: $at("Firewall config applied"),
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowBaseSubmitConfirm(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [appliedConfig, baseDraft, applyFirewallConfig, $at]);
|
||||||
|
|
||||||
|
const requestBaseSubmit = useCallback(() => {
|
||||||
|
if (!hasBaseChanges) return;
|
||||||
|
setShowBaseSubmitConfirm(true);
|
||||||
|
}, [hasBaseChanges]);
|
||||||
|
|
||||||
|
const openAddRule = () => {
|
||||||
|
setRuleEditingIndex(null);
|
||||||
|
setRuleDraft({
|
||||||
|
chain: "input",
|
||||||
|
sourceIP: "",
|
||||||
|
sourcePort: null,
|
||||||
|
protocols: ["any"],
|
||||||
|
destinationIP: "",
|
||||||
|
destinationPort: null,
|
||||||
|
action: "accept",
|
||||||
|
comment: "",
|
||||||
|
});
|
||||||
|
setRuleSourcePortText("");
|
||||||
|
setRuleDestinationPortText("");
|
||||||
|
setRuleModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditRule = (idx: number) => {
|
||||||
|
const current = appliedConfig.rules[idx];
|
||||||
|
if (!current) return;
|
||||||
|
setRuleEditingIndex(idx);
|
||||||
|
setRuleDraft({ ...current });
|
||||||
|
setRuleSourcePortText(current.sourcePort ? String(current.sourcePort) : "");
|
||||||
|
setRuleDestinationPortText(current.destinationPort ? String(current.destinationPort) : "");
|
||||||
|
setRuleModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveRuleDraft = () => {
|
||||||
|
const next: FirewallRule = {
|
||||||
|
...ruleDraft,
|
||||||
|
protocols: normalizeRuleProtocols(ruleDraft.protocols),
|
||||||
|
sourcePort: normalizePort(ruleSourcePortText),
|
||||||
|
destinationPort: normalizePort(ruleDestinationPortText),
|
||||||
|
sourceIP: ruleDraft.sourceIP.trim(),
|
||||||
|
destinationIP: ruleDraft.destinationIP.trim(),
|
||||||
|
comment: ruleDraft.comment.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!next.protocols.length) {
|
||||||
|
notifications.error($at("Please select protocol"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!next.chain || !next.action) {
|
||||||
|
notifications.error($at("Missing required fields"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = [...appliedConfig.rules];
|
||||||
|
if (ruleEditingIndex === null) {
|
||||||
|
rules.push(next);
|
||||||
|
} else {
|
||||||
|
rules[ruleEditingIndex] = next;
|
||||||
|
}
|
||||||
|
const nextConfig: FirewallConfig = {
|
||||||
|
...appliedConfig,
|
||||||
|
rules,
|
||||||
|
};
|
||||||
|
applyFirewallConfig(nextConfig, {
|
||||||
|
successText: $at("Firewall config applied"),
|
||||||
|
onSuccess: () => {
|
||||||
|
setRuleModalOpen(false);
|
||||||
|
setSelectedRuleRows(new Set());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSelectedRules = () => {
|
||||||
|
const idxs = [...selectedRuleRows.values()].sort((a, b) => a - b);
|
||||||
|
if (!idxs.length) return;
|
||||||
|
const nextRules = appliedConfig.rules.filter((_, i) => !selectedRuleRows.has(i));
|
||||||
|
const nextConfig: FirewallConfig = {
|
||||||
|
...appliedConfig,
|
||||||
|
rules: nextRules,
|
||||||
|
};
|
||||||
|
applyFirewallConfig(nextConfig, {
|
||||||
|
successText: $at("Firewall config applied"),
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedRuleRows(new Set());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAddPortForward = () => {
|
||||||
|
setPfEditingIndex(null);
|
||||||
|
setPfDraft({
|
||||||
|
chain: "prerouting",
|
||||||
|
sourcePort: 1,
|
||||||
|
protocols: ["tcp"],
|
||||||
|
destinationIP: "",
|
||||||
|
destinationPort: 1,
|
||||||
|
comment: "",
|
||||||
|
});
|
||||||
|
setPfSourcePortText("1");
|
||||||
|
setPfDestinationPortText("1");
|
||||||
|
setPfModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditPortForward = (idx: number) => {
|
||||||
|
const current = appliedConfig.portForwards[idx];
|
||||||
|
if (!current) return;
|
||||||
|
if (current.managed === false) return;
|
||||||
|
setPfEditingIndex(idx);
|
||||||
|
const inferredChain =
|
||||||
|
current.chain ??
|
||||||
|
(current.destinationIP?.trim() === "0.0.0.0" || current.destinationIP?.trim() === "127.0.0.1"
|
||||||
|
? "output"
|
||||||
|
: "prerouting");
|
||||||
|
setPfDraft({
|
||||||
|
...current,
|
||||||
|
chain: inferredChain,
|
||||||
|
destinationIP:
|
||||||
|
inferredChain === "output" || inferredChain === "prerouting_redirect"
|
||||||
|
? "0.0.0.0"
|
||||||
|
: current.destinationIP?.trim() === "0.0.0.0" || current.destinationIP?.trim() === "127.0.0.1"
|
||||||
|
? ""
|
||||||
|
: current.destinationIP,
|
||||||
|
});
|
||||||
|
setPfSourcePortText(String(current.sourcePort));
|
||||||
|
setPfDestinationPortText(String(current.destinationPort));
|
||||||
|
setPfModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePortForwardDraft = () => {
|
||||||
|
const srcPort = normalizePort(pfSourcePortText);
|
||||||
|
const dstPort = normalizePort(pfDestinationPortText);
|
||||||
|
if (!srcPort || !dstPort) {
|
||||||
|
notifications.error($at("Invalid port"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next: FirewallPortRule = {
|
||||||
|
...pfDraft,
|
||||||
|
sourcePort: srcPort,
|
||||||
|
destinationPort: dstPort,
|
||||||
|
destinationIP:
|
||||||
|
(pfDraft.chain ?? "prerouting") === "output" ||
|
||||||
|
(pfDraft.chain ?? "prerouting") === "prerouting_redirect"
|
||||||
|
? "0.0.0.0"
|
||||||
|
: pfDraft.destinationIP.trim(),
|
||||||
|
protocols: normalizeRuleProtocols(pfDraft.protocols).filter(p => p !== "any"),
|
||||||
|
comment: pfDraft.comment.trim(),
|
||||||
|
};
|
||||||
|
const pfChain = next.chain ?? "prerouting";
|
||||||
|
if (pfChain === "prerouting" && ["0.0.0.0", "127.0.0.1"].includes(next.destinationIP.trim())) {
|
||||||
|
notifications.error($at("For PREROUTING, Destination IP must be a real host IP"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pfChain === "prerouting" && !next.destinationIP) {
|
||||||
|
notifications.error($at("Destination IP is required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!next.protocols.length) {
|
||||||
|
notifications.error($at("Please select protocol"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [...appliedConfig.portForwards];
|
||||||
|
if (pfEditingIndex === null) {
|
||||||
|
items.push(next);
|
||||||
|
} else {
|
||||||
|
items[pfEditingIndex] = next;
|
||||||
|
}
|
||||||
|
const nextConfig: FirewallConfig = {
|
||||||
|
...appliedConfig,
|
||||||
|
portForwards: items,
|
||||||
|
};
|
||||||
|
applyFirewallConfig(nextConfig, {
|
||||||
|
successText: $at("Firewall config applied"),
|
||||||
|
onSuccess: () => {
|
||||||
|
setPfModalOpen(false);
|
||||||
|
setSelectedPortForwardRows(new Set());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSelectedPortForwards = () => {
|
||||||
|
const idxs = [...selectedPortForwardRows.values()].sort((a, b) => a - b);
|
||||||
|
if (!idxs.length) return;
|
||||||
|
const nextItems = appliedConfig.portForwards.filter((r, i) => {
|
||||||
|
if (!selectedPortForwardRows.has(i)) return true;
|
||||||
|
return r.managed === false;
|
||||||
|
});
|
||||||
|
const nextConfig: FirewallConfig = {
|
||||||
|
...appliedConfig,
|
||||||
|
portForwards: nextItems,
|
||||||
|
};
|
||||||
|
applyFirewallConfig(nextConfig, {
|
||||||
|
successText: $at("Firewall config applied"),
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedPortForwardRows(new Set());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SettingsItem
|
||||||
|
title={$at("Firewall")}
|
||||||
|
badge="Experimental"
|
||||||
|
description={$at("Manage the firewall rules of the device")}
|
||||||
|
>
|
||||||
|
</SettingsItem>
|
||||||
|
<div className="overflow-x-auto pb-2">
|
||||||
|
<div className="flex min-w-max">
|
||||||
|
{[
|
||||||
|
{ id: "base", label: $at("Basic") },
|
||||||
|
{ id: "rules", label: $at("Communication Rules") },
|
||||||
|
{ id: "portForwards", label: $at("Port Forwarding") },
|
||||||
|
].map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id as typeof activeTab)}
|
||||||
|
className={`
|
||||||
|
flex-1 min-w-[120px] px-6 py-3 text-sm font-medium transition-all duration-200 border-y border-r first:border-l first:rounded-l-lg last:rounded-r-lg flex items-center justify-center gap-2
|
||||||
|
${
|
||||||
|
activeTab === tab.id
|
||||||
|
? "!bg-[rgba(22,152,217,1)] dark:!bg-[rgba(45,106,229,1))] !text-white border-[rgba(22,152,217,1)] dark:border-[rgba(45,106,229,1)]"
|
||||||
|
: "bg-transparent text-slate-600 dark:text-slate-400 border-slate-200 dark:border-slate-700 hover:border-[rgba(22,152,217,1)] dark:hover:border-[rgba(45,106,229,1)] hover:text-[rgba(22,152,217,1)] dark:hover:text-[rgba(45,106,229,1)]"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{activeTab === "base" && (
|
||||||
|
<AutoHeight>
|
||||||
|
<GridCard>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-[64px_1fr] gap-y-3 items-center">
|
||||||
|
<div className="text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
{$at("Input")}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Select
|
||||||
|
className={isMobile ? "!w-full !h-[36px]" : "!w-[28%] !h-[36px]"}
|
||||||
|
value={baseDraft.inputPolicy}
|
||||||
|
onChange={v =>
|
||||||
|
setBaseDraft({
|
||||||
|
...baseDraft,
|
||||||
|
inputPolicy: v as FirewallAction,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
options={actionOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
{$at("Output")}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Select
|
||||||
|
className={isMobile ? "!w-full !h-[36px]" : "!w-[28%] !h-[36px]"}
|
||||||
|
value={baseDraft.outputPolicy}
|
||||||
|
onChange={v =>
|
||||||
|
setBaseDraft({
|
||||||
|
...baseDraft,
|
||||||
|
outputPolicy: v as FirewallAction,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
options={actionOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
{$at("Forward")}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Select
|
||||||
|
className={isMobile ? "!w-full !h-[36px]" : "!w-[28%] !h-[36px]"}
|
||||||
|
value={baseDraft.forwardPolicy}
|
||||||
|
onChange={v =>
|
||||||
|
setBaseDraft({
|
||||||
|
...baseDraft,
|
||||||
|
forwardPolicy: v as FirewallAction,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
options={actionOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AntdButton onClick={fetchConfig} loading={loading}>
|
||||||
|
{$at("Refresh")}
|
||||||
|
</AntdButton>
|
||||||
|
<AntdButton
|
||||||
|
type="primary"
|
||||||
|
onClick={requestBaseSubmit}
|
||||||
|
loading={applying}
|
||||||
|
disabled={!hasBaseChanges}
|
||||||
|
>
|
||||||
|
{$at("Submit")}
|
||||||
|
</AntdButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GridCard>
|
||||||
|
</AutoHeight>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "rules" && (
|
||||||
|
<AutoHeight>
|
||||||
|
<GridCard>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AntdButton
|
||||||
|
type="primary"
|
||||||
|
onClick={openAddRule}
|
||||||
|
>
|
||||||
|
{$at("Add")}
|
||||||
|
</AntdButton>
|
||||||
|
<AntdButton danger onClick={deleteSelectedRules} disabled={!selectedRuleRows.size}>
|
||||||
|
{$at("Delete")}
|
||||||
|
</AntdButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded border border-slate-200 dark:border-slate-700">
|
||||||
|
<table className="min-w-max w-full text-sm whitespace-nowrap">
|
||||||
|
<thead className="bg-slate-50 text-xs text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">
|
||||||
|
<tr>
|
||||||
|
<th className="w-10 p-2 text-center font-medium" />
|
||||||
|
<th className="p-2 text-center font-medium">{$at("Chain")}</th>
|
||||||
|
<th className="p-2 text-center font-medium">{$at("Source")}</th>
|
||||||
|
<th className="p-2 text-center font-medium">{$at("Protocol")}</th>
|
||||||
|
<th className="p-2 text-center font-medium">{$at("Destination")}</th>
|
||||||
|
<th className="p-2 text-center font-medium">{$at("Action")}</th>
|
||||||
|
<th className="p-2 text-center font-medium">{$at("Description")}</th>
|
||||||
|
<th className="w-20 p-2 text-center font-medium">{$at("Operation")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{appliedConfig.rules.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="p-6 text-center text-slate-500 dark:text-slate-400">
|
||||||
|
{$at("No rules available")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
appliedConfig.rules.map((r, idx) => (
|
||||||
|
<tr
|
||||||
|
key={idx}
|
||||||
|
className="border-t border-slate-200 dark:border-slate-700"
|
||||||
|
>
|
||||||
|
<td className="p-2 text-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRuleRows.has(idx)}
|
||||||
|
onChange={e => {
|
||||||
|
const next = new Set(selectedRuleRows);
|
||||||
|
if (e.target.checked) next.add(idx);
|
||||||
|
else next.delete(idx);
|
||||||
|
setSelectedRuleRows(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-center">{chainLabel(r.chain)}</td>
|
||||||
|
<td className="p-2 text-center">
|
||||||
|
{formatEndpoint(r.sourceIP, r.sourcePort, $at("Any"))}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-center">{formatProtocols(r.protocols)}</td>
|
||||||
|
<td className="p-2 text-center">
|
||||||
|
{formatEndpoint(r.destinationIP, r.destinationPort, $at("Any"))}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-center">{actionLabel(r.action)}</td>
|
||||||
|
<td className="p-2 text-center">{r.comment || "-"}</td>
|
||||||
|
<td className="p-2 text-center">
|
||||||
|
<AntdButton size="small" onClick={() => openEditRule(idx)}>
|
||||||
|
{$at("Edit")}
|
||||||
|
</AntdButton>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GridCard>
|
||||||
|
</AutoHeight>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "portForwards" && (
|
||||||
|
<AutoHeight>
|
||||||
|
<GridCard>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AntdButton
|
||||||
|
type="primary"
|
||||||
|
onClick={openAddPortForward}
|
||||||
|
>
|
||||||
|
{$at("Add")}
|
||||||
|
</AntdButton>
|
||||||
|
<AntdButton
|
||||||
|
danger
|
||||||
|
onClick={deleteSelectedPortForwards}
|
||||||
|
disabled={!selectedPortForwardRows.size}
|
||||||
|
>
|
||||||
|
{$at("Delete")}
|
||||||
|
</AntdButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded border border-slate-200 dark:border-slate-700">
|
||||||
|
<table className="min-w-max w-full text-sm whitespace-nowrap">
|
||||||
|
<thead className="bg-slate-50 text-xs text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">
|
||||||
|
<tr>
|
||||||
|
<th className="w-10 p-2 text-center font-medium" />
|
||||||
|
<th className="p-2 text-center font-medium">{$at("Chain")}</th>
|
||||||
|
<th className="p-2 text-center font-medium">{$at("Source")}</th>
|
||||||
|
<th className="p-2 text-center font-medium">{$at("Protocol")}</th>
|
||||||
|
<th className="p-2 text-center font-medium">{$at("Destination")}</th>
|
||||||
|
<th className="p-2 text-center font-medium">{$at("Description")}</th>
|
||||||
|
<th className="w-20 p-2 text-center font-medium">{$at("Operation")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{appliedConfig.portForwards.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="p-6 text-center text-slate-500 dark:text-slate-400">
|
||||||
|
{$at("No data available")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
appliedConfig.portForwards.map((r, idx) => (
|
||||||
|
<tr
|
||||||
|
key={idx}
|
||||||
|
className="border-t border-slate-200 dark:border-slate-700"
|
||||||
|
>
|
||||||
|
<td className="p-2 text-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedPortForwardRows.has(idx)}
|
||||||
|
disabled={r.managed === false}
|
||||||
|
onChange={e => {
|
||||||
|
const next = new Set(selectedPortForwardRows);
|
||||||
|
if (e.target.checked) next.add(idx);
|
||||||
|
else next.delete(idx);
|
||||||
|
setSelectedPortForwardRows(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-center">{portForwardChainLabel(r.chain)}</td>
|
||||||
|
<td className="p-2 text-center">
|
||||||
|
{formatEndpoint("", r.sourcePort, $at("Any"))}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-center">{formatProtocols(r.protocols)}</td>
|
||||||
|
<td className="p-2 text-center">
|
||||||
|
{formatEndpoint(r.destinationIP, r.destinationPort, $at("Any"))}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-center">{r.comment || "-"}</td>
|
||||||
|
<td className="p-2 text-center">
|
||||||
|
<AntdButton size="small" disabled={r.managed === false} onClick={() => openEditPortForward(idx)}>
|
||||||
|
{$at("Edit")}
|
||||||
|
</AntdButton>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GridCard>
|
||||||
|
</AutoHeight>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={ruleModalOpen}
|
||||||
|
onCancel={() => setRuleModalOpen(false)}
|
||||||
|
onOk={saveRuleDraft}
|
||||||
|
confirmLoading={applying}
|
||||||
|
title={$at(ruleEditingIndex === null ? "Add Rule" : "Edit Rule")}
|
||||||
|
okText={$at("OK")}
|
||||||
|
cancelText={$at("Cancel")}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">{$at("Chain")}</div>
|
||||||
|
<Select
|
||||||
|
className="!w-full"
|
||||||
|
value={ruleDraft.chain}
|
||||||
|
onChange={v => setRuleDraft({ ...ruleDraft, chain: v as FirewallChain })}
|
||||||
|
options={chainOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">{$at("Source IP")}</div>
|
||||||
|
<Input
|
||||||
|
value={ruleDraft.sourceIP}
|
||||||
|
placeholder={$at("Any")}
|
||||||
|
onChange={e => setRuleDraft({ ...ruleDraft, sourceIP: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">{$at("Source Port")}</div>
|
||||||
|
<Input
|
||||||
|
value={ruleSourcePortText}
|
||||||
|
placeholder={$at("Any")}
|
||||||
|
onChange={e => setRuleSourcePortText(e.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">{$at("Protocol")}</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{commProtocolOptions.map(p => (
|
||||||
|
<Checkbox
|
||||||
|
key={p.key}
|
||||||
|
checked={ruleDraft.protocols.includes(p.key)}
|
||||||
|
onChange={e => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
const next = new Set(ruleDraft.protocols);
|
||||||
|
if (checked) next.add(p.key);
|
||||||
|
else next.delete(p.key);
|
||||||
|
const arr = [...next.values()];
|
||||||
|
setRuleDraft({ ...ruleDraft, protocols: normalizeRuleProtocols(arr) });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">{$at("Destination IP")}</div>
|
||||||
|
<Input
|
||||||
|
value={ruleDraft.destinationIP}
|
||||||
|
placeholder={$at("Any")}
|
||||||
|
onChange={e => setRuleDraft({ ...ruleDraft, destinationIP: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">{$at("Destination Port")}</div>
|
||||||
|
<Input
|
||||||
|
value={ruleDestinationPortText}
|
||||||
|
placeholder={$at("Any")}
|
||||||
|
onChange={e => setRuleDestinationPortText(e.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">{$at("Action")}</div>
|
||||||
|
<Select
|
||||||
|
className="!w-full"
|
||||||
|
value={ruleDraft.action}
|
||||||
|
onChange={v => setRuleDraft({ ...ruleDraft, action: v as FirewallAction })}
|
||||||
|
options={actionOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">{$at("Description")}</div>
|
||||||
|
<Input
|
||||||
|
value={ruleDraft.comment}
|
||||||
|
onChange={e => setRuleDraft({ ...ruleDraft, comment: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={pfModalOpen}
|
||||||
|
onCancel={() => setPfModalOpen(false)}
|
||||||
|
onOk={savePortForwardDraft}
|
||||||
|
confirmLoading={applying}
|
||||||
|
title={$at(pfEditingIndex === null ? "Add Rule" : "Edit Rule")}
|
||||||
|
okText={$at("OK")}
|
||||||
|
cancelText={$at("Cancel")}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-red-500">*</span> {$at("Chain")}
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
className="!w-full"
|
||||||
|
value={pfDraft.chain ?? "prerouting"}
|
||||||
|
onChange={v => {
|
||||||
|
const c = v as "output" | "prerouting" | "prerouting_redirect";
|
||||||
|
const curDst = pfDraft.destinationIP.trim();
|
||||||
|
const forceLocal = c === "output" || c === "prerouting_redirect";
|
||||||
|
setPfDraft({
|
||||||
|
...pfDraft,
|
||||||
|
chain: c,
|
||||||
|
destinationIP: forceLocal
|
||||||
|
? "0.0.0.0"
|
||||||
|
: curDst === "0.0.0.0" || curDst === "127.0.0.1"
|
||||||
|
? ""
|
||||||
|
: pfDraft.destinationIP,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: "prerouting", label: "PREROUTING" },
|
||||||
|
{ value: "prerouting_redirect", label: "PREROUTING_REDIRECT" },
|
||||||
|
{ value: "output", label: "OUTPUT" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-red-500">*</span> {$at("Source Port")}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={pfSourcePortText}
|
||||||
|
onChange={e => setPfSourcePortText(e.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">{$at("Protocol")}</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{portForwardProtocolOptions.map(p => (
|
||||||
|
<Checkbox
|
||||||
|
key={p.key}
|
||||||
|
checked={pfDraft.protocols.includes(p.key)}
|
||||||
|
onChange={e => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
const next = new Set(pfDraft.protocols);
|
||||||
|
if (checked) next.add(p.key);
|
||||||
|
else next.delete(p.key);
|
||||||
|
setPfDraft({ ...pfDraft, protocols: [...next.values()] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-red-500">*</span> {$at("Destination IP")}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={pfDraft.destinationIP}
|
||||||
|
onChange={e => setPfDraft({ ...pfDraft, destinationIP: e.target.value })}
|
||||||
|
disabled={(pfDraft.chain ?? "prerouting") !== "prerouting"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-red-500">*</span> {$at("Destination Port")}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={pfDestinationPortText}
|
||||||
|
onChange={e => setPfDestinationPortText(e.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">{$at("Description")}</div>
|
||||||
|
<Input
|
||||||
|
value={pfDraft.comment}
|
||||||
|
onChange={e => setPfDraft({ ...pfDraft, comment: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={showBaseSubmitConfirm}
|
||||||
|
onClose={() => {
|
||||||
|
setShowBaseSubmitConfirm(false);
|
||||||
|
}}
|
||||||
|
title={$at("Submit Firewall Policies?")}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
{$at(
|
||||||
|
"Warning: Adjusting some policies may cause network address loss, leading to device unavailability.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
variant="warning"
|
||||||
|
cancelText={$at("Cancel")}
|
||||||
|
confirmText={$at("Submit")}
|
||||||
|
onConfirm={handleBaseSubmit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,9 +19,18 @@ export default function SettingsAdvanced() {
|
|||||||
const [sshKey, setSSHKey] = useState<string>("");
|
const [sshKey, setSSHKey] = useState<string>("");
|
||||||
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
|
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
|
||||||
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
|
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
|
||||||
|
const [usbEnhancedDetectionEnabled, setUsbEnhancedDetectionEnabled] =
|
||||||
|
useState(true);
|
||||||
const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);
|
const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);
|
||||||
const [showRebootConfirm, setShowRebootConfirm] = useState(false);
|
const [showRebootConfirm, setShowRebootConfirm] = useState(false);
|
||||||
|
const [showConfigEdit, setShowConfigEdit] = useState(false);
|
||||||
|
const [showConfigSavedReboot, setShowConfigSavedReboot] = useState(false);
|
||||||
|
const [configContent, setConfigContent] = useState("");
|
||||||
|
const [isSavingConfig, setIsSavingConfig] = useState(false);
|
||||||
const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
|
const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
|
||||||
|
const [apiKey, setApiKey] = useState<string>("");
|
||||||
|
const [apiKeyInput, setApiKeyInput] = useState<string>("");
|
||||||
|
const [showApiKeyClearWarning, setShowApiKeyClearWarning] = useState(false);
|
||||||
|
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||||
@@ -38,10 +47,22 @@ export default function SettingsAdvanced() {
|
|||||||
setUsbEmulationEnabled(resp.result as boolean);
|
setUsbEmulationEnabled(resp.result as boolean);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
send("getUsbEnhancedDetection", {}, resp => {
|
||||||
|
if ("error" in resp) return;
|
||||||
|
setUsbEnhancedDetectionEnabled(resp.result as boolean);
|
||||||
|
});
|
||||||
|
|
||||||
send("getLocalLoopbackOnly", {}, resp => {
|
send("getLocalLoopbackOnly", {}, resp => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
setLocalLoopbackOnly(resp.result as boolean);
|
setLocalLoopbackOnly(resp.result as boolean);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
send("getApiKey", {}, resp => {
|
||||||
|
if ("error" in resp) return;
|
||||||
|
const key = resp.result as string;
|
||||||
|
setApiKey(key);
|
||||||
|
setApiKeyInput(key);
|
||||||
|
});
|
||||||
}, [send, setDeveloperMode]);
|
}, [send, setDeveloperMode]);
|
||||||
|
|
||||||
const getUsbEmulationState = useCallback(() => {
|
const getUsbEmulationState = useCallback(() => {
|
||||||
@@ -67,6 +88,24 @@ export default function SettingsAdvanced() {
|
|||||||
[getUsbEmulationState, send],
|
[getUsbEmulationState, send],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleUsbEnhancedDetectionToggle = useCallback(
|
||||||
|
(enabled: boolean) => {
|
||||||
|
send("setUsbEnhancedDetection", { enabled }, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
`Failed to ${enabled ? "enable" : "disable"} USB enhanced detection: ${resp.error.data || "Unknown error"}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUsbEnhancedDetectionEnabled(enabled);
|
||||||
|
notifications.success(
|
||||||
|
enabled ? "USB enhanced detection enabled" : "USB enhanced detection disabled",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[send],
|
||||||
|
);
|
||||||
|
|
||||||
const handleResetConfig = useCallback(() => {
|
const handleResetConfig = useCallback(() => {
|
||||||
send("resetConfig", {}, resp => {
|
send("resetConfig", {}, resp => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
@@ -79,6 +118,54 @@ export default function SettingsAdvanced() {
|
|||||||
});
|
});
|
||||||
}, [send]);
|
}, [send]);
|
||||||
|
|
||||||
|
const handleUpdateApiKey = useCallback(() => {
|
||||||
|
if (apiKeyInput === "") {
|
||||||
|
setShowApiKeyClearWarning(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send("setApiKey", { apiKey: apiKeyInput }, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
`Failed to update API key: ${resp.error.data || "Unknown error"}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setApiKey(apiKeyInput);
|
||||||
|
notifications.success("API key updated successfully");
|
||||||
|
});
|
||||||
|
}, [send, apiKeyInput]);
|
||||||
|
|
||||||
|
const handleGenerateApiKey = useCallback(() => {
|
||||||
|
send("generateApiKey", {}, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
`Failed to generate API key: ${resp.error.data || "Unknown error"}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newKey = resp.result as string;
|
||||||
|
setApiKey(newKey);
|
||||||
|
setApiKeyInput(newKey);
|
||||||
|
notifications.success("New API key generated and saved");
|
||||||
|
});
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
|
const confirmClearApiKey = useCallback(() => {
|
||||||
|
send("generateApiKey", {}, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
`Failed to generate API key: ${resp.error.data || "Unknown error"}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newKey = resp.result as string;
|
||||||
|
setApiKey(newKey);
|
||||||
|
setApiKeyInput(newKey);
|
||||||
|
notifications.success("New API key generated and saved");
|
||||||
|
});
|
||||||
|
setShowApiKeyClearWarning(false);
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
const handleUpdateSSHKey = useCallback(() => {
|
const handleUpdateSSHKey = useCallback(() => {
|
||||||
send("setSSHKeyState", { sshKey }, resp => {
|
send("setSSHKeyState", { sshKey }, resp => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
@@ -133,6 +220,35 @@ export default function SettingsAdvanced() {
|
|||||||
setShowLoopbackWarning(false);
|
setShowLoopbackWarning(false);
|
||||||
}, [applyLoopbackOnlyMode, setShowLoopbackWarning]);
|
}, [applyLoopbackOnlyMode, setShowLoopbackWarning]);
|
||||||
|
|
||||||
|
const handleOpenConfigEditor = useCallback(() => {
|
||||||
|
send("getConfigRaw", {}, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
`Failed to load configuration: ${resp.error.data || "Unknown error"}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfigContent(resp.result as string);
|
||||||
|
setShowConfigEdit(true);
|
||||||
|
});
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
|
const handleSaveConfig = useCallback(() => {
|
||||||
|
setIsSavingConfig(true);
|
||||||
|
send("setConfigRaw", { configStr: configContent }, resp => {
|
||||||
|
setIsSavingConfig(false);
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
`Failed to save configuration: ${resp.error.data || "Unknown error"}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifications.success("Configuration saved successfully");
|
||||||
|
setShowConfigEdit(false);
|
||||||
|
setShowConfigSavedReboot(true);
|
||||||
|
});
|
||||||
|
}, [send, configContent]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SettingsPageHeader
|
<SettingsPageHeader
|
||||||
@@ -182,6 +298,42 @@ export default function SettingsAdvanced() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isOnDevice && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SettingsItem
|
||||||
|
title={$at("API Key")}
|
||||||
|
description={$at("API key for MCP and REST API authentication")}
|
||||||
|
/>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<TextAreaWithLabel
|
||||||
|
label={$at("API Key")}
|
||||||
|
value={apiKeyInput || ""}
|
||||||
|
rows={2}
|
||||||
|
onChange={e => setApiKeyInput(e.target.value)}
|
||||||
|
placeholder={$at("Enter API key or leave empty to auto-generate")}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-600 dark:text-[#ffffff]">
|
||||||
|
{$at("Used for authenticating MCP and REST API requests.")}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<AntdButton
|
||||||
|
type="primary"
|
||||||
|
onClick={handleUpdateApiKey}
|
||||||
|
className={isMobile?"w-full":""}
|
||||||
|
>
|
||||||
|
{$at("Save API Key")}
|
||||||
|
</AntdButton>
|
||||||
|
<AntdButton
|
||||||
|
onClick={handleGenerateApiKey}
|
||||||
|
className={isMobile?"w-full":""}
|
||||||
|
>
|
||||||
|
{$at("Generate New")}
|
||||||
|
</AntdButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
title={$at("Force HTTP Transmission")}
|
title={$at("Force HTTP Transmission")}
|
||||||
badge="Experimental"
|
badge="Experimental"
|
||||||
@@ -197,6 +349,17 @@ export default function SettingsAdvanced() {
|
|||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title={$at("USB detection enhancement")}
|
||||||
|
description={$at("The DISC state is also checked during USB status retrieval")}
|
||||||
|
noCol
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={usbEnhancedDetectionEnabled}
|
||||||
|
onChange={e => handleUsbEnhancedDetectionToggle(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
title={$at("USB Emulation")}
|
title={$at("USB Emulation")}
|
||||||
description={$at("Control the USB emulation state")}
|
description={$at("Control the USB emulation state")}
|
||||||
@@ -256,15 +419,24 @@ export default function SettingsAdvanced() {
|
|||||||
</AntdButton>
|
</AntdButton>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title={$at("Edit Configuration")}
|
||||||
|
description={$at("Edit the raw configuration file directly")}
|
||||||
|
>
|
||||||
|
<AntdButton
|
||||||
|
type="primary"
|
||||||
|
className={`${isMobile?"w-full":""}`}
|
||||||
|
onClick={handleOpenConfigEditor}
|
||||||
|
>
|
||||||
|
{$at("Edit")}
|
||||||
|
</AntdButton>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
title={$at("Reset Configuration")}
|
title={$at("Reset Configuration")}
|
||||||
description={$at("Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.")}
|
description={$at("Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.")}
|
||||||
>
|
>
|
||||||
|
|
||||||
<AntdButton
|
<AntdButton
|
||||||
|
|
||||||
type="primary"
|
type="primary"
|
||||||
className={`${isMobile?"w-full":""}`}
|
className={`${isMobile?"w-full":""}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -301,6 +473,28 @@ export default function SettingsAdvanced() {
|
|||||||
onConfirm={confirmLoopbackModeEnable}
|
onConfirm={confirmLoopbackModeEnable}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={showApiKeyClearWarning}
|
||||||
|
onClose={() => {
|
||||||
|
setShowApiKeyClearWarning(false);
|
||||||
|
}}
|
||||||
|
title={$at("Clear API Key?")}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
{$at("Setting the API key to empty will auto-generate a new random key.")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-600 dark:text-slate-400 mt-2">
|
||||||
|
{$at("Make sure to update your clients with the new key after saving.")}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
variant="warning"
|
||||||
|
cancelText={$at("Cancel")}
|
||||||
|
confirmText={$at("Generate New Key")}
|
||||||
|
onConfirm={confirmClearApiKey}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={showRebootConfirm}
|
open={showRebootConfirm}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -333,6 +527,61 @@ export default function SettingsAdvanced() {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={showConfigEdit}
|
||||||
|
onClose={() => setShowConfigEdit(false)}
|
||||||
|
title={$at("Edit Configuration")}
|
||||||
|
description={
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-xs text-slate-600 dark:text-slate-400">
|
||||||
|
{$at("Edit the raw configuration JSON. Be careful when making changes as invalid JSON can cause system issues.")}
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={configContent}
|
||||||
|
onChange={e => setConfigContent(e.target.value)}
|
||||||
|
className="w-full h-64 p-3 font-mono text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:text-white"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
variant="info"
|
||||||
|
cancelText={$at("Cancel")}
|
||||||
|
confirmText={isSavingConfig ? `${$at("Saving")}...` : $at("Save")}
|
||||||
|
onConfirm={handleSaveConfig}
|
||||||
|
isConfirming={isSavingConfig}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={showConfigSavedReboot}
|
||||||
|
onClose={() => setShowConfigSavedReboot(false)}
|
||||||
|
title={$at("Configuration Saved")}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
{$at("Configuration has been saved successfully. Some changes may require a system restart to take effect.")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-600 dark:text-slate-400 mt-2">
|
||||||
|
{$at("Would you like to restart the system now?")}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
variant="info"
|
||||||
|
cancelText={$at("Later")}
|
||||||
|
confirmText={$at("Restart Now")}
|
||||||
|
onConfirm={() => {
|
||||||
|
setShowConfigSavedReboot(false);
|
||||||
|
send("reboot", { force: false }, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
`Failed to reboot: ${resp.error.data || "Unknown error"}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifications.success("System rebooting...");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ export default function SettingsHardware() {
|
|||||||
value={settings.ledGreenMode.toString()}
|
value={settings.ledGreenMode.toString()}
|
||||||
className={`${isMobile?"w-full":"h-[36px] w-[22%]"}`}
|
className={`${isMobile?"w-full":"h-[36px] w-[22%]"}`}
|
||||||
options={[
|
options={[
|
||||||
|
{ value: "disabled", label: $at("Disabled") },
|
||||||
{ value: "network-link", label: $at("network-link") },
|
{ value: "network-link", label: $at("network-link") },
|
||||||
{ value: "network-tx", label: $at("network-tx") },
|
{ value: "network-tx", label: $at("network-tx") },
|
||||||
{ value: "network-rx", label: $at("network-rx") },
|
{ value: "network-rx", label: $at("network-rx") },
|
||||||
@@ -309,8 +310,9 @@ export default function SettingsHardware() {
|
|||||||
|
|
||||||
<SelectMenuBasic
|
<SelectMenuBasic
|
||||||
value={settings.ledYellowMode.toString()}
|
value={settings.ledYellowMode.toString()}
|
||||||
className={`${isMobile?"w-full":""}`}
|
className={`${isMobile?"w-full":"h-[36px] w-[22%]"}`}
|
||||||
options={[
|
options={[
|
||||||
|
{ value: "disabled", label: $at("Disabled") },
|
||||||
{ value: "network-link", label: $at("network-link") },
|
{ value: "network-link", label: $at("network-link") },
|
||||||
{ value: "network-tx", label: $at("network-tx") },
|
{ value: "network-tx", label: $at("network-tx") },
|
||||||
{ value: "network-rx", label: $at("network-rx") },
|
{ value: "network-rx", label: $at("network-rx") },
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ dayjs.extend(relativeTime);
|
|||||||
const defaultNetworkSettings: NetworkSettings = {
|
const defaultNetworkSettings: NetworkSettings = {
|
||||||
hostname: "",
|
hostname: "",
|
||||||
domain: "",
|
domain: "",
|
||||||
|
http_proxy: "",
|
||||||
|
https_proxy: "",
|
||||||
|
all_proxy: "",
|
||||||
ipv4_mode: "unknown",
|
ipv4_mode: "unknown",
|
||||||
ipv6_mode: "unknown",
|
ipv6_mode: "unknown",
|
||||||
lldp_mode: "unknown",
|
lldp_mode: "unknown",
|
||||||
@@ -85,6 +88,10 @@ export default function SettingsNetwork() {
|
|||||||
const [networkSettings, setNetworkSettings] =
|
const [networkSettings, setNetworkSettings] =
|
||||||
useState<NetworkSettings>(defaultNetworkSettings);
|
useState<NetworkSettings>(defaultNetworkSettings);
|
||||||
|
|
||||||
|
const [macAddressInput, setMacAddressInput] = useState<string>("");
|
||||||
|
const macAddressTouched = useRef(false);
|
||||||
|
const initialMacAddress = useRef<string>("");
|
||||||
|
|
||||||
// We use this to determine whether the settings have changed
|
// We use this to determine whether the settings have changed
|
||||||
const firstNetworkSettings = useRef<NetworkSettings | undefined>(undefined);
|
const firstNetworkSettings = useRef<NetworkSettings | undefined>(undefined);
|
||||||
// We use this to indicate whether saved settings differ from initial (effective) settings
|
// We use this to indicate whether saved settings differ from initial (effective) settings
|
||||||
@@ -165,8 +172,37 @@ export default function SettingsNetwork() {
|
|||||||
});
|
});
|
||||||
}, [send, setNetworkState]);
|
}, [send, setNetworkState]);
|
||||||
|
|
||||||
|
const normalizeMacAddress = useCallback((value: string) => {
|
||||||
|
return value.trim().toLowerCase();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isValidMacAddress = useCallback((value: string) => {
|
||||||
|
const v = normalizeMacAddress(value);
|
||||||
|
return /^([0-9a-f]{2}:){5}[0-9a-f]{2}$/.test(v);
|
||||||
|
}, [normalizeMacAddress]);
|
||||||
|
|
||||||
const setNetworkSettingsRemote = useCallback(
|
const setNetworkSettingsRemote = useCallback(
|
||||||
(settings: NetworkSettings) => {
|
(settings: NetworkSettings) => {
|
||||||
|
const currentMac = (networkState?.mac_address || "").toLowerCase();
|
||||||
|
const newMac = normalizeMacAddress(macAddressInput);
|
||||||
|
const macChanged = newMac !== currentMac;
|
||||||
|
|
||||||
|
if (macChanged) {
|
||||||
|
if (!isValidMacAddress(macAddressInput)) {
|
||||||
|
notifications.error("Please enter a valid MAC address");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingMacAddress(newMac);
|
||||||
|
setShowMacChangeConfirm(true);
|
||||||
|
} else {
|
||||||
|
saveNetworkSettings(settings);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[networkState?.mac_address, macAddressInput, isValidMacAddress, normalizeMacAddress],
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveNetworkSettings = useCallback(
|
||||||
|
(settings: NetworkSettings, onSaved?: () => void) => {
|
||||||
setNetworkSettingsLoaded(false);
|
setNetworkSettingsLoaded(false);
|
||||||
send("setNetworkSettings", { settings }, resp => {
|
send("setNetworkSettings", { settings }, resp => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
@@ -177,12 +213,13 @@ export default function SettingsNetwork() {
|
|||||||
setNetworkSettingsLoaded(true);
|
setNetworkSettingsLoaded(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// We need to update the firstNetworkSettings ref to the new settings so we can use it to determine if the settings have changed
|
|
||||||
firstNetworkSettings.current = resp.result as NetworkSettings;
|
firstNetworkSettings.current = resp.result as NetworkSettings;
|
||||||
setNetworkSettings(resp.result as NetworkSettings);
|
setNetworkSettings(resp.result as NetworkSettings);
|
||||||
|
macAddressTouched.current = false;
|
||||||
getNetworkState();
|
getNetworkState();
|
||||||
setNetworkSettingsLoaded(true);
|
setNetworkSettingsLoaded(true);
|
||||||
notifications.success("Network settings saved");
|
notifications.success("Network settings saved");
|
||||||
|
onSaved?.();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[getNetworkState, send],
|
[getNetworkState, send],
|
||||||
@@ -203,6 +240,14 @@ export default function SettingsNetwork() {
|
|||||||
getNetworkSettings();
|
getNetworkSettings();
|
||||||
}, [getNetworkState, getNetworkSettings]);
|
}, [getNetworkState, getNetworkSettings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (networkState?.mac_address && initialMacAddress.current === "") {
|
||||||
|
const normalized = networkState.mac_address.toLowerCase();
|
||||||
|
setMacAddressInput(normalized);
|
||||||
|
initialMacAddress.current = normalized;
|
||||||
|
}
|
||||||
|
}, [networkState?.mac_address]);
|
||||||
|
|
||||||
const handleIpv4ModeChange = (value: IPv4Mode | string) => {
|
const handleIpv4ModeChange = (value: IPv4Mode | string) => {
|
||||||
const newMode = value as IPv4Mode;
|
const newMode = value as IPv4Mode;
|
||||||
const updatedSettings: NetworkSettings = { ...networkSettings, ipv4_mode: newMode };
|
const updatedSettings: NetworkSettings = { ...networkSettings, ipv4_mode: newMode };
|
||||||
@@ -265,6 +310,10 @@ export default function SettingsNetwork() {
|
|||||||
setNetworkSettings({ ...networkSettings, domain: value });
|
setNetworkSettings({ ...networkSettings, domain: value });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleProxyChange = (field: "http_proxy" | "https_proxy" | "all_proxy", value: string) => {
|
||||||
|
setNetworkSettings({ ...networkSettings, [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
const handleDomainOptionChange = (value: string) => {
|
const handleDomainOptionChange = (value: string) => {
|
||||||
setSelectedDomainOption(value);
|
setSelectedDomainOption(value);
|
||||||
if (value !== "custom") {
|
if (value !== "custom") {
|
||||||
@@ -290,6 +339,8 @@ export default function SettingsNetwork() {
|
|||||||
const [showRequestAddrConfirm, setShowRequestAddrConfirm] = useState(false);
|
const [showRequestAddrConfirm, setShowRequestAddrConfirm] = useState(false);
|
||||||
const [showApplyStaticConfirm, setShowApplyStaticConfirm] = useState(false);
|
const [showApplyStaticConfirm, setShowApplyStaticConfirm] = useState(false);
|
||||||
const [showIpv4RestartConfirm, setShowIpv4RestartConfirm] = useState(false);
|
const [showIpv4RestartConfirm, setShowIpv4RestartConfirm] = useState(false);
|
||||||
|
const [showMacChangeConfirm, setShowMacChangeConfirm] = useState(false);
|
||||||
|
const [pendingMacAddress, setPendingMacAddress] = useState("");
|
||||||
const [pendingIpv4Mode, setPendingIpv4Mode] = useState<IPv4Mode | null>(null);
|
const [pendingIpv4Mode, setPendingIpv4Mode] = useState<IPv4Mode | null>(null);
|
||||||
const [ipv4StaticDnsText, setIpv4StaticDnsText] = useState("");
|
const [ipv4StaticDnsText, setIpv4StaticDnsText] = useState("");
|
||||||
|
|
||||||
@@ -336,19 +387,13 @@ export default function SettingsNetwork() {
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={networkState?.mac_address}
|
value={macAddressInput}
|
||||||
readOnly={true}
|
onChange={e => {
|
||||||
|
macAddressTouched.current = true;
|
||||||
|
setMacAddressInput(e.target.value);
|
||||||
|
}}
|
||||||
className={isMobile ? "!w-full !h-[36px]" : "!w-[35%] !h-[36px]"}
|
className={isMobile ? "!w-full !h-[36px]" : "!w-[35%] !h-[36px]"}
|
||||||
/>
|
/>
|
||||||
{/*<InputField*/}
|
|
||||||
{/* type="text"*/}
|
|
||||||
{/* size="SM"*/}
|
|
||||||
{/* value={networkState?.mac_address}*/}
|
|
||||||
{/* error={""}*/}
|
|
||||||
{/* readOnly={true}*/}
|
|
||||||
{/* className="dark:!text-opacity-60 "*/}
|
|
||||||
{/*/>*/}
|
|
||||||
|
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -499,11 +544,53 @@ export default function SettingsNetwork() {
|
|||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SettingsItem
|
||||||
|
title={$at("HTTP Proxy")}
|
||||||
|
description={$at("Configure program HTTP proxy (optional)")}
|
||||||
|
className={`${isMobile ? "w-full flex-col" : ""}`}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={networkSettings.http_proxy || ""}
|
||||||
|
placeholder="http://127.0.0.1:7890"
|
||||||
|
onChange={e => handleProxyChange("http_proxy", e.target.value)}
|
||||||
|
className={isMobile ? "!w-full !h-[36px]" : "!w-[37%] !h-[36px]"}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
<SettingsItem
|
||||||
|
title={$at("HTTPS Proxy")}
|
||||||
|
description={$at("Configure program HTTPS proxy (optional)")}
|
||||||
|
className={`${isMobile ? "w-full flex-col" : ""}`}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={networkSettings.https_proxy || ""}
|
||||||
|
placeholder="http://127.0.0.1:7890"
|
||||||
|
onChange={e => handleProxyChange("https_proxy", e.target.value)}
|
||||||
|
className={isMobile ? "!w-full !h-[36px]" : "!w-[37%] !h-[36px]"}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
<SettingsItem
|
||||||
|
title={$at("ALL Proxy")}
|
||||||
|
description={$at("Configure program ALL proxy (optional)")}
|
||||||
|
className={`${isMobile ? "w-full flex-col" : ""}`}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={networkSettings.all_proxy || ""}
|
||||||
|
placeholder="socks5://127.0.0.1:7890"
|
||||||
|
onChange={e => handleProxyChange("all_proxy", e.target.value)}
|
||||||
|
className={isMobile ? "!w-full !h-[36px]" : "!w-[37%] !h-[36px]"}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
</div>
|
||||||
<AntdButton
|
<AntdButton
|
||||||
type="primary"
|
type="primary"
|
||||||
disabled={
|
disabled={
|
||||||
firstNetworkSettings.current === networkSettings ||
|
(!macAddressTouched.current && firstNetworkSettings.current === networkSettings) ||
|
||||||
(networkSettings.ipv4_mode === "static" && firstNetworkSettings.current?.ipv4_mode !== "static")
|
(networkSettings.ipv4_mode === "static" && firstNetworkSettings.current?.ipv4_mode !== "static") ||
|
||||||
|
(macAddressTouched.current && !isValidMacAddress(macAddressInput))
|
||||||
}
|
}
|
||||||
onClick={() => setNetworkSettingsRemote(networkSettings)}
|
onClick={() => setNetworkSettingsRemote(networkSettings)}
|
||||||
className={isMobile ? "w-full" : ""}
|
className={isMobile ? "w-full" : ""}
|
||||||
@@ -797,6 +884,31 @@ export default function SettingsNetwork() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={showMacChangeConfirm}
|
||||||
|
onClose={() => setShowMacChangeConfirm(false)}
|
||||||
|
title={$at("Change MAC Address?")}
|
||||||
|
description={$at("Changing the MAC address may cause the device IP to be reassigned and changed.")}
|
||||||
|
variant="warning"
|
||||||
|
confirmText={$at("Confirm")}
|
||||||
|
cancelText={$at("Cancel")}
|
||||||
|
onConfirm={() => {
|
||||||
|
setShowMacChangeConfirm(false);
|
||||||
|
send("setEthernetMacAddress", { macAddress: pendingMacAddress }, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
"Failed to apply MAC address: " +
|
||||||
|
(resp.error.data ? resp.error.data : resp.error.message),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNetworkState(resp.result as NetworkState);
|
||||||
|
saveNetworkSettings(networkSettings, () => {
|
||||||
|
initialMacAddress.current = pendingMacAddress;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { CheckCircleIcon } from "@heroicons/react/20/solid";
|
|||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
|
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
import { useBootStorageType } from "@/hooks/useBootStorage";
|
||||||
import { SettingsPageHeader } from "@components/Settings/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/Settings/SettingsPageheader";
|
||||||
import { SettingsItem } from "@components/Settings/SettingsView";
|
import { SettingsItem } from "@components/Settings/SettingsView";
|
||||||
import Card from "@components/Card";
|
import Card from "@components/Card";
|
||||||
@@ -14,12 +15,22 @@ import { Button } from "@components/Button";
|
|||||||
import { InputFieldWithLabel } from "@components/InputField";
|
import { InputFieldWithLabel } from "@components/InputField";
|
||||||
import { UpdateState, useDeviceStore, useUpdateStore } from "@/hooks/stores";
|
import { UpdateState, useDeviceStore, useUpdateStore } from "@/hooks/stores";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
|
import { formatters } from "@/utils";
|
||||||
|
|
||||||
export interface SystemVersionInfo {
|
export interface SystemVersionInfo {
|
||||||
local: { appVersion: string; systemVersion: string };
|
local: { appVersion: string; systemVersion: string };
|
||||||
remote?: { appVersion: string; systemVersion: string };
|
remote?: { appVersion: string; systemVersion: string };
|
||||||
systemUpdateAvailable: boolean;
|
systemUpdateAvailable: boolean;
|
||||||
appUpdateAvailable: boolean;
|
appUpdateAvailable: boolean;
|
||||||
|
appSignatureMissing?: boolean;
|
||||||
|
systemSignatureMissing?: boolean;
|
||||||
|
appSignatureAbsent?: boolean;
|
||||||
|
systemSignatureAbsent?: boolean;
|
||||||
|
appSignatureInvalid?: boolean;
|
||||||
|
systemSignatureInvalid?: boolean;
|
||||||
|
appNoPublicKey?: boolean;
|
||||||
|
systemNoPublicKey?: boolean;
|
||||||
|
signatureVerified?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,10 +44,20 @@ export default function SettingsVersion() {
|
|||||||
const [autoUpdate, setAutoUpdate] = useState(true);
|
const [autoUpdate, setAutoUpdate] = useState(true);
|
||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
const { setModalView, otaState } = useUpdateStore();
|
const { setModalView, otaState } = useUpdateStore();
|
||||||
|
const { bootStorageType } = useBootStorageType();
|
||||||
|
const isBootFromSD = bootStorageType === "sd";
|
||||||
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false);
|
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false);
|
||||||
|
const [signatureStatusLoading, setSignatureStatusLoading] = useState(true);
|
||||||
|
const [signatureStatus, setSignatureStatus] = useState<{
|
||||||
|
appSignatureAbsent: boolean;
|
||||||
|
appSignatureInvalid: boolean;
|
||||||
|
appNoPublicKey: boolean;
|
||||||
|
signatureVerified: boolean;
|
||||||
|
} | null>(null);
|
||||||
const updatePanelRef = useRef<HTMLDivElement | null>(null);
|
const updatePanelRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [updateSource, setUpdateSource] = useState("github");
|
const [updateSource, setUpdateSource] = useState("github");
|
||||||
const [customUpdateBaseURL, setCustomUpdateBaseURL] = useState("");
|
const [customUpdateBaseURL, setCustomUpdateBaseURL] = useState("");
|
||||||
|
const [updateDownloadProxy, setUpdateDownloadProxy] = useState("");
|
||||||
|
|
||||||
const currentVersions = useDeviceStore(state => {
|
const currentVersions = useDeviceStore(state => {
|
||||||
const { appVersion, systemVersion } = state;
|
const { appVersion, systemVersion } = state;
|
||||||
@@ -58,6 +79,13 @@ export default function SettingsVersion() {
|
|||||||
});
|
});
|
||||||
}, [send]);
|
}, [send]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
send("getUpdateDownloadProxy", {}, resp => {
|
||||||
|
if ("error" in resp) return;
|
||||||
|
setUpdateDownloadProxy(resp.result as string);
|
||||||
|
});
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
const handleAutoUpdateChange = (enabled: boolean) => {
|
const handleAutoUpdateChange = (enabled: boolean) => {
|
||||||
send("setAutoUpdateState", { enabled }, resp => {
|
send("setAutoUpdateState", { enabled }, resp => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
@@ -70,6 +98,23 @@ export default function SettingsVersion() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSignatureStatusLoading(true);
|
||||||
|
send("getSelfSignatureStatus", {}, resp => {
|
||||||
|
setSignatureStatusLoading(false);
|
||||||
|
if ("error" in resp) return;
|
||||||
|
const sigStatus = resp.result as {
|
||||||
|
appSignatureAbsent: boolean;
|
||||||
|
appSignatureInvalid: boolean;
|
||||||
|
appNoPublicKey: boolean;
|
||||||
|
};
|
||||||
|
const hasSigFiles = !sigStatus.appSignatureAbsent;
|
||||||
|
const noPublicKey = sigStatus.appNoPublicKey;
|
||||||
|
const signatureVerified = hasSigFiles && !noPublicKey && !sigStatus.appSignatureInvalid;
|
||||||
|
setSignatureStatus({ ...sigStatus, signatureVerified });
|
||||||
|
});
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
const applyUpdateSource = useCallback(
|
const applyUpdateSource = useCallback(
|
||||||
(source: string) => {
|
(source: string) => {
|
||||||
send("setUpdateSource", { source }, resp => {
|
send("setUpdateSource", { source }, resp => {
|
||||||
@@ -96,6 +141,18 @@ export default function SettingsVersion() {
|
|||||||
});
|
});
|
||||||
}, [customUpdateBaseURL, send]);
|
}, [customUpdateBaseURL, send]);
|
||||||
|
|
||||||
|
const applyUpdateDownloadProxy = useCallback(() => {
|
||||||
|
send("setUpdateDownloadProxy", { proxy: updateDownloadProxy }, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
`Failed to save update download proxy: ${resp.error.data || "Unknown error"}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifications.success("Update download proxy applied");
|
||||||
|
});
|
||||||
|
}, [send, updateDownloadProxy]);
|
||||||
|
|
||||||
const closeUpdateDialog = useCallback(() => {
|
const closeUpdateDialog = useCallback(() => {
|
||||||
setIsUpdateDialogOpen(false);
|
setIsUpdateDialogOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -181,6 +238,13 @@ export default function SettingsVersion() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SignatureStatusCard
|
||||||
|
signatureStatus={signatureStatus}
|
||||||
|
signatureStatusLoading={signatureStatusLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isBootFromSD && (
|
||||||
|
<>
|
||||||
<UpdateSourceSettings
|
<UpdateSourceSettings
|
||||||
updateSource={updateSource}
|
updateSource={updateSource}
|
||||||
onUpdateSourceChange={applyUpdateSource}
|
onUpdateSourceChange={applyUpdateSource}
|
||||||
@@ -194,6 +258,8 @@ export default function SettingsVersion() {
|
|||||||
{$at("Check for Updates")}
|
{$at("Check for Updates")}
|
||||||
</AntdButton>
|
</AntdButton>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="hidden">
|
<div className="hidden">
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
@@ -211,7 +277,14 @@ export default function SettingsVersion() {
|
|||||||
|
|
||||||
{isUpdateDialogOpen && (
|
{isUpdateDialogOpen && (
|
||||||
<div ref={updatePanelRef} className="pt-2">
|
<div ref={updatePanelRef} className="pt-2">
|
||||||
<UpdateContent onClose={closeUpdateDialog} onConfirmUpdate={onConfirmUpdate} />
|
<UpdateContent
|
||||||
|
onClose={closeUpdateDialog}
|
||||||
|
onConfirmUpdate={onConfirmUpdate}
|
||||||
|
updateSource={updateSource}
|
||||||
|
updateDownloadProxy={updateDownloadProxy}
|
||||||
|
onUpdateDownloadProxyChange={setUpdateDownloadProxy}
|
||||||
|
onSaveUpdateDownloadProxy={applyUpdateDownloadProxy}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -278,9 +351,17 @@ function UpdateSourceSettings({
|
|||||||
function UpdateContent({
|
function UpdateContent({
|
||||||
onClose,
|
onClose,
|
||||||
onConfirmUpdate,
|
onConfirmUpdate,
|
||||||
|
updateSource,
|
||||||
|
updateDownloadProxy,
|
||||||
|
onUpdateDownloadProxyChange,
|
||||||
|
onSaveUpdateDownloadProxy,
|
||||||
}: {
|
}: {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirmUpdate: () => void;
|
onConfirmUpdate: () => void;
|
||||||
|
updateSource: string;
|
||||||
|
updateDownloadProxy: string;
|
||||||
|
onUpdateDownloadProxyChange: (proxy: string) => void;
|
||||||
|
onSaveUpdateDownloadProxy: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
|
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
|
||||||
const { modalView, setModalView, otaState } = useUpdateStore();
|
const { modalView, setModalView, otaState } = useUpdateStore();
|
||||||
@@ -324,6 +405,10 @@ function UpdateContent({
|
|||||||
onConfirmUpdate={onConfirmUpdate}
|
onConfirmUpdate={onConfirmUpdate}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
versionInfo={versionInfo!}
|
versionInfo={versionInfo!}
|
||||||
|
updateSource={updateSource}
|
||||||
|
updateDownloadProxy={updateDownloadProxy}
|
||||||
|
onUpdateDownloadProxyChange={onUpdateDownloadProxyChange}
|
||||||
|
onSaveUpdateDownloadProxy={onSaveUpdateDownloadProxy}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -335,6 +420,7 @@ function UpdateContent({
|
|||||||
<SystemUpToDateState
|
<SystemUpToDateState
|
||||||
checkUpdate={() => setModalView("loading")}
|
checkUpdate={() => setModalView("loading")}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
versionInfo={versionInfo}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -360,23 +446,58 @@ function LoadingState({
|
|||||||
|
|
||||||
const getVersionInfo = useCallback(() => {
|
const getVersionInfo = useCallback(() => {
|
||||||
return new Promise<SystemVersionInfo>((resolve, reject) => {
|
return new Promise<SystemVersionInfo>((resolve, reject) => {
|
||||||
send("getUpdateStatus", {}, async resp => {
|
Promise.all([
|
||||||
|
new Promise<SystemVersionInfo>((res, rej) => {
|
||||||
|
send("getUpdateStatus", {}, resp => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(`Failed to check for updates: ${resp.error}`);
|
notifications.error(`Failed to check for updates: ${resp.error}`);
|
||||||
reject(new Error("Failed to check for updates"));
|
rej(new Error("Failed to check for updates"));
|
||||||
} else {
|
} else {
|
||||||
const result = resp.result as SystemVersionInfo;
|
const result = resp.result as SystemVersionInfo;
|
||||||
setAppVersion(result.local.appVersion);
|
setAppVersion(result.local.appVersion);
|
||||||
setSystemVersion(result.local.systemVersion);
|
setSystemVersion(result.local.systemVersion);
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
notifications.error(`Failed to check for updates: ${result.error}`);
|
notifications.error(`Failed to check for updates: ${result.error}`);
|
||||||
reject(new Error("Failed to check for updates"));
|
rej(new Error("Failed to check for updates"));
|
||||||
} else {
|
} else {
|
||||||
resolve(result);
|
res(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}),
|
||||||
|
new Promise<SystemVersionInfo>((res, rej) => {
|
||||||
|
send("getSelfSignatureStatus", {}, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
rej(new Error("Failed to get signature status"));
|
||||||
|
} else {
|
||||||
|
const sigStatus = resp.result as {
|
||||||
|
appSignatureAbsent: boolean;
|
||||||
|
appSignatureInvalid: boolean;
|
||||||
|
appNoPublicKey: boolean;
|
||||||
|
};
|
||||||
|
const hasSigFiles = !sigStatus.appSignatureAbsent;
|
||||||
|
const signatureVerified = hasSigFiles && !sigStatus.appNoPublicKey && !sigStatus.appSignatureInvalid;
|
||||||
|
const partial: Partial<SystemVersionInfo> = {
|
||||||
|
appSignatureAbsent: sigStatus.appSignatureAbsent,
|
||||||
|
appSignatureInvalid: sigStatus.appSignatureInvalid,
|
||||||
|
appNoPublicKey: sigStatus.appNoPublicKey,
|
||||||
|
signatureVerified,
|
||||||
|
};
|
||||||
|
res(partial as SystemVersionInfo);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
.then(([versionResult, sigResult]) => {
|
||||||
|
resolve({
|
||||||
|
...versionResult,
|
||||||
|
appSignatureAbsent: sigResult.appSignatureAbsent,
|
||||||
|
appSignatureInvalid: sigResult.appSignatureInvalid,
|
||||||
|
appNoPublicKey: sigResult.appNoPublicKey,
|
||||||
|
signatureVerified: sigResult.signatureVerified,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(reject);
|
||||||
});
|
});
|
||||||
}, [send, setAppVersion, setSystemVersion]);
|
}, [send, setAppVersion, setSystemVersion]);
|
||||||
|
|
||||||
@@ -482,11 +603,14 @@ function UpdatingDeviceState({
|
|||||||
const downloadFinishedAt = otaState[`${type}DownloadFinishedAt`];
|
const downloadFinishedAt = otaState[`${type}DownloadFinishedAt`];
|
||||||
const verfiedAt = otaState[`${type}VerifiedAt`];
|
const verfiedAt = otaState[`${type}VerifiedAt`];
|
||||||
const updatedAt = otaState[`${type}UpdatedAt`];
|
const updatedAt = otaState[`${type}UpdatedAt`];
|
||||||
|
const downloadSpeedBps = (otaState as any)[`${type}DownloadSpeedBps`] as number | undefined;
|
||||||
|
const formattedSpeed =
|
||||||
|
downloadSpeedBps && downloadSpeedBps > 0 ? `${formatters.bytes(downloadSpeedBps, 1)}/s` : null;
|
||||||
|
|
||||||
if (!otaState.metadataFetchedAt) {
|
if (!otaState.metadataFetchedAt) {
|
||||||
return "Fetching update information...";
|
return "Fetching update information...";
|
||||||
} else if (!downloadFinishedAt) {
|
} else if (!downloadFinishedAt) {
|
||||||
return `Downloading ${type} update...`;
|
return formattedSpeed ? `Downloading ${type} update... (${formattedSpeed})` : `Downloading ${type} update...`;
|
||||||
} else if (!verfiedAt) {
|
} else if (!verfiedAt) {
|
||||||
return `Verifying ${type} update...`;
|
return `Verifying ${type} update...`;
|
||||||
} else if (!updatedAt) {
|
} else if (!updatedAt) {
|
||||||
@@ -619,11 +743,50 @@ function UpdatingDeviceState({
|
|||||||
function SystemUpToDateState({
|
function SystemUpToDateState({
|
||||||
checkUpdate,
|
checkUpdate,
|
||||||
onClose,
|
onClose,
|
||||||
|
versionInfo,
|
||||||
}: {
|
}: {
|
||||||
checkUpdate: () => void;
|
checkUpdate: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
versionInfo: SystemVersionInfo | null;
|
||||||
}) {
|
}) {
|
||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
|
const [send] = useJsonRpc();
|
||||||
|
const [sigUpdateLoading, setSigUpdateLoading] = useState(false);
|
||||||
|
const [sigUpdateResult, setSigUpdateResult] = useState<string | null>(null);
|
||||||
|
const hasAbsentSig = versionInfo?.appSignatureAbsent;
|
||||||
|
const hasInvalidSig = versionInfo?.appSignatureInvalid;
|
||||||
|
const hasNoPublicKey = versionInfo?.appNoPublicKey;
|
||||||
|
|
||||||
|
const handleUpdateSignatures = useCallback(() => {
|
||||||
|
setSigUpdateLoading(true);
|
||||||
|
setSigUpdateResult(null);
|
||||||
|
send("updateSignatures", {}, resp => {
|
||||||
|
setSigUpdateLoading(false);
|
||||||
|
if ("error" in resp) {
|
||||||
|
setSigUpdateResult(`Failed: ${resp.error.data || "Unknown error"}`);
|
||||||
|
notifications.error(`Signature update failed: ${resp.error.data || "Unknown error"}`);
|
||||||
|
} else {
|
||||||
|
const result = resp.result as {
|
||||||
|
appSignatureUpdated: boolean;
|
||||||
|
systemSignatureUpdated: boolean;
|
||||||
|
appSignatureValid: boolean;
|
||||||
|
systemSignatureValid: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
if (result.error) {
|
||||||
|
setSigUpdateResult(`Failed: ${result.error}`);
|
||||||
|
notifications.error(`Signature update failed: ${result.error}`);
|
||||||
|
} else {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (result.appSignatureUpdated) parts.push("App signature updated");
|
||||||
|
if (result.systemSignatureUpdated) parts.push("System signature updated");
|
||||||
|
setSigUpdateResult(parts.join(", ") || "No signatures to update");
|
||||||
|
notifications.success("Signatures updated successfully");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
@@ -634,6 +797,39 @@ function SystemUpToDateState({
|
|||||||
{$at("Your system is running the latest version. No updates are currently available.")}
|
{$at("Your system is running the latest version. No updates are currently available.")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{hasAbsentSig && (
|
||||||
|
<div className="mt-4 rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
|
||||||
|
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||||
|
{$at("Missing Signature File")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
|
||||||
|
{$at("The current firmware is missing signature files. Integrity cannot be fully verified.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasInvalidSig && (
|
||||||
|
<div className="mt-4 rounded-md border border-red-500 bg-red-50 p-3 dark:border-red-600 dark:bg-red-900/30">
|
||||||
|
<p className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||||
|
{$at("Signature Verification Failed")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-red-700 dark:text-red-300">
|
||||||
|
{$at("The signature file exists but does not match the firmware. This may indicate tampering.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasNoPublicKey && (
|
||||||
|
<div className="mt-4 rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
|
||||||
|
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||||
|
{$at("No Embedded Public Key")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
|
||||||
|
{$at("This build does not have an OTA public key embedded. Signature verification is unavailable.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-4 flex gap-x-2">
|
<div className="mt-4 flex gap-x-2">
|
||||||
<AntdButton type="primary" onClick={checkUpdate}>
|
<AntdButton type="primary" onClick={checkUpdate}>
|
||||||
{$at("Check Again")}
|
{$at("Check Again")}
|
||||||
@@ -642,6 +838,32 @@ function SystemUpToDateState({
|
|||||||
{$at("Back")}
|
{$at("Back")}
|
||||||
</AntdButton>
|
</AntdButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="text-base font-semibold text-black dark:text-white">
|
||||||
|
{$at("Update Signatures")}
|
||||||
|
</p>
|
||||||
|
<p className="mb-2 text-sm text-slate-600 dark:text-slate-300">
|
||||||
|
{$at("Update the signature of kvm_app to the latest version. If the current version is not up to date, signature verification will fail.")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{sigUpdateResult && (
|
||||||
|
<div className="rounded-md border border-blue-500 bg-blue-50 p-3 dark:border-blue-600 dark:bg-blue-900/30">
|
||||||
|
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">
|
||||||
|
{$at("Signature Update Result")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-blue-700 dark:text-blue-300">
|
||||||
|
{sigUpdateResult}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-start gap-x-2">
|
||||||
|
<AntdButton type="primary" loading={sigUpdateLoading} onClick={handleUpdateSignatures}>
|
||||||
|
{$at("Update")}
|
||||||
|
</AntdButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -651,12 +873,54 @@ function UpdateAvailableState({
|
|||||||
versionInfo,
|
versionInfo,
|
||||||
onConfirmUpdate,
|
onConfirmUpdate,
|
||||||
onClose,
|
onClose,
|
||||||
|
updateSource,
|
||||||
|
updateDownloadProxy,
|
||||||
|
onUpdateDownloadProxyChange,
|
||||||
|
onSaveUpdateDownloadProxy,
|
||||||
}: {
|
}: {
|
||||||
versionInfo: SystemVersionInfo;
|
versionInfo: SystemVersionInfo;
|
||||||
onConfirmUpdate: () => void;
|
onConfirmUpdate: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
updateSource: string;
|
||||||
|
updateDownloadProxy: string;
|
||||||
|
onUpdateDownloadProxyChange: (proxy: string) => void;
|
||||||
|
onSaveUpdateDownloadProxy: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
|
const [send] = useJsonRpc();
|
||||||
|
const [sigUpdateLoading, setSigUpdateLoading] = useState(false);
|
||||||
|
const [sigUpdateResult, setSigUpdateResult] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleUpdateSignatures = useCallback(() => {
|
||||||
|
setSigUpdateLoading(true);
|
||||||
|
setSigUpdateResult(null);
|
||||||
|
send("updateSignatures", {}, resp => {
|
||||||
|
setSigUpdateLoading(false);
|
||||||
|
if ("error" in resp) {
|
||||||
|
setSigUpdateResult(`Failed: ${resp.error.data || "Unknown error"}`);
|
||||||
|
notifications.error(`Signature update failed: ${resp.error.data || "Unknown error"}`);
|
||||||
|
} else {
|
||||||
|
const result = resp.result as {
|
||||||
|
appSignatureUpdated: boolean;
|
||||||
|
systemSignatureUpdated: boolean;
|
||||||
|
appSignatureValid: boolean;
|
||||||
|
systemSignatureValid: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
if (result.error) {
|
||||||
|
setSigUpdateResult(`Failed: ${result.error}`);
|
||||||
|
notifications.error(`Signature update failed: ${result.error}`);
|
||||||
|
} else {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (result.appSignatureUpdated) parts.push("App signature updated");
|
||||||
|
if (result.systemSignatureUpdated) parts.push("System signature updated");
|
||||||
|
setSigUpdateResult(parts.join(", ") || "No signatures to update");
|
||||||
|
notifications.success("Signatures updated successfully");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||||
<div className="w-full space-y-4">
|
<div className="w-full space-y-4">
|
||||||
@@ -681,6 +945,21 @@ function UpdateAvailableState({
|
|||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{updateSource === "github" && (
|
||||||
|
<div className="mb-4 flex items-end gap-x-2">
|
||||||
|
<InputFieldWithLabel
|
||||||
|
size="SM"
|
||||||
|
label={$at("Download Proxy Prefix")}
|
||||||
|
value={updateDownloadProxy}
|
||||||
|
onChange={e => onUpdateDownloadProxyChange(e.target.value)}
|
||||||
|
placeholder="https://gh-proxy.com/"
|
||||||
|
/>
|
||||||
|
<AntdButton type="primary" onClick={onSaveUpdateDownloadProxy}>
|
||||||
|
{$at("Apply")}
|
||||||
|
</AntdButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-start gap-x-2">
|
<div className="flex items-center justify-start gap-x-2">
|
||||||
<AntdButton type="primary" onClick={onConfirmUpdate}>
|
<AntdButton type="primary" onClick={onConfirmUpdate}>
|
||||||
@@ -691,6 +970,32 @@ function UpdateAvailableState({
|
|||||||
</AntdButton>
|
</AntdButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="text-base font-semibold text-black dark:text-white">
|
||||||
|
{$at("Update Signatures")}
|
||||||
|
</p>
|
||||||
|
<p className="mb-2 text-sm text-slate-600 dark:text-slate-300">
|
||||||
|
{$at("Update the signature of kvm_app to the latest version. If the current version is not up to date, signature verification will fail.")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{sigUpdateResult && (
|
||||||
|
<div className="rounded-md border border-blue-500 bg-blue-50 p-3 dark:border-blue-600 dark:bg-blue-900/30">
|
||||||
|
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">
|
||||||
|
{$at("Signature Update Result")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-blue-700 dark:text-blue-300">
|
||||||
|
{sigUpdateResult}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-start gap-x-2">
|
||||||
|
<AntdButton type="primary" loading={sigUpdateLoading} onClick={handleUpdateSignatures}>
|
||||||
|
{$at("Update")}
|
||||||
|
</AntdButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -752,3 +1057,101 @@ function UpdateErrorState({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SignatureStatusCard({
|
||||||
|
signatureStatus,
|
||||||
|
signatureStatusLoading,
|
||||||
|
}: {
|
||||||
|
signatureStatus: {
|
||||||
|
appSignatureAbsent: boolean;
|
||||||
|
appSignatureInvalid: boolean;
|
||||||
|
appNoPublicKey: boolean;
|
||||||
|
signatureVerified: boolean;
|
||||||
|
} | null;
|
||||||
|
signatureStatusLoading: boolean;
|
||||||
|
}) {
|
||||||
|
const { $at } = useReactAt();
|
||||||
|
if (signatureStatusLoading) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-slate-300 bg-slate-50 p-3 dark:border-slate-600 dark:bg-slate-900/30">
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<LoadingSpinner className="h-4 w-4 text-[rgba(22,152,217,1)] dark:text-[rgba(45,106,229,1)]" />
|
||||||
|
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
|
||||||
|
{$at("Verifying signature...")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-slate-600 dark:text-slate-300">
|
||||||
|
{$at("Please wait while verifying firmware signature.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signatureStatus) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
|
||||||
|
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||||
|
{$at("Signature Status Unavailable")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
|
||||||
|
{$at("Unable to retrieve signature verification status.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signatureStatus.signatureVerified) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-green-500 bg-green-50 p-3 dark:border-green-600 dark:bg-green-900/30">
|
||||||
|
<p className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||||
|
<CheckCircleIcon className="inline h-4 w-4 mr-1" />
|
||||||
|
{$at("Signature Verified")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-green-700 dark:text-green-300">
|
||||||
|
{$at("Firmware signature has been verified and is valid.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signatureStatus.appSignatureAbsent) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
|
||||||
|
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||||
|
{$at("Missing Signature File")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
|
||||||
|
{$at("The current firmware is missing signature files. Integrity cannot be fully verified.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signatureStatus.appSignatureInvalid) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-red-500 bg-red-50 p-3 dark:border-red-600 dark:bg-red-900/30">
|
||||||
|
<p className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||||
|
{$at("Signature Verification Failed")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-red-700 dark:text-red-300">
|
||||||
|
{$at("The signature file exists but does not match the firmware. This may indicate tampering.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signatureStatus.appNoPublicKey) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
|
||||||
|
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||||
|
{$at("No Embedded Public Key")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
|
||||||
|
{$at("This build does not have an OTA public key embedded. Signature verification is unavailable.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
|
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
|
||||||
import { useClose } from "@headlessui/react";
|
import { useClose } from "@headlessui/react";
|
||||||
import { Checkbox, Button } from "antd";
|
import { Checkbox, Button, Input } from "antd";
|
||||||
import { useReactAt } from "i18n-auto-extractor/react";
|
import { useReactAt } from "i18n-auto-extractor/react";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
|
|
||||||
import { TextAreaWithLabel } from "@components/TextArea";
|
import { TextAreaWithLabel } from "@components/TextArea";
|
||||||
|
import { SettingsItem } from "@components/Settings/SettingsView";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { useHidStore, useRTCStore, useUiStore, useSettingsStore } from "@/hooks/stores";
|
import { useHidStore, useRTCStore, useUiStore, useSettingsStore, useVideoStore } from "@/hooks/stores";
|
||||||
import { keys, modifiers } from "@/keyboardMappings";
|
import { keys, modifiers } from "@/keyboardMappings";
|
||||||
import { layouts, chars } from "@/keyboardLayouts";
|
import { layouts, chars } from "@/keyboardLayouts";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
|
import { eventMatchesShortcut, shortcutFromKeyboardEvent } from "@/utils/shortcuts";
|
||||||
|
|
||||||
const hidKeyboardPayload = (keys: number[], modifier: number) => {
|
const hidKeyboardPayload = (keys: number[], modifier: number) => {
|
||||||
return { keys, modifier };
|
return { keys, modifier };
|
||||||
@@ -28,15 +30,24 @@ export default function Clipboard() {
|
|||||||
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||||
const setSidebarView = useUiStore(state => state.setSidebarView);
|
const setSidebarView = useUiStore(state => state.setSidebarView);
|
||||||
const toggleTopBarView = useUiStore(state => state.toggleTopBarView);
|
const toggleTopBarView = useUiStore(state => state.toggleTopBarView);
|
||||||
|
const isOcrMode = useUiStore(state => state.isOcrMode);
|
||||||
|
const setOcrMode = useUiStore(state => state.setOcrMode);
|
||||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||||
|
const videoWidth = useVideoStore(state => state.width);
|
||||||
|
const videoHeight = useVideoStore(state => state.height);
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||||
|
|
||||||
const [invalidChars, setInvalidChars] = useState<string[]>([]);
|
const [invalidChars, setInvalidChars] = useState<string[]>([]);
|
||||||
const close = useClose();
|
const close = useClose();
|
||||||
const overrideCtrlV = useSettingsStore(state => state.overrideCtrlV);
|
const pasteShortcutEnabled = useSettingsStore(state => state.pasteShortcutEnabled);
|
||||||
const setOverrideCtrlV = useSettingsStore(state => state.setOverrideCtrlV);
|
const setPasteShortcutEnabled = useSettingsStore(state => state.setPasteShortcutEnabled);
|
||||||
const [pasteBuffer, setPasteBuffer] = useState<string>("");
|
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);
|
const [readyToRender, setReadyToRender] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -127,7 +138,6 @@ export default function Clipboard() {
|
|||||||
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, safeKeyboardLayout]);
|
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, safeKeyboardLayout]);
|
||||||
|
|
||||||
const handleTextSend = useCallback(async (text: string) => {
|
const handleTextSend = useCallback(async (text: string) => {
|
||||||
setPasteBuffer(text);
|
|
||||||
const segInvalid = [
|
const segInvalid = [
|
||||||
...new Set(
|
...new Set(
|
||||||
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
|
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
|
||||||
@@ -192,12 +202,35 @@ export default function Clipboard() {
|
|||||||
}, [handleTextSend]);
|
}, [handleTextSend]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// When overrideCtrlV is true, we want to focus the container div to capture paste events
|
if (readyToRender && TextAreaRef.current) {
|
||||||
// When it is false, we want to focus the textarea if it exists
|
|
||||||
if (!overrideCtrlV && TextAreaRef.current) {
|
|
||||||
TextAreaRef.current.focus();
|
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 (
|
return (
|
||||||
<div className="space-y-4 py-3" >
|
<div className="space-y-4 py-3" >
|
||||||
@@ -205,32 +238,42 @@ export default function Clipboard() {
|
|||||||
<div className="h-full space-y-4">
|
<div className="h-full space-y-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
||||||
<div className="flex items-center">
|
<div className="grid grid-cols-[minmax(0,1fr)_140px] items-center gap-2 sm:grid-cols-[minmax(0,1fr)_180px]">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={overrideCtrlV}
|
className="min-w-0"
|
||||||
onChange={e => setOverrideCtrlV(e.target.checked)}
|
checked={pasteShortcutEnabled}
|
||||||
|
onChange={e => setPasteShortcutEnabled(e.target.checked)}
|
||||||
>
|
>
|
||||||
{$at("Use Ctrl+V to paste clipboard to remote")}
|
<span className="whitespace-normal break-words">
|
||||||
|
{$at("Enable paste shortcut")}
|
||||||
|
</span>
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
value={pasteShortcut}
|
||||||
|
onKeyDown={handleShortcutInput(setPasteShortcut)}
|
||||||
|
onChange={() => void 0}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full px-1 outline-none"
|
<div className="w-full px-1 outline-none"
|
||||||
tabIndex={overrideCtrlV ? 0 : -1}
|
tabIndex={pasteShortcutEnabled ? 0 : -1}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
if (el && overrideCtrlV && readyToRender) {
|
if (el && pasteShortcutEnabled && readyToRender) {
|
||||||
el.focus();
|
el.focus();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onKeyUp={e => e.stopPropagation()}
|
onKeyUp={e => e.stopPropagation()}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (overrideCtrlV && (e.key.toLowerCase() === "v" || e.code === "KeyV") && (e.metaKey || e.ctrlKey)) {
|
if (pasteShortcutEnabled && eventMatchesShortcut(e.nativeEvent, pasteShortcut)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
readClipboardToBufferAndSend();
|
readClipboardToBufferAndSend();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onPaste={e => {
|
onPaste={e => {
|
||||||
if (overrideCtrlV) {
|
if (pasteShortcutEnabled) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const txt = e.clipboardData?.getData("text") || "";
|
const txt = e.clipboardData?.getData("text") || "";
|
||||||
if (txt) {
|
if (txt) {
|
||||||
@@ -240,7 +283,7 @@ export default function Clipboard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
{!overrideCtrlV && readyToRender && <TextAreaWithLabel
|
{readyToRender && <TextAreaWithLabel
|
||||||
ref={TextAreaRef}
|
ref={TextAreaRef}
|
||||||
label={$at("Copy text from your client to the remote host")}
|
label={$at("Copy text from your client to the remote host")}
|
||||||
rows={4}
|
rows={4}
|
||||||
@@ -295,32 +338,50 @@ export default function Clipboard() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<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={{
|
style={{
|
||||||
animationDuration: "0.7s",
|
animationDuration: "0.7s",
|
||||||
animationDelay: "0.2s",
|
animationDelay: "0.2s",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
||||||
type="primary"
|
type="primary"
|
||||||
className={isMobile ? "w-[49%]" : ""}
|
className="w-full"
|
||||||
onClick={onConfirmPaste}
|
onClick={onConfirmPaste}
|
||||||
>
|
>
|
||||||
{$at("Confirm paste")}</Button>
|
{$at("Confirm paste")}</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
|
<Button
|
||||||
className={isMobile ? "w-[49%]" : ""}
|
type="primary"
|
||||||
onClick={() => {
|
className={`${isMobile ? "w-full" : ""}`}
|
||||||
onCancelPasteMode();
|
onClick={handleOpenOcr}
|
||||||
close();
|
>
|
||||||
if(isMobile){
|
{$at("Open")}
|
||||||
toggleTopBarView("ClipboardMobile");
|
</Button>
|
||||||
}else{
|
</SettingsItem>
|
||||||
setSidebarView(null)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>{$at("Cancel")}</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ const SettingsMacrosEdit: React.FC<MenuComponentProps> = ({ onMenuSelect,macroId
|
|||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div onKeyUp={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}>
|
||||||
<MacroForm
|
<MacroForm
|
||||||
initialData={macro}
|
initialData={macro}
|
||||||
onSubmit={handleUpdateMacro}
|
onSubmit={handleUpdateMacro}
|
||||||
@@ -125,8 +126,8 @@ const SettingsMacrosEdit: React.FC<MenuComponentProps> = ({ onMenuSelect,macroId
|
|||||||
onMenuSelect("index");
|
onMenuSelect("index");
|
||||||
}}
|
}}
|
||||||
isSubmitting={isUpdating}
|
isSubmitting={isUpdating}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={showDeleteConfirm}
|
open={showDeleteConfirm}
|
||||||
|
|||||||
@@ -65,7 +65,10 @@ interface StorageFilePageProps {
|
|||||||
showSDManagement?: boolean;
|
showSDManagement?: boolean;
|
||||||
onResetSDStorage?: () => void;
|
onResetSDStorage?: () => void;
|
||||||
onUnmountSDStorage?: () => void;
|
onUnmountSDStorage?: () => void;
|
||||||
|
onFormatSDStorage?: () => void;
|
||||||
onMountSDStorage?: () => void;
|
onMountSDStorage?: () => void;
|
||||||
|
fsType?: 'exfat' | 'fat32';
|
||||||
|
onFsTypeChange?: (value: 'exfat' | 'fat32') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FileManager: React.FC<StorageFilePageProps> = ({
|
export const FileManager: React.FC<StorageFilePageProps> = ({
|
||||||
@@ -77,6 +80,9 @@ export const FileManager: React.FC<StorageFilePageProps> = ({
|
|||||||
showSDManagement = false,
|
showSDManagement = false,
|
||||||
onResetSDStorage,
|
onResetSDStorage,
|
||||||
onUnmountSDStorage,
|
onUnmountSDStorage,
|
||||||
|
onFormatSDStorage,
|
||||||
|
fsType,
|
||||||
|
onFsTypeChange,
|
||||||
}) => {
|
}) => {
|
||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
|
|
||||||
@@ -212,6 +218,16 @@ export const FileManager: React.FC<StorageFilePageProps> = ({
|
|||||||
}
|
}
|
||||||
}, [onResetSDStorage, syncStorage]);
|
}, [onResetSDStorage, syncStorage]);
|
||||||
|
|
||||||
|
const handleFormatWrapper = useCallback(async () => {
|
||||||
|
if (onFormatSDStorage) {
|
||||||
|
setLoading(true);
|
||||||
|
await onFormatSDStorage();
|
||||||
|
setSDMountStatus(null);
|
||||||
|
syncStorage();
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [onFormatSDStorage, syncStorage]);
|
||||||
|
|
||||||
if (mediaType === "sd" && sdMountStatus && sdMountStatus !== "ok") {
|
if (mediaType === "sd" && sdMountStatus && sdMountStatus !== "ok") {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl py-8">
|
<div className="mx-auto max-w-4xl py-8">
|
||||||
@@ -237,6 +253,32 @@ export const FileManager: React.FC<StorageFilePageProps> = ({
|
|||||||
? $at("Please insert an SD card and try again.")
|
? $at("Please insert an SD card and try again.")
|
||||||
: $at("Please format the SD card and try again.")}
|
: $at("Please format the SD card and try again.")}
|
||||||
</p>
|
</p>
|
||||||
|
{sdMountStatus !== "none" && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<p className="w-full text-left text-xs text-slate-700 dark:text-slate-300">
|
||||||
|
{$at("Choose the file system for MicroSD formatting")}
|
||||||
|
</p>
|
||||||
|
<select
|
||||||
|
value={fsType || "fat32"}
|
||||||
|
onChange={(e) => onFsTypeChange?.(e.target.value as 'exfat' | 'fat32')}
|
||||||
|
style={{ width: "100%", padding: "8px", borderRadius: "4px" }}
|
||||||
|
>
|
||||||
|
<option value="fat32">FAT32</option>
|
||||||
|
<option value="exfat">exFAT</option>
|
||||||
|
</select>
|
||||||
|
<AntdButton
|
||||||
|
disabled={loading}
|
||||||
|
danger={true}
|
||||||
|
type="primary"
|
||||||
|
onClick={handleFormatWrapper}
|
||||||
|
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
|
||||||
|
>
|
||||||
|
{$at("Format MicroSD Card")} ({(fsType || "fat32")})
|
||||||
|
</AntdButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -291,7 +333,10 @@ export const FileManager: React.FC<StorageFilePageProps> = ({
|
|||||||
showSDManagement={showSDManagement}
|
showSDManagement={showSDManagement}
|
||||||
onNewImageClick={handleNewImageClick}
|
onNewImageClick={handleNewImageClick}
|
||||||
onUnmountSDStorage={handleUnmountWrapper}
|
onUnmountSDStorage={handleUnmountWrapper}
|
||||||
|
onFormatSDStorage={handleFormatWrapper}
|
||||||
syncStorage={syncStorage}
|
syncStorage={syncStorage}
|
||||||
|
fsType={fsType}
|
||||||
|
onFsTypeChange={onFsTypeChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{uploadFile ? (
|
{uploadFile ? (
|
||||||
@@ -476,7 +521,10 @@ interface ActionButtonsSectionProps {
|
|||||||
showSDManagement?: boolean;
|
showSDManagement?: boolean;
|
||||||
onNewImageClick: (incompleteFileName?: string) => void;
|
onNewImageClick: (incompleteFileName?: string) => void;
|
||||||
onUnmountSDStorage?: () => void;
|
onUnmountSDStorage?: () => void;
|
||||||
|
onFormatSDStorage?: () => void;
|
||||||
syncStorage: () => void;
|
syncStorage: () => void;
|
||||||
|
fsType?: 'exfat' | 'fat32';
|
||||||
|
onFsTypeChange?: (value: 'exfat' | 'fat32') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ActionButtonsSection: React.FC<ActionButtonsSectionProps> = ({
|
const ActionButtonsSection: React.FC<ActionButtonsSectionProps> = ({
|
||||||
@@ -484,15 +532,37 @@ const ActionButtonsSection: React.FC<ActionButtonsSectionProps> = ({
|
|||||||
loading,
|
loading,
|
||||||
showSDManagement,
|
showSDManagement,
|
||||||
onUnmountSDStorage,
|
onUnmountSDStorage,
|
||||||
|
onFormatSDStorage,
|
||||||
|
fsType,
|
||||||
|
onFsTypeChange,
|
||||||
}) => {
|
}) => {
|
||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
|
|
||||||
if (mediaType === "sd" && showSDManagement) {
|
if (mediaType === "sd" && showSDManagement) {
|
||||||
return (
|
return (
|
||||||
<div className="flex animate-fadeIn justify-between opacity-0"
|
<div className="animate-fadeIn space-y-2 opacity-0"
|
||||||
style={{ animationDuration: "0.7s", animationDelay: "0.25s" }}
|
style={{ animationDuration: "0.7s", animationDelay: "0.25s" }}
|
||||||
>
|
>
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<p className="w-full text-left text-xs text-slate-700 dark:text-slate-300">
|
||||||
|
{$at("Choose the file system for MicroSD formatting")}
|
||||||
|
</p>
|
||||||
|
<select
|
||||||
|
value={fsType || "fat32"}
|
||||||
|
onChange={(e) => onFsTypeChange?.(e.target.value as 'exfat' | 'fat32')}
|
||||||
|
style={{ width: "100%", padding: "8px", borderRadius: "4px" }}
|
||||||
|
>
|
||||||
|
<option value="fat32">FAT32</option>
|
||||||
|
<option value="exfat">exFAT</option>
|
||||||
|
</select>
|
||||||
|
<AntdButton
|
||||||
|
disabled={loading}
|
||||||
|
type="primary"
|
||||||
|
danger={true}
|
||||||
|
onClick={onFormatSDStorage}
|
||||||
|
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
|
||||||
|
>{$at("Format MicroSD Card")} ({(fsType || "fat32")})</AntdButton>
|
||||||
|
</div>
|
||||||
<AntdButton
|
<AntdButton
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
type="primary"
|
type="primary"
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useReactAt } from "i18n-auto-extractor/react";
|
||||||
|
|
||||||
import { FileManager } from "@/layout/components_side/SharedFolders/FileManager";
|
import { FileManager } from "@/layout/components_side/SharedFolders/FileManager";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
|
||||||
export default function SDFilePage() {
|
export default function SDFilePage() {
|
||||||
|
const { $at } = useReactAt();
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [fsType, setFsType] = useState<'exfat' | 'fat32'>('fat32');
|
||||||
|
|
||||||
const handleResetSDStorage = async () => {
|
const handleResetSDStorage = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -34,7 +37,25 @@ export default function SDFilePage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFormatSDStorage = async () => {
|
||||||
|
if (!window.confirm($at(`Formatting the SD card as ${fsType.toUpperCase()} will erase all data. Continue?`))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
send("formatSDStorage", { confirm: true, fsType }, res => {
|
||||||
|
if ("error" in res) {
|
||||||
|
notifications.error(res.error.data || res.error.message);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifications.success($at("SD card formatted successfully"));
|
||||||
|
});
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<FileManager
|
<FileManager
|
||||||
mediaType="sd"
|
mediaType="sd"
|
||||||
returnTo="/sd-files"
|
returnTo="/sd-files"
|
||||||
@@ -45,6 +66,10 @@ export default function SDFilePage() {
|
|||||||
showSDManagement={true}
|
showSDManagement={true}
|
||||||
onResetSDStorage={handleResetSDStorage}
|
onResetSDStorage={handleResetSDStorage}
|
||||||
onUnmountSDStorage={handleUnmountSDStorage}
|
onUnmountSDStorage={handleUnmountSDStorage}
|
||||||
|
onFormatSDStorage={handleFormatSDStorage}
|
||||||
|
fsType={fsType}
|
||||||
|
onFsTypeChange={setFsType}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,16 @@ import React from "react";
|
|||||||
import SideTabs from "@components/Sidebar/SideTabs";
|
import SideTabs from "@components/Sidebar/SideTabs";
|
||||||
import DeviceFilePage from "@/layout/components_side/SharedFolders/DeviceFilePage";
|
import DeviceFilePage from "@/layout/components_side/SharedFolders/DeviceFilePage";
|
||||||
import SDFilePage from "@/layout/components_side/SharedFolders/SDFilePage";
|
import SDFilePage from "@/layout/components_side/SharedFolders/SDFilePage";
|
||||||
|
import { useBootStorageType } from "@/hooks/useBootStorage";
|
||||||
|
|
||||||
|
|
||||||
const SharedFolders: React.FC = () => {
|
const SharedFolders: React.FC = () => {
|
||||||
|
const { bootStorageType } = useBootStorageType();
|
||||||
|
const isBootFromSD = bootStorageType === "sd";
|
||||||
|
|
||||||
|
if (isBootFromSD) {
|
||||||
|
return <DeviceFilePage />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SideTabs
|
<SideTabs
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
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 { useReactAt } from "i18n-auto-extractor/react";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ import { SettingsItem, SettingsItemNew } from "@components/Settings/SettingsView
|
|||||||
|
|
||||||
import notifications from "../../../notifications";
|
import notifications from "../../../notifications";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -45,6 +46,133 @@ const streamQualityOptions = [
|
|||||||
{ value: "0.1", label: "Low" },
|
{ value: "0.1", label: "Low" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type RcQpParams = {
|
||||||
|
s32FirstFrameStartQp: number;
|
||||||
|
u32StepQp: number;
|
||||||
|
u32MinQp: number;
|
||||||
|
u32MaxQp: number;
|
||||||
|
u32MinIQp: number;
|
||||||
|
u32MaxIQp: number;
|
||||||
|
s32DeltIpQp: number;
|
||||||
|
s32MaxReEncodeTimes: number;
|
||||||
|
u32FrmMaxQp: number;
|
||||||
|
u32FrmMinQp: number;
|
||||||
|
u32FrmMinIQp: number;
|
||||||
|
u32FrmMaxIQp: number;
|
||||||
|
u32MotionStaticSwitchFrmQp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VideoRcConfig = {
|
||||||
|
h264: RcQpParams;
|
||||||
|
h265: RcQpParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RcSliderValues = {
|
||||||
|
stepQp: number;
|
||||||
|
minQp: number;
|
||||||
|
minIQp: number;
|
||||||
|
deltIpQp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RcSliderState = {
|
||||||
|
h264: RcSliderValues;
|
||||||
|
h265: RcSliderValues;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number) =>
|
||||||
|
Math.min(max, Math.max(min, value));
|
||||||
|
|
||||||
|
const sliderValueToNumber = (value: number | number[]) =>
|
||||||
|
Array.isArray(value) ? value[0] : value;
|
||||||
|
|
||||||
|
const DEFAULT_RC_CODEC: RcQpParams = {
|
||||||
|
s32FirstFrameStartQp: 0,
|
||||||
|
u32StepQp: 48,
|
||||||
|
u32MinQp: 48,
|
||||||
|
u32MaxQp: 51,
|
||||||
|
u32MinIQp: 48,
|
||||||
|
u32MaxIQp: 51,
|
||||||
|
s32DeltIpQp: 7,
|
||||||
|
s32MaxReEncodeTimes: 2,
|
||||||
|
u32FrmMaxQp: 51,
|
||||||
|
u32FrmMinQp: 48,
|
||||||
|
u32FrmMinIQp: 51,
|
||||||
|
u32FrmMaxIQp: 48,
|
||||||
|
u32MotionStaticSwitchFrmQp: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_VIDEO_RC_CONFIG: VideoRcConfig = {
|
||||||
|
h264: { ...DEFAULT_RC_CODEC },
|
||||||
|
h265: { ...DEFAULT_RC_CODEC },
|
||||||
|
};
|
||||||
|
|
||||||
|
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === "object" && value !== null;
|
||||||
|
|
||||||
|
const toVideoRcConfigOrDefault = (value: unknown): VideoRcConfig => {
|
||||||
|
if (!isObjectRecord(value)) return DEFAULT_VIDEO_RC_CONFIG;
|
||||||
|
if (!("h264" in value) || !("h265" in value)) return DEFAULT_VIDEO_RC_CONFIG;
|
||||||
|
return value as VideoRcConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RC_LIMITS = {
|
||||||
|
stepQp: { min: 1, max: 51 },
|
||||||
|
maxQp: { min: 1, max: 51 },
|
||||||
|
maxIQp: { min: 1, max: 51 },
|
||||||
|
deltIpQp: { min: -7, max: 7 },
|
||||||
|
maxReEncodeTimes: { min: 0, max: 3 },
|
||||||
|
frmQp: { min: 1, max: 51 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeRcCodec = (codec: RcQpParams): RcQpParams => {
|
||||||
|
const maxQp = clamp(Number(codec.u32MaxQp), RC_LIMITS.maxQp.min, RC_LIMITS.maxQp.max);
|
||||||
|
const maxIQp = clamp(Number(codec.u32MaxIQp), RC_LIMITS.maxIQp.min, RC_LIMITS.maxIQp.max);
|
||||||
|
const minQp = clamp(Number(codec.u32MinQp), RC_LIMITS.maxQp.min, maxQp);
|
||||||
|
const minIQp = clamp(Number(codec.u32MinIQp), RC_LIMITS.maxIQp.min, maxIQp);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...codec,
|
||||||
|
s32FirstFrameStartQp: clamp(Number(codec.s32FirstFrameStartQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max),
|
||||||
|
u32StepQp: clamp(Number(codec.u32StepQp), RC_LIMITS.stepQp.min, RC_LIMITS.stepQp.max),
|
||||||
|
u32MaxQp: maxQp,
|
||||||
|
u32MinQp: minQp,
|
||||||
|
u32MaxIQp: maxIQp,
|
||||||
|
u32MinIQp: minIQp,
|
||||||
|
s32DeltIpQp: clamp(Number(codec.s32DeltIpQp), RC_LIMITS.deltIpQp.min, RC_LIMITS.deltIpQp.max),
|
||||||
|
s32MaxReEncodeTimes: clamp(
|
||||||
|
Number(codec.s32MaxReEncodeTimes),
|
||||||
|
RC_LIMITS.maxReEncodeTimes.min,
|
||||||
|
RC_LIMITS.maxReEncodeTimes.max,
|
||||||
|
),
|
||||||
|
u32FrmMaxQp: clamp(Number(codec.u32FrmMaxQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max),
|
||||||
|
u32FrmMinQp: clamp(Number(codec.u32FrmMinQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max),
|
||||||
|
u32FrmMinIQp: clamp(Number(codec.u32FrmMinIQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max),
|
||||||
|
u32FrmMaxIQp: clamp(Number(codec.u32FrmMaxIQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max),
|
||||||
|
u32MotionStaticSwitchFrmQp: clamp(
|
||||||
|
Number(codec.u32MotionStaticSwitchFrmQp),
|
||||||
|
RC_LIMITS.frmQp.min,
|
||||||
|
RC_LIMITS.frmQp.max,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeVideoRcConfig = (config: VideoRcConfig): VideoRcConfig => ({
|
||||||
|
h264: normalizeRcCodec(config.h264),
|
||||||
|
h265: normalizeRcCodec(config.h265),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sliderValuesFromCodec = (codec: RcQpParams): RcSliderValues => ({
|
||||||
|
stepQp: clamp(Number(codec.u32StepQp), 1, 50),
|
||||||
|
minQp: clamp(Number(codec.u32MinQp), 1, 50),
|
||||||
|
minIQp: clamp(Number(codec.u32MinIQp), 1, 50),
|
||||||
|
deltIpQp: clamp(Number(codec.s32DeltIpQp), -7, 7),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sliderStateFromConfig = (config: VideoRcConfig): RcSliderState => ({
|
||||||
|
h264: sliderValuesFromCodec(config.h264),
|
||||||
|
h265: sliderValuesFromCodec(config.h265),
|
||||||
|
});
|
||||||
|
|
||||||
export default function SettingsVideoSide() {
|
export default function SettingsVideoSide() {
|
||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
@@ -54,6 +182,12 @@ export default function SettingsVideoSide() {
|
|||||||
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
|
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
|
||||||
const [edid, setEdid] = useState<string | null>(null);
|
const [edid, setEdid] = useState<string | null>(null);
|
||||||
const [forceHpd, setForceHpd] = useState(false);
|
const [forceHpd, setForceHpd] = useState(false);
|
||||||
|
const [videoRcConfig, setVideoRcConfig] = useState<VideoRcConfig>(DEFAULT_VIDEO_RC_CONFIG);
|
||||||
|
const [rcSliderValues, setRcSliderValues] = useState<RcSliderState>(
|
||||||
|
sliderStateFromConfig(DEFAULT_VIDEO_RC_CONFIG),
|
||||||
|
);
|
||||||
|
const [showRcAdvanced, setShowRcAdvanced] = useState(false);
|
||||||
|
const [rcDraftConfig, setRcDraftConfig] = useState<VideoRcConfig | null>(null);
|
||||||
|
|
||||||
// Video enhancement settings from store
|
// Video enhancement settings from store
|
||||||
const videoSaturation = useSettingsStore(state => state.videoSaturation);
|
const videoSaturation = useSettingsStore(state => state.videoSaturation);
|
||||||
@@ -63,6 +197,129 @@ export default function SettingsVideoSide() {
|
|||||||
const videoContrast = useSettingsStore(state => state.videoContrast);
|
const videoContrast = useSettingsStore(state => state.videoContrast);
|
||||||
const setVideoContrast = useSettingsStore(state => state.setVideoContrast);
|
const setVideoContrast = useSettingsStore(state => state.setVideoContrast);
|
||||||
|
|
||||||
|
const currentCodec: "h264" | "h265" = streamEncodecType === "hevc" ? "h265" : "h264";
|
||||||
|
const currentSliders = rcSliderValues[currentCodec];
|
||||||
|
|
||||||
|
const applySliderToCodec = (codec: RcQpParams, sliders: RcSliderValues): RcQpParams => ({
|
||||||
|
...codec,
|
||||||
|
u32StepQp: sliders.stepQp,
|
||||||
|
u32MinQp: sliders.minQp,
|
||||||
|
u32MinIQp: sliders.minIQp,
|
||||||
|
s32DeltIpQp: sliders.deltIpQp,
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyRcBasicConfig = () => {
|
||||||
|
const nextRcConfig = normalizeVideoRcConfig({
|
||||||
|
...videoRcConfig,
|
||||||
|
[currentCodec]: applySliderToCodec(videoRcConfig[currentCodec], currentSliders),
|
||||||
|
});
|
||||||
|
send("setVideoRc", { params: nextRcConfig }, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(`Failed to set video RC: ${resp.error.data || "Unknown error"}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVideoRcConfig(nextRcConfig);
|
||||||
|
setRcDraftConfig(nextRcConfig);
|
||||||
|
setRcSliderValues(sliderStateFromConfig(nextRcConfig));
|
||||||
|
notifications.success("Video RC updated");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRcDraftField = (
|
||||||
|
codec: "h264" | "h265",
|
||||||
|
field: keyof RcQpParams,
|
||||||
|
value: number,
|
||||||
|
) => {
|
||||||
|
setRcDraftConfig(prev => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const nextCodec = { ...prev[codec], [field]: value };
|
||||||
|
const normalizedCodec = normalizeRcCodec(nextCodec);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[codec]: normalizedCodec,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRcAdvancedModal = () => {
|
||||||
|
const nextDraft = normalizeVideoRcConfig({
|
||||||
|
...videoRcConfig,
|
||||||
|
[currentCodec]: applySliderToCodec(videoRcConfig[currentCodec], currentSliders),
|
||||||
|
});
|
||||||
|
setRcDraftConfig(nextDraft);
|
||||||
|
setShowRcAdvanced(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyRcAdvancedConfig = () => {
|
||||||
|
if (!rcDraftConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalized = normalizeVideoRcConfig(rcDraftConfig);
|
||||||
|
send("setVideoRc", { params: normalized }, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(`Failed to set video RC: ${resp.error.data || "Unknown error"}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVideoRcConfig(normalized);
|
||||||
|
setRcDraftConfig(normalized);
|
||||||
|
setRcSliderValues(sliderStateFromConfig(normalized));
|
||||||
|
notifications.success("Video RC updated");
|
||||||
|
setShowRcAdvanced(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRcAdvancedForm = (codec: "h264" | "h265") => {
|
||||||
|
const current = rcDraftConfig?.[codec];
|
||||||
|
if (!current) return null;
|
||||||
|
|
||||||
|
const fields: Array<{
|
||||||
|
key: keyof RcQpParams;
|
||||||
|
label: string;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}> = [
|
||||||
|
{ key: "s32FirstFrameStartQp", label: "FirstFrameStartQp", min: 1, max: 51 },
|
||||||
|
{ key: "u32StepQp", label: "StepQp", min: 1, max: 51 },
|
||||||
|
{ key: "u32MaxQp", label: "MaxQp", min: 1, max: 51 },
|
||||||
|
{ key: "u32MinQp", label: "MinQp", min: 1, max: Number(current.u32MaxQp) || 51 },
|
||||||
|
{ key: "u32MaxIQp", label: "MaxIQp", min: 1, max: 51 },
|
||||||
|
{ key: "u32MinIQp", label: "MinIQp", min: 1, max: Number(current.u32MaxIQp) || 51 },
|
||||||
|
{ key: "s32DeltIpQp", label: "DeltIpQp", min: -7, max: 7 },
|
||||||
|
{ key: "s32MaxReEncodeTimes", label: "MaxReEncodeTimes", min: 0, max: 3 },
|
||||||
|
{ key: "u32FrmMaxQp", label: "FrmMaxQp", min: 1, max: 51 },
|
||||||
|
{ key: "u32FrmMinQp", label: "FrmMinQp", min: 1, max: 51 },
|
||||||
|
{ key: "u32FrmMaxIQp", label: "FrmMaxIQp", min: 1, max: 51 },
|
||||||
|
{ key: "u32FrmMinIQp", label: "FrmMinIQp", min: 1, max: 51 },
|
||||||
|
{ key: "u32MotionStaticSwitchFrmQp", label: "MotionStaticSwitchFrmQp", min: 1, max: 51 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
{fields.map(field => (
|
||||||
|
<div key={`${codec}-${field.key}`} className="flex items-center justify-between gap-3">
|
||||||
|
<Text className="text-xs">{field.label}</Text>
|
||||||
|
<InputNumber
|
||||||
|
size="small"
|
||||||
|
value={current[field.key]}
|
||||||
|
min={field.min}
|
||||||
|
max={field.max}
|
||||||
|
step={1}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
onChange={val => {
|
||||||
|
if (typeof val !== "number") return;
|
||||||
|
const safeValue =
|
||||||
|
field.min !== undefined && field.max !== undefined
|
||||||
|
? clamp(Number(val), field.min, field.max)
|
||||||
|
: Number(val);
|
||||||
|
updateRcDraftField(codec, field.key, safeValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
send("getNpuAppStatus", {}, resp => {
|
send("getNpuAppStatus", {}, resp => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
@@ -79,6 +336,20 @@ export default function SettingsVideoSide() {
|
|||||||
setStreamQuality(String(resp.result));
|
setStreamQuality(String(resp.result));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
send("getVideoRc", {}, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(`Failed to get video RC: ${resp.error.data || "Unknown error"}`);
|
||||||
|
const fallbackRc = normalizeVideoRcConfig(DEFAULT_VIDEO_RC_CONFIG);
|
||||||
|
setVideoRcConfig(fallbackRc);
|
||||||
|
setRcSliderValues(sliderStateFromConfig(fallbackRc));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rc = normalizeVideoRcConfig(toVideoRcConfigOrDefault(resp.result));
|
||||||
|
setVideoRcConfig(rc);
|
||||||
|
setRcSliderValues(sliderStateFromConfig(rc));
|
||||||
|
});
|
||||||
|
|
||||||
send("getEDID", {}, resp => {
|
send("getEDID", {}, resp => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`);
|
notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`);
|
||||||
@@ -218,6 +489,116 @@ export default function SettingsVideoSide() {
|
|||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title={$at("RC Control")}
|
||||||
|
description={$at("Adjust rate control QP settings for better balance between quality and bitrate")}
|
||||||
|
/>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SettingsItemNew
|
||||||
|
title={$at("StepQp")}
|
||||||
|
description={String(currentSliders.stepQp)}
|
||||||
|
className={"flex-col w-full h-[40px]"}
|
||||||
|
>
|
||||||
|
<Slider
|
||||||
|
min={1}
|
||||||
|
max={51}
|
||||||
|
step={1}
|
||||||
|
value={currentSliders.stepQp}
|
||||||
|
onChange={value => {
|
||||||
|
const nextStepQp = clamp(sliderValueToNumber(value), 1, 50);
|
||||||
|
setRcSliderValues(prev => ({
|
||||||
|
...prev,
|
||||||
|
[currentCodec]: {
|
||||||
|
...prev[currentCodec],
|
||||||
|
stepQp: nextStepQp,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className={"w-full"}
|
||||||
|
/>
|
||||||
|
</SettingsItemNew>
|
||||||
|
|
||||||
|
<SettingsItemNew
|
||||||
|
title={$at("MinQp")}
|
||||||
|
description={String(currentSliders.minQp)}
|
||||||
|
className={"flex-col w-full h-[40px]"}
|
||||||
|
>
|
||||||
|
<Slider
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
step={1}
|
||||||
|
value={currentSliders.minQp}
|
||||||
|
onChange={value => {
|
||||||
|
const nextMinQp = clamp(sliderValueToNumber(value), 1, 50);
|
||||||
|
setRcSliderValues(prev => ({
|
||||||
|
...prev,
|
||||||
|
[currentCodec]: {
|
||||||
|
...prev[currentCodec],
|
||||||
|
minQp: nextMinQp,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className={"w-full"}
|
||||||
|
/>
|
||||||
|
</SettingsItemNew>
|
||||||
|
|
||||||
|
<SettingsItemNew
|
||||||
|
title={$at("MinIQp")}
|
||||||
|
description={String(currentSliders.minIQp)}
|
||||||
|
className={"flex-col w-full h-[40px]"}
|
||||||
|
>
|
||||||
|
<Slider
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
step={1}
|
||||||
|
value={currentSliders.minIQp}
|
||||||
|
onChange={value => {
|
||||||
|
const nextMinIQp = clamp(sliderValueToNumber(value), 1, 50);
|
||||||
|
setRcSliderValues(prev => ({
|
||||||
|
...prev,
|
||||||
|
[currentCodec]: {
|
||||||
|
...prev[currentCodec],
|
||||||
|
minIQp: nextMinIQp,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className={"w-full"}
|
||||||
|
/>
|
||||||
|
</SettingsItemNew>
|
||||||
|
|
||||||
|
<SettingsItemNew
|
||||||
|
title={$at("DetlpQp")}
|
||||||
|
description={String(currentSliders.deltIpQp)}
|
||||||
|
className={"flex-col w-full h-[40px]"}
|
||||||
|
>
|
||||||
|
<Slider
|
||||||
|
min={-7}
|
||||||
|
max={7}
|
||||||
|
step={1}
|
||||||
|
value={currentSliders.deltIpQp}
|
||||||
|
onChange={value => {
|
||||||
|
const nextDeltIpQp = clamp(sliderValueToNumber(value), -7, 7);
|
||||||
|
setRcSliderValues(prev => ({
|
||||||
|
...prev,
|
||||||
|
[currentCodec]: {
|
||||||
|
...prev[currentCodec],
|
||||||
|
deltIpQp: nextDeltIpQp,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className={"w-full"}
|
||||||
|
/>
|
||||||
|
</SettingsItemNew>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<AntdButton onClick={openRcAdvancedModal}>
|
||||||
|
{$at("Advanced")}
|
||||||
|
</AntdButton>
|
||||||
|
<AntdButton type="primary" onClick={applyRcBasicConfig}>
|
||||||
|
{$at("Apply")}
|
||||||
|
</AntdButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
title={$at("NPU Application")}
|
title={$at("NPU Application")}
|
||||||
@@ -362,25 +743,7 @@ export default function SettingsVideoSide() {
|
|||||||
}}
|
}}
|
||||||
options={[...edids, { value: "custom", label: "Custom" }]}
|
options={[...edids, { value: "custom", label: "Custom" }]}
|
||||||
/>
|
/>
|
||||||
{/* options={[...edids, { value: "custom", label: "Custom" }]}*/}
|
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
{/*<SelectMenuBasic*/}
|
|
||||||
{/* size="SM"*/}
|
|
||||||
{/* label=""*/}
|
|
||||||
{/* fullWidth*/}
|
|
||||||
{/* value={customEdidValue ? "custom" : edid || "asd"}*/}
|
|
||||||
{/* onChange={e => {*/}
|
|
||||||
{/* console.log(e.target.value)*/}
|
|
||||||
{/* if (e.target.value === "custom") {*/}
|
|
||||||
{/* setEdid("custom");*/}
|
|
||||||
{/* setCustomEdidValue("");*/}
|
|
||||||
{/* } else {*/}
|
|
||||||
{/* setCustomEdidValue(null);*/}
|
|
||||||
{/* handleEDIDChange(e.target.value as string);*/}
|
|
||||||
{/* }*/}
|
|
||||||
{/* }}*/}
|
|
||||||
{/* options={[...edids, { value: "custom", label: "Custom" }]}*/}
|
|
||||||
{/*/>*/}
|
|
||||||
|
|
||||||
{customEdidValue !== null && (
|
{customEdidValue !== null && (
|
||||||
<>
|
<>
|
||||||
@@ -418,6 +781,39 @@ export default function SettingsVideoSide() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={"h-[10vh]"}></div>
|
<div className={"h-[10vh]"}></div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<Text strong style={{ fontSize: "16px" }}>
|
||||||
|
{$at("RC Advanced Config")}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
open={showRcAdvanced}
|
||||||
|
onCancel={() => setShowRcAdvanced(false)}
|
||||||
|
onOk={applyRcAdvancedConfig}
|
||||||
|
okText={$at("Apply")}
|
||||||
|
cancelText={$at("Cancel")}
|
||||||
|
maskClosable={true}
|
||||||
|
keyboard={true}
|
||||||
|
width={520}
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
padding: "20px 24px",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
borderBottom: "1px solid #f0f0f0",
|
||||||
|
padding: "16px 24px",
|
||||||
|
marginBottom: 0,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{ key: "h264", label: "H264", children: renderRcAdvancedForm("h264") },
|
||||||
|
{ key: "h265", label: "H265", children: renderRcAdvancedForm("h265") },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -53,6 +53,11 @@ export interface StorageSpace {
|
|||||||
bytesFree: number;
|
bytesFree: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMountableVirtualMediaFile = (filename: string) => {
|
||||||
|
const lower = filename.toLowerCase();
|
||||||
|
return lower.endsWith(".img") || lower.endsWith(".iso");
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const LoadingOverlay: React.FC = () => {
|
const LoadingOverlay: React.FC = () => {
|
||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
@@ -89,7 +94,6 @@ export default function ImageManager({
|
|||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
|
|
||||||
// 状态管理
|
|
||||||
const [storageFiles, setStorageFiles] = useState<StorageFile[]>([]);
|
const [storageFiles, setStorageFiles] = useState<StorageFile[]>([]);
|
||||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
const [usbMode, setUsbMode] = useState<RemoteVirtualMediaState["mode"]>("CDROM");
|
const [usbMode, setUsbMode] = useState<RemoteVirtualMediaState["mode"]>("CDROM");
|
||||||
@@ -101,9 +105,9 @@ export default function ImageManager({
|
|||||||
const [sdMountStatus, setSDMountStatus] = useState<"ok" | "none" | "fail" | null>(storageType === 'sd' ? null : 'ok');
|
const [sdMountStatus, setSDMountStatus] = useState<"ok" | "none" | "fail" | null>(storageType === 'sd' ? null : 'ok');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [uploadFile, setUploadFile] = useState<string | null>(null);
|
const [uploadFile, setUploadFile] = useState<string | null>(null);
|
||||||
|
const [fsType, setFsType] = useState<'exfat' | 'fat32'>('fat32');
|
||||||
const filesPerPage = 5;
|
const filesPerPage = 5;
|
||||||
|
|
||||||
// 计算属性
|
|
||||||
const percentageUsed = useMemo(() => {
|
const percentageUsed = useMemo(() => {
|
||||||
if (!storageSpace) return 0;
|
if (!storageSpace) return 0;
|
||||||
return Number(
|
return Number(
|
||||||
@@ -162,13 +166,30 @@ export default function ImageManager({
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 数据获取函数
|
const handleFormatSDStorage = async () => {
|
||||||
|
if (!window.confirm($at("Formatting the SD card will erase all data. Continue?"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
send("formatSDStorage", { confirm: true, fsType }, res => {
|
||||||
|
if ("error" in res) {
|
||||||
|
notifications.error(res.error.data || res.error.message);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifications.success($at("SD card formatted successfully"));
|
||||||
|
setSDMountStatus(null);
|
||||||
|
checkSDStatus();
|
||||||
|
});
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
const syncStorage = useCallback(() => {
|
const syncStorage = useCallback(() => {
|
||||||
if (storageType === 'sd' && sdMountStatus !== 'ok') {
|
if (storageType === 'sd' && sdMountStatus !== 'ok') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取存储文件列表
|
|
||||||
send(listFilesApi, {}, res => {
|
send(listFilesApi, {}, res => {
|
||||||
if ("error" in res) {
|
if ("error" in res) {
|
||||||
notifications.error(`Error listing storage files: ${res.error}`);
|
notifications.error(`Error listing storage files: ${res.error}`);
|
||||||
@@ -180,10 +201,11 @@ export default function ImageManager({
|
|||||||
size: formatters.bytes(file.size),
|
size: formatters.bytes(file.size),
|
||||||
createdAt: formatters.date(new Date(file?.createdAt)),
|
createdAt: formatters.date(new Date(file?.createdAt)),
|
||||||
}));
|
}));
|
||||||
setStorageFiles(formattedFiles);
|
const mountableFiles = formattedFiles.filter(f => isMountableVirtualMediaFile(f.name));
|
||||||
|
setStorageFiles(mountableFiles);
|
||||||
|
setSelectedFile(prev => (prev && mountableFiles.some(f => f.name === prev) ? prev : null));
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取存储空间信息
|
|
||||||
send(getSpaceApi, {}, res => {
|
send(getSpaceApi, {}, res => {
|
||||||
if ("error" in res) {
|
if ("error" in res) {
|
||||||
notifications.error(`Error getting storage space: ${res.error}`);
|
notifications.error(`Error getting storage space: ${res.error}`);
|
||||||
@@ -192,7 +214,6 @@ export default function ImageManager({
|
|||||||
setStorageSpace(res.result as StorageSpace);
|
setStorageSpace(res.result as StorageSpace);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取自动挂载设置
|
|
||||||
if (showAutoMount && getAutoMountApi) {
|
if (showAutoMount && getAutoMountApi) {
|
||||||
send(getAutoMountApi, {}, resp => {
|
send(getAutoMountApi, {}, resp => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
@@ -205,7 +226,6 @@ export default function ImageManager({
|
|||||||
}
|
}
|
||||||
}, [send, listFilesApi, getSpaceApi, showAutoMount, getAutoMountApi, storageType, sdMountStatus]);
|
}, [send, listFilesApi, getSpaceApi, showAutoMount, getAutoMountApi, storageType, sdMountStatus]);
|
||||||
|
|
||||||
// 初始化数据
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (storageType === 'sd') {
|
if (storageType === 'sd') {
|
||||||
checkSDStatus();
|
checkSDStatus();
|
||||||
@@ -220,7 +240,6 @@ export default function ImageManager({
|
|||||||
}
|
}
|
||||||
}, [sdMountStatus, syncStorage]);
|
}, [sdMountStatus, syncStorage]);
|
||||||
|
|
||||||
// 事件处理函数
|
|
||||||
const handleDeleteFile = useCallback((file: StorageFile) => {
|
const handleDeleteFile = useCallback((file: StorageFile) => {
|
||||||
if (window.confirm($at("Are you sure you want to delete " + file.name + "?"))) {
|
if (window.confirm($at("Are you sure you want to delete " + file.name + "?"))) {
|
||||||
send(deleteFileApi, { filename: file.name }, res => {
|
send(deleteFileApi, { filename: file.name }, res => {
|
||||||
@@ -235,9 +254,10 @@ export default function ImageManager({
|
|||||||
|
|
||||||
const handleSelectFile = useCallback((file: StorageFile) => {
|
const handleSelectFile = useCallback((file: StorageFile) => {
|
||||||
setSelectedFile(file.name);
|
setSelectedFile(file.name);
|
||||||
if (file.name.endsWith(".iso")) {
|
const lower = file.name.toLowerCase();
|
||||||
|
if (lower.endsWith(".iso")) {
|
||||||
setUsbMode("CDROM");
|
setUsbMode("CDROM");
|
||||||
} else if (file.name.endsWith(".img")) {
|
} else if (lower.endsWith(".img")) {
|
||||||
setUsbMode("Disk");
|
setUsbMode("Disk");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -263,7 +283,6 @@ export default function ImageManager({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 挂载成功
|
|
||||||
syncRemoteVirtualMediaState()
|
syncRemoteVirtualMediaState()
|
||||||
setMountInProgress(false);
|
setMountInProgress(false);
|
||||||
if (onMountSuccess) {
|
if (onMountSuccess) {
|
||||||
@@ -327,6 +346,32 @@ export default function ImageManager({
|
|||||||
? $at("Please insert an SD card and try again.")
|
? $at("Please insert an SD card and try again.")
|
||||||
: $at("Please format the SD card and try again.")}
|
: $at("Please format the SD card and try again.")}
|
||||||
</p>
|
</p>
|
||||||
|
{sdMountStatus !== "none" && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<div className="mx-auto w-full max-w-[360px] space-y-2">
|
||||||
|
<p className="w-full text-left text-xs text-slate-700 dark:text-slate-300">
|
||||||
|
{$at("Choose the file system for MicroSD formatting")}
|
||||||
|
</p>
|
||||||
|
<select
|
||||||
|
value={fsType}
|
||||||
|
onChange={(e) => setFsType(e.target.value as 'exfat' | 'fat32')}
|
||||||
|
style={{ width: "100%", padding: "8px", borderRadius: "4px" }}
|
||||||
|
>
|
||||||
|
<option value="fat32">FAT32</option>
|
||||||
|
<option value="exfat">exFAT</option>
|
||||||
|
</select>
|
||||||
|
<AntdButton
|
||||||
|
disabled={loading}
|
||||||
|
danger={true}
|
||||||
|
type="primary"
|
||||||
|
onClick={handleFormatSDStorage}
|
||||||
|
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
|
||||||
|
>
|
||||||
|
{$at("Format MicroSD Card")} ({fsType})
|
||||||
|
</AntdButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -343,7 +388,6 @@ export default function ImageManager({
|
|||||||
title={$at("Mount from KVM Storage")}
|
title={$at("Mount from KVM Storage")}
|
||||||
description={$at("Select the image you want to mount from the KVM storage")}
|
description={$at("Select the image you want to mount from the KVM storage")}
|
||||||
/>
|
/>
|
||||||
{/* 自动挂载设置 */}
|
|
||||||
{showAutoMount && (
|
{showAutoMount && (
|
||||||
<div className="w-full animate-fadeIn opacity-0" style={{ animationDuration: "0.7s", animationDelay: "0.1s" }}>
|
<div className="w-full animate-fadeIn opacity-0" style={{ animationDuration: "0.7s", animationDelay: "0.1s" }}>
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
@@ -361,7 +405,6 @@ export default function ImageManager({
|
|||||||
|
|
||||||
{showAutoMount && <hr className="border-slate-800/20 dark:border-slate-300/20" />}
|
{showAutoMount && <hr className="border-slate-800/20 dark:border-slate-300/20" />}
|
||||||
|
|
||||||
{/* 文件列表 */}
|
|
||||||
<div className="w-full animate-fadeIn opacity-0 px-0.5" style={{ animationDuration: "0.7s", animationDelay: "0.1s" }}>
|
<div className="w-full animate-fadeIn opacity-0 px-0.5" style={{ animationDuration: "0.7s", animationDelay: "0.1s" }}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -402,7 +445,6 @@ export default function ImageManager({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* 分页控件 */}
|
|
||||||
{storageFiles.length > filesPerPage && (
|
{storageFiles.length > filesPerPage && (
|
||||||
<div className="flex items-center justify-between px-3 py-2">
|
<div className="flex items-center justify-between px-3 py-2">
|
||||||
<p className="text-sm text-slate-700 dark:text-slate-300">
|
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||||
@@ -438,7 +480,6 @@ export default function ImageManager({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
{storageFiles.length > 0 && (
|
{storageFiles.length > 0 && (
|
||||||
<div className="flex animate-fadeIn items-end justify-between opacity-0" style={{ animationDuration: "0.7s", animationDelay: "0.15s" }}>
|
<div className="flex animate-fadeIn items-end justify-between opacity-0" style={{ animationDuration: "0.7s", animationDelay: "0.15s" }}>
|
||||||
<Fieldset disabled={selectedFile === null}>
|
<Fieldset disabled={selectedFile === null}>
|
||||||
@@ -456,7 +497,6 @@ export default function ImageManager({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 存储空间信息 */}
|
|
||||||
<hr className="border-slate-800/20 dark:border-slate-300/20" />
|
<hr className="border-slate-800/20 dark:border-slate-300/20" />
|
||||||
<div className="animate-fadeIn space-y-2 opacity-0" style={{ animationDuration: "0.7s", animationDelay: "0.20s" }}>
|
<div className="animate-fadeIn space-y-2 opacity-0" style={{ animationDuration: "0.7s", animationDelay: "0.20s" }}>
|
||||||
<StorageSpaceBar
|
<StorageSpaceBar
|
||||||
@@ -466,11 +506,30 @@ export default function ImageManager({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 自定义操作区域 */}
|
|
||||||
{unmountApi && storageType === 'sd' && (
|
{unmountApi && storageType === 'sd' && (
|
||||||
<div className="flex animate-fadeIn justify-between opacity-0"
|
<div className="animate-fadeIn space-y-2 opacity-0"
|
||||||
style={{ animationDuration: "0.7s", animationDelay: "0.25s" }}
|
style={{ animationDuration: "0.7s", animationDelay: "0.25s" }}
|
||||||
>
|
>
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<p className="w-full text-left text-xs text-slate-700 dark:text-slate-300">
|
||||||
|
{$at("Choose the file system for MicroSD formatting")}
|
||||||
|
</p>
|
||||||
|
<select
|
||||||
|
value={fsType}
|
||||||
|
onChange={(e) => setFsType(e.target.value as 'exfat' | 'fat32')}
|
||||||
|
style={{ width: "100%", padding: "8px", borderRadius: "4px" }}
|
||||||
|
>
|
||||||
|
<option value="fat32">FAT32</option>
|
||||||
|
<option value="exfat">exFAT</option>
|
||||||
|
</select>
|
||||||
|
<AntdButton
|
||||||
|
disabled={loading}
|
||||||
|
type="primary"
|
||||||
|
danger={true}
|
||||||
|
onClick={handleFormatSDStorage}
|
||||||
|
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
|
||||||
|
>{$at("Format MicroSD Card")} ({fsType})</AntdButton>
|
||||||
|
</div>
|
||||||
<AntdButton
|
<AntdButton
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -482,7 +541,6 @@ export default function ImageManager({
|
|||||||
)}
|
)}
|
||||||
{customActions}
|
{customActions}
|
||||||
|
|
||||||
{/* 上传新镜像按钮 */}
|
|
||||||
{uploadFile ? (
|
{uploadFile ? (
|
||||||
<FileUploader
|
<FileUploader
|
||||||
key={`resume-${uploadFile}`}
|
key={`resume-${uploadFile}`}
|
||||||
@@ -492,12 +550,14 @@ export default function ImageManager({
|
|||||||
}}
|
}}
|
||||||
incompleteFileName={uploadFile}
|
incompleteFileName={uploadFile}
|
||||||
media={storageType}
|
media={storageType}
|
||||||
|
accept=".img,.iso"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<FileUploader
|
<FileUploader
|
||||||
key="new-upload"
|
key="new-upload"
|
||||||
onBack={handleFileUploadComplete}
|
onBack={handleFileUploadComplete}
|
||||||
media={storageType}
|
media={storageType}
|
||||||
|
accept=".img,.iso"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,17 @@ import SideTabs from "@components/Sidebar/SideTabs";
|
|||||||
import DevicePage from "@/layout/components_side/VirtualMediaSource/DevicePage";
|
import DevicePage from "@/layout/components_side/VirtualMediaSource/DevicePage";
|
||||||
import SDPage from "@/layout/components_side/VirtualMediaSource/SDPage";
|
import SDPage from "@/layout/components_side/VirtualMediaSource/SDPage";
|
||||||
import UnMountPage from "@/layout/components_side/VirtualMediaSource/UnMount";
|
import UnMountPage from "@/layout/components_side/VirtualMediaSource/UnMount";
|
||||||
|
import { useBootStorageType } from "@/hooks/useBootStorage";
|
||||||
|
|
||||||
|
|
||||||
////* KVM MicroSD Mount */
|
|
||||||
// width: 143px;
|
|
||||||
// height: 11px;
|
|
||||||
// display: flex;
|
|
||||||
// flex-direction: row;
|
|
||||||
// align-items: center;
|
|
||||||
// 主组件
|
|
||||||
const VirtualMediaSource: React.FC = () => {
|
const VirtualMediaSource: React.FC = () => {
|
||||||
|
const { bootStorageType } = useBootStorageType();
|
||||||
|
const isBootFromSD = bootStorageType === "sd";
|
||||||
|
|
||||||
|
if (isBootFromSD) {
|
||||||
|
return (
|
||||||
|
<UnMountPage unmountedPage={<DevicePage />} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnMountPage unmountedPage={(
|
<UnMountPage unmountedPage={(
|
||||||
@@ -25,7 +26,6 @@ const VirtualMediaSource: React.FC = () => {
|
|||||||
defaultActiveKey="1"
|
defaultActiveKey="1"
|
||||||
/>
|
/>
|
||||||
)}/>
|
)}/>
|
||||||
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 { Button as AntdButton, Typography } from "antd";
|
||||||
import { useReactAt } from "i18n-auto-extractor/react";
|
import { useReactAt } from "i18n-auto-extractor/react";
|
||||||
import KeyboardSVG from "@assets/second/keyboard.svg?react";
|
import KeyboardSVG from "@assets/second/keyboard.svg?react";
|
||||||
@@ -22,10 +22,10 @@ import {
|
|||||||
useRTCStore,
|
useRTCStore,
|
||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
useUiStore,
|
useUiStore,
|
||||||
useVideoStore,
|
|
||||||
useVpnStore,
|
useVpnStore,
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
import { keyboards } from "@/keyboardLayouts";
|
||||||
import {
|
import {
|
||||||
button_primary_color,
|
button_primary_color,
|
||||||
dark_bd_style,
|
dark_bd_style,
|
||||||
@@ -48,10 +48,13 @@ const views = [
|
|||||||
export default function BottomBarMobile() {
|
export default function BottomBarMobile() {
|
||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
const keyboardLedState = useHidStore(state => state.keyboardLedState);
|
const keyboardLedState = useHidStore(state => state.keyboardLedState);
|
||||||
const videoSize = useVideoStore(
|
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
|
||||||
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
|
|
||||||
);
|
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
|
|
||||||
|
const layoutAbbrev = useMemo(() => {
|
||||||
|
if (!keyboardLayout) return "en_US";
|
||||||
|
return keyboardLayout;
|
||||||
|
}, [keyboardLayout]);
|
||||||
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||||
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
|
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
|
||||||
const forceHttp = useSettingsStore(state => state.forceHttp);
|
const forceHttp = useSettingsStore(state => state.forceHttp);
|
||||||
@@ -79,10 +82,7 @@ export default function BottomBarMobile() {
|
|||||||
|
|
||||||
stats?.forEach(report => {
|
stats?.forEach(report => {
|
||||||
if (report.type === "inbound-rtp") {
|
if (report.type === "inbound-rtp") {
|
||||||
if(report.framesPerSecond){
|
setFps(report.framesPerSecond ?? 0);
|
||||||
setFps(report.framesPerSecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
@@ -117,7 +117,7 @@ export default function BottomBarMobile() {
|
|||||||
{ icon: isDark ? Video2SVG : VideoSVG, label: $at("video") },
|
{ icon: isDark ? Video2SVG : VideoSVG, label: $at("video") },
|
||||||
{ icon: StateSvg, label: $at("status") },
|
{ icon: StateSvg, label: $at("status") },
|
||||||
];
|
];
|
||||||
const videoButtonLabel = forceHttp ? `${videoSize}` : `${videoSize} ${fps}fps `;
|
const videoButtonLabel = forceHttp ? "N/A fps" : `${Math.round(fps)}fps`;
|
||||||
if(isVirtualKeyboardEnabled){
|
if(isVirtualKeyboardEnabled){
|
||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
@@ -151,6 +151,7 @@ export default function BottomBarMobile() {
|
|||||||
<LedStatusButton ledState={keyboardLedState?.num_lock} text={$at("Num")} />
|
<LedStatusButton ledState={keyboardLedState?.num_lock} text={$at("Num")} />
|
||||||
<LedStatusButton ledState={keyboardLedState?.caps_lock} text={$at("Caps")} />
|
<LedStatusButton ledState={keyboardLedState?.caps_lock} text={$at("Caps")} />
|
||||||
<LedStatusButton ledState={keyboardLedState?.scroll_lock} text={$at("Scroll")} />
|
<LedStatusButton ledState={keyboardLedState?.scroll_lock} text={$at("Scroll")} />
|
||||||
|
<span className="pl-2 text-xs opacity-70">{layoutAbbrev}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[20%] flex flex-row flex-wrap items-center justify-end">
|
<div className="w-[20%] flex flex-row flex-wrap items-center justify-end">
|
||||||
<AntdButton
|
<AntdButton
|
||||||
@@ -175,11 +176,13 @@ export default function BottomBarMobile() {
|
|||||||
text={$at("HDMI")}
|
text={$at("HDMI")}
|
||||||
isActive={!!peerConnectionState}
|
isActive={!!peerConnectionState}
|
||||||
/>
|
/>
|
||||||
|
<div onClick={() => toggleSidebarView("UsbStatusPanel")}>
|
||||||
<ConnectionStatusButton
|
<ConnectionStatusButton
|
||||||
icon={usbState === "configured" ? <Usb2SVG /> : <UsbSVG />}
|
icon={usbState === "configured" ? <Usb2SVG /> : <UsbSVG />}
|
||||||
text={$at("USB")}
|
text={$at("USB")}
|
||||||
isActive={usbState === "configured"}
|
isActive={usbState === "configured"}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<VpnStatusButton
|
<VpnStatusButton
|
||||||
text={$at("TailScale")}
|
text={$at("TailScale")}
|
||||||
peerState={peerConnectionState}
|
peerState={peerConnectionState}
|
||||||
|
|||||||
@@ -27,10 +27,12 @@ import {
|
|||||||
useVpnStore,
|
useVpnStore,
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
import { keys, modifiers } from "@/keyboardMappings";
|
import { keys, modifiers } from "@/keyboardMappings";
|
||||||
|
import { keyboards } from "@/keyboardLayouts";
|
||||||
import BottomPopoverButton from "@components/PopoverButton";
|
import BottomPopoverButton from "@components/PopoverButton";
|
||||||
import MousePanel from "@components/MousePanel";
|
import MousePanel from "@components/MousePanel";
|
||||||
import KeyboardPanel from "@/layout/components_bottom/keyboard/KeyboardPanel";
|
import KeyboardPanel from "@/layout/components_bottom/keyboard/KeyboardPanel";
|
||||||
import UsbEpModeSelect from "@/layout/components_bottom/usbepmode/UsbEpModeSelect";
|
import UsbEpModeSelect from "@/layout/components_bottom/usbepmode/UsbEpModeSelect";
|
||||||
|
import UsbStatusPanel from "@/layout/components_bottom/usb_status/UsbStatusPanel";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { dark_bg2_style, selected_bt_bg } from "@/layout/theme_color";
|
import { dark_bg2_style, selected_bt_bg } from "@/layout/theme_color";
|
||||||
import { useThemeSettings } from "@routes/login_page/useLocalAuth";
|
import { useThemeSettings } from "@routes/login_page/useLocalAuth";
|
||||||
@@ -44,12 +46,6 @@ export default function BottomBarPC() {
|
|||||||
const activeModifiers = useHidStore(state => state.activeModifiers);
|
const activeModifiers = useHidStore(state => state.activeModifiers);
|
||||||
const audioMode = useAudioModeStore(state => state.audioMode);
|
const audioMode = useAudioModeStore(state => state.audioMode);
|
||||||
const usbEpMode = useUsbEpModeStore(state => state.usbEpMode);
|
const usbEpMode = useUsbEpModeStore(state => state.usbEpMode);
|
||||||
// const videoSize = useVideoStore(
|
|
||||||
// state => `${Math.round(state.width)}x${Math.round(state.height)}`,
|
|
||||||
// );
|
|
||||||
const videoSize = useVideoStore(
|
|
||||||
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
|
|
||||||
);
|
|
||||||
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||||
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
|
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
|
||||||
const showPressedKeys = useSettingsStore(state => state.showPressedKeys);
|
const showPressedKeys = useSettingsStore(state => state.showPressedKeys);
|
||||||
@@ -63,8 +59,14 @@ export default function BottomBarPC() {
|
|||||||
|
|
||||||
|
|
||||||
const keyboardLedState = useHidStore(state => state.keyboardLedState);
|
const keyboardLedState = useHidStore(state => state.keyboardLedState);
|
||||||
|
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
|
||||||
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
|
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
|
||||||
|
|
||||||
|
const layoutAbbrev = useMemo(() => {
|
||||||
|
if (!keyboardLayout) return "en_US";
|
||||||
|
return keyboardLayout;
|
||||||
|
}, [keyboardLayout]);
|
||||||
|
|
||||||
const [hostname, setHostname] = useState("");
|
const [hostname, setHostname] = useState("");
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
const peerConnection = useRTCStore(state => state.peerConnection);
|
const peerConnection = useRTCStore(state => state.peerConnection);
|
||||||
@@ -89,10 +91,10 @@ export default function BottomBarPC() {
|
|||||||
|
|
||||||
const videoButtonLabel = useMemo(() => {
|
const videoButtonLabel = useMemo(() => {
|
||||||
if (forceHttp) {
|
if (forceHttp) {
|
||||||
return `${videoSize}`;
|
return "N/A fps";
|
||||||
}
|
}
|
||||||
return `${videoSize} ${fps}fps `;
|
return `${Math.round(fps)}fps`;
|
||||||
}, [forceHttp, videoSize, fps]);
|
}, [forceHttp, fps]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
send("getNetworkSettings", {}, resp => {
|
send("getNetworkSettings", {}, resp => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
@@ -114,10 +116,12 @@ export default function BottomBarPC() {
|
|||||||
isActive={hdmiState === "ready"}
|
isActive={hdmiState === "ready"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConnectionStatusButton
|
<BottomPopoverButton
|
||||||
icon={usbState === "configured" ? <Usb2SVG fontSize={16} /> : <UsbSVG fontSize={16} />}
|
buttonIconNode={usbState === "configured" ? <Usb2SVG fontSize={16} /> : <UsbSVG fontSize={16} />}
|
||||||
text={$at("USB")}
|
buttonText={$at("USB")}
|
||||||
isActive={usbState === "configured"}
|
style={{ color: usbState === "configured" ? "rgba(0, 205, 27, 1)" : "inherit" }}
|
||||||
|
panelContent={<UsbStatusPanel />}
|
||||||
|
align="left"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VpnStatusButton
|
<VpnStatusButton
|
||||||
@@ -170,6 +174,7 @@ export default function BottomBarPC() {
|
|||||||
ledState={keyboardLedState?.scroll_lock}
|
ledState={keyboardLedState?.scroll_lock}
|
||||||
text={$at("Scroll")}
|
text={$at("Scroll")}
|
||||||
/>
|
/>
|
||||||
|
<span className="pl-1 text-xs opacity-70" style={{ fontSize: 12 }}>{layoutAbbrev}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
align="left"
|
align="left"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
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 { useReactAt } from "i18n-auto-extractor/react";
|
||||||
|
|
||||||
import VirtualKeyboard from "@components/VirtualKeyboard";
|
import VirtualKeyboard from "@components/VirtualKeyboard";
|
||||||
@@ -24,7 +24,7 @@ import KeyboardPanel from "@/layout/components_bottom/keyboard/KeyboardPanel";
|
|||||||
import Clipboard from "@/layout/components_side/Clipboard/Clipboard";
|
import Clipboard from "@/layout/components_side/Clipboard/Clipboard";
|
||||||
import SettingsModal from "@/layout/components_setting";
|
import SettingsModal from "@/layout/components_setting";
|
||||||
import { MacroMoreList } from "@/layout/components_side/Macros/MacroTopBar";
|
import { MacroMoreList } from "@/layout/components_side/Macros/MacroTopBar";
|
||||||
import { useMacrosSideTitleState , useHidStore, useMouseStore, useSettingsStore } from "@/hooks/stores";
|
import { useMacrosSideTitleState , useHidStore, useMouseStore, useSettingsStore, useUiStore } from "@/hooks/stores";
|
||||||
import MobileTerminal from "@/layout/components_bottom/terminal/index.mobile";
|
import MobileTerminal from "@/layout/components_bottom/terminal/index.mobile";
|
||||||
import { dark_bg_desktop, dark_bg_style_fun } from "@/layout/theme_color";
|
import { dark_bg_desktop, dark_bg_style_fun } from "@/layout/theme_color";
|
||||||
import PowerControl from "@/layout/components_side/Power";
|
import PowerControl from "@/layout/components_side/Power";
|
||||||
@@ -37,10 +37,52 @@ import SettingsMacros from "@/layout/components_side/Macros";
|
|||||||
import { useTouchZoom } from "@/layout/core/desktop/hooks/useTouchZoom";
|
import { useTouchZoom } from "@/layout/core/desktop/hooks/useTouchZoom";
|
||||||
import { usePasteHandler } from "@/layout/core/desktop/hooks/usePasteHandler";
|
import { usePasteHandler } from "@/layout/core/desktop/hooks/usePasteHandler";
|
||||||
import UsbEpModeSelect from "@/layout/components_bottom/usbepmode/UsbEpModeSelect";
|
import UsbEpModeSelect from "@/layout/components_bottom/usbepmode/UsbEpModeSelect";
|
||||||
|
import UsbStatusPanel from "@/layout/components_bottom/usb_status/UsbStatusPanel";
|
||||||
import VirtualMediaSource from "@/layout/components_side/VirtualMediaSource";
|
import VirtualMediaSource from "@/layout/components_side/VirtualMediaSource";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
import OcrOverlay from "@components/OcrOverlay";
|
||||||
|
|
||||||
|
const GestureIcon = ({ className = "h-4 w-4" }: { className?: string }) => (
|
||||||
|
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="10" cy="10" r="6.5" />
|
||||||
|
<path d="M14.8 14.8L21 21" />
|
||||||
|
<path d="M10 7.5V12.5" />
|
||||||
|
<path d="M7.5 10H12.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ResetViewIcon = ({ className = "h-4 w-4" }: { className?: string }) => (
|
||||||
|
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M19 12A7 7 0 1 1 12 5" />
|
||||||
|
<path d="M12 2L12 6L16 6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MouseStickIcon = ({ className = "h-4 w-4" }: { className?: string }) => (
|
||||||
|
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<rect x="7" y="3" width="10" height="18" rx="5" />
|
||||||
|
<path d="M12 3V8" />
|
||||||
|
<circle cx="12" cy="13" r="1.5" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const FourWayMoveIcon = ({ className = "h-5 w-5" }: { className?: string }) => (
|
||||||
|
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M12 3V21" />
|
||||||
|
<path d="M3 12H21" />
|
||||||
|
<path d="M12 3L9 6" />
|
||||||
|
<path d="M12 3L15 6" />
|
||||||
|
<path d="M12 21L9 18" />
|
||||||
|
<path d="M12 21L15 18" />
|
||||||
|
<path d="M3 12L6 9" />
|
||||||
|
<path d="M3 12L6 15" />
|
||||||
|
<path d="M21 12L18 9" />
|
||||||
|
<path d="M21 12L18 15" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number }) {
|
export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number }) {
|
||||||
|
const joystickSpeedLevels = [1.4, 1.05, 0.7];
|
||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
const videoElm = useRef<HTMLVideoElement>(null);
|
const videoElm = useRef<HTMLVideoElement>(null);
|
||||||
@@ -49,56 +91,126 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
|
|||||||
const zoomContainerRef = useRef<HTMLDivElement>(null);
|
const zoomContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const pasteCaptureRef = useRef<HTMLTextAreaElement>(null);
|
const pasteCaptureRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||||
|
const isOcrMode = useUiStore(state => state.isOcrMode);
|
||||||
const macrosSideTitle = useMacrosSideTitleState(state => state.sideTitle);
|
const macrosSideTitle = useMacrosSideTitleState(state => state.sideTitle);
|
||||||
|
|
||||||
const videoEffects = useVideoEffects();
|
const videoEffects = useVideoEffects();
|
||||||
const videoStream = useVideoStream(videoElm as React.RefObject<HTMLVideoElement>, audioElm as React.RefObject<HTMLAudioElement>);
|
const videoStream = useVideoStream(videoElm as React.RefObject<HTMLVideoElement>, audioElm as React.RefObject<HTMLAudioElement>);
|
||||||
const pointerLock = usePointerLock(videoElm as React.RefObject<HTMLVideoElement>);
|
const pointerLock = usePointerLock(videoElm as React.RefObject<HTMLVideoElement>);
|
||||||
useFullscreen(videoElm as React.RefObject<HTMLVideoElement>, pointerLock, isFullscreen);
|
useFullscreen(videoElm as React.RefObject<HTMLVideoElement>, pointerLock, isFullscreen);
|
||||||
const 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 { handleGlobalPaste } = usePasteHandler(pasteCaptureRef as React.RefObject<HTMLTextAreaElement>);
|
||||||
const keyboardEvents = useKeyboardEvents(pasteCaptureRef as React.RefObject<HTMLTextAreaElement>, isReinitializingGadget);
|
const keyboardEvents = useKeyboardEvents(pasteCaptureRef as React.RefObject<HTMLTextAreaElement>, isReinitializingGadget);
|
||||||
const [showVirtualMouseButtons, setShowVirtualMouseButtons] = useState(false);
|
const [showVirtualMouseButtons, setShowVirtualMouseButtons] = useState(false);
|
||||||
|
const [showVirtualJoystick, setShowVirtualJoystick] = useState(false);
|
||||||
|
const [joystickVector, setJoystickVector] = useState({ x: 0, y: 0 });
|
||||||
|
const [joystickSensitivity, setJoystickSensitivity] = useState(1);
|
||||||
|
const [joystickPos, setJoystickPos] = useState({ x: 16, y: 24 });
|
||||||
const [lockedButtons, setLockedButtons] = useState(0);
|
const [lockedButtons, setLockedButtons] = useState(0);
|
||||||
const mouseEvents = useMouseEvents(videoElm as React.RefObject<HTMLVideoElement>, pointerLock, touchZoom, showVirtualMouseButtons, lockedButtons);
|
const mouseEvents = useMouseEvents(videoElm as React.RefObject<HTMLVideoElement>, pointerLock, touchZoom, showVirtualMouseButtons, lockedButtons);
|
||||||
const overlays = useVideoOverlays(videoStream, pointerLock, videoEffects);
|
const overlays = useVideoOverlays(videoStream, pointerLock, videoEffects);
|
||||||
|
|
||||||
const forceHttp = useSettingsStore(state => state.forceHttp);
|
const forceHttp = useSettingsStore(state => state.forceHttp);
|
||||||
|
const mouseMode = useSettingsStore(state => state.mouseMode);
|
||||||
const mouseX = useMouseStore(state => state.mouseX);
|
const mouseX = useMouseStore(state => state.mouseX);
|
||||||
const mouseY = useMouseStore(state => state.mouseY);
|
const mouseY = useMouseStore(state => state.mouseY);
|
||||||
|
const allowTapToOpenVirtualKeyboard = useHidStore(state => state.allowTapToOpenVirtualKeyboard);
|
||||||
|
const setAllowTapToOpenVirtualKeyboard = useHidStore(state => state.setAllowTapToOpenVirtualKeyboard);
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
const [leftBtnPos, setLeftBtnPos] = useState({ x: 40, y: 40 });
|
const [leftBtnPos, setLeftBtnPos] = useState({ x: 40, y: 40 });
|
||||||
const [rightBtnPos, setRightBtnPos] = useState({ x: 120, y: 40 });
|
const [rightBtnPos, setRightBtnPos] = useState({ x: 120, y: 40 });
|
||||||
const [leftLockPos, setLeftLockPos] = useState({ x: 40, y: 110 });
|
const [wheelPos, setWheelPos] = useState({ x: 184, y: 140 });
|
||||||
const [rightLockPos, setRightLockPos] = useState({ x: 120, y: 110 });
|
|
||||||
|
|
||||||
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 dragOffset = useRef({ x: 0, y: 0 });
|
||||||
|
const joystickAreaRef = useRef<HTMLDivElement>(null);
|
||||||
|
const joystickPointerIdRef = useRef<number | null>(null);
|
||||||
|
const joystickVectorRef = useRef({ x: 0, y: 0 });
|
||||||
|
const joystickFrameRef = useRef<number | null>(null);
|
||||||
|
const joystickLastTsRef = useRef<number | null>(null);
|
||||||
|
const joystickMovePointerIdRef = useRef<number | null>(null);
|
||||||
|
const joystickMoveHoldTimerRef = useRef<number | null>(null);
|
||||||
|
const joystickMoveEnabledRef = useRef(false);
|
||||||
|
|
||||||
const activeButtonsRef = useRef(0);
|
const activeButtonsRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isFullscreen) {
|
if (isFullscreen) {
|
||||||
setShowVirtualMouseButtons(false);
|
setShowVirtualMouseButtons(false);
|
||||||
|
setShowVirtualJoystick(false);
|
||||||
}
|
}
|
||||||
}, [isFullscreen]);
|
}, [isFullscreen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
joystickVectorRef.current = joystickVector;
|
||||||
|
}, [joystickVector]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showVirtualJoystick) {
|
||||||
|
joystickPointerIdRef.current = null;
|
||||||
|
joystickMovePointerIdRef.current = null;
|
||||||
|
if (joystickMoveHoldTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(joystickMoveHoldTimerRef.current);
|
||||||
|
joystickMoveHoldTimerRef.current = null;
|
||||||
|
}
|
||||||
|
joystickMoveEnabledRef.current = false;
|
||||||
|
joystickLastTsRef.current = null;
|
||||||
|
setJoystickVector({ x: 0, y: 0 });
|
||||||
|
if (joystickFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(joystickFrameRef.current);
|
||||||
|
joystickFrameRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = (timestamp: number) => {
|
||||||
|
const prevTs = joystickLastTsRef.current ?? timestamp;
|
||||||
|
joystickLastTsRef.current = timestamp;
|
||||||
|
const frameScale = Math.min(2, Math.max(0.5, (timestamp - prevTs) / 16.67));
|
||||||
|
const vector = joystickVectorRef.current;
|
||||||
|
const container = containerRef.current;
|
||||||
|
const containerWidth = container?.clientWidth ?? 1280;
|
||||||
|
// Reduce sensitivity on small screens to avoid over-shooting.
|
||||||
|
const resolutionFactor = Math.max(0.35, Math.min(1, containerWidth / 1280));
|
||||||
|
const speed = 12 * resolutionFactor * joystickSensitivity;
|
||||||
|
const dx = Math.round(vector.x * speed * frameScale);
|
||||||
|
const dy = Math.round(vector.y * speed * frameScale);
|
||||||
|
if (dx !== 0 || dy !== 0) {
|
||||||
|
mouseEvents.sendVirtualRelativeMovement(dx, dy, 0);
|
||||||
|
}
|
||||||
|
joystickFrameRef.current = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
joystickFrameRef.current = requestAnimationFrame(tick);
|
||||||
|
return () => {
|
||||||
|
if (joystickFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(joystickFrameRef.current);
|
||||||
|
joystickFrameRef.current = null;
|
||||||
|
}
|
||||||
|
joystickLastTsRef.current = null;
|
||||||
|
};
|
||||||
|
}, [showVirtualJoystick, mouseEvents, joystickSensitivity]);
|
||||||
|
|
||||||
const updateButtons = (mask: number, isDown: boolean) => {
|
const updateButtons = (mask: number, isDown: boolean) => {
|
||||||
if (isReinitializingGadget) return;
|
if (isReinitializingGadget) return;
|
||||||
|
|
||||||
let newButtons = activeButtonsRef.current;
|
let newButtons = activeButtonsRef.current;
|
||||||
if (isDown) {
|
if (isDown) {
|
||||||
newButtons |= mask;
|
newButtons |= mask;
|
||||||
|
} else if (lockedButtons & mask) {
|
||||||
|
// Keep pressed while lock is enabled.
|
||||||
|
newButtons |= mask;
|
||||||
} else {
|
} else {
|
||||||
newButtons &= ~mask;
|
newButtons &= ~mask;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isDown && (lockedButtons & mask)) {
|
|
||||||
setLockedButtons(prev => prev & ~mask);
|
|
||||||
}
|
|
||||||
|
|
||||||
activeButtonsRef.current = newButtons;
|
activeButtonsRef.current = newButtons;
|
||||||
|
if (mouseMode === "relative") {
|
||||||
|
mouseEvents.sendVirtualRelativeMovement(0, 0, newButtons);
|
||||||
|
} else {
|
||||||
send("absMouseReport", { x: mouseX, y: mouseY, buttons: newButtons });
|
send("absMouseReport", { x: mouseX, y: mouseY, buttons: newButtons });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleLock = (mask: number) => {
|
const toggleLock = (mask: number) => {
|
||||||
@@ -115,10 +227,14 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
|
|||||||
}
|
}
|
||||||
|
|
||||||
activeButtonsRef.current = newButtons;
|
activeButtonsRef.current = newButtons;
|
||||||
|
if (mouseMode === "relative") {
|
||||||
|
mouseEvents.sendVirtualRelativeMovement(0, 0, newButtons);
|
||||||
|
} else {
|
||||||
send("absMouseReport", { x: mouseX, y: mouseY, buttons: newButtons });
|
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 target = e.currentTarget;
|
||||||
const rect = target.getBoundingClientRect();
|
const rect = target.getBoundingClientRect();
|
||||||
dragOffset.current = {
|
dragOffset.current = {
|
||||||
@@ -136,26 +252,121 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
|
|||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
const x = e.clientX - containerRect.left - dragOffset.current.x;
|
const x = e.clientX - containerRect.left - dragOffset.current.x;
|
||||||
const y = e.clientY - containerRect.top - dragOffset.current.y;
|
const y = e.clientY - containerRect.top - dragOffset.current.y;
|
||||||
const clampedX = Math.max(0, Math.min(containerRect.width - 56, x));
|
const dragWidth = draggingBtn === "wheel" ? 32 : 56;
|
||||||
const clampedY = Math.max(0, Math.min(containerRect.height - 56, y));
|
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") {
|
if (draggingBtn === "left") {
|
||||||
setLeftBtnPos({ x: clampedX, y: clampedY });
|
setLeftBtnPos({ x: clampedX, y: clampedY });
|
||||||
} else if (draggingBtn === "right") {
|
} else if (draggingBtn === "right") {
|
||||||
setRightBtnPos({ x: clampedX, y: clampedY });
|
setRightBtnPos({ x: clampedX, y: clampedY });
|
||||||
} else if (draggingBtn === "leftLock") {
|
} else if (draggingBtn === "wheel") {
|
||||||
setLeftLockPos({ x: clampedX, y: clampedY });
|
setWheelPos({ x: clampedX, y: clampedY });
|
||||||
} else if (draggingBtn === "rightLock") {
|
|
||||||
setRightLockPos({ 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;
|
const wasDragging = draggingBtn === type;
|
||||||
setDraggingBtn(null);
|
setDraggingBtn(null);
|
||||||
(e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
|
(e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
|
||||||
if (!wasDragging) return;
|
if (!wasDragging) return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateJoystickVector = (clientX: number, clientY: number) => {
|
||||||
|
const joystickElm = joystickAreaRef.current;
|
||||||
|
if (!joystickElm) return;
|
||||||
|
const rect = joystickElm.getBoundingClientRect();
|
||||||
|
const centerX = rect.left + rect.width / 2;
|
||||||
|
const centerY = rect.top + rect.height / 2;
|
||||||
|
const rawX = clientX - centerX;
|
||||||
|
const rawY = clientY - centerY;
|
||||||
|
const maxRadius = 32;
|
||||||
|
const length = Math.hypot(rawX, rawY);
|
||||||
|
if (!length || length <= maxRadius) {
|
||||||
|
setJoystickVector({ x: rawX / maxRadius, y: rawY / maxRadius });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scale = maxRadius / length;
|
||||||
|
setJoystickVector({ x: (rawX * scale) / maxRadius, y: (rawY * scale) / maxRadius });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoystickPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (joystickMovePointerIdRef.current !== null) return;
|
||||||
|
e.preventDefault();
|
||||||
|
joystickPointerIdRef.current = e.pointerId;
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
updateJoystickVector(e.clientX, e.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoystickPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (joystickPointerIdRef.current !== e.pointerId) return;
|
||||||
|
e.preventDefault();
|
||||||
|
updateJoystickVector(e.clientX, e.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoystickPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (joystickPointerIdRef.current !== e.pointerId) return;
|
||||||
|
joystickPointerIdRef.current = null;
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
|
setJoystickVector({ x: 0, y: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoystickMoveStart = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
joystickMovePointerIdRef.current = e.pointerId;
|
||||||
|
joystickMoveEnabledRef.current = false;
|
||||||
|
if (joystickMoveHoldTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(joystickMoveHoldTimerRef.current);
|
||||||
|
}
|
||||||
|
joystickMoveHoldTimerRef.current = window.setTimeout(() => {
|
||||||
|
joystickMoveEnabledRef.current = true;
|
||||||
|
}, 350);
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoystickMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (joystickMovePointerIdRef.current !== e.pointerId) return;
|
||||||
|
if (!joystickMoveEnabledRef.current) return;
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const joystickSize = 80;
|
||||||
|
const nextLeft = e.clientX - containerRect.left - joystickSize / 2;
|
||||||
|
const nextTop = e.clientY - containerRect.top - joystickSize / 2;
|
||||||
|
const clampedLeft = Math.max(0, Math.min(containerRect.width - joystickSize, nextLeft));
|
||||||
|
const clampedTop = Math.max(0, Math.min(containerRect.height - joystickSize, nextTop));
|
||||||
|
const nextBottom = containerRect.height - joystickSize - clampedTop;
|
||||||
|
setJoystickPos({ x: clampedLeft, y: nextBottom });
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoystickMoveEnd = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (joystickMovePointerIdRef.current !== e.pointerId) return;
|
||||||
|
if (joystickMoveHoldTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(joystickMoveHoldTimerRef.current);
|
||||||
|
joystickMoveHoldTimerRef.current = null;
|
||||||
|
}
|
||||||
|
joystickMoveEnabledRef.current = false;
|
||||||
|
joystickMovePointerIdRef.current = null;
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMouseControlEnabled = showVirtualJoystick || showVirtualMouseButtons;
|
||||||
|
const joystickSpeedIndex = joystickSpeedLevels.reduce((bestIndex, value, index, arr) => {
|
||||||
|
const bestDistance = Math.abs(arr[bestIndex] - joystickSensitivity);
|
||||||
|
const currentDistance = Math.abs(value - joystickSensitivity);
|
||||||
|
return currentDistance < bestDistance ? index : bestIndex;
|
||||||
|
}, 0);
|
||||||
|
const toggleMouseControl = () => {
|
||||||
|
const nextEnabled = !isMouseControlEnabled;
|
||||||
|
setShowVirtualJoystick(nextEnabled);
|
||||||
|
setShowVirtualMouseButtons(nextEnabled);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const keyboardCleanup = keyboardEvents.setupKeyboardEvents();
|
const keyboardCleanup = keyboardEvents.setupKeyboardEvents();
|
||||||
const videoCleanup = videoStream.setupVideoEventListeners();
|
const videoCleanup = videoStream.setupVideoEventListeners();
|
||||||
@@ -213,6 +424,11 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
|
|||||||
drawerRender={() => (<UsbEpModeSelect/>)}
|
drawerRender={() => (<UsbEpModeSelect/>)}
|
||||||
className={"px-[20px]"}
|
className={"px-[20px]"}
|
||||||
/>
|
/>
|
||||||
|
<EnhancedDrawer
|
||||||
|
targetView={"UsbStatusPanel"}
|
||||||
|
placement={"bottom"}
|
||||||
|
drawerRender={() => (<UsbStatusPanel/>)}
|
||||||
|
/>
|
||||||
|
|
||||||
<EnhancedDrawer
|
<EnhancedDrawer
|
||||||
title={$at("Virtual Media Source")}
|
title={$at("Virtual Media Source")}
|
||||||
@@ -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`,
|
`h-full w-full ${dark_bg_style_fun(isDark)} object-contain transition-all duration-1000`,
|
||||||
{
|
{
|
||||||
"cursor-none": videoEffects.settings.isCursorHidden,
|
"cursor-none": videoEffects.settings.isCursorHidden,
|
||||||
|
"pointer-events-none": isOcrMode,
|
||||||
"opacity-0": overlays.shouldHideVideo,
|
"opacity-0": overlays.shouldHideVideo,
|
||||||
"opacity-60!": overlays.showPointerLockBar,
|
"opacity-60!": overlays.showPointerLockBar,
|
||||||
"animate-slideUpFade dark:border-slate-300/20":
|
"animate-slideUpFade dark:border-slate-300/20":
|
||||||
@@ -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) && (
|
{(videoStream.peerConnectionState === "connected" || forceHttp) && (
|
||||||
<div
|
<div
|
||||||
@@ -296,107 +517,292 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
|
|||||||
className="pointer-events-none absolute inset-0"
|
className="pointer-events-none absolute inset-0"
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
>
|
>
|
||||||
|
<div className="pointer-events-auto absolute right-3 top-3 grid grid-cols-[auto_auto_auto] grid-rows-2 gap-1.5">
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
"pointer-events-auto absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full text-white text-xs",
|
"flex h-8 w-8 items-center justify-center rounded-full text-white",
|
||||||
|
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",
|
isDark ? "bg-gray-500/70" : "bg-black/30",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
}}
|
}}
|
||||||
onClick={() => setShowVirtualMouseButtons(prev => !prev)}
|
onClick={() => touchZoom.resetTransform()}
|
||||||
>
|
>
|
||||||
<BsMouseFill className="h-4 w-4" />
|
<ResetViewIcon />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showVirtualMouseButtons && (
|
{showVirtualMouseButtons && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100",
|
"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",
|
||||||
isDark ? "bg-gray-500/70" : "bg-black/30",
|
(lockedButtons & 1) ? "bg-green-600/80" : (isDark ? "bg-gray-500/70" : "bg-black/30"),
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
left: leftBtnPos.x,
|
left: leftBtnPos.x,
|
||||||
top: leftBtnPos.y,
|
top: leftBtnPos.y,
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
}}
|
}}
|
||||||
onPointerDown={e => {
|
onPointerDown={() => {
|
||||||
handlePointerDown(e, "left");
|
|
||||||
updateButtons(1, true);
|
updateButtons(1, true);
|
||||||
}}
|
}}
|
||||||
onPointerUp={e => {
|
onPointerUp={() => {
|
||||||
handlePointerUp(e, "left");
|
|
||||||
updateButtons(1, false);
|
updateButtons(1, false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
L
|
L
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className="absolute -left-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/45 text-white"
|
||||||
"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 => {
|
onPointerDown={e => {
|
||||||
handlePointerDown(e, "leftLock");
|
e.stopPropagation();
|
||||||
|
handlePointerDown(e, "left");
|
||||||
|
}}
|
||||||
|
onPointerMove={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePointerMove(e);
|
||||||
}}
|
}}
|
||||||
onPointerUp={e => {
|
onPointerUp={e => {
|
||||||
handlePointerUp(e, "leftLock");
|
e.stopPropagation();
|
||||||
|
handlePointerUp(e, "left");
|
||||||
}}
|
}}
|
||||||
onClick={() => toggleLock(1)}
|
|
||||||
>
|
>
|
||||||
{(lockedButtons & 1) ? <BsLockFill /> : <BsUnlockFill />} L
|
<FourWayMoveIcon className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full text-white",
|
||||||
|
(lockedButtons & 1) ? "bg-green-600/90" : "bg-black/45",
|
||||||
|
)}
|
||||||
|
onPointerDown={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleLock(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(lockedButtons & 1) ? <BsLockFill className="h-3 w-3" /> : <BsUnlockFill className="h-3 w-3" />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100",
|
"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",
|
||||||
isDark ? "bg-gray-500/70" : "bg-black/30",
|
(lockedButtons & 2) ? "bg-green-600/80" : (isDark ? "bg-gray-500/70" : "bg-black/30"),
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
left: rightBtnPos.x,
|
left: rightBtnPos.x,
|
||||||
top: rightBtnPos.y,
|
top: rightBtnPos.y,
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
}}
|
}}
|
||||||
onPointerDown={e => {
|
onPointerDown={() => {
|
||||||
handlePointerDown(e, "right");
|
|
||||||
updateButtons(2, true);
|
updateButtons(2, true);
|
||||||
}}
|
}}
|
||||||
onPointerUp={e => {
|
onPointerUp={() => {
|
||||||
handlePointerUp(e, "right");
|
|
||||||
updateButtons(2, false);
|
updateButtons(2, false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
R
|
R
|
||||||
|
<div
|
||||||
|
className="absolute -left-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/45 text-white"
|
||||||
|
onPointerDown={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePointerDown(e, "right");
|
||||||
|
}}
|
||||||
|
onPointerMove={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePointerMove(e);
|
||||||
|
}}
|
||||||
|
onPointerUp={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePointerUp(e, "right");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FourWayMoveIcon className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full text-white",
|
||||||
|
(lockedButtons & 2) ? "bg-green-600/90" : "bg-black/45",
|
||||||
|
)}
|
||||||
|
onPointerDown={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleLock(2);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(lockedButtons & 2) ? <BsLockFill className="h-3 w-3" /> : <BsUnlockFill className="h-3 w-3" />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className="pointer-events-auto absolute"
|
||||||
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100",
|
|
||||||
(lockedButtons & 2) ? "bg-green-600/80" : (isDark ? "bg-gray-500/70" : "bg-black/30"),
|
|
||||||
)}
|
|
||||||
style={{
|
style={{
|
||||||
left: rightLockPos.x,
|
left: wheelPos.x,
|
||||||
top: rightLockPos.y,
|
top: wheelPos.y,
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<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 => {
|
onPointerDown={e => {
|
||||||
handlePointerDown(e, "rightLock");
|
e.stopPropagation();
|
||||||
|
handlePointerDown(e, "wheel");
|
||||||
|
}}
|
||||||
|
onPointerMove={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePointerMove(e);
|
||||||
}}
|
}}
|
||||||
onPointerUp={e => {
|
onPointerUp={e => {
|
||||||
handlePointerUp(e, "rightLock");
|
e.stopPropagation();
|
||||||
|
handlePointerUp(e, "wheel");
|
||||||
}}
|
}}
|
||||||
onClick={() => toggleLock(2)}
|
|
||||||
>
|
>
|
||||||
{(lockedButtons & 2) ? <BsLockFill /> : <BsUnlockFill />} R
|
<FourWayMoveIcon className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"mt-1 flex h-8 w-8 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100",
|
||||||
|
isDark ? "bg-gray-500/70" : "bg-black/30",
|
||||||
|
)}
|
||||||
|
onClick={() => { send("wheelReport", { wheelY: -1 }); }}
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{showVirtualJoystick && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-auto absolute"
|
||||||
|
style={{ left: joystickPos.x, bottom: joystickPos.y }}
|
||||||
|
>
|
||||||
|
<div className="absolute -top-7 left-0 flex items-center gap-1">
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"flex h-6 w-6 items-center justify-center rounded-full text-white transition-transform duration-100 active:scale-95",
|
||||||
|
isDark ? "bg-gray-500/70" : "bg-black/30",
|
||||||
|
)}
|
||||||
|
style={{ touchAction: "none" }}
|
||||||
|
onPointerDown={handleJoystickMoveStart}
|
||||||
|
onPointerMove={handleJoystickMove}
|
||||||
|
onPointerUp={handleJoystickMoveEnd}
|
||||||
|
onPointerCancel={handleJoystickMoveEnd}
|
||||||
|
>
|
||||||
|
<FourWayMoveIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute right-[-36px] top-0 flex h-20 items-center">
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"relative flex h-16 w-3 flex-col justify-between rounded-full py-1",
|
||||||
|
isDark ? "bg-white/25" : "bg-black/20",
|
||||||
|
)}
|
||||||
|
style={{ touchAction: "none" }}
|
||||||
|
>
|
||||||
|
{joystickSpeedLevels.map((level, index) => (
|
||||||
|
<button
|
||||||
|
key={level}
|
||||||
|
type="button"
|
||||||
|
className={cx(
|
||||||
|
"relative z-10 h-3 w-3 rounded-full border",
|
||||||
|
joystickSpeedIndex === index
|
||||||
|
? "border-blue-300 bg-blue-400"
|
||||||
|
: (isDark ? "border-white/60 bg-white/40" : "border-black/40 bg-black/20"),
|
||||||
|
)}
|
||||||
|
onClick={() => setJoystickSensitivity(level)}
|
||||||
|
aria-label={`Set joystick speed ${level.toFixed(2)}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute left-full top-1/2 ml-[1px] -translate-y-1/2 border-y-[4px] border-l-[6px] border-y-transparent border-l-blue-400"
|
||||||
|
style={{
|
||||||
|
top: `${(joystickSpeedIndex / (joystickSpeedLevels.length - 1)) * 100}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ml-1 flex h-16 flex-col justify-between text-[8px] text-white/80">
|
||||||
|
<span>Fast</span>
|
||||||
|
<span>Slow</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={joystickAreaRef}
|
||||||
|
className={cx(
|
||||||
|
"flex h-20 w-20 items-center justify-center rounded-full border",
|
||||||
|
isDark ? "border-white/40 bg-black/20" : "border-black/30 bg-white/20",
|
||||||
|
)}
|
||||||
|
style={{ touchAction: "none" }}
|
||||||
|
onPointerDown={handleJoystickPointerDown}
|
||||||
|
onPointerMove={handleJoystickPointerMove}
|
||||||
|
onPointerUp={handleJoystickPointerUp}
|
||||||
|
onPointerCancel={handleJoystickPointerUp}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"h-9 w-9 rounded-full",
|
||||||
|
isDark ? "bg-white/70" : "bg-black/50",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
transform: `translate(${joystickVector.x * 24}px, ${joystickVector.y * 24}px)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VirtualKeyboard />
|
<VirtualKeyboard />
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { cx } from "@/cva.config";
|
|||||||
import { useVideoEffects } from "@/layout/core/desktop/hooks/useVideoEffects";
|
import { useVideoEffects } from "@/layout/core/desktop/hooks/useVideoEffects";
|
||||||
import { useVideoStream } from "@/layout/core/desktop/hooks/useVideoStream";
|
import { useVideoStream } from "@/layout/core/desktop/hooks/useVideoStream";
|
||||||
import { usePointerLock } from "@/layout/core/desktop/hooks/usePointerLock";
|
import { usePointerLock } from "@/layout/core/desktop/hooks/usePointerLock";
|
||||||
|
import { useFullscreen } from "@/layout/core/desktop/hooks/useFullscreen";
|
||||||
import { useKeyboardEvents } from "@/layout/core/desktop/hooks/useKeyboardEvents";
|
import { useKeyboardEvents } from "@/layout/core/desktop/hooks/useKeyboardEvents";
|
||||||
import { useMouseEvents } from "@/layout/core/desktop/hooks/useMouseEvents";
|
import { useMouseEvents } from "@/layout/core/desktop/hooks/useMouseEvents";
|
||||||
import { useVideoOverlays } from "@/layout/core/desktop/hooks/useVideoOverlays";
|
import { useVideoOverlays } from "@/layout/core/desktop/hooks/useVideoOverlays";
|
||||||
@@ -24,8 +25,9 @@ import { MacroMoreList } from "@/layout/components_side/Macros/MacroTopBar";
|
|||||||
import { useUiStore, useHidStore, useSettingsStore } from "@/hooks/stores";
|
import { useUiStore, useHidStore, useSettingsStore } from "@/hooks/stores";
|
||||||
import { useTouchZoom } from "@/layout/core/desktop/hooks/useTouchZoom";
|
import { useTouchZoom } from "@/layout/core/desktop/hooks/useTouchZoom";
|
||||||
import { usePasteHandler } from "@/layout/core/desktop/hooks/usePasteHandler";
|
import { usePasteHandler } from "@/layout/core/desktop/hooks/usePasteHandler";
|
||||||
|
import OcrOverlay from "@components/OcrOverlay";
|
||||||
|
|
||||||
export default function PCDesktop() {
|
export default function PCDesktop({ isFullscreen }: { isFullscreen?: number }) {
|
||||||
const videoElm = useRef<HTMLVideoElement>(null);
|
const videoElm = useRef<HTMLVideoElement>(null);
|
||||||
const audioElm = useRef<HTMLAudioElement>(null);
|
const audioElm = useRef<HTMLAudioElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -37,6 +39,7 @@ export default function PCDesktop() {
|
|||||||
const setTerminalType = useUiStore(state => state.setTerminalType);
|
const setTerminalType = useUiStore(state => state.setTerminalType);
|
||||||
const terminalType = useUiStore(state => state.terminalType);
|
const terminalType = useUiStore(state => state.terminalType);
|
||||||
const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled);
|
const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled);
|
||||||
|
const isOcrMode = useUiStore(state => state.isOcrMode);
|
||||||
|
|
||||||
const forceHttp = useSettingsStore(state => state.forceHttp);
|
const forceHttp = useSettingsStore(state => state.forceHttp);
|
||||||
|
|
||||||
@@ -55,6 +58,7 @@ export default function PCDesktop() {
|
|||||||
const videoEffects = useVideoEffects();
|
const videoEffects = useVideoEffects();
|
||||||
const videoStream = useVideoStream(videoElm as React.RefObject<HTMLVideoElement>, audioElm as React.RefObject<HTMLAudioElement>);
|
const videoStream = useVideoStream(videoElm as React.RefObject<HTMLVideoElement>, audioElm as React.RefObject<HTMLAudioElement>);
|
||||||
const pointerLock = usePointerLock(videoElm as React.RefObject<HTMLVideoElement>);
|
const pointerLock = usePointerLock(videoElm as React.RefObject<HTMLVideoElement>);
|
||||||
|
useFullscreen(videoElm as React.RefObject<HTMLVideoElement>, pointerLock, isFullscreen);
|
||||||
const touchZoom = useTouchZoom(zoomContainerRef as React.RefObject<HTMLDivElement>);
|
const touchZoom = useTouchZoom(zoomContainerRef as React.RefObject<HTMLDivElement>);
|
||||||
const { handleGlobalPaste } = usePasteHandler(pasteCaptureRef as React.RefObject<HTMLTextAreaElement>);
|
const { handleGlobalPaste } = usePasteHandler(pasteCaptureRef as React.RefObject<HTMLTextAreaElement>);
|
||||||
|
|
||||||
@@ -114,6 +118,7 @@ export default function PCDesktop() {
|
|||||||
`max-h-full min-h-[384px] max-w-full min-w-[512px] object-contain transition-all duration-1000`,
|
`max-h-full min-h-[384px] max-w-full min-w-[512px] object-contain transition-all duration-1000`,
|
||||||
{
|
{
|
||||||
"cursor-none": videoEffects.settings.isCursorHidden,
|
"cursor-none": videoEffects.settings.isCursorHidden,
|
||||||
|
"pointer-events-none": isOcrMode,
|
||||||
"opacity-0": overlays.shouldHideVideo,
|
"opacity-0": overlays.shouldHideVideo,
|
||||||
"opacity-60!": overlays.showPointerLockBar,
|
"opacity-60!": overlays.showPointerLockBar,
|
||||||
"animate-slideUpFade shadow-xs ":
|
"animate-slideUpFade shadow-xs ":
|
||||||
@@ -121,6 +126,10 @@ export default function PCDesktop() {
|
|||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<OcrOverlay
|
||||||
|
videoRef={videoElm as React.RefObject<HTMLVideoElement>}
|
||||||
|
containerRef={zoomContainerRef as React.RefObject<HTMLDivElement>}
|
||||||
|
/>
|
||||||
|
|
||||||
{(videoStream.peerConnectionState === "connected" || forceHttp) && (
|
{(videoStream.peerConnectionState === "connected" || forceHttp) && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
import useKeyboard from "@/hooks/useKeyboard";
|
import useKeyboard from "@/hooks/useKeyboard";
|
||||||
import { useHidStore, useSettingsStore } from "@/hooks/stores";
|
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
|
||||||
import { keys, modifiers } from "@/keyboardMappings";
|
import { keys, modifiers } from "@/keyboardMappings";
|
||||||
|
import { keyboards } from "@/keyboardLayouts";
|
||||||
|
import { eventMatchesShortcut } from "@/utils/shortcuts";
|
||||||
|
|
||||||
export const useKeyboardEvents = (
|
export const useKeyboardEvents = (
|
||||||
pasteCaptureRef?: React.RefObject<HTMLTextAreaElement>,
|
pasteCaptureRef?: React.RefObject<HTMLTextAreaElement>,
|
||||||
@@ -14,7 +16,31 @@ export const useKeyboardEvents = (
|
|||||||
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
|
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
|
||||||
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
|
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
|
||||||
const isKeyboardLedManagedByHost = keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable;
|
const isKeyboardLedManagedByHost = keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable;
|
||||||
const 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 handleModifierKeys = useCallback((e: KeyboardEvent, activeModifiers: number[]) => {
|
||||||
const { shiftKey, ctrlKey, altKey, metaKey } = e;
|
const { shiftKey, ctrlKey, altKey, metaKey } = e;
|
||||||
@@ -28,8 +54,8 @@ export const useKeyboardEvents = (
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const keyDownHandler = useCallback(async (e: KeyboardEvent) => {
|
const keyDownHandler = useCallback(async (e: KeyboardEvent) => {
|
||||||
if (overrideCtrlV && (e.code === "KeyV" || e.key.toLowerCase() === "v") && (e.ctrlKey || e.metaKey)) {
|
if (isOcrMode) return;
|
||||||
console.log("Override Ctrl V");
|
if (pasteShortcutEnabled && eventMatchesShortcut(e, pasteShortcut)) {
|
||||||
if (isReinitializingGadget) return;
|
if (isReinitializingGadget) return;
|
||||||
if (pasteCaptureRef && pasteCaptureRef.current) {
|
if (pasteCaptureRef && pasteCaptureRef.current) {
|
||||||
pasteCaptureRef.current.value = "";
|
pasteCaptureRef.current.value = "";
|
||||||
@@ -39,6 +65,11 @@ export const useKeyboardEvents = (
|
|||||||
}
|
}
|
||||||
if (isReinitializingGadget) return;
|
if (isReinitializingGadget) return;
|
||||||
|
|
||||||
|
if (e.repeat) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const prev = useHidStore.getState();
|
const prev = useHidStore.getState();
|
||||||
let code = e.code;
|
let code = e.code;
|
||||||
@@ -56,6 +87,8 @@ export const useKeyboardEvents = (
|
|||||||
code = "IntlBackslash";
|
code = "IntlBackslash";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
code = remapCode(code, key);
|
||||||
|
|
||||||
const newKeys = [...prev.activeKeys, keys[code]].filter(Boolean);
|
const newKeys = [...prev.activeKeys, keys[code]].filter(Boolean);
|
||||||
const newModifiers = handleModifierKeys(e, [...prev.activeModifiers, modifiers[code]]);
|
const newModifiers = handleModifierKeys(e, [...prev.activeModifiers, modifiers[code]]);
|
||||||
|
|
||||||
@@ -67,12 +100,15 @@ export const useKeyboardEvents = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
|
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) => {
|
const keyUpHandler = useCallback((e: KeyboardEvent) => {
|
||||||
|
if (isOcrMode) return;
|
||||||
if (isReinitializingGadget) return;
|
if (isReinitializingGadget) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const prev = useHidStore.getState();
|
const prev = useHidStore.getState();
|
||||||
|
const key = e.key;
|
||||||
|
let code = remapCode(e.code, key);
|
||||||
|
|
||||||
if (!isKeyboardLedManagedByHost) {
|
if (!isKeyboardLedManagedByHost) {
|
||||||
setIsNumLockActive(e.getModifierState("NumLock"));
|
setIsNumLockActive(e.getModifierState("NumLock"));
|
||||||
@@ -80,14 +116,14 @@ export const useKeyboardEvents = (
|
|||||||
setIsScrollLockActive(e.getModifierState("ScrollLock"));
|
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(
|
const newModifiers = handleModifierKeys(
|
||||||
e,
|
e,
|
||||||
prev.activeModifiers.filter(k => k !== modifiers[e.code]),
|
prev.activeModifiers.filter(k => k !== modifiers[code]),
|
||||||
);
|
);
|
||||||
|
|
||||||
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
|
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 setupKeyboardEvents = useCallback(() => {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|||||||
@@ -25,15 +25,18 @@ export const useMouseEvents = (
|
|||||||
const { setMousePosition, setMouseMove } = useMouseStore();
|
const { setMousePosition, setMouseMove } = useMouseStore();
|
||||||
const { width: videoWidth, height: videoHeight } = useVideoStore();
|
const { width: videoWidth, height: videoHeight } = useVideoStore();
|
||||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||||
|
const touchDragActiveRef = useRef(false);
|
||||||
|
|
||||||
const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos);
|
const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos);
|
||||||
|
|
||||||
const sendRelMouseMovement = useCallback(
|
const sendRelMouseMovement = useCallback(
|
||||||
(x: number, y: number, buttons: number) => {
|
(x: number, y: number, buttons: number, force = false) => {
|
||||||
if (settings.mouseMode !== "relative") return;
|
if (!force && settings.mouseMode !== "relative") return;
|
||||||
// Don't send mouse events while reinitializing gadget
|
// Don't send mouse events while reinitializing gadget
|
||||||
if (isReinitializingGadget) return;
|
if (isReinitializingGadget) return;
|
||||||
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 });
|
setMouseMove({ x, y, buttons });
|
||||||
},
|
},
|
||||||
[send, setMouseMove, settings.mouseMode, isReinitializingGadget],
|
[send, setMouseMove, settings.mouseMode, isReinitializingGadget],
|
||||||
@@ -50,6 +53,13 @@ export const useMouseEvents = (
|
|||||||
[send, setMousePosition, settings.mouseMode, isReinitializingGadget],
|
[send, setMousePosition, settings.mouseMode, isReinitializingGadget],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sendVirtualRelativeMovement = useCallback(
|
||||||
|
(x: number, y: number, buttons = 0) => {
|
||||||
|
sendRelMouseMovement(x, y, buttons, true);
|
||||||
|
},
|
||||||
|
[sendRelMouseMovement],
|
||||||
|
);
|
||||||
|
|
||||||
const relMouseMoveHandler = useCallback(
|
const relMouseMoveHandler = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
const pt = (e as unknown as PointerEvent).pointerType as unknown as string;
|
const pt = (e as unknown as PointerEvent).pointerType as unknown as string;
|
||||||
@@ -76,11 +86,17 @@ export const useMouseEvents = (
|
|||||||
const absMouseMoveHandler = useCallback(
|
const absMouseMoveHandler = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
const pt = (e as unknown as PointerEvent).pointerType as unknown as string;
|
const pt = (e as unknown as PointerEvent).pointerType as unknown as string;
|
||||||
|
const pointerEvent = e as unknown as PointerEvent;
|
||||||
|
const eventType = pointerEvent.type;
|
||||||
if (pt === "touch") {
|
if (pt === "touch") {
|
||||||
if (touchZoom) {
|
if (touchZoom) {
|
||||||
const touchCount = touchZoom.activeTouchPointers.current.size;
|
const touchCount = touchZoom.activeTouchPointers.current.size;
|
||||||
const eventType = (e as unknown as PointerEvent).type;
|
if (touchCount >= 2) {
|
||||||
if (touchCount >= 2 && eventType !== "pointerup") return;
|
if (eventType === "pointerup" || eventType === "pointercancel") {
|
||||||
|
touchDragActiveRef.current = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,28 +151,20 @@ export const useMouseEvents = (
|
|||||||
let buttons = e.buttons;
|
let buttons = e.buttons;
|
||||||
|
|
||||||
if (pt === "touch") {
|
if (pt === "touch") {
|
||||||
const touchCount = touchZoom ? touchZoom.activeTouchPointers.current.size : 1;
|
if (eventType === "pointerdown") {
|
||||||
const pointerEvent = e as unknown as PointerEvent;
|
touchDragActiveRef.current = !disableTouchClick;
|
||||||
const eventType = pointerEvent.type;
|
|
||||||
|
|
||||||
if (eventType === "pointerup") {
|
|
||||||
if (touchCount >= 2 || disableTouchClick) {
|
|
||||||
buttons = 0;
|
|
||||||
} else {
|
|
||||||
buttons = 1;
|
|
||||||
}
|
}
|
||||||
} else {
|
if (eventType === "pointerup" || eventType === "pointercancel") {
|
||||||
buttons = 0;
|
buttons = 0;
|
||||||
|
touchDragActiveRef.current = false;
|
||||||
|
} else {
|
||||||
|
buttons = touchDragActiveRef.current ? 1 : 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buttons |= externalButtons;
|
buttons |= externalButtons;
|
||||||
|
|
||||||
sendAbsMouseMovement(x, y, buttons);
|
sendAbsMouseMovement(x, y, buttons);
|
||||||
|
|
||||||
if (pt === "touch" && buttons !== externalButtons && (e as unknown as PointerEvent).type === "pointerup") {
|
|
||||||
sendAbsMouseMovement(x, y, externalButtons);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[settings.mouseMode, videoElm, videoWidth, videoHeight, sendAbsMouseMovement, touchZoom, disableTouchClick, externalButtons],
|
[settings.mouseMode, videoElm, videoWidth, videoHeight, sendAbsMouseMovement, touchZoom, disableTouchClick, externalButtons],
|
||||||
);
|
);
|
||||||
@@ -216,8 +224,10 @@ export const useMouseEvents = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
videoElmRefValue.addEventListener("mousemove", eventHandler, { signal });
|
videoElmRefValue.addEventListener("mousemove", eventHandler, { signal });
|
||||||
|
videoElmRefValue.addEventListener("pointermove", eventHandler, { signal });
|
||||||
videoElmRefValue.addEventListener("pointerdown", eventHandler, { signal });
|
videoElmRefValue.addEventListener("pointerdown", eventHandler, { signal });
|
||||||
videoElmRefValue.addEventListener("pointerup", eventHandler, { signal });
|
videoElmRefValue.addEventListener("pointerup", eventHandler, { signal });
|
||||||
|
videoElmRefValue.addEventListener("pointercancel", eventHandler, { signal });
|
||||||
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
|
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
|
||||||
signal,
|
signal,
|
||||||
passive: true,
|
passive: true,
|
||||||
@@ -255,5 +265,6 @@ export const useMouseEvents = (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
setupMouseEvents,
|
setupMouseEvents,
|
||||||
|
sendVirtualRelativeMovement,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import { useSettingsStore, useHidStore, useUiStore } from "@/hooks/stores";
|
|||||||
import { keys, modifiers } from "@/keyboardMappings";
|
import { keys, modifiers } from "@/keyboardMappings";
|
||||||
import { chars } from "@/keyboardLayouts";
|
import { chars } from "@/keyboardLayouts";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
|
import { eventMatchesShortcut } from "@/utils/shortcuts";
|
||||||
|
|
||||||
export const usePasteHandler = (pasteCaptureRef?: React.RefObject<HTMLTextAreaElement>) => {
|
export const usePasteHandler = (pasteCaptureRef?: React.RefObject<HTMLTextAreaElement>) => {
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
const overrideCtrlV = useSettingsStore(state => state.overrideCtrlV);
|
const pasteShortcutEnabled = useSettingsStore(state => state.pasteShortcutEnabled);
|
||||||
|
const pasteShortcut = useSettingsStore(state => state.pasteShortcut);
|
||||||
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
|
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
|
||||||
const setKeyboardLayout = useSettingsStore(state => state.setKeyboardLayout);
|
const setKeyboardLayout = useSettingsStore(state => state.setKeyboardLayout);
|
||||||
const debugMode = useSettingsStore(state => state.debugMode);
|
const debugMode = useSettingsStore(state => state.debugMode);
|
||||||
@@ -140,12 +142,11 @@ export const usePasteHandler = (pasteCaptureRef?: React.RefObject<HTMLTextAreaEl
|
|||||||
}, [log, send, setKeyboardLayout]);
|
}, [log, send, setKeyboardLayout]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!overrideCtrlV) return;
|
if (!pasteShortcutEnabled) return;
|
||||||
|
|
||||||
const onKeyDownCapture = (e: KeyboardEvent) => {
|
const onKeyDownCapture = (e: KeyboardEvent) => {
|
||||||
if (!overrideCtrlV) return;
|
if (!pasteShortcutEnabled) return;
|
||||||
if (!(e.ctrlKey || e.metaKey)) return;
|
if (!eventMatchesShortcut(e, pasteShortcut)) return;
|
||||||
if (e.code !== "KeyV" && e.key.toLowerCase() !== "v") return;
|
|
||||||
if (isReinitializingGadget) return;
|
if (isReinitializingGadget) return;
|
||||||
|
|
||||||
const activeElement = document.activeElement as HTMLElement | null;
|
const activeElement = document.activeElement as HTMLElement | null;
|
||||||
@@ -196,20 +197,20 @@ export const usePasteHandler = (pasteCaptureRef?: React.RefObject<HTMLTextAreaEl
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("keydown", onKeyDownCapture, { capture: true });
|
document.removeEventListener("keydown", onKeyDownCapture, { capture: true });
|
||||||
};
|
};
|
||||||
}, [ensureFocusTrapPaused, isReinitializingGadget, log, overrideCtrlV, pasteCaptureRef, safeKeyboardLayout, sendTextToRemote]);
|
}, [ensureFocusTrapPaused, isReinitializingGadget, log, pasteShortcutEnabled, pasteShortcut, pasteCaptureRef, safeKeyboardLayout, sendTextToRemote]);
|
||||||
|
|
||||||
const handleGlobalPaste = useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement> | ClipboardEvent) => {
|
const handleGlobalPaste = useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement> | ClipboardEvent) => {
|
||||||
if (!overrideCtrlV) return;
|
if (!pasteShortcutEnabled) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const clipboardData = (e as React.ClipboardEvent).clipboardData || (e as ClipboardEvent).clipboardData;
|
const clipboardData = (e as React.ClipboardEvent).clipboardData || (e as ClipboardEvent).clipboardData;
|
||||||
const txt = clipboardData?.getData("text") || "";
|
const txt = clipboardData?.getData("text") || "";
|
||||||
|
|
||||||
await sendTextToRemote(txt);
|
await sendTextToRemote(txt);
|
||||||
}, [log, overrideCtrlV, safeKeyboardLayout, sendTextToRemote]);
|
}, [log, pasteShortcutEnabled, safeKeyboardLayout, sendTextToRemote]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleGlobalPaste,
|
handleGlobalPaste,
|
||||||
overrideCtrlV,
|
pasteShortcutEnabled,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,31 +7,17 @@ export const usePointerLock = (videoElm: React.RefObject<HTMLVideoElement>) => {
|
|||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost";
|
const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost";
|
||||||
|
|
||||||
const checkNavigatorPermissions = useCallback(async (permissionName: string) => {
|
|
||||||
if (!navigator.permissions?.query) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { state } = await navigator.permissions.query({
|
|
||||||
name: permissionName as PermissionName
|
|
||||||
});
|
|
||||||
return state === "granted";
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const requestPointerLock = useCallback(async () => {
|
const requestPointerLock = useCallback(async () => {
|
||||||
if (!isPointerLockPossible || !videoElm.current || document.pointerLockElement) return;
|
if (!isPointerLockPossible || !videoElm.current || document.pointerLockElement || settings.mouseMode !== "relative") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isPointerLockGranted = await checkNavigatorPermissions("pointer-lock");
|
|
||||||
if (isPointerLockGranted && settings.mouseMode === "relative") {
|
|
||||||
try {
|
try {
|
||||||
await videoElm.current.requestPointerLock();
|
await videoElm.current.requestPointerLock();
|
||||||
} catch {
|
} catch (err) {
|
||||||
// ignore errors
|
console.warn("[pointer-lock] requestPointerLock failed:", err);
|
||||||
}
|
}
|
||||||
}
|
}, [isPointerLockPossible, settings.mouseMode, videoElm]);
|
||||||
}, [checkNavigatorPermissions, isPointerLockPossible, settings.mouseMode, videoElm]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPointerLockPossible || !videoElm.current) return;
|
if (!isPointerLockPossible || !videoElm.current) return;
|
||||||
@@ -40,6 +26,7 @@ export const usePointerLock = (videoElm: React.RefObject<HTMLVideoElement>) => {
|
|||||||
setIsPointerLockActive(!!document.pointerLockElement);
|
setIsPointerLockActive(!!document.pointerLockElement);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handlePointerLockChange();
|
||||||
document.addEventListener("pointerlockchange", handlePointerLockChange);
|
document.addEventListener("pointerlockchange", handlePointerLockChange);
|
||||||
return () => document.removeEventListener("pointerlockchange", handlePointerLockChange);
|
return () => document.removeEventListener("pointerlockchange", handlePointerLockChange);
|
||||||
}, [isPointerLockPossible, videoElm]);
|
}, [isPointerLockPossible, videoElm]);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
export const useTouchZoom = (
|
export const useTouchZoom = (
|
||||||
containerRef: React.RefObject<HTMLDivElement>
|
containerRef: React.RefObject<HTMLDivElement>,
|
||||||
|
gestureEnabled = true,
|
||||||
) => {
|
) => {
|
||||||
const [mobileScale, setMobileScale] = useState(1);
|
const [mobileScale, setMobileScale] = useState(1);
|
||||||
const [mobileTx, setMobileTx] = useState(0);
|
const [mobileTx, setMobileTx] = useState(0);
|
||||||
@@ -17,67 +18,83 @@ export const useTouchZoom = (
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const signal = abortController.signal;
|
const signal = abortController.signal;
|
||||||
|
const isPointInVideo = (x: number, y: number) => {
|
||||||
|
const video = el.querySelector("video") as HTMLVideoElement | null;
|
||||||
|
if (!video) return false;
|
||||||
|
const vRect = video.getBoundingClientRect();
|
||||||
|
return x >= vRect.left && x <= vRect.right && y >= vRect.top && y <= vRect.bottom;
|
||||||
|
};
|
||||||
|
|
||||||
const onPointerDown = (e: PointerEvent) => {
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
if (e.pointerType !== "touch") return;
|
if (e.pointerType !== "touch") return;
|
||||||
activeTouchPointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
activeTouchPointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
|
if (!gestureEnabled) return;
|
||||||
|
let shouldHandleLocalGesture = false;
|
||||||
|
|
||||||
if (activeTouchPointers.current.size === 1) {
|
if (activeTouchPointers.current.size === 1) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
let isInVideo = false;
|
const isInVideo = isPointInVideo(e.clientX, e.clientY);
|
||||||
const video = el.querySelector("video") as HTMLVideoElement | null;
|
|
||||||
if (video) {
|
|
||||||
const vRect = video.getBoundingClientRect();
|
|
||||||
if (
|
|
||||||
e.clientX >= vRect.left &&
|
|
||||||
e.clientX <= vRect.right &&
|
|
||||||
e.clientY >= vRect.top &&
|
|
||||||
e.clientY <= vRect.bottom
|
|
||||||
) {
|
|
||||||
isInVideo = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!isInVideo) {
|
if (!isInVideo) {
|
||||||
if (now - lastTapAt.current < 300) {
|
if (now - lastTapAt.current < 300) {
|
||||||
setMobileScale(1);
|
setMobileScale(1);
|
||||||
setMobileTx(0);
|
setMobileTx(0);
|
||||||
setMobileTy(0);
|
setMobileTy(0);
|
||||||
}
|
}
|
||||||
|
shouldHandleLocalGesture = true;
|
||||||
}
|
}
|
||||||
lastTapAt.current = now;
|
lastTapAt.current = now;
|
||||||
|
if (mobileScale > 1) {
|
||||||
lastPanPoint.current = { x: e.clientX, y: e.clientY };
|
lastPanPoint.current = { x: e.clientX, y: e.clientY };
|
||||||
|
shouldHandleLocalGesture = true;
|
||||||
|
} else {
|
||||||
|
lastPanPoint.current = null;
|
||||||
|
}
|
||||||
} else if (activeTouchPointers.current.size === 2) {
|
} else if (activeTouchPointers.current.size === 2) {
|
||||||
const pts = Array.from(activeTouchPointers.current.values());
|
const pts = Array.from(activeTouchPointers.current.values());
|
||||||
const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
||||||
initialPinchDistance.current = d;
|
initialPinchDistance.current = d;
|
||||||
initialPinchScale.current = mobileScale;
|
initialPinchScale.current = mobileScale;
|
||||||
|
shouldHandleLocalGesture = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldHandleLocalGesture) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPointerMove = (e: PointerEvent) => {
|
const onPointerMove = (e: PointerEvent) => {
|
||||||
if (e.pointerType !== "touch") return;
|
if (e.pointerType !== "touch") return;
|
||||||
const prev = activeTouchPointers.current.get(e.pointerId);
|
const prev = activeTouchPointers.current.get(e.pointerId);
|
||||||
activeTouchPointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
activeTouchPointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
|
if (!gestureEnabled) return;
|
||||||
const pts = Array.from(activeTouchPointers.current.values());
|
const pts = Array.from(activeTouchPointers.current.values());
|
||||||
|
let shouldHandleLocalGesture = false;
|
||||||
|
|
||||||
if (pts.length === 2 && initialPinchDistance.current) {
|
if (pts.length === 2 && initialPinchDistance.current) {
|
||||||
const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
||||||
const factor = d / initialPinchDistance.current;
|
const factor = d / initialPinchDistance.current;
|
||||||
const next = Math.max(1, Math.min(4, initialPinchScale.current * factor));
|
const next = Math.max(1, Math.min(4, initialPinchScale.current * factor));
|
||||||
setMobileScale(next);
|
setMobileScale(next);
|
||||||
} 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 dx = e.clientX - lastPanPoint.current.x;
|
||||||
const dy = e.clientY - lastPanPoint.current.y;
|
const dy = e.clientY - lastPanPoint.current.y;
|
||||||
lastPanPoint.current = { x: e.clientX, y: e.clientY };
|
lastPanPoint.current = { x: e.clientX, y: e.clientY };
|
||||||
setMobileTx(v => v + dx);
|
setMobileTx(v => v + dx);
|
||||||
setMobileTy(v => v + dy);
|
setMobileTy(v => v + dy);
|
||||||
|
shouldHandleLocalGesture = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldHandleLocalGesture) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPointerUp = (e: PointerEvent) => {
|
const onPointerUp = (e: PointerEvent) => {
|
||||||
if (e.pointerType !== "touch") return;
|
if (e.pointerType !== "touch") return;
|
||||||
|
const wasHandlingLocalGesture = gestureEnabled && (initialPinchDistance.current !== null || mobileScale > 1);
|
||||||
activeTouchPointers.current.delete(e.pointerId);
|
activeTouchPointers.current.delete(e.pointerId);
|
||||||
if (activeTouchPointers.current.size < 2) {
|
if (activeTouchPointers.current.size < 2) {
|
||||||
initialPinchDistance.current = null;
|
initialPinchDistance.current = null;
|
||||||
@@ -85,8 +102,11 @@ export const useTouchZoom = (
|
|||||||
if (activeTouchPointers.current.size === 0) {
|
if (activeTouchPointers.current.size === 0) {
|
||||||
lastPanPoint.current = null;
|
lastPanPoint.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (wasHandlingLocalGesture) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
el.addEventListener("pointerdown", onPointerDown, { signal });
|
el.addEventListener("pointerdown", onPointerDown, { signal });
|
||||||
@@ -95,7 +115,15 @@ export const useTouchZoom = (
|
|||||||
el.addEventListener("pointercancel", onPointerUp, { signal });
|
el.addEventListener("pointercancel", onPointerUp, { signal });
|
||||||
|
|
||||||
return () => abortController.abort();
|
return () => abortController.abort();
|
||||||
}, [mobileScale, containerRef]);
|
}, [mobileScale, containerRef, gestureEnabled]);
|
||||||
|
|
||||||
|
const resetTransform = () => {
|
||||||
|
initialPinchDistance.current = null;
|
||||||
|
lastPanPoint.current = null;
|
||||||
|
setMobileScale(1);
|
||||||
|
setMobileTx(0);
|
||||||
|
setMobileTy(0);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@@ -115,5 +143,6 @@ export const useTouchZoom = (
|
|||||||
mobileTy,
|
mobileTy,
|
||||||
activeTouchPointers,
|
activeTouchPointers,
|
||||||
lastPanPoint,
|
lastPanPoint,
|
||||||
|
resetTransform,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ export default function Desktop({ isFullscreen }: { isFullscreen?: number }) {
|
|||||||
if(isMobile){
|
if(isMobile){
|
||||||
return <MobileDesktop isFullscreen={isFullscreen}/>
|
return <MobileDesktop isFullscreen={isFullscreen}/>
|
||||||
}
|
}
|
||||||
return <PCDesktop/> ;
|
return <PCDesktop isFullscreen={isFullscreen}/> ;
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
useVpnStore } from "@/hooks/stores";
|
useVpnStore } from "@/hooks/stores";
|
||||||
import { JsonRpcRequest, useJsonRpc, resetHttpSessionId } from "@/hooks/useJsonRpc";
|
import { JsonRpcRequest, useJsonRpc, resetHttpSessionId } from "@/hooks/useJsonRpc";
|
||||||
|
import api from "@/api";
|
||||||
import Modal from "@components/Modal";
|
import Modal from "@components/Modal";
|
||||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||||
import {
|
import {
|
||||||
@@ -351,6 +352,18 @@ export default function MobileHome() {
|
|||||||
try {
|
try {
|
||||||
console.log("[setupPeerConnection] Creating peer connection");
|
console.log("[setupPeerConnection] Creating peer connection");
|
||||||
setLoadingMessage("Creating peer connection...");
|
setLoadingMessage("Creating peer connection...");
|
||||||
|
let fetchedIceServers: RTCIceServer[] = [];
|
||||||
|
if (!iceConfig?.iceServers) {
|
||||||
|
try {
|
||||||
|
const res = await api.GET("/api/ice-servers");
|
||||||
|
const data = await res.json();
|
||||||
|
fetchedIceServers = data.iceServers ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error("failed to fetch ICE servers, fallback", e);
|
||||||
|
fetchedIceServers = [{ urls: ["stun:stun.l.google.com:19302"] }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pc = new RTCPeerConnection({
|
pc = new RTCPeerConnection({
|
||||||
// We only use STUN or TURN servers if we're in the cloud
|
// We only use STUN or TURN servers if we're in the cloud
|
||||||
//...(isInCloud && iceConfig?.iceServers
|
//...(isInCloud && iceConfig?.iceServers
|
||||||
@@ -358,13 +371,7 @@ export default function MobileHome() {
|
|||||||
// : {}),
|
// : {}),
|
||||||
...(iceConfig?.iceServers
|
...(iceConfig?.iceServers
|
||||||
? { iceServers: [iceConfig?.iceServers] }
|
? { iceServers: [iceConfig?.iceServers] }
|
||||||
: {
|
: { iceServers: fetchedIceServers }),
|
||||||
iceServers: [
|
|
||||||
{
|
|
||||||
urls: ['stun:stun.l.google.com:19302']
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setPeerConnectionState(pc.connectionState);
|
setPeerConnectionState(pc.connectionState);
|
||||||
|
|||||||
@@ -363,6 +363,18 @@ export default function PCHome() {
|
|||||||
try {
|
try {
|
||||||
console.log("[setupPeerConnection] Creating peer connection");
|
console.log("[setupPeerConnection] Creating peer connection");
|
||||||
setLoadingMessage("Creating peer connection...");
|
setLoadingMessage("Creating peer connection...");
|
||||||
|
let fetchedIceServers: RTCIceServer[] = [];
|
||||||
|
if (!iceConfig?.iceServers) {
|
||||||
|
try {
|
||||||
|
const res = await api.GET("/api/ice-servers");
|
||||||
|
const data = await res.json();
|
||||||
|
fetchedIceServers = data.iceServers ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error("failed to fetch ICE servers, fallback", e);
|
||||||
|
fetchedIceServers = [{ urls: ["stun:stun.l.google.com:19302"] }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pc = new RTCPeerConnection({
|
pc = new RTCPeerConnection({
|
||||||
// We only use STUN or TURN servers if we're in the cloud
|
// We only use STUN or TURN servers if we're in the cloud
|
||||||
//...(isInCloud && iceConfig?.iceServers
|
//...(isInCloud && iceConfig?.iceServers
|
||||||
@@ -370,13 +382,7 @@ export default function PCHome() {
|
|||||||
// : {}),
|
// : {}),
|
||||||
...(iceConfig?.iceServers
|
...(iceConfig?.iceServers
|
||||||
? { iceServers: [iceConfig?.iceServers] }
|
? { iceServers: [iceConfig?.iceServers] }
|
||||||
: {
|
: { iceServers: fetchedIceServers }),
|
||||||
iceServers: [
|
|
||||||
{
|
|
||||||
urls: ['stun:stun.l.google.com:19302']
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setPeerConnectionState(pc.connectionState);
|
setPeerConnectionState(pc.connectionState);
|
||||||
@@ -837,9 +843,9 @@ export default function PCHome() {
|
|||||||
<Desktop isFullscreen={isFullscreen} />
|
<Desktop isFullscreen={isFullscreen} />
|
||||||
<div
|
<div
|
||||||
style={{ animationDuration: "500ms" }}
|
style={{ animationDuration: "500ms" }}
|
||||||
className="animate-slideUpFade pointer-events-none absolute inset-0 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}
|
{!!ConnectionStatusElement && ConnectionStatusElement}
|
||||||
{/*<ConnectionFailedOverlay show={true} setupPeerConnection={setupPeerConnection} />*/}
|
{/*<ConnectionFailedOverlay show={true} setupPeerConnection={setupPeerConnection} />*/}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,10 +14,11 @@
|
|||||||
"9aab28af69": "Variation in packet delay, affecting video smoothness.",
|
"9aab28af69": "Variation in packet delay, affecting video smoothness.",
|
||||||
"75316ccce3": "Frame per second",
|
"75316ccce3": "Frame per second",
|
||||||
"75a4e60fe7": "Number of video frames displayed per second.",
|
"75a4e60fe7": "Number of video frames displayed per second.",
|
||||||
|
"867f8b3151": "Only {{types}} files are supported",
|
||||||
"13c27e178b": "Please select the file {{fileName}} to continue the upload.",
|
"13c27e178b": "Please select the file {{fileName}} to continue the upload.",
|
||||||
"8282fb0974": "Resume Upload",
|
"8282fb0974": "Resume Upload",
|
||||||
"411f67b4c6": "Click here to select {{fileName}} to resume upload",
|
"411f67b4c6": "Click here to select {{fileName}} to resume upload",
|
||||||
"5d2e347cdb": "Click here to upload a new image",
|
"6e8daad8ab": "Click here to upload",
|
||||||
"ddeb0eac69": "Do not support directories",
|
"ddeb0eac69": "Do not support directories",
|
||||||
"3f1c5b0049": "Uploading",
|
"3f1c5b0049": "Uploading",
|
||||||
"f287042190": "Uploading...",
|
"f287042190": "Uploading...",
|
||||||
@@ -103,6 +104,14 @@
|
|||||||
"f9099fc033": "Renew DHCP Lease",
|
"f9099fc033": "Renew DHCP Lease",
|
||||||
"6e188f5984": "IPv6 Information",
|
"6e188f5984": "IPv6 Information",
|
||||||
"cfdffa4fc7": "Link-local",
|
"cfdffa4fc7": "Link-local",
|
||||||
|
"a588b1abc5": "Copied",
|
||||||
|
"c5505be6c3": "No text detected",
|
||||||
|
"a29aaf6603": "OCR failed",
|
||||||
|
"dc67ba5f40": "Drag to select text area",
|
||||||
|
"b603b043e8": "Copy text",
|
||||||
|
"f5f78c501b": "Recognizing text...",
|
||||||
|
"5df17e67f5": "Review the recognized text before copying.",
|
||||||
|
"4dfb4a614f": "Please wait while OCR is running.",
|
||||||
"d6e24388b4": "Continue Uploading",
|
"d6e24388b4": "Continue Uploading",
|
||||||
"62a5e49088": "Hide",
|
"62a5e49088": "Hide",
|
||||||
"b34e855501": "USB Expansion Function",
|
"b34e855501": "USB Expansion Function",
|
||||||
@@ -172,6 +181,24 @@
|
|||||||
"c40a72ad93": "KVM Terminal",
|
"c40a72ad93": "KVM Terminal",
|
||||||
"dd110018d2": "Serial Console",
|
"dd110018d2": "Serial Console",
|
||||||
"7b277018e4": "Not connected",
|
"7b277018e4": "Not connected",
|
||||||
|
"02d2f33ec9": "Reinitialize USB Gadget",
|
||||||
|
"c8e875e1c9": "Version Manager",
|
||||||
|
"63a6a88c06": "Refresh",
|
||||||
|
"d15f6e3312": "Hide Install Actions",
|
||||||
|
"2b9b84a087": "Show Install Actions",
|
||||||
|
"81dfff9d6f": "Install Status",
|
||||||
|
"98dd43dfae": "Installed",
|
||||||
|
"ddd8eef6f8": "Not Installed",
|
||||||
|
"e2dc83997b": "Detected Version",
|
||||||
|
"e3cc7e7df9": "System Architecture",
|
||||||
|
"bc56777902": "Release Version",
|
||||||
|
"c319e3982f": "Release Asset",
|
||||||
|
"06933067aa": "Update",
|
||||||
|
"a27dfe7717": "Uninstall",
|
||||||
|
"349838fb1d": "Install",
|
||||||
|
"4a0764788d": "Install Task",
|
||||||
|
"46b5f8c58b": "Progress",
|
||||||
|
"5e4caae72b": "Installed Versions",
|
||||||
"bf733d8a93": "Access",
|
"bf733d8a93": "Access",
|
||||||
"70e23e7d6f": "Manage the Access Control of the device",
|
"70e23e7d6f": "Manage the Access Control of the device",
|
||||||
"509820290d": "Local",
|
"509820290d": "Local",
|
||||||
@@ -194,6 +221,8 @@
|
|||||||
"efd3cc702e": "Enable Password",
|
"efd3cc702e": "Enable Password",
|
||||||
"8f1e77e0d2": "Change Password",
|
"8f1e77e0d2": "Change Password",
|
||||||
"3d0de21428": "Update your device access password",
|
"3d0de21428": "Update your device access password",
|
||||||
|
"d6d56d5972": "WebRTC Servers",
|
||||||
|
"8bea04c48e": "STUN and TURN servers used for peer connections",
|
||||||
"f8508f576c": "Remote",
|
"f8508f576c": "Remote",
|
||||||
"a8bb6f5f9f": "Manage the mode of Remote access to the device",
|
"a8bb6f5f9f": "Manage the mode of Remote access to the device",
|
||||||
"0b86461350": "TailScale use xEdge server",
|
"0b86461350": "TailScale use xEdge server",
|
||||||
@@ -250,6 +279,58 @@
|
|||||||
"c420b0d8f0": "Enter Cloudflare Tunnel Token",
|
"c420b0d8f0": "Enter Cloudflare Tunnel Token",
|
||||||
"1bbabcdef3": "Edit frpc.toml",
|
"1bbabcdef3": "Edit frpc.toml",
|
||||||
"9c088a303a": "Enter frpc configuration",
|
"9c088a303a": "Enter frpc configuration",
|
||||||
|
"87ed72fc20": "Failed to get firewall config",
|
||||||
|
"a25ea1e1ee": "Failed to apply firewall config",
|
||||||
|
"bcbc6c71a1": "Firewall config applied",
|
||||||
|
"f4b0e93291": "Please select protocol",
|
||||||
|
"7687f1154f": "Missing required fields",
|
||||||
|
"a133cae395": "Invalid port",
|
||||||
|
"d10b3069c2": "For PREROUTING, Destination IP must be a real host IP",
|
||||||
|
"0834bb6c87": "Destination IP is required",
|
||||||
|
"c5381dc540": "Firewall",
|
||||||
|
"8256c0c407": "Manage the firewall rules of the device",
|
||||||
|
"972e73b7a8": "Basic",
|
||||||
|
"7218073b8c": "Communication Rules",
|
||||||
|
"23fed496dd": "Port Forwarding",
|
||||||
|
"324118a672": "Input",
|
||||||
|
"29c2c02a36": "Output",
|
||||||
|
"67d2f6740a": "Forward",
|
||||||
|
"a4d3b161ce": "Submit",
|
||||||
|
"ec211f7c20": "Add",
|
||||||
|
"5320550175": "Chain",
|
||||||
|
"f31bbdd1b3": "Source",
|
||||||
|
"888a77f5ac": "Protocol",
|
||||||
|
"12007e1d59": "Destination",
|
||||||
|
"004bf6c9a4": "Action",
|
||||||
|
"b5a7adde1a": "Description",
|
||||||
|
"2a78ed7645": "Operation",
|
||||||
|
"095c375025": "No rules available",
|
||||||
|
"ed36a1ef76": "Any",
|
||||||
|
"7dce122004": "Edit",
|
||||||
|
"efba20a02e": "No data available",
|
||||||
|
"e0aa021e21": "OK",
|
||||||
|
"7d367dab8b": "Source IP",
|
||||||
|
"c050956ed2": "Source Port",
|
||||||
|
"94386968c2": "Destination IP",
|
||||||
|
"64ae5dd047": "Destination Port",
|
||||||
|
"fbb3878eb7": "Submit Firewall Policies?",
|
||||||
|
"1c17a7e15b": "Warning: Adjusting some policies may cause network address loss, leading to device unavailability.",
|
||||||
|
"6f20995c95": "Failed to load WebRTC servers",
|
||||||
|
"aee9784c03": "Unknown error",
|
||||||
|
"6ef7ce5b80": "Failed to save STUN",
|
||||||
|
"4cd48aed7f": "STUN server saved",
|
||||||
|
"4b5d050e51": "Failed to save TURN",
|
||||||
|
"f6f10b4517": "TURN servers saved",
|
||||||
|
"d235995f96": "STUN Server",
|
||||||
|
"fc1bd2c935": "Public STUN server for NAT traversal",
|
||||||
|
"289929755b": "Restore Default",
|
||||||
|
"0268827609": "TURN Servers",
|
||||||
|
"b3c14a0273": "Used as relay when direct peer-to-peer connection fails",
|
||||||
|
"bbc48fb751": "No TURN servers configured",
|
||||||
|
"f6039d44b2": "Username",
|
||||||
|
"03bc142e64": "Credential",
|
||||||
|
"ca3e8baee9": "Add TURN Server",
|
||||||
|
"0acde1b3e3": "Save TURN Servers",
|
||||||
"867cee98fd": "Passwords do not match",
|
"867cee98fd": "Passwords do not match",
|
||||||
"9864ff9420": "Please enter your old password",
|
"9864ff9420": "Please enter your old password",
|
||||||
"14a714ab22": "Please enter a new password",
|
"14a714ab22": "Please enter a new password",
|
||||||
@@ -290,24 +371,44 @@
|
|||||||
"3369c97f56": "Enter your SSH public key",
|
"3369c97f56": "Enter your SSH public key",
|
||||||
"81bafb2833": "The default SSH user is ",
|
"81bafb2833": "The default SSH user is ",
|
||||||
"7a941a0f87": "Update SSH Key",
|
"7a941a0f87": "Update SSH Key",
|
||||||
|
"d876ff8da6": "API Key",
|
||||||
|
"96164f17cf": "API key for MCP and REST API authentication",
|
||||||
|
"538ce1893b": "Enter API key or leave empty to auto-generate",
|
||||||
|
"a55c6d580b": "Used for authenticating MCP and REST API requests.",
|
||||||
|
"e85c388332": "Save API Key",
|
||||||
|
"927c25953b": "Generate New",
|
||||||
"fdad65b7be": "Force HTTP Transmission",
|
"fdad65b7be": "Force HTTP Transmission",
|
||||||
"e033b5e2a3": "Force using HTTP for video streaming instead of WebRTC",
|
"e033b5e2a3": "Force using HTTP for video streaming instead of WebRTC",
|
||||||
|
"bda22ca687": "USB detection enhancement",
|
||||||
|
"f7df928057": "The DISC state is also checked during USB status retrieval",
|
||||||
"021fa314ef": "USB Emulation",
|
"021fa314ef": "USB Emulation",
|
||||||
"5c43d74dbd": "Control the USB emulation state",
|
"5c43d74dbd": "Control the USB emulation state",
|
||||||
"f6c8ddbadf": "Disable USB Emulation",
|
"f6c8ddbadf": "Disable USB Emulation",
|
||||||
"020b92cfbb": "Enable USB Emulation",
|
"020b92cfbb": "Enable USB Emulation",
|
||||||
"9f55f64b0f": "USB Gadget Reinitialize",
|
"9f55f64b0f": "USB Gadget Reinitialize",
|
||||||
"40dc677a89": "Reinitialize USB gadget configuration",
|
"40dc677a89": "Reinitialize USB gadget configuration",
|
||||||
"02d2f33ec9": "Reinitialize USB Gadget",
|
|
||||||
"f5ddf02991": "Reboot System",
|
"f5ddf02991": "Reboot System",
|
||||||
"1dbbf194af": "Restart the device system",
|
"1dbbf194af": "Restart the device system",
|
||||||
"1de72c4fc6": "Reboot",
|
"1de72c4fc6": "Reboot",
|
||||||
|
"c887e5a479": "Edit Configuration",
|
||||||
|
"90b21a0536": "Edit the raw configuration file directly",
|
||||||
"f43c0398a4": "Reset Configuration",
|
"f43c0398a4": "Reset Configuration",
|
||||||
"0031dbef48": "Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.",
|
"0031dbef48": "Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.",
|
||||||
"0d784092e8": "Reset Config",
|
"0d784092e8": "Reset Config",
|
||||||
|
"115082e888": "Clear API Key?",
|
||||||
|
"78fcaed30d": "Setting the API key to empty will auto-generate a new random key.",
|
||||||
|
"81e8b4cd6b": "Make sure to update your clients with the new key after saving.",
|
||||||
|
"211730be68": "Generate New Key",
|
||||||
"a776e925bf": "Reboot System?",
|
"a776e925bf": "Reboot System?",
|
||||||
"1f070051ff": "Are you sure you want to reboot the system?",
|
"1f070051ff": "Are you sure you want to reboot the system?",
|
||||||
"f1a79f466e": "The device will restart and you will be disconnected from the web interface.",
|
"f1a79f466e": "The device will restart and you will be disconnected from the web interface.",
|
||||||
|
"919ff9ff77": "Edit the raw configuration JSON. Be careful when making changes as invalid JSON can cause system issues.",
|
||||||
|
"4e11db406c": "Saving",
|
||||||
|
"f24a0236a3": "Configuration Saved",
|
||||||
|
"b032420c4b": "Configuration has been saved successfully. Some changes may require a system restart to take effect.",
|
||||||
|
"c8850fa947": "Would you like to restart the system now?",
|
||||||
|
"61057a0c84": "Later",
|
||||||
|
"2d9c2140c5": "Restart Now",
|
||||||
"0db377921f": "General",
|
"0db377921f": "General",
|
||||||
"3845ee1693": "Configure device settings and update preferences",
|
"3845ee1693": "Configure device settings and update preferences",
|
||||||
"d721757161": "Theme",
|
"d721757161": "Theme",
|
||||||
@@ -354,6 +455,12 @@
|
|||||||
"ef64a3770e": "Control mDNS (multicast DNS) operational mode",
|
"ef64a3770e": "Control mDNS (multicast DNS) operational mode",
|
||||||
"be8226fe0c": "Time synchronization",
|
"be8226fe0c": "Time synchronization",
|
||||||
"7e06bd28a6": "Configure time synchronization settings",
|
"7e06bd28a6": "Configure time synchronization settings",
|
||||||
|
"a2323452ba": "HTTP Proxy",
|
||||||
|
"7fedd1ea53": "Configure program HTTP proxy (optional)",
|
||||||
|
"a30d487cba": "HTTPS Proxy",
|
||||||
|
"3f4c7e23cb": "Configure program HTTPS proxy (optional)",
|
||||||
|
"48d941841f": "ALL Proxy",
|
||||||
|
"3a2cd1e4a7": "Configure program ALL proxy (optional)",
|
||||||
"d4dccb8ca2": "Save settings",
|
"d4dccb8ca2": "Save settings",
|
||||||
"8750a898cb": "IPv4 Mode",
|
"8750a898cb": "IPv4 Mode",
|
||||||
"72c2543791": "Configure IPv4 mode",
|
"72c2543791": "Configure IPv4 mode",
|
||||||
@@ -372,6 +479,8 @@
|
|||||||
"a30194c638": "This will save the requested IPv4 address. Changes take effect after a restart.",
|
"a30194c638": "This will save the requested IPv4 address. Changes take effect after a restart.",
|
||||||
"cc172c234b": "Change IPv4 Mode?",
|
"cc172c234b": "Change IPv4 Mode?",
|
||||||
"a344a29861": "IPv4 mode changes will take effect after a restart.",
|
"a344a29861": "IPv4 mode changes will take effect after a restart.",
|
||||||
|
"0ddd0ae8fd": "Change MAC Address?",
|
||||||
|
"d0158ce3c1": "Changing the MAC address may cause the device IP to be reassigned and changed.",
|
||||||
"34b6cd7517": "Version",
|
"34b6cd7517": "Version",
|
||||||
"a1937e8ac1": "Check the versions of the system and applications",
|
"a1937e8ac1": "Check the versions of the system and applications",
|
||||||
"04a115bdd8": "AppVersion",
|
"04a115bdd8": "AppVersion",
|
||||||
@@ -393,19 +502,40 @@
|
|||||||
"5b404b3c98": "Update in Background",
|
"5b404b3c98": "Update in Background",
|
||||||
"3723a3f846": "System is up to date",
|
"3723a3f846": "System is up to date",
|
||||||
"a6b8796d51": "Your system is running the latest version. No updates are currently available.",
|
"a6b8796d51": "Your system is running the latest version. No updates are currently available.",
|
||||||
|
"a8d2a89696": "Missing Signature File",
|
||||||
|
"480f05b41b": "The current firmware is missing signature files. Integrity cannot be fully verified.",
|
||||||
|
"323ebc9620": "Signature Verification Failed",
|
||||||
|
"21355c9ecb": "The signature file exists but does not match the firmware. This may indicate tampering.",
|
||||||
|
"0b54a6c322": "No Embedded Public Key",
|
||||||
|
"9beb932d5a": "This build does not have an OTA public key embedded. Signature verification is unavailable.",
|
||||||
"7c81553358": "Check Again",
|
"7c81553358": "Check Again",
|
||||||
|
"3b5f9a1f01": "Update Signatures",
|
||||||
|
"d25bfbb978": "Update the signature of kvm_app to the latest version. If the current version is not up to date, signature verification will fail.",
|
||||||
|
"1bdd158a19": "Signature Update Result",
|
||||||
"217b416896": "Update available",
|
"217b416896": "Update available",
|
||||||
"f00af8c98f": "A new update is available to enhance system performance and improve compatibility. We recommend updating to ensure everything runs smoothly.",
|
"f00af8c98f": "A new update is available to enhance system performance and improve compatibility. We recommend updating to ensure everything runs smoothly.",
|
||||||
|
"8977c3f0b7": "Download Proxy Prefix",
|
||||||
"99b1054c0f": "Update Now",
|
"99b1054c0f": "Update Now",
|
||||||
"7f3cd4480d": "Do it later",
|
"7f3cd4480d": "Do it later",
|
||||||
"cffae9918d": "Update Error",
|
"cffae9918d": "Update Error",
|
||||||
"49cba7cadf": "An error occurred while updating your device. Please try again later.",
|
"49cba7cadf": "An error occurred while updating your device. Please try again later.",
|
||||||
"d849d5b330": "Error details:",
|
"d849d5b330": "Error details:",
|
||||||
"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",
|
"2b1a1676d1": "Copy text from your client to the remote host",
|
||||||
"604c45fbf2": "The following characters will not be pasted:",
|
"604c45fbf2": "The following characters will not be pasted:",
|
||||||
"a7eb9efa0b": "Sending text using keyboard layout:",
|
"a7eb9efa0b": "Sending text using keyboard layout:",
|
||||||
"2593d0a3d5": "Confirm paste",
|
"2593d0a3d5": "Confirm paste",
|
||||||
|
"ac7e2a9783": "Enable OCR shortcut",
|
||||||
|
"f529c51ee6": "OCR",
|
||||||
|
"607440855c": "Open OCR selection mode on the video area",
|
||||||
|
"c3bf447eab": "Open",
|
||||||
"a0e3947a02": "Macros",
|
"a0e3947a02": "Macros",
|
||||||
"276043dbf0": "Create a new keyboard macro",
|
"276043dbf0": "Create a new keyboard macro",
|
||||||
"9605ef9593": "Modify your keyboard macro",
|
"9605ef9593": "Modify your keyboard macro",
|
||||||
@@ -413,7 +543,6 @@
|
|||||||
"6b2f64058e": "Are you sure you want to delete this macro? This action cannot be undone.",
|
"6b2f64058e": "Are you sure you want to delete this macro? This action cannot be undone.",
|
||||||
"ffbb410a55": "Deleting",
|
"ffbb410a55": "Deleting",
|
||||||
"3dd2e50646": "Delay Only",
|
"3dd2e50646": "Delay Only",
|
||||||
"7dce122004": "Edit",
|
|
||||||
"5fb63579fc": "Copy",
|
"5fb63579fc": "Copy",
|
||||||
"fc2839fcdf": "+ Add new macro",
|
"fc2839fcdf": "+ Add new macro",
|
||||||
"05fd7d5b9c": "Are you sure you want to delete",
|
"05fd7d5b9c": "Are you sure you want to delete",
|
||||||
@@ -436,6 +565,8 @@
|
|||||||
"21d104a54f": "Processing...",
|
"21d104a54f": "Processing...",
|
||||||
"9844086d90": "No SD Card Detected",
|
"9844086d90": "No SD Card Detected",
|
||||||
"b56e598918": "SD Card Mount Failed",
|
"b56e598918": "SD Card Mount Failed",
|
||||||
|
"d646589704": "Choose the file system for MicroSD formatting",
|
||||||
|
"16eb8ed6c8": "Format MicroSD Card",
|
||||||
"a63d5e0260": "Manage Shared Folders in KVM MicroSD Card",
|
"a63d5e0260": "Manage Shared Folders in KVM MicroSD Card",
|
||||||
"1d50425f88": "Manage Shared Folders in KVM Storage",
|
"1d50425f88": "Manage Shared Folders in KVM Storage",
|
||||||
"74ff4bad28": "No files found",
|
"74ff4bad28": "No files found",
|
||||||
@@ -446,8 +577,15 @@
|
|||||||
"8bf8854beb": "of",
|
"8bf8854beb": "of",
|
||||||
"53e61336bb": "results",
|
"53e61336bb": "results",
|
||||||
"d1f5a81904": "Unmount MicroSD Card",
|
"d1f5a81904": "Unmount MicroSD Card",
|
||||||
|
"5874cf46ff": "SD card formatted successfully",
|
||||||
"7dc25305d4": "Encodec Type",
|
"7dc25305d4": "Encodec Type",
|
||||||
"41d263a988": "Stream Quality",
|
"41d263a988": "Stream Quality",
|
||||||
|
"d8d7a5377b": "RC Control",
|
||||||
|
"a09ff5b350": "Adjust rate control QP settings for better balance between quality and bitrate",
|
||||||
|
"b1f9d95e72": "StepQp",
|
||||||
|
"bf1af7559b": "MinQp",
|
||||||
|
"6e4e284fcc": "MinIQp",
|
||||||
|
"df1b8f72f2": "DetlpQp",
|
||||||
"41f5283941": "NPU Application",
|
"41f5283941": "NPU Application",
|
||||||
"466990072d": "Enable NPU to Object Detection",
|
"466990072d": "Enable NPU to Object Detection",
|
||||||
"a6c2e30b8e": "Video Enhancement",
|
"a6c2e30b8e": "Video Enhancement",
|
||||||
@@ -464,6 +602,8 @@
|
|||||||
"85a003df9b": "EDID File",
|
"85a003df9b": "EDID File",
|
||||||
"5c7a666766": "Set Custom EDID",
|
"5c7a666766": "Set Custom EDID",
|
||||||
"7dad0ba758": "Restore to default",
|
"7dad0ba758": "Restore to default",
|
||||||
|
"7b2fb72a68": "RC Advanced Config",
|
||||||
|
"827048afc2": "Formatting the SD card will erase all data. Continue?",
|
||||||
"556c7553f1": "KVM MicroSD Card Mount",
|
"556c7553f1": "KVM MicroSD Card Mount",
|
||||||
"b99cf1ecb7": "Manage and mount images from MicroSD card",
|
"b99cf1ecb7": "Manage and mount images from MicroSD card",
|
||||||
"0a01e80566": "Mounted from KVM storage",
|
"0a01e80566": "Mounted from KVM storage",
|
||||||
|
|||||||
@@ -14,10 +14,11 @@
|
|||||||
"9aab28af69": "数据包延迟的变化,影响视频流畅度。",
|
"9aab28af69": "数据包延迟的变化,影响视频流畅度。",
|
||||||
"75316ccce3": "帧率",
|
"75316ccce3": "帧率",
|
||||||
"75a4e60fe7": "每秒显示的视频帧数。",
|
"75a4e60fe7": "每秒显示的视频帧数。",
|
||||||
|
"867f8b3151": "只支持 {{types}} 文件",
|
||||||
"13c27e178b": "请选择 {{fileName}} 继续上传",
|
"13c27e178b": "请选择 {{fileName}} 继续上传",
|
||||||
"8282fb0974": "重新上传",
|
"8282fb0974": "重新上传",
|
||||||
"411f67b4c6": "点击这里选择 {{fileName}} 重新上传",
|
"411f67b4c6": "点击这里选择 {{fileName}} 重新上传",
|
||||||
"5d2e347cdb": "点击这里上传新镜像",
|
"6e8daad8ab": "点击这里上传",
|
||||||
"ddeb0eac69": "不支持目录",
|
"ddeb0eac69": "不支持目录",
|
||||||
"3f1c5b0049": "上传中",
|
"3f1c5b0049": "上传中",
|
||||||
"f287042190": "正在上传...",
|
"f287042190": "正在上传...",
|
||||||
@@ -103,6 +104,14 @@
|
|||||||
"f9099fc033": "重订 DHCP 租约",
|
"f9099fc033": "重订 DHCP 租约",
|
||||||
"6e188f5984": "IPv6 信息",
|
"6e188f5984": "IPv6 信息",
|
||||||
"cfdffa4fc7": "本地链路",
|
"cfdffa4fc7": "本地链路",
|
||||||
|
"a588b1abc5": "已复制",
|
||||||
|
"c5505be6c3": "未检测到文本",
|
||||||
|
"a29aaf6603": "OCR 失败",
|
||||||
|
"dc67ba5f40": "拖动选择文本区域",
|
||||||
|
"b603b043e8": "复制文本",
|
||||||
|
"f5f78c501b": "正在识别文本...",
|
||||||
|
"5df17e67f5": "复制前请检查识别出的文本。",
|
||||||
|
"4dfb4a614f": "请等待 OCR 运行完成。",
|
||||||
"d6e24388b4": "继续上传",
|
"d6e24388b4": "继续上传",
|
||||||
"62a5e49088": "隐藏",
|
"62a5e49088": "隐藏",
|
||||||
"b34e855501": "USB拓展功能",
|
"b34e855501": "USB拓展功能",
|
||||||
@@ -172,6 +181,24 @@
|
|||||||
"c40a72ad93": "KVM 终端",
|
"c40a72ad93": "KVM 终端",
|
||||||
"dd110018d2": "串口控制台",
|
"dd110018d2": "串口控制台",
|
||||||
"7b277018e4": "未连接",
|
"7b277018e4": "未连接",
|
||||||
|
"02d2f33ec9": "重新初始化 USB 设备",
|
||||||
|
"c8e875e1c9": "版本管理器",
|
||||||
|
"63a6a88c06": "刷新",
|
||||||
|
"d15f6e3312": "隐藏安装操作",
|
||||||
|
"2b9b84a087": "显示安装操作",
|
||||||
|
"81dfff9d6f": "安装状态",
|
||||||
|
"98dd43dfae": "已安装",
|
||||||
|
"ddd8eef6f8": "未安装",
|
||||||
|
"e2dc83997b": "检测到的版本",
|
||||||
|
"e3cc7e7df9": "系统架构",
|
||||||
|
"bc56777902": "发布版本",
|
||||||
|
"c319e3982f": "发布资源",
|
||||||
|
"06933067aa": "更新",
|
||||||
|
"a27dfe7717": "卸载",
|
||||||
|
"349838fb1d": "安装",
|
||||||
|
"4a0764788d": "安装任务",
|
||||||
|
"46b5f8c58b": "进度",
|
||||||
|
"5e4caae72b": "已安装版本",
|
||||||
"bf733d8a93": "访问",
|
"bf733d8a93": "访问",
|
||||||
"70e23e7d6f": "管理设备的访问控制",
|
"70e23e7d6f": "管理设备的访问控制",
|
||||||
"509820290d": "本地",
|
"509820290d": "本地",
|
||||||
@@ -194,6 +221,8 @@
|
|||||||
"efd3cc702e": "启用密码",
|
"efd3cc702e": "启用密码",
|
||||||
"8f1e77e0d2": "更改密码",
|
"8f1e77e0d2": "更改密码",
|
||||||
"3d0de21428": "更新设备访问密码",
|
"3d0de21428": "更新设备访问密码",
|
||||||
|
"d6d56d5972": "WebRTC 服务器",
|
||||||
|
"8bea04c48e": "用于点对点连接的 STUN 和 TURN 服务器",
|
||||||
"f8508f576c": "远程",
|
"f8508f576c": "远程",
|
||||||
"a8bb6f5f9f": "管理远程访问设备的方式",
|
"a8bb6f5f9f": "管理远程访问设备的方式",
|
||||||
"0b86461350": "TailScale 使用 xEdge 服务器",
|
"0b86461350": "TailScale 使用 xEdge 服务器",
|
||||||
@@ -250,6 +279,58 @@
|
|||||||
"c420b0d8f0": "输入 Cloudflare 通道令牌",
|
"c420b0d8f0": "输入 Cloudflare 通道令牌",
|
||||||
"1bbabcdef3": "编辑 frpc.toml",
|
"1bbabcdef3": "编辑 frpc.toml",
|
||||||
"9c088a303a": "输入 frpc 配置",
|
"9c088a303a": "输入 frpc 配置",
|
||||||
|
"87ed72fc20": "获取防火墙配置失败",
|
||||||
|
"a25ea1e1ee": "应用防火墙配置失败",
|
||||||
|
"bcbc6c71a1": "防火墙配置已应用",
|
||||||
|
"f4b0e93291": "请选择协议",
|
||||||
|
"7687f1154f": "缺少必填字段",
|
||||||
|
"a133cae395": "无效端口",
|
||||||
|
"d10b3069c2": "对于 PREROUTING,目标 IP 必须是真实主机 IP",
|
||||||
|
"0834bb6c87": "目标 IP 是必填项",
|
||||||
|
"c5381dc540": "防火墙",
|
||||||
|
"8256c0c407": "管理设备的防火墙规则",
|
||||||
|
"972e73b7a8": "基础",
|
||||||
|
"7218073b8c": "通讯规则",
|
||||||
|
"23fed496dd": "端口转发",
|
||||||
|
"324118a672": "输入",
|
||||||
|
"29c2c02a36": "输出",
|
||||||
|
"67d2f6740a": "转发",
|
||||||
|
"a4d3b161ce": "提交",
|
||||||
|
"ec211f7c20": "添加",
|
||||||
|
"5320550175": "链",
|
||||||
|
"f31bbdd1b3": "源",
|
||||||
|
"888a77f5ac": "协议",
|
||||||
|
"12007e1d59": "目标",
|
||||||
|
"004bf6c9a4": "操作",
|
||||||
|
"b5a7adde1a": "描述",
|
||||||
|
"2a78ed7645": "操作",
|
||||||
|
"095c375025": "没有可用规则",
|
||||||
|
"ed36a1ef76": "任何",
|
||||||
|
"7dce122004": "编辑",
|
||||||
|
"efba20a02e": "没有可用数据",
|
||||||
|
"e0aa021e21": "确定",
|
||||||
|
"7d367dab8b": "源 IP",
|
||||||
|
"c050956ed2": "源端口",
|
||||||
|
"94386968c2": "目标 IP",
|
||||||
|
"64ae5dd047": "目标端口",
|
||||||
|
"fbb3878eb7": "提交防火墙策略?",
|
||||||
|
"1c17a7e15b": "警告:调整某些策略可能会导致网络地址丢失,导致设备不可用。",
|
||||||
|
"6f20995c95": "加载 WebRTC 服务器失败",
|
||||||
|
"aee9784c03": "未知错误",
|
||||||
|
"6ef7ce5b80": "保存 STUN 失败",
|
||||||
|
"4cd48aed7f": "STUN 服务器已保存",
|
||||||
|
"4b5d050e51": "保存 TURN 失败",
|
||||||
|
"f6f10b4517": "TURN 服务器已保存",
|
||||||
|
"d235995f96": "STUN 服务器",
|
||||||
|
"fc1bd2c935": "用于 NAT 穿透的公共 STUN 服务器",
|
||||||
|
"289929755b": "恢复默认",
|
||||||
|
"0268827609": "TURN 服务器",
|
||||||
|
"b3c14a0273": "当直接点对点连接失败时用作中继",
|
||||||
|
"bbc48fb751": "未配置 TURN 服务器",
|
||||||
|
"f6039d44b2": "用户名",
|
||||||
|
"03bc142e64": "凭据",
|
||||||
|
"ca3e8baee9": "添加 TURN 服务器",
|
||||||
|
"0acde1b3e3": "保存 TURN 服务器",
|
||||||
"867cee98fd": "密码不一致",
|
"867cee98fd": "密码不一致",
|
||||||
"9864ff9420": "请输入旧密码",
|
"9864ff9420": "请输入旧密码",
|
||||||
"14a714ab22": "请输入新密码",
|
"14a714ab22": "请输入新密码",
|
||||||
@@ -290,24 +371,44 @@
|
|||||||
"3369c97f56": "输入您的 SSH 公钥",
|
"3369c97f56": "输入您的 SSH 公钥",
|
||||||
"81bafb2833": "默认 SSH 用户是 ",
|
"81bafb2833": "默认 SSH 用户是 ",
|
||||||
"7a941a0f87": "更新 SSH 密钥",
|
"7a941a0f87": "更新 SSH 密钥",
|
||||||
|
"d876ff8da6": "API 密钥",
|
||||||
|
"96164f17cf": "用于 MCP 和 REST API 认证的 API 密钥",
|
||||||
|
"538ce1893b": "输入 API 密钥或留空以自动生成",
|
||||||
|
"a55c6d580b": "用于认证 MCP 和 REST API 请求。",
|
||||||
|
"e85c388332": "保存 API 密钥",
|
||||||
|
"927c25953b": "生成新密钥",
|
||||||
"fdad65b7be": "强制 HTTP 传输",
|
"fdad65b7be": "强制 HTTP 传输",
|
||||||
"e033b5e2a3": "强制使用 HTTP 传输数据替代 WebRTC",
|
"e033b5e2a3": "强制使用 HTTP 传输数据替代 WebRTC",
|
||||||
|
"bda22ca687": "USB 检测增强",
|
||||||
|
"f7df928057": "在获取 USB 状态时也会检查 DISC 状态",
|
||||||
"021fa314ef": "USB 复用",
|
"021fa314ef": "USB 复用",
|
||||||
"5c43d74dbd": "控制 USB 复用状态",
|
"5c43d74dbd": "控制 USB 复用状态",
|
||||||
"f6c8ddbadf": "禁用 USB 复用",
|
"f6c8ddbadf": "禁用 USB 复用",
|
||||||
"020b92cfbb": "启用 USB 复用",
|
"020b92cfbb": "启用 USB 复用",
|
||||||
"9f55f64b0f": "USB 设备重新初始化",
|
"9f55f64b0f": "USB 设备重新初始化",
|
||||||
"40dc677a89": "重新初始化 USB 设备配置",
|
"40dc677a89": "重新初始化 USB 设备配置",
|
||||||
"02d2f33ec9": "重新初始化 USB 设备",
|
|
||||||
"f5ddf02991": "重启系统",
|
"f5ddf02991": "重启系统",
|
||||||
"1dbbf194af": "重启设备系统",
|
"1dbbf194af": "重启设备系统",
|
||||||
"1de72c4fc6": "重启",
|
"1de72c4fc6": "重启",
|
||||||
|
"c887e5a479": "编辑配置",
|
||||||
|
"90b21a0536": "编辑原始配置文件",
|
||||||
"f43c0398a4": "重置配置",
|
"f43c0398a4": "重置配置",
|
||||||
"0031dbef48": "重置配置,这将使你退出登录。部分配置重启后生效。",
|
"0031dbef48": "重置配置,这将使你退出登录。部分配置重启后生效。",
|
||||||
"0d784092e8": "重置配置",
|
"0d784092e8": "重置配置",
|
||||||
|
"115082e888": "清除 API 密钥?",
|
||||||
|
"78fcaed30d": "将 API 密钥留空将自动生成一个新的随机密钥。",
|
||||||
|
"81e8b4cd6b": "请确保在保存后使用新密钥更新您的客户端。",
|
||||||
|
"211730be68": "生成新密钥",
|
||||||
"a776e925bf": "重启系统?",
|
"a776e925bf": "重启系统?",
|
||||||
"1f070051ff": "你确定重启系统吗?",
|
"1f070051ff": "你确定重启系统吗?",
|
||||||
"f1a79f466e": "设备将重启,你将从 Web 界面断开连接。",
|
"f1a79f466e": "设备将重启,你将从 Web 界面断开连接。",
|
||||||
|
"919ff9ff77": "编辑原始配置 JSON。小心修改,无效 JSON 可能导致系统问题。",
|
||||||
|
"4e11db406c": "保存中",
|
||||||
|
"f24a0236a3": "配置已保存",
|
||||||
|
"b032420c4b": "配置已成功保存。某些更改可能需要系统重启才能生效。",
|
||||||
|
"c8850fa947": "是否现在重启系统?",
|
||||||
|
"61057a0c84": "稍后",
|
||||||
|
"2d9c2140c5": "立刻重启",
|
||||||
"0db377921f": "常规",
|
"0db377921f": "常规",
|
||||||
"3845ee1693": "查看设备的版本信息",
|
"3845ee1693": "查看设备的版本信息",
|
||||||
"d721757161": "主题",
|
"d721757161": "主题",
|
||||||
@@ -354,6 +455,12 @@
|
|||||||
"ef64a3770e": "控制 mDNS(多播 DNS)运行模式",
|
"ef64a3770e": "控制 mDNS(多播 DNS)运行模式",
|
||||||
"be8226fe0c": "时间同步",
|
"be8226fe0c": "时间同步",
|
||||||
"7e06bd28a6": "配置时间同步设置",
|
"7e06bd28a6": "配置时间同步设置",
|
||||||
|
"a2323452ba": "HTTP 代理",
|
||||||
|
"7fedd1ea53": "配置程序 HTTP 代理(可选)",
|
||||||
|
"a30d487cba": "HTTPS 代理",
|
||||||
|
"3f4c7e23cb": "配置程序 HTTPS 代理(可选)",
|
||||||
|
"48d941841f": "ALL 代理",
|
||||||
|
"3a2cd1e4a7": "配置程序 ALL 代理(可选)",
|
||||||
"d4dccb8ca2": "保存设置",
|
"d4dccb8ca2": "保存设置",
|
||||||
"8750a898cb": "IPv4 模式",
|
"8750a898cb": "IPv4 模式",
|
||||||
"72c2543791": "配置 IPv4 模式",
|
"72c2543791": "配置 IPv4 模式",
|
||||||
@@ -372,6 +479,8 @@
|
|||||||
"a30194c638": "这将保存请求的 IPv4 地址。更改将在重启后生效。",
|
"a30194c638": "这将保存请求的 IPv4 地址。更改将在重启后生效。",
|
||||||
"cc172c234b": "更改 IPv4 模式?",
|
"cc172c234b": "更改 IPv4 模式?",
|
||||||
"a344a29861": "IPv4 模式更改将在重启后生效。",
|
"a344a29861": "IPv4 模式更改将在重启后生效。",
|
||||||
|
"0ddd0ae8fd": "更改 MAC 地址?",
|
||||||
|
"d0158ce3c1": "更改 MAC 地址可能会导致设备 IP 重新分配和更改。",
|
||||||
"34b6cd7517": "版本",
|
"34b6cd7517": "版本",
|
||||||
"a1937e8ac1": "检查系统和应用程序的版本",
|
"a1937e8ac1": "检查系统和应用程序的版本",
|
||||||
"04a115bdd8": "应用版本",
|
"04a115bdd8": "应用版本",
|
||||||
@@ -393,19 +502,40 @@
|
|||||||
"5b404b3c98": "后台更新",
|
"5b404b3c98": "后台更新",
|
||||||
"3723a3f846": "系统已更新到最新版本",
|
"3723a3f846": "系统已更新到最新版本",
|
||||||
"a6b8796d51": "您的系统已运行最新版本。当前没有可用的更新。",
|
"a6b8796d51": "您的系统已运行最新版本。当前没有可用的更新。",
|
||||||
|
"a8d2a89696": "签名文件缺失",
|
||||||
|
"480f05b41b": "当前固件缺少签名文件,无法完全验证完整性。",
|
||||||
|
"323ebc9620": "签名验证失败",
|
||||||
|
"21355c9ecb": "签名文件存在但与固件不匹配,可能存在篡改。",
|
||||||
|
"0b54a6c322": "未嵌入公钥",
|
||||||
|
"9beb932d5a": "此版本未嵌入 OTA 公钥,无法进行签名验证。",
|
||||||
"7c81553358": "再次检查",
|
"7c81553358": "再次检查",
|
||||||
|
"3b5f9a1f01": "更新签名",
|
||||||
|
"d25bfbb978": "更新 kvm_app 的签名到最新版本。如果当前版本不是最新版本,签名验证将会失败。",
|
||||||
|
"1bdd158a19": "签名更新结果",
|
||||||
"217b416896": "更新可用",
|
"217b416896": "更新可用",
|
||||||
"f00af8c98f": "新的更新可用,以增强系统性能和提高兼容性。我们建议更新以确保一切正常运行。",
|
"f00af8c98f": "新的更新可用,以增强系统性能和提高兼容性。我们建议更新以确保一切正常运行。",
|
||||||
|
"8977c3f0b7": "下载代理加速前缀",
|
||||||
"99b1054c0f": "立即更新",
|
"99b1054c0f": "立即更新",
|
||||||
"7f3cd4480d": "稍后更新",
|
"7f3cd4480d": "稍后更新",
|
||||||
"cffae9918d": "更新错误",
|
"cffae9918d": "更新错误",
|
||||||
"49cba7cadf": "更新设备时发生错误。请稍后重试。",
|
"49cba7cadf": "更新设备时发生错误。请稍后重试。",
|
||||||
"d849d5b330": "错误详情:",
|
"d849d5b330": "错误详情:",
|
||||||
"2b2f7a6d7c": "按下 Ctrl+V 直接将本地剪贴板内容发送到远端主机",
|
"fcfc8da1a3": "正在验证签名...",
|
||||||
|
"4e16083ef8": "请等待固件签名验证完成。",
|
||||||
|
"5367acff78": "签名状态不可用",
|
||||||
|
"634aac26af": "无法获取签名验证状态。",
|
||||||
|
"77de342f39": "签名已验证",
|
||||||
|
"20ac4a17cc": "固件签名已验证且有效。",
|
||||||
|
"36a41c937c": "无视频信号",
|
||||||
|
"d34da01de2": "启用粘贴快捷键",
|
||||||
"2b1a1676d1": "将文本从您的客户端复制到远程主机",
|
"2b1a1676d1": "将文本从您的客户端复制到远程主机",
|
||||||
"604c45fbf2": "以下字符将不会被粘贴:",
|
"604c45fbf2": "以下字符将不会被粘贴:",
|
||||||
"a7eb9efa0b": "使用键盘布局发送文本:",
|
"a7eb9efa0b": "使用键盘布局发送文本:",
|
||||||
"2593d0a3d5": "确认粘贴",
|
"2593d0a3d5": "确认粘贴",
|
||||||
|
"ac7e2a9783": "启用 OCR 快捷键",
|
||||||
|
"f529c51ee6": "OCR",
|
||||||
|
"607440855c": "在视频区域打开 OCR 选择模式",
|
||||||
|
"c3bf447eab": "打开",
|
||||||
"a0e3947a02": "宏",
|
"a0e3947a02": "宏",
|
||||||
"276043dbf0": "创建新的键盘宏",
|
"276043dbf0": "创建新的键盘宏",
|
||||||
"9605ef9593": "修改键盘宏",
|
"9605ef9593": "修改键盘宏",
|
||||||
@@ -413,7 +543,6 @@
|
|||||||
"6b2f64058e": "确定要删除此宏吗?此操作无法撤销。",
|
"6b2f64058e": "确定要删除此宏吗?此操作无法撤销。",
|
||||||
"ffbb410a55": "删除中",
|
"ffbb410a55": "删除中",
|
||||||
"3dd2e50646": "仅延迟",
|
"3dd2e50646": "仅延迟",
|
||||||
"7dce122004": "编辑",
|
|
||||||
"5fb63579fc": "复制",
|
"5fb63579fc": "复制",
|
||||||
"fc2839fcdf": "+ 添加新宏",
|
"fc2839fcdf": "+ 添加新宏",
|
||||||
"05fd7d5b9c": "您确定要删除",
|
"05fd7d5b9c": "您确定要删除",
|
||||||
@@ -436,6 +565,8 @@
|
|||||||
"21d104a54f": "处理中...",
|
"21d104a54f": "处理中...",
|
||||||
"9844086d90": "未检测到 SD 卡",
|
"9844086d90": "未检测到 SD 卡",
|
||||||
"b56e598918": "SD 卡挂载失败",
|
"b56e598918": "SD 卡挂载失败",
|
||||||
|
"d646589704": "选择 MicroSD 格式化的文件系统",
|
||||||
|
"16eb8ed6c8": "格式化 MicroSD 卡",
|
||||||
"a63d5e0260": "管理 KVM MicroSD Card 的共享文件夹",
|
"a63d5e0260": "管理 KVM MicroSD Card 的共享文件夹",
|
||||||
"1d50425f88": "管理 KVM 存储中的共享文件夹",
|
"1d50425f88": "管理 KVM 存储中的共享文件夹",
|
||||||
"74ff4bad28": "未找到文件",
|
"74ff4bad28": "未找到文件",
|
||||||
@@ -446,8 +577,15 @@
|
|||||||
"8bf8854beb": "共",
|
"8bf8854beb": "共",
|
||||||
"53e61336bb": "条结果",
|
"53e61336bb": "条结果",
|
||||||
"d1f5a81904": "卸载 MicroSD 卡",
|
"d1f5a81904": "卸载 MicroSD 卡",
|
||||||
|
"5874cf46ff": "SD 卡格式化成功",
|
||||||
"7dc25305d4": "视频编码类型",
|
"7dc25305d4": "视频编码类型",
|
||||||
"41d263a988": "视频质量",
|
"41d263a988": "视频质量",
|
||||||
|
"d8d7a5377b": "RC 控制",
|
||||||
|
"a09ff5b350": "调整码率控制 QP 参数,在质量和码率之间取得更好的平衡",
|
||||||
|
"b1f9d95e72": "StepQp",
|
||||||
|
"bf1af7559b": "MinQp",
|
||||||
|
"6e4e284fcc": "MinIQp",
|
||||||
|
"df1b8f72f2": "DetlpQp",
|
||||||
"41f5283941": "NPU 应用",
|
"41f5283941": "NPU 应用",
|
||||||
"466990072d": "启用 NPU 以进行物体检测",
|
"466990072d": "启用 NPU 以进行物体检测",
|
||||||
"a6c2e30b8e": "视频增强",
|
"a6c2e30b8e": "视频增强",
|
||||||
@@ -464,6 +602,8 @@
|
|||||||
"85a003df9b": "EDID 文件",
|
"85a003df9b": "EDID 文件",
|
||||||
"5c7a666766": "设置自定义 EDID",
|
"5c7a666766": "设置自定义 EDID",
|
||||||
"7dad0ba758": "恢复默认",
|
"7dad0ba758": "恢复默认",
|
||||||
|
"7b2fb72a68": "RC 高级配置",
|
||||||
|
"827048afc2": "格式化 SD 卡会清除所有数据。是否继续?",
|
||||||
"556c7553f1": "从 KVM MicroSD 卡挂载",
|
"556c7553f1": "从 KVM MicroSD 卡挂载",
|
||||||
"b99cf1ecb7": "从 MicroSD 卡中管理和挂载镜像",
|
"b99cf1ecb7": "从 MicroSD 卡中管理和挂载镜像",
|
||||||
"0a01e80566": "已从 KVM 存储挂载",
|
"0a01e80566": "已从 KVM 存储挂载",
|
||||||
|
|||||||
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("+");
|
||||||
|
}
|
||||||
14
usb.go
14
usb.go
@@ -37,7 +37,7 @@ func initUsbGadget() {
|
|||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
checkUSBState()
|
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)
|
return gadget.KeyboardReport(modifier, keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcKeypressReport(key uint8, press bool) error {
|
||||||
|
return gadget.KeypressReport(key, press)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcKeypressKeepAlive() error {
|
||||||
|
return gadget.KeypressKeepAlive()
|
||||||
|
}
|
||||||
|
|
||||||
func rpcAbsMouseReport(x, y int, buttons uint8) error {
|
func rpcAbsMouseReport(x, y int, buttons uint8) error {
|
||||||
return gadget.AbsMouseReport(x, y, buttons)
|
return gadget.AbsMouseReport(x, y, buttons)
|
||||||
}
|
}
|
||||||
@@ -130,7 +138,7 @@ func rpcGetKeyboardLedState() (state usbgadget.KeyboardState) {
|
|||||||
var usbState = "unknown"
|
var usbState = "unknown"
|
||||||
|
|
||||||
func rpcGetUSBState() (state string) {
|
func rpcGetUSBState() (state string) {
|
||||||
return gadget.GetUsbState()
|
return gadget.GetUsbState(config.UsbEnhancedDetection)
|
||||||
}
|
}
|
||||||
|
|
||||||
func triggerUSBStateUpdate() {
|
func triggerUSBStateUpdate() {
|
||||||
@@ -144,7 +152,7 @@ func triggerUSBStateUpdate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkUSBState() {
|
func checkUSBState() {
|
||||||
newState := gadget.GetUsbState()
|
newState := gadget.GetUsbState(config.UsbEnhancedDetection)
|
||||||
if newState == usbState {
|
if newState == usbState {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package kvm
|
package kvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -781,6 +782,117 @@ func rpcUnmountSDStorage() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcFormatSDStorage(confirm bool, fsType string) error {
|
||||||
|
validFsTypes := map[string]bool{"exfat": true, "fat32": true}
|
||||||
|
if !validFsTypes[fsType] {
|
||||||
|
fsType = "fat32"
|
||||||
|
}
|
||||||
|
if !confirm {
|
||||||
|
return fmt.Errorf("format not confirmed")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat("/dev/mmcblk1"); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("sd device not found: /dev/mmcblk1")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to stat sd device: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := updateMtp(false); err != nil {
|
||||||
|
logger.Error().Err(err).Msg("failed to update mtp before formatting sd")
|
||||||
|
}
|
||||||
|
|
||||||
|
if out, err := exec.Command("mount").Output(); err == nil {
|
||||||
|
if strings.Contains(string(out), " on /mnt/sdcard") {
|
||||||
|
if umOut, umErr := exec.Command("umount", "/mnt/sdcard").CombinedOutput(); umErr != nil {
|
||||||
|
return fmt.Errorf("failed to unmount sdcard: %w: %s", umErr, strings.TrimSpace(string(umOut)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(SDImagesFolder, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to ensure mount point: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat("/dev/mmcblk1p1"); os.IsNotExist(err) {
|
||||||
|
var lastErr error
|
||||||
|
if _, err := exec.LookPath("sfdisk"); err == nil {
|
||||||
|
sfdiskInput := "label: dos\nunit: sectors\n\n2048,,c,*\n"
|
||||||
|
cmd := exec.Command("sfdisk", "/dev/mmcblk1")
|
||||||
|
cmd.Stdin = bytes.NewBufferString(sfdiskInput)
|
||||||
|
partOut, partErr := cmd.CombinedOutput()
|
||||||
|
if partErr != nil {
|
||||||
|
lastErr = fmt.Errorf("sfdisk failed: %w: %s", partErr, strings.TrimSpace(string(partOut)))
|
||||||
|
} else {
|
||||||
|
lastErr = nil
|
||||||
|
}
|
||||||
|
} else if _, err := exec.LookPath("fdisk"); err == nil {
|
||||||
|
fdiskScript := "o\nn\np\n1\n2048\n\nt\n1\nc\na\n1\nw\n"
|
||||||
|
cmd := exec.Command("fdisk", "/dev/mmcblk1")
|
||||||
|
cmd.Stdin = bytes.NewBufferString(fdiskScript)
|
||||||
|
partOut, partErr := cmd.CombinedOutput()
|
||||||
|
if partErr != nil {
|
||||||
|
lastErr = fmt.Errorf("fdisk failed: %w: %s", partErr, strings.TrimSpace(string(partOut)))
|
||||||
|
} else {
|
||||||
|
lastErr = nil
|
||||||
|
}
|
||||||
|
} else if _, err := exec.LookPath("parted"); err == nil {
|
||||||
|
partedOut, partedErr := exec.Command("parted", "-s", "/dev/mmcblk1", "mklabel", "msdos", "mkpart", "primary", "fat32", "1MiB", "100%").CombinedOutput()
|
||||||
|
if partedErr != nil {
|
||||||
|
lastErr = fmt.Errorf("parted failed: %w: %s", partedErr, strings.TrimSpace(string(partedOut)))
|
||||||
|
} else {
|
||||||
|
lastErr = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("no partitioning tool found (need sfdisk, fdisk, or parted)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr != nil {
|
||||||
|
return fmt.Errorf("failed to create sd partition: %w", lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := exec.LookPath("partprobe"); err == nil {
|
||||||
|
if _, err := exec.Command("partprobe", "/dev/mmcblk1").CombinedOutput(); err != nil {
|
||||||
|
time.Sleep(800 * time.Millisecond)
|
||||||
|
} else {
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
time.Sleep(800 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat("/dev/mmcblk1p1"); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("sd partition not found after partitioning: /dev/mmcblk1p1")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to stat sd partition: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var mkfsCmd *exec.Cmd
|
||||||
|
if fsType == "exfat" {
|
||||||
|
mkfsCmd = exec.Command("mkfs.exfat", "-n", "PICOKVM", "/dev/mmcblk1p1")
|
||||||
|
} else {
|
||||||
|
mkfsCmd = exec.Command("mkfs.vfat", "-F", "32", "-n", "PICOKVM", "/dev/mmcblk1p1")
|
||||||
|
}
|
||||||
|
mkfsOut, mkfsErr := mkfsCmd.CombinedOutput()
|
||||||
|
if mkfsErr != nil {
|
||||||
|
return fmt.Errorf("failed to format sdcard: %w: %s", mkfsErr, strings.TrimSpace(string(mkfsOut)))
|
||||||
|
}
|
||||||
|
|
||||||
|
mountOut, mountErr := exec.Command("mount", "/dev/mmcblk1p1", SDImagesFolder).CombinedOutput()
|
||||||
|
if mountErr != nil {
|
||||||
|
return fmt.Errorf("failed to mount sdcard after format: %w: %s", mountErr, strings.TrimSpace(string(mountOut)))
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncConfigSD(false)
|
||||||
|
|
||||||
|
if err := updateMtp(true); err != nil {
|
||||||
|
return fmt.Errorf("failed to update mtp after formatting sd: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func rpcListSDStorageFiles() (*StorageFiles, error) {
|
func rpcListSDStorageFiles() (*StorageFiles, error) {
|
||||||
files, err := os.ReadDir(SDImagesFolder)
|
files, err := os.ReadDir(SDImagesFolder)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -842,6 +954,7 @@ const (
|
|||||||
|
|
||||||
type SDMountStatusResponse struct {
|
type SDMountStatusResponse struct {
|
||||||
Status SDMountStatus `json:"status"`
|
Status SDMountStatus `json:"status"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcGetSDMountStatus() (*SDMountStatusResponse, error) {
|
func rpcGetSDMountStatus() (*SDMountStatusResponse, error) {
|
||||||
@@ -849,9 +962,13 @@ func rpcGetSDMountStatus() (*SDMountStatusResponse, error) {
|
|||||||
return &SDMountStatusResponse{Status: SDMountNone}, nil
|
return &SDMountStatusResponse{Status: SDMountNone}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat("/dev/mmcblk1p1"); os.IsNotExist(err) {
|
||||||
|
return &SDMountStatusResponse{Status: SDMountFail, Reason: "no_partition"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
output, err := exec.Command("mount").Output()
|
output, err := exec.Command("mount").Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &SDMountStatusResponse{Status: SDMountFail}, fmt.Errorf("failed to check mount status: %v", err)
|
return &SDMountStatusResponse{Status: SDMountFail, Reason: "check_mount_failed"}, fmt.Errorf("failed to check mount status: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(string(output), "/dev/mmcblk1p1 on /mnt/sdcard") {
|
if strings.Contains(string(output), "/dev/mmcblk1p1 on /mnt/sdcard") {
|
||||||
@@ -860,19 +977,19 @@ func rpcGetSDMountStatus() (*SDMountStatusResponse, error) {
|
|||||||
|
|
||||||
err = exec.Command("mount", "/dev/mmcblk1p1", "/mnt/sdcard").Run()
|
err = exec.Command("mount", "/dev/mmcblk1p1", "/mnt/sdcard").Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &SDMountStatusResponse{Status: SDMountFail}, fmt.Errorf("failed to mount SD card: %v", err)
|
return &SDMountStatusResponse{Status: SDMountFail, Reason: "mount_failed"}, fmt.Errorf("failed to mount SD card: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
output, err = exec.Command("mount").Output()
|
output, err = exec.Command("mount").Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &SDMountStatusResponse{Status: SDMountFail}, fmt.Errorf("failed to check mount status after mounting: %v", err)
|
return &SDMountStatusResponse{Status: SDMountFail, Reason: "check_mount_after_failed"}, fmt.Errorf("failed to check mount status after mounting: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(string(output), "/dev/mmcblk1p1 on /mnt/sdcard") {
|
if strings.Contains(string(output), "/dev/mmcblk1p1 on /mnt/sdcard") {
|
||||||
return &SDMountStatusResponse{Status: SDMountOK}, nil
|
return &SDMountStatusResponse{Status: SDMountOK}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return &SDMountStatusResponse{Status: SDMountFail}, nil
|
return &SDMountStatusResponse{Status: SDMountFail, Reason: "mount_unknown"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type SDStorageFileUpload struct {
|
type SDStorageFileUpload struct {
|
||||||
@@ -984,21 +1101,13 @@ usb_max_packet_size 0x200
|
|||||||
return os.WriteFile(umtprdConfPath, []byte(conf), 0644)
|
return os.WriteFile(umtprdConfPath, []byte(conf), 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateMtpWithSDStatus() error {
|
func updateMtp(withSD bool) error {
|
||||||
resp, _ := rpcGetSDMountStatus()
|
if err := writeUmtprdConfFile(withSD); err != nil {
|
||||||
if resp.Status == SDMountOK {
|
|
||||||
if err := writeUmtprdConfFile(true); err != nil {
|
|
||||||
logger.Error().Err(err).Msg("failed to write umtprd conf file")
|
logger.Error().Err(err).Msg("failed to write umtprd conf file")
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
if err := writeUmtprdConfFile(false); err != nil {
|
|
||||||
logger.Error().Err(err).Msg("failed to write umtprd conf file")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.UsbDevices.Mtp {
|
if config.UsbDevices.Mtp {
|
||||||
if err := gadget.UnbindUDCToDWC3(); err != nil {
|
if err := gadget.UnbindUDC(); err != nil {
|
||||||
logger.Error().Err(err).Msg("failed to unbind UDC")
|
logger.Error().Err(err).Msg("failed to unbind gadget from UDC")
|
||||||
}
|
}
|
||||||
|
|
||||||
if out, err := exec.Command("pgrep", "-x", "umtprd").Output(); err == nil && len(out) > 0 {
|
if out, err := exec.Command("pgrep", "-x", "umtprd").Output(); err == nil && len(out) > 0 {
|
||||||
@@ -1013,10 +1122,29 @@ func updateMtpWithSDStatus() error {
|
|||||||
return fmt.Errorf("failed to exec binary: %w", err)
|
return fmt.Errorf("failed to exec binary: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := rpcSetUsbDevices(*config.UsbDevices); err != nil {
|
var lastErr error
|
||||||
return fmt.Errorf("failed to set usb devices: %w", err)
|
for attempt := 0; attempt < 6; attempt++ {
|
||||||
|
if err := rpcSetUsbDevices(*config.UsbDevices); err == nil {
|
||||||
|
lastErr = nil
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
lastErr = err
|
||||||
|
logger.Warn().
|
||||||
|
Int("attempt", attempt+1).
|
||||||
|
Err(err).
|
||||||
|
Msg("failed to re-apply usb devices after mtp update, retrying")
|
||||||
|
time.Sleep(time.Duration(300*(attempt+1)) * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
return fmt.Errorf("failed to set usb devices after mtp update: %w", lastErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateMtpWithSDStatus() error {
|
||||||
|
resp, _ := rpcGetSDMountStatus()
|
||||||
|
return updateMtp(resp.Status == SDMountOK)
|
||||||
|
}
|
||||||
|
|||||||
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
|
Error string `json:"error,omitempty"` //no_signal, no_lock, out_of_range
|
||||||
Width int `json:"width"`
|
Width int `json:"width"`
|
||||||
Height int `json:"height"`
|
Height int `json:"height"`
|
||||||
FramePerSecond float64 `json:"fps"`
|
FramePerSecond float64 `json:"frame_per_second"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastVideoState VideoInputState
|
var lastVideoState VideoInputState
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user