mirror of
https://github.com/luckfox-eng29/kvm.git
synced 2026-06-09 03:23:38 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f426e8999 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,4 +9,3 @@ node_modules/
|
|||||||
ui/.i18n_extractor.json
|
ui/.i18n_extractor.json
|
||||||
|
|
||||||
device-tests.tar.gz
|
device-tests.tar.gz
|
||||||
.worktrees/
|
|
||||||
|
|||||||
30
Makefile
30
Makefile
@@ -2,19 +2,12 @@ BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
|
|||||||
BUILDDATE ?= $(shell date -u +%FT%T%z)
|
BUILDDATE ?= $(shell date -u +%FT%T%z)
|
||||||
BUILDTS ?= $(shell date -u +%s)
|
BUILDTS ?= $(shell date -u +%s)
|
||||||
REVISION ?= $(shell git rev-parse HEAD)
|
REVISION ?= $(shell git rev-parse HEAD)
|
||||||
VERSION_DEV ?= 0.1.3-dev
|
VERSION_DEV ?= 0.1.2-dev
|
||||||
VERSION ?= 0.1.3
|
VERSION ?= 0.1.2
|
||||||
|
|
||||||
PROMETHEUS_TAG := github.com/prometheus/common/version
|
PROMETHEUS_TAG := github.com/prometheus/common/version
|
||||||
KVM_PKG_NAME := kvm
|
KVM_PKG_NAME := kvm
|
||||||
|
|
||||||
# OTA signing key path (Ed25519 private key for auto-signing at build time)
|
|
||||||
OTA_SIGNING_KEY ?=
|
|
||||||
|
|
||||||
# OTA signing public key (hex-encoded Ed25519 public key, 64 hex chars)
|
|
||||||
# Default empty = signature verification disabled (backward compatible)
|
|
||||||
OTA_PUBLIC_KEY ?=
|
|
||||||
|
|
||||||
GO_BUILD_ARGS := -tags netgo
|
GO_BUILD_ARGS := -tags netgo
|
||||||
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
|
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
|
||||||
GO_LDFLAGS := \
|
GO_LDFLAGS := \
|
||||||
@@ -22,8 +15,7 @@ GO_LDFLAGS := \
|
|||||||
-X $(PROMETHEUS_TAG).Branch=$(BRANCH) \
|
-X $(PROMETHEUS_TAG).Branch=$(BRANCH) \
|
||||||
-X $(PROMETHEUS_TAG).BuildDate=$(BUILDDATE) \
|
-X $(PROMETHEUS_TAG).BuildDate=$(BUILDDATE) \
|
||||||
-X $(PROMETHEUS_TAG).Revision=$(REVISION) \
|
-X $(PROMETHEUS_TAG).Revision=$(REVISION) \
|
||||||
-X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS) \
|
-X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS)
|
||||||
-X $(KVM_PKG_NAME).builtOtaPublicKey=$(OTA_PUBLIC_KEY)
|
|
||||||
|
|
||||||
GO_CMD := GOOS=linux GOARCH=arm GOARM=7 go
|
GO_CMD := GOOS=linux GOARCH=arm GOARM=7 go
|
||||||
BIN_DIR := $(shell pwd)/bin
|
BIN_DIR := $(shell pwd)/bin
|
||||||
@@ -36,12 +28,6 @@ build_dev:
|
|||||||
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
|
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
|
||||||
$(GO_RELEASE_BUILD_ARGS) \
|
$(GO_RELEASE_BUILD_ARGS) \
|
||||||
-o $(BIN_DIR)/kvm_app cmd/main.go
|
-o $(BIN_DIR)/kvm_app cmd/main.go
|
||||||
@if [ -n "$(OTA_SIGNING_KEY)" ]; then \
|
|
||||||
echo "Signing $(BIN_DIR)/kvm_app..."; \
|
|
||||||
go run cmd/main.go cli signer sign --key "$(OTA_SIGNING_KEY)" $(BIN_DIR)/kvm_app; \
|
|
||||||
else \
|
|
||||||
echo "OTA_SIGNING_KEY not set, skipping signing."; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
cd ui && npm ci && npm run build:device
|
cd ui && npm ci && npm run build:device
|
||||||
@@ -52,13 +38,3 @@ build_release: frontend
|
|||||||
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" \
|
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" \
|
||||||
$(GO_RELEASE_BUILD_ARGS) \
|
$(GO_RELEASE_BUILD_ARGS) \
|
||||||
-o bin/kvm_app cmd/main.go
|
-o bin/kvm_app cmd/main.go
|
||||||
@if [ -n "$(OTA_SIGNING_KEY)" ]; then \
|
|
||||||
echo "Signing bin/kvm_app..."; \
|
|
||||||
go run cmd/main.go cli signer sign --key "$(OTA_SIGNING_KEY)" bin/kvm_app; \
|
|
||||||
else \
|
|
||||||
echo "OTA_SIGNING_KEY not set, skipping signing."; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
sign:
|
|
||||||
@echo "Signing firmware files..."
|
|
||||||
go run cmd/main.go cli signer sign --key $(KEY) $(FILES)
|
|
||||||
|
|||||||
389
api.go
389
api.go
@@ -1,389 +0,0 @@
|
|||||||
package kvm
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func StartAPIServer(port int) {
|
|
||||||
gin.SetMode(gin.ReleaseMode)
|
|
||||||
r := gin.New()
|
|
||||||
r.Use(gin.Recovery())
|
|
||||||
r.Use(corsMiddleware())
|
|
||||||
r.Use(apiKeyAuthMiddleware(config.APIKey))
|
|
||||||
|
|
||||||
// Health check (no auth required)
|
|
||||||
r.GET("/health", func(c *gin.Context) {
|
|
||||||
c.JSON(200, gin.H{
|
|
||||||
"status": "ok",
|
|
||||||
"device": GetDeviceID(),
|
|
||||||
"version": builtAppVersion,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// API routes
|
|
||||||
api := r.Group("/api/lan")
|
|
||||||
{
|
|
||||||
// Mouse
|
|
||||||
api.POST("/mouse/absolute", handleAPIMouseAbsolute)
|
|
||||||
api.POST("/mouse/relative", handleAPIMouseRelative)
|
|
||||||
api.POST("/mouse/click", handleAPIMouseClick)
|
|
||||||
api.POST("/mouse/scroll", handleAPIMouseScroll)
|
|
||||||
|
|
||||||
// Keyboard
|
|
||||||
api.POST("/keyboard/key", handleAPIKeyboardKey)
|
|
||||||
api.POST("/keyboard/combo", handleAPIKeyboardCombo)
|
|
||||||
api.POST("/keyboard/type", handleAPIKeyboardType)
|
|
||||||
|
|
||||||
// Capture
|
|
||||||
api.GET("/capture", handleAPICapture)
|
|
||||||
|
|
||||||
// Status
|
|
||||||
api.GET("/status", handleAPIStatus)
|
|
||||||
api.GET("/video/state", handleAPIVideoState)
|
|
||||||
}
|
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%d", port)
|
|
||||||
logger.Info().Str("addr", addr).Msg("Starting LAN API server")
|
|
||||||
if err := r.Run(addr); err != nil {
|
|
||||||
logger.Error().Err(err).Msg("LAN API server failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Middleware ===
|
|
||||||
|
|
||||||
func corsMiddleware() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
c.Header("Access-Control-Allow-Origin", "*")
|
|
||||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
||||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
|
|
||||||
if c.Request.Method == "OPTIONS" {
|
|
||||||
c.AbortWithStatus(204)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func apiKeyAuthMiddleware(expectedKey string) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
// Skip auth for health endpoint
|
|
||||||
if c.Request.URL.Path == "/health" {
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip auth for localhost
|
|
||||||
remoteAddr := c.Request.RemoteAddr
|
|
||||||
if strings.HasPrefix(remoteAddr, "127.0.0.1:") ||
|
|
||||||
strings.HasPrefix(remoteAddr, "[::1]:") {
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no API key configured, reject LAN requests
|
|
||||||
if expectedKey == "" {
|
|
||||||
c.AbortWithStatusJSON(401, gin.H{"error": "API key not configured"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
auth := c.GetHeader("Authorization")
|
|
||||||
var key string
|
|
||||||
if _, err := fmt.Sscanf(auth, "Bearer %s", &key); err != nil {
|
|
||||||
c.AbortWithStatusJSON(401, gin.H{"error": "missing or invalid authorization"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !strings.EqualFold(key, expectedKey) {
|
|
||||||
c.AbortWithStatusJSON(401, gin.H{"error": "invalid api key"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === API Handlers: Mouse ===
|
|
||||||
|
|
||||||
func handleAPIMouseAbsolute(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
X int `json:"x" binding:"required"`
|
|
||||||
Y int `json:"y" binding:"required"`
|
|
||||||
Buttons uint8 `json:"buttons"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, err := callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
|
|
||||||
"x": req.X, "y": req.Y, "buttons": req.Buttons,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "x": req.X, "y": req.Y})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleAPIMouseRelative(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
Dx int8 `json:"dx" binding:"required"`
|
|
||||||
Dy int8 `json:"dy" binding:"required"`
|
|
||||||
Buttons uint8 `json:"buttons"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, err := callRPCHandler(rpcHandlers["relMouseReport"], map[string]interface{}{
|
|
||||||
"dx": req.Dx, "dy": req.Dy, "buttons": req.Buttons,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "dx": req.Dx, "dy": req.Dy})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleAPIMouseClick(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
Button string `json:"button" binding:"required"`
|
|
||||||
Double bool `json:"double"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var buttons uint8
|
|
||||||
switch req.Button {
|
|
||||||
case "left":
|
|
||||||
buttons = 1
|
|
||||||
case "right":
|
|
||||||
buttons = 2
|
|
||||||
case "middle":
|
|
||||||
buttons = 4
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("unknown button: %s", req.Button)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
clickCount := 1
|
|
||||||
if req.Double {
|
|
||||||
clickCount = 2
|
|
||||||
}
|
|
||||||
for i := 0; i < clickCount; i++ {
|
|
||||||
_, err := callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
|
|
||||||
"x": 0, "y": 0, "buttons": buttons,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, err = callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
|
|
||||||
"x": 0, "y": 0, "buttons": 0,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "button": req.Button, "clicks": clickCount})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleAPIMouseScroll(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
Delta int8 `json:"delta" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, err := callRPCHandler(rpcHandlers["wheelReport"], map[string]interface{}{
|
|
||||||
"wheelY": req.Delta,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "delta": req.Delta})
|
|
||||||
}
|
|
||||||
|
|
||||||
// === API Handlers: Keyboard ===
|
|
||||||
|
|
||||||
func handleAPIKeyboardKey(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
Key string `json:"key" binding:"required"`
|
|
||||||
Action string `json:"action"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
keyCode, ok := keyNameToCode[req.Key]
|
|
||||||
if !ok {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("unknown key: %s", req.Key)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
action := strings.ToLower(req.Action)
|
|
||||||
if action == "" {
|
|
||||||
action = "press"
|
|
||||||
}
|
|
||||||
switch action {
|
|
||||||
case "press":
|
|
||||||
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
|
||||||
"modifier": 0, "keys": []uint8{keyCode},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
|
||||||
"modifier": 0, "keys": []uint8{},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "down":
|
|
||||||
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
|
||||||
"modifier": 0, "keys": []uint8{keyCode},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "up":
|
|
||||||
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
|
||||||
"modifier": 0, "keys": []uint8{},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("unknown action: %s", req.Action)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "key": req.Key, "action": action})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleAPIKeyboardCombo(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
Keys []string `json:"keys" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var keys []uint8
|
|
||||||
var modifier uint8
|
|
||||||
for _, keyName := range req.Keys {
|
|
||||||
switch strings.ToLower(keyName) {
|
|
||||||
case "ctrl", "control":
|
|
||||||
modifier |= 0x01
|
|
||||||
continue
|
|
||||||
case "shift":
|
|
||||||
modifier |= 0x02
|
|
||||||
continue
|
|
||||||
case "alt":
|
|
||||||
modifier |= 0x04
|
|
||||||
continue
|
|
||||||
case "meta", "win", "cmd":
|
|
||||||
modifier |= 0x08
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
keyCode, ok := keyNameToCode[keyName]
|
|
||||||
if !ok {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("unknown key: %s", keyName)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
keys = append(keys, keyCode)
|
|
||||||
}
|
|
||||||
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
|
||||||
"modifier": modifier, "keys": keys,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
|
||||||
"modifier": 0, "keys": []uint8{},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "keys": req.Keys})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleAPIKeyboardType(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
Text string `json:"text" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, char := range req.Text {
|
|
||||||
keyCode, modifier, ok := charToKeyCode(uint8(char))
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
|
||||||
"modifier": modifier, "keys": []uint8{keyCode},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
|
||||||
"modifier": 0, "keys": []uint8{},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "text": req.Text})
|
|
||||||
}
|
|
||||||
|
|
||||||
// === API Handlers: Capture ===
|
|
||||||
|
|
||||||
func handleAPICapture(c *gin.Context) {
|
|
||||||
data, err := captureScreenshot("jpeg")
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Data(http.StatusOK, "image/jpeg", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// === API Handlers: Status ===
|
|
||||||
|
|
||||||
func handleAPIStatus(c *gin.Context) {
|
|
||||||
state := lastVideoState
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"video": gin.H{
|
|
||||||
"ready": state.Ready,
|
|
||||||
"width": state.Width,
|
|
||||||
"height": state.Height,
|
|
||||||
"fps": state.FramePerSecond,
|
|
||||||
"error": state.Error,
|
|
||||||
},
|
|
||||||
"device": gin.H{
|
|
||||||
"name": "PicoKVM",
|
|
||||||
"version": builtAppVersion,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleAPIVideoState(c *gin.Context) {
|
|
||||||
state := lastVideoState
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"ready": state.Ready,
|
|
||||||
"width": state.Width,
|
|
||||||
"height": state.Height,
|
|
||||||
"fps": state.FramePerSecond,
|
|
||||||
"error": state.Error,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
552
cli.go
552
cli.go
@@ -1,552 +0,0 @@
|
|||||||
package kvm
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/ed25519"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var cliRootCmd = &cobra.Command{
|
|
||||||
Use: "kvm_app cli",
|
|
||||||
Short: "PicoKVM CLI tools",
|
|
||||||
Long: `Command line interface for controlling HID devices via API`,
|
|
||||||
SilenceErrors: true,
|
|
||||||
SilenceUsage: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
func RunCLI(args []string) {
|
|
||||||
cliRootCmd.SetArgs(args)
|
|
||||||
if err := cliRootCmd.Execute(); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var apiBaseURL = "http://localhost:8080/api/lan"
|
|
||||||
|
|
||||||
func apiPost(path string, body interface{}) error {
|
|
||||||
jsonBody, err := json.Marshal(body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := http.Post(apiBaseURL+path, "application/json", strings.NewReader(string(jsonBody)))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to connect to API server: %w\nIs kvm_app running?", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
||||||
var errResp map[string]interface{}
|
|
||||||
if json.Unmarshal(bodyBytes, &errResp) == nil {
|
|
||||||
if msg, ok := errResp["error"].(string); ok {
|
|
||||||
return fmt.Errorf("API error: %s", msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("API error: HTTP %d - %s", resp.StatusCode, string(bodyBytes))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func apiGetBytes(path string) ([]byte, error) {
|
|
||||||
resp, err := http.Get(apiBaseURL + path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to connect to API server: %w\nIs kvm_app running?", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
||||||
return nil, fmt.Errorf("API error: HTTP %d - %s", resp.StatusCode, string(bodyBytes))
|
|
||||||
}
|
|
||||||
return io.ReadAll(resp.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func apiGetJSON(path string) (map[string]interface{}, error) {
|
|
||||||
resp, err := http.Get(apiBaseURL + path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to connect to API server: %w\nIs kvm_app running?", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
||||||
return nil, fmt.Errorf("API error: HTTP %d - %s", resp.StatusCode, string(bodyBytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
var result map[string]interface{}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ShowHelp() {
|
|
||||||
fmt.Println(`PicoKVM - Remote KVM over IP
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
kvm_app Start the main web service (default)
|
|
||||||
kvm_app cli <command> Run CLI commands for HID control
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
cli HID control via API (mouse, keyboard, capture, status, signer)
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
kvm_app cli mouse move 100 200
|
|
||||||
kvm_app cli mouse click left
|
|
||||||
kvm_app cli keyboard type "Hello World"
|
|
||||||
kvm_app cli capture
|
|
||||||
kvm_app cli status
|
|
||||||
kvm_app cli signer keygen
|
|
||||||
kvm_app cli signer sign --key ota_ed25519.key firmware.bin
|
|
||||||
kvm_app cli signer verify --pubkey ota_ed25519.pub firmware.bin
|
|
||||||
|
|
||||||
For more help on a specific command, use:
|
|
||||||
kvm_app cli --help`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Mouse Commands ===
|
|
||||||
|
|
||||||
var mouseCmd = &cobra.Command{
|
|
||||||
Use: "mouse",
|
|
||||||
Short: "Mouse control commands",
|
|
||||||
}
|
|
||||||
|
|
||||||
var mouseMoveCmd = &cobra.Command{
|
|
||||||
Use: "move [x] [y]",
|
|
||||||
Short: "Move mouse to position",
|
|
||||||
Args: cobra.ExactArgs(2),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
x, err := strconv.Atoi(args[0])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid x coordinate: %w", err)
|
|
||||||
}
|
|
||||||
y, err := strconv.Atoi(args[1])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid y coordinate: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
relative, _ := cmd.Flags().GetBool("relative")
|
|
||||||
if relative {
|
|
||||||
return apiPost("/mouse/relative", map[string]interface{}{
|
|
||||||
"dx": x, "dy": y,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return apiPost("/mouse/absolute", map[string]interface{}{
|
|
||||||
"x": x, "y": y,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var mouseClickCmd = &cobra.Command{
|
|
||||||
Use: "click [button]",
|
|
||||||
Short: "Click a mouse button",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return apiPost("/mouse/click", map[string]interface{}{
|
|
||||||
"button": args[0],
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var mouseScrollCmd = &cobra.Command{
|
|
||||||
Use: "scroll [delta]",
|
|
||||||
Short: "Scroll mouse wheel",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
delta, err := strconv.Atoi(args[0])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid delta: %w", err)
|
|
||||||
}
|
|
||||||
return apiPost("/mouse/scroll", map[string]interface{}{
|
|
||||||
"delta": delta,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var mouseDownCmd = &cobra.Command{
|
|
||||||
Use: "down [button]",
|
|
||||||
Short: "Press and hold a mouse button",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
// API doesn't support hold yet, simulate with click
|
|
||||||
return apiPost("/mouse/click", map[string]interface{}{
|
|
||||||
"button": args[0],
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var mouseUpCmd = &cobra.Command{
|
|
||||||
Use: "up",
|
|
||||||
Short: "Release all mouse buttons",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
// API doesn't support hold yet, no-op
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Keyboard Commands ===
|
|
||||||
|
|
||||||
var keyboardCmd = &cobra.Command{
|
|
||||||
Use: "keyboard",
|
|
||||||
Short: "Keyboard control commands",
|
|
||||||
}
|
|
||||||
|
|
||||||
var keyboardKeyCmd = &cobra.Command{
|
|
||||||
Use: "key [keyname]",
|
|
||||||
Short: "Press a key",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return apiPost("/keyboard/key", map[string]interface{}{
|
|
||||||
"key": args[0],
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var keyboardComboCmd = &cobra.Command{
|
|
||||||
Use: "combo [keys...]",
|
|
||||||
Short: "Press a key combination",
|
|
||||||
Args: cobra.MinimumNArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return apiPost("/keyboard/combo", map[string]interface{}{
|
|
||||||
"keys": args,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var keyboardTypeCmd = &cobra.Command{
|
|
||||||
Use: "type [text]",
|
|
||||||
Short: "Type a text string",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return apiPost("/keyboard/type", map[string]interface{}{
|
|
||||||
"text": args[0],
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Capture Command ===
|
|
||||||
|
|
||||||
var captureScreenshotPath = "/userdata/picokvm/screenshot/kvm_screenshot.jpg"
|
|
||||||
|
|
||||||
var captureCmd = &cobra.Command{
|
|
||||||
Use: "capture",
|
|
||||||
Short: "Capture a JPEG screenshot",
|
|
||||||
Long: "Capture screenshot using hardware JPEG encoder and save to fixed path",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
data, err := apiGetBytes("/capture")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(captureScreenshotPath, data, 0644); err != nil {
|
|
||||||
return fmt.Errorf("failed to write: %w", err)
|
|
||||||
}
|
|
||||||
fmt.Printf("Screenshot saved to %s (%d bytes)\n", captureScreenshotPath, len(data))
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Status Command ===
|
|
||||||
|
|
||||||
var statusCmd = &cobra.Command{
|
|
||||||
Use: "status",
|
|
||||||
Short: "Show device status",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
resp, err := apiGetJSON("/video/state")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Video State:\n")
|
|
||||||
fmt.Printf(" Ready: %v\n", resp["ready"])
|
|
||||||
fmt.Printf(" Error: %s\n", resp["error"])
|
|
||||||
fmt.Printf(" Size: %dx%d\n", int(resp["width"].(float64)), int(resp["height"].(float64)))
|
|
||||||
fmt.Printf(" FPS: %.1f\n", resp["fps"])
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Key Maps ===
|
|
||||||
|
|
||||||
var keyNameToCode = map[string]uint8{
|
|
||||||
"a": 0x04, "b": 0x05, "c": 0x06, "d": 0x07,
|
|
||||||
"e": 0x08, "f": 0x09, "g": 0x0A, "h": 0x0B,
|
|
||||||
"i": 0x0C, "j": 0x0D, "k": 0x0E, "l": 0x0F,
|
|
||||||
"m": 0x10, "n": 0x11, "o": 0x12, "p": 0x13,
|
|
||||||
"q": 0x14, "r": 0x15, "s": 0x16, "t": 0x17,
|
|
||||||
"u": 0x18, "v": 0x19, "w": 0x1A, "x": 0x1B,
|
|
||||||
"y": 0x1C, "z": 0x1D,
|
|
||||||
"1": 0x1E, "2": 0x1F, "3": 0x20, "4": 0x21,
|
|
||||||
"5": 0x22, "6": 0x23, "7": 0x24, "8": 0x25,
|
|
||||||
"9": 0x26, "0": 0x27,
|
|
||||||
"Enter": 0x28,
|
|
||||||
"Escape": 0x29,
|
|
||||||
"Backspace": 0x2A,
|
|
||||||
"Tab": 0x2B,
|
|
||||||
"Space": 0x2C,
|
|
||||||
"Minus": 0x2D,
|
|
||||||
"Equal": 0x2E,
|
|
||||||
"LeftBrace": 0x2F,
|
|
||||||
"RightBrace": 0x30,
|
|
||||||
"Backslash": 0x31,
|
|
||||||
"Semicolon": 0x33,
|
|
||||||
"Quote": 0x34,
|
|
||||||
"Grave": 0x35,
|
|
||||||
"Comma": 0x36,
|
|
||||||
"Dot": 0x37,
|
|
||||||
"Slash": 0x38,
|
|
||||||
"CapsLock": 0x39,
|
|
||||||
"F1": 0x3A, "F2": 0x3B, "F3": 0x3C, "F4": 0x3D,
|
|
||||||
"F5": 0x3E, "F6": 0x3F, "F7": 0x40, "F8": 0x41,
|
|
||||||
"F9": 0x42, "F10": 0x43, "F11": 0x44, "F12": 0x45,
|
|
||||||
"PrintScreen": 0x46,
|
|
||||||
"ScrollLock": 0x47,
|
|
||||||
"Pause": 0x48,
|
|
||||||
"Insert": 0x49,
|
|
||||||
"Home": 0x4A,
|
|
||||||
"PageUp": 0x4B,
|
|
||||||
"Delete": 0x4C,
|
|
||||||
"End": 0x4D,
|
|
||||||
"PageDown": 0x4E,
|
|
||||||
"Right": 0x4F,
|
|
||||||
"Left": 0x50,
|
|
||||||
"Down": 0x51,
|
|
||||||
"Up": 0x52,
|
|
||||||
}
|
|
||||||
|
|
||||||
func charToKeyCode(char uint8) (keyCode uint8, modifier uint8, ok bool) {
|
|
||||||
if char >= 'a' && char <= 'z' {
|
|
||||||
return keyNameToCode[string(char)], 0, true
|
|
||||||
}
|
|
||||||
if char >= 'A' && char <= 'Z' {
|
|
||||||
return keyNameToCode[strings.ToLower(string(char))], 0x02, true
|
|
||||||
}
|
|
||||||
if char >= '0' && char <= '9' {
|
|
||||||
return keyNameToCode[string(char)], 0, true
|
|
||||||
}
|
|
||||||
if char == ' ' {
|
|
||||||
return 0x2C, 0, true
|
|
||||||
}
|
|
||||||
switch char {
|
|
||||||
case '-':
|
|
||||||
return 0x2D, 0, true
|
|
||||||
case '=':
|
|
||||||
return 0x2E, 0, true
|
|
||||||
case '[':
|
|
||||||
return 0x2F, 0, true
|
|
||||||
case ']':
|
|
||||||
return 0x30, 0, true
|
|
||||||
case '\\':
|
|
||||||
return 0x31, 0, true
|
|
||||||
case ';':
|
|
||||||
return 0x33, 0, true
|
|
||||||
case '\'':
|
|
||||||
return 0x34, 0, true
|
|
||||||
case '`':
|
|
||||||
return 0x35, 0, true
|
|
||||||
case ',':
|
|
||||||
return 0x36, 0, true
|
|
||||||
case '.':
|
|
||||||
return 0x37, 0, true
|
|
||||||
case '/':
|
|
||||||
return 0x38, 0, true
|
|
||||||
}
|
|
||||||
return 0, 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
var signerCmd = &cobra.Command{
|
|
||||||
Use: "signer",
|
|
||||||
Short: "OTA firmware signing tool",
|
|
||||||
Long: "Generate keys, sign, and verify OTA firmware using Ed25519",
|
|
||||||
}
|
|
||||||
|
|
||||||
var signerKeygenCmd = &cobra.Command{
|
|
||||||
Use: "keygen",
|
|
||||||
Short: "Generate Ed25519 key pair",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
outputDir, _ := cmd.Flags().GetString("output-dir")
|
|
||||||
|
|
||||||
pubKeyPath := filepath.Join(outputDir, "ota_ed25519.pub")
|
|
||||||
privKeyPath := filepath.Join(outputDir, "ota_ed25519.key")
|
|
||||||
|
|
||||||
if _, err := os.Stat(privKeyPath); err == nil {
|
|
||||||
return fmt.Errorf("private key already exists at %s, refusing to overwrite", privKeyPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
publicKey, privateKey, err := ed25519.GenerateKey(nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("generating key pair: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
|
||||||
return fmt.Errorf("creating output directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(privKeyPath, privateKey, 0600); err != nil {
|
|
||||||
return fmt.Errorf("writing private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(pubKeyPath, publicKey, 0644); err != nil {
|
|
||||||
return fmt.Errorf("writing public key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println(hex.EncodeToString(publicKey))
|
|
||||||
fmt.Fprintf(os.Stderr, "Private key: %s\n", privKeyPath)
|
|
||||||
fmt.Fprintf(os.Stderr, "Public key: %s\n", pubKeyPath)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var signerSignCmd = &cobra.Command{
|
|
||||||
Use: "sign --key <private-key> <firmware-file>",
|
|
||||||
Short: "Sign a firmware file",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
keyArg, _ := cmd.Flags().GetString("key")
|
|
||||||
filePath := args[0]
|
|
||||||
|
|
||||||
if keyArg == "" {
|
|
||||||
return fmt.Errorf("--key is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
privateKey, err := os.ReadFile(keyArg)
|
|
||||||
if err != nil {
|
|
||||||
privateKey, err = hex.DecodeString(keyArg)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid private key: not a valid file path or hex string")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(privateKey) != ed25519.PrivateKeySize {
|
|
||||||
return fmt.Errorf("invalid private key size: got %d bytes, expected %d", len(privateKey), ed25519.PrivateKeySize)
|
|
||||||
}
|
|
||||||
|
|
||||||
fileData, err := os.ReadFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading firmware file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fileHash := sha256.Sum256(fileData)
|
|
||||||
signature := ed25519.Sign(ed25519.PrivateKey(privateKey), fileHash[:])
|
|
||||||
|
|
||||||
sigPath := filePath + ".sig"
|
|
||||||
if err := os.WriteFile(sigPath, signature, 0644); err != nil {
|
|
||||||
return fmt.Errorf("writing signature: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hash := sha256.Sum256(fileData)
|
|
||||||
fmt.Println(hex.EncodeToString(hash[:]))
|
|
||||||
fmt.Fprintf(os.Stderr, "Signature written to: %s\n", sigPath)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var signerVerifyCmd = &cobra.Command{
|
|
||||||
Use: "verify [--pubkey <pubkey-path-or-hex>] <firmware-file> [<sig-file>]",
|
|
||||||
Short: "Verify firmware signature",
|
|
||||||
Args: cobra.RangeArgs(1, 2),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
pubKeyArg, _ := cmd.Flags().GetString("pubkey")
|
|
||||||
filePath := args[0]
|
|
||||||
sigPath := ""
|
|
||||||
if len(args) > 1 {
|
|
||||||
sigPath = args[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
if sigPath == "" {
|
|
||||||
sigPath = filePath + ".sig"
|
|
||||||
}
|
|
||||||
|
|
||||||
var publicKey ed25519.PublicKey
|
|
||||||
|
|
||||||
if pubKeyArg == "" {
|
|
||||||
keyStr := strings.TrimSpace(builtOtaPublicKey)
|
|
||||||
if keyStr == "" {
|
|
||||||
return fmt.Errorf("no --pubkey provided and no OTA public key embedded in binary")
|
|
||||||
}
|
|
||||||
keyBytes, err := hex.DecodeString(keyStr)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("decoding embedded public key hex: %w", err)
|
|
||||||
}
|
|
||||||
publicKey = ed25519.PublicKey(keyBytes)
|
|
||||||
} else if _, err := os.Stat(pubKeyArg); err == nil {
|
|
||||||
keyBytes, err := os.ReadFile(pubKeyArg)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading public key file: %w", err)
|
|
||||||
}
|
|
||||||
publicKey = ed25519.PublicKey(keyBytes)
|
|
||||||
} else {
|
|
||||||
keyBytes, err := hex.DecodeString(pubKeyArg)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("decoding public key hex: %w", err)
|
|
||||||
}
|
|
||||||
publicKey = ed25519.PublicKey(keyBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(publicKey) != ed25519.PublicKeySize {
|
|
||||||
return fmt.Errorf("invalid public key size: got %d bytes, expected %d", len(publicKey), ed25519.PublicKeySize)
|
|
||||||
}
|
|
||||||
|
|
||||||
fileData, err := os.ReadFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading firmware file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sigBytes, err := os.ReadFile(sigPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading signature file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(sigBytes) != ed25519.SignatureSize {
|
|
||||||
return fmt.Errorf("invalid signature size: got %d bytes, expected %d", len(sigBytes), ed25519.SignatureSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
fileHash := sha256.Sum256(fileData)
|
|
||||||
if !ed25519.Verify(publicKey, fileHash[:], sigBytes) {
|
|
||||||
return fmt.Errorf("VERIFICATION FAILED: signature is invalid")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(os.Stderr, "VERIFICATION OK: signature is valid\n")
|
|
||||||
fmt.Fprintf(os.Stderr, "SHA256: %s\n", hex.EncodeToString(fileHash[:]))
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
mouseMoveCmd.Flags().Bool("relative", false, "Use relative positioning")
|
|
||||||
mouseCmd.AddCommand(mouseMoveCmd)
|
|
||||||
mouseCmd.AddCommand(mouseClickCmd)
|
|
||||||
mouseCmd.AddCommand(mouseScrollCmd)
|
|
||||||
mouseCmd.AddCommand(mouseDownCmd)
|
|
||||||
mouseCmd.AddCommand(mouseUpCmd)
|
|
||||||
cliRootCmd.AddCommand(mouseCmd)
|
|
||||||
|
|
||||||
keyboardCmd.AddCommand(keyboardKeyCmd)
|
|
||||||
keyboardCmd.AddCommand(keyboardComboCmd)
|
|
||||||
keyboardCmd.AddCommand(keyboardTypeCmd)
|
|
||||||
cliRootCmd.AddCommand(keyboardCmd)
|
|
||||||
|
|
||||||
cliRootCmd.AddCommand(captureCmd)
|
|
||||||
|
|
||||||
signerKeygenCmd.Flags().String("output-dir", ".", "Directory for key output")
|
|
||||||
signerCmd.AddCommand(signerKeygenCmd)
|
|
||||||
signerSignCmd.Flags().String("key", "", "Path to private key")
|
|
||||||
signerCmd.AddCommand(signerSignCmd)
|
|
||||||
signerVerifyCmd.Flags().String("pubkey", "", "Path to public key file or hex-encoded public key")
|
|
||||||
signerCmd.AddCommand(signerVerifyCmd)
|
|
||||||
cliRootCmd.AddCommand(signerCmd)
|
|
||||||
|
|
||||||
cliRootCmd.AddCommand(statusCmd)
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
|
|
||||||
"kvm"
|
"kvm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) > 1 && os.Args[1] == "cli" {
|
|
||||||
kvm.RunCLI(os.Args[2:])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
kvm.Main()
|
kvm.Main()
|
||||||
}
|
}
|
||||||
|
|||||||
132
config.go
132
config.go
@@ -2,9 +2,6 @@ package kvm
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -24,12 +21,6 @@ type WakeOnLanDevice struct {
|
|||||||
MacAddress string `json:"macAddress"`
|
MacAddress string `json:"macAddress"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TurnServer struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
Credential string `json:"credential"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Constants for keyboard macro limits
|
// Constants for keyboard macro limits
|
||||||
const (
|
const (
|
||||||
MaxMacrosPerDevice = 25
|
MaxMacrosPerDevice = 25
|
||||||
@@ -90,7 +81,6 @@ func (m *KeyboardMacro) Validate() error {
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
STUN string `json:"stun"`
|
STUN string `json:"stun"`
|
||||||
TurnServers []TurnServer `json:"turn_servers"`
|
|
||||||
JigglerEnabled bool `json:"jiggler_enabled"`
|
JigglerEnabled bool `json:"jiggler_enabled"`
|
||||||
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
||||||
IncludePreRelease bool `json:"include_pre_release"`
|
IncludePreRelease bool `json:"include_pre_release"`
|
||||||
@@ -139,7 +129,6 @@ type Config struct {
|
|||||||
WireguardConfig WireguardConfig `json:"wireguard_config"`
|
WireguardConfig WireguardConfig `json:"wireguard_config"`
|
||||||
NpuAppEnabled bool `json:"npu_app_enabled"`
|
NpuAppEnabled bool `json:"npu_app_enabled"`
|
||||||
Firewall *FirewallConfig `json:"firewall"`
|
Firewall *FirewallConfig `json:"firewall"`
|
||||||
APIKey string `json:"api_key"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type FirewallConfig struct {
|
type FirewallConfig struct {
|
||||||
@@ -194,13 +183,8 @@ type WireguardConfig struct {
|
|||||||
const configPath = "/userdata/kvm_config.json"
|
const configPath = "/userdata/kvm_config.json"
|
||||||
const sdConfigPath = "/mnt/sdcard/kvm_config.json"
|
const sdConfigPath = "/mnt/sdcard/kvm_config.json"
|
||||||
|
|
||||||
// builtOtaPublicKey is the hex-encoded Ed25519 public key for OTA signature verification,
|
|
||||||
// injected via -ldflags at build time. Empty string disables signature verification.
|
|
||||||
var builtOtaPublicKey = ""
|
|
||||||
|
|
||||||
var defaultConfig = &Config{
|
var defaultConfig = &Config{
|
||||||
STUN: "stun:stun.l.google.com:19302",
|
STUN: "stun:stun.l.google.com:19302",
|
||||||
TurnServers: []TurnServer{},
|
|
||||||
AutoUpdateEnabled: false, // Set a default value
|
AutoUpdateEnabled: false, // Set a default value
|
||||||
ActiveExtension: "",
|
ActiveExtension: "",
|
||||||
KeyboardMacros: []KeyboardMacro{},
|
KeyboardMacros: []KeyboardMacro{},
|
||||||
@@ -260,75 +244,6 @@ var (
|
|||||||
configLock = &sync.Mutex{}
|
configLock = &sync.Mutex{}
|
||||||
)
|
)
|
||||||
|
|
||||||
type ConfigMigrationFunc func(raw json.RawMessage) (json.RawMessage, error)
|
|
||||||
|
|
||||||
var configMigrations = []ConfigMigrationFunc{
|
|
||||||
migrateLocalAuthMode,
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrateLocalAuthMode(raw json.RawMessage) (json.RawMessage, error) {
|
|
||||||
var rawMap map[string]json.RawMessage
|
|
||||||
if err := json.Unmarshal(raw, &rawMap); err != nil {
|
|
||||||
return nil, fmt.Errorf("migrateLocalAuthMode: failed to parse config JSON: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if authModeRaw, exists := rawMap["localAuthMode"]; exists {
|
|
||||||
var authMode string
|
|
||||||
if err := json.Unmarshal(authModeRaw, &authMode); err == nil {
|
|
||||||
if authMode != "" {
|
|
||||||
validModes := map[string]bool{"password": true, "noPassword": true}
|
|
||||||
if !validModes[authMode] {
|
|
||||||
rawMap["localAuthMode"] = json.RawMessage(`"password"`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
delete(rawMap, "localAuthMode")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := json.Marshal(rawMap)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("migrateLocalAuthMode: failed to marshal migrated config: %w", err)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runMigrations(raw json.RawMessage) (json.RawMessage, bool, error) {
|
|
||||||
current := raw
|
|
||||||
didMigrate := false
|
|
||||||
for i, migration := range configMigrations {
|
|
||||||
migrated, err := migration(current)
|
|
||||||
if err != nil {
|
|
||||||
return current, didMigrate, fmt.Errorf("migration %d failed: %w", i, err)
|
|
||||||
}
|
|
||||||
if string(migrated) != string(current) {
|
|
||||||
didMigrate = true
|
|
||||||
logger.Info().Int("migration", i).Msg("config migrated")
|
|
||||||
}
|
|
||||||
current = migrated
|
|
||||||
}
|
|
||||||
return current, didMigrate, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeRawConfig(path string, raw json.RawMessage) error {
|
|
||||||
file, err := os.Create(path)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create config file: %w", err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
var indented bytes.Buffer
|
|
||||||
if err := json.Indent(&indented, raw, "", " "); err != nil {
|
|
||||||
return fmt.Errorf("failed to indent config JSON: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := indented.WriteTo(file); err != nil {
|
|
||||||
return fmt.Errorf("failed to write config file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadConfig() {
|
func LoadConfig() {
|
||||||
configLock.Lock()
|
configLock.Lock()
|
||||||
defer configLock.Unlock()
|
defer configLock.Unlock()
|
||||||
@@ -338,6 +253,7 @@ func LoadConfig() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// load the default config
|
||||||
if defaultConfig.UsbConfig.SerialNumber == "" {
|
if defaultConfig.UsbConfig.SerialNumber == "" {
|
||||||
serialNumber, err := extractSerialNumber()
|
serialNumber, err := extractSerialNumber()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -349,38 +265,24 @@ func LoadConfig() {
|
|||||||
loadedConfig := *defaultConfig
|
loadedConfig := *defaultConfig
|
||||||
config = &loadedConfig
|
config = &loadedConfig
|
||||||
|
|
||||||
rawData, err := os.ReadFile(configPath)
|
file, err := os.Open(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Debug().Msg("config file does not exist, using default")
|
logger.Debug().Msg("default config file doesn't exist, using default")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
migrated, didMigrate, err := runMigrations(json.RawMessage(rawData))
|
// load and merge the default config with the user config
|
||||||
if err != nil {
|
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
|
||||||
logger.Warn().Err(err).Msg("config migration failed, preserving corrupt file")
|
logger.Warn().Err(err).Msg("config file JSON parsing failed")
|
||||||
corruptPath := configPath + ".corrupt"
|
os.Remove(configPath)
|
||||||
_ = os.Rename(configPath, corruptPath)
|
if _, err := os.Stat(sdConfigPath); err == nil {
|
||||||
logger.Info().Str("corrupt_path", corruptPath).Msg("corrupt config preserved for diagnosis")
|
os.Remove(sdConfigPath)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if didMigrate {
|
|
||||||
if writeErr := writeRawConfig(configPath, migrated); writeErr != nil {
|
|
||||||
logger.Warn().Err(writeErr).Msg("failed to write migrated config, continuing with in-memory version")
|
|
||||||
} else {
|
|
||||||
logger.Info().Msg("migrated config saved to disk")
|
|
||||||
SyncConfigSD(false)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(migrated, &loadedConfig); err != nil {
|
|
||||||
logger.Warn().Err(err).Msg("config file JSON parsing failed, preserving corrupt file")
|
|
||||||
corruptPath := configPath + ".corrupt"
|
|
||||||
_ = os.Rename(configPath, corruptPath)
|
|
||||||
logger.Info().Str("corrupt_path", corruptPath).Msg("corrupt config preserved for diagnosis")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// merge the user config with the default config
|
||||||
if loadedConfig.UsbConfig == nil {
|
if loadedConfig.UsbConfig == nil {
|
||||||
loadedConfig.UsbConfig = defaultConfig.UsbConfig
|
loadedConfig.UsbConfig = defaultConfig.UsbConfig
|
||||||
}
|
}
|
||||||
@@ -397,10 +299,6 @@ func LoadConfig() {
|
|||||||
loadedConfig.Firewall = defaultConfig.Firewall
|
loadedConfig.Firewall = defaultConfig.Firewall
|
||||||
}
|
}
|
||||||
|
|
||||||
if loadedConfig.TurnServers == nil {
|
|
||||||
loadedConfig.TurnServers = []TurnServer{}
|
|
||||||
}
|
|
||||||
|
|
||||||
config = &loadedConfig
|
config = &loadedConfig
|
||||||
|
|
||||||
logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel)
|
logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel)
|
||||||
@@ -499,14 +397,6 @@ func SaveConfig() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateAPIKey() (string, error) {
|
|
||||||
bytes := make([]byte, 32)
|
|
||||||
if _, err := rand.Read(bytes); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(bytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureConfigLoaded() {
|
func ensureConfigLoaded() {
|
||||||
if config == nil {
|
if config == nil {
|
||||||
LoadConfig()
|
LoadConfig()
|
||||||
|
|||||||
25
go.mod
25
go.mod
@@ -1,11 +1,14 @@
|
|||||||
module kvm
|
module kvm
|
||||||
|
|
||||||
go 1.25.5
|
go 1.23.4
|
||||||
|
|
||||||
|
toolchain go1.24.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Masterminds/semver/v3 v3.3.1
|
github.com/Masterminds/semver/v3 v3.3.1
|
||||||
github.com/beevik/ntp v1.4.3
|
github.com/beevik/ntp v1.4.3
|
||||||
github.com/coder/websocket v1.8.13
|
github.com/coder/websocket v1.8.13
|
||||||
|
github.com/coreos/go-oidc/v3 v3.11.0
|
||||||
github.com/creack/pty v1.1.23
|
github.com/creack/pty v1.1.23
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
github.com/gin-contrib/logger v1.2.6
|
github.com/gin-contrib/logger v1.2.6
|
||||||
@@ -14,10 +17,8 @@ require (
|
|||||||
github.com/guregu/null/v6 v6.0.0
|
github.com/guregu/null/v6 v6.0.0
|
||||||
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf
|
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf
|
||||||
github.com/hanwen/go-fuse/v2 v2.8.0
|
github.com/hanwen/go-fuse/v2 v2.8.0
|
||||||
github.com/mark3labs/mcp-go v0.52.0
|
|
||||||
github.com/pion/logging v0.2.3
|
github.com/pion/logging v0.2.3
|
||||||
github.com/pion/mdns/v2 v2.0.7
|
github.com/pion/mdns/v2 v2.0.7
|
||||||
github.com/pion/rtp v1.8.18
|
|
||||||
github.com/pion/webrtc/v4 v4.0.16
|
github.com/pion/webrtc/v4 v4.0.16
|
||||||
github.com/pojntfx/go-nbd v0.3.2
|
github.com/pojntfx/go-nbd v0.3.2
|
||||||
github.com/prometheus/client_golang v1.22.0
|
github.com/prometheus/client_golang v1.22.0
|
||||||
@@ -26,7 +27,6 @@ require (
|
|||||||
github.com/psanford/httpreadat v0.1.0
|
github.com/psanford/httpreadat v0.1.0
|
||||||
github.com/rs/zerolog v1.34.0
|
github.com/rs/zerolog v1.34.0
|
||||||
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f
|
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f
|
||||||
github.com/spf13/cobra v1.10.2
|
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
github.com/vishvananda/netlink v1.3.0
|
github.com/vishvananda/netlink v1.3.0
|
||||||
go.bug.st/serial v1.6.2
|
go.bug.st/serial v1.6.2
|
||||||
@@ -47,12 +47,11 @@ require (
|
|||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
@@ -64,29 +63,35 @@ require (
|
|||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/pilebones/go-udev v0.9.0 // indirect
|
github.com/pilebones/go-udev v0.9.0 // indirect
|
||||||
github.com/pion/datachannel v1.5.10 // indirect
|
github.com/pion/datachannel v1.5.10 // indirect
|
||||||
|
github.com/pion/dtls/v2 v2.2.12 // indirect
|
||||||
github.com/pion/dtls/v3 v3.0.6 // indirect
|
github.com/pion/dtls/v3 v3.0.6 // indirect
|
||||||
|
github.com/pion/ice/v2 v2.3.36 // indirect
|
||||||
github.com/pion/ice/v4 v4.0.10 // indirect
|
github.com/pion/ice/v4 v4.0.10 // indirect
|
||||||
github.com/pion/interceptor v0.1.40 // indirect
|
github.com/pion/interceptor v0.1.40 // indirect
|
||||||
|
github.com/pion/mdns v0.0.12 // indirect
|
||||||
github.com/pion/randutil v0.1.0 // indirect
|
github.com/pion/randutil v0.1.0 // indirect
|
||||||
github.com/pion/rtcp v1.2.15 // indirect
|
github.com/pion/rtcp v1.2.15 // indirect
|
||||||
|
github.com/pion/rtp v1.8.18 // indirect
|
||||||
github.com/pion/sctp v1.8.39 // indirect
|
github.com/pion/sctp v1.8.39 // indirect
|
||||||
github.com/pion/sdp/v3 v3.0.13 // indirect
|
github.com/pion/sdp/v3 v3.0.13 // indirect
|
||||||
|
github.com/pion/srtp/v2 v2.0.20 // indirect
|
||||||
github.com/pion/srtp/v3 v3.0.5 // indirect
|
github.com/pion/srtp/v3 v3.0.5 // indirect
|
||||||
|
github.com/pion/stun v0.6.1 // indirect
|
||||||
github.com/pion/stun/v3 v3.0.0 // indirect
|
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||||
|
github.com/pion/transport/v2 v2.2.10 // indirect
|
||||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||||
|
github.com/pion/turn/v2 v2.1.6 // indirect
|
||||||
github.com/pion/turn/v4 v4.0.2 // indirect
|
github.com/pion/turn/v4 v4.0.2 // indirect
|
||||||
|
github.com/pion/webrtc/v3 v3.3.5 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/client_model v0.6.1 // indirect
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.11.0 // indirect
|
github.com/rogpeppe/go-internal v1.11.0 // indirect
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
|
||||||
github.com/spf13/cast v1.7.1 // indirect
|
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
github.com/vishvananda/netns v0.0.4 // indirect
|
github.com/vishvananda/netns v0.0.4 // indirect
|
||||||
github.com/wlynxg/anet v0.0.5 // indirect
|
github.com/wlynxg/anet v0.0.5 // indirect
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
|
||||||
golang.org/x/arch v0.17.0 // indirect
|
golang.org/x/arch v0.17.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.24.0 // indirect
|
||||||
golang.org/x/text v0.26.0 // indirect
|
golang.org/x/text v0.26.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.6 // indirect
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|||||||
109
go.sum
109
go.sum
@@ -18,8 +18,9 @@ github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJ
|
|||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
|
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
|
||||||
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
|
||||||
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
|
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
|
||||||
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
|
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
|
||||||
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
|
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
|
||||||
@@ -27,10 +28,6 @@ github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
|
||||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||||
@@ -41,6 +38,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
|||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@@ -55,8 +54,7 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x
|
|||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ=
|
github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ=
|
||||||
@@ -65,8 +63,6 @@ github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf h1:JO6ISZIvEUitto
|
|||||||
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
|
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
|
||||||
github.com/hanwen/go-fuse/v2 v2.8.0 h1:wV8rG7rmCz8XHSOwBZhG5YcVqcYjkzivjmbaMafPlAs=
|
github.com/hanwen/go-fuse/v2 v2.8.0 h1:wV8rG7rmCz8XHSOwBZhG5YcVqcYjkzivjmbaMafPlAs=
|
||||||
github.com/hanwen/go-fuse/v2 v2.8.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
|
github.com/hanwen/go-fuse/v2 v2.8.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
@@ -75,6 +71,7 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02
|
|||||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
@@ -86,8 +83,6 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
|||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/mark3labs/mcp-go v0.52.0 h1:uRSzupNSUyPGDpF4owY5X4zEpACPwBnlM3FAFuXN6gQ=
|
|
||||||
github.com/mark3labs/mcp-go v0.52.0/go.mod h1:Zg9cB2HdwdMMVgY0xtTzq3KvYIOJQDsaut+jWjwDaQY=
|
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
@@ -110,34 +105,59 @@ github.com/pilebones/go-udev v0.9.0 h1:N1uEO/SxUwtIctc0WLU0t69JeBxIYEYnj8lT/Nabl
|
|||||||
github.com/pilebones/go-udev v0.9.0/go.mod h1:T2eI2tUSK0hA2WS5QLjXJUfQkluZQu+18Cqvem3CaXI=
|
github.com/pilebones/go-udev v0.9.0/go.mod h1:T2eI2tUSK0hA2WS5QLjXJUfQkluZQu+18Cqvem3CaXI=
|
||||||
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
||||||
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
|
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
|
||||||
|
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||||
|
github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk=
|
||||||
|
github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=
|
||||||
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
|
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
|
||||||
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
|
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
|
||||||
|
github.com/pion/ice/v2 v2.3.36 h1:SopeXiVbbcooUg2EIR8sq4b13RQ8gzrkkldOVg+bBsc=
|
||||||
|
github.com/pion/ice/v2 v2.3.36/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ=
|
||||||
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
|
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
|
||||||
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
|
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
|
||||||
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
|
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
|
||||||
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
|
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
|
||||||
|
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||||
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
|
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
|
||||||
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
|
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
|
||||||
|
github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8=
|
||||||
|
github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk=
|
||||||
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
|
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
|
||||||
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
|
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
|
||||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||||
|
github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
|
||||||
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
|
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
|
||||||
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
|
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
|
||||||
|
github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
|
||||||
github.com/pion/rtp v1.8.18 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU=
|
github.com/pion/rtp v1.8.18 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU=
|
||||||
github.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
|
github.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
|
||||||
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
|
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
|
||||||
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
|
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
|
||||||
github.com/pion/sdp/v3 v3.0.13 h1:uN3SS2b+QDZnWXgdr69SM8KB4EbcnPnPf2Laxhty/l4=
|
github.com/pion/sdp/v3 v3.0.13 h1:uN3SS2b+QDZnWXgdr69SM8KB4EbcnPnPf2Laxhty/l4=
|
||||||
github.com/pion/sdp/v3 v3.0.13/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
|
github.com/pion/sdp/v3 v3.0.13/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
|
||||||
|
github.com/pion/srtp/v2 v2.0.20 h1:HNNny4s+OUmG280ETrCdgFndp4ufx3/uy85EawYEhTk=
|
||||||
|
github.com/pion/srtp/v2 v2.0.20/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA=
|
||||||
github.com/pion/srtp/v3 v3.0.5 h1:8XLB6Dt3QXkMkRFpoqC3314BemkpMQK2mZeJc4pUKqo=
|
github.com/pion/srtp/v3 v3.0.5 h1:8XLB6Dt3QXkMkRFpoqC3314BemkpMQK2mZeJc4pUKqo=
|
||||||
github.com/pion/srtp/v3 v3.0.5/go.mod h1:r1G7y5r1scZRLe2QJI/is+/O83W2d+JoEsuIexpw+uM=
|
github.com/pion/srtp/v3 v3.0.5/go.mod h1:r1G7y5r1scZRLe2QJI/is+/O83W2d+JoEsuIexpw+uM=
|
||||||
|
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
|
||||||
|
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
|
||||||
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
||||||
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
||||||
|
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
|
||||||
|
github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
||||||
|
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
||||||
|
github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q=
|
||||||
|
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
|
||||||
|
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
|
||||||
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||||
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||||
|
github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||||
|
github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc=
|
||||||
|
github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||||
github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps=
|
github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps=
|
||||||
github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
|
github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
|
||||||
|
github.com/pion/webrtc/v3 v3.3.5 h1:ZsSzaMz/i9nblPdiAkZoP+E6Kmjw+jnyq3bEmU3EtRg=
|
||||||
|
github.com/pion/webrtc/v3 v3.3.5/go.mod h1:liNa+E1iwyzyXqNUwvoMRNQ10x8h8FOeJKL8RkIbamE=
|
||||||
github.com/pion/webrtc/v4 v4.0.16 h1:5f8QMVIbNvJr2mPRGi2QamkPa/LVUB6NWolOCwphKHA=
|
github.com/pion/webrtc/v4 v4.0.16 h1:5f8QMVIbNvJr2mPRGi2QamkPa/LVUB6NWolOCwphKHA=
|
||||||
github.com/pion/webrtc/v4 v4.0.16/go.mod h1:C3uTCPzVafUA0eUzru9f47OgNt3nEO7ZJ6zNY6VSJno=
|
github.com/pion/webrtc/v4 v4.0.16/go.mod h1:C3uTCPzVafUA0eUzru9f47OgNt3nEO7ZJ6zNY6VSJno=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
@@ -158,17 +178,8 @@ github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUz
|
|||||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
|
||||||
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f h1:VgoRCP1efSCEZIcF2THLQ46+pIBzzgNiaUBe9wEDwYU=
|
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f h1:VgoRCP1efSCEZIcF2THLQ46+pIBzzgNiaUBe9wEDwYU=
|
||||||
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzro7BGorij2WgrjEammtrkbo3+xldxo+KaGLGUiD+Q=
|
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzro7BGorij2WgrjEammtrkbo3+xldxo+KaGLGUiD+Q=
|
||||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
|
||||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
|
||||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
|
||||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -177,6 +188,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
@@ -187,31 +200,81 @@ github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQ
|
|||||||
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
|
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
|
||||||
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
|
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
|
||||||
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||||
|
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
|
||||||
go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8=
|
go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8=
|
||||||
go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE=
|
go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
|
||||||
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
|
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
|
||||||
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||||
|
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||||
|
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||||
|
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||||
|
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
|
||||||
|
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||||
|
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -35,9 +35,6 @@ 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"`
|
||||||
|
|||||||
@@ -131,10 +131,10 @@ func (u *UsbGadget) GetKeyboardState() KeyboardState {
|
|||||||
return u.keyboardState
|
return u.keyboardState
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UsbGadget) listenKeyboardEvents(ctx context.Context, file *os.File) {
|
func (u *UsbGadget) listenKeyboardEvents() {
|
||||||
var path string
|
var path string
|
||||||
if file != nil {
|
if u.keyboardHidFile != nil {
|
||||||
path = file.Name()
|
path = u.keyboardHidFile.Name()
|
||||||
}
|
}
|
||||||
l := u.log.With().Str("listener", "keyboardEvents").Str("path", path).Logger()
|
l := u.log.With().Str("listener", "keyboardEvents").Str("path", path).Logger()
|
||||||
l.Trace().Msg("starting")
|
l.Trace().Msg("starting")
|
||||||
@@ -143,12 +143,12 @@ func (u *UsbGadget) listenKeyboardEvents(ctx context.Context, file *os.File) {
|
|||||||
buf := make([]byte, hidReadBufferSize)
|
buf := make([]byte, hidReadBufferSize)
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-u.keyboardStateCtx.Done():
|
||||||
l.Info().Msg("context done")
|
l.Info().Msg("context done")
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
l.Trace().Msg("reading from keyboard")
|
l.Trace().Msg("reading from keyboard")
|
||||||
if file == nil {
|
if u.keyboardHidFile == nil {
|
||||||
u.logWithSupression("keyboardHidFileNil", 100, &l, nil, "keyboardHidFile is nil")
|
u.logWithSupression("keyboardHidFileNil", 100, &l, nil, "keyboardHidFile is nil")
|
||||||
// show the error every 100 times to avoid spamming the logs
|
// show the error every 100 times to avoid spamming the logs
|
||||||
time.Sleep(time.Second)
|
time.Sleep(time.Second)
|
||||||
@@ -157,26 +157,16 @@ func (u *UsbGadget) listenKeyboardEvents(ctx context.Context, file *os.File) {
|
|||||||
// reset the counter
|
// reset the counter
|
||||||
u.resetLogSuppressionCounter("keyboardHidFileNil")
|
u.resetLogSuppressionCounter("keyboardHidFileNil")
|
||||||
|
|
||||||
n, err := file.Read(buf)
|
n, err := u.keyboardHidFile.Read(buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() != nil {
|
|
||||||
l.Info().Msg("context canceled while reading keyboard HID file")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
u.logWithSupression("keyboardHidFileRead", 100, &l, err, "failed to read")
|
u.logWithSupression("keyboardHidFileRead", 100, &l, err, "failed to read")
|
||||||
if reopenErr := u.reopenKeyboardHidFile(); reopenErr != nil {
|
continue
|
||||||
u.logWithSupression("keyboardHidFileReopen", 100, &l, reopenErr, "failed to reopen keyboard HID file")
|
|
||||||
} else {
|
|
||||||
u.resetLogSuppressionCounter("keyboardHidFileReopen")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
u.resetLogSuppressionCounter("keyboardHidFileRead")
|
u.resetLogSuppressionCounter("keyboardHidFileRead")
|
||||||
|
|
||||||
l.Trace().Int("n", n).Bytes("buf", buf).Msg("got data from keyboard")
|
l.Trace().Int("n", n).Bytes("buf", buf).Msg("got data from keyboard")
|
||||||
if n < 1 {
|
if n != 1 {
|
||||||
l.Info().Int("n", n).Msg("expected at least 1 byte, got 0")
|
l.Trace().Int("n", n).Msg("expected 1 byte, got")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
u.updateKeyboardState(buf[0])
|
u.updateKeyboardState(buf[0])
|
||||||
@@ -185,52 +175,13 @@ func (u *UsbGadget) listenKeyboardEvents(ctx context.Context, file *os.File) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func openWithTimeout(name string, flag int, perm os.FileMode, timeout time.Duration) (*os.File, error) {
|
func (u *UsbGadget) openKeyboardHidFile() error {
|
||||||
type result struct {
|
|
||||||
file *os.File
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
ch := make(chan result, 1)
|
|
||||||
go func() {
|
|
||||||
f, err := os.OpenFile(name, flag, perm)
|
|
||||||
ch <- result{f, err}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case r := <-ch:
|
|
||||||
return r.file, r.err
|
|
||||||
case <-time.After(timeout):
|
|
||||||
// Drain the channel in the background to close the leaked fd if the
|
|
||||||
// open eventually succeeds.
|
|
||||||
go func() {
|
|
||||||
if r := <-ch; r.file != nil {
|
|
||||||
r.file.Close()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return nil, fmt.Errorf("open %s: timed out after %s", name, timeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UsbGadget) closeKeyboardHidFileLocked() {
|
|
||||||
if u.keyboardStateCancel != nil {
|
|
||||||
u.keyboardStateCancel()
|
|
||||||
u.keyboardStateCancel = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if u.keyboardHidFile != nil {
|
if u.keyboardHidFile != nil {
|
||||||
u.keyboardHidFile.Close()
|
|
||||||
u.keyboardHidFile = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UsbGadget) openKeyboardHidFileLocked(forceReopen bool) error {
|
|
||||||
if forceReopen {
|
|
||||||
u.closeKeyboardHidFileLocked()
|
|
||||||
} else if u.keyboardHidFile != nil {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := openWithTimeout("/dev/hidg0", os.O_RDWR, 0666, 3*time.Second)
|
var err error
|
||||||
|
u.keyboardHidFile, err = os.OpenFile("/dev/hidg0", os.O_RDWR, 0666)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) || strings.Contains(err.Error(), "no such file or directory") || strings.Contains(err.Error(), "no such device") {
|
if errors.Is(err, os.ErrNotExist) || strings.Contains(err.Error(), "no such file or directory") || strings.Contains(err.Error(), "no such device") {
|
||||||
u.log.Error().
|
u.log.Error().
|
||||||
@@ -245,203 +196,33 @@ func (u *UsbGadget) openKeyboardHidFileLocked(forceReopen bool) error {
|
|||||||
return fmt.Errorf("failed to open hidg0: %w", err)
|
return fmt.Errorf("failed to open hidg0: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
if u.keyboardStateCancel != nil {
|
||||||
u.keyboardHidFile = file
|
u.keyboardStateCancel()
|
||||||
u.keyboardStateCtx = ctx
|
}
|
||||||
u.keyboardStateCancel = cancel
|
|
||||||
u.listenKeyboardEvents(ctx, file)
|
u.keyboardStateCtx, u.keyboardStateCancel = context.WithCancel(context.Background())
|
||||||
|
u.listenKeyboardEvents()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UsbGadget) openKeyboardHidFile() error {
|
|
||||||
u.keyboardLock.Lock()
|
|
||||||
defer u.keyboardLock.Unlock()
|
|
||||||
|
|
||||||
return u.openKeyboardHidFileLocked(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UsbGadget) reopenKeyboardHidFile() error {
|
|
||||||
u.keyboardLock.Lock()
|
|
||||||
defer u.keyboardLock.Unlock()
|
|
||||||
|
|
||||||
return u.openKeyboardHidFileLocked(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UsbGadget) OpenKeyboardHidFile() error {
|
func (u *UsbGadget) OpenKeyboardHidFile() error {
|
||||||
return u.openKeyboardHidFile()
|
return u.openKeyboardHidFile()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UsbGadget) ReopenKeyboardHidFile() error {
|
func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
|
||||||
return u.reopenKeyboardHidFile()
|
if err := u.openKeyboardHidFile(); err != nil {
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UsbGadget) keyboardWriteHidFileLocked(modifier byte, keys []byte) error {
|
|
||||||
if len(keys) > 6 {
|
|
||||||
keys = keys[:6]
|
|
||||||
}
|
|
||||||
if len(keys) < 6 {
|
|
||||||
keys = append(keys, make([]byte, 6-len(keys))...)
|
|
||||||
}
|
|
||||||
|
|
||||||
data := []byte{modifier, 0, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]}
|
|
||||||
|
|
||||||
if u.keyboardHidFile == nil {
|
|
||||||
if err := u.openKeyboardHidFileLocked(false); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
_, err := u.writeWithTimeout(u.keyboardHidFile, data)
|
_, err := u.keyboardHidFile.Write(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
u.logWithSupression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0")
|
u.logWithSupression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0")
|
||||||
u.closeKeyboardHidFileLocked()
|
u.keyboardHidFile.Close()
|
||||||
|
u.keyboardHidFile = nil
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u.resetLogSuppressionCounter("keyboardWriteHidFile")
|
u.resetLogSuppressionCounter("keyboardWriteHidFile")
|
||||||
|
|
||||||
u.resetUserInputTime()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type autoReleaseTimer struct {
|
|
||||||
timer *time.Timer
|
|
||||||
key byte
|
|
||||||
active bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type KeysDownState struct {
|
|
||||||
Modifier byte
|
|
||||||
Keys [6]byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UsbGadget) scheduleAutoRelease(key byte) {
|
|
||||||
// Cancel existing timer for this key
|
|
||||||
for i := range u.autoReleaseTimers {
|
|
||||||
if u.autoReleaseTimers[i].key == key && u.autoReleaseTimers[i].active {
|
|
||||||
u.autoReleaseTimers[i].timer.Stop()
|
|
||||||
u.autoReleaseTimers[i].active = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule new timer
|
|
||||||
timer := time.AfterFunc(100*time.Millisecond, func() {
|
|
||||||
u.autoReleaseKey(key)
|
|
||||||
})
|
|
||||||
|
|
||||||
u.autoReleaseTimers = append(u.autoReleaseTimers, autoReleaseTimer{
|
|
||||||
timer: timer,
|
|
||||||
key: key,
|
|
||||||
active: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UsbGadget) autoReleaseKey(key byte) {
|
|
||||||
u.keyboardLock.Lock()
|
|
||||||
defer u.keyboardLock.Unlock()
|
|
||||||
|
|
||||||
// Remove key from buffer
|
|
||||||
found := false
|
|
||||||
for i := 0; i < len(u.keysDownState.Keys); i++ {
|
|
||||||
if u.keysDownState.Keys[i] == key {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
if found && i < len(u.keysDownState.Keys)-1 {
|
|
||||||
u.keysDownState.Keys[i] = u.keysDownState.Keys[i+1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if found {
|
|
||||||
u.keysDownState.Keys[len(u.keysDownState.Keys)-1] = 0
|
|
||||||
u.keyboardWriteHidFileLocked(u.keysDownState.Modifier, u.keysDownState.Keys[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark timer as inactive
|
|
||||||
for i := range u.autoReleaseTimers {
|
|
||||||
if u.autoReleaseTimers[i].key == key && u.autoReleaseTimers[i].active {
|
|
||||||
u.autoReleaseTimers[i].active = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UsbGadget) cancelAutoRelease(key byte) {
|
|
||||||
for i := range u.autoReleaseTimers {
|
|
||||||
if u.autoReleaseTimers[i].key == key && u.autoReleaseTimers[i].active {
|
|
||||||
u.autoReleaseTimers[i].timer.Stop()
|
|
||||||
u.autoReleaseTimers[i].active = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UsbGadget) resetAllAutoReleaseTimers() {
|
|
||||||
for i := range u.autoReleaseTimers {
|
|
||||||
if u.autoReleaseTimers[i].active {
|
|
||||||
u.autoReleaseTimers[i].timer.Stop()
|
|
||||||
u.autoReleaseTimers[i].active = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UsbGadget) KeypressReport(key byte, press bool) error {
|
|
||||||
u.keyboardLock.Lock()
|
|
||||||
defer u.keyboardLock.Unlock()
|
|
||||||
|
|
||||||
if press {
|
|
||||||
// Check if key already in buffer
|
|
||||||
for _, k := range u.keysDownState.Keys {
|
|
||||||
if k == key {
|
|
||||||
return nil // Already pressed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find empty slot
|
|
||||||
emptySlot := -1
|
|
||||||
for i, k := range u.keysDownState.Keys {
|
|
||||||
if k == 0 {
|
|
||||||
emptySlot = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if emptySlot == -1 {
|
|
||||||
// Buffer full - ErrorRollOver
|
|
||||||
u.keysDownState.Keys = [6]byte{0x01, 0x01, 0x01, 0x01, 0x01, 0x01}
|
|
||||||
} else {
|
|
||||||
u.keysDownState.Keys[emptySlot] = key
|
|
||||||
}
|
|
||||||
|
|
||||||
u.scheduleAutoRelease(key)
|
|
||||||
} else {
|
|
||||||
// Remove key from buffer
|
|
||||||
found := false
|
|
||||||
for i := 0; i < len(u.keysDownState.Keys); i++ {
|
|
||||||
if u.keysDownState.Keys[i] == key {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
if found && i < len(u.keysDownState.Keys)-1 {
|
|
||||||
u.keysDownState.Keys[i] = u.keysDownState.Keys[i+1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if found {
|
|
||||||
u.keysDownState.Keys[len(u.keysDownState.Keys)-1] = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
u.cancelAutoRelease(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
return u.keyboardWriteHidFileLocked(u.keysDownState.Modifier, u.keysDownState.Keys[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UsbGadget) KeypressKeepAlive() error {
|
|
||||||
u.keyboardLock.Lock()
|
|
||||||
defer u.keyboardLock.Unlock()
|
|
||||||
|
|
||||||
// Reset auto-release timers for all currently held keys
|
|
||||||
for _, key := range u.keysDownState.Keys {
|
|
||||||
if key != 0 {
|
|
||||||
u.scheduleAutoRelease(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,8 +230,18 @@ func (u *UsbGadget) KeyboardReport(modifier uint8, keys []uint8) error {
|
|||||||
u.keyboardLock.Lock()
|
u.keyboardLock.Lock()
|
||||||
defer u.keyboardLock.Unlock()
|
defer u.keyboardLock.Unlock()
|
||||||
|
|
||||||
u.keysDownState.Modifier = modifier
|
if len(keys) > 6 {
|
||||||
copy(u.keysDownState.Keys[:], keys)
|
keys = keys[:6]
|
||||||
|
}
|
||||||
|
if len(keys) < 6 {
|
||||||
|
keys = append(keys, make([]uint8, 6-len(keys))...)
|
||||||
|
}
|
||||||
|
|
||||||
return u.keyboardWriteHidFileLocked(modifier, keys)
|
err := u.keyboardWriteHidFile([]byte{modifier, 0, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
u.resetUserInputTime()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,9 +82,6 @@ type UsbGadget struct {
|
|||||||
onKeyboardStateChange *func(state KeyboardState)
|
onKeyboardStateChange *func(state KeyboardState)
|
||||||
onHidDeviceMissing *func(device string, err error)
|
onHidDeviceMissing *func(device string, err error)
|
||||||
|
|
||||||
keysDownState KeysDownState
|
|
||||||
autoReleaseTimers []autoReleaseTimer
|
|
||||||
|
|
||||||
log *zerolog.Logger
|
log *zerolog.Logger
|
||||||
|
|
||||||
logSuppressionCounter map[string]int
|
logSuppressionCounter map[string]int
|
||||||
|
|||||||
@@ -2,13 +2,10 @@ package usbgadget
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
@@ -110,36 +107,3 @@ func (u *UsbGadget) resetLogSuppressionCounter(counterName string) {
|
|||||||
u.logSuppressionCounter[counterName] = 0
|
u.logSuppressionCounter[counterName] = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hidWriteTimeout = 50 * time.Millisecond
|
|
||||||
|
|
||||||
func (u *UsbGadget) writeWithTimeout(file *os.File, data []byte) (n int, err error) {
|
|
||||||
if err := file.SetWriteDeadline(time.Now().Add(hidWriteTimeout)); err != nil {
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
|
|
||||||
n, err = file.Write(data)
|
|
||||||
if err == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
u.log.Trace().
|
|
||||||
Str("file", file.Name()).
|
|
||||||
Bytes("data", data).
|
|
||||||
Err(err).
|
|
||||||
Msg("write failed")
|
|
||||||
|
|
||||||
if errors.Is(err, os.ErrDeadlineExceeded) {
|
|
||||||
u.logWithSupression(
|
|
||||||
fmt.Sprintf("writeWithTimeout_%s", file.Name()),
|
|
||||||
1000,
|
|
||||||
u.log,
|
|
||||||
err,
|
|
||||||
"write timed out: %s",
|
|
||||||
file.Name(),
|
|
||||||
)
|
|
||||||
err = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|||||||
9
io.go
9
io.go
@@ -105,15 +105,6 @@ func setLedMode(ledConfigPath string, mode string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to set LED trigger: %v", err)
|
return fmt.Errorf("failed to set LED trigger: %v", err)
|
||||||
}
|
}
|
||||||
case "disabled":
|
|
||||||
err := os.WriteFile(ledConfigPath+"/trigger", []byte("none"), 0644)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to set LED trigger: %v", err)
|
|
||||||
}
|
|
||||||
err = os.WriteFile(ledConfigPath+"/brightness", []byte("0"), 0644)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to set LED brightness: %v", err)
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("invalid LED mode: %s", mode)
|
return fmt.Errorf("invalid LED mode: %s", mode)
|
||||||
}
|
}
|
||||||
|
|||||||
333
jsonrpc.go
333
jsonrpc.go
@@ -79,12 +79,15 @@ func writeJSONRPCEvent(event string, params interface{}, session *Session) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
requestString := string(requestBytes)
|
requestString := string(requestBytes)
|
||||||
|
scopedLogger := jsonRpcLogger.With().
|
||||||
|
Str("data", requestString).
|
||||||
|
Logger()
|
||||||
|
|
||||||
jsonRpcLogger.Trace().Str("event", event).Msg("sending JSONRPC event")
|
scopedLogger.Info().Msg("sending JSONRPC event")
|
||||||
|
|
||||||
err = session.RPCChannel.SendText(requestString)
|
err = session.RPCChannel.SendText(requestString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonRpcLogger.Warn().Err(err).Str("event", event).Msg("error sending JSONRPC event")
|
scopedLogger.Warn().Err(err).Msg("error sending JSONRPC event")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,124 +236,6 @@ func rpcSetStreamEncodecType(encodecType string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type RcQpParams struct {
|
|
||||||
S32FirstFrameStartQp int `json:"s32FirstFrameStartQp"`
|
|
||||||
U32StepQp int `json:"u32StepQp"`
|
|
||||||
U32MinQp int `json:"u32MinQp"`
|
|
||||||
U32MaxQp int `json:"u32MaxQp"`
|
|
||||||
U32MinIQp int `json:"u32MinIQp"`
|
|
||||||
U32MaxIQp int `json:"u32MaxIQp"`
|
|
||||||
S32DeltIpQp int `json:"s32DeltIpQp"`
|
|
||||||
S32MaxReEncodeTimes int `json:"s32MaxReEncodeTimes"`
|
|
||||||
U32FrmMaxQp int `json:"u32FrmMaxQp"`
|
|
||||||
U32FrmMinQp int `json:"u32FrmMinQp"`
|
|
||||||
U32FrmMinIQp int `json:"u32FrmMinIQp"`
|
|
||||||
U32FrmMaxIQp int `json:"u32FrmMaxIQp"`
|
|
||||||
U32MotionStaticSwitchFrmQp int `json:"u32MotionStaticSwitchFrmQp"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type VideoRcConfigParams struct {
|
|
||||||
H264 RcQpParams `json:"h264"`
|
|
||||||
H265 RcQpParams `json:"h265"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcSetVideoRc(params VideoRcConfigParams) error {
|
|
||||||
logger.Info().Interface("params", params).Msg("Setting video RC params")
|
|
||||||
rcParams := map[string]interface{}{
|
|
||||||
"h264": map[string]interface{}{
|
|
||||||
"s32FirstFrameStartQp": params.H264.S32FirstFrameStartQp,
|
|
||||||
"u32StepQp": params.H264.U32StepQp,
|
|
||||||
"u32MinQp": params.H264.U32MinQp,
|
|
||||||
"u32MaxQp": params.H264.U32MaxQp,
|
|
||||||
"u32MinIQp": params.H264.U32MinIQp,
|
|
||||||
"u32MaxIQp": params.H264.U32MaxIQp,
|
|
||||||
"s32DeltIpQp": params.H264.S32DeltIpQp,
|
|
||||||
"s32MaxReEncodeTimes": params.H264.S32MaxReEncodeTimes,
|
|
||||||
"u32FrmMaxQp": params.H264.U32FrmMaxQp,
|
|
||||||
"u32FrmMinQp": params.H264.U32FrmMinQp,
|
|
||||||
"u32FrmMinIQp": params.H264.U32FrmMinIQp,
|
|
||||||
"u32FrmMaxIQp": params.H264.U32FrmMaxIQp,
|
|
||||||
"u32MotionStaticSwitchFrmQp": params.H264.U32MotionStaticSwitchFrmQp,
|
|
||||||
},
|
|
||||||
"h265": map[string]interface{}{
|
|
||||||
"s32FirstFrameStartQp": params.H265.S32FirstFrameStartQp,
|
|
||||||
"u32StepQp": params.H265.U32StepQp,
|
|
||||||
"u32MinQp": params.H265.U32MinQp,
|
|
||||||
"u32MaxQp": params.H265.U32MaxQp,
|
|
||||||
"u32MinIQp": params.H265.U32MinIQp,
|
|
||||||
"u32MaxIQp": params.H265.U32MaxIQp,
|
|
||||||
"s32DeltIpQp": params.H265.S32DeltIpQp,
|
|
||||||
"s32MaxReEncodeTimes": params.H265.S32MaxReEncodeTimes,
|
|
||||||
"u32FrmMaxQp": params.H265.U32FrmMaxQp,
|
|
||||||
"u32FrmMinQp": params.H265.U32FrmMinQp,
|
|
||||||
"u32FrmMinIQp": params.H265.U32FrmMinIQp,
|
|
||||||
"u32FrmMaxIQp": params.H265.U32FrmMaxIQp,
|
|
||||||
"u32MotionStaticSwitchFrmQp": params.H265.U32MotionStaticSwitchFrmQp,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
var _, err = CallCtrlAction("set_video_rc", rcParams)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcGetVideoRc() (VideoRcConfigParams, error) {
|
|
||||||
resp, err := CallCtrlAction("get_video_rc", nil)
|
|
||||||
if err != nil {
|
|
||||||
return VideoRcConfigParams{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
result := resp.Result
|
|
||||||
if result == nil {
|
|
||||||
return VideoRcConfigParams{}, errors.New("invalid response format")
|
|
||||||
}
|
|
||||||
|
|
||||||
h264Map, _ := result["h264"].(map[string]interface{})
|
|
||||||
h265Map, _ := result["h265"].(map[string]interface{})
|
|
||||||
|
|
||||||
getInt := func(m map[string]interface{}, k string) int {
|
|
||||||
if v, ok := m[k].(float64); ok {
|
|
||||||
return int(v)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
getUint := func(m map[string]interface{}, k string) int {
|
|
||||||
return getInt(m, k)
|
|
||||||
}
|
|
||||||
|
|
||||||
rc := VideoRcConfigParams{
|
|
||||||
H264: RcQpParams{
|
|
||||||
S32FirstFrameStartQp: getInt(h264Map, "s32FirstFrameStartQp"),
|
|
||||||
U32StepQp: getUint(h264Map, "u32StepQp"),
|
|
||||||
U32MinQp: getUint(h264Map, "u32MinQp"),
|
|
||||||
U32MaxQp: getUint(h264Map, "u32MaxQp"),
|
|
||||||
U32MinIQp: getUint(h264Map, "u32MinIQp"),
|
|
||||||
U32MaxIQp: getUint(h264Map, "u32MaxIQp"),
|
|
||||||
S32DeltIpQp: getInt(h264Map, "s32DeltIpQp"),
|
|
||||||
S32MaxReEncodeTimes: getInt(h264Map, "s32MaxReEncodeTimes"),
|
|
||||||
U32FrmMaxQp: getUint(h264Map, "u32FrmMaxQp"),
|
|
||||||
U32FrmMinQp: getUint(h264Map, "u32FrmMinQp"),
|
|
||||||
U32FrmMinIQp: getUint(h264Map, "u32FrmMinIQp"),
|
|
||||||
U32FrmMaxIQp: getUint(h264Map, "u32FrmMaxIQp"),
|
|
||||||
U32MotionStaticSwitchFrmQp: getUint(h264Map, "u32MotionStaticSwitchFrmQp"),
|
|
||||||
},
|
|
||||||
H265: RcQpParams{
|
|
||||||
S32FirstFrameStartQp: getInt(h265Map, "s32FirstFrameStartQp"),
|
|
||||||
U32StepQp: getUint(h265Map, "u32StepQp"),
|
|
||||||
U32MinQp: getUint(h265Map, "u32MinQp"),
|
|
||||||
U32MaxQp: getUint(h265Map, "u32MaxQp"),
|
|
||||||
U32MinIQp: getUint(h265Map, "u32MinIQp"),
|
|
||||||
U32MaxIQp: getUint(h265Map, "u32MaxIQp"),
|
|
||||||
S32DeltIpQp: getInt(h265Map, "s32DeltIpQp"),
|
|
||||||
S32MaxReEncodeTimes: getInt(h265Map, "s32MaxReEncodeTimes"),
|
|
||||||
U32FrmMaxQp: getUint(h265Map, "u32FrmMaxQp"),
|
|
||||||
U32FrmMinQp: getUint(h265Map, "u32FrmMinQp"),
|
|
||||||
U32FrmMinIQp: getUint(h265Map, "u32FrmMinIQp"),
|
|
||||||
U32FrmMaxIQp: getUint(h265Map, "u32FrmMaxIQp"),
|
|
||||||
U32MotionStaticSwitchFrmQp: getUint(h265Map, "u32MotionStaticSwitchFrmQp"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return rc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcSetNpuAppStatus(enable bool) error {
|
func rpcSetNpuAppStatus(enable bool) error {
|
||||||
logger.Info().Bool("enable", enable).Msg("Setting NPU app status")
|
logger.Info().Bool("enable", enable).Msg("Setting NPU app status")
|
||||||
var _, err = CallCtrlAction("set_yolo_enable", map[string]interface{}{"enable": enable})
|
var _, err = CallCtrlAction("set_yolo_enable", map[string]interface{}{"enable": enable})
|
||||||
@@ -494,36 +379,6 @@ func rpcGetUpdateStatus() (*UpdateStatus, error) {
|
|||||||
return updateStatus, nil
|
return updateStatus, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type SelfSignatureStatus struct {
|
|
||||||
AppSignatureAbsent bool `json:"appSignatureAbsent,omitempty"`
|
|
||||||
AppSignatureInvalid bool `json:"appSignatureInvalid,omitempty"`
|
|
||||||
AppNoPublicKey bool `json:"appNoPublicKey,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcGetSelfSignatureStatus() (*SelfSignatureStatus, error) {
|
|
||||||
return getSelfSignatureStatus(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getSelfSignatureStatus() *SelfSignatureStatus {
|
|
||||||
status := &SelfSignatureStatus{}
|
|
||||||
publicKey := getOTAPublicKey()
|
|
||||||
|
|
||||||
appBinPath := "/userdata/picokvm/bin/kvm_app"
|
|
||||||
appSigPath := appBinPath + ".sig"
|
|
||||||
|
|
||||||
status.AppSignatureAbsent = isSigFileAbsent(appSigPath)
|
|
||||||
|
|
||||||
if !status.AppSignatureAbsent {
|
|
||||||
if publicKey == nil {
|
|
||||||
status.AppNoPublicKey = true
|
|
||||||
} else {
|
|
||||||
status.AppSignatureInvalid = !verifyLocalFileSignature(appBinPath, appSigPath, publicKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcTryUpdate() error {
|
func rpcTryUpdate() error {
|
||||||
includePreRelease := config.IncludePreRelease
|
includePreRelease := config.IncludePreRelease
|
||||||
go func() {
|
go func() {
|
||||||
@@ -535,14 +390,6 @@ func rpcTryUpdate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcUpdateSignatures() (*SignatureUpdateResult, error) {
|
|
||||||
result, err := UpdateSignatures(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn().Err(err).Msg("failed to update signatures")
|
|
||||||
}
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcGetCustomUpdateBaseURL() (string, error) {
|
func rpcGetCustomUpdateBaseURL() (string, error) {
|
||||||
return customUpdateBaseURL, nil
|
return customUpdateBaseURL, nil
|
||||||
}
|
}
|
||||||
@@ -1005,60 +852,6 @@ func rpcSetConfigRaw(configStr string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type RtcServersConfig struct {
|
|
||||||
STUN string `json:"stun"`
|
|
||||||
DefaultSTUN string `json:"defaultStun"`
|
|
||||||
TurnServers []TurnServer `json:"turnServers"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcGetRtcServersConfig() (RtcServersConfig, error) {
|
|
||||||
return RtcServersConfig{
|
|
||||||
STUN: config.STUN,
|
|
||||||
DefaultSTUN: DefaultSTUN,
|
|
||||||
TurnServers: config.TurnServers,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcSetStunServer(stun string) error {
|
|
||||||
config.STUN = stun
|
|
||||||
return SaveConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
type SetTurnServersParams struct {
|
|
||||||
Servers []TurnServer `json:"servers"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcSetTurnServers(params SetTurnServersParams) error {
|
|
||||||
config.TurnServers = params.Servers
|
|
||||||
if config.TurnServers == nil {
|
|
||||||
config.TurnServers = []TurnServer{}
|
|
||||||
}
|
|
||||||
return SaveConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
type IceServerJSON struct {
|
|
||||||
URLs []string `json:"urls"`
|
|
||||||
Username string `json:"username,omitempty"`
|
|
||||||
Credential string `json:"credential,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcGetIceServers() ([]IceServerJSON, error) {
|
|
||||||
raw := buildICEServers()
|
|
||||||
out := make([]IceServerJSON, 0, len(raw))
|
|
||||||
for _, server := range raw {
|
|
||||||
credential := ""
|
|
||||||
if server.Credential != nil {
|
|
||||||
credential = fmt.Sprintf("%v", server.Credential)
|
|
||||||
}
|
|
||||||
out = append(out, IceServerJSON{
|
|
||||||
URLs: server.URLs,
|
|
||||||
Username: server.Username,
|
|
||||||
Credential: credential,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcGetActiveExtension() (string, error) {
|
func rpcGetActiveExtension() (string, error) {
|
||||||
return config.ActiveExtension, nil
|
return config.ActiveExtension, nil
|
||||||
}
|
}
|
||||||
@@ -1333,30 +1126,6 @@ func rpcSetLocalLoopbackOnly(enabled bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcGetApiKey() (string, error) {
|
|
||||||
return config.APIKey, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcSetApiKey(apiKey string) error {
|
|
||||||
config.APIKey = apiKey
|
|
||||||
if err := SaveConfig(); err != nil {
|
|
||||||
return fmt.Errorf("failed to save config: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcGenerateApiKey() (string, error) {
|
|
||||||
key, err := generateAPIKey()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to generate API key: %w", err)
|
|
||||||
}
|
|
||||||
config.APIKey = key
|
|
||||||
if err := SaveConfig(); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to save config: %w", err)
|
|
||||||
}
|
|
||||||
return key, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type IOSettings struct {
|
type IOSettings struct {
|
||||||
IO0Status bool `json:"io0Status"`
|
IO0Status bool `json:"io0Status"`
|
||||||
IO1Status bool `json:"io1Status"`
|
IO1Status bool `json:"io1Status"`
|
||||||
@@ -1555,77 +1324,6 @@ func rpcConfirmOtherSession() (bool, error) {
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const jpegScreenshotPath = "/userdata/picokvm/screenshot/kvm_screenshot.jpg"
|
|
||||||
|
|
||||||
// StartJpegCapture starts continuous JPEG capture mode.
|
|
||||||
func StartJpegCapture() error {
|
|
||||||
_, err := CallCtrlAction("jpeg_capture_start", nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to start JPEG capture: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StopJpegCapture stops continuous JPEG capture mode.
|
|
||||||
func StopJpegCapture() error {
|
|
||||||
_, err := CallCtrlAction("jpeg_capture_stop", nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to stop JPEG capture: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// captureScreenshot captures a JPEG screenshot using hardware encoder.
|
|
||||||
func captureScreenshot(format string) ([]byte, error) {
|
|
||||||
if format != "jpeg" && format != "jpg" {
|
|
||||||
return nil, fmt.Errorf("only JPEG format is supported")
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info().Msg("triggering JPEG snapshot via jpeg_take_snapshot")
|
|
||||||
|
|
||||||
if err := os.MkdirAll("/userdata/picokvm/screenshot", 0o755); err != nil {
|
|
||||||
logger.Warn().Err(err).Msg("failed to create screenshot directory")
|
|
||||||
}
|
|
||||||
|
|
||||||
os.Remove(jpegScreenshotPath)
|
|
||||||
|
|
||||||
// drain any stale signal before triggering
|
|
||||||
select {
|
|
||||||
case <-jpegReadyCh:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := CallCtrlAction("jpeg_take_snapshot", nil)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error().Err(err).Msg("jpeg_take_snapshot failed")
|
|
||||||
return nil, fmt.Errorf("failed to trigger JPEG capture: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// wait for jpeg_ready event from native, fall back to polling on timeout
|
|
||||||
timeout := time.NewTimer(2 * time.Second)
|
|
||||||
defer timeout.Stop()
|
|
||||||
select {
|
|
||||||
case <-jpegReadyCh:
|
|
||||||
case <-timeout.C:
|
|
||||||
logger.Warn().Msg("jpeg_ready event not received within 2s, falling back to polling")
|
|
||||||
for i := 0; i < 5; i++ {
|
|
||||||
if data, err := os.ReadFile(jpegScreenshotPath); err == nil && len(data) > 0 {
|
|
||||||
logger.Info().Int("size", len(data)).Msg("JPEG captured (fallback polling)")
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
time.Sleep(200 * time.Millisecond)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("JPEG file not found at %s", jpegScreenshotPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(jpegScreenshotPath)
|
|
||||||
if err != nil || len(data) == 0 {
|
|
||||||
return nil, fmt.Errorf("JPEG file not readable after jpeg_ready event: %w", err)
|
|
||||||
}
|
|
||||||
logger.Info().Int("size", len(data)).Msg("JPEG captured successfully")
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var rpcHandlers = map[string]RPCHandler{
|
var rpcHandlers = map[string]RPCHandler{
|
||||||
"ping": {Func: rpcPing},
|
"ping": {Func: rpcPing},
|
||||||
"reboot": {Func: rpcReboot, Params: []string{"force"}},
|
"reboot": {Func: rpcReboot, Params: []string{"force"}},
|
||||||
@@ -1663,9 +1361,7 @@ var rpcHandlers = map[string]RPCHandler{
|
|||||||
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
|
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
|
||||||
"getLocalUpdateStatus": {Func: rpcGetLocalUpdateStatus},
|
"getLocalUpdateStatus": {Func: rpcGetLocalUpdateStatus},
|
||||||
"getUpdateStatus": {Func: rpcGetUpdateStatus},
|
"getUpdateStatus": {Func: rpcGetUpdateStatus},
|
||||||
"getSelfSignatureStatus": {Func: rpcGetSelfSignatureStatus},
|
|
||||||
"tryUpdate": {Func: rpcTryUpdate},
|
"tryUpdate": {Func: rpcTryUpdate},
|
||||||
"updateSignatures": {Func: rpcUpdateSignatures},
|
|
||||||
"getCustomUpdateBaseURL": {Func: rpcGetCustomUpdateBaseURL},
|
"getCustomUpdateBaseURL": {Func: rpcGetCustomUpdateBaseURL},
|
||||||
"setCustomUpdateBaseURL": {Func: rpcSetCustomUpdateBaseURL, Params: []string{"baseURL"}},
|
"setCustomUpdateBaseURL": {Func: rpcSetCustomUpdateBaseURL, Params: []string{"baseURL"}},
|
||||||
"getUpdateDownloadProxy": {Func: rpcGetUpdateDownloadProxy},
|
"getUpdateDownloadProxy": {Func: rpcGetUpdateDownloadProxy},
|
||||||
@@ -1673,9 +1369,6 @@ var rpcHandlers = map[string]RPCHandler{
|
|||||||
"getDevModeState": {Func: rpcGetDevModeState},
|
"getDevModeState": {Func: rpcGetDevModeState},
|
||||||
"getSSHKeyState": {Func: rpcGetSSHKeyState},
|
"getSSHKeyState": {Func: rpcGetSSHKeyState},
|
||||||
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
|
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
|
||||||
"getApiKey": {Func: rpcGetApiKey},
|
|
||||||
"setApiKey": {Func: rpcSetApiKey, Params: []string{"apiKey"}},
|
|
||||||
"generateApiKey": {Func: rpcGenerateApiKey},
|
|
||||||
"getTLSState": {Func: rpcGetTLSState},
|
"getTLSState": {Func: rpcGetTLSState},
|
||||||
"setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}},
|
"setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}},
|
||||||
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
|
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
|
||||||
@@ -1694,7 +1387,7 @@ var rpcHandlers = map[string]RPCHandler{
|
|||||||
"resetSDStorage": {Func: rpcResetSDStorage},
|
"resetSDStorage": {Func: rpcResetSDStorage},
|
||||||
"mountSDStorage": {Func: rpcMountSDStorage},
|
"mountSDStorage": {Func: rpcMountSDStorage},
|
||||||
"unmountSDStorage": {Func: rpcUnmountSDStorage},
|
"unmountSDStorage": {Func: rpcUnmountSDStorage},
|
||||||
"formatSDStorage": {Func: rpcFormatSDStorage, Params: []string{"confirm", "fsType"}},
|
"formatSDStorage": {Func: rpcFormatSDStorage, Params: []string{"confirm"}},
|
||||||
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
|
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
|
||||||
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
|
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
|
||||||
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
|
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
|
||||||
@@ -1713,10 +1406,6 @@ var rpcHandlers = map[string]RPCHandler{
|
|||||||
"resetConfig": {Func: rpcResetConfig},
|
"resetConfig": {Func: rpcResetConfig},
|
||||||
"getConfigRaw": {Func: rpcGetConfigRaw},
|
"getConfigRaw": {Func: rpcGetConfigRaw},
|
||||||
"setConfigRaw": {Func: rpcSetConfigRaw, Params: []string{"configStr"}},
|
"setConfigRaw": {Func: rpcSetConfigRaw, Params: []string{"configStr"}},
|
||||||
"getRtcServersConfig": {Func: rpcGetRtcServersConfig},
|
|
||||||
"setStunServer": {Func: rpcSetStunServer, Params: []string{"stun"}},
|
|
||||||
"setTurnServers": {Func: rpcSetTurnServers, Params: []string{"params"}},
|
|
||||||
"getIceServers": {Func: rpcGetIceServers},
|
|
||||||
"setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}},
|
"setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}},
|
||||||
"getDisplayRotation": {Func: rpcGetDisplayRotation},
|
"getDisplayRotation": {Func: rpcGetDisplayRotation},
|
||||||
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
|
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
|
||||||
@@ -1780,18 +1469,8 @@ var rpcHandlers = map[string]RPCHandler{
|
|||||||
"stopCloudflared": {Func: rpcStopCloudflared},
|
"stopCloudflared": {Func: rpcStopCloudflared},
|
||||||
"getCloudflaredStatus": {Func: rpcGetCloudflaredStatus},
|
"getCloudflaredStatus": {Func: rpcGetCloudflaredStatus},
|
||||||
"getCloudflaredLog": {Func: rpcGetCloudflaredLog},
|
"getCloudflaredLog": {Func: rpcGetCloudflaredLog},
|
||||||
"getVpnToolSystemInfo": {Func: rpcGetVpnToolSystemInfo},
|
|
||||||
"getVpnToolStatus": {Func: rpcGetVpnToolStatus, Params: []string{"tool"}},
|
|
||||||
"listVpnToolReleases": {Func: rpcListVpnToolReleases, Params: []string{"tool"}},
|
|
||||||
"installVpnTool": {Func: rpcInstallVpnTool, Params: []string{"tool", "version", "assetName", "downloadURL"}},
|
|
||||||
"startVpnToolInstall": {Func: rpcStartVpnToolInstall, Params: []string{"tool", "version", "assetName", "downloadURL"}},
|
|
||||||
"getVpnToolInstallTask": {Func: rpcGetVpnToolInstallTask, Params: []string{"tool"}},
|
|
||||||
"useVpnToolVersion": {Func: rpcUseVpnToolVersion, Params: []string{"tool", "version"}},
|
|
||||||
"uninstallVpnToolVersion": {Func: rpcUninstallVpnToolVersion, Params: []string{"tool", "version"}},
|
|
||||||
"getStreamEncodecType": {Func: rpcGetStreamEncodecType},
|
"getStreamEncodecType": {Func: rpcGetStreamEncodecType},
|
||||||
"setStreamEncodecType": {Func: rpcSetStreamEncodecType, Params: []string{"encodecType"}},
|
"setStreamEncodecType": {Func: rpcSetStreamEncodecType, Params: []string{"encodecType"}},
|
||||||
"setVideoRc": {Func: rpcSetVideoRc, Params: []string{"params"}},
|
|
||||||
"getVideoRc": {Func: rpcGetVideoRc},
|
|
||||||
"setNpuAppStatus": {Func: rpcSetNpuAppStatus, Params: []string{"enable"}},
|
"setNpuAppStatus": {Func: rpcSetNpuAppStatus, Params: []string{"enable"}},
|
||||||
"getNpuAppStatus": {Func: rpcGetNpuAppStatus},
|
"getNpuAppStatus": {Func: rpcGetNpuAppStatus},
|
||||||
"startWireguard": {Func: rpcStartWireguard, Params: []string{"configFile"}},
|
"startWireguard": {Func: rpcStartWireguard, Params: []string{"configFile"}},
|
||||||
|
|||||||
23
main.go
23
main.go
@@ -18,20 +18,6 @@ func Main() {
|
|||||||
SyncConfigSD(true)
|
SyncConfigSD(true)
|
||||||
LoadConfig()
|
LoadConfig()
|
||||||
|
|
||||||
if config.APIKey == "" {
|
|
||||||
key, err := generateAPIKey()
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn().Err(err).Msg("failed to generate API key")
|
|
||||||
} else {
|
|
||||||
config.APIKey = key
|
|
||||||
if err := SaveConfig(); err != nil {
|
|
||||||
logger.Warn().Err(err).Msg("failed to save API key to config")
|
|
||||||
} else {
|
|
||||||
logger.Info().Msg("generated new API key")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var cancel context.CancelFunc
|
var cancel context.CancelFunc
|
||||||
appCtx, cancel = context.WithCancel(context.Background())
|
appCtx, cancel = context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -199,15 +185,6 @@ func Main() {
|
|||||||
//go RunFuseServer()
|
//go RunFuseServer()
|
||||||
go RunWebServer()
|
go RunWebServer()
|
||||||
|
|
||||||
// API and MCP services temporarily disabled for debugging
|
|
||||||
go func() {
|
|
||||||
StartAPIServer(8080)
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
StartMCP(8081, false)
|
|
||||||
}()
|
|
||||||
|
|
||||||
go RunWebSecureServer()
|
go RunWebSecureServer()
|
||||||
// Web secure server is started only if TLS mode is enabled
|
// Web secure server is started only if TLS mode is enabled
|
||||||
if config.TLSMode != "" {
|
if config.TLSMode != "" {
|
||||||
|
|||||||
313
mcp.go
313
mcp.go
@@ -1,313 +0,0 @@
|
|||||||
package kvm
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/mark3labs/mcp-go/mcp"
|
|
||||||
"github.com/mark3labs/mcp-go/server"
|
|
||||||
)
|
|
||||||
|
|
||||||
func StartMCP(port int, stdio bool) {
|
|
||||||
s := server.NewMCPServer("picokvm-mcp", "1.0.0")
|
|
||||||
registerMCPTools(s)
|
|
||||||
|
|
||||||
if stdio {
|
|
||||||
logger.Info().Msg("Starting MCP stdio server")
|
|
||||||
if err := server.ServeStdio(s); err != nil {
|
|
||||||
logger.Error().Err(err).Msg("MCP stdio server failed")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSE mode
|
|
||||||
addr := fmt.Sprintf(":%d", port)
|
|
||||||
sseServer := server.NewSSEServer(s)
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.Handle("/sse", sseServer.SSEHandler())
|
|
||||||
mux.Handle("/message", sseServer.MessageHandler())
|
|
||||||
|
|
||||||
var handler http.Handler = mux
|
|
||||||
if config.APIKey != "" {
|
|
||||||
handler = withAPIKeyAuth(handler, config.APIKey)
|
|
||||||
}
|
|
||||||
handler = withCORS(handler)
|
|
||||||
|
|
||||||
logger.Info().Str("addr", addr).Msg("Starting MCP SSE server")
|
|
||||||
if err := http.ListenAndServe(addr, handler); err != nil {
|
|
||||||
logger.Error().Err(err).Msg("MCP SSE server failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Shared middleware helpers ===
|
|
||||||
|
|
||||||
func withCORS(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
|
|
||||||
if r.Method == "OPTIONS" {
|
|
||||||
w.WriteHeader(204)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func withAPIKeyAuth(next http.Handler, expectedKey string) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Skip auth for localhost
|
|
||||||
if strings.HasPrefix(r.RemoteAddr, "127.0.0.1:") ||
|
|
||||||
strings.HasPrefix(r.RemoteAddr, "[::1]:") {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
auth := r.Header.Get("Authorization")
|
|
||||||
var key string
|
|
||||||
if _, err := fmt.Sscanf(auth, "Bearer %s", &key); err != nil {
|
|
||||||
http.Error(w, `{"error":"missing or invalid authorization"}`, http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !strings.EqualFold(key, expectedKey) {
|
|
||||||
http.Error(w, `{"error":"invalid api key"}`, http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// === MCP Tool Registration ===
|
|
||||||
|
|
||||||
func registerMCPTools(s *server.MCPServer) {
|
|
||||||
s.AddTool(mcp.NewTool("mouse_move_absolute",
|
|
||||||
mcp.WithDescription("Move mouse to absolute coordinates (0-32767)"),
|
|
||||||
mcp.WithNumber("x", mcp.Required(), mcp.Description("X coordinate")),
|
|
||||||
mcp.WithNumber("y", mcp.Required(), mcp.Description("Y coordinate")),
|
|
||||||
), handleMouseMoveAbsolute)
|
|
||||||
|
|
||||||
s.AddTool(mcp.NewTool("mouse_move_relative",
|
|
||||||
mcp.WithDescription("Move mouse by relative offset"),
|
|
||||||
mcp.WithNumber("dx", mcp.Required()),
|
|
||||||
mcp.WithNumber("dy", mcp.Required()),
|
|
||||||
), handleMouseMoveRelative)
|
|
||||||
|
|
||||||
s.AddTool(mcp.NewTool("mouse_click",
|
|
||||||
mcp.WithDescription("Click mouse button"),
|
|
||||||
mcp.WithString("button", mcp.Required(), mcp.Enum("left", "right", "middle")),
|
|
||||||
), handleMouseClick)
|
|
||||||
|
|
||||||
s.AddTool(mcp.NewTool("mouse_scroll",
|
|
||||||
mcp.WithDescription("Scroll mouse wheel"),
|
|
||||||
mcp.WithNumber("delta", mcp.Required()),
|
|
||||||
), handleMouseScroll)
|
|
||||||
|
|
||||||
s.AddTool(mcp.NewTool("keyboard_key",
|
|
||||||
mcp.WithDescription("Press a key"),
|
|
||||||
mcp.WithString("key", mcp.Required(), mcp.Description("Key name: Enter, Escape, Tab, etc.")),
|
|
||||||
), handleKeyboardKey)
|
|
||||||
|
|
||||||
s.AddTool(mcp.NewTool("keyboard_combo",
|
|
||||||
mcp.WithDescription("Press key combination"),
|
|
||||||
mcp.WithArray("keys", mcp.Required(), mcp.Items(map[string]any{"type": "string"})),
|
|
||||||
), handleKeyboardCombo)
|
|
||||||
|
|
||||||
s.AddTool(mcp.NewTool("type_text",
|
|
||||||
mcp.WithDescription("Type text string"),
|
|
||||||
mcp.WithString("text", mcp.Required()),
|
|
||||||
), handleTypeText)
|
|
||||||
|
|
||||||
s.AddTool(mcp.NewTool("capture_screenshot",
|
|
||||||
mcp.WithDescription("Capture JPEG screenshot using hardware encoder"),
|
|
||||||
), handleCaptureScreenshot)
|
|
||||||
|
|
||||||
s.AddTool(mcp.NewTool("get_video_state",
|
|
||||||
mcp.WithDescription("Get screen resolution and video status"),
|
|
||||||
), handleGetVideoState)
|
|
||||||
}
|
|
||||||
|
|
||||||
// === MCP Handlers ===
|
|
||||||
|
|
||||||
func handleMouseMoveAbsolute(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := req.GetArguments()
|
|
||||||
x, _ := args["x"].(float64)
|
|
||||||
y, _ := args["y"].(float64)
|
|
||||||
_, err := callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
|
|
||||||
"x": x, "y": y, "buttons": 0,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return mcp.NewToolResultText(fmt.Sprintf("Mouse moved to (%d, %d)", int(x), int(y))), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleMouseMoveRelative(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := req.GetArguments()
|
|
||||||
dx, _ := args["dx"].(float64)
|
|
||||||
dy, _ := args["dy"].(float64)
|
|
||||||
_, err := callRPCHandler(rpcHandlers["relMouseReport"], map[string]interface{}{
|
|
||||||
"dx": dx, "dy": dy, "buttons": 0,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return mcp.NewToolResultText(fmt.Sprintf("Mouse moved by (%d, %d)", int(dx), int(dy))), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleMouseClick(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := req.GetArguments()
|
|
||||||
button, _ := args["button"].(string)
|
|
||||||
var buttons uint8
|
|
||||||
switch button {
|
|
||||||
case "left":
|
|
||||||
buttons = 1
|
|
||||||
case "right":
|
|
||||||
buttons = 2
|
|
||||||
case "middle":
|
|
||||||
buttons = 4
|
|
||||||
}
|
|
||||||
_, err := callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
|
|
||||||
"x": 0, "y": 0, "buttons": buttons,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
_, err = callRPCHandler(rpcHandlers["absMouseReport"], map[string]interface{}{
|
|
||||||
"x": 0, "y": 0, "buttons": 0,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return mcp.NewToolResultText(fmt.Sprintf("Clicked %s button", button)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleMouseScroll(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := req.GetArguments()
|
|
||||||
delta, _ := args["delta"].(float64)
|
|
||||||
_, err := callRPCHandler(rpcHandlers["wheelReport"], map[string]interface{}{
|
|
||||||
"wheelY": delta,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return mcp.NewToolResultText(fmt.Sprintf("Scrolled by %d", int(delta))), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleKeyboardKey(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := req.GetArguments()
|
|
||||||
keyName, _ := args["key"].(string)
|
|
||||||
keyCode, ok := keyNameToCode[keyName]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("unknown key: %s", keyName)
|
|
||||||
}
|
|
||||||
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
|
||||||
"modifier": 0, "keys": []uint8{keyCode},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
|
||||||
"modifier": 0, "keys": []uint8{},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return mcp.NewToolResultText(fmt.Sprintf("Pressed key: %s", keyName)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleKeyboardCombo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := req.GetArguments()
|
|
||||||
keysArg, _ := args["keys"].([]interface{})
|
|
||||||
var keys []uint8
|
|
||||||
var modifier uint8
|
|
||||||
|
|
||||||
for _, k := range keysArg {
|
|
||||||
keyName, _ := k.(string)
|
|
||||||
switch strings.ToLower(keyName) {
|
|
||||||
case "ctrl", "control":
|
|
||||||
modifier |= 0x01
|
|
||||||
continue
|
|
||||||
case "shift":
|
|
||||||
modifier |= 0x02
|
|
||||||
continue
|
|
||||||
case "alt":
|
|
||||||
modifier |= 0x04
|
|
||||||
continue
|
|
||||||
case "meta", "win", "cmd":
|
|
||||||
modifier |= 0x08
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
keyCode, ok := keyNameToCode[keyName]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("unknown key: %s", keyName)
|
|
||||||
}
|
|
||||||
keys = append(keys, keyCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
|
||||||
"modifier": modifier, "keys": keys,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
|
||||||
"modifier": 0, "keys": []uint8{},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return mcp.NewToolResultText(fmt.Sprintf("Pressed combo: %v", keysArg)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleTypeText(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := req.GetArguments()
|
|
||||||
text, _ := args["text"].(string)
|
|
||||||
for _, char := range text {
|
|
||||||
keyCode, modifier, ok := charToKeyCode(uint8(char))
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
_, err := callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
|
||||||
"modifier": modifier, "keys": []uint8{keyCode},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
_, err = callRPCHandler(rpcHandlers["keyboardReport"], map[string]interface{}{
|
|
||||||
"modifier": 0, "keys": []uint8{},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mcp.NewToolResultText(fmt.Sprintf("Typed: %s", text)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleCaptureScreenshot(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
data, err := captureScreenshot("jpeg")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
base64Data := base64.StdEncoding.EncodeToString(data)
|
|
||||||
return mcp.NewToolResultImage("JPEG screenshot captured", base64Data, "image/jpeg"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleGetVideoState(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
result, err := callRPCHandler(rpcHandlers["getVideoState"], nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
state, ok := result.(VideoInputState)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("unexpected video state type")
|
|
||||||
}
|
|
||||||
text := fmt.Sprintf("Video: %dx%d @ %.1f fps (Ready: %v)", state.Width, state.Height, state.FramePerSecond, state.Ready)
|
|
||||||
if state.Error != "" {
|
|
||||||
text += fmt.Sprintf(" [Error: %s]", state.Error)
|
|
||||||
}
|
|
||||||
return mcp.NewToolResultText(text), nil
|
|
||||||
}
|
|
||||||
22
native.go
22
native.go
@@ -43,10 +43,6 @@ var (
|
|||||||
videoCmdLock = &sync.Mutex{}
|
videoCmdLock = &sync.Mutex{}
|
||||||
)
|
)
|
||||||
|
|
||||||
// jpegReadyCh is written by the ctrl socket event handler and read by captureScreenshot.
|
|
||||||
// Buffered to 1 so the jpeg thread doesn't block if captureScreenshot has already timed out.
|
|
||||||
var jpegReadyCh = make(chan struct{}, 1)
|
|
||||||
|
|
||||||
func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse, error) {
|
func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse, error) {
|
||||||
lock.Lock()
|
lock.Lock()
|
||||||
defer lock.Unlock()
|
defer lock.Unlock()
|
||||||
@@ -80,7 +76,7 @@ func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case response := <-responseChan:
|
case response := <-responseChan:
|
||||||
delete(ongoingRequests, ctrlAction.Seq)
|
delete(ongoingRequests, seq)
|
||||||
if response.Error != "" {
|
if response.Error != "" {
|
||||||
return nil, ErrorfL(
|
return nil, ErrorfL(
|
||||||
&scopedLogger,
|
&scopedLogger,
|
||||||
@@ -91,7 +87,7 @@ func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse
|
|||||||
return response, nil
|
return response, nil
|
||||||
case <-time.After(5 * time.Second):
|
case <-time.After(5 * time.Second):
|
||||||
close(responseChan)
|
close(responseChan)
|
||||||
delete(ongoingRequests, ctrlAction.Seq)
|
delete(ongoingRequests, seq)
|
||||||
return nil, ErrorfL(&scopedLogger, "timeout waiting for response", nil)
|
return nil, ErrorfL(&scopedLogger, "timeout waiting for response", nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,11 +194,12 @@ func handleCtrlClient(conn net.Conn) {
|
|||||||
scopedLogger.Warn().Err(err).Msg("error reading from ctrl sock")
|
scopedLogger.Warn().Err(err).Msg("error reading from ctrl sock")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
readMsg := string(readBuf[:n])
|
||||||
|
|
||||||
ctrlResp := CtrlResponse{}
|
ctrlResp := CtrlResponse{}
|
||||||
err = json.Unmarshal(readBuf[:n], &ctrlResp)
|
err = json.Unmarshal([]byte(readMsg), &ctrlResp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
scopedLogger.Warn().Err(err).Str("data", string(readBuf[:n])).Msg("error parsing ctrl sock msg")
|
scopedLogger.Warn().Err(err).Str("data", readMsg).Msg("error parsing ctrl sock msg")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
scopedLogger.Trace().Interface("data", ctrlResp).Msg("ctrl sock msg")
|
scopedLogger.Trace().Interface("data", ctrlResp).Msg("ctrl sock msg")
|
||||||
@@ -216,11 +213,6 @@ func handleCtrlClient(conn net.Conn) {
|
|||||||
switch ctrlResp.Event {
|
switch ctrlResp.Event {
|
||||||
case "video_input_state":
|
case "video_input_state":
|
||||||
HandleVideoStateMessage(ctrlResp)
|
HandleVideoStateMessage(ctrlResp)
|
||||||
case "jpeg_ready":
|
|
||||||
select {
|
|
||||||
case jpegReadyCh <- struct{}{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,7 +242,9 @@ func handleVideoClient(conn net.Conn) {
|
|||||||
lastFrame = now
|
lastFrame = now
|
||||||
|
|
||||||
// Broadcast to HTTP clients
|
// Broadcast to HTTP clients
|
||||||
videoBroadcaster.Broadcast(inboundPacket[:n])
|
dataCopy := make([]byte, n)
|
||||||
|
copy(dataCopy, inboundPacket[:n])
|
||||||
|
videoBroadcaster.Broadcast(dataCopy)
|
||||||
|
|
||||||
if currentSession != nil {
|
if currentSession != nil {
|
||||||
err := currentSession.VideoTrack.WriteSample(media.Sample{Data: inboundPacket[:n], Duration: sinceLastFrame})
|
err := currentSession.VideoTrack.WriteSample(media.Sample{Data: inboundPacket[:n], Duration: sinceLastFrame})
|
||||||
|
|||||||
@@ -163,11 +163,12 @@ func handleDisplayCtrlClient(conn net.Conn) {
|
|||||||
scopedLogger.Warn().Err(err).Msg("error reading from display sock")
|
scopedLogger.Warn().Err(err).Msg("error reading from display sock")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
readMsg := string(readBuf[:n])
|
||||||
|
|
||||||
displayResp := CtrlResponse{}
|
displayResp := CtrlResponse{}
|
||||||
err = json.Unmarshal(readBuf[:n], &displayResp)
|
err = json.Unmarshal([]byte(readMsg), &displayResp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
scopedLogger.Warn().Err(err).Str("data", string(readBuf[:n])).Msg("error parsing display sock msg")
|
scopedLogger.Warn().Err(err).Str("data", readMsg).Msg("error parsing display sock msg")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
scopedLogger.Trace().Interface("data", displayResp).Msg("display sock msg")
|
scopedLogger.Trace().Interface("data", displayResp).Msg("display sock msg")
|
||||||
|
|||||||
@@ -163,11 +163,12 @@ func handleVpnCtrlClient(conn net.Conn) {
|
|||||||
scopedLogger.Warn().Err(err).Msg("error reading from vpn sock")
|
scopedLogger.Warn().Err(err).Msg("error reading from vpn sock")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
readMsg := string(readBuf[:n])
|
||||||
|
|
||||||
vpnResp := CtrlResponse{}
|
vpnResp := CtrlResponse{}
|
||||||
err = json.Unmarshal(readBuf[:n], &vpnResp)
|
err = json.Unmarshal([]byte(readMsg), &vpnResp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
scopedLogger.Warn().Err(err).Str("data", string(readBuf[:n])).Msg("error parsing vpn sock msg")
|
scopedLogger.Warn().Err(err).Str("data", readMsg).Msg("error parsing vpn sock msg")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
scopedLogger.Trace().Interface("data", vpnResp).Msg("vpn sock msg")
|
scopedLogger.Trace().Interface("data", vpnResp).Msg("vpn sock msg")
|
||||||
|
|||||||
38
network.go
38
network.go
@@ -2,8 +2,6 @@ package kvm
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"kvm/internal/network"
|
"kvm/internal/network"
|
||||||
"kvm/internal/udhcpc"
|
"kvm/internal/udhcpc"
|
||||||
@@ -19,27 +17,6 @@ var (
|
|||||||
networkState *network.NetworkInterfaceState
|
networkState *network.NetworkInterfaceState
|
||||||
)
|
)
|
||||||
|
|
||||||
func setProxyEnvVar(key, value string) {
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
upperKey := strings.ToUpper(key)
|
|
||||||
if value == "" {
|
|
||||||
_ = os.Unsetenv(key)
|
|
||||||
_ = os.Unsetenv(upperKey)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = os.Setenv(key, value)
|
|
||||||
_ = os.Setenv(upperKey, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyProxyEnvironment(networkConfig *network.NetworkConfig) {
|
|
||||||
if networkConfig == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setProxyEnvVar("http_proxy", networkConfig.HTTPProxy.String)
|
|
||||||
setProxyEnvVar("https_proxy", networkConfig.HTTPSProxy.String)
|
|
||||||
setProxyEnvVar("all_proxy", networkConfig.ALLProxy.String)
|
|
||||||
}
|
|
||||||
|
|
||||||
func networkStateChanged() {
|
func networkStateChanged() {
|
||||||
// do not block the main thread
|
// do not block the main thread
|
||||||
go waitCtrlAndRequestDisplayUpdate(true)
|
go waitCtrlAndRequestDisplayUpdate(true)
|
||||||
@@ -56,7 +33,6 @@ func networkStateChanged() {
|
|||||||
|
|
||||||
func initNetwork() error {
|
func initNetwork() error {
|
||||||
ensureConfigLoaded()
|
ensureConfigLoaded()
|
||||||
applyProxyEnvironment(config.NetworkConfig)
|
|
||||||
|
|
||||||
state, err := network.NewNetworkInterfaceState(&network.NetworkInterfaceOptions{
|
state, err := network.NewNetworkInterfaceState(&network.NetworkInterfaceOptions{
|
||||||
DefaultHostname: GetDefaultHostname(),
|
DefaultHostname: GetDefaultHostname(),
|
||||||
@@ -155,7 +131,6 @@ func rpcSetNetworkSettings(settings network.RpcNetworkSettings) (*network.RpcNet
|
|||||||
if err := SaveConfig(); err != nil {
|
if err := SaveConfig(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
applyProxyEnvironment(config.NetworkConfig)
|
|
||||||
|
|
||||||
return &network.RpcNetworkSettings{NetworkConfig: *config.NetworkConfig}, nil
|
return &network.RpcNetworkSettings{NetworkConfig: *config.NetworkConfig}, nil
|
||||||
}
|
}
|
||||||
@@ -167,16 +142,3 @@ func rpcRenewDHCPLease() error {
|
|||||||
func rpcRequestDHCPAddress(ip string) error {
|
func rpcRequestDHCPAddress(ip string) error {
|
||||||
return networkState.RpcRequestDHCPAddress(ip)
|
return networkState.RpcRequestDHCPAddress(ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ethernetMacAddressPath = "/userdata/ethaddr.txt"
|
|
||||||
|
|
||||||
func rpcSetEthernetMacAddress(macAddress string) (interface{}, error) {
|
|
||||||
normalized, err := networkState.SetMACAddress(macAddress)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(ethernetMacAddressPath, []byte(normalized+"\n"), 0644); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to write %s: %w", ethernetMacAddressPath, err)
|
|
||||||
}
|
|
||||||
return networkState.RpcGetNetworkState(), nil
|
|
||||||
}
|
|
||||||
|
|||||||
20
network_mac.go
Normal file
20
network_mac.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package kvm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ethernetMacAddressPath = "/userdata/ethaddr.txt"
|
||||||
|
|
||||||
|
func rpcSetEthernetMacAddress(macAddress string) (interface{}, error) {
|
||||||
|
normalized, err := networkState.SetMACAddress(macAddress)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(ethernetMacAddressPath, []byte(normalized+"\n"), 0644); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write %s: %w", ethernetMacAddressPath, err)
|
||||||
|
}
|
||||||
|
return networkState.RpcGetNetworkState(), nil
|
||||||
|
}
|
||||||
|
|
||||||
328
ota.go
328
ota.go
@@ -4,7 +4,6 @@ import (
|
|||||||
"archive/zip"
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/ed25519"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -43,10 +42,8 @@ type RemoteMetadata struct {
|
|||||||
AppVersion string `json:"appVersion"`
|
AppVersion string `json:"appVersion"`
|
||||||
AppUrl string `json:"appUrl"`
|
AppUrl string `json:"appUrl"`
|
||||||
AppHash string `json:"appHash"`
|
AppHash string `json:"appHash"`
|
||||||
AppSigUrl string `json:"appSigUrl,omitempty"`
|
|
||||||
SystemUrl string `json:"systemUrl"`
|
SystemUrl string `json:"systemUrl"`
|
||||||
SystemHash string `json:"systemHash,omitempty"`
|
SystemHash string `json:"systemHash,omitempty"`
|
||||||
SystemSigUrl string `json:"systemSigUrl,omitempty"`
|
|
||||||
SystemVersion string `json:"systemVersion"`
|
SystemVersion string `json:"systemVersion"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,8 +53,6 @@ type UpdateStatus struct {
|
|||||||
Remote *RemoteMetadata `json:"remote"`
|
Remote *RemoteMetadata `json:"remote"`
|
||||||
SystemUpdateAvailable bool `json:"systemUpdateAvailable"`
|
SystemUpdateAvailable bool `json:"systemUpdateAvailable"`
|
||||||
AppUpdateAvailable bool `json:"appUpdateAvailable"`
|
AppUpdateAvailable bool `json:"appUpdateAvailable"`
|
||||||
AppSignatureMissing bool `json:"appSignatureMissing,omitempty"`
|
|
||||||
SystemSignatureMissing bool `json:"systemSignatureMissing,omitempty"`
|
|
||||||
|
|
||||||
// for backwards compatibility
|
// for backwards compatibility
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
@@ -94,12 +89,10 @@ var UpdateGiteeSystemZipUrls = []string{
|
|||||||
|
|
||||||
const cdnUpdateBaseURL = "https://cdn.picokvm.top/luckfox_picokvm_firmware/lastest/"
|
const cdnUpdateBaseURL = "https://cdn.picokvm.top/luckfox_picokvm_firmware/lastest/"
|
||||||
|
|
||||||
var builtAppVersion = "0.1.3+dev"
|
var builtAppVersion = "0.1.2+dev"
|
||||||
|
|
||||||
var (
|
var updateSource = "github"
|
||||||
updateSource = "github"
|
var customUpdateBaseURL string
|
||||||
customUpdateBaseURL string
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
updateSourceGithub = "github"
|
updateSourceGithub = "github"
|
||||||
@@ -151,12 +144,12 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
|
|||||||
|
|
||||||
_, _ = deviceId, includePreRelease
|
_, _ = deviceId, includePreRelease
|
||||||
|
|
||||||
appVersionRemote, appURL, appSha256, appSigURL, err := fetchKvmAppLatestRelease(ctx)
|
appVersionRemote, appURL, appSha256, err := fetchKvmAppLatestRelease(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
systemVersionRemote, systemZipURL, systemSigURL, err := fetchKvmSystemLatestRelease(ctx)
|
systemVersionRemote, systemZipURL, err := fetchKvmSystemLatestRelease(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -165,14 +158,12 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
|
|||||||
AppUrl: appURL,
|
AppUrl: appURL,
|
||||||
AppVersion: appVersionRemote,
|
AppVersion: appVersionRemote,
|
||||||
AppHash: appSha256,
|
AppHash: appSha256,
|
||||||
AppSigUrl: appSigURL,
|
|
||||||
SystemUrl: systemZipURL,
|
SystemUrl: systemZipURL,
|
||||||
SystemVersion: systemVersionRemote,
|
SystemVersion: systemVersionRemote,
|
||||||
SystemSigUrl: systemSigURL,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL string, sha256 string, sigURL string, err error) {
|
func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL string, sha256 string, err error) {
|
||||||
apiURLs := UpdateGithubAppReleaseUrls
|
apiURLs := UpdateGithubAppReleaseUrls
|
||||||
fallbackToGithub := false
|
fallbackToGithub := false
|
||||||
if updateSource == updateSourceGitee {
|
if updateSource == updateSourceGitee {
|
||||||
@@ -180,7 +171,7 @@ func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL stri
|
|||||||
fallbackToGithub = true
|
fallbackToGithub = true
|
||||||
}
|
}
|
||||||
|
|
||||||
tryFetch := func(urls []string) (string, string, string, string, error) {
|
tryFetch := func(urls []string) (string, string, string, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for _, apiURL := range urls {
|
for _, apiURL := range urls {
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||||
@@ -227,54 +218,44 @@ func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL stri
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var downloadURL, sha256, sigURL string
|
var downloadURL string
|
||||||
for _, asset := range release.Assets {
|
var sha256 string
|
||||||
name := strings.ToLower(strings.TrimSpace(asset.Name))
|
if len(release.Assets) > 0 {
|
||||||
u := strings.TrimSpace(asset.BrowserDownloadURL)
|
downloadURL = release.Assets[0].BrowserDownloadURL
|
||||||
if strings.HasSuffix(name, ".sig") || strings.HasSuffix(name, ".sha256") || strings.HasSuffix(name, ".sha2565") {
|
sha256 = release.Assets[0].Digest
|
||||||
if strings.HasSuffix(name, ".sig") && sigURL == "" {
|
|
||||||
sigURL = u
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if downloadURL == "" {
|
|
||||||
downloadURL = u
|
|
||||||
sha256 = strings.TrimPrefix(strings.TrimSpace(asset.Digest), "sha256:")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
sha256 = strings.TrimPrefix(strings.TrimSpace(sha256), "sha256:")
|
||||||
|
|
||||||
if strings.TrimSpace(downloadURL) == "" {
|
if strings.TrimSpace(downloadURL) == "" {
|
||||||
lastErr = fmt.Errorf("empty app download url from %s", apiURL)
|
lastErr = fmt.Errorf("empty app download url from %s", apiURL)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
return tag, downloadURL, sha256, sigURL, nil
|
return tag, downloadURL, sha256, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if lastErr == nil {
|
if lastErr == nil {
|
||||||
lastErr = fmt.Errorf("no app release API URLs configured")
|
lastErr = fmt.Errorf("no app release API URLs configured")
|
||||||
}
|
}
|
||||||
return "", "", "", "", lastErr
|
return "", "", "", lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
tag, downloadURL, sha256, sigURL, err = tryFetch(apiURLs)
|
tag, downloadURL, sha256, err = tryFetch(apiURLs)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return tag, downloadURL, sha256, sigURL, nil
|
return tag, downloadURL, sha256, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
lastErr = err
|
lastErr = err
|
||||||
if updateSource == updateSourceGitee && fallbackToGithub {
|
if updateSource == updateSourceGitee && fallbackToGithub {
|
||||||
var ghSigURL string
|
tag, downloadURL, sha256, err = tryFetch(UpdateGithubAppReleaseUrls)
|
||||||
tag, downloadURL, sha256, ghSigURL, err = tryFetch(UpdateGithubAppReleaseUrls)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
downloadURL = strings.Replace(downloadURL, "github.com", "gitee.com", 1)
|
downloadURL = strings.Replace(downloadURL, "github.com", "gitee.com", 1)
|
||||||
ghSigURL = strings.Replace(ghSigURL, "github.com", "gitee.com", 1)
|
return tag, downloadURL, sha256, nil
|
||||||
return tag, downloadURL, sha256, ghSigURL, nil
|
|
||||||
}
|
}
|
||||||
lastErr = fmt.Errorf("gitee app release fetch failed (%v); github fallback failed (%w)", lastErr, err)
|
lastErr = fmt.Errorf("gitee app release fetch failed (%v); github fallback failed (%w)", lastErr, err)
|
||||||
}
|
}
|
||||||
return "", "", "", "", lastErr
|
return "", "", "", lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
type releaseAsset struct {
|
type releaseAsset struct {
|
||||||
@@ -300,7 +281,7 @@ func pickZipAssetURL(assets []releaseAsset) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string, sigURL string, err error) {
|
func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string, err error) {
|
||||||
apiURLs := UpdateGithubSystemReleaseUrls
|
apiURLs := UpdateGithubSystemReleaseUrls
|
||||||
fallbackToGithub := false
|
fallbackToGithub := false
|
||||||
if updateSource == updateSourceGitee {
|
if updateSource == updateSourceGitee {
|
||||||
@@ -355,18 +336,11 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var sysSigURL string
|
|
||||||
for _, asset := range release.Assets {
|
|
||||||
name := strings.ToLower(strings.TrimSpace(asset.Name))
|
|
||||||
if strings.HasSuffix(name, ".sig") && sysSigURL == "" {
|
|
||||||
sysSigURL = strings.TrimSpace(asset.BrowserDownloadURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if u := pickZipAssetURL(release.Assets); strings.TrimSpace(u) != "" {
|
if u := pickZipAssetURL(release.Assets); strings.TrimSpace(u) != "" {
|
||||||
return tag, strings.TrimSpace(u), sysSigURL, nil
|
return tag, strings.TrimSpace(u), nil
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(release.ZipballURL) != "" {
|
if strings.TrimSpace(release.ZipballURL) != "" {
|
||||||
return tag, strings.TrimSpace(release.ZipballURL), sysSigURL, nil
|
return tag, strings.TrimSpace(release.ZipballURL), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
lastErr = fmt.Errorf("no usable system archive url in release response from %s", apiURL)
|
lastErr = fmt.Errorf("no usable system archive url in release response from %s", apiURL)
|
||||||
@@ -381,22 +355,22 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
|
|||||||
var githubTag string
|
var githubTag string
|
||||||
var githubZipURL string
|
var githubZipURL string
|
||||||
for i, apiURL := range UpdateGithubSystemReleaseUrls {
|
for i, apiURL := range UpdateGithubSystemReleaseUrls {
|
||||||
githubTag, githubZipURL, _, githubErr = func(apiURL string) (string, string, string, error) {
|
githubTag, githubZipURL, githubErr = func(apiURL string) (string, string, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", "", fmt.Errorf("error creating system release request: %w", err)
|
return "", "", fmt.Errorf("error creating system release request: %w", err)
|
||||||
}
|
}
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", "", fmt.Errorf("error fetching system release: %w", err)
|
return "", "", fmt.Errorf("error fetching system release: %w", err)
|
||||||
}
|
}
|
||||||
body, readErr := io.ReadAll(resp.Body)
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
return "", "", "", fmt.Errorf("error reading system release response: %w", readErr)
|
return "", "", fmt.Errorf("error reading system release response: %w", readErr)
|
||||||
}
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", "", "", fmt.Errorf(
|
return "", "", fmt.Errorf(
|
||||||
"unexpected status code fetching system release from %s: %d, %s",
|
"unexpected status code fetching system release from %s: %d, %s",
|
||||||
apiURL,
|
apiURL,
|
||||||
resp.StatusCode,
|
resp.StatusCode,
|
||||||
@@ -409,26 +383,19 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
|
|||||||
Assets []releaseAsset `json:"assets"`
|
Assets []releaseAsset `json:"assets"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &release); err != nil {
|
if err := json.Unmarshal(body, &release); err != nil {
|
||||||
return "", "", "", fmt.Errorf("error parsing system release JSON from %s: %w", apiURL, err)
|
return "", "", fmt.Errorf("error parsing system release JSON from %s: %w", apiURL, err)
|
||||||
}
|
}
|
||||||
tag := strings.TrimSpace(release.TagName)
|
tag := strings.TrimSpace(release.TagName)
|
||||||
if tag == "" {
|
if tag == "" {
|
||||||
return "", "", "", fmt.Errorf("empty system tag_name from %s", apiURL)
|
return "", "", fmt.Errorf("empty system tag_name from %s", apiURL)
|
||||||
}
|
|
||||||
var sigURL string
|
|
||||||
for _, asset := range release.Assets {
|
|
||||||
name := strings.ToLower(strings.TrimSpace(asset.Name))
|
|
||||||
if strings.HasSuffix(name, ".sig") && sigURL == "" {
|
|
||||||
sigURL = strings.TrimSpace(asset.BrowserDownloadURL)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if u := pickZipAssetURL(release.Assets); strings.TrimSpace(u) != "" {
|
if u := pickZipAssetURL(release.Assets); strings.TrimSpace(u) != "" {
|
||||||
return tag, strings.TrimSpace(u), sigURL, nil
|
return tag, strings.TrimSpace(u), nil
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(release.ZipballURL) != "" {
|
if strings.TrimSpace(release.ZipballURL) != "" {
|
||||||
return tag, strings.TrimSpace(release.ZipballURL), sigURL, nil
|
return tag, strings.TrimSpace(release.ZipballURL), nil
|
||||||
}
|
}
|
||||||
return "", "", "", fmt.Errorf("no usable system archive url in release response from %s", apiURL)
|
return "", "", fmt.Errorf("no usable system archive url in release response from %s", apiURL)
|
||||||
}(apiURL)
|
}(apiURL)
|
||||||
if githubErr == nil && strings.TrimSpace(githubTag) != "" {
|
if githubErr == nil && strings.TrimSpace(githubTag) != "" {
|
||||||
_ = githubZipURL
|
_ = githubZipURL
|
||||||
@@ -447,15 +414,15 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
|
|||||||
zipTag = strings.TrimPrefix(zipTag, "V")
|
zipTag = strings.TrimPrefix(zipTag, "V")
|
||||||
}
|
}
|
||||||
zipURL := strings.TrimRight(selectedZipURL, "/") + "/" + zipTag + ".zip"
|
zipURL := strings.TrimRight(selectedZipURL, "/") + "/" + zipTag + ".zip"
|
||||||
return githubTag, zipURL, "", nil
|
return githubTag, zipURL, nil
|
||||||
}
|
}
|
||||||
githubErr = fmt.Errorf("no gitee system zip urls configured")
|
githubErr = fmt.Errorf("no gitee system zip urls configured")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", "", "", fmt.Errorf("gitee system release fetch failed (%v); github fallback failed (%w)", lastErr, githubErr)
|
return "", "", fmt.Errorf("gitee system release fetch failed (%v); github fallback failed (%w)", lastErr, githubErr)
|
||||||
}
|
}
|
||||||
return "", "", "", lastErr
|
return "", "", lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchUpdateMetadataFromBaseURL(ctx context.Context, baseURL string) (*RemoteMetadata, error) {
|
func fetchUpdateMetadataFromBaseURL(ctx context.Context, baseURL string) (*RemoteMetadata, error) {
|
||||||
@@ -529,21 +496,13 @@ func fetchUpdateMetadataFromBaseURL(ctx context.Context, baseURL string) (*Remot
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
appSigURL, _ := resolveURL(baseURL, "kvm_app.sig")
|
|
||||||
systemSigURL, _ := resolveURL(baseURL, "update_system.zip.sig")
|
|
||||||
if strings.HasSuffix(systemURL, ".tar") {
|
|
||||||
systemSigURL, _ = resolveURL(baseURL, "update_system.tar.sig")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &RemoteMetadata{
|
return &RemoteMetadata{
|
||||||
AppVersion: appVersion,
|
AppVersion: appVersion,
|
||||||
AppUrl: appURL,
|
AppUrl: appURL,
|
||||||
AppHash: appHash,
|
AppHash: appHash,
|
||||||
AppSigUrl: appSigURL,
|
|
||||||
SystemVersion: systemVersion,
|
SystemVersion: systemVersion,
|
||||||
SystemUrl: systemURL,
|
SystemUrl: systemURL,
|
||||||
SystemHash: systemHash,
|
SystemHash: systemHash,
|
||||||
SystemSigUrl: systemSigURL,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -926,7 +885,7 @@ func downloadFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear the filesystem caches to force a read from disk
|
// Clear the filesystem caches to force a read from disk
|
||||||
err = os.WriteFile("/proc/sys/vm/drop_caches", []byte("1"), 0o644)
|
err = os.WriteFile("/proc/sys/vm/drop_caches", []byte("1"), 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
otaLogger.Warn().Err(err).Msg("Failed to clear filesystem caches")
|
otaLogger.Warn().Err(err).Msg("Failed to clear filesystem caches")
|
||||||
}
|
}
|
||||||
@@ -950,8 +909,6 @@ func prepareSystemUpdateTarFromKvmSystemZip(
|
|||||||
downloadProgress *float32,
|
downloadProgress *float32,
|
||||||
downloadSpeedBps *float32,
|
downloadSpeedBps *float32,
|
||||||
verificationProgress *float32,
|
verificationProgress *float32,
|
||||||
sigURL string,
|
|
||||||
expectedHash string,
|
|
||||||
scopedLogger *zerolog.Logger,
|
scopedLogger *zerolog.Logger,
|
||||||
) error {
|
) error {
|
||||||
if scopedLogger == nil {
|
if scopedLogger == nil {
|
||||||
@@ -963,14 +920,14 @@ func prepareSystemUpdateTarFromKvmSystemZip(
|
|||||||
extractDir := filepath.Join(workDir, "extract")
|
extractDir := filepath.Join(workDir, "extract")
|
||||||
zipPath := filepath.Join(workDir, "master.zip")
|
zipPath := filepath.Join(workDir, "master.zip")
|
||||||
|
|
||||||
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
if err := os.MkdirAll(workDir, 0755); err != nil {
|
||||||
return fmt.Errorf("error creating work dir: %w", err)
|
return fmt.Errorf("error creating work dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.RemoveAll(extractDir); err != nil {
|
if err := os.RemoveAll(extractDir); err != nil {
|
||||||
return fmt.Errorf("error cleaning extract dir: %w", err)
|
return fmt.Errorf("error cleaning extract dir: %w", err)
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(extractDir, 0o755); err != nil {
|
if err := os.MkdirAll(extractDir, 0755); err != nil {
|
||||||
return fmt.Errorf("error creating extract dir: %w", err)
|
return fmt.Errorf("error creating extract dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -998,26 +955,19 @@ func prepareSystemUpdateTarFromKvmSystemZip(
|
|||||||
zipUnverifiedPath := zipPath + ".unverified"
|
zipUnverifiedPath := zipPath + ".unverified"
|
||||||
if _, err := os.Stat(zipUnverifiedPath); err != nil {
|
if _, err := os.Stat(zipUnverifiedPath); err != nil {
|
||||||
lastErr = fmt.Errorf("downloaded zip not found: %s: %w", zipUnverifiedPath, err)
|
lastErr = fmt.Errorf("downloaded zip not found: %s: %w", zipUnverifiedPath, err)
|
||||||
} else if sigURL != "" || expectedHash != "" {
|
} else {
|
||||||
if err := verifyFile(ctx, zipPath, expectedHash, sigURL, verificationProgress, scopedLogger); err != nil {
|
if err := unzipArchive(zipUnverifiedPath, extractDir); err != nil {
|
||||||
lastErr = fmt.Errorf("system zip verification failed: %w", err)
|
|
||||||
} else if err := unzipArchive(zipUnverifiedPath, extractDir); err != nil {
|
|
||||||
lastErr = err
|
lastErr = err
|
||||||
} else {
|
} else {
|
||||||
lastErr = nil
|
lastErr = nil
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
} else if err := unzipArchive(zipUnverifiedPath, extractDir); err != nil {
|
|
||||||
lastErr = err
|
|
||||||
} else {
|
|
||||||
lastErr = nil
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = os.Remove(zipPath + ".unverified")
|
_ = os.Remove(zipPath + ".unverified")
|
||||||
_ = os.RemoveAll(extractDir)
|
_ = os.RemoveAll(extractDir)
|
||||||
_ = os.MkdirAll(extractDir, 0o755)
|
_ = os.MkdirAll(extractDir, 0755)
|
||||||
if attempt < maxAttempts {
|
if attempt < maxAttempts {
|
||||||
time.Sleep(time.Duration(attempt*2) * time.Second)
|
time.Sleep(time.Duration(attempt*2) * time.Second)
|
||||||
}
|
}
|
||||||
@@ -1049,7 +999,7 @@ func prepareSystemUpdateTarFromKvmSystemZip(
|
|||||||
if _, err := os.Stat(scriptPath); err != nil {
|
if _, err := os.Stat(scriptPath); err != nil {
|
||||||
return fmt.Errorf("split_and_check_md5.sh not found: %w", err)
|
return fmt.Errorf("split_and_check_md5.sh not found: %w", err)
|
||||||
}
|
}
|
||||||
if err := os.Chmod(scriptPath, 0o755); err != nil {
|
if err := os.Chmod(scriptPath, 0755); err != nil {
|
||||||
return fmt.Errorf("error chmod split_and_check_md5.sh: %w", err)
|
return fmt.Errorf("error chmod split_and_check_md5.sh: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1114,13 +1064,13 @@ func unzipArchive(zipPath string, destDir string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if file.FileInfo().IsDir() {
|
if file.FileInfo().IsDir() {
|
||||||
if err := os.MkdirAll(cleanTargetPath, 0o755); err != nil {
|
if err := os.MkdirAll(cleanTargetPath, 0755); err != nil {
|
||||||
return fmt.Errorf("error creating dir: %w", err)
|
return fmt.Errorf("error creating dir: %w", err)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(cleanTargetPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(cleanTargetPath), 0755); err != nil {
|
||||||
return fmt.Errorf("error creating dir: %w", err)
|
return fmt.Errorf("error creating dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1129,7 +1079,7 @@ func unzipArchive(zipPath string, destDir string) error {
|
|||||||
return fmt.Errorf("error opening zipped file: %w", err)
|
return fmt.Errorf("error opening zipped file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
outFile, err := os.OpenFile(cleanTargetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
outFile, err := os.OpenFile(cleanTargetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rc.Close()
|
rc.Close()
|
||||||
return fmt.Errorf("error creating file: %w", err)
|
return fmt.Errorf("error creating file: %w", err)
|
||||||
@@ -1152,42 +1102,17 @@ func unzipArchive(zipPath string, destDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func verifyFile(ctx context.Context, path string, expectedHash string, sigURL string, verifyProgress *float32, scopedLogger *zerolog.Logger) error {
|
func verifyFile(path string, expectedHash string, verifyProgress *float32, scopedLogger *zerolog.Logger) error {
|
||||||
if scopedLogger == nil {
|
if scopedLogger == nil {
|
||||||
scopedLogger = otaLogger
|
scopedLogger = otaLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
unverifiedPath := path + ".unverified"
|
unverifiedPath := path + ".unverified"
|
||||||
|
|
||||||
if strings.TrimSpace(sigURL) == "" && strings.TrimSpace(expectedHash) == "" {
|
|
||||||
return fmt.Errorf("refusing to flash unverified firmware: no signature URL and no hash provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(sigURL) != "" {
|
|
||||||
sigBasePath := path + ".sig"
|
|
||||||
sigDownloadErr := downloadFile(ctx, sigBasePath, sigURL, nil, nil)
|
|
||||||
sigPath := sigBasePath + ".unverified"
|
|
||||||
if sigDownloadErr != nil {
|
|
||||||
scopedLogger.Warn().Err(sigDownloadErr).Str("sigURL", sigURL).Msg("failed to download signature file, falling back to hash-only verification")
|
|
||||||
} else {
|
|
||||||
sigPresent, sigErr := verifyFileSignature(unverifiedPath, sigPath, scopedLogger)
|
|
||||||
_ = os.Remove(sigPath)
|
|
||||||
if sigPresent && sigErr != nil {
|
|
||||||
return fmt.Errorf("signature verification failed: %w", sigErr)
|
|
||||||
}
|
|
||||||
if sigPresent && sigErr == nil {
|
|
||||||
scopedLogger.Info().Str("path", path).Msg("firmware signature verified, proceeding to hash check")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
scopedLogger.Info().Str("path", path).Msg("no signature URL provided, skipping signature verification")
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(expectedHash) == "" {
|
if strings.TrimSpace(expectedHash) == "" {
|
||||||
if err := os.Rename(unverifiedPath, path); err != nil {
|
if err := os.Rename(unverifiedPath, path); err != nil {
|
||||||
return fmt.Errorf("error renaming file: %w", err)
|
return fmt.Errorf("error renaming file: %w", err)
|
||||||
}
|
}
|
||||||
if err := os.Chmod(path, 0o755); err != nil {
|
if err := os.Chmod(path, 0755); err != nil {
|
||||||
return fmt.Errorf("error making file executable: %w", err)
|
return fmt.Errorf("error making file executable: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -1245,7 +1170,7 @@ func verifyFile(ctx context.Context, path string, expectedHash string, sigURL st
|
|||||||
return fmt.Errorf("error renaming file: %w", err)
|
return fmt.Errorf("error renaming file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.Chmod(path, 0o755); err != nil {
|
if err := os.Chmod(path, 0755); err != nil {
|
||||||
return fmt.Errorf("error making file executable: %w", err)
|
return fmt.Errorf("error making file executable: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1268,10 +1193,6 @@ type OTAState struct {
|
|||||||
AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"`
|
AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"`
|
||||||
SystemVerificationProgress float32 `json:"systemVerificationProgress,omitempty"`
|
SystemVerificationProgress float32 `json:"systemVerificationProgress,omitempty"`
|
||||||
SystemVerifiedAt *time.Time `json:"systemVerifiedAt,omitempty"`
|
SystemVerifiedAt *time.Time `json:"systemVerifiedAt,omitempty"`
|
||||||
AppSignatureVerified bool `json:"appSignatureVerified,omitempty"`
|
|
||||||
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
|
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
|
||||||
@@ -1293,12 +1214,7 @@ func triggerOTAStateUpdate() {
|
|||||||
func cleanupUpdateTempFiles(logger *zerolog.Logger) {
|
func cleanupUpdateTempFiles(logger *zerolog.Logger) {
|
||||||
paths := []string{
|
paths := []string{
|
||||||
"/userdata/picokvm/bin/kvm_app.unverified",
|
"/userdata/picokvm/bin/kvm_app.unverified",
|
||||||
"/userdata/picokvm/bin/kvm_app.sig.unverified",
|
|
||||||
"/userdata/picokvm/update_system.zip.unverified",
|
|
||||||
"/userdata/picokvm/update_system.zip.sig.unverified",
|
|
||||||
"/userdata/picokvm/update_system.zip",
|
|
||||||
"/userdata/picokvm/update_system.tar.unverified",
|
"/userdata/picokvm/update_system.tar.unverified",
|
||||||
"/userdata/picokvm/update_system.tar.sig.unverified",
|
|
||||||
"/userdata/picokvm/update_system.tar",
|
"/userdata/picokvm/update_system.tar",
|
||||||
"/userdata/picokvm/kvm_system_work",
|
"/userdata/picokvm/kvm_system_work",
|
||||||
}
|
}
|
||||||
@@ -1382,10 +1298,8 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||||||
triggerOTAStateUpdate()
|
triggerOTAStateUpdate()
|
||||||
|
|
||||||
err = verifyFile(
|
err = verifyFile(
|
||||||
ctx,
|
|
||||||
"/userdata/picokvm/bin/kvm_app",
|
"/userdata/picokvm/bin/kvm_app",
|
||||||
remote.AppHash,
|
remote.AppHash,
|
||||||
remote.AppSigUrl,
|
|
||||||
&otaState.AppVerificationProgress,
|
&otaState.AppVerificationProgress,
|
||||||
&scopedLogger,
|
&scopedLogger,
|
||||||
)
|
)
|
||||||
@@ -1398,7 +1312,6 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||||||
verifyFinished := time.Now()
|
verifyFinished := time.Now()
|
||||||
otaState.AppVerifiedAt = &verifyFinished
|
otaState.AppVerifiedAt = &verifyFinished
|
||||||
otaState.AppVerificationProgress = 1
|
otaState.AppVerificationProgress = 1
|
||||||
otaState.AppSignatureVerified = strings.TrimSpace(remote.AppSigUrl) != ""
|
|
||||||
otaState.AppUpdatedAt = &verifyFinished
|
otaState.AppUpdatedAt = &verifyFinished
|
||||||
otaState.AppUpdateProgress = 1
|
otaState.AppUpdateProgress = 1
|
||||||
triggerOTAStateUpdate()
|
triggerOTAStateUpdate()
|
||||||
@@ -1424,8 +1337,6 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||||||
&otaState.SystemDownloadProgress,
|
&otaState.SystemDownloadProgress,
|
||||||
&otaState.SystemDownloadSpeedBps,
|
&otaState.SystemDownloadSpeedBps,
|
||||||
&otaState.SystemVerificationProgress,
|
&otaState.SystemVerificationProgress,
|
||||||
remote.SystemSigUrl,
|
|
||||||
remote.SystemHash,
|
|
||||||
&scopedLogger,
|
&scopedLogger,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1450,7 +1361,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = verifyFile(ctx, systemZipPath, remote.SystemHash, remote.SystemSigUrl, &otaState.SystemVerificationProgress, &scopedLogger)
|
err = verifyFile(systemZipPath, remote.SystemHash, &otaState.SystemVerificationProgress, &scopedLogger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
otaState.Error = fmt.Sprintf("Error preparing system update archive: %v", err)
|
otaState.Error = fmt.Sprintf("Error preparing system update archive: %v", err)
|
||||||
scopedLogger.Error().Err(err).Msg("Error preparing system update archive")
|
scopedLogger.Error().Err(err).Msg("Error preparing system update archive")
|
||||||
@@ -1474,7 +1385,6 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||||||
verifyFinished := time.Now()
|
verifyFinished := time.Now()
|
||||||
otaState.SystemVerifiedAt = &verifyFinished
|
otaState.SystemVerifiedAt = &verifyFinished
|
||||||
otaState.SystemVerificationProgress = 1
|
otaState.SystemVerificationProgress = 1
|
||||||
otaState.SystemSignatureVerified = strings.TrimSpace(remote.SystemSigUrl) != ""
|
|
||||||
triggerOTAStateUpdate()
|
triggerOTAStateUpdate()
|
||||||
|
|
||||||
scopedLogger.Info().Msg("Starting rk_ota command")
|
scopedLogger.Info().Msg("Starting rk_ota command")
|
||||||
@@ -1544,7 +1454,13 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rebootNeeded {
|
if rebootNeeded {
|
||||||
cleanupUpdateTempFiles(&scopedLogger)
|
configPath := "/userdata/kvm_config.json"
|
||||||
|
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
scopedLogger.Warn().Err(err).Str("path", configPath).Msg("failed to delete config before reboot")
|
||||||
|
} else {
|
||||||
|
scopedLogger.Info().Str("path", configPath).Msg("deleted config before reboot")
|
||||||
|
}
|
||||||
|
|
||||||
scopedLogger.Info().Msg("System Rebooting in 10s")
|
scopedLogger.Info().Msg("System Rebooting in 10s")
|
||||||
time.Sleep(10 * time.Second)
|
time.Sleep(10 * time.Second)
|
||||||
cmd := exec.Command("reboot")
|
cmd := exec.Command("reboot")
|
||||||
@@ -1605,9 +1521,6 @@ func GetUpdateStatus(ctx context.Context, deviceId string, includePreRelease boo
|
|||||||
updateStatus.AppUpdateAvailable = false
|
updateStatus.AppUpdateAvailable = false
|
||||||
}
|
}
|
||||||
|
|
||||||
updateStatus.AppSignatureMissing = strings.TrimSpace(remoteMetadata.AppSigUrl) == ""
|
|
||||||
updateStatus.SystemSignatureMissing = strings.TrimSpace(remoteMetadata.SystemSigUrl) == ""
|
|
||||||
|
|
||||||
return updateStatus, nil
|
return updateStatus, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1622,130 +1535,3 @@ func confirmCurrentSystem() {
|
|||||||
logger.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup")
|
logger.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getOTAPublicKey() ed25519.PublicKey {
|
|
||||||
keyStr := strings.TrimSpace(builtOtaPublicKey)
|
|
||||||
if keyStr == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
keyBytes, err := hex.DecodeString(keyStr)
|
|
||||||
if err != nil {
|
|
||||||
otaLogger.Warn().Err(err).Msg("invalid OTA public key hex in binary")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if len(keyBytes) != ed25519.PublicKeySize {
|
|
||||||
otaLogger.Warn().Int("size", len(keyBytes)).Msg("OTA public key wrong size, expected 32 bytes")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ed25519.PublicKey(keyBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
func verifyFileSignature(
|
|
||||||
unverifiedPath string,
|
|
||||||
sigPath string,
|
|
||||||
scopedLogger *zerolog.Logger,
|
|
||||||
) (signaturePresent bool, err error) {
|
|
||||||
if scopedLogger == nil {
|
|
||||||
scopedLogger = otaLogger
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(sigPath); os.IsNotExist(err) {
|
|
||||||
scopedLogger.Info().Str("path", sigPath).Msg("signature file not found, skipping signature verification")
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
sigBytes, err := os.ReadFile(sigPath)
|
|
||||||
if err != nil {
|
|
||||||
return true, fmt.Errorf("error reading signature file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(sigBytes) != ed25519.SignatureSize {
|
|
||||||
return true, fmt.Errorf("invalid signature file size: got %d bytes, expected %d", len(sigBytes), ed25519.SignatureSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
publicKey := getOTAPublicKey()
|
|
||||||
if publicKey == nil {
|
|
||||||
return true, fmt.Errorf("signature present but no public key embedded in binary")
|
|
||||||
}
|
|
||||||
|
|
||||||
fileBytes, err := os.ReadFile(unverifiedPath)
|
|
||||||
if err != nil {
|
|
||||||
return true, fmt.Errorf("error reading file for signature verification: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fileHash := sha256.Sum256(fileBytes)
|
|
||||||
if !ed25519.Verify(publicKey, fileHash[:], sigBytes) {
|
|
||||||
return true, fmt.Errorf("Ed25519 signature verification failed for %s", unverifiedPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
scopedLogger.Info().Str("path", unverifiedPath).Msg("Ed25519 signature verification passed")
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isSigFileAbsent(sigPath string) bool {
|
|
||||||
_, err := os.Stat(sigPath)
|
|
||||||
return os.IsNotExist(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func verifyLocalFileSignature(filePath string, sigPath string, publicKey ed25519.PublicKey) bool {
|
|
||||||
sigBytes, err := os.ReadFile(sigPath)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if len(sigBytes) != ed25519.SignatureSize {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
fileBytes, err := os.ReadFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
fileHash := sha256.Sum256(fileBytes)
|
|
||||||
return ed25519.Verify(publicKey, fileHash[:], sigBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
type SignatureUpdateResult struct {
|
|
||||||
AppSignatureUpdated bool `json:"appSignatureUpdated"`
|
|
||||||
SystemSignatureUpdated bool `json:"systemSignatureUpdated"`
|
|
||||||
AppSignatureValid bool `json:"appSignatureValid"`
|
|
||||||
SystemSignatureValid bool `json:"systemSignatureValid"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func UpdateSignatures(ctx context.Context) (*SignatureUpdateResult, error) {
|
|
||||||
result := &SignatureUpdateResult{}
|
|
||||||
|
|
||||||
remoteMetadata, err := fetchUpdateMetadata(ctx, "", false)
|
|
||||||
if err != nil {
|
|
||||||
result.Error = fmt.Sprintf("failed to fetch remote metadata: %v", err)
|
|
||||||
return result, fmt.Errorf("failed to fetch remote metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
publicKey := getOTAPublicKey()
|
|
||||||
|
|
||||||
appBinPath := "/userdata/picokvm/bin/kvm_app"
|
|
||||||
appSigPath := appBinPath + ".sig"
|
|
||||||
|
|
||||||
if strings.TrimSpace(remoteMetadata.AppSigUrl) != "" {
|
|
||||||
err := downloadFile(ctx, appSigPath, remoteMetadata.AppSigUrl, nil, nil)
|
|
||||||
if err != nil {
|
|
||||||
result.Error = fmt.Sprintf("failed to download app signature: %v", err)
|
|
||||||
return result, fmt.Errorf("failed to download app signature: %w", err)
|
|
||||||
}
|
|
||||||
result.AppSignatureUpdated = true
|
|
||||||
|
|
||||||
sigUnverified := appSigPath + ".unverified"
|
|
||||||
if _, statErr := os.Stat(sigUnverified); statErr == nil {
|
|
||||||
_ = os.Remove(appSigPath)
|
|
||||||
if renameErr := os.Rename(sigUnverified, appSigPath); renameErr != nil {
|
|
||||||
result.Error = fmt.Sprintf("failed to rename app signature: %v", renameErr)
|
|
||||||
return result, fmt.Errorf("failed to rename app signature: %w", renameErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if publicKey != nil {
|
|
||||||
result.AppSignatureValid = verifyLocalFileSignature(appBinPath, appSigPath, publicKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,64 +2,30 @@ package kvm
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type VideoFrame struct {
|
|
||||||
data []byte
|
|
||||||
refs atomic.Int32
|
|
||||||
pool *sync.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *VideoFrame) Data() []byte {
|
|
||||||
return f.data
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *VideoFrame) Release() {
|
|
||||||
if f.refs.Add(-1) == 0 {
|
|
||||||
f.data = f.data[:cap(f.data)]
|
|
||||||
f.pool.Put(f.data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var framePool = sync.Pool{
|
|
||||||
New: func() interface{} {
|
|
||||||
return make([]byte, maxFrameSize)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
type VideoBroadcaster struct {
|
type VideoBroadcaster struct {
|
||||||
subscribers map[string]chan *VideoFrame
|
subscribers map[string]chan []byte
|
||||||
subscriberList []chan *VideoFrame // cached flat slice, rebuilt on Subscribe/Unsubscribe
|
|
||||||
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 *VideoFrame),
|
subscribers: make(map[string]chan []byte),
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *VideoBroadcaster) rebuildList() {
|
func (b *VideoBroadcaster) Subscribe() (string, chan []byte) {
|
||||||
list := make([]chan *VideoFrame, 0, len(b.subscribers))
|
|
||||||
for _, ch := range b.subscribers {
|
|
||||||
list = append(list, ch)
|
|
||||||
}
|
|
||||||
b.subscriberList = list
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *VideoBroadcaster) Subscribe() (string, chan *VideoFrame) {
|
|
||||||
b.lock.Lock()
|
b.lock.Lock()
|
||||||
defer b.lock.Unlock()
|
defer b.lock.Unlock()
|
||||||
id := uuid.New().String()
|
id := uuid.New().String()
|
||||||
ch := make(chan *VideoFrame, 200)
|
// Buffer a bit to avoid dropping frames too easily,
|
||||||
|
// but not too much to avoid latency build-up
|
||||||
|
ch := make(chan []byte, 200)
|
||||||
wasEmpty := len(b.subscribers) == 0
|
wasEmpty := len(b.subscribers) == 0
|
||||||
b.subscribers[id] = ch
|
b.subscribers[id] = ch
|
||||||
b.rebuildList()
|
|
||||||
b.count.Store(int32(len(b.subscribers)))
|
|
||||||
if wasEmpty && b.onFirstSubscribe != nil {
|
if wasEmpty && b.onFirstSubscribe != nil {
|
||||||
b.onFirstSubscribe()
|
b.onFirstSubscribe()
|
||||||
}
|
}
|
||||||
@@ -72,8 +38,6 @@ func (b *VideoBroadcaster) Unsubscribe(id string) {
|
|||||||
if ch, ok := b.subscribers[id]; ok {
|
if ch, ok := b.subscribers[id]; ok {
|
||||||
close(ch)
|
close(ch)
|
||||||
delete(b.subscribers, id)
|
delete(b.subscribers, id)
|
||||||
b.rebuildList()
|
|
||||||
b.count.Store(int32(len(b.subscribers)))
|
|
||||||
if len(b.subscribers) == 0 && b.onLastUnsubscribe != nil {
|
if len(b.subscribers) == 0 && b.onLastUnsubscribe != nil {
|
||||||
b.onLastUnsubscribe()
|
b.onLastUnsubscribe()
|
||||||
}
|
}
|
||||||
@@ -81,38 +45,15 @@ func (b *VideoBroadcaster) Unsubscribe(id string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *VideoBroadcaster) Broadcast(data []byte) {
|
func (b *VideoBroadcaster) Broadcast(data []byte) {
|
||||||
// atomic check avoids acquiring RLock on every video frame when no HTTP clients are connected
|
|
||||||
if b.count.Load() == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
b.lock.RLock()
|
b.lock.RLock()
|
||||||
subscribers := b.subscriberList
|
defer b.lock.RUnlock()
|
||||||
subscriberCount := len(subscribers)
|
for _, ch := range b.subscribers {
|
||||||
if subscriberCount == 0 {
|
// Non-blocking send
|
||||||
b.lock.RUnlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
buf := framePool.Get().([]byte)
|
|
||||||
if cap(buf) < len(data) {
|
|
||||||
buf = make([]byte, len(data))
|
|
||||||
}
|
|
||||||
n := copy(buf, data)
|
|
||||||
|
|
||||||
frame := &VideoFrame{
|
|
||||||
data: buf[:n],
|
|
||||||
pool: &framePool,
|
|
||||||
}
|
|
||||||
frame.refs.Store(int32(subscriberCount + 1))
|
|
||||||
|
|
||||||
for _, ch := range subscribers {
|
|
||||||
select {
|
select {
|
||||||
case ch <- frame:
|
case ch <- data:
|
||||||
default:
|
default:
|
||||||
frame.Release()
|
// Drop frame if channel is full to avoid blocking other subscribers
|
||||||
|
// Ideally we should have a ring buffer or similar, but this is simple
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
b.lock.RUnlock()
|
|
||||||
frame.Release()
|
|
||||||
}
|
}
|
||||||
|
|||||||
906
tools.go
906
tools.go
@@ -1,906 +0,0 @@
|
|||||||
package kvm
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/tar"
|
|
||||||
"archive/zip"
|
|
||||||
"compress/gzip"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const vpnToolsRoot = "/userdata/vpn-tools"
|
|
||||||
const vntPinnedVersion = "v1.2.16"
|
|
||||||
|
|
||||||
type vpnToolSpec struct {
|
|
||||||
Name string
|
|
||||||
Repo string
|
|
||||||
Binaries []string
|
|
||||||
VersionBinary string
|
|
||||||
VersionFlags [][]string
|
|
||||||
}
|
|
||||||
|
|
||||||
var vpnToolSpecs = map[string]vpnToolSpec{
|
|
||||||
"frpc": {
|
|
||||||
Name: "frpc",
|
|
||||||
Repo: "fatedier/frp",
|
|
||||||
Binaries: []string{"frpc"},
|
|
||||||
VersionBinary: "frpc",
|
|
||||||
VersionFlags: [][]string{{"-v"}, {"--version"}, {"version"}},
|
|
||||||
},
|
|
||||||
"easytier": {
|
|
||||||
Name: "easytier",
|
|
||||||
Repo: "EasyTier/EasyTier",
|
|
||||||
Binaries: []string{"easytier-core", "easytier-cli"},
|
|
||||||
VersionBinary: "easytier-cli",
|
|
||||||
VersionFlags: [][]string{{"--version"}, {"-V"}, {"version"}},
|
|
||||||
},
|
|
||||||
"vnt": {
|
|
||||||
Name: "vnt",
|
|
||||||
Repo: "vnt-dev/vnt",
|
|
||||||
Binaries: []string{"vnt-cli"},
|
|
||||||
VersionBinary: "vnt-cli",
|
|
||||||
VersionFlags: [][]string{{}, {"--version"}, {"-V"}, {"version"}},
|
|
||||||
},
|
|
||||||
"cloudflared": {
|
|
||||||
Name: "cloudflared",
|
|
||||||
Repo: "cloudflare/cloudflared",
|
|
||||||
Binaries: []string{"cloudflared"},
|
|
||||||
VersionBinary: "cloudflared",
|
|
||||||
VersionFlags: [][]string{{"-v"}, {"version"}, {"--version"}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
type VpnToolSystemInfo struct {
|
|
||||||
GOOS string `json:"goos"`
|
|
||||||
GOARCH string `json:"goarch"`
|
|
||||||
UnameArch string `json:"uname_arch"`
|
|
||||||
ArchLabel string `json:"arch_label"`
|
|
||||||
ArchKeywords []string `json:"arch_keywords"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type VpnToolStatus struct {
|
|
||||||
Tool string `json:"tool"`
|
|
||||||
Installed bool `json:"installed"`
|
|
||||||
Source string `json:"source"`
|
|
||||||
CurrentVersion string `json:"current_version"`
|
|
||||||
DetectedVersion string `json:"detected_version"`
|
|
||||||
ManagedVersions []string `json:"managed_versions"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type VpnToolReleaseAsset struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
ArchMatch bool `json:"arch_match"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type VpnToolRelease struct {
|
|
||||||
TagName string `json:"tag_name"`
|
|
||||||
Assets []VpnToolReleaseAsset `json:"assets"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type githubReleaseAsset struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
BrowserDownloadURL string `json:"browser_download_url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type githubRelease struct {
|
|
||||||
TagName string `json:"tag_name"`
|
|
||||||
Draft bool `json:"draft"`
|
|
||||||
Prerelease bool `json:"prerelease"`
|
|
||||||
Assets []githubReleaseAsset `json:"assets"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type VpnToolInstallTask struct {
|
|
||||||
Tool string `json:"tool"`
|
|
||||||
Running bool `json:"running"`
|
|
||||||
Progress float64 `json:"progress"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Logs []string `json:"logs"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
Version string `json:"version"`
|
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
vpnToolInstallTaskMu sync.Mutex
|
|
||||||
vpnToolInstallTasks = map[string]*VpnToolInstallTask{}
|
|
||||||
)
|
|
||||||
|
|
||||||
func getVpnToolSpec(tool string) (vpnToolSpec, error) {
|
|
||||||
spec, ok := vpnToolSpecs[strings.ToLower(strings.TrimSpace(tool))]
|
|
||||||
if !ok {
|
|
||||||
return vpnToolSpec{}, fmt.Errorf("unsupported vpn tool: %s", tool)
|
|
||||||
}
|
|
||||||
return spec, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeArchLabel(arch string) string {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(arch)) {
|
|
||||||
case "x86_64", "amd64":
|
|
||||||
return "amd64"
|
|
||||||
case "aarch64", "arm64":
|
|
||||||
return "arm64"
|
|
||||||
case "armv7l", "armv7", "armhf":
|
|
||||||
return "armv7"
|
|
||||||
case "armv6l", "armv6":
|
|
||||||
return "armv6"
|
|
||||||
case "i386", "i686", "386", "x86":
|
|
||||||
return "386"
|
|
||||||
default:
|
|
||||||
return strings.ToLower(strings.TrimSpace(arch))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func archKeywords(archLabel string) []string {
|
|
||||||
switch archLabel {
|
|
||||||
case "amd64":
|
|
||||||
return []string{"amd64", "x86_64", "x64"}
|
|
||||||
case "arm64":
|
|
||||||
return []string{"arm64", "aarch64"}
|
|
||||||
case "armv7":
|
|
||||||
return []string{"armv7", "armv7l", "armhf", "arm"}
|
|
||||||
case "armv6":
|
|
||||||
return []string{"armv6", "armv6l", "arm"}
|
|
||||||
case "386":
|
|
||||||
return []string{"386", "i386", "x86"}
|
|
||||||
default:
|
|
||||||
if archLabel == "" {
|
|
||||||
return []string{}
|
|
||||||
}
|
|
||||||
return []string{archLabel}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcGetVpnToolSystemInfo() (VpnToolSystemInfo, error) {
|
|
||||||
unameArch := runtime.GOARCH
|
|
||||||
if out, err := exec.Command("uname", "-m").Output(); err == nil {
|
|
||||||
unameArch = strings.TrimSpace(string(out))
|
|
||||||
}
|
|
||||||
archLabel := normalizeArchLabel(unameArch)
|
|
||||||
if archLabel == "" {
|
|
||||||
archLabel = normalizeArchLabel(runtime.GOARCH)
|
|
||||||
}
|
|
||||||
|
|
||||||
return VpnToolSystemInfo{
|
|
||||||
GOOS: runtime.GOOS,
|
|
||||||
GOARCH: runtime.GOARCH,
|
|
||||||
UnameArch: unameArch,
|
|
||||||
ArchLabel: archLabel,
|
|
||||||
ArchKeywords: archKeywords(archLabel),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func vpnToolDir(spec vpnToolSpec) string {
|
|
||||||
return filepath.Join(vpnToolsRoot, spec.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func vpnToolVersionsDir(spec vpnToolSpec) string {
|
|
||||||
return filepath.Join(vpnToolDir(spec), "versions")
|
|
||||||
}
|
|
||||||
|
|
||||||
func vpnToolCurrentDir(spec vpnToolSpec) string {
|
|
||||||
return filepath.Join(vpnToolDir(spec), "current")
|
|
||||||
}
|
|
||||||
|
|
||||||
func managedBinaryPath(spec vpnToolSpec, binaryName string) string {
|
|
||||||
return filepath.Join(vpnToolCurrentDir(spec), binaryName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func findExecutablePath(binary string) (string, error) {
|
|
||||||
if strings.Contains(binary, "/") {
|
|
||||||
info, err := os.Stat(binary)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if info.Mode().IsRegular() || (info.Mode()&os.ModeSymlink) != 0 {
|
|
||||||
return binary, nil
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("not executable file: %s", binary)
|
|
||||||
}
|
|
||||||
return exec.LookPath(binary)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveVpnToolBinary(tool, defaultBinary string) string {
|
|
||||||
spec, err := getVpnToolSpec(tool)
|
|
||||||
if err != nil {
|
|
||||||
return defaultBinary
|
|
||||||
}
|
|
||||||
managed := managedBinaryPath(spec, defaultBinary)
|
|
||||||
if _, err := os.Stat(managed); err == nil {
|
|
||||||
return managed
|
|
||||||
}
|
|
||||||
return defaultBinary
|
|
||||||
}
|
|
||||||
|
|
||||||
func detectCommandVersion(binaryPath string, flags [][]string) string {
|
|
||||||
for _, args := range flags {
|
|
||||||
cmd := exec.Command(binaryPath, args...)
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
if err != nil && len(out) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
line := extractVersionLine(string(out))
|
|
||||||
if line != "" {
|
|
||||||
return line
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractVersionLine(output string) string {
|
|
||||||
normalized := strings.ReplaceAll(output, "\r\n", "\n")
|
|
||||||
lines := strings.Split(normalized, "\n")
|
|
||||||
|
|
||||||
// Prefer explicit version lines to handle tools like vnt-cli
|
|
||||||
// where the first line is usage text.
|
|
||||||
for _, raw := range lines {
|
|
||||||
line := strings.TrimSpace(raw)
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lower := strings.ToLower(line)
|
|
||||||
if strings.Contains(lower, "version:") {
|
|
||||||
return line
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, raw := range lines {
|
|
||||||
line := strings.TrimSpace(raw)
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lower := strings.ToLower(line)
|
|
||||||
if strings.Contains(lower, "version") || strings.HasPrefix(lower, "v") {
|
|
||||||
return line
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, raw := range lines {
|
|
||||||
line := strings.TrimSpace(raw)
|
|
||||||
if line != "" {
|
|
||||||
return line
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func listManagedVersions(spec vpnToolSpec) []string {
|
|
||||||
versionsDir := vpnToolVersionsDir(spec)
|
|
||||||
entries, err := os.ReadDir(versionsDir)
|
|
||||||
if err != nil {
|
|
||||||
return []string{}
|
|
||||||
}
|
|
||||||
versions := make([]string, 0, len(entries))
|
|
||||||
for _, e := range entries {
|
|
||||||
if e.IsDir() {
|
|
||||||
versions = append(versions, e.Name())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Slice(versions, func(i, j int) bool { return versions[i] > versions[j] })
|
|
||||||
return versions
|
|
||||||
}
|
|
||||||
|
|
||||||
func currentManagedVersion(spec vpnToolSpec) string {
|
|
||||||
currentDir := vpnToolCurrentDir(spec)
|
|
||||||
for _, binary := range spec.Binaries {
|
|
||||||
p := filepath.Join(currentDir, binary)
|
|
||||||
target, err := os.Readlink(p)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
abs := target
|
|
||||||
if !filepath.IsAbs(target) {
|
|
||||||
abs = filepath.Clean(filepath.Join(filepath.Dir(p), target))
|
|
||||||
}
|
|
||||||
versionsRoot := vpnToolVersionsDir(spec) + string(os.PathSeparator)
|
|
||||||
if strings.HasPrefix(abs, versionsRoot) {
|
|
||||||
rel := strings.TrimPrefix(abs, versionsRoot)
|
|
||||||
parts := strings.Split(rel, string(os.PathSeparator))
|
|
||||||
if len(parts) > 0 && parts[0] != "" {
|
|
||||||
return parts[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func initVpnToolInstallTask(tool, version string) {
|
|
||||||
vpnToolInstallTaskMu.Lock()
|
|
||||||
defer vpnToolInstallTaskMu.Unlock()
|
|
||||||
vpnToolInstallTasks[tool] = &VpnToolInstallTask{
|
|
||||||
Tool: tool,
|
|
||||||
Running: true,
|
|
||||||
Progress: 0,
|
|
||||||
Message: "preparing",
|
|
||||||
Logs: []string{"Preparing install task..."},
|
|
||||||
Error: "",
|
|
||||||
Version: version,
|
|
||||||
UpdatedAt: time.Now().Unix(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateVpnToolInstallTask(tool string, progress float64, message string) {
|
|
||||||
vpnToolInstallTaskMu.Lock()
|
|
||||||
defer vpnToolInstallTaskMu.Unlock()
|
|
||||||
task, ok := vpnToolInstallTasks[tool]
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if progress < 0 {
|
|
||||||
progress = 0
|
|
||||||
}
|
|
||||||
if progress > 1 {
|
|
||||||
progress = 1
|
|
||||||
}
|
|
||||||
task.Progress = progress
|
|
||||||
if strings.TrimSpace(message) != "" {
|
|
||||||
task.Message = message
|
|
||||||
}
|
|
||||||
task.UpdatedAt = time.Now().Unix()
|
|
||||||
}
|
|
||||||
|
|
||||||
func appendVpnToolInstallLog(tool, line string) {
|
|
||||||
vpnToolInstallTaskMu.Lock()
|
|
||||||
defer vpnToolInstallTaskMu.Unlock()
|
|
||||||
task, ok := vpnToolInstallTasks[tool]
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
task.Logs = append(task.Logs, line)
|
|
||||||
if len(task.Logs) > 200 {
|
|
||||||
task.Logs = task.Logs[len(task.Logs)-200:]
|
|
||||||
}
|
|
||||||
task.UpdatedAt = time.Now().Unix()
|
|
||||||
}
|
|
||||||
|
|
||||||
func finishVpnToolInstallTask(tool string, err error) {
|
|
||||||
vpnToolInstallTaskMu.Lock()
|
|
||||||
defer vpnToolInstallTaskMu.Unlock()
|
|
||||||
task, ok := vpnToolInstallTasks[tool]
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
task.Running = false
|
|
||||||
task.UpdatedAt = time.Now().Unix()
|
|
||||||
if err != nil {
|
|
||||||
task.Error = err.Error()
|
|
||||||
task.Message = "failed"
|
|
||||||
task.Logs = append(task.Logs, "Install failed: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
task.Progress = 1
|
|
||||||
task.Message = "completed"
|
|
||||||
task.Error = ""
|
|
||||||
task.Logs = append(task.Logs, "Install completed.")
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcGetVpnToolInstallTask(tool string) (VpnToolInstallTask, error) {
|
|
||||||
spec, err := getVpnToolSpec(tool)
|
|
||||||
if err != nil {
|
|
||||||
return VpnToolInstallTask{}, err
|
|
||||||
}
|
|
||||||
_ = spec
|
|
||||||
vpnToolInstallTaskMu.Lock()
|
|
||||||
defer vpnToolInstallTaskMu.Unlock()
|
|
||||||
task, ok := vpnToolInstallTasks[tool]
|
|
||||||
if !ok {
|
|
||||||
return VpnToolInstallTask{
|
|
||||||
Tool: tool,
|
|
||||||
Running: false,
|
|
||||||
Progress: 0,
|
|
||||||
Message: "",
|
|
||||||
Logs: []string{},
|
|
||||||
Error: "",
|
|
||||||
Version: "",
|
|
||||||
UpdatedAt: time.Now().Unix(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
cp := *task
|
|
||||||
cp.Logs = append([]string{}, task.Logs...)
|
|
||||||
return cp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcGetVpnToolStatus(tool string) (VpnToolStatus, error) {
|
|
||||||
spec, err := getVpnToolSpec(tool)
|
|
||||||
if err != nil {
|
|
||||||
return VpnToolStatus{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
status := VpnToolStatus{
|
|
||||||
Tool: spec.Name,
|
|
||||||
Installed: false,
|
|
||||||
Source: "none",
|
|
||||||
CurrentVersion: currentManagedVersion(spec),
|
|
||||||
DetectedVersion: "",
|
|
||||||
ManagedVersions: listManagedVersions(spec),
|
|
||||||
}
|
|
||||||
|
|
||||||
primaryBinary := spec.Binaries[0]
|
|
||||||
versionBinary := spec.VersionBinary
|
|
||||||
if strings.TrimSpace(versionBinary) == "" {
|
|
||||||
versionBinary = primaryBinary
|
|
||||||
}
|
|
||||||
|
|
||||||
managedPrimary := managedBinaryPath(spec, primaryBinary)
|
|
||||||
managedVersionBinary := managedBinaryPath(spec, versionBinary)
|
|
||||||
if _, err := os.Stat(managedPrimary); err == nil {
|
|
||||||
status.Installed = true
|
|
||||||
status.Source = "managed"
|
|
||||||
status.DetectedVersion = detectCommandVersion(managedVersionBinary, spec.VersionFlags)
|
|
||||||
if status.CurrentVersion == "" {
|
|
||||||
status.CurrentVersion = "managed"
|
|
||||||
}
|
|
||||||
return status, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
primaryLookedUp, primaryErr := findExecutablePath(primaryBinary)
|
|
||||||
if primaryErr == nil {
|
|
||||||
status.Installed = true
|
|
||||||
status.Source = "system"
|
|
||||||
_ = primaryLookedUp
|
|
||||||
versionLookedUp, versionErr := findExecutablePath(versionBinary)
|
|
||||||
if versionErr == nil {
|
|
||||||
status.DetectedVersion = detectCommandVersion(versionLookedUp, spec.VersionFlags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return status, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcListVpnToolReleases(tool string) ([]VpnToolRelease, error) {
|
|
||||||
spec, err := getVpnToolSpec(tool)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
systemInfo, err := rpcGetVpnToolSystemInfo()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=20", spec.Repo)
|
|
||||||
req, err := http.NewRequestWithContext(
|
|
||||||
context.Background(),
|
|
||||||
http.MethodGet,
|
|
||||||
apiURL,
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to build release request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Accept", "application/vnd.github+json")
|
|
||||||
req.Header.Set("User-Agent", "kvm-vpn-tool-manager")
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 20 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to fetch releases: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
||||||
return nil, fmt.Errorf("fetch releases failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
||||||
}
|
|
||||||
|
|
||||||
var ghReleases []githubRelease
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&ghReleases); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse release json: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
keywords := systemInfo.ArchKeywords
|
|
||||||
results := make([]VpnToolRelease, 0, len(ghReleases))
|
|
||||||
for _, rel := range ghReleases {
|
|
||||||
if rel.Draft {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if spec.Name == "vnt" {
|
|
||||||
tag := strings.TrimSpace(rel.TagName)
|
|
||||||
if tag != vntPinnedVersion && tag != strings.TrimPrefix(vntPinnedVersion, "v") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
releaseItem := VpnToolRelease{
|
|
||||||
TagName: strings.TrimSpace(rel.TagName),
|
|
||||||
Assets: make([]VpnToolReleaseAsset, 0, len(rel.Assets)),
|
|
||||||
}
|
|
||||||
for _, asset := range rel.Assets {
|
|
||||||
nameLower := strings.ToLower(strings.TrimSpace(asset.Name))
|
|
||||||
isLinux := strings.Contains(nameLower, "linux")
|
|
||||||
archMatch := false
|
|
||||||
for _, kw := range keywords {
|
|
||||||
if strings.Contains(nameLower, strings.ToLower(kw)) {
|
|
||||||
archMatch = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !isLinux {
|
|
||||||
archMatch = false
|
|
||||||
}
|
|
||||||
releaseItem.Assets = append(releaseItem.Assets, VpnToolReleaseAsset{
|
|
||||||
Name: asset.Name,
|
|
||||||
URL: strings.TrimSpace(asset.BrowserDownloadURL),
|
|
||||||
ArchMatch: archMatch,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if releaseItem.TagName != "" {
|
|
||||||
results = append(results, releaseItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadFileToPath(url, targetPath string, onProgress func(downloaded int64, total int64)) error {
|
|
||||||
if strings.TrimSpace(url) == "" {
|
|
||||||
return fmt.Errorf("empty download url")
|
|
||||||
}
|
|
||||||
req, err := http.NewRequestWithContext(
|
|
||||||
context.Background(),
|
|
||||||
http.MethodGet,
|
|
||||||
url,
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create download request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", "kvm-vpn-tool-manager")
|
|
||||||
client := &http.Client{Timeout: 2 * time.Minute}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to download file: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
||||||
return fmt.Errorf("download failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := os.Create(targetPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create target file: %w", err)
|
|
||||||
}
|
|
||||||
defer out.Close()
|
|
||||||
total := resp.ContentLength
|
|
||||||
var downloaded int64
|
|
||||||
buf := make([]byte, 64*1024)
|
|
||||||
for {
|
|
||||||
n, readErr := resp.Body.Read(buf)
|
|
||||||
if n > 0 {
|
|
||||||
written, writeErr := out.Write(buf[:n])
|
|
||||||
if writeErr != nil {
|
|
||||||
return fmt.Errorf("failed to write target file: %w", writeErr)
|
|
||||||
}
|
|
||||||
if written != n {
|
|
||||||
return fmt.Errorf("short write while saving downloaded file")
|
|
||||||
}
|
|
||||||
downloaded += int64(n)
|
|
||||||
if onProgress != nil {
|
|
||||||
onProgress(downloaded, total)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if readErr == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if readErr != nil {
|
|
||||||
return fmt.Errorf("failed to read download stream: %w", readErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractFromTarGz(archivePath string, targetBinaryNames []string, outDir string) (map[string]bool, error) {
|
|
||||||
f, err := os.Open(archivePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
gzReader, err := gzip.NewReader(f)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer gzReader.Close()
|
|
||||||
tr := tar.NewReader(gzReader)
|
|
||||||
|
|
||||||
need := make(map[string]bool, len(targetBinaryNames))
|
|
||||||
found := make(map[string]bool, len(targetBinaryNames))
|
|
||||||
for _, b := range targetBinaryNames {
|
|
||||||
need[b] = true
|
|
||||||
found[b] = false
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
hdr, err := tr.Next()
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return found, err
|
|
||||||
}
|
|
||||||
if hdr.Typeflag != tar.TypeReg {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
base := filepath.Base(hdr.Name)
|
|
||||||
if !need[base] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
dstPath := filepath.Join(outDir, base)
|
|
||||||
dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
|
||||||
if err != nil {
|
|
||||||
return found, err
|
|
||||||
}
|
|
||||||
if _, err := io.Copy(dst, tr); err != nil {
|
|
||||||
dst.Close()
|
|
||||||
return found, err
|
|
||||||
}
|
|
||||||
if err := dst.Close(); err != nil {
|
|
||||||
return found, err
|
|
||||||
}
|
|
||||||
found[base] = true
|
|
||||||
}
|
|
||||||
return found, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractFromZip(archivePath string, targetBinaryNames []string, outDir string) (map[string]bool, error) {
|
|
||||||
zr, err := zip.OpenReader(archivePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer zr.Close()
|
|
||||||
|
|
||||||
need := make(map[string]bool, len(targetBinaryNames))
|
|
||||||
found := make(map[string]bool, len(targetBinaryNames))
|
|
||||||
for _, b := range targetBinaryNames {
|
|
||||||
need[b] = true
|
|
||||||
found[b] = false
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, f := range zr.File {
|
|
||||||
base := filepath.Base(f.Name)
|
|
||||||
if !need[base] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
src, err := f.Open()
|
|
||||||
if err != nil {
|
|
||||||
return found, err
|
|
||||||
}
|
|
||||||
dstPath := filepath.Join(outDir, base)
|
|
||||||
dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
|
||||||
if err != nil {
|
|
||||||
src.Close()
|
|
||||||
return found, err
|
|
||||||
}
|
|
||||||
if _, err := io.Copy(dst, src); err != nil {
|
|
||||||
src.Close()
|
|
||||||
dst.Close()
|
|
||||||
return found, err
|
|
||||||
}
|
|
||||||
src.Close()
|
|
||||||
if err := dst.Close(); err != nil {
|
|
||||||
return found, err
|
|
||||||
}
|
|
||||||
found[base] = true
|
|
||||||
}
|
|
||||||
return found, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureBinariesInstalled(found map[string]bool, required []string) error {
|
|
||||||
missing := make([]string, 0)
|
|
||||||
for _, b := range required {
|
|
||||||
if !found[b] {
|
|
||||||
missing = append(missing, b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(missing) > 0 {
|
|
||||||
return fmt.Errorf("required binaries are missing in package: %s", strings.Join(missing, ", "))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupCurrentSymlinks(spec vpnToolSpec, version string) error {
|
|
||||||
currentDir := vpnToolCurrentDir(spec)
|
|
||||||
versionDir := filepath.Join(vpnToolVersionsDir(spec), version)
|
|
||||||
if err := os.MkdirAll(currentDir, 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, b := range spec.Binaries {
|
|
||||||
currentLink := filepath.Join(currentDir, b)
|
|
||||||
_ = os.Remove(currentLink)
|
|
||||||
target := filepath.Join(versionDir, b)
|
|
||||||
if err := os.Symlink(target, currentLink); err != nil {
|
|
||||||
return fmt.Errorf("failed to update current binary symlink for %s: %w", b, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcInstallVpnTool(tool, version, assetName, downloadURL string) error {
|
|
||||||
spec, err := getVpnToolSpec(tool)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
version = strings.TrimSpace(version)
|
|
||||||
if version == "" {
|
|
||||||
return fmt.Errorf("version is required")
|
|
||||||
}
|
|
||||||
if spec.Name == "vnt" && version != vntPinnedVersion && version != strings.TrimPrefix(vntPinnedVersion, "v") {
|
|
||||||
return fmt.Errorf("vnt install is temporarily pinned to %s", vntPinnedVersion)
|
|
||||||
}
|
|
||||||
downloadURL = strings.TrimSpace(downloadURL)
|
|
||||||
if downloadURL == "" {
|
|
||||||
return fmt.Errorf("downloadURL is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
versionDir := filepath.Join(vpnToolVersionsDir(spec), version)
|
|
||||||
appendVpnToolInstallLog(tool, "Preparing version directory...")
|
|
||||||
updateVpnToolInstallTask(tool, 0.05, "preparing")
|
|
||||||
if err := os.MkdirAll(versionDir, 0755); err != nil {
|
|
||||||
return fmt.Errorf("failed to create version directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpFile, err := os.CreateTemp("", "vpn-tool-*")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create temp file: %w", err)
|
|
||||||
}
|
|
||||||
tmpPath := tmpFile.Name()
|
|
||||||
tmpFile.Close()
|
|
||||||
defer os.Remove(tmpPath)
|
|
||||||
|
|
||||||
appendVpnToolInstallLog(tool, "Downloading release package...")
|
|
||||||
if err := downloadFileToPath(downloadURL, tmpPath, func(downloaded int64, total int64) {
|
|
||||||
if total > 0 {
|
|
||||||
ratio := float64(downloaded) / float64(total)
|
|
||||||
updateVpnToolInstallTask(tool, 0.1+ratio*0.65, "downloading")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Unknown total size, keep showing progress moving slowly.
|
|
||||||
updateVpnToolInstallTask(tool, 0.4, "downloading")
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
appendVpnToolInstallLog(tool, "Download completed.")
|
|
||||||
|
|
||||||
assetLower := strings.ToLower(strings.TrimSpace(assetName))
|
|
||||||
if assetLower == "" {
|
|
||||||
assetLower = strings.ToLower(strings.TrimSpace(downloadURL))
|
|
||||||
}
|
|
||||||
|
|
||||||
found := map[string]bool{}
|
|
||||||
updateVpnToolInstallTask(tool, 0.8, "extracting")
|
|
||||||
appendVpnToolInstallLog(tool, "Extracting package...")
|
|
||||||
switch {
|
|
||||||
case strings.HasSuffix(assetLower, ".tar.gz") || strings.HasSuffix(assetLower, ".tgz"):
|
|
||||||
found, err = extractFromTarGz(tmpPath, spec.Binaries, versionDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to extract tar.gz: %w", err)
|
|
||||||
}
|
|
||||||
case strings.HasSuffix(assetLower, ".zip"):
|
|
||||||
found, err = extractFromZip(tmpPath, spec.Binaries, versionDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to extract zip: %w", err)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
if len(spec.Binaries) != 1 {
|
|
||||||
return fmt.Errorf("single binary install is not supported for %s", spec.Name)
|
|
||||||
}
|
|
||||||
dstPath := filepath.Join(versionDir, spec.Binaries[0])
|
|
||||||
input, err := os.Open(tmpPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer input.Close()
|
|
||||||
output, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := io.Copy(output, input); err != nil {
|
|
||||||
output.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := output.Close(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
found[spec.Binaries[0]] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
appendVpnToolInstallLog(tool, "Validating installed binaries...")
|
|
||||||
updateVpnToolInstallTask(tool, 0.92, "validating")
|
|
||||||
if err := ensureBinariesInstalled(found, spec.Binaries); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
appendVpnToolInstallLog(tool, "Switching to installed version...")
|
|
||||||
updateVpnToolInstallTask(tool, 0.97, "activating")
|
|
||||||
if err := setupCurrentSymlinks(spec, version); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
updateVpnToolInstallTask(tool, 1, "completed")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcStartVpnToolInstall(tool, version, assetName, downloadURL string) error {
|
|
||||||
_, err := getVpnToolSpec(tool)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
vpnToolInstallTaskMu.Lock()
|
|
||||||
if task, ok := vpnToolInstallTasks[tool]; ok && task.Running {
|
|
||||||
vpnToolInstallTaskMu.Unlock()
|
|
||||||
return fmt.Errorf("install task is already running for %s", tool)
|
|
||||||
}
|
|
||||||
vpnToolInstallTaskMu.Unlock()
|
|
||||||
|
|
||||||
initVpnToolInstallTask(tool, version)
|
|
||||||
go func() {
|
|
||||||
err := rpcInstallVpnTool(tool, version, assetName, downloadURL)
|
|
||||||
finishVpnToolInstallTask(tool, err)
|
|
||||||
}()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcUseVpnToolVersion(tool, version string) error {
|
|
||||||
spec, err := getVpnToolSpec(tool)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
version = strings.TrimSpace(version)
|
|
||||||
if version == "" {
|
|
||||||
return fmt.Errorf("version is required")
|
|
||||||
}
|
|
||||||
versionDir := filepath.Join(vpnToolVersionsDir(spec), version)
|
|
||||||
info, err := os.Stat(versionDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("version is not installed: %w", err)
|
|
||||||
}
|
|
||||||
if !info.IsDir() {
|
|
||||||
return fmt.Errorf("invalid version directory")
|
|
||||||
}
|
|
||||||
for _, b := range spec.Binaries {
|
|
||||||
if _, err := os.Stat(filepath.Join(versionDir, b)); err != nil {
|
|
||||||
return fmt.Errorf("binary %s is missing in version %s", b, version)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return setupCurrentSymlinks(spec, version)
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcUninstallVpnToolVersion(tool, version string) error {
|
|
||||||
spec, err := getVpnToolSpec(tool)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
version = strings.TrimSpace(version)
|
|
||||||
if version == "" {
|
|
||||||
return fmt.Errorf("version is required")
|
|
||||||
}
|
|
||||||
versionDir := filepath.Join(vpnToolVersionsDir(spec), version)
|
|
||||||
if err := os.RemoveAll(versionDir); err != nil {
|
|
||||||
return fmt.Errorf("failed to uninstall version: %w", err)
|
|
||||||
}
|
|
||||||
if currentManagedVersion(spec) == version {
|
|
||||||
currentDir := vpnToolCurrentDir(spec)
|
|
||||||
for _, b := range spec.Binaries {
|
|
||||||
_ = os.Remove(filepath.Join(currentDir, b))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
remaining := listManagedVersions(spec)
|
|
||||||
if len(remaining) > 0 {
|
|
||||||
// Automatically switch to latest remaining managed version.
|
|
||||||
if err := setupCurrentSymlinks(spec, remaining[0]); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
390
ui/package-lock.json
generated
390
ui/package-lock.json
generated
@@ -43,7 +43,6 @@
|
|||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
"styled-components": "^6.1.19",
|
"styled-components": "^6.1.19",
|
||||||
"tailwind-merge": "^3.3.0",
|
"tailwind-merge": "^3.3.0",
|
||||||
"tesseract.js": "^7.0.0",
|
|
||||||
"usehooks-ts": "^3.1.1",
|
"usehooks-ts": "^3.1.1",
|
||||||
"validator": "^13.15.0",
|
"validator": "^13.15.0",
|
||||||
"w-touch": "^2.0.0",
|
"w-touch": "^2.0.0",
|
||||||
@@ -509,6 +508,12 @@
|
|||||||
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
|
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@emotion/babel-plugin/node_modules/@emotion/memoize": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@emotion/memoize/-/memoize-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@emotion/babel-plugin/node_modules/convert-source-map": {
|
"node_modules/@emotion/babel-plugin/node_modules/convert-source-map": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-1.9.0.tgz",
|
"resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-1.9.0.tgz",
|
||||||
@@ -534,6 +539,12 @@
|
|||||||
"stylis": "4.2.0"
|
"stylis": "4.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emotion/cache/node_modules/@emotion/memoize": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@emotion/memoize/-/memoize-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@emotion/cache/node_modules/stylis": {
|
"node_modules/@emotion/cache/node_modules/stylis": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.2.0.tgz",
|
||||||
@@ -560,18 +571,18 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@emotion/is-prop-valid": {
|
"node_modules/@emotion/is-prop-valid": {
|
||||||
"version": "1.4.0",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz",
|
||||||
"integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==",
|
"integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/memoize": "^0.9.0"
|
"@emotion/memoize": "^0.8.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emotion/memoize": {
|
"node_modules/@emotion/memoize": {
|
||||||
"version": "0.9.0",
|
"version": "0.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@emotion/memoize/-/memoize-0.8.1.tgz",
|
||||||
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
|
"integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@emotion/react": {
|
"node_modules/@emotion/react": {
|
||||||
@@ -617,6 +628,12 @@
|
|||||||
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
|
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@emotion/serialize/node_modules/@emotion/memoize": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@emotion/memoize/-/memoize-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@emotion/serialize/node_modules/@emotion/unitless": {
|
"node_modules/@emotion/serialize/node_modules/@emotion/unitless": {
|
||||||
"version": "0.10.0",
|
"version": "0.10.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.10.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.10.0.tgz",
|
||||||
@@ -1760,9 +1777,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/pluginutils/node_modules/picomatch": {
|
"node_modules/@rollup/pluginutils/node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2804,66 +2821,6 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
|
||||||
"version": "1.4.3",
|
|
||||||
"dev": true,
|
|
||||||
"inBundle": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/wasi-threads": "1.0.2",
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
|
||||||
"version": "1.4.3",
|
|
||||||
"dev": true,
|
|
||||||
"inBundle": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"dev": true,
|
|
||||||
"inBundle": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
|
||||||
"version": "0.2.9",
|
|
||||||
"dev": true,
|
|
||||||
"inBundle": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/core": "^1.4.0",
|
|
||||||
"@emnapi/runtime": "^1.4.0",
|
|
||||||
"@tybys/wasm-util": "^0.9.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
|
||||||
"version": "0.9.0",
|
|
||||||
"dev": true,
|
|
||||||
"inBundle": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
|
||||||
"version": "2.8.0",
|
|
||||||
"dev": true,
|
|
||||||
"inBundle": true,
|
|
||||||
"license": "0BSD",
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.1.7",
|
"version": "4.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.7.tgz",
|
||||||
@@ -3127,6 +3084,12 @@
|
|||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/stylis": {
|
||||||
|
"version": "4.2.5",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@types/stylis/-/stylis-4.2.5.tgz",
|
||||||
|
"integrity": "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/validator": {
|
"node_modules/@types/validator": {
|
||||||
"version": "13.15.0",
|
"version": "13.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.0.tgz",
|
||||||
@@ -3299,9 +3262,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
"version": "5.0.6",
|
"version": "5.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3870,9 +3833,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/babel-plugin-macros/node_modules/yaml": {
|
"node_modules/babel-plugin-macros/node_modules/yaml": {
|
||||||
"version": "1.10.3",
|
"version": "1.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
|
"resolved": "https://registry.npmmirror.com/yaml/-/yaml-1.10.2.tgz",
|
||||||
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
|
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
@@ -3884,16 +3847,10 @@
|
|||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/bmp-js": {
|
|
||||||
"version": "0.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
|
|
||||||
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.14",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0",
|
"balanced-match": "^1.0.0",
|
||||||
@@ -4236,9 +4193,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/cva": {
|
"node_modules/cva": {
|
||||||
@@ -5379,9 +5336,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.4.2",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
||||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/focus-trap": {
|
"node_modules/focus-trap": {
|
||||||
@@ -5820,12 +5777,6 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/idb-keyval": {
|
|
||||||
"version": "6.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
|
|
||||||
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -5848,9 +5799,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/immutable": {
|
"node_modules/immutable": {
|
||||||
"version": "3.8.3",
|
"version": "3.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.3.tgz",
|
"resolved": "https://registry.npmmirror.com/immutable/-/immutable-3.8.2.tgz",
|
||||||
"integrity": "sha512-AUY/VyX0E5XlibOmWt10uabJzam1zlYjwiEgQSDc5+UIkFNaF9WM0JxXKaNMGf+F/ffUF+7kRKXM9A7C0xXqMg==",
|
"integrity": "sha512-15gZoQ38eYjEjxkorfbcgBKBL6R7T459OuK+CpcWt7O3KF4uPCx2tD0uFETlUDIyo+1789crbMhTvQBSR5yBMg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -6248,12 +6199,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-url": {
|
|
||||||
"version": "1.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
|
||||||
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/is-weakmap": {
|
"node_modules/is-weakmap": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
||||||
@@ -6719,9 +6664,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.18.1",
|
"version": "4.17.23",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.castarray": {
|
"node_modules/lodash.castarray": {
|
||||||
@@ -7018,26 +6963,6 @@
|
|||||||
"tslib": "^2.0.3"
|
"tslib": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-fetch": {
|
|
||||||
"version": "2.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
|
||||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"whatwg-url": "^5.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "4.x || >=6.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"encoding": "^0.1.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"encoding": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.19",
|
"version": "2.0.19",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
||||||
@@ -7187,15 +7112,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/opencollective-postinstall": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"opencollective-postinstall": "index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -7396,9 +7312,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -7418,9 +7334,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.14",
|
"version": "8.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
|
||||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -7437,7 +7353,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.8",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -8514,12 +8430,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/regenerator-runtime": {
|
|
||||||
"version": "0.13.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
|
||||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/regexp.prototype.flags": {
|
"node_modules/regexp.prototype.flags": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||||
@@ -8797,6 +8707,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/shallowequal": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/shallowequal/-/shallowequal-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -9128,15 +9044,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/styled-components": {
|
"node_modules/styled-components": {
|
||||||
"version": "6.4.1",
|
"version": "6.1.19",
|
||||||
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.4.1.tgz",
|
"resolved": "https://registry.npmmirror.com/styled-components/-/styled-components-6.1.19.tgz",
|
||||||
"integrity": "sha512-ADu2dF53esUzzM4I0ewxhxFtsDd6v4V6dNkg3vG0iFKhnt06sJneTZnRvujAosZwW0XD58IKgGMQoqri4wHRqg==",
|
"integrity": "sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/is-prop-valid": "1.4.0",
|
"@emotion/is-prop-valid": "1.2.2",
|
||||||
|
"@emotion/unitless": "0.8.1",
|
||||||
|
"@types/stylis": "4.2.5",
|
||||||
"css-to-react-native": "3.2.0",
|
"css-to-react-native": "3.2.0",
|
||||||
"csstype": "3.2.3",
|
"csstype": "3.1.3",
|
||||||
"stylis": "4.3.6"
|
"postcss": "8.4.49",
|
||||||
|
"shallowequal": "1.1.0",
|
||||||
|
"stylis": "4.3.2",
|
||||||
|
"tslib": "2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 16"
|
||||||
@@ -9146,22 +9067,55 @@
|
|||||||
"url": "https://opencollective.com/styled-components"
|
"url": "https://opencollective.com/styled-components"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"css-to-react-native": ">= 3.2.0",
|
|
||||||
"react": ">= 16.8.0",
|
"react": ">= 16.8.0",
|
||||||
"react-dom": ">= 16.8.0",
|
"react-dom": ">= 16.8.0"
|
||||||
"react-native": ">= 0.68.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"css-to-react-native": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react-dom": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react-native": {
|
|
||||||
"optional": true
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/styled-components/node_modules/@emotion/unitless": {
|
||||||
|
"version": "0.8.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.8.1.tgz",
|
||||||
|
"integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/styled-components/node_modules/postcss": {
|
||||||
|
"version": "8.4.49",
|
||||||
|
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.49.tgz",
|
||||||
|
"integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/postcss/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"nanoid": "^3.3.7",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || >=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/styled-components/node_modules/stylis": {
|
||||||
|
"version": "4.3.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.3.2.tgz",
|
||||||
|
"integrity": "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/styled-components/node_modules/tslib": {
|
||||||
|
"version": "2.6.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.6.2.tgz",
|
||||||
|
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
|
||||||
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/stylis": {
|
"node_modules/stylis": {
|
||||||
"version": "4.3.6",
|
"version": "4.3.6",
|
||||||
@@ -9233,9 +9187,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tar": {
|
"node_modules/tar": {
|
||||||
"version": "7.5.15",
|
"version": "7.5.9",
|
||||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz",
|
||||||
"integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==",
|
"integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -9249,30 +9203,6 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tesseract.js": {
|
|
||||||
"version": "7.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
|
|
||||||
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"bmp-js": "^0.1.0",
|
|
||||||
"idb-keyval": "^6.2.0",
|
|
||||||
"is-url": "^1.2.4",
|
|
||||||
"node-fetch": "^2.6.9",
|
|
||||||
"opencollective-postinstall": "^2.0.3",
|
|
||||||
"regenerator-runtime": "^0.13.3",
|
|
||||||
"tesseract.js-core": "^7.0.0",
|
|
||||||
"wasm-feature-detect": "^1.8.0",
|
|
||||||
"zlibjs": "^0.3.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tesseract.js-core": {
|
|
||||||
"version": "7.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
|
|
||||||
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/text-encoding-utf-8": {
|
"node_modules/text-encoding-utf-8": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz",
|
||||||
@@ -9324,9 +9254,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -9354,12 +9284,6 @@
|
|||||||
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
|
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tr46": {
|
|
||||||
"version": "0.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
|
||||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
||||||
@@ -9675,9 +9599,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.2",
|
"version": "6.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
@@ -9798,9 +9722,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/picomatch": {
|
"node_modules/vite/node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -9815,34 +9739,12 @@
|
|||||||
"integrity": "sha512-PYnngF+KHzZRBFI6qsm5PHwVNO5Lx3dYDrvNv//Ei9fvnYlaOWr9sKAriD/crlshWee9ZPsyvDMA4UnoR1LUHw==",
|
"integrity": "sha512-PYnngF+KHzZRBFI6qsm5PHwVNO5Lx3dYDrvNv//Ei9fvnYlaOWr9sKAriD/crlshWee9ZPsyvDMA4UnoR1LUHw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/wasm-feature-detect": {
|
|
||||||
"version": "1.8.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
|
|
||||||
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/webidl-conversions": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
|
||||||
"license": "BSD-2-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/whatwg-fetch": {
|
"node_modules/whatwg-fetch": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmmirror.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz",
|
"resolved": "https://registry.npmmirror.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz",
|
||||||
"integrity": "sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng==",
|
"integrity": "sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/whatwg-url": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tr46": "~0.0.3",
|
|
||||||
"webidl-conversions": "^3.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -9963,9 +9865,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "2.8.4",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
|
"resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.1.tgz",
|
||||||
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
|
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
"peer": true,
|
||||||
@@ -9974,9 +9876,6 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14.6"
|
"node": ">= 14.6"
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/eemeli"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
@@ -9991,15 +9890,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zlibjs": {
|
|
||||||
"version": "0.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
|
|
||||||
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/zustand": {
|
"node_modules/zustand": {
|
||||||
"version": "4.5.7",
|
"version": "4.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||||
|
|||||||
@@ -54,7 +54,6 @@
|
|||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
"styled-components": "^6.1.19",
|
"styled-components": "^6.1.19",
|
||||||
"tailwind-merge": "^3.3.0",
|
"tailwind-merge": "^3.3.0",
|
||||||
"tesseract.js": "^7.0.0",
|
|
||||||
"usehooks-ts": "^3.1.1",
|
"usehooks-ts": "^3.1.1",
|
||||||
"validator": "^13.15.0",
|
"validator": "^13.15.0",
|
||||||
"w-touch": "^2.0.0",
|
"w-touch": "^2.0.0",
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ interface ConfirmDialogProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
title: string;
|
title: string;
|
||||||
description: React.ReactNode;
|
description: React.ReactNode;
|
||||||
children?: React.ReactNode;
|
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
cancelText?: string | null;
|
cancelText?: string | null;
|
||||||
@@ -66,7 +65,6 @@ export function ConfirmDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
children,
|
|
||||||
variant = "info",
|
variant = "info",
|
||||||
confirmText = "Confirm",
|
confirmText = "Confirm",
|
||||||
cancelText = "Cancel",
|
cancelText = "Cancel",
|
||||||
@@ -109,10 +107,9 @@ export function ConfirmDialog({
|
|||||||
{description}
|
{description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={cx("mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3", isConfirming && "pointer-events-none")}>
|
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
|
||||||
<Button
|
<Button
|
||||||
size="LG"
|
size="LG"
|
||||||
theme={buttonTheme}
|
theme={buttonTheme}
|
||||||
@@ -160,11 +157,10 @@ export function ConfirmDialog({
|
|||||||
<div className="mt-2 text-sm leading-snug text-slate-600 dark:text-[#ffffff]">
|
<div className="mt-2 text-sm leading-snug text-slate-600 dark:text-[#ffffff]">
|
||||||
{description}
|
{description}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cx("flex justify-end gap-x-2", isConfirming && "pointer-events-none")}>
|
<div className="flex justify-end gap-x-2">
|
||||||
{cancelText && (
|
{cancelText && (
|
||||||
<Button size="SM" theme="light" text={cancelText} onClick={onClose} />
|
<Button size="SM" theme="light" text={cancelText} onClick={onClose} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const comboboxVariants = cva({
|
|||||||
variants: { size: sizes },
|
variants: { size: sizes },
|
||||||
});
|
});
|
||||||
|
|
||||||
type BaseProps = React.ComponentProps<typeof HeadlessCombobox<ComboboxOption>>;
|
type BaseProps = React.ComponentProps<typeof HeadlessCombobox>;
|
||||||
|
|
||||||
interface ComboboxProps extends Omit<BaseProps, "displayValue"> {
|
interface ComboboxProps extends Omit<BaseProps, "displayValue"> {
|
||||||
displayValue: (option: ComboboxOption) => string;
|
displayValue: (option: ComboboxOption) => string;
|
||||||
|
|||||||
@@ -205,8 +205,7 @@ export function MacroStepCard({
|
|||||||
)}
|
)}
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<Combobox
|
<Combobox
|
||||||
onChange={value => {
|
onChange={(value: { value: string; label: string }) => {
|
||||||
if (!value) return;
|
|
||||||
onKeySelect(value);
|
onKeySelect(value);
|
||||||
onKeyQueryChange('');
|
onKeyQueryChange('');
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,458 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useReactAt } from "i18n-auto-extractor/react";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
import { useSettingsStore, useUiStore, useVideoStore } from "@/hooks/stores";
|
|
||||||
import Card from "@components/Card";
|
|
||||||
import { ConfirmDialog } from "@components/ConfirmDialog";
|
|
||||||
import TextArea from "@components/TextArea";
|
|
||||||
import notifications from "@/notifications";
|
|
||||||
import { eventMatchesShortcut } from "@/utils/shortcuts";
|
|
||||||
|
|
||||||
interface Rect {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type OcrStatus = "idle" | "selecting" | "processing" | "result";
|
|
||||||
|
|
||||||
type TesseractWorker = {
|
|
||||||
recognize: (image: HTMLCanvasElement, options?: unknown, output?: unknown) => Promise<{
|
|
||||||
data: {
|
|
||||||
text?: string;
|
|
||||||
blocks?: Array<{
|
|
||||||
paragraphs: Array<{
|
|
||||||
lines: Array<{
|
|
||||||
text: string;
|
|
||||||
bbox: { x0: number };
|
|
||||||
words: Array<{ text: string; bbox: { x0: number; x1: number } }>;
|
|
||||||
}>;
|
|
||||||
}>;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
terminate: () => Promise<unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function loadTesseract() {
|
|
||||||
const { createWorker } = await import("tesseract.js");
|
|
||||||
return createWorker;
|
|
||||||
}
|
|
||||||
|
|
||||||
let workerPromise: Promise<TesseractWorker> | null = null;
|
|
||||||
let cleanupTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
async function initWorker() {
|
|
||||||
const createWorker = await loadTesseract();
|
|
||||||
return createWorker("eng", 1) as Promise<TesseractWorker>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function terminateWorker() {
|
|
||||||
if (!workerPromise) return;
|
|
||||||
try {
|
|
||||||
const worker = await workerPromise;
|
|
||||||
await worker.terminate();
|
|
||||||
} catch {
|
|
||||||
// Ignore termination errors.
|
|
||||||
}
|
|
||||||
workerPromise = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWorker() {
|
|
||||||
if (cleanupTimer) {
|
|
||||||
clearTimeout(cleanupTimer);
|
|
||||||
cleanupTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!workerPromise) {
|
|
||||||
workerPromise = initWorker().catch(err => {
|
|
||||||
workerPromise = null;
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-terminate worker after inactivity to reduce memory footprint.
|
|
||||||
cleanupTimer = setTimeout(() => {
|
|
||||||
void terminateWorker();
|
|
||||||
cleanupTimer = null;
|
|
||||||
}, 60_000);
|
|
||||||
|
|
||||||
return workerPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function performOcr(canvas: HTMLCanvasElement): Promise<string> {
|
|
||||||
const worker = await getWorker();
|
|
||||||
const { data } = await worker.recognize(canvas, {}, { text: true, blocks: true });
|
|
||||||
const lines = data.blocks?.flatMap(b => b.paragraphs.flatMap(p => p.lines)) ?? [];
|
|
||||||
|
|
||||||
if (lines.length === 0) return (data.text || "").trim();
|
|
||||||
|
|
||||||
// Estimate character width from OCR words so left indentation is preserved.
|
|
||||||
let totalCharWidth = 0;
|
|
||||||
let samples = 0;
|
|
||||||
for (const line of lines) {
|
|
||||||
for (const word of line.words) {
|
|
||||||
const len = word.text.trim().length;
|
|
||||||
if (len > 0) {
|
|
||||||
totalCharWidth += (word.bbox.x1 - word.bbox.x0) / len;
|
|
||||||
samples++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (samples === 0) return (data.text || "").trim();
|
|
||||||
|
|
||||||
const charWidth = totalCharWidth / samples;
|
|
||||||
const minX = Math.min(...lines.map(l => l.bbox.x0));
|
|
||||||
|
|
||||||
return lines
|
|
||||||
.map(line => {
|
|
||||||
const indent = Math.round((line.bbox.x0 - minX) / charWidth);
|
|
||||||
return " ".repeat(indent) + line.text.trim();
|
|
||||||
})
|
|
||||||
.join("\n")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function captureRegion(videoEl: HTMLVideoElement, rect: Rect): HTMLCanvasElement {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = rect.width;
|
|
||||||
canvas.height = rect.height;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
if (!ctx) throw new Error("Failed to acquire 2D canvas context");
|
|
||||||
ctx.drawImage(videoEl, rect.x, rect.y, rect.width, rect.height, 0, 0, rect.width, rect.height);
|
|
||||||
return canvas;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getVideoDisplayRect(videoElement: HTMLVideoElement, videoWidth: number, videoHeight: number) {
|
|
||||||
const videoRect = videoElement.getBoundingClientRect();
|
|
||||||
const elementAspectRatio = videoRect.width / videoRect.height;
|
|
||||||
const streamAspectRatio = videoWidth / videoHeight;
|
|
||||||
|
|
||||||
let effectiveWidth = videoRect.width;
|
|
||||||
let effectiveHeight = videoRect.height;
|
|
||||||
let offsetX = 0;
|
|
||||||
let offsetY = 0;
|
|
||||||
|
|
||||||
if (elementAspectRatio > streamAspectRatio) {
|
|
||||||
effectiveWidth = videoRect.height * streamAspectRatio;
|
|
||||||
offsetX = (videoRect.width - effectiveWidth) / 2;
|
|
||||||
} else if (elementAspectRatio < streamAspectRatio) {
|
|
||||||
effectiveHeight = videoRect.width / streamAspectRatio;
|
|
||||||
offsetY = (videoRect.height - effectiveHeight) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { videoRect, effectiveWidth, effectiveHeight, offsetX, offsetY };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OcrOverlayProps {
|
|
||||||
videoRef: React.RefObject<HTMLVideoElement>;
|
|
||||||
containerRef: React.RefObject<HTMLDivElement>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OcrOverlay({ videoRef, containerRef }: OcrOverlayProps) {
|
|
||||||
const { width: videoWidth, height: videoHeight } = useVideoStore();
|
|
||||||
const isOcrMode = useUiStore(state => state.isOcrMode);
|
|
||||||
const setOcrMode = useUiStore(state => state.setOcrMode);
|
|
||||||
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
|
||||||
const ocrShortcutEnabled = useSettingsStore(state => state.ocrShortcutEnabled);
|
|
||||||
const ocrShortcut = useSettingsStore(state => state.ocrShortcut);
|
|
||||||
const { $at } = useReactAt();
|
|
||||||
|
|
||||||
const mountedRef = useRef(true);
|
|
||||||
const resultRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const [status, setStatus] = useState<OcrStatus>("idle");
|
|
||||||
const [selectionStart, setSelectionStart] = useState<{ x: number; y: number } | null>(null);
|
|
||||||
const [selectionRect, setSelectionRect] = useState<Rect | null>(null);
|
|
||||||
const [ocrResult, setOcrResult] = useState("");
|
|
||||||
const [isClosing, setIsClosing] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
mountedRef.current = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (!ocrShortcutEnabled) return;
|
|
||||||
if (!eventMatchesShortcut(e, ocrShortcut)) return;
|
|
||||||
const activeElement = document.activeElement as HTMLElement | null;
|
|
||||||
const isEditable =
|
|
||||||
!!activeElement
|
|
||||||
&& (activeElement.tagName === "INPUT"
|
|
||||||
|| activeElement.tagName === "TEXTAREA"
|
|
||||||
|| activeElement.isContentEditable);
|
|
||||||
if (isEditable) return;
|
|
||||||
if (videoWidth === 0 || videoHeight === 0) return;
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setOcrMode(!isOcrMode);
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleKeyDown, { capture: true });
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown, { capture: true });
|
|
||||||
}, [isOcrMode, ocrShortcut, ocrShortcutEnabled, setOcrMode, videoWidth, videoHeight]);
|
|
||||||
|
|
||||||
const closeOverlay = useCallback(() => {
|
|
||||||
if (status === "processing" || status === "result") {
|
|
||||||
setIsClosing(true);
|
|
||||||
setSelectionRect(null);
|
|
||||||
setSelectionStart(null);
|
|
||||||
setTimeout(() => setOcrMode(false), 200);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOcrMode(false);
|
|
||||||
}, [setOcrMode, status]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOcrMode) {
|
|
||||||
setStatus("idle");
|
|
||||||
setSelectionRect(null);
|
|
||||||
setSelectionStart(null);
|
|
||||||
setOcrResult("");
|
|
||||||
setIsClosing(false);
|
|
||||||
}
|
|
||||||
}, [isOcrMode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOcrMode) return;
|
|
||||||
if (status === "processing" || status === "result") {
|
|
||||||
setDisableVideoFocusTrap(true);
|
|
||||||
return () => setDisableVideoFocusTrap(false);
|
|
||||||
}
|
|
||||||
}, [isOcrMode, setDisableVideoFocusTrap, status]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOcrMode) return;
|
|
||||||
if (status === "processing" || status === "result") return;
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setOcrMode(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleKeyDown, { capture: true });
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown, { capture: true });
|
|
||||||
}, [isOcrMode, setOcrMode, status]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOcrMode) return;
|
|
||||||
if (status !== "result") return;
|
|
||||||
const handleCopy = () => {
|
|
||||||
notifications.success($at("Copied"), { duration: 4000 });
|
|
||||||
closeOverlay();
|
|
||||||
};
|
|
||||||
document.addEventListener("copy", handleCopy);
|
|
||||||
return () => document.removeEventListener("copy", handleCopy);
|
|
||||||
}, [closeOverlay, isOcrMode, status, $at]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (status === "result" && resultRef.current) {
|
|
||||||
resultRef.current.focus();
|
|
||||||
resultRef.current.select();
|
|
||||||
}
|
|
||||||
}, [status]);
|
|
||||||
|
|
||||||
const toVideoCoords = useCallback((clientX: number, clientY: number) => {
|
|
||||||
const videoElement = videoRef.current;
|
|
||||||
if (!videoElement) return { x: 0, y: 0 };
|
|
||||||
const { videoRect, effectiveWidth, effectiveHeight, offsetX, offsetY } = getVideoDisplayRect(
|
|
||||||
videoElement,
|
|
||||||
videoWidth,
|
|
||||||
videoHeight,
|
|
||||||
);
|
|
||||||
const relX = clientX - videoRect.left - offsetX;
|
|
||||||
const relY = clientY - videoRect.top - offsetY;
|
|
||||||
const scaleX = videoWidth / effectiveWidth;
|
|
||||||
const scaleY = videoHeight / effectiveHeight;
|
|
||||||
return {
|
|
||||||
x: Math.max(0, Math.min(videoWidth, Math.round(relX * scaleX))),
|
|
||||||
y: Math.max(0, Math.min(videoHeight, Math.round(relY * scaleY))),
|
|
||||||
};
|
|
||||||
}, [videoHeight, videoRef, videoWidth]);
|
|
||||||
|
|
||||||
const finishSelection = useCallback(async () => {
|
|
||||||
const videoElement = videoRef.current;
|
|
||||||
if (status !== "selecting") return;
|
|
||||||
if (!videoElement || !selectionRect) {
|
|
||||||
setStatus("idle");
|
|
||||||
setSelectionStart(null);
|
|
||||||
setSelectionRect(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (selectionRect.width < 10 || selectionRect.height < 10) {
|
|
||||||
setStatus("idle");
|
|
||||||
setSelectionStart(null);
|
|
||||||
setSelectionRect(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus("processing");
|
|
||||||
try {
|
|
||||||
const canvas = captureRegion(videoElement, selectionRect);
|
|
||||||
const text = await performOcr(canvas);
|
|
||||||
canvas.width = 0;
|
|
||||||
canvas.height = 0;
|
|
||||||
|
|
||||||
if (!mountedRef.current) return;
|
|
||||||
if (text) {
|
|
||||||
setOcrResult(text);
|
|
||||||
setStatus("result");
|
|
||||||
} else {
|
|
||||||
notifications.error($at("No text detected"));
|
|
||||||
closeOverlay();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!mountedRef.current) return;
|
|
||||||
console.error("OCR failed:", error);
|
|
||||||
notifications.error($at("OCR failed"));
|
|
||||||
closeOverlay();
|
|
||||||
}
|
|
||||||
}, [closeOverlay, selectionRect, status, videoRef, $at]);
|
|
||||||
|
|
||||||
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
|
||||||
if (status === "processing" || status === "result") return;
|
|
||||||
if (!videoRef.current || videoWidth === 0 || videoHeight === 0) return;
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
|
||||||
const coords = toVideoCoords(e.clientX, e.clientY);
|
|
||||||
setSelectionStart(coords);
|
|
||||||
setSelectionRect(null);
|
|
||||||
setStatus("selecting");
|
|
||||||
}, [status, toVideoCoords, videoHeight, videoRef, videoWidth]);
|
|
||||||
|
|
||||||
const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
|
||||||
if (status !== "selecting" || !selectionStart) return;
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
const coords = toVideoCoords(e.clientX, e.clientY);
|
|
||||||
const x = Math.min(selectionStart.x, coords.x);
|
|
||||||
const y = Math.min(selectionStart.y, coords.y);
|
|
||||||
const width = Math.abs(coords.x - selectionStart.x);
|
|
||||||
const height = Math.abs(coords.y - selectionStart.y);
|
|
||||||
setSelectionRect({ x, y, width, height });
|
|
||||||
}, [selectionStart, status, toVideoCoords]);
|
|
||||||
|
|
||||||
const onPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
|
||||||
if (status !== "selecting") return;
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
||||||
void finishSelection();
|
|
||||||
}, [finishSelection, status]);
|
|
||||||
|
|
||||||
const selectionStyle = useMemo(() => {
|
|
||||||
const videoElement = videoRef.current;
|
|
||||||
const containerElement = containerRef.current;
|
|
||||||
if (!selectionRect || !videoElement || !containerElement || videoWidth === 0 || videoHeight === 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { videoRect, effectiveWidth, effectiveHeight, offsetX, offsetY } = getVideoDisplayRect(
|
|
||||||
videoElement,
|
|
||||||
videoWidth,
|
|
||||||
videoHeight,
|
|
||||||
);
|
|
||||||
const containerRect = containerElement.getBoundingClientRect();
|
|
||||||
const baseX = videoRect.left - containerRect.left + offsetX;
|
|
||||||
const baseY = videoRect.top - containerRect.top + offsetY;
|
|
||||||
|
|
||||||
return {
|
|
||||||
left: `${baseX + (selectionRect.x / videoWidth) * effectiveWidth}px`,
|
|
||||||
top: `${baseY + (selectionRect.y / videoHeight) * effectiveHeight}px`,
|
|
||||||
width: `${(selectionRect.width / videoWidth) * effectiveWidth}px`,
|
|
||||||
height: `${(selectionRect.height / videoHeight) * effectiveHeight}px`,
|
|
||||||
};
|
|
||||||
}, [containerRef, selectionRect, videoHeight, videoRef, videoWidth]);
|
|
||||||
|
|
||||||
if (!isOcrMode) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
className="absolute inset-0 z-20 touch-none"
|
|
||||||
style={{ cursor: status === "result" ? "default" : "crosshair" }}
|
|
||||||
onPointerDown={onPointerDown}
|
|
||||||
onPointerMove={onPointerMove}
|
|
||||||
onPointerUp={onPointerUp}
|
|
||||||
>
|
|
||||||
<div className="fixed inset-0 bg-black/20" />
|
|
||||||
|
|
||||||
{status === "idle" && (
|
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-4 flex justify-center">
|
|
||||||
<div className="rounded-md bg-black/70 px-3 py-1.5 text-xs font-medium text-white">
|
|
||||||
{$at("Drag to select text area")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectionRect && selectionStyle && status !== "result" && (
|
|
||||||
<div
|
|
||||||
className="absolute border-2 border-dashed border-blue-400 bg-blue-400/10"
|
|
||||||
style={selectionStyle}
|
|
||||||
>
|
|
||||||
{selectionRect.width >= 10 && selectionRect.height >= 10 && (
|
|
||||||
<Card className="absolute right-0 -bottom-6 w-auto px-1.5 py-0.5 text-[10px] font-medium tabular-nums dark:text-white">
|
|
||||||
{selectionRect.width} × {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,7 +11,6 @@ interface PopoverButtonProps {
|
|||||||
align?: "left" | "right";
|
align?: "left" | "right";
|
||||||
buttonClassName?: string;
|
buttonClassName?: string;
|
||||||
panelClassName?: string;
|
panelClassName?: string;
|
||||||
style?: React.CSSProperties;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const BottomPopoverButton: React.FC<PopoverButtonProps> = ({
|
const BottomPopoverButton: React.FC<PopoverButtonProps> = ({
|
||||||
@@ -19,7 +18,6 @@ const BottomPopoverButton: React.FC<PopoverButtonProps> = ({
|
|||||||
buttonIconNode,
|
buttonIconNode,
|
||||||
panelContent,
|
panelContent,
|
||||||
align = "left",
|
align = "left",
|
||||||
style,
|
|
||||||
}) => {
|
}) => {
|
||||||
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||||
return (
|
return (
|
||||||
@@ -30,7 +28,7 @@ const BottomPopoverButton: React.FC<PopoverButtonProps> = ({
|
|||||||
<>
|
<>
|
||||||
<PopoverButton
|
<PopoverButton
|
||||||
as="div"
|
as="div"
|
||||||
style={{ display: "flex", justifyContent: "center", alignItems: "center", ...style }}>
|
style={{ display: "flex", justifyContent: "center", alignItems: "center" }}>
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDisableFocusTrap(true);
|
setDisableFocusTrap(true);
|
||||||
|
|||||||
@@ -13,10 +13,9 @@ export const VideoElement = forwardRef<HTMLVideoElement, VideoElementProps>(
|
|||||||
({ onPlaying, style, className }, ref) => {
|
({ onPlaying, style, className }, ref) => {
|
||||||
const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled);
|
const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled);
|
||||||
const isVirtualKeyboardEnabled = useHidStore(state => state.isVirtualKeyboardEnabled);
|
const isVirtualKeyboardEnabled = useHidStore(state => state.isVirtualKeyboardEnabled);
|
||||||
const allowTapToOpenVirtualKeyboard = useHidStore(state => state.allowTapToOpenVirtualKeyboard);
|
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (isMobile && allowTapToOpenVirtualKeyboard && !isVirtualKeyboardEnabled) {
|
if (isMobile && !isVirtualKeyboardEnabled) {
|
||||||
setVirtualKeyboardEnabled(true);
|
setVirtualKeyboardEnabled(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function LoadingConnectionOverlay({ show, text }: LoadingConnectionOverla
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{show && (
|
{show && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute inset-0 h-full w-full"
|
className="aspect-video h-full w-full"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0, transition: { duration: 0 } }}
|
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||||
@@ -124,7 +124,7 @@ export function ConnectionFailedOverlay({
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{show && (
|
{show && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute inset-0 h-full w-full"
|
className="aspect-video h-full w-full"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0, transition: { duration: 0 } }}
|
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||||
@@ -215,7 +215,7 @@ export function PeerConnectionDisconnectedOverlay({
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{show && (
|
{show && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute inset-0 h-full w-full"
|
className="aspect-video h-full w-full"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0, transition: { duration: 0 } }}
|
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||||
@@ -278,10 +278,9 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
|||||||
|
|
||||||
const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled);
|
const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled);
|
||||||
const isVirtualKeyboardEnabled = useHidStore(state => state.isVirtualKeyboardEnabled);
|
const isVirtualKeyboardEnabled = useHidStore(state => state.isVirtualKeyboardEnabled);
|
||||||
const allowTapToOpenVirtualKeyboard = useHidStore(state => state.allowTapToOpenVirtualKeyboard);
|
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (isMobile && allowTapToOpenVirtualKeyboard && !isVirtualKeyboardEnabled) {
|
if (isMobile && !isVirtualKeyboardEnabled) {
|
||||||
setVirtualKeyboardEnabled(true);
|
setVirtualKeyboardEnabled(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -302,7 +301,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{show && isNoSignal && (
|
{show && isNoSignal && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute inset-0 h-full w-full "
|
className="absolute inset-0 aspect-video h-full w-full "
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
@@ -386,7 +385,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{show && isOtherError && (
|
{show && isOtherError && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute inset-0 h-full w-full"
|
className="absolute inset-0 aspect-video h-full w-full"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
@@ -446,7 +445,7 @@ export function NoAutoplayPermissionsOverlay({
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{show && (
|
{show && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute inset-0 z-10 h-full w-full"
|
className="absolute inset-0 z-10 aspect-video h-full w-full"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ import DetachIconRaw from "@/assets/detach-icon.svg";
|
|||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
|
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
|
||||||
import useKeyboard from "@/hooks/useKeyboard";
|
import useKeyboard from "@/hooks/useKeyboard";
|
||||||
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
|
import { keyDisplayMap, keyDisplayMap2, keys, modifiers, sKeyDisplayMap } from "@/keyboardMappings";
|
||||||
import { keyDisplayMap2, keys, modifiers, sKeyDisplayMap, latchingKeys } from "@/keyboardMappings";
|
|
||||||
import { dark_bg2_style} from "@/layout/theme_color";
|
import { dark_bg2_style} from "@/layout/theme_color";
|
||||||
|
|
||||||
import GoBottomSvg from "@/assets/second/gobottom.svg?react";
|
import GoBottomSvg from "@/assets/second/gobottom.svg?react";
|
||||||
@@ -34,15 +33,6 @@ function KeyboardWrapper() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
|
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
|
||||||
const { selectedKeyboard } = useKeyboardLayout();
|
|
||||||
|
|
||||||
const keyDisplayMap = useMemo(() => {
|
|
||||||
return selectedKeyboard.keyDisplayMap;
|
|
||||||
}, [selectedKeyboard]);
|
|
||||||
|
|
||||||
const virtualKeyboardLayout = useMemo(() => {
|
|
||||||
return selectedKeyboard.virtualKeyboard;
|
|
||||||
}, [selectedKeyboard]);
|
|
||||||
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
@@ -270,7 +260,6 @@ function KeyboardWrapper() {
|
|||||||
setIsCapsLockActive(false);
|
setIsCapsLockActive(false);
|
||||||
}
|
}
|
||||||
sendKeyboardEvent([keys["CapsLock"]], []);
|
sendKeyboardEvent([keys["CapsLock"]], []);
|
||||||
setTimeout(resetKeyboardState, 100);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -848,7 +837,24 @@ function KeyboardWrapper() {
|
|||||||
? [{ class: "modifier-locked", buttons: modifierLockButtons }]
|
? [{ class: "modifier-locked", buttons: modifierLockButtons }]
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
layout={virtualKeyboardLayout.main}
|
layout={{
|
||||||
|
default: [
|
||||||
|
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
||||||
|
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
|
||||||
|
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
|
||||||
|
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter",
|
||||||
|
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
|
||||||
|
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
|
||||||
|
],
|
||||||
|
shift: [
|
||||||
|
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
||||||
|
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
|
||||||
|
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
|
||||||
|
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
|
||||||
|
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
|
||||||
|
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
|
||||||
|
],
|
||||||
|
}}
|
||||||
disableButtonHold={true}
|
disableButtonHold={true}
|
||||||
syncInstanceInputs={true}
|
syncInstanceInputs={true}
|
||||||
debug={false}
|
debug={false}
|
||||||
@@ -868,7 +874,10 @@ function KeyboardWrapper() {
|
|||||||
layoutName={layoutName}
|
layoutName={layoutName}
|
||||||
onKeyPress={onKeyDown}
|
onKeyPress={onKeyDown}
|
||||||
display={keyDisplayMap}
|
display={keyDisplayMap}
|
||||||
layout={virtualKeyboardLayout.control}
|
layout={{
|
||||||
|
default: ["PrintScreen ScrollLock Pause", "Insert Home Pageup", "Delete End Pagedown"],
|
||||||
|
shift: ["(PrintScreen) ScrollLock (Pause)", "Insert Home Pageup", "Delete End Pagedown"],
|
||||||
|
}}
|
||||||
syncInstanceInputs={true}
|
syncInstanceInputs={true}
|
||||||
debug={false}
|
debug={false}
|
||||||
/>
|
/>
|
||||||
@@ -893,7 +902,7 @@ function KeyboardWrapper() {
|
|||||||
onKeyPress={onKeyDown}
|
onKeyPress={onKeyDown}
|
||||||
display={keyDisplayMap}
|
display={keyDisplayMap}
|
||||||
layout={{
|
layout={{
|
||||||
default: [virtualKeyboardLayout.arrows?.default?.[0] || "ArrowUp"],
|
default: ["ArrowUp"],
|
||||||
}}
|
}}
|
||||||
syncInstanceInputs={true}
|
syncInstanceInputs={true}
|
||||||
debug={false}
|
debug={false}
|
||||||
@@ -907,7 +916,7 @@ function KeyboardWrapper() {
|
|||||||
onKeyPress={onKeyDown}
|
onKeyPress={onKeyDown}
|
||||||
display={keyDisplayMap}
|
display={keyDisplayMap}
|
||||||
layout={{
|
layout={{
|
||||||
default: [virtualKeyboardLayout.arrows?.default?.[1] || "ArrowLeft ArrowDown ArrowRight"],
|
default: ["ArrowLeft ArrowDown ArrowRight"],
|
||||||
}}
|
}}
|
||||||
syncInstanceInputs={true}
|
syncInstanceInputs={true}
|
||||||
debug={false}
|
debug={false}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const appendStatToMap = <T extends { timestamp: number }>(
|
|||||||
|
|
||||||
// Constants and types
|
// Constants and types
|
||||||
export type AvailableSidebarViews ="ConsoleLogViewer"|"MacroMoreList"|"Fullscreen"|"TerminalTabsMobile"|"SettingsModal"|"ClipboardMobile"|"KeyboardPanel"|"MousePanel"|"SettingsVideo"
|
export type AvailableSidebarViews ="ConsoleLogViewer"|"MacroMoreList"|"Fullscreen"|"TerminalTabsMobile"|"SettingsModal"|"ClipboardMobile"|"KeyboardPanel"|"MousePanel"|"SettingsVideo"
|
||||||
| "connection-stats" | "Clipboard" | "PowerControl" | "Macros" | "VirtualMedia" | "SharedFolders" | "UsbEpModeSelect" | "UsbStatusPanel" | null;
|
|"connection-stats"|"Clipboard"|"PowerControl"|"Macros"|"VirtualMedia"|"SharedFolders"|null;
|
||||||
export type AvailableTerminalTypes = "kvm" | "serial" | "none";
|
export type AvailableTerminalTypes = "kvm" | "serial" | "none";
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
@@ -64,8 +64,6 @@ interface UIState {
|
|||||||
|
|
||||||
disableVideoFocusTrap: boolean;
|
disableVideoFocusTrap: boolean;
|
||||||
setDisableVideoFocusTrap: (enabled: boolean) => void;
|
setDisableVideoFocusTrap: (enabled: boolean) => void;
|
||||||
isOcrMode: boolean;
|
|
||||||
setOcrMode: (enabled: boolean) => void;
|
|
||||||
|
|
||||||
isWakeOnLanModalVisible: boolean;
|
isWakeOnLanModalVisible: boolean;
|
||||||
setWakeOnLanModalVisibility: (enabled: boolean) => void;
|
setWakeOnLanModalVisibility: (enabled: boolean) => void;
|
||||||
@@ -93,8 +91,6 @@ export const useUiStore = create<UIState>(set => ({
|
|||||||
|
|
||||||
disableVideoFocusTrap: false,
|
disableVideoFocusTrap: false,
|
||||||
setDisableVideoFocusTrap: enabled => set({ disableVideoFocusTrap: enabled }),
|
setDisableVideoFocusTrap: enabled => set({ disableVideoFocusTrap: enabled }),
|
||||||
isOcrMode: false,
|
|
||||||
setOcrMode: enabled => set({ isOcrMode: enabled }),
|
|
||||||
isAnimationComplete: false,
|
isAnimationComplete: false,
|
||||||
setIsAnimationComplete: enabled => set({ isAnimationComplete: enabled }),
|
setIsAnimationComplete: enabled => set({ isAnimationComplete: enabled }),
|
||||||
|
|
||||||
@@ -390,14 +386,6 @@ interface SettingsState {
|
|||||||
|
|
||||||
overrideCtrlV: boolean;
|
overrideCtrlV: boolean;
|
||||||
setOverrideCtrlV: (enabled: boolean) => void;
|
setOverrideCtrlV: (enabled: boolean) => void;
|
||||||
pasteShortcutEnabled: boolean;
|
|
||||||
setPasteShortcutEnabled: (enabled: boolean) => void;
|
|
||||||
pasteShortcut: string;
|
|
||||||
setPasteShortcut: (shortcut: string) => void;
|
|
||||||
ocrShortcutEnabled: boolean;
|
|
||||||
setOcrShortcutEnabled: (enabled: boolean) => void;
|
|
||||||
ocrShortcut: string;
|
|
||||||
setOcrShortcut: (shortcut: string) => void;
|
|
||||||
|
|
||||||
// Video enhancement settings
|
// Video enhancement settings
|
||||||
videoSaturation: number;
|
videoSaturation: number;
|
||||||
@@ -467,14 +455,6 @@ export const useSettingsStore = create(
|
|||||||
|
|
||||||
overrideCtrlV: false,
|
overrideCtrlV: false,
|
||||||
setOverrideCtrlV: enabled => set({ overrideCtrlV: enabled }),
|
setOverrideCtrlV: enabled => set({ overrideCtrlV: enabled }),
|
||||||
pasteShortcutEnabled: true,
|
|
||||||
setPasteShortcutEnabled: enabled => set({ pasteShortcutEnabled: enabled }),
|
|
||||||
pasteShortcut: "Ctrl+V",
|
|
||||||
setPasteShortcut: shortcut => set({ pasteShortcut: shortcut }),
|
|
||||||
ocrShortcutEnabled: true,
|
|
||||||
setOcrShortcutEnabled: enabled => set({ ocrShortcutEnabled: enabled }),
|
|
||||||
ocrShortcut: "Ctrl+C",
|
|
||||||
setOcrShortcut: shortcut => set({ ocrShortcut: shortcut }),
|
|
||||||
|
|
||||||
// Video enhancement settings with default values (1.0 = normal)
|
// Video enhancement settings with default values (1.0 = normal)
|
||||||
videoSaturation: 1.0,
|
videoSaturation: 1.0,
|
||||||
@@ -586,13 +566,8 @@ export interface HidState {
|
|||||||
keyboardLedStateSyncAvailable: boolean;
|
keyboardLedStateSyncAvailable: boolean;
|
||||||
setKeyboardLedStateSyncAvailable: (available: boolean) => void;
|
setKeyboardLedStateSyncAvailable: (available: boolean) => void;
|
||||||
|
|
||||||
keysDownState?: { modifier: number; keys: number[] };
|
|
||||||
setKeysDownState: (state: { modifier: number; keys: number[] }) => void;
|
|
||||||
|
|
||||||
isVirtualKeyboardEnabled: boolean;
|
isVirtualKeyboardEnabled: boolean;
|
||||||
setVirtualKeyboardEnabled: (enabled: boolean) => void;
|
setVirtualKeyboardEnabled: (enabled: boolean) => void;
|
||||||
allowTapToOpenVirtualKeyboard: boolean;
|
|
||||||
setAllowTapToOpenVirtualKeyboard: (enabled: boolean) => void;
|
|
||||||
|
|
||||||
isPasteModeEnabled: boolean;
|
isPasteModeEnabled: boolean;
|
||||||
setPasteModeEnabled: (enabled: boolean) => void;
|
setPasteModeEnabled: (enabled: boolean) => void;
|
||||||
@@ -647,16 +622,11 @@ export const useHidStore = create<HidState>((set, get) => ({
|
|||||||
set({ keyboardLedState });
|
set({ keyboardLedState });
|
||||||
},
|
},
|
||||||
|
|
||||||
keysDownState: undefined,
|
|
||||||
setKeysDownState: state => set({ keysDownState: state }),
|
|
||||||
|
|
||||||
keyboardLedStateSyncAvailable: false,
|
keyboardLedStateSyncAvailable: false,
|
||||||
setKeyboardLedStateSyncAvailable: available => set({ keyboardLedStateSyncAvailable: available }),
|
setKeyboardLedStateSyncAvailable: available => set({ keyboardLedStateSyncAvailable: available }),
|
||||||
|
|
||||||
isVirtualKeyboardEnabled: false,
|
isVirtualKeyboardEnabled: false,
|
||||||
setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }),
|
setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }),
|
||||||
allowTapToOpenVirtualKeyboard: true,
|
|
||||||
setAllowTapToOpenVirtualKeyboard: enabled => set({ allowTapToOpenVirtualKeyboard: enabled }),
|
|
||||||
|
|
||||||
isPasteModeEnabled: false,
|
isPasteModeEnabled: false,
|
||||||
setPasteModeEnabled: enabled => set({ isPasteModeEnabled: enabled }),
|
setPasteModeEnabled: enabled => set({ isPasteModeEnabled: enabled }),
|
||||||
@@ -729,13 +699,6 @@ export interface UpdateState {
|
|||||||
|
|
||||||
systemUpdateProgress: number;
|
systemUpdateProgress: number;
|
||||||
systemUpdatedAt: string | null;
|
systemUpdatedAt: string | null;
|
||||||
|
|
||||||
appSignatureMissing: boolean;
|
|
||||||
systemSignatureMissing: boolean;
|
|
||||||
appSignatureAbsent: boolean;
|
|
||||||
appSignatureInvalid: boolean;
|
|
||||||
appNoPublicKey: boolean;
|
|
||||||
signatureVerified: boolean;
|
|
||||||
};
|
};
|
||||||
setOtaState: (state: UpdateState["otaState"]) => void;
|
setOtaState: (state: UpdateState["otaState"]) => void;
|
||||||
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
|
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
|
||||||
@@ -784,12 +747,6 @@ export const useUpdateStore = create<UpdateState>(set => ({
|
|||||||
appUpdatedAt: null,
|
appUpdatedAt: null,
|
||||||
systemUpdateProgress: 0,
|
systemUpdateProgress: 0,
|
||||||
systemUpdatedAt: null,
|
systemUpdatedAt: null,
|
||||||
appSignatureMissing: false,
|
|
||||||
systemSignatureMissing: false,
|
|
||||||
appSignatureAbsent: false,
|
|
||||||
appSignatureInvalid: false,
|
|
||||||
appNoPublicKey: false,
|
|
||||||
signatureVerified: false,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
updateDialogHasBeenMinimized: false,
|
updateDialogHasBeenMinimized: false,
|
||||||
@@ -939,9 +896,6 @@ export interface IPv4StaticConfig {
|
|||||||
export interface NetworkSettings {
|
export interface NetworkSettings {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
http_proxy?: string;
|
|
||||||
https_proxy?: string;
|
|
||||||
all_proxy?: string;
|
|
||||||
ipv4_mode: IPv4Mode;
|
ipv4_mode: IPv4Mode;
|
||||||
ipv4_request_address?: string;
|
ipv4_request_address?: string;
|
||||||
ipv4_static?: IPv4StaticConfig;
|
ipv4_static?: IPv4StaticConfig;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import { useHidStore, useRTCStore, useSettingsStore } from "@/hooks/stores";
|
import { useHidStore, useRTCStore, useSettingsStore } from "@/hooks/stores";
|
||||||
@@ -16,10 +16,6 @@ export default function useKeyboard() {
|
|||||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||||
const usbState = useHidStore(state => state.usbState);
|
const usbState = useHidStore(state => state.usbState);
|
||||||
|
|
||||||
// Track held keys for keepalive
|
|
||||||
const heldKeysRef = useRef<Set<number>>(new Set());
|
|
||||||
const keepaliveIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
|
|
||||||
const sendKeyboardEvent = useCallback(
|
const sendKeyboardEvent = useCallback(
|
||||||
(keys: number[], modifiers: number[]) => {
|
(keys: number[], modifiers: number[]) => {
|
||||||
if (!forceHttp && rpcDataChannel?.readyState !== "open") return;
|
if (!forceHttp && rpcDataChannel?.readyState !== "open") return;
|
||||||
@@ -28,7 +24,6 @@ export default function useKeyboard() {
|
|||||||
if (usbState !== "configured") return;
|
if (usbState !== "configured") return;
|
||||||
const accModifier = modifiers.reduce((acc, val) => acc + val, 0);
|
const accModifier = modifiers.reduce((acc, val) => acc + val, 0);
|
||||||
|
|
||||||
// Fallback to JSON-RPC
|
|
||||||
send("keyboardReport", { keys, modifier: accModifier }, resp => {
|
send("keyboardReport", { keys, modifier: accModifier }, resp => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
const msg = (resp.error.data as string) || resp.error.message || "";
|
const msg = (resp.error.data as string) || resp.error.message || "";
|
||||||
@@ -44,37 +39,10 @@ export default function useKeyboard() {
|
|||||||
[forceHttp, rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers, isReinitializingGadget, usbState],
|
[forceHttp, rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers, isReinitializingGadget, usbState],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send per-key press/release
|
|
||||||
const sendKeypress = useCallback(
|
|
||||||
(key: number, press: boolean) => {
|
|
||||||
if (isReinitializingGadget || usbState !== "configured") return;
|
|
||||||
|
|
||||||
// Legacy: simulate device-side key handling
|
|
||||||
// This maintains the 6-key buffer on the frontend for legacy compatibility
|
|
||||||
// For simplicity in migration, we fall back to full state reports
|
|
||||||
const modifier = press ? 0 : 0; // Simplified - would need proper modifier tracking
|
|
||||||
sendKeyboardEvent(press ? [key] : [], [modifier]);
|
|
||||||
},
|
|
||||||
[isReinitializingGadget, usbState, sendKeyboardEvent]
|
|
||||||
);
|
|
||||||
|
|
||||||
const resetKeyboardState = useCallback(() => {
|
const resetKeyboardState = useCallback(() => {
|
||||||
// Release all held keys
|
|
||||||
sendKeyboardEvent([], []);
|
sendKeyboardEvent([], []);
|
||||||
heldKeysRef.current.clear();
|
|
||||||
if (keepaliveIntervalRef.current) {
|
|
||||||
clearInterval(keepaliveIntervalRef.current);
|
|
||||||
keepaliveIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
}, [sendKeyboardEvent]);
|
}, [sendKeyboardEvent]);
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
resetKeyboardState();
|
|
||||||
};
|
|
||||||
}, [resetKeyboardState]);
|
|
||||||
|
|
||||||
const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => {
|
const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => {
|
||||||
for (const [index, step] of steps.entries()) {
|
for (const [index, step] of steps.entries()) {
|
||||||
const keyValues = step.keys?.map(key => keys[key]).filter(Boolean) || [];
|
const keyValues = step.keys?.map(key => keys[key]).filter(Boolean) || [];
|
||||||
@@ -98,5 +66,5 @@ export default function useKeyboard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return { sendKeyboardEvent, sendKeypress, resetKeyboardState, executeMacro };
|
return { sendKeyboardEvent, resetKeyboardState, executeMacro };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import { useSettingsStore } from "@/hooks/stores";
|
|
||||||
import { keyboards } from "@/keyboardLayouts";
|
|
||||||
|
|
||||||
export default function useKeyboardLayout() {
|
|
||||||
const { keyboardLayout } = useSettingsStore();
|
|
||||||
|
|
||||||
const keyboardOptions = useMemo(() => {
|
|
||||||
return keyboards.map(keyboard => {
|
|
||||||
return { label: keyboard.name, value: keyboard.isoCode };
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isoCode = useMemo(() => {
|
|
||||||
if (keyboardLayout && keyboardLayout.length > 0)
|
|
||||||
return keyboardLayout.replace("en_US", "en-US");
|
|
||||||
return "en-US";
|
|
||||||
}, [keyboardLayout]);
|
|
||||||
|
|
||||||
const selectedKeyboard = useMemo(() => {
|
|
||||||
return (
|
|
||||||
keyboards.find(keyboard => keyboard.isoCode === isoCode) ??
|
|
||||||
keyboards.find(keyboard => keyboard.isoCode === "en-US")!
|
|
||||||
);
|
|
||||||
}, [isoCode]);
|
|
||||||
|
|
||||||
return { keyboardOptions, isoCode, selectedKeyboard };
|
|
||||||
}
|
|
||||||
@@ -1,85 +1,45 @@
|
|||||||
export interface KeyStroke {
|
import { chars as chars_fr_BE, name as name_fr_BE } from "@/keyboardLayouts/fr_BE"
|
||||||
modifier: number;
|
import { chars as chars_cs_CZ, name as name_cs_CZ } from "@/keyboardLayouts/cs_CZ"
|
||||||
keys: number[];
|
import { chars as chars_en_UK, name as name_en_UK } from "@/keyboardLayouts/en_UK"
|
||||||
|
import { chars as chars_en_US, name as name_en_US } from "@/keyboardLayouts/en_US"
|
||||||
|
import { chars as chars_fr_FR, name as name_fr_FR } from "@/keyboardLayouts/fr_FR"
|
||||||
|
import { chars as chars_de_DE, name as name_de_DE } from "@/keyboardLayouts/de_DE"
|
||||||
|
import { chars as chars_it_IT, name as name_it_IT } from "@/keyboardLayouts/it_IT"
|
||||||
|
import { chars as chars_nb_NO, name as name_nb_NO } from "@/keyboardLayouts/nb_NO"
|
||||||
|
import { chars as chars_es_ES, name as name_es_ES } from "@/keyboardLayouts/es_ES"
|
||||||
|
import { chars as chars_sv_SE, name as name_sv_SE } from "@/keyboardLayouts/sv_SE"
|
||||||
|
import { chars as chars_fr_CH, name as name_fr_CH } from "@/keyboardLayouts/fr_CH"
|
||||||
|
import { chars as chars_de_CH, name as name_de_CH } from "@/keyboardLayouts/de_CH"
|
||||||
|
|
||||||
|
interface KeyInfo { key: string | number; shift?: boolean, altRight?: boolean }
|
||||||
|
export type KeyCombo = KeyInfo & { deadKey?: boolean, accentKey?: KeyInfo }
|
||||||
|
|
||||||
|
export const layouts: Record<string, string> = {
|
||||||
|
en_UK: name_en_UK,
|
||||||
|
en_US: name_en_US,
|
||||||
|
fr_FR: name_fr_FR,
|
||||||
|
be_FR: name_fr_BE,
|
||||||
|
cs_CZ: name_cs_CZ,
|
||||||
|
de_DE: name_de_DE,
|
||||||
|
it_IT: name_it_IT,
|
||||||
|
nb_NO: name_nb_NO,
|
||||||
|
es_ES: name_es_ES,
|
||||||
|
sv_SE: name_sv_SE,
|
||||||
|
fr_CH: name_fr_CH,
|
||||||
|
de_CH: name_de_CH,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KeyInfo {
|
export const chars: Record<string, Record<string, KeyCombo>> = {
|
||||||
key: string | number;
|
be_FR: chars_fr_BE,
|
||||||
shift?: boolean;
|
cs_CZ: chars_cs_CZ,
|
||||||
altRight?: boolean;
|
en_UK: chars_en_UK,
|
||||||
}
|
en_US: chars_en_US,
|
||||||
|
fr_FR: chars_fr_FR,
|
||||||
export interface KeyCombo extends KeyInfo {
|
de_DE: chars_de_DE,
|
||||||
deadKey?: boolean;
|
it_IT: chars_it_IT,
|
||||||
accentKey?: KeyInfo;
|
nb_NO: chars_nb_NO,
|
||||||
}
|
es_ES: chars_es_ES,
|
||||||
|
sv_SE: chars_sv_SE,
|
||||||
export interface KeyboardLayout {
|
fr_CH: chars_fr_CH,
|
||||||
isoCode: string;
|
de_CH: chars_de_CH,
|
||||||
name: string;
|
|
||||||
chars: Record<string, KeyCombo>;
|
|
||||||
modifierDisplayMap: Record<string, string>;
|
|
||||||
keyDisplayMap: Record<string, string>;
|
|
||||||
virtualKeyboard: {
|
|
||||||
main: { default: string[]; shift: string[] };
|
|
||||||
control?: { default: string[]; shift?: string[] };
|
|
||||||
arrows?: { default: string[] };
|
|
||||||
numpad?: {
|
|
||||||
numlocked: string[];
|
|
||||||
default: string[];
|
|
||||||
};
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import all layouts
|
|
||||||
import { cs_CZ } from "./keyboardLayouts/cs_CZ";
|
|
||||||
import { da_DK } from "./keyboardLayouts/da_DK";
|
|
||||||
import { de_CH } from "./keyboardLayouts/de_CH";
|
|
||||||
import { de_DE } from "./keyboardLayouts/de_DE";
|
|
||||||
import { en_US } from "./keyboardLayouts/en_US";
|
|
||||||
import { en_UK } from "./keyboardLayouts/en_UK";
|
|
||||||
import { es_ES } from "./keyboardLayouts/es_ES";
|
|
||||||
import { fr_BE } from "./keyboardLayouts/fr_BE";
|
|
||||||
import { fr_CH } from "./keyboardLayouts/fr_CH";
|
|
||||||
import { fr_FR } from "./keyboardLayouts/fr_FR";
|
|
||||||
import { hu_HU } from "./keyboardLayouts/hu_HU";
|
|
||||||
import { it_IT } from "./keyboardLayouts/it_IT";
|
|
||||||
import { ja_JP } from "./keyboardLayouts/ja_JP";
|
|
||||||
import { nb_NO } from "./keyboardLayouts/nb_NO";
|
|
||||||
import { pl_PL } from "./keyboardLayouts/pl_PL";
|
|
||||||
import { pt_PT } from "./keyboardLayouts/pt_PT";
|
|
||||||
import { sv_SE } from "./keyboardLayouts/sv_SE";
|
|
||||||
import { sl_SI } from "./keyboardLayouts/sl_SI";
|
|
||||||
import { ru_RU } from "./keyboardLayouts/ru_RU";
|
|
||||||
|
|
||||||
export const keyboards: KeyboardLayout[] = [
|
|
||||||
cs_CZ,
|
|
||||||
da_DK,
|
|
||||||
de_CH,
|
|
||||||
de_DE,
|
|
||||||
en_UK,
|
|
||||||
en_US,
|
|
||||||
es_ES,
|
|
||||||
fr_BE,
|
|
||||||
fr_CH,
|
|
||||||
fr_FR,
|
|
||||||
hu_HU,
|
|
||||||
it_IT,
|
|
||||||
ja_JP,
|
|
||||||
nb_NO,
|
|
||||||
pl_PL,
|
|
||||||
pt_PT,
|
|
||||||
sv_SE,
|
|
||||||
sl_SI,
|
|
||||||
ru_RU,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Backward-compatible maps
|
|
||||||
export const layouts: Record<string, string> = {};
|
|
||||||
export const chars: Record<string, Record<string, KeyCombo>> = {};
|
|
||||||
|
|
||||||
keyboards.forEach(kb => {
|
|
||||||
const oldCode = kb.isoCode.replace("-", "_");
|
|
||||||
layouts[oldCode] = kb.name;
|
|
||||||
chars[oldCode] = kb.chars;
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
import { KeyCombo } from "../keyboardLayouts"
|
||||||
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
|
|
||||||
|
|
||||||
const name = "Čeština";
|
export const name = "Čeština";
|
||||||
const isoCode = "cs-CZ";
|
|
||||||
|
|
||||||
const keyTrema = { key: "Backslash" } // tréma (umlaut), two dots placed above a vowel
|
const keyTrema = { key: "Backslash" } // tréma (umlaut), two dots placed above a vowel
|
||||||
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
|
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
|
||||||
@@ -99,11 +97,11 @@ export const chars = {
|
|||||||
"Ẇ": { key: "KeyW", shift: true, accentKey: keyOverdot },
|
"Ẇ": { key: "KeyW", shift: true, accentKey: keyOverdot },
|
||||||
X: { key: "KeyX", shift: true },
|
X: { key: "KeyX", shift: true },
|
||||||
"Ẋ": { key: "KeyX", shift: true, accentKey: keyOverdot },
|
"Ẋ": { key: "KeyX", shift: true, accentKey: keyOverdot },
|
||||||
Y: { key: "KeyZ", shift: true },
|
Y: { key: "KeyY", shift: true },
|
||||||
"Ý": { key: "KeyZ", shift: true, accentKey: keyAcute },
|
"Ý": { key: "KeyY", shift: true, accentKey: keyAcute },
|
||||||
"Ẏ": { key: "KeyZ", shift: true, accentKey: keyOverdot },
|
"Ẏ": { key: "KeyY", shift: true, accentKey: keyOverdot },
|
||||||
Z: { key: "KeyY", shift: true },
|
Z: { key: "KeyZ", shift: true },
|
||||||
"Ż": { key: "KeyY", shift: true, accentKey: keyOverdot },
|
"Ż": { key: "KeyZ", shift: true, accentKey: keyOverdot },
|
||||||
a: { key: "KeyA" },
|
a: { key: "KeyA" },
|
||||||
"ä": { key: "KeyA", accentKey: keyTrema },
|
"ä": { key: "KeyA", accentKey: keyTrema },
|
||||||
"â": { key: "KeyA", accentKey: keyHat },
|
"â": { key: "KeyA", accentKey: keyHat },
|
||||||
@@ -191,10 +189,10 @@ export const chars = {
|
|||||||
x: { key: "KeyX" },
|
x: { key: "KeyX" },
|
||||||
"#": { key: "KeyX", altRight: true },
|
"#": { key: "KeyX", altRight: true },
|
||||||
"ẋ": { key: "KeyX", accentKey: keyOverdot },
|
"ẋ": { key: "KeyX", accentKey: keyOverdot },
|
||||||
y: { key: "KeyZ" },
|
y: { key: "KeyY" },
|
||||||
"ẏ": { key: "KeyZ", accentKey: keyOverdot },
|
"ẏ": { key: "KeyY", accentKey: keyOverdot },
|
||||||
z: { key: "KeyY" },
|
z: { key: "KeyZ" },
|
||||||
"ż": { key: "KeyY", accentKey: keyOverdot },
|
"ż": { key: "KeyZ", accentKey: keyOverdot },
|
||||||
";": { key: "Backquote" },
|
";": { key: "Backquote" },
|
||||||
"°": { key: "Backquote", shift: true, deadKey: true },
|
"°": { key: "Backquote", shift: true, deadKey: true },
|
||||||
"+": { key: "Digit1" },
|
"+": { key: "Digit1" },
|
||||||
@@ -244,20 +242,3 @@ export const chars = {
|
|||||||
Enter: { key: "Enter" },
|
Enter: { key: "Enter" },
|
||||||
Tab: { key: "Tab" },
|
Tab: { key: "Tab" },
|
||||||
} as Record<string, KeyCombo>;
|
} as Record<string, KeyCombo>;
|
||||||
|
|
||||||
const cs_CZ_keyDisplayMap = {
|
|
||||||
...keyDisplayMap,
|
|
||||||
KeyY: "z",
|
|
||||||
KeyZ: "y",
|
|
||||||
"(KeyY)": "Z",
|
|
||||||
"(KeyZ)": "Y",
|
|
||||||
} as Record<string, string>;
|
|
||||||
|
|
||||||
export const cs_CZ: KeyboardLayout = {
|
|
||||||
isoCode,
|
|
||||||
name,
|
|
||||||
chars,
|
|
||||||
keyDisplayMap: cs_CZ_keyDisplayMap,
|
|
||||||
modifierDisplayMap,
|
|
||||||
virtualKeyboard,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts";
|
|
||||||
|
|
||||||
import { en_US } from "./en_US"; // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
|
|
||||||
|
|
||||||
export const name = "Dansk";
|
|
||||||
const isoCode = "da-DK";
|
|
||||||
|
|
||||||
const keyTrema = { key: "BracketRight" };
|
|
||||||
const keyAcute = { key: "Equal", altRight: true };
|
|
||||||
const keyHat = { key: "BracketRight", shift: true };
|
|
||||||
const keyGrave = { key: "Equal", shift: true };
|
|
||||||
const keyTilde = { key: "BracketRight", altRight: true };
|
|
||||||
|
|
||||||
export const chars = {
|
|
||||||
A: { key: "KeyA", shift: true },
|
|
||||||
Ä: { key: "KeyA", shift: true, accentKey: keyTrema },
|
|
||||||
Á: { key: "KeyA", shift: true, accentKey: keyAcute },
|
|
||||||
Â: { key: "KeyA", shift: true, accentKey: keyHat },
|
|
||||||
À: { key: "KeyA", shift: true, accentKey: keyGrave },
|
|
||||||
Ã: { key: "KeyA", shift: true, accentKey: keyTilde },
|
|
||||||
B: { key: "KeyB", shift: true },
|
|
||||||
C: { key: "KeyC", shift: true },
|
|
||||||
D: { key: "KeyD", shift: true },
|
|
||||||
E: { key: "KeyE", shift: true },
|
|
||||||
Ë: { key: "KeyE", shift: true, accentKey: keyTrema },
|
|
||||||
É: { key: "KeyE", shift: true, accentKey: keyAcute },
|
|
||||||
Ê: { key: "KeyE", shift: true, accentKey: keyHat },
|
|
||||||
È: { key: "KeyE", shift: true, accentKey: keyGrave },
|
|
||||||
Ẽ: { key: "KeyE", shift: true, accentKey: keyTilde },
|
|
||||||
F: { key: "KeyF", shift: true },
|
|
||||||
G: { key: "KeyG", shift: true },
|
|
||||||
H: { key: "KeyH", shift: true },
|
|
||||||
I: { key: "KeyI", shift: true },
|
|
||||||
Ï: { key: "KeyI", shift: true, accentKey: keyTrema },
|
|
||||||
Í: { key: "KeyI", shift: true, accentKey: keyAcute },
|
|
||||||
Î: { key: "KeyI", shift: true, accentKey: keyHat },
|
|
||||||
Ì: { key: "KeyI", shift: true, accentKey: keyGrave },
|
|
||||||
Ĩ: { key: "KeyI", shift: true, accentKey: keyTilde },
|
|
||||||
J: { key: "KeyJ", shift: true },
|
|
||||||
K: { key: "KeyK", shift: true },
|
|
||||||
L: { key: "KeyL", shift: true },
|
|
||||||
M: { key: "KeyM", shift: true },
|
|
||||||
N: { key: "KeyN", shift: true },
|
|
||||||
O: { key: "KeyO", shift: true },
|
|
||||||
Ö: { key: "KeyO", shift: true, accentKey: keyTrema },
|
|
||||||
Ó: { key: "KeyO", shift: true, accentKey: keyAcute },
|
|
||||||
Ô: { key: "KeyO", shift: true, accentKey: keyHat },
|
|
||||||
Ò: { key: "KeyO", shift: true, accentKey: keyGrave },
|
|
||||||
Õ: { key: "KeyO", shift: true, accentKey: keyTilde },
|
|
||||||
P: { key: "KeyP", shift: true },
|
|
||||||
Q: { key: "KeyQ", shift: true },
|
|
||||||
R: { key: "KeyR", shift: true },
|
|
||||||
S: { key: "KeyS", shift: true },
|
|
||||||
T: { key: "KeyT", shift: true },
|
|
||||||
U: { key: "KeyU", shift: true },
|
|
||||||
Ü: { key: "KeyU", shift: true, accentKey: keyTrema },
|
|
||||||
Ú: { key: "KeyU", shift: true, accentKey: keyAcute },
|
|
||||||
Û: { key: "KeyU", shift: true, accentKey: keyHat },
|
|
||||||
Ù: { key: "KeyU", shift: true, accentKey: keyGrave },
|
|
||||||
Ũ: { key: "KeyU", shift: true, accentKey: keyTilde },
|
|
||||||
V: { key: "KeyV", shift: true },
|
|
||||||
W: { key: "KeyW", shift: true },
|
|
||||||
X: { key: "KeyX", shift: true },
|
|
||||||
Y: { key: "KeyY", shift: true },
|
|
||||||
Z: { key: "KeyZ", shift: true },
|
|
||||||
a: { key: "KeyA" },
|
|
||||||
ä: { key: "KeyA", accentKey: keyTrema },
|
|
||||||
á: { key: "KeyA", accentKey: keyAcute },
|
|
||||||
â: { key: "KeyA", accentKey: keyHat },
|
|
||||||
à: { key: "KeyA", accentKey: keyGrave },
|
|
||||||
ã: { key: "KeyA", accentKey: keyTilde },
|
|
||||||
b: { key: "KeyB" },
|
|
||||||
c: { key: "KeyC" },
|
|
||||||
d: { key: "KeyD" },
|
|
||||||
e: { key: "KeyE" },
|
|
||||||
ë: { key: "KeyE", accentKey: keyTrema },
|
|
||||||
é: { key: "KeyE", accentKey: keyAcute },
|
|
||||||
ê: { key: "KeyE", accentKey: keyHat },
|
|
||||||
è: { key: "KeyE", accentKey: keyGrave },
|
|
||||||
ẽ: { key: "KeyE", accentKey: keyTilde },
|
|
||||||
"€": { key: "KeyE", altRight: true },
|
|
||||||
f: { key: "KeyF" },
|
|
||||||
g: { key: "KeyG" },
|
|
||||||
h: { key: "KeyH" },
|
|
||||||
i: { key: "KeyI" },
|
|
||||||
ï: { key: "KeyI", accentKey: keyTrema },
|
|
||||||
í: { key: "KeyI", accentKey: keyAcute },
|
|
||||||
î: { key: "KeyI", accentKey: keyHat },
|
|
||||||
ì: { key: "KeyI", accentKey: keyGrave },
|
|
||||||
ĩ: { key: "KeyI", accentKey: keyTilde },
|
|
||||||
j: { key: "KeyJ" },
|
|
||||||
k: { key: "KeyK" },
|
|
||||||
l: { key: "KeyL" },
|
|
||||||
m: { key: "KeyM" },
|
|
||||||
n: { key: "KeyN" },
|
|
||||||
o: { key: "KeyO" },
|
|
||||||
ö: { key: "KeyO", accentKey: keyTrema },
|
|
||||||
ó: { key: "KeyO", accentKey: keyAcute },
|
|
||||||
ô: { key: "KeyO", accentKey: keyHat },
|
|
||||||
ò: { key: "KeyO", accentKey: keyGrave },
|
|
||||||
õ: { key: "KeyO", accentKey: keyTilde },
|
|
||||||
p: { key: "KeyP" },
|
|
||||||
q: { key: "KeyQ" },
|
|
||||||
r: { key: "KeyR" },
|
|
||||||
s: { key: "KeyS" },
|
|
||||||
t: { key: "KeyT" },
|
|
||||||
u: { key: "KeyU" },
|
|
||||||
ü: { key: "KeyU", accentKey: keyTrema },
|
|
||||||
ú: { key: "KeyU", accentKey: keyAcute },
|
|
||||||
û: { key: "KeyU", accentKey: keyHat },
|
|
||||||
ù: { key: "KeyU", accentKey: keyGrave },
|
|
||||||
ũ: { key: "KeyU", accentKey: keyTilde },
|
|
||||||
v: { key: "KeyV" },
|
|
||||||
w: { key: "KeyW" },
|
|
||||||
x: { key: "KeyX" },
|
|
||||||
y: { key: "KeyY" }, // <-- corrected
|
|
||||||
z: { key: "KeyZ" }, // <-- corrected
|
|
||||||
"½": { key: "Backquote" },
|
|
||||||
"§": { key: "Backquote", shift: true },
|
|
||||||
1: { key: "Digit1" },
|
|
||||||
"!": { key: "Digit1", shift: true },
|
|
||||||
2: { key: "Digit2" },
|
|
||||||
'"': { key: "Digit2", shift: true },
|
|
||||||
"@": { key: "Digit2", altRight: true },
|
|
||||||
3: { key: "Digit3" },
|
|
||||||
"#": { key: "Digit3", shift: true },
|
|
||||||
"£": { key: "Digit3", altRight: true },
|
|
||||||
4: { key: "Digit4" },
|
|
||||||
"¤": { key: "Digit4", shift: true },
|
|
||||||
$: { key: "Digit4", altRight: true },
|
|
||||||
5: { key: "Digit5" },
|
|
||||||
"%": { key: "Digit5", shift: true },
|
|
||||||
6: { key: "Digit6" },
|
|
||||||
"&": { key: "Digit6", shift: true },
|
|
||||||
7: { key: "Digit7" },
|
|
||||||
"/": { key: "Digit7", shift: true },
|
|
||||||
"{": { key: "Digit7", altRight: true },
|
|
||||||
8: { key: "Digit8" },
|
|
||||||
"(": { key: "Digit8", shift: true },
|
|
||||||
"[": { key: "Digit8", altRight: true },
|
|
||||||
9: { key: "Digit9" },
|
|
||||||
")": { key: "Digit9", shift: true },
|
|
||||||
"]": { key: "Digit9", altRight: true },
|
|
||||||
0: { key: "Digit0" },
|
|
||||||
"=": { key: "Digit0", shift: true },
|
|
||||||
"}": { key: "Digit0", altRight: true },
|
|
||||||
"+": { key: "Minus" },
|
|
||||||
"?": { key: "Minus", shift: true },
|
|
||||||
"\\": { key: "Equal" },
|
|
||||||
å: { key: "BracketLeft" },
|
|
||||||
Å: { key: "BracketLeft", shift: true },
|
|
||||||
ø: { key: "Semicolon" },
|
|
||||||
Ø: { key: "Semicolon", shift: true },
|
|
||||||
æ: { key: "Quote" },
|
|
||||||
Æ: { key: "Quote", shift: true },
|
|
||||||
"'": { key: "Backslash" },
|
|
||||||
"*": { key: "Backslash", shift: true },
|
|
||||||
",": { key: "Comma" },
|
|
||||||
";": { key: "Comma", shift: true },
|
|
||||||
".": { key: "Period" },
|
|
||||||
":": { key: "Period", shift: true },
|
|
||||||
"-": { key: "Slash" },
|
|
||||||
_: { key: "Slash", shift: true },
|
|
||||||
"<": { key: "IntlBackslash" },
|
|
||||||
">": { key: "IntlBackslash", shift: true },
|
|
||||||
"~": { key: "BracketRight", deadKey: true, altRight: true },
|
|
||||||
"^": { key: "BracketRight", deadKey: true, shift: true },
|
|
||||||
"¨": { key: "BracketRight", deadKey: true },
|
|
||||||
"|": { key: "Equal", deadKey: true, altRight: true },
|
|
||||||
"`": { key: "Equal", deadKey: true, shift: true },
|
|
||||||
"´": { key: "Equal", deadKey: true },
|
|
||||||
" ": { key: "Space" },
|
|
||||||
"\n": { key: "Enter" },
|
|
||||||
Enter: { key: "Enter" },
|
|
||||||
Tab: { key: "Tab" },
|
|
||||||
} as Record<string, KeyCombo>;
|
|
||||||
|
|
||||||
export const da_DK: KeyboardLayout = {
|
|
||||||
isoCode: isoCode,
|
|
||||||
name: name,
|
|
||||||
chars: chars,
|
|
||||||
// TODO need to localize these maps and layouts
|
|
||||||
keyDisplayMap: en_US.keyDisplayMap,
|
|
||||||
modifierDisplayMap: en_US.modifierDisplayMap,
|
|
||||||
virtualKeyboard: en_US.virtualKeyboard,
|
|
||||||
};
|
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
import { KeyCombo } from "../keyboardLayouts"
|
||||||
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
|
|
||||||
export { keyDisplayMap } from "./en_US";
|
|
||||||
|
|
||||||
const name = "Schwiizerdütsch";
|
export const name = "Schwiizerdütsch";
|
||||||
const isoCode = "de-CH";
|
|
||||||
|
|
||||||
const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
|
const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
|
||||||
const keyAcute = { key: "Minus", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
|
const keyAcute = { key: "Minus", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
|
||||||
@@ -166,20 +163,3 @@ export const chars = {
|
|||||||
Enter: { key: "Enter" },
|
Enter: { key: "Enter" },
|
||||||
Tab: { key: "Tab" },
|
Tab: { key: "Tab" },
|
||||||
} as Record<string, KeyCombo>;
|
} as Record<string, KeyCombo>;
|
||||||
|
|
||||||
export const de_CH_keyDisplayMap = {
|
|
||||||
...keyDisplayMap,
|
|
||||||
KeyY: "z",
|
|
||||||
KeyZ: "y",
|
|
||||||
"(KeyY)": "Z",
|
|
||||||
"(KeyZ)": "Y",
|
|
||||||
} as Record<string, string>;
|
|
||||||
|
|
||||||
export const de_CH: KeyboardLayout = {
|
|
||||||
isoCode,
|
|
||||||
name,
|
|
||||||
chars,
|
|
||||||
keyDisplayMap: de_CH_keyDisplayMap,
|
|
||||||
modifierDisplayMap,
|
|
||||||
virtualKeyboard,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
import { KeyCombo } from "../keyboardLayouts"
|
||||||
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
|
|
||||||
|
|
||||||
const name = "Deutsch";
|
export const name = "Deutsch";
|
||||||
const isoCode = "de-DE";
|
|
||||||
|
|
||||||
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
|
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
|
||||||
const keyHat = { key: "Backquote" } // accent circonflexe (accent hat), mark ^ placed above the letter
|
const keyHat = { key: "Backquote" } // accent circonflexe (accent hat), mark ^ placed above the letter
|
||||||
@@ -152,20 +150,3 @@ export const chars = {
|
|||||||
Enter: { key: "Enter" },
|
Enter: { key: "Enter" },
|
||||||
Tab: { key: "Tab" },
|
Tab: { key: "Tab" },
|
||||||
} as Record<string, KeyCombo>;
|
} as Record<string, KeyCombo>;
|
||||||
|
|
||||||
const de_DE_keyDisplayMap = {
|
|
||||||
...keyDisplayMap,
|
|
||||||
KeyY: "z",
|
|
||||||
KeyZ: "y",
|
|
||||||
"(KeyY)": "Z",
|
|
||||||
"(KeyZ)": "Y",
|
|
||||||
} as Record<string, string>;
|
|
||||||
|
|
||||||
export const de_DE: KeyboardLayout = {
|
|
||||||
isoCode,
|
|
||||||
name,
|
|
||||||
chars,
|
|
||||||
keyDisplayMap: de_DE_keyDisplayMap,
|
|
||||||
modifierDisplayMap,
|
|
||||||
virtualKeyboard,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
import { KeyCombo } from "../keyboardLayouts"
|
||||||
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
|
|
||||||
|
|
||||||
const name = "English (UK)";
|
export const name = "English (UK)";
|
||||||
const isoCode = "en-GB";
|
|
||||||
|
|
||||||
export const chars = {
|
export const chars = {
|
||||||
A: { key: "KeyA", shift: true },
|
A: { key: "KeyA", shift: true },
|
||||||
@@ -106,13 +104,4 @@ export const chars = {
|
|||||||
"\n": { key: "Enter" },
|
"\n": { key: "Enter" },
|
||||||
Enter: { key: "Enter" },
|
Enter: { key: "Enter" },
|
||||||
Tab: { key: "Tab" },
|
Tab: { key: "Tab" },
|
||||||
} as Record<string, KeyCombo>;
|
} as Record<string, KeyCombo>
|
||||||
|
|
||||||
export const en_UK: KeyboardLayout = {
|
|
||||||
isoCode,
|
|
||||||
name,
|
|
||||||
chars,
|
|
||||||
keyDisplayMap,
|
|
||||||
modifierDisplayMap,
|
|
||||||
virtualKeyboard,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
import { KeyCombo } from "../keyboardLayouts"
|
||||||
|
|
||||||
const name = "English (US)";
|
export const name = "English (US)";
|
||||||
const isoCode = "en-US";
|
|
||||||
|
|
||||||
export const chars = {
|
export const chars = {
|
||||||
A: { key: "KeyA", shift: true },
|
A: { key: "KeyA", shift: true },
|
||||||
@@ -90,283 +89,25 @@ export const chars = {
|
|||||||
">": { key: "Period", shift: true },
|
">": { key: "Period", shift: true },
|
||||||
";": { key: "Semicolon" },
|
";": { key: "Semicolon" },
|
||||||
":": { key: "Semicolon", shift: true },
|
":": { key: "Semicolon", shift: true },
|
||||||
"¶": { key: "Semicolon", altRight: true }, // pilcrow sign
|
|
||||||
"[": { key: "BracketLeft" },
|
"[": { key: "BracketLeft" },
|
||||||
"{": { key: "BracketLeft", shift: true },
|
"{": { key: "BracketLeft", shift: true },
|
||||||
"«": { key: "BracketLeft", altRight: true }, // double left quote sign
|
|
||||||
"]": { key: "BracketRight" },
|
"]": { key: "BracketRight" },
|
||||||
"}": { key: "BracketRight", shift: true },
|
"}": { key: "BracketRight", shift: true },
|
||||||
"»": { key: "BracketRight", altRight: true }, // double right quote sign
|
|
||||||
"\\": { key: "Backslash" },
|
"\\": { key: "Backslash" },
|
||||||
"|": { key: "Backslash", shift: true },
|
"|": { key: "Backslash", shift: true },
|
||||||
"¬": { key: "Backslash", altRight: true }, // not sign
|
|
||||||
"`": { key: "Backquote" },
|
"`": { key: "Backquote" },
|
||||||
"~": { key: "Backquote", shift: true },
|
"~": { key: "Backquote", shift: true },
|
||||||
"§": { key: "IntlBackslash" },
|
"§": { key: "IntlBackslash" },
|
||||||
"±": { key: "IntlBackslash", shift: true },
|
"±": { key: "IntlBackslash", shift: true },
|
||||||
" ": { key: "Space" },
|
" ": { key: "Space", shift: false },
|
||||||
"\n": { key: "Enter" },
|
"\n": { key: "Enter", shift: false },
|
||||||
Enter: { key: "Enter" },
|
Enter: { key: "Enter", shift: false },
|
||||||
Escape: { key: "Escape" },
|
Tab: { key: "Tab", shift: false },
|
||||||
Tab: { key: "Tab" },
|
PrintScreen: { key: "Prt Sc", shift: false },
|
||||||
PrintScreen: { key: "Prt Sc" },
|
|
||||||
SystemRequest: { key: "Prt Sc", shift: true },
|
SystemRequest: { key: "Prt Sc", shift: true },
|
||||||
ScrollLock: { key: "ScrollLock" },
|
ScrollLock: { key: "ScrollLock", shift: false},
|
||||||
Pause: { key: "Pause" },
|
Pause: { key: "Pause", shift: false },
|
||||||
Break: { key: "Pause", shift: true },
|
Break: { key: "Pause", shift: true },
|
||||||
Insert: { key: "Insert" },
|
Insert: { key: "Insert", shift: false },
|
||||||
Delete: { key: "Delete" },
|
Delete: { key: "Delete", shift: false },
|
||||||
} as Record<string, KeyCombo>;
|
} as Record<string, KeyCombo>
|
||||||
|
|
||||||
export const modifierDisplayMap: Record<string, string> = {
|
|
||||||
ControlLeft: "Left Ctrl",
|
|
||||||
ControlRight: "Right Ctrl",
|
|
||||||
ShiftLeft: "Left Shift",
|
|
||||||
ShiftRight: "Right Shift",
|
|
||||||
AltLeft: "Left Alt",
|
|
||||||
AltRight: "Right Alt",
|
|
||||||
MetaLeft: "Left Meta",
|
|
||||||
MetaRight: "Right Meta",
|
|
||||||
AltGr: "AltGr",
|
|
||||||
} as Record<string, string>;
|
|
||||||
|
|
||||||
export const keyDisplayMap: Record<string, string> = {
|
|
||||||
CtrlAltDelete: "Ctrl + Alt + Delete",
|
|
||||||
AltMetaEscape: "Alt + Meta + Escape",
|
|
||||||
CtrlAltBackspace: "Ctrl + Alt + Backspace",
|
|
||||||
AltGr: "AltGr",
|
|
||||||
AltLeft: "Alt ⌥",
|
|
||||||
AltRight: "⌥ Alt",
|
|
||||||
ArrowDown: "↓",
|
|
||||||
ArrowLeft: "←",
|
|
||||||
ArrowRight: "→",
|
|
||||||
ArrowUp: "↑",
|
|
||||||
Backspace: "Backspace",
|
|
||||||
"(Backspace)": "Backspace",
|
|
||||||
CapsLock: "Caps Lock ⇪",
|
|
||||||
Clear: "Clear",
|
|
||||||
ControlLeft: "Ctrl ⌃",
|
|
||||||
ControlRight: "⌃ Ctrl",
|
|
||||||
Delete: "Delete ⌦",
|
|
||||||
End: "End",
|
|
||||||
Enter: "Enter",
|
|
||||||
Escape: "Esc",
|
|
||||||
Home: "Home",
|
|
||||||
Insert: "Insert",
|
|
||||||
Menu: "Menu",
|
|
||||||
MetaLeft: "Meta ⌘",
|
|
||||||
MetaRight: "⌘ Meta",
|
|
||||||
PageDown: "PgDn",
|
|
||||||
PageUp: "PgUp",
|
|
||||||
ShiftLeft: "Shift ⇧",
|
|
||||||
ShiftRight: "⇧ Shift",
|
|
||||||
Space: " ",
|
|
||||||
Tab: "Tab ⇥",
|
|
||||||
|
|
||||||
// Letters
|
|
||||||
KeyA: "a",
|
|
||||||
KeyB: "b",
|
|
||||||
KeyC: "c",
|
|
||||||
KeyD: "d",
|
|
||||||
KeyE: "e",
|
|
||||||
KeyF: "f",
|
|
||||||
KeyG: "g",
|
|
||||||
KeyH: "h",
|
|
||||||
KeyI: "i",
|
|
||||||
KeyJ: "j",
|
|
||||||
KeyK: "k",
|
|
||||||
KeyL: "l",
|
|
||||||
KeyM: "m",
|
|
||||||
KeyN: "n",
|
|
||||||
KeyO: "o",
|
|
||||||
KeyP: "p",
|
|
||||||
KeyQ: "q",
|
|
||||||
KeyR: "r",
|
|
||||||
KeyS: "s",
|
|
||||||
KeyT: "t",
|
|
||||||
KeyU: "u",
|
|
||||||
KeyV: "v",
|
|
||||||
KeyW: "w",
|
|
||||||
KeyX: "x",
|
|
||||||
KeyY: "y",
|
|
||||||
KeyZ: "z",
|
|
||||||
|
|
||||||
// Capital letters
|
|
||||||
"(KeyA)": "A",
|
|
||||||
"(KeyB)": "B",
|
|
||||||
"(KeyC)": "C",
|
|
||||||
"(KeyD)": "D",
|
|
||||||
"(KeyE)": "E",
|
|
||||||
"(KeyF)": "F",
|
|
||||||
"(KeyG)": "G",
|
|
||||||
"(KeyH)": "H",
|
|
||||||
"(KeyI)": "I",
|
|
||||||
"(KeyJ)": "J",
|
|
||||||
"(KeyK)": "K",
|
|
||||||
"(KeyL)": "L",
|
|
||||||
"(KeyM)": "M",
|
|
||||||
"(KeyN)": "N",
|
|
||||||
"(KeyO)": "O",
|
|
||||||
"(KeyP)": "P",
|
|
||||||
"(KeyQ)": "Q",
|
|
||||||
"(KeyR)": "R",
|
|
||||||
"(KeyS)": "S",
|
|
||||||
"(KeyT)": "T",
|
|
||||||
"(KeyU)": "U",
|
|
||||||
"(KeyV)": "V",
|
|
||||||
"(KeyW)": "W",
|
|
||||||
"(KeyX)": "X",
|
|
||||||
"(KeyY)": "Y",
|
|
||||||
"(KeyZ)": "Z",
|
|
||||||
|
|
||||||
// Numbers
|
|
||||||
Digit1: "1",
|
|
||||||
Digit2: "2",
|
|
||||||
Digit3: "3",
|
|
||||||
Digit4: "4",
|
|
||||||
Digit5: "5",
|
|
||||||
Digit6: "6",
|
|
||||||
Digit7: "7",
|
|
||||||
Digit8: "8",
|
|
||||||
Digit9: "9",
|
|
||||||
Digit0: "0",
|
|
||||||
|
|
||||||
// Shifted Numbers
|
|
||||||
"(Digit1)": "!",
|
|
||||||
"(Digit2)": "@",
|
|
||||||
"(Digit3)": "#",
|
|
||||||
"(Digit4)": "$",
|
|
||||||
"(Digit5)": "%",
|
|
||||||
"(Digit6)": "^",
|
|
||||||
"(Digit7)": "&",
|
|
||||||
"(Digit8)": "*",
|
|
||||||
"(Digit9)": "(",
|
|
||||||
"(Digit0)": ")",
|
|
||||||
|
|
||||||
// Symbols
|
|
||||||
Minus: "-",
|
|
||||||
"(Minus)": "_",
|
|
||||||
Equal: "=",
|
|
||||||
"(Equal)": "+",
|
|
||||||
BracketLeft: "[",
|
|
||||||
"(BracketLeft)": "{",
|
|
||||||
BracketRight: "]",
|
|
||||||
"(BracketRight)": "}",
|
|
||||||
Backslash: "\\",
|
|
||||||
"(Backslash)": "|",
|
|
||||||
Semicolon: ";",
|
|
||||||
"(Semicolon)": ":",
|
|
||||||
Quote: "'",
|
|
||||||
"(Quote)": '"',
|
|
||||||
Comma: ",",
|
|
||||||
"(Comma)": "<",
|
|
||||||
Period: ".",
|
|
||||||
"(Period)": ">",
|
|
||||||
Slash: "/",
|
|
||||||
"(Slash)": "?",
|
|
||||||
Backquote: "`",
|
|
||||||
"(Backquote)": "~",
|
|
||||||
IntlBackslash: "\\",
|
|
||||||
|
|
||||||
// Function keys
|
|
||||||
F1: "F1",
|
|
||||||
F2: "F2",
|
|
||||||
F3: "F3",
|
|
||||||
F4: "F4",
|
|
||||||
F5: "F5",
|
|
||||||
F6: "F6",
|
|
||||||
F7: "F7",
|
|
||||||
F8: "F8",
|
|
||||||
F9: "F9",
|
|
||||||
F10: "F10",
|
|
||||||
F11: "F11",
|
|
||||||
F12: "F12",
|
|
||||||
|
|
||||||
// Numpad
|
|
||||||
Numpad0: "Num 0",
|
|
||||||
Numpad1: "Num 1",
|
|
||||||
Numpad2: "Num 2",
|
|
||||||
Numpad3: "Num 3",
|
|
||||||
Numpad4: "Num 4",
|
|
||||||
Numpad5: "Num 5",
|
|
||||||
Numpad6: "Num 6",
|
|
||||||
Numpad7: "Num 7",
|
|
||||||
Numpad8: "Num 8",
|
|
||||||
Numpad9: "Num 9",
|
|
||||||
NumpadAdd: "Num +",
|
|
||||||
NumpadSubtract: "Num -",
|
|
||||||
NumpadMultiply: "Num *",
|
|
||||||
NumpadDivide: "Num /",
|
|
||||||
NumpadDecimal: "Num .",
|
|
||||||
NumpadEqual: "Num =",
|
|
||||||
NumpadEnter: "Num Enter",
|
|
||||||
NumpadInsert: "Ins",
|
|
||||||
NumpadDelete: "Del",
|
|
||||||
NumLock: "Num Lock",
|
|
||||||
|
|
||||||
// Modals
|
|
||||||
PrintScreen: "Prt Sc",
|
|
||||||
ScrollLock: "Scr Lk",
|
|
||||||
Pause: "Pause",
|
|
||||||
"(PrintScreen)": "Sys Rq",
|
|
||||||
"(Pause)": "Break",
|
|
||||||
SystemRequest: "Sys Rq",
|
|
||||||
Break: "Break",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const virtualKeyboard = {
|
|
||||||
main: {
|
|
||||||
default: [
|
|
||||||
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
|
|
||||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
|
||||||
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
|
|
||||||
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
|
|
||||||
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter",
|
|
||||||
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
|
|
||||||
"ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight",
|
|
||||||
],
|
|
||||||
shift: [
|
|
||||||
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
|
|
||||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
|
||||||
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
|
|
||||||
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
|
|
||||||
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
|
|
||||||
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
|
|
||||||
"ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
control: {
|
|
||||||
default: ["PrintScreen ScrollLock Pause", "Insert Home PageUp", "Delete End PageDown"],
|
|
||||||
shift: ["(PrintScreen) ScrollLock (Pause)", "Insert Home PageUp", "Delete End PageDown"],
|
|
||||||
},
|
|
||||||
|
|
||||||
arrows: {
|
|
||||||
default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
|
|
||||||
},
|
|
||||||
|
|
||||||
numpad: {
|
|
||||||
numlocked: [
|
|
||||||
"NumLock NumpadDivide NumpadMultiply NumpadSubtract",
|
|
||||||
"Numpad7 Numpad8 Numpad9 NumpadAdd",
|
|
||||||
"Numpad4 Numpad5 Numpad6",
|
|
||||||
"Numpad1 Numpad2 Numpad3 NumpadEnter",
|
|
||||||
"Numpad0 NumpadDecimal",
|
|
||||||
],
|
|
||||||
default: [
|
|
||||||
"NumLock NumpadDivide NumpadMultiply NumpadSubtract",
|
|
||||||
"Home ArrowUp PageUp NumpadAdd",
|
|
||||||
"ArrowLeft Clear ArrowRight",
|
|
||||||
"End ArrowDown PageDown NumpadEnter",
|
|
||||||
"NumpadInsert NumpadDelete",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const en_US: KeyboardLayout = {
|
|
||||||
isoCode,
|
|
||||||
name,
|
|
||||||
chars,
|
|
||||||
keyDisplayMap,
|
|
||||||
modifierDisplayMap,
|
|
||||||
virtualKeyboard,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
import { KeyCombo } from "../keyboardLayouts"
|
||||||
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
|
|
||||||
|
|
||||||
const name = "Español";
|
export const name = "Español";
|
||||||
const isoCode = "es-ES";
|
|
||||||
|
|
||||||
const keyTrema = { key: "Quote", shift: true } // tréma (umlaut), two dots placed above a vowel
|
const keyTrema = { key: "Quote", shift: true } // tréma (umlaut), two dots placed above a vowel
|
||||||
const keyAcute = { key: "Quote" } // accent aigu (acute accent), mark ´ placed above the letter
|
const keyAcute = { key: "Quote" } // accent aigu (acute accent), mark ´ placed above the letter
|
||||||
@@ -168,12 +166,3 @@ export const chars = {
|
|||||||
Enter: { key: "Enter" },
|
Enter: { key: "Enter" },
|
||||||
Tab: { key: "Tab" },
|
Tab: { key: "Tab" },
|
||||||
} as Record<string, KeyCombo>;
|
} as Record<string, KeyCombo>;
|
||||||
|
|
||||||
export const es_ES: KeyboardLayout = {
|
|
||||||
isoCode,
|
|
||||||
name,
|
|
||||||
chars,
|
|
||||||
keyDisplayMap,
|
|
||||||
modifierDisplayMap,
|
|
||||||
virtualKeyboard,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
import { KeyCombo } from "../keyboardLayouts"
|
||||||
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
|
|
||||||
|
|
||||||
const name = "Belgisch Nederlands";
|
export const name = "Belgisch Nederlands";
|
||||||
const isoCode = "fr-BE";
|
|
||||||
|
|
||||||
const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
|
const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
|
||||||
const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
|
const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
|
||||||
@@ -58,10 +56,10 @@ export const chars = {
|
|||||||
"Ù": { key: "KeyU", shift: true, accentKey: keyGrave },
|
"Ù": { key: "KeyU", shift: true, accentKey: keyGrave },
|
||||||
"Ũ": { key: "KeyU", shift: true, accentKey: keyTilde },
|
"Ũ": { key: "KeyU", shift: true, accentKey: keyTilde },
|
||||||
V: { key: "KeyV", shift: true },
|
V: { key: "KeyV", shift: true },
|
||||||
W: { key: "KeyZ", shift: true },
|
W: { key: "KeyW", shift: true },
|
||||||
X: { key: "KeyX", shift: true },
|
X: { key: "KeyX", shift: true },
|
||||||
Y: { key: "KeyY", shift: true },
|
Y: { key: "KeyZ", shift: true },
|
||||||
Z: { key: "KeyW", shift: true },
|
Z: { key: "KeyY", shift: true },
|
||||||
a: { key: "KeyQ" },
|
a: { key: "KeyQ" },
|
||||||
"ä": { key: "KeyQ", accentKey: keyTrema },
|
"ä": { key: "KeyQ", accentKey: keyTrema },
|
||||||
"â": { key: "KeyQ", accentKey: keyHat },
|
"â": { key: "KeyQ", accentKey: keyHat },
|
||||||
@@ -106,10 +104,10 @@ export const chars = {
|
|||||||
"ú": { key: "KeyU", accentKey: keyAcute },
|
"ú": { key: "KeyU", accentKey: keyAcute },
|
||||||
"ũ": { key: "KeyU", accentKey: keyTilde },
|
"ũ": { key: "KeyU", accentKey: keyTilde },
|
||||||
v: { key: "KeyV" },
|
v: { key: "KeyV" },
|
||||||
w: { key: "KeyZ" },
|
w: { key: "KeyW" },
|
||||||
x: { key: "KeyX" },
|
x: { key: "KeyX" },
|
||||||
y: { key: "KeyY" },
|
y: { key: "KeyZ" },
|
||||||
z: { key: "KeyW" },
|
z: { key: "KeyY" },
|
||||||
"²": { key: "Backquote" },
|
"²": { key: "Backquote" },
|
||||||
"³": { key: "Backquote", shift: true },
|
"³": { key: "Backquote", shift: true },
|
||||||
"&": { key: "Digit1" },
|
"&": { key: "Digit1" },
|
||||||
@@ -167,28 +165,3 @@ export const chars = {
|
|||||||
Enter: { key: "Enter" },
|
Enter: { key: "Enter" },
|
||||||
Tab: { key: "Tab" },
|
Tab: { key: "Tab" },
|
||||||
} as Record<string, KeyCombo>;
|
} as Record<string, KeyCombo>;
|
||||||
|
|
||||||
const fr_BE_keyDisplayMap = {
|
|
||||||
...keyDisplayMap,
|
|
||||||
KeyA: "q",
|
|
||||||
KeyQ: "a",
|
|
||||||
KeyW: "z",
|
|
||||||
KeyZ: "w",
|
|
||||||
Semicolon: "m",
|
|
||||||
KeyM: ",",
|
|
||||||
"(KeyA)": "Q",
|
|
||||||
"(KeyQ)": "A",
|
|
||||||
"(KeyW)": "Z",
|
|
||||||
"(KeyZ)": "W",
|
|
||||||
"(Semicolon)": "M",
|
|
||||||
"(KeyM)": "?",
|
|
||||||
} as Record<string, string>;
|
|
||||||
|
|
||||||
export const fr_BE: KeyboardLayout = {
|
|
||||||
isoCode,
|
|
||||||
name,
|
|
||||||
chars,
|
|
||||||
keyDisplayMap: fr_BE_keyDisplayMap,
|
|
||||||
modifierDisplayMap,
|
|
||||||
virtualKeyboard,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
import { KeyCombo } from "../keyboardLayouts"
|
||||||
import { chars as chars_de_CH, de_CH_keyDisplayMap } from "./de_CH"
|
|
||||||
import { modifierDisplayMap, virtualKeyboard } from "./en_US"
|
|
||||||
|
|
||||||
const name = "Français de Suisse";
|
import { chars as chars_de_CH } from "./de_CH"
|
||||||
const isoCode = "fr-CH";
|
|
||||||
|
export const name = "Français de Suisse";
|
||||||
|
|
||||||
export const chars = {
|
export const chars = {
|
||||||
...chars_de_CH,
|
...chars_de_CH,
|
||||||
@@ -14,22 +13,3 @@ export const chars = {
|
|||||||
"à": { key: "Quote" },
|
"à": { key: "Quote" },
|
||||||
"ä": { key: "Quote", shift: true },
|
"ä": { key: "Quote", shift: true },
|
||||||
} as Record<string, KeyCombo>;
|
} as Record<string, KeyCombo>;
|
||||||
|
|
||||||
const fr_CH_keyDisplayMap = {
|
|
||||||
...de_CH_keyDisplayMap,
|
|
||||||
BracketLeft: "è",
|
|
||||||
"(BracketLeft)": "ü",
|
|
||||||
Semicolon: "é",
|
|
||||||
"(Semicolon)": "ö",
|
|
||||||
Quote: "à",
|
|
||||||
"(Quote)": "ä",
|
|
||||||
} as Record<string, string>;
|
|
||||||
|
|
||||||
export const fr_CH: KeyboardLayout = {
|
|
||||||
isoCode,
|
|
||||||
name,
|
|
||||||
chars,
|
|
||||||
keyDisplayMap: fr_CH_keyDisplayMap,
|
|
||||||
modifierDisplayMap,
|
|
||||||
virtualKeyboard,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
import { KeyCombo } from "../keyboardLayouts"
|
||||||
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
|
|
||||||
|
|
||||||
const name = "Français";
|
export const name = "Français";
|
||||||
const isoCode = "fr-FR";
|
|
||||||
|
|
||||||
const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
|
const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
|
||||||
const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
|
const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
|
||||||
@@ -139,28 +137,3 @@ export const chars = {
|
|||||||
Enter: { key: "Enter" },
|
Enter: { key: "Enter" },
|
||||||
Tab: { key: "Tab" },
|
Tab: { key: "Tab" },
|
||||||
} as Record<string, KeyCombo>;
|
} as Record<string, KeyCombo>;
|
||||||
|
|
||||||
const fr_FR_keyDisplayMap = {
|
|
||||||
...keyDisplayMap,
|
|
||||||
KeyA: "q",
|
|
||||||
KeyQ: "a",
|
|
||||||
KeyW: "z",
|
|
||||||
KeyZ: "w",
|
|
||||||
Semicolon: "m",
|
|
||||||
KeyM: ",",
|
|
||||||
"(KeyA)": "Q",
|
|
||||||
"(KeyQ)": "A",
|
|
||||||
"(KeyW)": "Z",
|
|
||||||
"(KeyZ)": "W",
|
|
||||||
"(Semicolon)": "M",
|
|
||||||
"(KeyM)": "?",
|
|
||||||
} as Record<string, string>;
|
|
||||||
|
|
||||||
export const fr_FR: KeyboardLayout = {
|
|
||||||
isoCode,
|
|
||||||
name,
|
|
||||||
chars,
|
|
||||||
keyDisplayMap: fr_FR_keyDisplayMap,
|
|
||||||
modifierDisplayMap,
|
|
||||||
virtualKeyboard,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts";
|
|
||||||
import { en_US } from "./en_US";
|
|
||||||
|
|
||||||
const name = "Magyar";
|
|
||||||
const isoCode = "hu-HU";
|
|
||||||
|
|
||||||
const keyAcute: KeyCombo = { key: "Digit9", altRight: true };
|
|
||||||
const keyDoubleAcute: KeyCombo = { key: "Equal", shift: true };
|
|
||||||
const keyTrema: KeyCombo = { key: "Equal", altRight: true };
|
|
||||||
|
|
||||||
const chars = {
|
|
||||||
A: { key: "KeyA", shift: true },
|
|
||||||
Á: { key: "Semicolon", shift: true, accentKey: keyAcute },
|
|
||||||
B: { key: "KeyB", shift: true },
|
|
||||||
C: { key: "KeyC", shift: true },
|
|
||||||
D: { key: "KeyD", shift: true },
|
|
||||||
E: { key: "KeyE", shift: true },
|
|
||||||
É: { key: "Quote", shift: true, accentKey: keyAcute },
|
|
||||||
F: { key: "KeyF", shift: true },
|
|
||||||
G: { key: "KeyG", shift: true },
|
|
||||||
H: { key: "KeyH", shift: true },
|
|
||||||
I: { key: "KeyI", shift: true },
|
|
||||||
Í: { key: "IntlBackslash", shift: true, accentKey: keyAcute },
|
|
||||||
J: { key: "KeyJ", shift: true },
|
|
||||||
K: { key: "KeyK", shift: true },
|
|
||||||
L: { key: "KeyL", shift: true },
|
|
||||||
M: { key: "KeyM", shift: true },
|
|
||||||
N: { key: "KeyN", shift: true },
|
|
||||||
O: { key: "KeyO", shift: true },
|
|
||||||
Ó: { key: "BracketLeft", shift: true, accentKey: keyAcute },
|
|
||||||
Ö: { key: "Minus", shift: true, accentKey: keyTrema },
|
|
||||||
Ő: { key: "BracketRight", shift: true, accentKey: keyDoubleAcute },
|
|
||||||
P: { key: "KeyP", shift: true },
|
|
||||||
Q: { key: "KeyQ", shift: true },
|
|
||||||
R: { key: "KeyR", shift: true },
|
|
||||||
S: { key: "KeyS", shift: true },
|
|
||||||
T: { key: "KeyT", shift: true },
|
|
||||||
U: { key: "KeyU", shift: true },
|
|
||||||
Ú: { key: "Backslash", shift: true, accentKey: keyAcute },
|
|
||||||
Ü: { key: "Equal", shift: true, accentKey: keyTrema },
|
|
||||||
Ű: { key: "Backquote", shift: true, accentKey: keyDoubleAcute },
|
|
||||||
V: { key: "KeyV", shift: true },
|
|
||||||
W: { key: "KeyW", shift: true },
|
|
||||||
X: { key: "KeyX", shift: true },
|
|
||||||
Y: { key: "KeyZ", shift: true },
|
|
||||||
Z: { key: "KeyY", shift: true },
|
|
||||||
a: { key: "KeyA" },
|
|
||||||
á: { key: "Semicolon", accentKey: keyAcute },
|
|
||||||
b: { key: "KeyB" },
|
|
||||||
c: { key: "KeyC" },
|
|
||||||
d: { key: "KeyD" },
|
|
||||||
e: { key: "KeyE" },
|
|
||||||
é: { key: "Quote", accentKey: keyAcute },
|
|
||||||
f: { key: "KeyF" },
|
|
||||||
g: { key: "KeyG" },
|
|
||||||
h: { key: "KeyH" },
|
|
||||||
i: { key: "KeyI" },
|
|
||||||
í: { key: "IntlBackslash", accentKey: keyAcute },
|
|
||||||
j: { key: "KeyJ" },
|
|
||||||
k: { key: "KeyK" },
|
|
||||||
l: { key: "KeyL" },
|
|
||||||
m: { key: "KeyM" },
|
|
||||||
n: { key: "KeyN" },
|
|
||||||
o: { key: "KeyO" },
|
|
||||||
ó: { key: "BracketLeft", accentKey: keyAcute },
|
|
||||||
ö: { key: "Minus", accentKey: keyTrema },
|
|
||||||
ő: { key: "BracketRight", accentKey: keyDoubleAcute },
|
|
||||||
p: { key: "KeyP" },
|
|
||||||
q: { key: "KeyQ" },
|
|
||||||
r: { key: "KeyR" },
|
|
||||||
s: { key: "KeyS" },
|
|
||||||
t: { key: "KeyT" },
|
|
||||||
u: { key: "KeyU" },
|
|
||||||
ú: { key: "Backslash", accentKey: keyAcute },
|
|
||||||
ü: { key: "Equal", accentKey: keyTrema },
|
|
||||||
ű: { key: "Backquote", accentKey: keyDoubleAcute },
|
|
||||||
v: { key: "KeyV" },
|
|
||||||
w: { key: "KeyW" },
|
|
||||||
x: { key: "KeyX" },
|
|
||||||
y: { key: "KeyZ" },
|
|
||||||
z: { key: "KeyY" },
|
|
||||||
|
|
||||||
// Numbers and top row symbols
|
|
||||||
0: { key: "Digit0" },
|
|
||||||
"§": { key: "Digit0", shift: true },
|
|
||||||
1: { key: "Digit1" },
|
|
||||||
"'": { key: "Digit1", shift: true },
|
|
||||||
2: { key: "Digit2" },
|
|
||||||
'"': { key: "Digit2", shift: true },
|
|
||||||
3: { key: "Digit3" },
|
|
||||||
"+": { key: "Digit3", shift: true },
|
|
||||||
4: { key: "Digit4" },
|
|
||||||
"!": { key: "Digit4", shift: true },
|
|
||||||
5: { key: "Digit5" },
|
|
||||||
"%": { key: "Digit5", shift: true },
|
|
||||||
6: { key: "Digit6" },
|
|
||||||
"/": { key: "Digit6", shift: true },
|
|
||||||
7: { key: "Digit7" },
|
|
||||||
"=": { key: "Digit7", shift: true },
|
|
||||||
8: { key: "Digit8" },
|
|
||||||
"(": { key: "Digit8", shift: true },
|
|
||||||
9: { key: "Digit9" },
|
|
||||||
")": { key: "Digit9", shift: true },
|
|
||||||
|
|
||||||
// AltGr symbols
|
|
||||||
"~": { key: "Digit1", altRight: true },
|
|
||||||
ˇ: { key: "Digit2", altRight: true },
|
|
||||||
"^": { key: "Digit3", altRight: true },
|
|
||||||
"˘": { key: "Digit4", altRight: true },
|
|
||||||
"°": { key: "Digit5", altRight: true },
|
|
||||||
"˛": { key: "Digit6", altRight: true },
|
|
||||||
"`": { key: "Digit7", altRight: true },
|
|
||||||
"˙": { key: "Digit8", altRight: true },
|
|
||||||
"´": { key: "Digit9", altRight: true },
|
|
||||||
"˝": { key: "Digit0", altRight: true },
|
|
||||||
"„": { key: "KeyO", altRight: true },
|
|
||||||
"\\": { key: "KeyQ", altRight: true },
|
|
||||||
"|": { key: "KeyW", altRight: true },
|
|
||||||
"€": { key: "KeyU", altRight: true },
|
|
||||||
đ: { key: "KeyS", altRight: true },
|
|
||||||
"[": { key: "KeyF", altRight: true },
|
|
||||||
"]": { key: "KeyG", altRight: true },
|
|
||||||
ß: { key: "Semicolon", altRight: true },
|
|
||||||
$: { key: "Quote", altRight: true },
|
|
||||||
"¤": { key: "Backquote", altRight: true },
|
|
||||||
"@": { key: "KeyV", altRight: true },
|
|
||||||
"{": { key: "KeyB", altRight: true },
|
|
||||||
"}": { key: "KeyN", altRight: true },
|
|
||||||
"<": { key: "IntlBackslash", altRight: true },
|
|
||||||
">": { key: "KeyZ", altRight: true },
|
|
||||||
"#": { key: "KeyX", altRight: true },
|
|
||||||
"&": { key: "KeyC", altRight: true },
|
|
||||||
";": { key: "Comma", altRight: true },
|
|
||||||
"*": { key: "Period", altRight: true },
|
|
||||||
"÷": { key: "BracketRight", altRight: true },
|
|
||||||
"×": { key: "Backslash", altRight: true },
|
|
||||||
|
|
||||||
// Punctuation
|
|
||||||
",": { key: "Comma" },
|
|
||||||
"?": { key: "Comma", shift: true },
|
|
||||||
".": { key: "Period" },
|
|
||||||
":": { key: "Period", shift: true },
|
|
||||||
"-": { key: "Slash" },
|
|
||||||
_: { key: "Slash", shift: true },
|
|
||||||
" ": { key: "Space" },
|
|
||||||
"\n": { key: "Enter" },
|
|
||||||
Enter: { key: "Enter" },
|
|
||||||
Tab: { key: "Tab" },
|
|
||||||
} as Record<string, KeyCombo>;
|
|
||||||
|
|
||||||
const keyDisplayMap = {
|
|
||||||
...en_US.keyDisplayMap,
|
|
||||||
Digit0: "0",
|
|
||||||
Backquote: "ű",
|
|
||||||
Minus: "ö",
|
|
||||||
Equal: "ü",
|
|
||||||
BracketLeft: "ó",
|
|
||||||
BracketRight: "ő",
|
|
||||||
Semicolon: "á",
|
|
||||||
Quote: "é",
|
|
||||||
Backslash: "ú",
|
|
||||||
IntlBackslash: "í",
|
|
||||||
KeyY: "Z",
|
|
||||||
KeyZ: "Y",
|
|
||||||
} as Record<string, string>;
|
|
||||||
|
|
||||||
export const hu_HU: KeyboardLayout = {
|
|
||||||
isoCode,
|
|
||||||
name,
|
|
||||||
chars,
|
|
||||||
keyDisplayMap,
|
|
||||||
modifierDisplayMap: {
|
|
||||||
...en_US.modifierDisplayMap,
|
|
||||||
altRight: "AltGr",
|
|
||||||
},
|
|
||||||
virtualKeyboard: en_US.virtualKeyboard,
|
|
||||||
};
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
import { KeyCombo } from "../keyboardLayouts"
|
||||||
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
|
|
||||||
|
|
||||||
const name = "Italiano";
|
export const name = "Italiano";
|
||||||
const isoCode = "it-IT";
|
|
||||||
|
|
||||||
export const chars = {
|
export const chars = {
|
||||||
A: { key: "KeyA", shift: true },
|
A: { key: "KeyA", shift: true },
|
||||||
@@ -113,12 +111,3 @@ export const chars = {
|
|||||||
Enter: { key: "Enter" },
|
Enter: { key: "Enter" },
|
||||||
Tab: { key: "Tab" },
|
Tab: { key: "Tab" },
|
||||||
} as Record<string, KeyCombo>;
|
} as Record<string, KeyCombo>;
|
||||||
|
|
||||||
export const it_IT: KeyboardLayout = {
|
|
||||||
isoCode,
|
|
||||||
name,
|
|
||||||
chars,
|
|
||||||
keyDisplayMap,
|
|
||||||
modifierDisplayMap,
|
|
||||||
virtualKeyboard,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts";
|
|
||||||
|
|
||||||
import { en_US } from "./en_US";
|
|
||||||
|
|
||||||
const name = "Japanese";
|
|
||||||
const isoCode = "ja-JP";
|
|
||||||
|
|
||||||
// NOTE:
|
|
||||||
// This layout is primarily implemented with primarily targets Windows/Linux in mind on common JIS 106/109 keyboards.
|
|
||||||
// Across Windows, Linux, and macOS, there are small but important differences in:
|
|
||||||
// - how backslash ("\\") vs yen ("¥") are produced / interpreted, and
|
|
||||||
// - how Japanese IME mode switching keys behave (e.g. Henkan/Muhenkan/KatakanaHiragana).
|
|
||||||
//
|
|
||||||
// For Windows/Linux friendliness, we intentionally map both "\\" and "¥" to the Yen key,
|
|
||||||
// since many environments/applications render the Yen key as a backslash.
|
|
||||||
//
|
|
||||||
// TODO:
|
|
||||||
// If macOS-specific behavior is required, consider adding a dedicated macOS JIS layout
|
|
||||||
// (e.g. ja_JP_mac) and adjust mappings (often mapping "\\" to Backslash instead of Yen),
|
|
||||||
// plus any IME-key semantics differences as needed.
|
|
||||||
|
|
||||||
export const chars = {
|
|
||||||
...en_US.chars,
|
|
||||||
'"': { key: "Digit2", shift: true },
|
|
||||||
"&": { key: "Digit6", shift: true },
|
|
||||||
"'": { key: "Digit7", shift: true },
|
|
||||||
"(": { key: "Digit8", shift: true },
|
|
||||||
")": { key: "Digit9", shift: true },
|
|
||||||
"=": { key: "Minus", shift: true },
|
|
||||||
"^": { key: "Equal" },
|
|
||||||
"~": { key: "Equal", shift: true },
|
|
||||||
"\\": { key: "Yen" },
|
|
||||||
"¥": { key: "Yen" },
|
|
||||||
"|": { key: "Yen", shift: true },
|
|
||||||
"@": { key: "BracketLeft" },
|
|
||||||
"`": { key: "BracketLeft", shift: true },
|
|
||||||
"[": { key: "BracketRight" },
|
|
||||||
"{": { key: "BracketRight", shift: true },
|
|
||||||
";": { key: "Semicolon" },
|
|
||||||
"+": { key: "Semicolon", shift: true },
|
|
||||||
":": { key: "Quote" },
|
|
||||||
"*": { key: "Quote", shift: true },
|
|
||||||
"]": { key: "Backslash" },
|
|
||||||
"}": { key: "Backslash", shift: true },
|
|
||||||
_: { key: "KeyRO", shift: true },
|
|
||||||
} as Record<string, KeyCombo>;
|
|
||||||
|
|
||||||
// NOTE:
|
|
||||||
// We intentionally avoid providing Hiragana glyph labels on keycaps in the UI.
|
|
||||||
// Only about 5.1% of users typed with Kana input as of 2015; thus Kana legends are
|
|
||||||
// generally omitted to reduce visual clutter while keeping IME-related keys functional
|
|
||||||
// (Henkan/Muhenkan/KatakanaHiragana) for users who need them.
|
|
||||||
// Source: https://ja.wikipedia.org/wiki/%E3%81%8B%E3%81%AA%E5%85%A5%E5%8A%9B#%E3%81%8B%E3%81%AA%E5%85%A5%E5%8A%9B%E3%81%AE%E5%88%A9%E7%94%A8%E7%8A%B6%E6%B3%81
|
|
||||||
export const keyDisplayMap: Record<string, string> = {
|
|
||||||
...en_US.keyDisplayMap,
|
|
||||||
"(Digit2)": '"',
|
|
||||||
"(Digit6)": "&",
|
|
||||||
"(Digit7)": "'",
|
|
||||||
"(Digit8)": "(",
|
|
||||||
"(Digit9)": ")",
|
|
||||||
"(Minus)": "=",
|
|
||||||
Equal: "^",
|
|
||||||
"(Equal)": "~",
|
|
||||||
Yen: "¥",
|
|
||||||
"(Yen)": "|",
|
|
||||||
KeyRO: "\\",
|
|
||||||
"(KeyRO)": "_",
|
|
||||||
Henkan: "変換",
|
|
||||||
Muhenkan: "無変換",
|
|
||||||
KatakanaHiragana: "ひらがな",
|
|
||||||
Backquote: "半角/全角",
|
|
||||||
"(KatakanaHiragana)": "ローマ字",
|
|
||||||
BracketLeft: "@",
|
|
||||||
"(BracketLeft)": "`",
|
|
||||||
BracketRight: "[",
|
|
||||||
"(BracketRight)": "{",
|
|
||||||
Semicolon: ";",
|
|
||||||
"(Semicolon)": "+",
|
|
||||||
Quote: ":",
|
|
||||||
"(Quote)": "*",
|
|
||||||
Backslash: "]",
|
|
||||||
"(Backslash)": "}",
|
|
||||||
ContextMenu: "Menu",
|
|
||||||
|
|
||||||
// UI-only notes:
|
|
||||||
// - Keep a placeholder label for shifted Digit0 to avoid a "missing" keycap in the UI.
|
|
||||||
// - Use "⏎" to hint at the tall, JIS/ISO-style L-shaped Enter key in the UI,
|
|
||||||
// while internally representing it with two virtual buttons.
|
|
||||||
"(Digit0)": " ",
|
|
||||||
"(Enter)": "⏎",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const virtualKeyboard = {
|
|
||||||
...en_US.virtualKeyboard,
|
|
||||||
main: {
|
|
||||||
default: [
|
|
||||||
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
|
|
||||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
|
||||||
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Yen Backspace",
|
|
||||||
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Enter",
|
|
||||||
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Backslash (Enter)",
|
|
||||||
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash KeyRO ShiftRight",
|
|
||||||
"ControlLeft MetaLeft AltLeft Muhenkan Space Henkan KatakanaHiragana AltRight MetaRight ContextMenu ControlRight",
|
|
||||||
],
|
|
||||||
shift: [
|
|
||||||
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
|
|
||||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
|
||||||
"Backquote (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Yen) (Backspace)",
|
|
||||||
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) Enter",
|
|
||||||
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) (Backslash) (Enter)",
|
|
||||||
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) (KeyRO) ShiftRight",
|
|
||||||
"ControlLeft MetaLeft AltLeft Muhenkan Space Henkan (KatakanaHiragana) AltRight MetaRight ContextMenu ControlRight",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ja_JP: KeyboardLayout = {
|
|
||||||
isoCode,
|
|
||||||
name,
|
|
||||||
chars,
|
|
||||||
keyDisplayMap,
|
|
||||||
modifierDisplayMap: en_US.modifierDisplayMap,
|
|
||||||
virtualKeyboard,
|
|
||||||
};
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
import { KeyCombo } from "../keyboardLayouts"
|
||||||
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
|
|
||||||
|
|
||||||
const name = "Norsk bokmål";
|
export const name = "Norsk bokmål";
|
||||||
const isoCode = "nb-NO";
|
|
||||||
|
|
||||||
const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
|
const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
|
||||||
const keyAcute = { key: "Equal", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
|
const keyAcute = { key: "Equal", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
|
||||||
@@ -60,8 +58,8 @@ export const chars = {
|
|||||||
V: { key: "KeyV", shift: true },
|
V: { key: "KeyV", shift: true },
|
||||||
W: { key: "KeyW", shift: true },
|
W: { key: "KeyW", shift: true },
|
||||||
X: { key: "KeyX", shift: true },
|
X: { key: "KeyX", shift: true },
|
||||||
Y: { key: "KeyY", shift: true },
|
Y: { key: "KeyZ", shift: true },
|
||||||
Z: { key: "KeyZ", shift: true },
|
Z: { key: "KeyY", shift: true },
|
||||||
a: { key: "KeyA" },
|
a: { key: "KeyA" },
|
||||||
"ä": { key: "KeyA", accentKey: keyTrema },
|
"ä": { key: "KeyA", accentKey: keyTrema },
|
||||||
"á": { key: "KeyA", accentKey: keyAcute },
|
"á": { key: "KeyA", accentKey: keyAcute },
|
||||||
@@ -112,8 +110,8 @@ export const chars = {
|
|||||||
v: { key: "KeyV" },
|
v: { key: "KeyV" },
|
||||||
w: { key: "KeyW" },
|
w: { key: "KeyW" },
|
||||||
x: { key: "KeyX" },
|
x: { key: "KeyX" },
|
||||||
y: { key: "KeyY" },
|
y: { key: "KeyZ" },
|
||||||
z: { key: "KeyZ" },
|
z: { key: "KeyY" },
|
||||||
"|": { key: "Backquote" },
|
"|": { key: "Backquote" },
|
||||||
"§": { key: "Backquote", shift: true },
|
"§": { key: "Backquote", shift: true },
|
||||||
1: { key: "Digit1" },
|
1: { key: "Digit1" },
|
||||||
@@ -167,12 +165,3 @@ export const chars = {
|
|||||||
Enter: { key: "Enter" },
|
Enter: { key: "Enter" },
|
||||||
Tab: { key: "Tab" },
|
Tab: { key: "Tab" },
|
||||||
} as Record<string, KeyCombo>;
|
} as Record<string, KeyCombo>;
|
||||||
|
|
||||||
export const nb_NO: KeyboardLayout = {
|
|
||||||
isoCode,
|
|
||||||
name,
|
|
||||||
chars,
|
|
||||||
keyDisplayMap,
|
|
||||||
modifierDisplayMap,
|
|
||||||
virtualKeyboard,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts";
|
|
||||||
|
|
||||||
import { en_US, chars as en_US_chars } from "./en_US";
|
|
||||||
|
|
||||||
const name = "Polski";
|
|
||||||
const isoCode = "pl-PL";
|
|
||||||
|
|
||||||
// Polish Programmer layout (kbdpl1): QWERTY + AltGr diacritics, no dead keys
|
|
||||||
const chars: Record<string, KeyCombo> = {
|
|
||||||
...en_US_chars,
|
|
||||||
// lowercase diacritics (AltGr + letter)
|
|
||||||
ą: { key: "KeyA", altRight: true },
|
|
||||||
ć: { key: "KeyC", altRight: true },
|
|
||||||
ę: { key: "KeyE", altRight: true },
|
|
||||||
ł: { key: "KeyL", altRight: true },
|
|
||||||
ń: { key: "KeyN", altRight: true },
|
|
||||||
ó: { key: "KeyO", altRight: true },
|
|
||||||
ś: { key: "KeyS", altRight: true },
|
|
||||||
ż: { key: "KeyZ", altRight: true },
|
|
||||||
ź: { key: "KeyX", altRight: true },
|
|
||||||
// uppercase diacritics (Shift + AltGr + letter)
|
|
||||||
Ą: { key: "KeyA", shift: true, altRight: true },
|
|
||||||
Ć: { key: "KeyC", shift: true, altRight: true },
|
|
||||||
Ę: { key: "KeyE", shift: true, altRight: true },
|
|
||||||
Ł: { key: "KeyL", shift: true, altRight: true },
|
|
||||||
Ń: { key: "KeyN", shift: true, altRight: true },
|
|
||||||
Ó: { key: "KeyO", shift: true, altRight: true },
|
|
||||||
Ś: { key: "KeyS", shift: true, altRight: true },
|
|
||||||
Ż: { key: "KeyZ", shift: true, altRight: true },
|
|
||||||
Ź: { key: "KeyX", shift: true, altRight: true },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const pl_PL: KeyboardLayout = {
|
|
||||||
isoCode: isoCode,
|
|
||||||
name: name,
|
|
||||||
chars: chars,
|
|
||||||
keyDisplayMap: en_US.keyDisplayMap,
|
|
||||||
modifierDisplayMap: en_US.modifierDisplayMap,
|
|
||||||
virtualKeyboard: en_US.virtualKeyboard,
|
|
||||||
};
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts";
|
|
||||||
|
|
||||||
import { en_US } from "./en_US"; // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
|
|
||||||
|
|
||||||
const name = "Português";
|
|
||||||
const isoCode = "pt-PT";
|
|
||||||
|
|
||||||
// Dead keys
|
|
||||||
const keyAcute: KeyCombo = { key: "BracketRight" }; // ´ (dead) on SC 1B base
|
|
||||||
const keyGrave: KeyCombo = { key: "BracketRight", shift: true }; // ` (dead) on SC 1B shift
|
|
||||||
const keyTrema: KeyCombo = { key: "BracketLeft", altRight: true }; // ¨ (dead) on SC 1A AltGr
|
|
||||||
const keyTilde: KeyCombo = { key: "Backslash" }; // ~ (dead) on SC 2B base
|
|
||||||
const keyHat: KeyCombo = { key: "Backslash", shift: true }; // ^ (dead) on SC 2B shift
|
|
||||||
|
|
||||||
const chars = {
|
|
||||||
// Uppercase letters
|
|
||||||
A: { key: "KeyA", shift: true },
|
|
||||||
Á: { key: "KeyA", shift: true, accentKey: keyAcute },
|
|
||||||
À: { key: "KeyA", shift: true, accentKey: keyGrave },
|
|
||||||
Ä: { key: "KeyA", shift: true, accentKey: keyTrema },
|
|
||||||
Ã: { key: "KeyA", shift: true, accentKey: keyTilde },
|
|
||||||
Â: { key: "KeyA", shift: true, accentKey: keyHat },
|
|
||||||
B: { key: "KeyB", shift: true },
|
|
||||||
C: { key: "KeyC", shift: true },
|
|
||||||
D: { key: "KeyD", shift: true },
|
|
||||||
E: { key: "KeyE", shift: true },
|
|
||||||
É: { key: "KeyE", shift: true, accentKey: keyAcute },
|
|
||||||
È: { key: "KeyE", shift: true, accentKey: keyGrave },
|
|
||||||
Ë: { key: "KeyE", shift: true, accentKey: keyTrema },
|
|
||||||
Ê: { key: "KeyE", shift: true, accentKey: keyHat },
|
|
||||||
F: { key: "KeyF", shift: true },
|
|
||||||
G: { key: "KeyG", shift: true },
|
|
||||||
H: { key: "KeyH", shift: true },
|
|
||||||
I: { key: "KeyI", shift: true },
|
|
||||||
Í: { key: "KeyI", shift: true, accentKey: keyAcute },
|
|
||||||
Ì: { key: "KeyI", shift: true, accentKey: keyGrave },
|
|
||||||
Ï: { key: "KeyI", shift: true, accentKey: keyTrema },
|
|
||||||
Î: { key: "KeyI", shift: true, accentKey: keyHat },
|
|
||||||
J: { key: "KeyJ", shift: true },
|
|
||||||
K: { key: "KeyK", shift: true },
|
|
||||||
L: { key: "KeyL", shift: true },
|
|
||||||
M: { key: "KeyM", shift: true },
|
|
||||||
N: { key: "KeyN", shift: true },
|
|
||||||
Ñ: { key: "KeyN", shift: true, accentKey: keyTilde },
|
|
||||||
O: { key: "KeyO", shift: true },
|
|
||||||
Ó: { key: "KeyO", shift: true, accentKey: keyAcute },
|
|
||||||
Ò: { key: "KeyO", shift: true, accentKey: keyGrave },
|
|
||||||
Ö: { key: "KeyO", shift: true, accentKey: keyTrema },
|
|
||||||
Õ: { key: "KeyO", shift: true, accentKey: keyTilde },
|
|
||||||
Ô: { key: "KeyO", shift: true, accentKey: keyHat },
|
|
||||||
P: { key: "KeyP", shift: true },
|
|
||||||
Q: { key: "KeyQ", shift: true },
|
|
||||||
R: { key: "KeyR", shift: true },
|
|
||||||
S: { key: "KeyS", shift: true },
|
|
||||||
T: { key: "KeyT", shift: true },
|
|
||||||
U: { key: "KeyU", shift: true },
|
|
||||||
Ú: { key: "KeyU", shift: true, accentKey: keyAcute },
|
|
||||||
Ù: { key: "KeyU", shift: true, accentKey: keyGrave },
|
|
||||||
Ü: { key: "KeyU", shift: true, accentKey: keyTrema },
|
|
||||||
Û: { key: "KeyU", shift: true, accentKey: keyHat },
|
|
||||||
V: { key: "KeyV", shift: true },
|
|
||||||
W: { key: "KeyW", shift: true },
|
|
||||||
X: { key: "KeyX", shift: true },
|
|
||||||
Y: { key: "KeyY", shift: true },
|
|
||||||
Ý: { key: "KeyY", shift: true, accentKey: keyAcute },
|
|
||||||
Z: { key: "KeyZ", shift: true },
|
|
||||||
|
|
||||||
// Lowercase letters
|
|
||||||
a: { key: "KeyA" },
|
|
||||||
á: { key: "KeyA", accentKey: keyAcute },
|
|
||||||
à: { key: "KeyA", accentKey: keyGrave },
|
|
||||||
ä: { key: "KeyA", accentKey: keyTrema },
|
|
||||||
ã: { key: "KeyA", accentKey: keyTilde },
|
|
||||||
â: { key: "KeyA", accentKey: keyHat },
|
|
||||||
b: { key: "KeyB" },
|
|
||||||
c: { key: "KeyC" },
|
|
||||||
d: { key: "KeyD" },
|
|
||||||
e: { key: "KeyE" },
|
|
||||||
é: { key: "KeyE", accentKey: keyAcute },
|
|
||||||
è: { key: "KeyE", accentKey: keyGrave },
|
|
||||||
ë: { key: "KeyE", accentKey: keyTrema },
|
|
||||||
ê: { key: "KeyE", accentKey: keyHat },
|
|
||||||
"€": { key: "KeyE", altRight: true },
|
|
||||||
f: { key: "KeyF" },
|
|
||||||
g: { key: "KeyG" },
|
|
||||||
h: { key: "KeyH" },
|
|
||||||
i: { key: "KeyI" },
|
|
||||||
í: { key: "KeyI", accentKey: keyAcute },
|
|
||||||
ì: { key: "KeyI", accentKey: keyGrave },
|
|
||||||
ï: { key: "KeyI", accentKey: keyTrema },
|
|
||||||
î: { key: "KeyI", accentKey: keyHat },
|
|
||||||
j: { key: "KeyJ" },
|
|
||||||
k: { key: "KeyK" },
|
|
||||||
l: { key: "KeyL" },
|
|
||||||
m: { key: "KeyM" },
|
|
||||||
n: { key: "KeyN" },
|
|
||||||
ñ: { key: "KeyN", accentKey: keyTilde },
|
|
||||||
o: { key: "KeyO" },
|
|
||||||
ó: { key: "KeyO", accentKey: keyAcute },
|
|
||||||
ò: { key: "KeyO", accentKey: keyGrave },
|
|
||||||
ö: { key: "KeyO", accentKey: keyTrema },
|
|
||||||
õ: { key: "KeyO", accentKey: keyTilde },
|
|
||||||
ô: { key: "KeyO", accentKey: keyHat },
|
|
||||||
p: { key: "KeyP" },
|
|
||||||
q: { key: "KeyQ" },
|
|
||||||
r: { key: "KeyR" },
|
|
||||||
s: { key: "KeyS" },
|
|
||||||
t: { key: "KeyT" },
|
|
||||||
u: { key: "KeyU" },
|
|
||||||
ú: { key: "KeyU", accentKey: keyAcute },
|
|
||||||
ù: { key: "KeyU", accentKey: keyGrave },
|
|
||||||
ü: { key: "KeyU", accentKey: keyTrema },
|
|
||||||
û: { key: "KeyU", accentKey: keyHat },
|
|
||||||
v: { key: "KeyV" },
|
|
||||||
w: { key: "KeyW" },
|
|
||||||
x: { key: "KeyX" },
|
|
||||||
y: { key: "KeyY" },
|
|
||||||
ý: { key: "KeyY", accentKey: keyAcute },
|
|
||||||
ÿ: { key: "KeyY", accentKey: keyTrema },
|
|
||||||
z: { key: "KeyZ" },
|
|
||||||
|
|
||||||
// SC 29 (OEM_5) → Backquote: \ |
|
|
||||||
"\\": { key: "Backquote" },
|
|
||||||
"|": { key: "Backquote", shift: true },
|
|
||||||
|
|
||||||
// Number row
|
|
||||||
1: { key: "Digit1" },
|
|
||||||
"!": { key: "Digit1", shift: true },
|
|
||||||
2: { key: "Digit2" },
|
|
||||||
'"': { key: "Digit2", shift: true },
|
|
||||||
"@": { key: "Digit2", altRight: true },
|
|
||||||
3: { key: "Digit3" },
|
|
||||||
"#": { key: "Digit3", shift: true },
|
|
||||||
"£": { key: "Digit3", altRight: true },
|
|
||||||
4: { key: "Digit4" },
|
|
||||||
$: { key: "Digit4", shift: true },
|
|
||||||
"§": { key: "Digit4", altRight: true },
|
|
||||||
5: { key: "Digit5" },
|
|
||||||
"%": { key: "Digit5", shift: true },
|
|
||||||
6: { key: "Digit6" },
|
|
||||||
"&": { key: "Digit6", shift: true },
|
|
||||||
7: { key: "Digit7" },
|
|
||||||
"/": { key: "Digit7", shift: true },
|
|
||||||
"{": { key: "Digit7", altRight: true },
|
|
||||||
8: { key: "Digit8" },
|
|
||||||
"(": { key: "Digit8", shift: true },
|
|
||||||
"[": { key: "Digit8", altRight: true },
|
|
||||||
9: { key: "Digit9" },
|
|
||||||
")": { key: "Digit9", shift: true },
|
|
||||||
"]": { key: "Digit9", altRight: true },
|
|
||||||
0: { key: "Digit0" },
|
|
||||||
"=": { key: "Digit0", shift: true },
|
|
||||||
"}": { key: "Digit0", altRight: true },
|
|
||||||
|
|
||||||
// SC 0C (OEM_4) → Minus: ' ?
|
|
||||||
"'": { key: "Minus" },
|
|
||||||
"?": { key: "Minus", shift: true },
|
|
||||||
|
|
||||||
// SC 0D (OEM_6) → Equal: « »
|
|
||||||
"«": { key: "Equal" },
|
|
||||||
"»": { key: "Equal", shift: true },
|
|
||||||
|
|
||||||
// SC 1A (OEM_PLUS) → BracketLeft: + * ¨(dead)
|
|
||||||
"+": { key: "BracketLeft" },
|
|
||||||
"*": { key: "BracketLeft", shift: true },
|
|
||||||
"¨": { key: "BracketLeft", altRight: true, deadKey: true },
|
|
||||||
|
|
||||||
// SC 1B (OEM_1) → BracketRight: ´(dead) `(dead)
|
|
||||||
"´": { key: "BracketRight", deadKey: true },
|
|
||||||
"`": { key: "BracketRight", shift: true, deadKey: true },
|
|
||||||
|
|
||||||
// SC 27 (OEM_3) → Semicolon: ç Ç
|
|
||||||
ç: { key: "Semicolon" },
|
|
||||||
Ç: { key: "Semicolon", shift: true },
|
|
||||||
|
|
||||||
// SC 28 (OEM_7) → Quote: º ª
|
|
||||||
º: { key: "Quote" },
|
|
||||||
ª: { key: "Quote", shift: true },
|
|
||||||
|
|
||||||
// SC 2B (OEM_2) → Backslash: ~(dead) ^(dead)
|
|
||||||
"~": { key: "Backslash", deadKey: true },
|
|
||||||
"^": { key: "Backslash", shift: true, deadKey: true },
|
|
||||||
|
|
||||||
// SC 33-35: Comma, Period, Slash
|
|
||||||
",": { key: "Comma" },
|
|
||||||
";": { key: "Comma", shift: true },
|
|
||||||
".": { key: "Period" },
|
|
||||||
":": { key: "Period", shift: true },
|
|
||||||
"-": { key: "Slash" },
|
|
||||||
_: { key: "Slash", shift: true },
|
|
||||||
|
|
||||||
// SC 56 (OEM_102) → IntlBackslash: < >
|
|
||||||
"<": { key: "IntlBackslash" },
|
|
||||||
">": { key: "IntlBackslash", shift: true },
|
|
||||||
|
|
||||||
" ": { key: "Space" },
|
|
||||||
"\n": { key: "Enter" },
|
|
||||||
Enter: { key: "Enter" },
|
|
||||||
Tab: { key: "Tab" },
|
|
||||||
} as Record<string, KeyCombo>;
|
|
||||||
|
|
||||||
export const pt_PT: KeyboardLayout = {
|
|
||||||
isoCode: isoCode,
|
|
||||||
name: name,
|
|
||||||
chars: chars,
|
|
||||||
keyDisplayMap: en_US.keyDisplayMap,
|
|
||||||
modifierDisplayMap: en_US.modifierDisplayMap,
|
|
||||||
virtualKeyboard: en_US.virtualKeyboard,
|
|
||||||
};
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts";
|
|
||||||
import { en_US } from "./en_US";
|
|
||||||
|
|
||||||
const name = "Русская";
|
|
||||||
const isoCode = "ru-RU";
|
|
||||||
|
|
||||||
export const chars = {
|
|
||||||
...en_US.chars,
|
|
||||||
А: { key: "KeyF", shift: true },
|
|
||||||
Б: { key: "Comma", shift: true },
|
|
||||||
В: { key: "KeyD", shift: true },
|
|
||||||
Г: { key: "KeyU", shift: true },
|
|
||||||
Д: { key: "KeyL", shift: true },
|
|
||||||
Е: { key: "KeyT", shift: true },
|
|
||||||
Ё: { key: "Backquote", shift: true },
|
|
||||||
Ж: { key: "Semicolon", shift: true },
|
|
||||||
З: { key: "KeyP", shift: true },
|
|
||||||
И: { key: "KeyB", shift: true },
|
|
||||||
Й: { key: "KeyQ", shift: true },
|
|
||||||
К: { key: "KeyR", shift: true },
|
|
||||||
Л: { key: "KeyK", shift: true },
|
|
||||||
М: { key: "KeyV", shift: true },
|
|
||||||
Н: { key: "KeyY", shift: true },
|
|
||||||
О: { key: "KeyJ", shift: true },
|
|
||||||
П: { key: "KeyG", shift: true },
|
|
||||||
Р: { key: "KeyH", shift: true },
|
|
||||||
С: { key: "KeyC", shift: true },
|
|
||||||
Т: { key: "KeyN", shift: true },
|
|
||||||
У: { key: "KeyE", shift: true },
|
|
||||||
Ф: { key: "KeyA", shift: true },
|
|
||||||
Х: { key: "BracketLeft", shift: true },
|
|
||||||
Ц: { key: "KeyW", shift: true },
|
|
||||||
Ч: { key: "KeyX", shift: true },
|
|
||||||
Ш: { key: "KeyI", shift: true },
|
|
||||||
Щ: { key: "KeyO", shift: true },
|
|
||||||
Ъ: { key: "BracketRight", shift: true },
|
|
||||||
Ы: { key: "KeyS", shift: true },
|
|
||||||
Ь: { key: "KeyM", shift: true },
|
|
||||||
Э: { key: "Quote", shift: true },
|
|
||||||
Ю: { key: "Period", shift: true },
|
|
||||||
Я: { key: "KeyZ", shift: true },
|
|
||||||
а: { key: "KeyF" },
|
|
||||||
б: { key: "Comma" },
|
|
||||||
в: { key: "KeyD" },
|
|
||||||
г: { key: "KeyU" },
|
|
||||||
д: { key: "KeyL" },
|
|
||||||
е: { key: "KeyT" },
|
|
||||||
ё: { key: "Backquote" },
|
|
||||||
ж: { key: "Semicolon" },
|
|
||||||
з: { key: "KeyP" },
|
|
||||||
и: { key: "KeyB" },
|
|
||||||
й: { key: "KeyQ" },
|
|
||||||
к: { key: "KeyR" },
|
|
||||||
л: { key: "KeyK" },
|
|
||||||
м: { key: "KeyV" },
|
|
||||||
н: { key: "KeyY" },
|
|
||||||
о: { key: "KeyJ" },
|
|
||||||
п: { key: "KeyG" },
|
|
||||||
р: { key: "KeyH" },
|
|
||||||
с: { key: "KeyC" },
|
|
||||||
т: { key: "KeyN" },
|
|
||||||
у: { key: "KeyE" },
|
|
||||||
ф: { key: "KeyA" },
|
|
||||||
х: { key: "BracketLeft" },
|
|
||||||
ц: { key: "KeyW" },
|
|
||||||
ч: { key: "KeyX" },
|
|
||||||
ш: { key: "KeyI" },
|
|
||||||
щ: { key: "KeyO" },
|
|
||||||
ъ: { key: "BracketRight" },
|
|
||||||
ы: { key: "KeyS" },
|
|
||||||
ь: { key: "KeyM" },
|
|
||||||
э: { key: "Quote" },
|
|
||||||
ю: { key: "Period" },
|
|
||||||
я: { key: "KeyZ" },
|
|
||||||
'"': { key: "Digit2", shift: true },
|
|
||||||
"№": { key: "Digit3", shift: true },
|
|
||||||
";": { key: "Digit4", shift: true },
|
|
||||||
":": { key: "Digit6", shift: true },
|
|
||||||
"?": { key: "Digit7", shift: true },
|
|
||||||
".": { key: "Slash" },
|
|
||||||
",": { key: "Slash", shift: true },
|
|
||||||
} as Record<string, KeyCombo>;
|
|
||||||
|
|
||||||
export const keyDisplayMap = {
|
|
||||||
...en_US.keyDisplayMap,
|
|
||||||
KeyF: "а",
|
|
||||||
Comma: "б",
|
|
||||||
KeyD: "в",
|
|
||||||
KeyU: "г",
|
|
||||||
KeyL: "д",
|
|
||||||
KeyT: "е",
|
|
||||||
Backquote: "ё",
|
|
||||||
Semicolon: "ж",
|
|
||||||
KeyP: "з",
|
|
||||||
KeyB: "и",
|
|
||||||
KeyQ: "й",
|
|
||||||
KeyR: "к",
|
|
||||||
KeyK: "л",
|
|
||||||
KeyV: "м",
|
|
||||||
KeyY: "н",
|
|
||||||
KeyJ: "о",
|
|
||||||
KeyG: "п",
|
|
||||||
KeyH: "р",
|
|
||||||
KeyC: "с",
|
|
||||||
KeyN: "т",
|
|
||||||
KeyE: "у",
|
|
||||||
KeyA: "ф",
|
|
||||||
BracketLeft: "х",
|
|
||||||
KeyW: "ц",
|
|
||||||
KeyX: "ч",
|
|
||||||
KeyI: "ш",
|
|
||||||
KeyO: "щ",
|
|
||||||
BracketRight: "ъ",
|
|
||||||
KeyS: "ы",
|
|
||||||
KeyM: "ь",
|
|
||||||
Quote: "э",
|
|
||||||
Period: "ю",
|
|
||||||
KeyZ: "я",
|
|
||||||
Slash: ".",
|
|
||||||
"(KeyF)": "А",
|
|
||||||
"(Comma)": "Б",
|
|
||||||
"(KeyD)": "В",
|
|
||||||
"(KeyU)": "Г",
|
|
||||||
"(KeyL)": "Д",
|
|
||||||
"(KeyT)": "Е",
|
|
||||||
"(Backquote)": "Ё",
|
|
||||||
"(Semicolon)": "Ж",
|
|
||||||
"(KeyP)": "З",
|
|
||||||
"(KeyB)": "И",
|
|
||||||
"(KeyQ)": "Й",
|
|
||||||
"(KeyR)": "К",
|
|
||||||
"(KeyK)": "Л",
|
|
||||||
"(KeyV)": "М",
|
|
||||||
"(KeyY)": "Н",
|
|
||||||
"(KeyJ)": "О",
|
|
||||||
"(KeyG)": "П",
|
|
||||||
"(KeyH)": "Р",
|
|
||||||
"(KeyC)": "С",
|
|
||||||
"(KeyN)": "Т",
|
|
||||||
"(KeyE)": "У",
|
|
||||||
"(KeyA)": "Ф",
|
|
||||||
"(BracketLeft)": "Х",
|
|
||||||
"(KeyW)": "Ц",
|
|
||||||
"(KeyX)": "Ч",
|
|
||||||
"(KeyI)": "Ш",
|
|
||||||
"(KeyO)": "Щ",
|
|
||||||
"(BracketRight)": "Ъ",
|
|
||||||
"(KeyS)": "Ы",
|
|
||||||
"(KeyM)": "Ь",
|
|
||||||
"(Quote)": "Э",
|
|
||||||
"(Period)": "Ю",
|
|
||||||
"(KeyZ)": "Я",
|
|
||||||
"(Digit2)": '"',
|
|
||||||
"(Digit3)": "№",
|
|
||||||
"(Digit4)": ";",
|
|
||||||
"(Digit6)": ":",
|
|
||||||
"(Digit7)": "?",
|
|
||||||
"(Slash)": ",",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const modifierDisplayMap = en_US.modifierDisplayMap;
|
|
||||||
export const virtualKeyboard = en_US.virtualKeyboard;
|
|
||||||
|
|
||||||
export const ru_RU: KeyboardLayout = {
|
|
||||||
isoCode,
|
|
||||||
name,
|
|
||||||
chars,
|
|
||||||
keyDisplayMap,
|
|
||||||
modifierDisplayMap,
|
|
||||||
virtualKeyboard,
|
|
||||||
};
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts";
|
|
||||||
|
|
||||||
import { en_US } from "./en_US"; // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
|
|
||||||
|
|
||||||
const name = "Slovenian";
|
|
||||||
const isoCode = "sl-SI";
|
|
||||||
|
|
||||||
export const chars = {
|
|
||||||
A: { key: "KeyA", shift: true },
|
|
||||||
B: { key: "KeyB", shift: true },
|
|
||||||
C: { key: "KeyC", shift: true },
|
|
||||||
Č: { key: "Semicolon", shift: true },
|
|
||||||
Ć: { key: "Quote", shift: true },
|
|
||||||
D: { key: "KeyD", shift: true },
|
|
||||||
Đ: { key: "BracketRight", shift: true },
|
|
||||||
E: { key: "KeyE", shift: true },
|
|
||||||
F: { key: "KeyF", shift: true },
|
|
||||||
G: { key: "KeyG", shift: true },
|
|
||||||
H: { key: "KeyH", shift: true },
|
|
||||||
I: { key: "KeyI", shift: true },
|
|
||||||
J: { key: "KeyJ", shift: true },
|
|
||||||
K: { key: "KeyK", shift: true },
|
|
||||||
L: { key: "KeyL", shift: true },
|
|
||||||
M: { key: "KeyM", shift: true },
|
|
||||||
N: { key: "KeyN", shift: true },
|
|
||||||
O: { key: "KeyO", shift: true },
|
|
||||||
P: { key: "KeyP", shift: true },
|
|
||||||
Q: { key: "KeyQ", shift: true },
|
|
||||||
R: { key: "KeyR", shift: true },
|
|
||||||
S: { key: "KeyS", shift: true },
|
|
||||||
Š: { key: "BracketLeft", shift: true },
|
|
||||||
T: { key: "KeyT", shift: true },
|
|
||||||
U: { key: "KeyU", shift: true },
|
|
||||||
V: { key: "KeyV", shift: true },
|
|
||||||
W: { key: "KeyW", shift: true },
|
|
||||||
X: { key: "KeyX", shift: true },
|
|
||||||
Y: { key: "KeyZ", shift: true },
|
|
||||||
Z: { key: "KeyY", shift: true },
|
|
||||||
Ž: { key: "Backslash", shift: true },
|
|
||||||
a: { key: "KeyA" },
|
|
||||||
b: { key: "KeyB" },
|
|
||||||
c: { key: "KeyC" },
|
|
||||||
č: { key: "Semicolon" },
|
|
||||||
ć: { key: "Quote" },
|
|
||||||
d: { key: "KeyD" },
|
|
||||||
đ: { key: "BracketRight" },
|
|
||||||
e: { key: "KeyE" },
|
|
||||||
f: { key: "KeyF" },
|
|
||||||
g: { key: "KeyG" },
|
|
||||||
h: { key: "KeyH" },
|
|
||||||
i: { key: "KeyI" },
|
|
||||||
j: { key: "KeyJ" },
|
|
||||||
k: { key: "KeyK" },
|
|
||||||
l: { key: "KeyL" },
|
|
||||||
m: { key: "KeyM" },
|
|
||||||
n: { key: "KeyN" },
|
|
||||||
o: { key: "KeyO" },
|
|
||||||
p: { key: "KeyP" },
|
|
||||||
q: { key: "KeyQ" },
|
|
||||||
r: { key: "KeyR" },
|
|
||||||
s: { key: "KeyS" },
|
|
||||||
š: { key: "BracketLeft" },
|
|
||||||
t: { key: "KeyT" },
|
|
||||||
u: { key: "KeyU" },
|
|
||||||
v: { key: "KeyV" },
|
|
||||||
w: { key: "KeyW" },
|
|
||||||
x: { key: "KeyX" },
|
|
||||||
y: { key: "KeyZ" },
|
|
||||||
z: { key: "KeyY" },
|
|
||||||
ž: { key: "Backslash" },
|
|
||||||
1: { key: "Digit1" },
|
|
||||||
"!": { key: "Digit1", shift: true },
|
|
||||||
2: { key: "Digit2" },
|
|
||||||
'"': { key: "Digit2", shift: true },
|
|
||||||
3: { key: "Digit3" },
|
|
||||||
"#": { key: "Digit3", shift: true },
|
|
||||||
4: { key: "Digit4" },
|
|
||||||
$: { key: "Digit4", shift: true },
|
|
||||||
5: { key: "Digit5" },
|
|
||||||
"%": { key: "Digit5", shift: true },
|
|
||||||
6: { key: "Digit6" },
|
|
||||||
"&": { key: "Digit6", shift: true },
|
|
||||||
7: { key: "Digit7" },
|
|
||||||
"/": { key: "Digit7", shift: true },
|
|
||||||
8: { key: "Digit8" },
|
|
||||||
"(": { key: "Digit8", shift: true },
|
|
||||||
9: { key: "Digit9" },
|
|
||||||
")": { key: "Digit9", shift: true },
|
|
||||||
0: { key: "Digit0" },
|
|
||||||
"=": { key: "Digit0", shift: true },
|
|
||||||
"'": { key: "Minus" },
|
|
||||||
"?": { key: "Minus", shift: true },
|
|
||||||
"+": { key: "Equal" },
|
|
||||||
"*": { key: "Equal", shift: true },
|
|
||||||
|
|
||||||
"<": { key: "IntlBackslash" },
|
|
||||||
">": { key: "IntlBackslash", shift: true },
|
|
||||||
",": { key: "Comma" },
|
|
||||||
";": { key: "Comma", shift: true },
|
|
||||||
".": { key: "Period" },
|
|
||||||
":": { key: "Period", shift: true },
|
|
||||||
"-": { key: "Slash" },
|
|
||||||
_: { key: "Slash", shift: true },
|
|
||||||
|
|
||||||
"~": { key: "Digit1", shift: true },
|
|
||||||
ˇ: { key: "Digit2", shift: true },
|
|
||||||
"^": { key: "Digit3", shift: true },
|
|
||||||
"˘": { key: "Digit4", shift: true },
|
|
||||||
"°": { key: "Digit5", shift: true },
|
|
||||||
"˛": { key: "Digit6", shift: true },
|
|
||||||
"`": { key: "Digit7", shift: true },
|
|
||||||
"˙": { key: "Digit8", shift: true },
|
|
||||||
"´": { key: "Digit9", shift: true },
|
|
||||||
"˝": { key: "Digit0", shift: true },
|
|
||||||
"¨": { key: "Minus", shift: true },
|
|
||||||
"¸": { key: "Equal", shift: true },
|
|
||||||
"\\": { key: "KeyQ", AltGr: true },
|
|
||||||
"|": { key: "KeyW", AltGr: true },
|
|
||||||
"€": { key: "KeyE", AltGr: true },
|
|
||||||
"÷": { key: "BracketLeft", AltGr: true },
|
|
||||||
"×": { key: "BracketRight", AltGr: true },
|
|
||||||
"[": { key: "KeyF", AltGr: true },
|
|
||||||
"]": { key: "KeyG", AltGr: true },
|
|
||||||
ł: { key: "KeyK", AltGr: true },
|
|
||||||
Ł: { key: "KeyL", AltGr: true },
|
|
||||||
ß: { key: "Quote", AltGr: true },
|
|
||||||
"¤": { key: "Backslash", AltGr: true },
|
|
||||||
"@": { key: "KeyV", AltGr: true },
|
|
||||||
"{": { key: "KeyB", AltGr: true },
|
|
||||||
"}": { key: "KeyN", AltGr: true },
|
|
||||||
"§": { key: "KeyM", AltGr: true },
|
|
||||||
// "<": { key: "Comma", AltGr: true }, // Can be typed in two different locations (`IntlBackslash`)
|
|
||||||
// ">": { key: "Period", AltGr: true }, // Can be typed in two different locations (`IntlBackslash+Shift`)
|
|
||||||
|
|
||||||
" ": { key: "Space" },
|
|
||||||
"\n": { key: "Enter" },
|
|
||||||
Enter: { key: "Enter" },
|
|
||||||
Escape: { key: "Escape" },
|
|
||||||
Tab: { key: "Tab" },
|
|
||||||
PrintScreen: { key: "Prt Sc" },
|
|
||||||
SystemRequest: { key: "Prt Sc", shift: true },
|
|
||||||
ScrollLock: { key: "ScrollLock" },
|
|
||||||
Pause: { key: "Pause" },
|
|
||||||
Break: { key: "Pause", shift: true },
|
|
||||||
Insert: { key: "Insert" },
|
|
||||||
Delete: { key: "Delete" },
|
|
||||||
} as Record<string, KeyCombo>;
|
|
||||||
|
|
||||||
const sl_SI_keyDisplayMap = {
|
|
||||||
...en_US.keyDisplayMap,
|
|
||||||
KeyY: "z",
|
|
||||||
KeyZ: "y",
|
|
||||||
"(KeyY)": "Z",
|
|
||||||
"(KeyZ)": "Y",
|
|
||||||
} as Record<string, string>;
|
|
||||||
|
|
||||||
export const sl_SI: KeyboardLayout = {
|
|
||||||
isoCode: isoCode,
|
|
||||||
name: name,
|
|
||||||
chars: chars,
|
|
||||||
keyDisplayMap: sl_SI_keyDisplayMap,
|
|
||||||
modifierDisplayMap: en_US.modifierDisplayMap,
|
|
||||||
virtualKeyboard: en_US.virtualKeyboard,
|
|
||||||
};
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
import { KeyCombo } from "../keyboardLayouts"
|
||||||
import { modifierDisplayMap, keyDisplayMap, virtualKeyboard } from "./en_US"
|
|
||||||
|
|
||||||
const name = "Svenska";
|
export const name = "Svenska";
|
||||||
const isoCode = "sv-SE";
|
|
||||||
|
|
||||||
const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
|
const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
|
||||||
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
|
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
|
||||||
@@ -164,12 +162,3 @@ export const chars = {
|
|||||||
Enter: { key: "Enter" },
|
Enter: { key: "Enter" },
|
||||||
Tab: { key: "Tab" },
|
Tab: { key: "Tab" },
|
||||||
} as Record<string, KeyCombo>;
|
} as Record<string, KeyCombo>;
|
||||||
|
|
||||||
export const sv_SE: KeyboardLayout = {
|
|
||||||
isoCode,
|
|
||||||
name,
|
|
||||||
chars,
|
|
||||||
keyDisplayMap,
|
|
||||||
modifierDisplayMap,
|
|
||||||
virtualKeyboard,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -105,11 +105,6 @@ export const keys = {
|
|||||||
Space: 0x2c,
|
Space: 0x2c,
|
||||||
SystemRequest: 0x9a,
|
SystemRequest: 0x9a,
|
||||||
Tab: 0x2b,
|
Tab: 0x2b,
|
||||||
Yen: 0x89,
|
|
||||||
KeyRO: 0x87,
|
|
||||||
Henkan: 0x8a,
|
|
||||||
Muhenkan: 0x8b,
|
|
||||||
KatakanaHiragana: 0x88,
|
|
||||||
} as Record<string, number>;
|
} as Record<string, number>;
|
||||||
|
|
||||||
export const modifiers = {
|
export const modifiers = {
|
||||||
@@ -233,17 +228,6 @@ export const keyDisplayMap: Record<string, string> = {
|
|||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const latchingKeys = ["CapsLock", "ScrollLock", "NumLock", "MetaLeft", "MetaRight", "Compose", "Kana"];
|
|
||||||
|
|
||||||
export function decodeModifiers(modifier: number) {
|
|
||||||
return {
|
|
||||||
isShiftActive: (modifier & (modifiers.ShiftLeft | modifiers.ShiftRight)) !== 0,
|
|
||||||
isControlActive: (modifier & (modifiers.ControlLeft | modifiers.ControlRight)) !== 0,
|
|
||||||
isAltActive: (modifier & (modifiers.AltLeft | modifiers.AltRight)) !== 0,
|
|
||||||
isMetaActive: (modifier & (modifiers.MetaLeft | modifiers.MetaRight)) !== 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const keyDisplayMap2: Record<string, string> = {
|
export const keyDisplayMap2: Record<string, string> = {
|
||||||
...keyDisplayMap,
|
...keyDisplayMap,
|
||||||
...{
|
...{
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { CloseOutlined } from '@ant-design/icons';
|
|||||||
import { useReactAt } from "i18n-auto-extractor/react";
|
import { useReactAt } from "i18n-auto-extractor/react";
|
||||||
|
|
||||||
import ScrollThrottlingSelect, { Option } from "@components/ScrollThrottlingSelect";
|
import ScrollThrottlingSelect, { Option } from "@components/ScrollThrottlingSelect";
|
||||||
import { layouts, keyboards } from "@/keyboardLayouts";
|
import { layouts } from "@/keyboardLayouts";
|
||||||
import { KeyboardLedSync, useSettingsStore } from "@/hooks/stores";
|
import { KeyboardLedSync, useSettingsStore } from "@/hooks/stores";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
@@ -24,21 +24,11 @@ const KeyboardPanel: React.FC = () => {
|
|||||||
|
|
||||||
const [layoutOptions, setLayoutOptions] = useState<Option[]>();
|
const [layoutOptions, setLayoutOptions] = useState<Option[]>();
|
||||||
const [maxShowCount, setMaxShowCount] = useState(3);
|
const [maxShowCount, setMaxShowCount] = useState(3);
|
||||||
|
|
||||||
const layoutAbbrevMap = useMemo(() => {
|
|
||||||
const map: Record<string, string> = {};
|
|
||||||
keyboards.forEach(kb => {
|
|
||||||
const oldCode = kb.isoCode.replace("-", "_");
|
|
||||||
map[oldCode] = oldCode;
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const curLayoutOptions = (() => {
|
const curLayoutOptions = (() => {
|
||||||
const options = Object.entries(layouts).map(([code, language]) => ({
|
const options = Object.entries(layouts).map(([code, language]) => ({
|
||||||
value: code,
|
value: code,
|
||||||
label: `${language} (${layoutAbbrevMap[code] || code})`,
|
label: language,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const currentLayout = keyboardLayout ?? "";
|
const currentLayout = keyboardLayout ?? "";
|
||||||
@@ -57,7 +47,7 @@ const KeyboardPanel: React.FC = () => {
|
|||||||
return options;
|
return options;
|
||||||
})();
|
})();
|
||||||
setLayoutOptions(curLayoutOptions);
|
setLayoutOptions(curLayoutOptions);
|
||||||
}, [layouts, keyboardLayout, layoutAbbrevMap]);
|
}, [layouts, keyboardLayout]);
|
||||||
|
|
||||||
const safeKeyboardLayout = useMemo(() => {
|
const safeKeyboardLayout = useMemo(() => {
|
||||||
if (keyboardLayout && keyboardLayout.length > 0)
|
if (keyboardLayout && keyboardLayout.length > 0)
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import React, { useCallback } from "react";
|
|
||||||
import { useReactAt } from "i18n-auto-extractor/react";
|
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
|
||||||
import notifications from "@/notifications";
|
|
||||||
import { dark_bg2_style, dark_bd_style, dark_line_style, dark_bg_style_fun } from "@/layout/theme_color";
|
|
||||||
import { useThemeSettings } from "@routes/login_page/useLocalAuth";
|
|
||||||
import { isMobile } from "react-device-detect";
|
|
||||||
|
|
||||||
const UsbStatusPanel: React.FC = () => {
|
|
||||||
const { $at } = useReactAt();
|
|
||||||
const { isDark } = useThemeSettings();
|
|
||||||
const [send] = useJsonRpc();
|
|
||||||
|
|
||||||
const handleReinitializeUsbGadget = useCallback(() => {
|
|
||||||
send("reinitializeUsbGadget", {}, resp => {
|
|
||||||
if ("error" in resp) {
|
|
||||||
notifications.error(
|
|
||||||
`Failed to reinitialize USB gadget: ${resp.error.data || "Unknown error"}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
notifications.success("USB Gadget reinitialized successfully");
|
|
||||||
});
|
|
||||||
}, [send]);
|
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
return (
|
|
||||||
<div className={`w-full h-full flex flex-col ${dark_bg_style_fun(isDark)} p-4`}>
|
|
||||||
<div className={`flex flex-col w-full mx-auto ${isDark ? 'text-white' : 'text-black'}`}>
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
flex items-center justify-between py-4 w-full
|
|
||||||
cursor-pointer transition-all duration-200 ease-in-out
|
|
||||||
${isDark ? 'text-white' : 'text-black'}
|
|
||||||
`}
|
|
||||||
onClick={handleReinitializeUsbGadget}
|
|
||||||
>
|
|
||||||
<span className="font-normal tracking-[0.5px]">
|
|
||||||
{$at("Reinitialize USB Gadget")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)' }}
|
|
||||||
className={`p-1.5 w-[200px] rounded font-sans ${dark_bg2_style} border ${dark_bd_style}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '8px 12px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
color: isDark ? 'rgba(255, 255, 255, 0.85)' : 'rgba(0, 0, 0, 0.85)',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
borderRadius: '4px',
|
|
||||||
}}
|
|
||||||
onClick={handleReinitializeUsbGadget}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.04)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = 'transparent';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: "12px" }}>{$at("Reinitialize USB Gadget")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UsbStatusPanel;
|
|
||||||
@@ -23,7 +23,6 @@ import { LogDialog } from "@components/LogDialog";
|
|||||||
import { Dialog } from "@/layout/components_setting/access/auth";
|
import { Dialog } from "@/layout/components_setting/access/auth";
|
||||||
import AutoHeight from "@components/AutoHeight";
|
import AutoHeight from "@components/AutoHeight";
|
||||||
import FirewallSettings from "./FirewallSettings";
|
import FirewallSettings from "./FirewallSettings";
|
||||||
import WebRtcServersSettings from "./WebRtcServers";
|
|
||||||
|
|
||||||
export interface TailScaleResponse {
|
export interface TailScaleResponse {
|
||||||
state: string;
|
state: string;
|
||||||
@@ -71,47 +70,6 @@ export interface CloudflaredRunningResponse {
|
|||||||
running: boolean;
|
running: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ManagedVpnTool = "frpc" | "easytier" | "vnt" | "cloudflared";
|
|
||||||
|
|
||||||
export interface VpnToolSystemInfo {
|
|
||||||
goos: string;
|
|
||||||
goarch: string;
|
|
||||||
uname_arch: string;
|
|
||||||
arch_label: string;
|
|
||||||
arch_keywords: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VpnToolStatus {
|
|
||||||
tool: string;
|
|
||||||
installed: boolean;
|
|
||||||
source: string;
|
|
||||||
current_version: string;
|
|
||||||
detected_version: string;
|
|
||||||
managed_versions: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VpnToolReleaseAsset {
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
arch_match: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VpnToolRelease {
|
|
||||||
tag_name: string;
|
|
||||||
assets: VpnToolReleaseAsset[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VpnToolInstallTask {
|
|
||||||
tool: string;
|
|
||||||
running: boolean;
|
|
||||||
progress: number;
|
|
||||||
message: string;
|
|
||||||
logs: string[];
|
|
||||||
error: string;
|
|
||||||
version: string;
|
|
||||||
updated_at: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WireguardStatus {
|
export interface WireguardStatus {
|
||||||
running: boolean;
|
running: boolean;
|
||||||
}
|
}
|
||||||
@@ -239,20 +197,6 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
|||||||
const [cloudflaredLog, setCloudflaredLog] = useState<string>("");
|
const [cloudflaredLog, setCloudflaredLog] = useState<string>("");
|
||||||
const [showCloudflaredLogModal, setShowCloudflaredLogModal] = useState(false);
|
const [showCloudflaredLogModal, setShowCloudflaredLogModal] = useState(false);
|
||||||
|
|
||||||
const [vpnToolSystemInfo, setVpnToolSystemInfo] = useState<VpnToolSystemInfo | null>(null);
|
|
||||||
const [vpnToolStatusMap, setVpnToolStatusMap] = useState<Record<string, VpnToolStatus>>({});
|
|
||||||
const [vpnToolReleasesMap, setVpnToolReleasesMap] = useState<Record<string, VpnToolRelease[]>>({});
|
|
||||||
const [vpnToolSelectedVersionMap, setVpnToolSelectedVersionMap] = useState<Record<string, string>>({});
|
|
||||||
const [vpnToolSelectedAssetMap, setVpnToolSelectedAssetMap] = useState<Record<string, string>>({});
|
|
||||||
const [vpnToolBusyMap, setVpnToolBusyMap] = useState<Record<string, boolean>>({});
|
|
||||||
const [vpnToolInstallTaskMap, setVpnToolInstallTaskMap] = useState<Record<string, VpnToolInstallTask>>({});
|
|
||||||
const [vpnToolInstallPanelOpenMap, setVpnToolInstallPanelOpenMap] = useState<Record<string, boolean>>({
|
|
||||||
frpc: false,
|
|
||||||
easytier: false,
|
|
||||||
vnt: false,
|
|
||||||
cloudflared: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const getTLSState = useCallback(() => {
|
const getTLSState = useCallback(() => {
|
||||||
send("getTLSState", {}, resp => {
|
send("getTLSState", {}, resp => {
|
||||||
@@ -337,156 +281,10 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
|||||||
});
|
});
|
||||||
}, [send]);
|
}, [send]);
|
||||||
|
|
||||||
const managedTools: ManagedVpnTool[] = ["frpc", "easytier", "vnt", "cloudflared"];
|
|
||||||
|
|
||||||
const getVpnToolSystemInfo = useCallback(() => {
|
|
||||||
send("getVpnToolSystemInfo", {}, resp => {
|
|
||||||
if ("error" in resp) {
|
|
||||||
notifications.error(`Failed to get system architecture info: ${resp.error.data || "Unknown error"}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setVpnToolSystemInfo(resp.result as VpnToolSystemInfo);
|
|
||||||
});
|
|
||||||
}, [send]);
|
|
||||||
|
|
||||||
const getVpnToolStatus = useCallback((tool: ManagedVpnTool) => {
|
|
||||||
send("getVpnToolStatus", { tool }, resp => {
|
|
||||||
if ("error" in resp) {
|
|
||||||
notifications.error(`Failed to get ${tool} status: ${resp.error.data || "Unknown error"}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setVpnToolStatusMap(prev => ({ ...prev, [tool]: resp.result as VpnToolStatus }));
|
|
||||||
});
|
|
||||||
}, [send]);
|
|
||||||
|
|
||||||
const listVpnToolReleases = useCallback((tool: ManagedVpnTool) => {
|
|
||||||
send("listVpnToolReleases", { tool }, resp => {
|
|
||||||
if ("error" in resp) {
|
|
||||||
notifications.error(`Failed to list ${tool} releases: ${resp.error.data || "Unknown error"}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const releases = resp.result as VpnToolRelease[];
|
|
||||||
setVpnToolReleasesMap(prev => ({ ...prev, [tool]: releases }));
|
|
||||||
if (!releases.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setVpnToolSelectedVersionMap(prev => {
|
|
||||||
if (prev[tool]) return prev;
|
|
||||||
return { ...prev, [tool]: releases[0].tag_name };
|
|
||||||
});
|
|
||||||
setVpnToolSelectedAssetMap(prev => {
|
|
||||||
if (prev[tool]) return prev;
|
|
||||||
const firstRelease = releases[0];
|
|
||||||
const preferred = firstRelease.assets.find(asset => asset.arch_match) || firstRelease.assets[0];
|
|
||||||
return preferred ? { ...prev, [tool]: preferred.url } : prev;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [send]);
|
|
||||||
|
|
||||||
const refreshVpnToolManager = useCallback((tool: ManagedVpnTool, withReleases: boolean) => {
|
|
||||||
getVpnToolStatus(tool);
|
|
||||||
if (withReleases) {
|
|
||||||
listVpnToolReleases(tool);
|
|
||||||
}
|
|
||||||
}, [getVpnToolStatus, listVpnToolReleases]);
|
|
||||||
|
|
||||||
const getVpnToolInstallTask = useCallback((tool: ManagedVpnTool) => {
|
|
||||||
send("getVpnToolInstallTask", { tool }, resp => {
|
|
||||||
if ("error" in resp) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setVpnToolInstallTaskMap(prev => ({ ...prev, [tool]: resp.result as VpnToolInstallTask }));
|
|
||||||
});
|
|
||||||
}, [send]);
|
|
||||||
|
|
||||||
const handleVpnToolVersionChange = useCallback((tool: ManagedVpnTool, version: string) => {
|
|
||||||
setVpnToolSelectedVersionMap(prev => ({ ...prev, [tool]: version }));
|
|
||||||
const releases = vpnToolReleasesMap[tool] || [];
|
|
||||||
const selectedRelease = releases.find(release => release.tag_name === version);
|
|
||||||
if (!selectedRelease || !selectedRelease.assets.length) {
|
|
||||||
setVpnToolSelectedAssetMap(prev => ({ ...prev, [tool]: "" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const preferred = selectedRelease.assets.find(asset => asset.arch_match) || selectedRelease.assets[0];
|
|
||||||
setVpnToolSelectedAssetMap(prev => ({ ...prev, [tool]: preferred?.url || "" }));
|
|
||||||
}, [vpnToolReleasesMap]);
|
|
||||||
|
|
||||||
const handleInstallVpnTool = useCallback((tool: ManagedVpnTool) => {
|
|
||||||
const version = vpnToolSelectedVersionMap[tool];
|
|
||||||
const downloadURL = vpnToolSelectedAssetMap[tool];
|
|
||||||
const releases = vpnToolReleasesMap[tool] || [];
|
|
||||||
const release = releases.find(item => item.tag_name === version);
|
|
||||||
const selectedAsset = release?.assets.find(asset => asset.url === downloadURL);
|
|
||||||
if (!version || !downloadURL || !selectedAsset) {
|
|
||||||
notifications.error(`Please select version and release asset for ${tool}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setVpnToolBusyMap(prev => ({ ...prev, [tool]: true }));
|
|
||||||
send("startVpnToolInstall", { tool, version, assetName: selectedAsset.name, downloadURL }, resp => {
|
|
||||||
setVpnToolBusyMap(prev => ({ ...prev, [tool]: false }));
|
|
||||||
if ("error" in resp) {
|
|
||||||
notifications.error(`Failed to install ${tool}: ${resp.error.data || "Unknown error"}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
notifications.success(`${tool} install task started (${version})`);
|
|
||||||
getVpnToolInstallTask(tool);
|
|
||||||
});
|
|
||||||
}, [send, vpnToolSelectedVersionMap, vpnToolSelectedAssetMap, vpnToolReleasesMap, getVpnToolInstallTask]);
|
|
||||||
|
|
||||||
const handleUninstallVpnToolVersion = useCallback((tool: ManagedVpnTool, version: string) => {
|
|
||||||
if (!version) {
|
|
||||||
notifications.error("Please select installed version");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setVpnToolBusyMap(prev => ({ ...prev, [tool]: true }));
|
|
||||||
send("uninstallVpnToolVersion", { tool, version }, resp => {
|
|
||||||
setVpnToolBusyMap(prev => ({ ...prev, [tool]: false }));
|
|
||||||
if ("error" in resp) {
|
|
||||||
notifications.error(`Failed to uninstall ${tool} version ${version}: ${resp.error.data || "Unknown error"}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
notifications.success(`${tool} ${version} uninstalled`);
|
|
||||||
refreshVpnToolManager(tool, true);
|
|
||||||
});
|
|
||||||
}, [send, refreshVpnToolManager]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getCloudflaredStatus();
|
getCloudflaredStatus();
|
||||||
}, [getCloudflaredStatus]);
|
}, [getCloudflaredStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getVpnToolSystemInfo();
|
|
||||||
managedTools.forEach(tool => {
|
|
||||||
refreshVpnToolManager(tool, false);
|
|
||||||
getVpnToolInstallTask(tool);
|
|
||||||
});
|
|
||||||
}, [getVpnToolSystemInfo, refreshVpnToolManager, getVpnToolInstallTask]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
managedTools.forEach(tool => {
|
|
||||||
getVpnToolInstallTask(tool);
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [getVpnToolInstallTask]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const tabToolMap: Partial<Record<string, ManagedVpnTool>> = {
|
|
||||||
frp: "frpc",
|
|
||||||
easytier: "easytier",
|
|
||||||
vnt: "vnt",
|
|
||||||
cloudflared: "cloudflared",
|
|
||||||
};
|
|
||||||
const tool = tabToolMap[activeTab];
|
|
||||||
if (tool) {
|
|
||||||
refreshVpnToolManager(tool, false);
|
|
||||||
}
|
|
||||||
if (activeTab === "cloudflared") {
|
|
||||||
getCloudflaredStatus();
|
|
||||||
}
|
|
||||||
}, [activeTab, refreshVpnToolManager, getCloudflaredStatus]);
|
|
||||||
|
|
||||||
// Handle TLS mode change
|
// Handle TLS mode change
|
||||||
const handleTlsModeChange = (value: string) => {
|
const handleTlsModeChange = (value: string) => {
|
||||||
setTlsMode(value);
|
setTlsMode(value);
|
||||||
@@ -1071,154 +869,6 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
|||||||
getVntConfigFile();
|
getVntConfigFile();
|
||||||
}, [getVntStatus, getVntConfig]);
|
}, [getVntStatus, getVntConfig]);
|
||||||
|
|
||||||
const renderVpnToolManager = (tool: ManagedVpnTool, label: string) => {
|
|
||||||
const status = vpnToolStatusMap[tool];
|
|
||||||
const releases = vpnToolReleasesMap[tool] || [];
|
|
||||||
const selectedVersion = vpnToolSelectedVersionMap[tool] || "";
|
|
||||||
const selectedRelease = releases.find(release => release.tag_name === selectedVersion);
|
|
||||||
const selectedAsset = vpnToolSelectedAssetMap[tool] || "";
|
|
||||||
const assets = selectedRelease?.assets || [];
|
|
||||||
const busy = vpnToolBusyMap[tool] || false;
|
|
||||||
const installTask = vpnToolInstallTaskMap[tool];
|
|
||||||
const installRunning = installTask?.running === true;
|
|
||||||
const installPanelOpen = vpnToolInstallPanelOpenMap[tool] || false;
|
|
||||||
const isInstalled = status?.installed === true;
|
|
||||||
const uninstallVersion = status?.current_version || status?.managed_versions?.[0] || "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-700">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
|
||||||
{label} {$at("Version Manager")}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-x-2">
|
|
||||||
<Button
|
|
||||||
size="SM"
|
|
||||||
theme="light"
|
|
||||||
text={$at("Refresh")}
|
|
||||||
onClick={() => refreshVpnToolManager(tool, installPanelOpen)}
|
|
||||||
disabled={busy || installRunning}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="SM"
|
|
||||||
theme="light"
|
|
||||||
text={installPanelOpen ? $at("Hide Install Actions") : $at("Show Install Actions")}
|
|
||||||
onClick={() => {
|
|
||||||
const nextOpen = !installPanelOpen;
|
|
||||||
setVpnToolInstallPanelOpenMap(prev => ({ ...prev, [tool]: nextOpen }));
|
|
||||||
if (nextOpen) {
|
|
||||||
listVpnToolReleases(tool);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={busy || installRunning}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
|
||||||
{$at("Install Status")}: {status?.installed ? $at("Installed") : $at("Not Installed")}
|
|
||||||
{status?.source ? ` (${status.source})` : ""}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
|
||||||
{$at("Detected Version")}: {status?.detected_version || "-"}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{installPanelOpen ? (
|
|
||||||
<>
|
|
||||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
|
||||||
{$at("System Architecture")}: {vpnToolSystemInfo?.arch_label || "unknown"}
|
|
||||||
{vpnToolSystemInfo?.arch_keywords?.length
|
|
||||||
? ` (${vpnToolSystemInfo.arch_keywords.join(", ")})`
|
|
||||||
: ""}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<SettingsItem title={$at("Release Version")} description="">
|
|
||||||
<Select
|
|
||||||
className={isMobile ? "!w-full !h-[36px]" : "!w-[28%] !h-[36px]"}
|
|
||||||
value={selectedVersion}
|
|
||||||
onChange={value => handleVpnToolVersionChange(tool, value)}
|
|
||||||
options={releases.map(release => ({
|
|
||||||
value: release.tag_name,
|
|
||||||
label: release.tag_name,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</SettingsItem>
|
|
||||||
<SettingsItem title={$at("Release Asset")} description="">
|
|
||||||
<Select
|
|
||||||
className={isMobile ? "!w-full !h-[36px]" : "!w-[60%] !h-[36px]"}
|
|
||||||
value={selectedAsset}
|
|
||||||
onChange={value => setVpnToolSelectedAssetMap(prev => ({ ...prev, [tool]: value }))}
|
|
||||||
options={assets.map(asset => ({
|
|
||||||
value: asset.url,
|
|
||||||
label: `${asset.arch_match ? "[ARCH OK] " : ""}${asset.name}`,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</SettingsItem>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-x-2">
|
|
||||||
{isInstalled ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="SM"
|
|
||||||
theme="primary"
|
|
||||||
text={$at("Update")}
|
|
||||||
onClick={() => handleInstallVpnTool(tool)}
|
|
||||||
disabled={busy || installRunning || !selectedVersion || !selectedAsset}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="SM"
|
|
||||||
theme="danger"
|
|
||||||
text={$at("Uninstall")}
|
|
||||||
onClick={() => handleUninstallVpnToolVersion(tool, uninstallVersion)}
|
|
||||||
disabled={busy || installRunning || !uninstallVersion}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="SM"
|
|
||||||
theme="primary"
|
|
||||||
text={$at("Install")}
|
|
||||||
onClick={() => handleInstallVpnTool(tool)}
|
|
||||||
disabled={busy || installRunning || !selectedVersion || !selectedAsset}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{installRunning && installTask ? (
|
|
||||||
<div className="space-y-2 rounded border border-slate-200 p-2 dark:border-slate-700">
|
|
||||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
|
||||||
{$at("Install Task")}: {installTask.message || "-"}
|
|
||||||
{installTask.error ? ` (${installTask.error})` : ""}
|
|
||||||
</div>
|
|
||||||
<div className="h-2 w-full rounded bg-slate-200 dark:bg-slate-700">
|
|
||||||
<div
|
|
||||||
className="h-2 rounded bg-blue-500 transition-all"
|
|
||||||
style={{ width: `${Math.max(0, Math.min(100, Math.round((installTask.progress || 0) * 100)))}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
|
||||||
{$at("Progress")}: {Math.max(0, Math.min(100, Math.round((installTask.progress || 0) * 100)))}%
|
|
||||||
</div>
|
|
||||||
{!!installTask.logs?.length && (
|
|
||||||
<pre className="max-h-36 overflow-auto rounded bg-slate-50 p-2 text-[11px] text-slate-600 dark:bg-slate-900 dark:text-slate-300">
|
|
||||||
{installTask.logs.join("\n")}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{status?.managed_versions?.length ? (
|
|
||||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
|
||||||
{$at("Installed Versions")}: {status.managed_versions.join(", ")}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -1347,20 +997,6 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<SettingsSectionHeader
|
|
||||||
title={$at("WebRTC Servers")}
|
|
||||||
description={$at("STUN and TURN servers used for peer connections")}
|
|
||||||
/>
|
|
||||||
<GridCard>
|
|
||||||
<AutoHeight>
|
|
||||||
<div className="space-y-4 p-4">
|
|
||||||
<WebRtcServersSettings />
|
|
||||||
</div>
|
|
||||||
</AutoHeight>
|
|
||||||
</GridCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SettingsSectionHeader
|
<SettingsSectionHeader
|
||||||
title={$at("Remote")}
|
title={$at("Remote")}
|
||||||
@@ -1656,7 +1292,6 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
|||||||
<GridCard>
|
<GridCard>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{renderVpnToolManager("easytier", "EasyTier")}
|
|
||||||
{ easyTierRunningStatus.running ? (
|
{ easyTierRunningStatus.running ? (
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||||
@@ -1774,7 +1409,6 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
|||||||
<GridCard>
|
<GridCard>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{renderVpnToolManager("vnt", "Vnt")}
|
|
||||||
{ vntRunningStatus.running ? (
|
{ vntRunningStatus.running ? (
|
||||||
|
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
@@ -1987,7 +1621,6 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
|||||||
<GridCard>
|
<GridCard>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{renderVpnToolManager("cloudflared", "Cloudflare")}
|
|
||||||
{cloudflaredRunningStatus.running ? (
|
{cloudflaredRunningStatus.running ? (
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -2036,7 +1669,6 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
|||||||
<GridCard>
|
<GridCard>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{renderVpnToolManager("frpc", "frpc")}
|
|
||||||
<TextAreaWithLabel
|
<TextAreaWithLabel
|
||||||
label={$at("Edit frpc.toml")}
|
label={$at("Edit frpc.toml")}
|
||||||
placeholder={$at("Enter frpc configuration")}
|
placeholder={$at("Enter frpc configuration")}
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useReactAt } from "i18n-auto-extractor/react";
|
|
||||||
|
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
|
||||||
import notifications from "@/notifications";
|
|
||||||
import { Button } from "@components/Button";
|
|
||||||
import { InputField } from "@components/InputField";
|
|
||||||
import { SettingsItem } from "@components/Settings/SettingsView";
|
|
||||||
|
|
||||||
interface TurnServer {
|
|
||||||
url: string;
|
|
||||||
username: string;
|
|
||||||
credential: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RtcServersConfig {
|
|
||||||
stun: string;
|
|
||||||
defaultStun: string;
|
|
||||||
turnServers: TurnServer[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WebRtcServersSettings() {
|
|
||||||
const { $at } = useReactAt();
|
|
||||||
const [send] = useJsonRpc();
|
|
||||||
|
|
||||||
const [stun, setStun] = useState("");
|
|
||||||
const [defaultStun, setDefaultStun] = useState("");
|
|
||||||
const [turnServers, setTurnServers] = useState<TurnServer[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
send("getRtcServersConfig", {}, resp => {
|
|
||||||
if ("error" in resp) {
|
|
||||||
notifications.error(`${$at("Failed to load WebRTC servers")}: ${resp.error.data || $at("Unknown error")}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cfg = resp.result as RtcServersConfig;
|
|
||||||
setDefaultStun(cfg.defaultStun);
|
|
||||||
setStun(cfg.stun || cfg.defaultStun);
|
|
||||||
setTurnServers(cfg.turnServers ?? []);
|
|
||||||
});
|
|
||||||
}, [send]);
|
|
||||||
|
|
||||||
const saveStun = (value: string) => {
|
|
||||||
send("setStunServer", { stun: value }, resp => {
|
|
||||||
if ("error" in resp) {
|
|
||||||
notifications.error(`${$at("Failed to save STUN")}: ${resp.error.data || $at("Unknown error")}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStun(value);
|
|
||||||
notifications.success($at("STUN server saved"));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const persistTurnServers = (servers: TurnServer[]) => {
|
|
||||||
send("setTurnServers", { params: { servers } }, resp => {
|
|
||||||
if ("error" in resp) {
|
|
||||||
notifications.error(`${$at("Failed to save TURN")}: ${resp.error.data || $at("Unknown error")}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTurnServers(servers);
|
|
||||||
notifications.success($at("TURN servers saved"));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateTurnRow = (index: number, field: keyof TurnServer, value: string) => {
|
|
||||||
setTurnServers(prev => prev.map((server, i) => (i === index ? { ...server, [field]: value } : server)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addTurnRow = () => {
|
|
||||||
setTurnServers(prev => [...prev, { url: "", username: "", credential: "" }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteTurnRow = (index: number) => {
|
|
||||||
persistTurnServers(turnServers.filter((_, i) => i !== index));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<SettingsItem
|
|
||||||
title={$at("STUN Server")}
|
|
||||||
description={$at("Public STUN server for NAT traversal")}
|
|
||||||
noCol
|
|
||||||
>
|
|
||||||
<div className="flex w-full max-w-2xl flex-col gap-2 sm:flex-row">
|
|
||||||
<InputField
|
|
||||||
value={stun}
|
|
||||||
onChange={e => setStun(e.target.value)}
|
|
||||||
placeholder={defaultStun}
|
|
||||||
className="min-w-0"
|
|
||||||
/>
|
|
||||||
<div className="flex shrink-0 gap-2">
|
|
||||||
<Button size="MD" theme="primary" text={$at("Save")} onClick={() => saveStun(stun)} />
|
|
||||||
<Button
|
|
||||||
size="MD"
|
|
||||||
theme="light"
|
|
||||||
text={$at("Restore Default")}
|
|
||||||
onClick={() => saveStun(defaultStun)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SettingsItem>
|
|
||||||
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="text-base font-semibold text-black dark:text-white">
|
|
||||||
{$at("TURN Servers")}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
{$at("Used as relay when direct peer-to-peer connection fails")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{turnServers.length === 0 && (
|
|
||||||
<div className="text-sm text-slate-500 dark:text-slate-400">{$at("No TURN servers configured")}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{turnServers.map((server, index) => (
|
|
||||||
<div key={index} className="grid gap-2 lg:grid-cols-[minmax(220px,1fr)_minmax(120px,180px)_minmax(120px,180px)_auto]">
|
|
||||||
<InputField
|
|
||||||
value={server.url}
|
|
||||||
onChange={e => updateTurnRow(index, "url", e.target.value)}
|
|
||||||
placeholder="turn:turn.example.com:3478"
|
|
||||||
/>
|
|
||||||
<InputField
|
|
||||||
value={server.username}
|
|
||||||
onChange={e => updateTurnRow(index, "username", e.target.value)}
|
|
||||||
placeholder={$at("Username")}
|
|
||||||
/>
|
|
||||||
<InputField
|
|
||||||
value={server.credential}
|
|
||||||
onChange={e => updateTurnRow(index, "credential", e.target.value)}
|
|
||||||
placeholder={$at("Credential")}
|
|
||||||
type="password"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="MD"
|
|
||||||
theme="lightDanger"
|
|
||||||
text={$at("Delete")}
|
|
||||||
onClick={() => deleteTurnRow(index)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button size="MD" theme="light" text={$at("Add TURN Server")} onClick={addTurnRow} />
|
|
||||||
<Button
|
|
||||||
size="MD"
|
|
||||||
theme="primary"
|
|
||||||
text={$at("Save TURN Servers")}
|
|
||||||
onClick={() => persistTurnServers(turnServers)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -28,9 +28,6 @@ export default function SettingsAdvanced() {
|
|||||||
const [configContent, setConfigContent] = useState("");
|
const [configContent, setConfigContent] = useState("");
|
||||||
const [isSavingConfig, setIsSavingConfig] = useState(false);
|
const [isSavingConfig, setIsSavingConfig] = useState(false);
|
||||||
const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
|
const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
|
||||||
const [apiKey, setApiKey] = useState<string>("");
|
|
||||||
const [apiKeyInput, setApiKeyInput] = useState<string>("");
|
|
||||||
const [showApiKeyClearWarning, setShowApiKeyClearWarning] = useState(false);
|
|
||||||
|
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||||
@@ -56,13 +53,6 @@ export default function SettingsAdvanced() {
|
|||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
setLocalLoopbackOnly(resp.result as boolean);
|
setLocalLoopbackOnly(resp.result as boolean);
|
||||||
});
|
});
|
||||||
|
|
||||||
send("getApiKey", {}, resp => {
|
|
||||||
if ("error" in resp) return;
|
|
||||||
const key = resp.result as string;
|
|
||||||
setApiKey(key);
|
|
||||||
setApiKeyInput(key);
|
|
||||||
});
|
|
||||||
}, [send, setDeveloperMode]);
|
}, [send, setDeveloperMode]);
|
||||||
|
|
||||||
const getUsbEmulationState = useCallback(() => {
|
const getUsbEmulationState = useCallback(() => {
|
||||||
@@ -118,54 +108,6 @@ export default function SettingsAdvanced() {
|
|||||||
});
|
});
|
||||||
}, [send]);
|
}, [send]);
|
||||||
|
|
||||||
const handleUpdateApiKey = useCallback(() => {
|
|
||||||
if (apiKeyInput === "") {
|
|
||||||
setShowApiKeyClearWarning(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
send("setApiKey", { apiKey: apiKeyInput }, resp => {
|
|
||||||
if ("error" in resp) {
|
|
||||||
notifications.error(
|
|
||||||
`Failed to update API key: ${resp.error.data || "Unknown error"}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setApiKey(apiKeyInput);
|
|
||||||
notifications.success("API key updated successfully");
|
|
||||||
});
|
|
||||||
}, [send, apiKeyInput]);
|
|
||||||
|
|
||||||
const handleGenerateApiKey = useCallback(() => {
|
|
||||||
send("generateApiKey", {}, resp => {
|
|
||||||
if ("error" in resp) {
|
|
||||||
notifications.error(
|
|
||||||
`Failed to generate API key: ${resp.error.data || "Unknown error"}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newKey = resp.result as string;
|
|
||||||
setApiKey(newKey);
|
|
||||||
setApiKeyInput(newKey);
|
|
||||||
notifications.success("New API key generated and saved");
|
|
||||||
});
|
|
||||||
}, [send]);
|
|
||||||
|
|
||||||
const confirmClearApiKey = useCallback(() => {
|
|
||||||
send("generateApiKey", {}, resp => {
|
|
||||||
if ("error" in resp) {
|
|
||||||
notifications.error(
|
|
||||||
`Failed to generate API key: ${resp.error.data || "Unknown error"}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newKey = resp.result as string;
|
|
||||||
setApiKey(newKey);
|
|
||||||
setApiKeyInput(newKey);
|
|
||||||
notifications.success("New API key generated and saved");
|
|
||||||
});
|
|
||||||
setShowApiKeyClearWarning(false);
|
|
||||||
}, [send]);
|
|
||||||
|
|
||||||
const handleUpdateSSHKey = useCallback(() => {
|
const handleUpdateSSHKey = useCallback(() => {
|
||||||
send("setSSHKeyState", { sshKey }, resp => {
|
send("setSSHKeyState", { sshKey }, resp => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
@@ -298,42 +240,6 @@ export default function SettingsAdvanced() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isOnDevice && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<SettingsItem
|
|
||||||
title={$at("API Key")}
|
|
||||||
description={$at("API key for MCP and REST API authentication")}
|
|
||||||
/>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<TextAreaWithLabel
|
|
||||||
label={$at("API Key")}
|
|
||||||
value={apiKeyInput || ""}
|
|
||||||
rows={2}
|
|
||||||
onChange={e => setApiKeyInput(e.target.value)}
|
|
||||||
placeholder={$at("Enter API key or leave empty to auto-generate")}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-600 dark:text-[#ffffff]">
|
|
||||||
{$at("Used for authenticating MCP and REST API requests.")}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-x-2">
|
|
||||||
<AntdButton
|
|
||||||
type="primary"
|
|
||||||
onClick={handleUpdateApiKey}
|
|
||||||
className={isMobile?"w-full":""}
|
|
||||||
>
|
|
||||||
{$at("Save API Key")}
|
|
||||||
</AntdButton>
|
|
||||||
<AntdButton
|
|
||||||
onClick={handleGenerateApiKey}
|
|
||||||
className={isMobile?"w-full":""}
|
|
||||||
>
|
|
||||||
{$at("Generate New")}
|
|
||||||
</AntdButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
title={$at("Force HTTP Transmission")}
|
title={$at("Force HTTP Transmission")}
|
||||||
badge="Experimental"
|
badge="Experimental"
|
||||||
@@ -473,28 +379,6 @@ export default function SettingsAdvanced() {
|
|||||||
onConfirm={confirmLoopbackModeEnable}
|
onConfirm={confirmLoopbackModeEnable}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={showApiKeyClearWarning}
|
|
||||||
onClose={() => {
|
|
||||||
setShowApiKeyClearWarning(false);
|
|
||||||
}}
|
|
||||||
title={$at("Clear API Key?")}
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
<p>
|
|
||||||
{$at("Setting the API key to empty will auto-generate a new random key.")}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-slate-600 dark:text-slate-400 mt-2">
|
|
||||||
{$at("Make sure to update your clients with the new key after saving.")}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
variant="warning"
|
|
||||||
cancelText={$at("Cancel")}
|
|
||||||
confirmText={$at("Generate New Key")}
|
|
||||||
onConfirm={confirmClearApiKey}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={showRebootConfirm}
|
open={showRebootConfirm}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
@@ -290,7 +290,6 @@ export default function SettingsHardware() {
|
|||||||
value={settings.ledGreenMode.toString()}
|
value={settings.ledGreenMode.toString()}
|
||||||
className={`${isMobile?"w-full":"h-[36px] w-[22%]"}`}
|
className={`${isMobile?"w-full":"h-[36px] w-[22%]"}`}
|
||||||
options={[
|
options={[
|
||||||
{ value: "disabled", label: $at("Disabled") },
|
|
||||||
{ value: "network-link", label: $at("network-link") },
|
{ value: "network-link", label: $at("network-link") },
|
||||||
{ value: "network-tx", label: $at("network-tx") },
|
{ value: "network-tx", label: $at("network-tx") },
|
||||||
{ value: "network-rx", label: $at("network-rx") },
|
{ value: "network-rx", label: $at("network-rx") },
|
||||||
@@ -310,9 +309,8 @@ export default function SettingsHardware() {
|
|||||||
|
|
||||||
<SelectMenuBasic
|
<SelectMenuBasic
|
||||||
value={settings.ledYellowMode.toString()}
|
value={settings.ledYellowMode.toString()}
|
||||||
className={`${isMobile?"w-full":"h-[36px] w-[22%]"}`}
|
className={`${isMobile?"w-full":""}`}
|
||||||
options={[
|
options={[
|
||||||
{ value: "disabled", label: $at("Disabled") },
|
|
||||||
{ value: "network-link", label: $at("network-link") },
|
{ value: "network-link", label: $at("network-link") },
|
||||||
{ value: "network-tx", label: $at("network-tx") },
|
{ value: "network-tx", label: $at("network-tx") },
|
||||||
{ value: "network-rx", label: $at("network-rx") },
|
{ value: "network-rx", label: $at("network-rx") },
|
||||||
|
|||||||
@@ -39,9 +39,6 @@ dayjs.extend(relativeTime);
|
|||||||
const defaultNetworkSettings: NetworkSettings = {
|
const defaultNetworkSettings: NetworkSettings = {
|
||||||
hostname: "",
|
hostname: "",
|
||||||
domain: "",
|
domain: "",
|
||||||
http_proxy: "",
|
|
||||||
https_proxy: "",
|
|
||||||
all_proxy: "",
|
|
||||||
ipv4_mode: "unknown",
|
ipv4_mode: "unknown",
|
||||||
ipv6_mode: "unknown",
|
ipv6_mode: "unknown",
|
||||||
lldp_mode: "unknown",
|
lldp_mode: "unknown",
|
||||||
@@ -310,10 +307,6 @@ export default function SettingsNetwork() {
|
|||||||
setNetworkSettings({ ...networkSettings, domain: value });
|
setNetworkSettings({ ...networkSettings, domain: value });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProxyChange = (field: "http_proxy" | "https_proxy" | "all_proxy", value: string) => {
|
|
||||||
setNetworkSettings({ ...networkSettings, [field]: value });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDomainOptionChange = (value: string) => {
|
const handleDomainOptionChange = (value: string) => {
|
||||||
setSelectedDomainOption(value);
|
setSelectedDomainOption(value);
|
||||||
if (value !== "custom") {
|
if (value !== "custom") {
|
||||||
@@ -544,47 +537,6 @@ export default function SettingsNetwork() {
|
|||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<SettingsItem
|
|
||||||
title={$at("HTTP Proxy")}
|
|
||||||
description={$at("Configure program HTTP proxy (optional)")}
|
|
||||||
className={`${isMobile ? "w-full flex-col" : ""}`}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={networkSettings.http_proxy || ""}
|
|
||||||
placeholder="http://127.0.0.1:7890"
|
|
||||||
onChange={e => handleProxyChange("http_proxy", e.target.value)}
|
|
||||||
className={isMobile ? "!w-full !h-[36px]" : "!w-[37%] !h-[36px]"}
|
|
||||||
/>
|
|
||||||
</SettingsItem>
|
|
||||||
<SettingsItem
|
|
||||||
title={$at("HTTPS Proxy")}
|
|
||||||
description={$at("Configure program HTTPS proxy (optional)")}
|
|
||||||
className={`${isMobile ? "w-full flex-col" : ""}`}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={networkSettings.https_proxy || ""}
|
|
||||||
placeholder="http://127.0.0.1:7890"
|
|
||||||
onChange={e => handleProxyChange("https_proxy", e.target.value)}
|
|
||||||
className={isMobile ? "!w-full !h-[36px]" : "!w-[37%] !h-[36px]"}
|
|
||||||
/>
|
|
||||||
</SettingsItem>
|
|
||||||
<SettingsItem
|
|
||||||
title={$at("ALL Proxy")}
|
|
||||||
description={$at("Configure program ALL proxy (optional)")}
|
|
||||||
className={`${isMobile ? "w-full flex-col" : ""}`}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={networkSettings.all_proxy || ""}
|
|
||||||
placeholder="socks5://127.0.0.1:7890"
|
|
||||||
onChange={e => handleProxyChange("all_proxy", e.target.value)}
|
|
||||||
className={isMobile ? "!w-full !h-[36px]" : "!w-[37%] !h-[36px]"}
|
|
||||||
/>
|
|
||||||
</SettingsItem>
|
|
||||||
</div>
|
|
||||||
<AntdButton
|
<AntdButton
|
||||||
type="primary"
|
type="primary"
|
||||||
disabled={
|
disabled={
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { CheckCircleIcon } from "@heroicons/react/20/solid";
|
|||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
|
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { useBootStorageType } from "@/hooks/useBootStorage";
|
|
||||||
import { SettingsPageHeader } from "@components/Settings/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/Settings/SettingsPageheader";
|
||||||
import { SettingsItem } from "@components/Settings/SettingsView";
|
import { SettingsItem } from "@components/Settings/SettingsView";
|
||||||
import Card from "@components/Card";
|
import Card from "@components/Card";
|
||||||
@@ -22,15 +21,6 @@ export interface SystemVersionInfo {
|
|||||||
remote?: { appVersion: string; systemVersion: string };
|
remote?: { appVersion: string; systemVersion: string };
|
||||||
systemUpdateAvailable: boolean;
|
systemUpdateAvailable: boolean;
|
||||||
appUpdateAvailable: boolean;
|
appUpdateAvailable: boolean;
|
||||||
appSignatureMissing?: boolean;
|
|
||||||
systemSignatureMissing?: boolean;
|
|
||||||
appSignatureAbsent?: boolean;
|
|
||||||
systemSignatureAbsent?: boolean;
|
|
||||||
appSignatureInvalid?: boolean;
|
|
||||||
systemSignatureInvalid?: boolean;
|
|
||||||
appNoPublicKey?: boolean;
|
|
||||||
systemNoPublicKey?: boolean;
|
|
||||||
signatureVerified?: boolean;
|
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,16 +34,7 @@ export default function SettingsVersion() {
|
|||||||
const [autoUpdate, setAutoUpdate] = useState(true);
|
const [autoUpdate, setAutoUpdate] = useState(true);
|
||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
const { setModalView, otaState } = useUpdateStore();
|
const { setModalView, otaState } = useUpdateStore();
|
||||||
const { bootStorageType } = useBootStorageType();
|
|
||||||
const isBootFromSD = bootStorageType === "sd";
|
|
||||||
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false);
|
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false);
|
||||||
const [signatureStatusLoading, setSignatureStatusLoading] = useState(true);
|
|
||||||
const [signatureStatus, setSignatureStatus] = useState<{
|
|
||||||
appSignatureAbsent: boolean;
|
|
||||||
appSignatureInvalid: boolean;
|
|
||||||
appNoPublicKey: boolean;
|
|
||||||
signatureVerified: boolean;
|
|
||||||
} | null>(null);
|
|
||||||
const updatePanelRef = useRef<HTMLDivElement | null>(null);
|
const updatePanelRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [updateSource, setUpdateSource] = useState("github");
|
const [updateSource, setUpdateSource] = useState("github");
|
||||||
const [customUpdateBaseURL, setCustomUpdateBaseURL] = useState("");
|
const [customUpdateBaseURL, setCustomUpdateBaseURL] = useState("");
|
||||||
@@ -98,23 +79,6 @@ export default function SettingsVersion() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSignatureStatusLoading(true);
|
|
||||||
send("getSelfSignatureStatus", {}, resp => {
|
|
||||||
setSignatureStatusLoading(false);
|
|
||||||
if ("error" in resp) return;
|
|
||||||
const sigStatus = resp.result as {
|
|
||||||
appSignatureAbsent: boolean;
|
|
||||||
appSignatureInvalid: boolean;
|
|
||||||
appNoPublicKey: boolean;
|
|
||||||
};
|
|
||||||
const hasSigFiles = !sigStatus.appSignatureAbsent;
|
|
||||||
const noPublicKey = sigStatus.appNoPublicKey;
|
|
||||||
const signatureVerified = hasSigFiles && !noPublicKey && !sigStatus.appSignatureInvalid;
|
|
||||||
setSignatureStatus({ ...sigStatus, signatureVerified });
|
|
||||||
});
|
|
||||||
}, [send]);
|
|
||||||
|
|
||||||
const applyUpdateSource = useCallback(
|
const applyUpdateSource = useCallback(
|
||||||
(source: string) => {
|
(source: string) => {
|
||||||
send("setUpdateSource", { source }, resp => {
|
send("setUpdateSource", { source }, resp => {
|
||||||
@@ -238,13 +202,6 @@ export default function SettingsVersion() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SignatureStatusCard
|
|
||||||
signatureStatus={signatureStatus}
|
|
||||||
signatureStatusLoading={signatureStatusLoading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!isBootFromSD && (
|
|
||||||
<>
|
|
||||||
<UpdateSourceSettings
|
<UpdateSourceSettings
|
||||||
updateSource={updateSource}
|
updateSource={updateSource}
|
||||||
onUpdateSourceChange={applyUpdateSource}
|
onUpdateSourceChange={applyUpdateSource}
|
||||||
@@ -258,8 +215,6 @@ export default function SettingsVersion() {
|
|||||||
{$at("Check for Updates")}
|
{$at("Check for Updates")}
|
||||||
</AntdButton>
|
</AntdButton>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="hidden">
|
<div className="hidden">
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
@@ -420,7 +375,6 @@ function UpdateContent({
|
|||||||
<SystemUpToDateState
|
<SystemUpToDateState
|
||||||
checkUpdate={() => setModalView("loading")}
|
checkUpdate={() => setModalView("loading")}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
versionInfo={versionInfo}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -446,58 +400,23 @@ function LoadingState({
|
|||||||
|
|
||||||
const getVersionInfo = useCallback(() => {
|
const getVersionInfo = useCallback(() => {
|
||||||
return new Promise<SystemVersionInfo>((resolve, reject) => {
|
return new Promise<SystemVersionInfo>((resolve, reject) => {
|
||||||
Promise.all([
|
send("getUpdateStatus", {}, async resp => {
|
||||||
new Promise<SystemVersionInfo>((res, rej) => {
|
|
||||||
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}`);
|
||||||
rej(new Error("Failed to check for updates"));
|
reject(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}`);
|
||||||
rej(new Error("Failed to check for updates"));
|
reject(new Error("Failed to check for updates"));
|
||||||
} else {
|
} else {
|
||||||
res(result);
|
resolve(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}),
|
|
||||||
new Promise<SystemVersionInfo>((res, rej) => {
|
|
||||||
send("getSelfSignatureStatus", {}, resp => {
|
|
||||||
if ("error" in resp) {
|
|
||||||
rej(new Error("Failed to get signature status"));
|
|
||||||
} else {
|
|
||||||
const sigStatus = resp.result as {
|
|
||||||
appSignatureAbsent: boolean;
|
|
||||||
appSignatureInvalid: boolean;
|
|
||||||
appNoPublicKey: boolean;
|
|
||||||
};
|
|
||||||
const hasSigFiles = !sigStatus.appSignatureAbsent;
|
|
||||||
const signatureVerified = hasSigFiles && !sigStatus.appNoPublicKey && !sigStatus.appSignatureInvalid;
|
|
||||||
const partial: Partial<SystemVersionInfo> = {
|
|
||||||
appSignatureAbsent: sigStatus.appSignatureAbsent,
|
|
||||||
appSignatureInvalid: sigStatus.appSignatureInvalid,
|
|
||||||
appNoPublicKey: sigStatus.appNoPublicKey,
|
|
||||||
signatureVerified,
|
|
||||||
};
|
|
||||||
res(partial as SystemVersionInfo);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
.then(([versionResult, sigResult]) => {
|
|
||||||
resolve({
|
|
||||||
...versionResult,
|
|
||||||
appSignatureAbsent: sigResult.appSignatureAbsent,
|
|
||||||
appSignatureInvalid: sigResult.appSignatureInvalid,
|
|
||||||
appNoPublicKey: sigResult.appNoPublicKey,
|
|
||||||
signatureVerified: sigResult.signatureVerified,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(reject);
|
|
||||||
});
|
});
|
||||||
}, [send, setAppVersion, setSystemVersion]);
|
}, [send, setAppVersion, setSystemVersion]);
|
||||||
|
|
||||||
@@ -743,50 +662,11 @@ function UpdatingDeviceState({
|
|||||||
function SystemUpToDateState({
|
function SystemUpToDateState({
|
||||||
checkUpdate,
|
checkUpdate,
|
||||||
onClose,
|
onClose,
|
||||||
versionInfo,
|
|
||||||
}: {
|
}: {
|
||||||
checkUpdate: () => void;
|
checkUpdate: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
versionInfo: SystemVersionInfo | null;
|
|
||||||
}) {
|
}) {
|
||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
const [send] = useJsonRpc();
|
|
||||||
const [sigUpdateLoading, setSigUpdateLoading] = useState(false);
|
|
||||||
const [sigUpdateResult, setSigUpdateResult] = useState<string | null>(null);
|
|
||||||
const hasAbsentSig = versionInfo?.appSignatureAbsent;
|
|
||||||
const hasInvalidSig = versionInfo?.appSignatureInvalid;
|
|
||||||
const hasNoPublicKey = versionInfo?.appNoPublicKey;
|
|
||||||
|
|
||||||
const handleUpdateSignatures = useCallback(() => {
|
|
||||||
setSigUpdateLoading(true);
|
|
||||||
setSigUpdateResult(null);
|
|
||||||
send("updateSignatures", {}, resp => {
|
|
||||||
setSigUpdateLoading(false);
|
|
||||||
if ("error" in resp) {
|
|
||||||
setSigUpdateResult(`Failed: ${resp.error.data || "Unknown error"}`);
|
|
||||||
notifications.error(`Signature update failed: ${resp.error.data || "Unknown error"}`);
|
|
||||||
} else {
|
|
||||||
const result = resp.result as {
|
|
||||||
appSignatureUpdated: boolean;
|
|
||||||
systemSignatureUpdated: boolean;
|
|
||||||
appSignatureValid: boolean;
|
|
||||||
systemSignatureValid: boolean;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
if (result.error) {
|
|
||||||
setSigUpdateResult(`Failed: ${result.error}`);
|
|
||||||
notifications.error(`Signature update failed: ${result.error}`);
|
|
||||||
} else {
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (result.appSignatureUpdated) parts.push("App signature updated");
|
|
||||||
if (result.systemSignatureUpdated) parts.push("System signature updated");
|
|
||||||
setSigUpdateResult(parts.join(", ") || "No signatures to update");
|
|
||||||
notifications.success("Signatures updated successfully");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [send]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
@@ -797,39 +677,6 @@ function SystemUpToDateState({
|
|||||||
{$at("Your system is running the latest version. No updates are currently available.")}
|
{$at("Your system is running the latest version. No updates are currently available.")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{hasAbsentSig && (
|
|
||||||
<div className="mt-4 rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
|
|
||||||
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
|
||||||
{$at("Missing Signature File")}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
|
|
||||||
{$at("The current firmware is missing signature files. Integrity cannot be fully verified.")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasInvalidSig && (
|
|
||||||
<div className="mt-4 rounded-md border border-red-500 bg-red-50 p-3 dark:border-red-600 dark:bg-red-900/30">
|
|
||||||
<p className="text-sm font-medium text-red-800 dark:text-red-200">
|
|
||||||
{$at("Signature Verification Failed")}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-red-700 dark:text-red-300">
|
|
||||||
{$at("The signature file exists but does not match the firmware. This may indicate tampering.")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasNoPublicKey && (
|
|
||||||
<div className="mt-4 rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
|
|
||||||
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
|
||||||
{$at("No Embedded Public Key")}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
|
|
||||||
{$at("This build does not have an OTA public key embedded. Signature verification is unavailable.")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-4 flex gap-x-2">
|
<div className="mt-4 flex gap-x-2">
|
||||||
<AntdButton type="primary" onClick={checkUpdate}>
|
<AntdButton type="primary" onClick={checkUpdate}>
|
||||||
{$at("Check Again")}
|
{$at("Check Again")}
|
||||||
@@ -838,32 +685,6 @@ function SystemUpToDateState({
|
|||||||
{$at("Back")}
|
{$at("Back")}
|
||||||
</AntdButton>
|
</AntdButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-base font-semibold text-black dark:text-white">
|
|
||||||
{$at("Update Signatures")}
|
|
||||||
</p>
|
|
||||||
<p className="mb-2 text-sm text-slate-600 dark:text-slate-300">
|
|
||||||
{$at("Update the signature of kvm_app to the latest version. If the current version is not up to date, signature verification will fail.")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{sigUpdateResult && (
|
|
||||||
<div className="rounded-md border border-blue-500 bg-blue-50 p-3 dark:border-blue-600 dark:bg-blue-900/30">
|
|
||||||
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">
|
|
||||||
{$at("Signature Update Result")}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-blue-700 dark:text-blue-300">
|
|
||||||
{sigUpdateResult}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-start gap-x-2">
|
|
||||||
<AntdButton type="primary" loading={sigUpdateLoading} onClick={handleUpdateSignatures}>
|
|
||||||
{$at("Update")}
|
|
||||||
</AntdButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -887,40 +708,6 @@ function UpdateAvailableState({
|
|||||||
onSaveUpdateDownloadProxy: () => void;
|
onSaveUpdateDownloadProxy: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
const [send] = useJsonRpc();
|
|
||||||
const [sigUpdateLoading, setSigUpdateLoading] = useState(false);
|
|
||||||
const [sigUpdateResult, setSigUpdateResult] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleUpdateSignatures = useCallback(() => {
|
|
||||||
setSigUpdateLoading(true);
|
|
||||||
setSigUpdateResult(null);
|
|
||||||
send("updateSignatures", {}, resp => {
|
|
||||||
setSigUpdateLoading(false);
|
|
||||||
if ("error" in resp) {
|
|
||||||
setSigUpdateResult(`Failed: ${resp.error.data || "Unknown error"}`);
|
|
||||||
notifications.error(`Signature update failed: ${resp.error.data || "Unknown error"}`);
|
|
||||||
} else {
|
|
||||||
const result = resp.result as {
|
|
||||||
appSignatureUpdated: boolean;
|
|
||||||
systemSignatureUpdated: boolean;
|
|
||||||
appSignatureValid: boolean;
|
|
||||||
systemSignatureValid: boolean;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
if (result.error) {
|
|
||||||
setSigUpdateResult(`Failed: ${result.error}`);
|
|
||||||
notifications.error(`Signature update failed: ${result.error}`);
|
|
||||||
} else {
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (result.appSignatureUpdated) parts.push("App signature updated");
|
|
||||||
if (result.systemSignatureUpdated) parts.push("System signature updated");
|
|
||||||
setSigUpdateResult(parts.join(", ") || "No signatures to update");
|
|
||||||
notifications.success("Signatures updated successfully");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [send]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||||
<div className="w-full space-y-4">
|
<div className="w-full space-y-4">
|
||||||
@@ -970,32 +757,6 @@ function UpdateAvailableState({
|
|||||||
</AntdButton>
|
</AntdButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-base font-semibold text-black dark:text-white">
|
|
||||||
{$at("Update Signatures")}
|
|
||||||
</p>
|
|
||||||
<p className="mb-2 text-sm text-slate-600 dark:text-slate-300">
|
|
||||||
{$at("Update the signature of kvm_app to the latest version. If the current version is not up to date, signature verification will fail.")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{sigUpdateResult && (
|
|
||||||
<div className="rounded-md border border-blue-500 bg-blue-50 p-3 dark:border-blue-600 dark:bg-blue-900/30">
|
|
||||||
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">
|
|
||||||
{$at("Signature Update Result")}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-blue-700 dark:text-blue-300">
|
|
||||||
{sigUpdateResult}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-start gap-x-2">
|
|
||||||
<AntdButton type="primary" loading={sigUpdateLoading} onClick={handleUpdateSignatures}>
|
|
||||||
{$at("Update")}
|
|
||||||
</AntdButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1057,101 +818,3 @@ function UpdateErrorState({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SignatureStatusCard({
|
|
||||||
signatureStatus,
|
|
||||||
signatureStatusLoading,
|
|
||||||
}: {
|
|
||||||
signatureStatus: {
|
|
||||||
appSignatureAbsent: boolean;
|
|
||||||
appSignatureInvalid: boolean;
|
|
||||||
appNoPublicKey: boolean;
|
|
||||||
signatureVerified: boolean;
|
|
||||||
} | null;
|
|
||||||
signatureStatusLoading: boolean;
|
|
||||||
}) {
|
|
||||||
const { $at } = useReactAt();
|
|
||||||
if (signatureStatusLoading) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border border-slate-300 bg-slate-50 p-3 dark:border-slate-600 dark:bg-slate-900/30">
|
|
||||||
<div className="flex items-center gap-x-2">
|
|
||||||
<LoadingSpinner className="h-4 w-4 text-[rgba(22,152,217,1)] dark:text-[rgba(45,106,229,1)]" />
|
|
||||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
|
|
||||||
{$at("Verifying signature...")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs text-slate-600 dark:text-slate-300">
|
|
||||||
{$at("Please wait while verifying firmware signature.")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!signatureStatus) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
|
|
||||||
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
|
||||||
{$at("Signature Status Unavailable")}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
|
|
||||||
{$at("Unable to retrieve signature verification status.")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (signatureStatus.signatureVerified) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border border-green-500 bg-green-50 p-3 dark:border-green-600 dark:bg-green-900/30">
|
|
||||||
<p className="text-sm font-medium text-green-800 dark:text-green-200">
|
|
||||||
<CheckCircleIcon className="inline h-4 w-4 mr-1" />
|
|
||||||
{$at("Signature Verified")}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-green-700 dark:text-green-300">
|
|
||||||
{$at("Firmware signature has been verified and is valid.")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (signatureStatus.appSignatureAbsent) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
|
|
||||||
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
|
||||||
{$at("Missing Signature File")}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
|
|
||||||
{$at("The current firmware is missing signature files. Integrity cannot be fully verified.")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (signatureStatus.appSignatureInvalid) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border border-red-500 bg-red-50 p-3 dark:border-red-600 dark:bg-red-900/30">
|
|
||||||
<p className="text-sm font-medium text-red-800 dark:text-red-200">
|
|
||||||
{$at("Signature Verification Failed")}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-red-700 dark:text-red-300">
|
|
||||||
{$at("The signature file exists but does not match the firmware. This may indicate tampering.")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (signatureStatus.appNoPublicKey) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
|
|
||||||
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
|
||||||
{$at("No Embedded Public Key")}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
|
|
||||||
{$at("This build does not have an OTA public key embedded. Signature verification is unavailable.")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
|
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
|
||||||
import { useClose } from "@headlessui/react";
|
import { useClose } from "@headlessui/react";
|
||||||
import { Checkbox, Button, Input } from "antd";
|
import { Checkbox, Button } from "antd";
|
||||||
import { useReactAt } from "i18n-auto-extractor/react";
|
import { useReactAt } from "i18n-auto-extractor/react";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
|
|
||||||
import { TextAreaWithLabel } from "@components/TextArea";
|
import { TextAreaWithLabel } from "@components/TextArea";
|
||||||
import { SettingsItem } from "@components/Settings/SettingsView";
|
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { useHidStore, useRTCStore, useUiStore, useSettingsStore, useVideoStore } from "@/hooks/stores";
|
import { useHidStore, useRTCStore, useUiStore, useSettingsStore } from "@/hooks/stores";
|
||||||
import { keys, modifiers } from "@/keyboardMappings";
|
import { keys, modifiers } from "@/keyboardMappings";
|
||||||
import { layouts, chars } from "@/keyboardLayouts";
|
import { layouts, chars } from "@/keyboardLayouts";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import { eventMatchesShortcut, shortcutFromKeyboardEvent } from "@/utils/shortcuts";
|
|
||||||
|
|
||||||
const hidKeyboardPayload = (keys: number[], modifier: number) => {
|
const hidKeyboardPayload = (keys: number[], modifier: number) => {
|
||||||
return { keys, modifier };
|
return { keys, modifier };
|
||||||
@@ -30,24 +28,15 @@ export default function Clipboard() {
|
|||||||
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||||
const setSidebarView = useUiStore(state => state.setSidebarView);
|
const setSidebarView = useUiStore(state => state.setSidebarView);
|
||||||
const toggleTopBarView = useUiStore(state => state.toggleTopBarView);
|
const toggleTopBarView = useUiStore(state => state.toggleTopBarView);
|
||||||
const isOcrMode = useUiStore(state => state.isOcrMode);
|
|
||||||
const setOcrMode = useUiStore(state => state.setOcrMode);
|
|
||||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||||
const videoWidth = useVideoStore(state => state.width);
|
|
||||||
const videoHeight = useVideoStore(state => state.height);
|
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||||
|
|
||||||
const [invalidChars, setInvalidChars] = useState<string[]>([]);
|
const [invalidChars, setInvalidChars] = useState<string[]>([]);
|
||||||
const close = useClose();
|
const close = useClose();
|
||||||
const pasteShortcutEnabled = useSettingsStore(state => state.pasteShortcutEnabled);
|
const overrideCtrlV = useSettingsStore(state => state.overrideCtrlV);
|
||||||
const setPasteShortcutEnabled = useSettingsStore(state => state.setPasteShortcutEnabled);
|
const setOverrideCtrlV = useSettingsStore(state => state.setOverrideCtrlV);
|
||||||
const pasteShortcut = useSettingsStore(state => state.pasteShortcut);
|
const [pasteBuffer, setPasteBuffer] = useState<string>("");
|
||||||
const setPasteShortcut = useSettingsStore(state => state.setPasteShortcut);
|
|
||||||
const ocrShortcutEnabled = useSettingsStore(state => state.ocrShortcutEnabled);
|
|
||||||
const setOcrShortcutEnabled = useSettingsStore(state => state.setOcrShortcutEnabled);
|
|
||||||
const ocrShortcut = useSettingsStore(state => state.ocrShortcut);
|
|
||||||
const setOcrShortcut = useSettingsStore(state => state.setOcrShortcut);
|
|
||||||
const [readyToRender, setReadyToRender] = useState(false);
|
const [readyToRender, setReadyToRender] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -138,6 +127,7 @@ export default function Clipboard() {
|
|||||||
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, safeKeyboardLayout]);
|
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, safeKeyboardLayout]);
|
||||||
|
|
||||||
const handleTextSend = useCallback(async (text: string) => {
|
const handleTextSend = useCallback(async (text: string) => {
|
||||||
|
setPasteBuffer(text);
|
||||||
const segInvalid = [
|
const segInvalid = [
|
||||||
...new Set(
|
...new Set(
|
||||||
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
|
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
|
||||||
@@ -202,35 +192,12 @@ export default function Clipboard() {
|
|||||||
}, [handleTextSend]);
|
}, [handleTextSend]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (readyToRender && TextAreaRef.current) {
|
// When overrideCtrlV is true, we want to focus the container div to capture paste events
|
||||||
|
// When it is false, we want to focus the textarea if it exists
|
||||||
|
if (!overrideCtrlV && TextAreaRef.current) {
|
||||||
TextAreaRef.current.focus();
|
TextAreaRef.current.focus();
|
||||||
}
|
}
|
||||||
}, [readyToRender]);
|
}, [readyToRender, overrideCtrlV]);
|
||||||
|
|
||||||
const handleShortcutInput = useCallback(
|
|
||||||
(setter: (shortcut: string) => void) => (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
const shortcut = shortcutFromKeyboardEvent(e.nativeEvent);
|
|
||||||
if (!shortcut) return;
|
|
||||||
setter(shortcut);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleOpenOcr = useCallback(() => {
|
|
||||||
if (videoWidth === 0 || videoHeight === 0) {
|
|
||||||
notifications.error($at("No video signal"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOcrMode(!isOcrMode);
|
|
||||||
close();
|
|
||||||
if (isMobile) {
|
|
||||||
toggleTopBarView("ClipboardMobile");
|
|
||||||
} else {
|
|
||||||
setSidebarView(null);
|
|
||||||
}
|
|
||||||
}, [videoWidth, videoHeight, $at, setOcrMode, isOcrMode, close, toggleTopBarView, setSidebarView]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 py-3" >
|
<div className="space-y-4 py-3" >
|
||||||
@@ -238,42 +205,32 @@ export default function Clipboard() {
|
|||||||
<div className="h-full space-y-4">
|
<div className="h-full space-y-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_140px] items-center gap-2 sm:grid-cols-[minmax(0,1fr)_180px]">
|
<div className="flex items-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
className="min-w-0"
|
checked={overrideCtrlV}
|
||||||
checked={pasteShortcutEnabled}
|
onChange={e => setOverrideCtrlV(e.target.checked)}
|
||||||
onChange={e => setPasteShortcutEnabled(e.target.checked)}
|
|
||||||
>
|
>
|
||||||
<span className="whitespace-normal break-words">
|
{$at("Use Ctrl+V to paste clipboard to remote")}
|
||||||
{$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={pasteShortcutEnabled ? 0 : -1}
|
tabIndex={overrideCtrlV ? 0 : -1}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
if (el && pasteShortcutEnabled && readyToRender) {
|
if (el && overrideCtrlV && readyToRender) {
|
||||||
el.focus();
|
el.focus();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onKeyUp={e => e.stopPropagation()}
|
onKeyUp={e => e.stopPropagation()}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (pasteShortcutEnabled && eventMatchesShortcut(e.nativeEvent, pasteShortcut)) {
|
if (overrideCtrlV && (e.key.toLowerCase() === "v" || e.code === "KeyV") && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
readClipboardToBufferAndSend();
|
readClipboardToBufferAndSend();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onPaste={e => {
|
onPaste={e => {
|
||||||
if (pasteShortcutEnabled) {
|
if (overrideCtrlV) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const txt = e.clipboardData?.getData("text") || "";
|
const txt = e.clipboardData?.getData("text") || "";
|
||||||
if (txt) {
|
if (txt) {
|
||||||
@@ -283,7 +240,7 @@ export default function Clipboard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
{readyToRender && <TextAreaWithLabel
|
{!overrideCtrlV && readyToRender && <TextAreaWithLabel
|
||||||
ref={TextAreaRef}
|
ref={TextAreaRef}
|
||||||
label={$at("Copy text from your client to the remote host")}
|
label={$at("Copy text from your client to the remote host")}
|
||||||
rows={4}
|
rows={4}
|
||||||
@@ -338,50 +295,32 @@ export default function Clipboard() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex animate-fadeIn opacity-0 flex-col gap-y-2"
|
className="flex animate-fadeIn opacity-0 items-center justify-start gap-x-2"
|
||||||
style={{
|
style={{
|
||||||
animationDuration: "0.7s",
|
animationDuration: "0.7s",
|
||||||
animationDelay: "0.2s",
|
animationDelay: "0.2s",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
||||||
type="primary"
|
type="primary"
|
||||||
className="w-full"
|
className={isMobile ? "w-[49%]" : ""}
|
||||||
onClick={onConfirmPaste}
|
onClick={onConfirmPaste}
|
||||||
>
|
>
|
||||||
{$at("Confirm paste")}</Button>
|
{$at("Confirm paste")}</Button>
|
||||||
|
|
||||||
<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
|
||||||
type="primary"
|
className={isMobile ? "w-[49%]" : ""}
|
||||||
className={`${isMobile ? "w-full" : ""}`}
|
onClick={() => {
|
||||||
onClick={handleOpenOcr}
|
onCancelPasteMode();
|
||||||
>
|
close();
|
||||||
{$at("Open")}
|
if(isMobile){
|
||||||
</Button>
|
toggleTopBarView("ClipboardMobile");
|
||||||
</SettingsItem>
|
}else{
|
||||||
|
setSidebarView(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>{$at("Cancel")}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ 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}
|
||||||
@@ -126,8 +125,8 @@ const SettingsMacrosEdit: React.FC<MenuComponentProps> = ({ onMenuSelect,macroId
|
|||||||
onMenuSelect("index");
|
onMenuSelect("index");
|
||||||
}}
|
}}
|
||||||
isSubmitting={isUpdating}
|
isSubmitting={isUpdating}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={showDeleteConfirm}
|
open={showDeleteConfirm}
|
||||||
|
|||||||
@@ -67,8 +67,6 @@ interface StorageFilePageProps {
|
|||||||
onUnmountSDStorage?: () => void;
|
onUnmountSDStorage?: () => void;
|
||||||
onFormatSDStorage?: () => void;
|
onFormatSDStorage?: () => void;
|
||||||
onMountSDStorage?: () => void;
|
onMountSDStorage?: () => void;
|
||||||
fsType?: 'exfat' | 'fat32';
|
|
||||||
onFsTypeChange?: (value: 'exfat' | 'fat32') => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FileManager: React.FC<StorageFilePageProps> = ({
|
export const FileManager: React.FC<StorageFilePageProps> = ({
|
||||||
@@ -81,8 +79,6 @@ export const FileManager: React.FC<StorageFilePageProps> = ({
|
|||||||
onResetSDStorage,
|
onResetSDStorage,
|
||||||
onUnmountSDStorage,
|
onUnmountSDStorage,
|
||||||
onFormatSDStorage,
|
onFormatSDStorage,
|
||||||
fsType,
|
|
||||||
onFsTypeChange,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
|
|
||||||
@@ -255,18 +251,6 @@ export const FileManager: React.FC<StorageFilePageProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
{sdMountStatus !== "none" && (
|
{sdMountStatus !== "none" && (
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<div className="w-full space-y-2">
|
|
||||||
<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
|
<AntdButton
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
danger={true}
|
danger={true}
|
||||||
@@ -274,10 +258,9 @@ export const FileManager: React.FC<StorageFilePageProps> = ({
|
|||||||
onClick={handleFormatWrapper}
|
onClick={handleFormatWrapper}
|
||||||
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
|
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
|
||||||
>
|
>
|
||||||
{$at("Format MicroSD Card")} ({(fsType || "fat32")})
|
{$at("Format MicroSD Card")}
|
||||||
</AntdButton>
|
</AntdButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -335,8 +318,6 @@ export const FileManager: React.FC<StorageFilePageProps> = ({
|
|||||||
onUnmountSDStorage={handleUnmountWrapper}
|
onUnmountSDStorage={handleUnmountWrapper}
|
||||||
onFormatSDStorage={handleFormatWrapper}
|
onFormatSDStorage={handleFormatWrapper}
|
||||||
syncStorage={syncStorage}
|
syncStorage={syncStorage}
|
||||||
fsType={fsType}
|
|
||||||
onFsTypeChange={onFsTypeChange}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{uploadFile ? (
|
{uploadFile ? (
|
||||||
@@ -523,8 +504,6 @@ interface ActionButtonsSectionProps {
|
|||||||
onUnmountSDStorage?: () => void;
|
onUnmountSDStorage?: () => void;
|
||||||
onFormatSDStorage?: () => void;
|
onFormatSDStorage?: () => void;
|
||||||
syncStorage: () => void;
|
syncStorage: () => void;
|
||||||
fsType?: 'exfat' | 'fat32';
|
|
||||||
onFsTypeChange?: (value: 'exfat' | 'fat32') => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ActionButtonsSection: React.FC<ActionButtonsSectionProps> = ({
|
const ActionButtonsSection: React.FC<ActionButtonsSectionProps> = ({
|
||||||
@@ -533,36 +512,21 @@ const ActionButtonsSection: React.FC<ActionButtonsSectionProps> = ({
|
|||||||
showSDManagement,
|
showSDManagement,
|
||||||
onUnmountSDStorage,
|
onUnmountSDStorage,
|
||||||
onFormatSDStorage,
|
onFormatSDStorage,
|
||||||
fsType,
|
|
||||||
onFsTypeChange,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
|
|
||||||
if (mediaType === "sd" && showSDManagement) {
|
if (mediaType === "sd" && showSDManagement) {
|
||||||
return (
|
return (
|
||||||
<div className="animate-fadeIn space-y-2 opacity-0"
|
<div className="flex animate-fadeIn justify-between gap-2 opacity-0"
|
||||||
style={{ animationDuration: "0.7s", animationDelay: "0.25s" }}
|
style={{ animationDuration: "0.7s", animationDelay: "0.25s" }}
|
||||||
>
|
>
|
||||||
<div className="w-full space-y-2">
|
|
||||||
<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
|
<AntdButton
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
type="primary"
|
type="primary"
|
||||||
danger={true}
|
danger={true}
|
||||||
onClick={onFormatSDStorage}
|
onClick={onFormatSDStorage}
|
||||||
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
|
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
|
||||||
>{$at("Format MicroSD Card")} ({(fsType || "fat32")})</AntdButton>
|
>{$at("Format MicroSD Card")}</AntdButton>
|
||||||
</div>
|
|
||||||
<AntdButton
|
<AntdButton
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
type="primary"
|
type="primary"
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export default function SDFilePage() {
|
|||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [fsType, setFsType] = useState<'exfat' | 'fat32'>('fat32');
|
|
||||||
|
|
||||||
const handleResetSDStorage = async () => {
|
const handleResetSDStorage = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -38,11 +37,11 @@ export default function SDFilePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleFormatSDStorage = async () => {
|
const handleFormatSDStorage = async () => {
|
||||||
if (!window.confirm($at(`Formatting the SD card as ${fsType.toUpperCase()} will erase all data. Continue?`))) {
|
if (!window.confirm($at("Formatting the SD card will erase all data. Continue?"))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
send("formatSDStorage", { confirm: true, fsType }, res => {
|
send("formatSDStorage", { confirm: true }, res => {
|
||||||
if ("error" in res) {
|
if ("error" in res) {
|
||||||
notifications.error(res.error.data || res.error.message);
|
notifications.error(res.error.data || res.error.message);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -55,7 +54,6 @@ export default function SDFilePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<FileManager
|
<FileManager
|
||||||
mediaType="sd"
|
mediaType="sd"
|
||||||
returnTo="/sd-files"
|
returnTo="/sd-files"
|
||||||
@@ -67,9 +65,6 @@ export default function SDFilePage() {
|
|||||||
onResetSDStorage={handleResetSDStorage}
|
onResetSDStorage={handleResetSDStorage}
|
||||||
onUnmountSDStorage={handleUnmountSDStorage}
|
onUnmountSDStorage={handleUnmountSDStorage}
|
||||||
onFormatSDStorage={handleFormatSDStorage}
|
onFormatSDStorage={handleFormatSDStorage}
|
||||||
fsType={fsType}
|
|
||||||
onFsTypeChange={setFsType}
|
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Button as AntdButton , Slider , Checkbox, Select, Modal, InputNumber, Tabs, Typography } from "antd";
|
import { Button as AntdButton , Slider , Checkbox, Select } from "antd";
|
||||||
import { useReactAt } from "i18n-auto-extractor/react";
|
import { useReactAt } from "i18n-auto-extractor/react";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
|
|
||||||
@@ -10,7 +10,6 @@ import { SettingsItem, SettingsItemNew } from "@components/Settings/SettingsView
|
|||||||
|
|
||||||
import notifications from "../../../notifications";
|
import notifications from "../../../notifications";
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -46,133 +45,6 @@ const streamQualityOptions = [
|
|||||||
{ value: "0.1", label: "Low" },
|
{ value: "0.1", label: "Low" },
|
||||||
];
|
];
|
||||||
|
|
||||||
type RcQpParams = {
|
|
||||||
s32FirstFrameStartQp: number;
|
|
||||||
u32StepQp: number;
|
|
||||||
u32MinQp: number;
|
|
||||||
u32MaxQp: number;
|
|
||||||
u32MinIQp: number;
|
|
||||||
u32MaxIQp: number;
|
|
||||||
s32DeltIpQp: number;
|
|
||||||
s32MaxReEncodeTimes: number;
|
|
||||||
u32FrmMaxQp: number;
|
|
||||||
u32FrmMinQp: number;
|
|
||||||
u32FrmMinIQp: number;
|
|
||||||
u32FrmMaxIQp: number;
|
|
||||||
u32MotionStaticSwitchFrmQp: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type VideoRcConfig = {
|
|
||||||
h264: RcQpParams;
|
|
||||||
h265: RcQpParams;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RcSliderValues = {
|
|
||||||
stepQp: number;
|
|
||||||
minQp: number;
|
|
||||||
minIQp: number;
|
|
||||||
deltIpQp: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RcSliderState = {
|
|
||||||
h264: RcSliderValues;
|
|
||||||
h265: RcSliderValues;
|
|
||||||
};
|
|
||||||
|
|
||||||
const clamp = (value: number, min: number, max: number) =>
|
|
||||||
Math.min(max, Math.max(min, value));
|
|
||||||
|
|
||||||
const sliderValueToNumber = (value: number | number[]) =>
|
|
||||||
Array.isArray(value) ? value[0] : value;
|
|
||||||
|
|
||||||
const DEFAULT_RC_CODEC: RcQpParams = {
|
|
||||||
s32FirstFrameStartQp: 0,
|
|
||||||
u32StepQp: 48,
|
|
||||||
u32MinQp: 48,
|
|
||||||
u32MaxQp: 51,
|
|
||||||
u32MinIQp: 48,
|
|
||||||
u32MaxIQp: 51,
|
|
||||||
s32DeltIpQp: 7,
|
|
||||||
s32MaxReEncodeTimes: 2,
|
|
||||||
u32FrmMaxQp: 51,
|
|
||||||
u32FrmMinQp: 48,
|
|
||||||
u32FrmMinIQp: 51,
|
|
||||||
u32FrmMaxIQp: 48,
|
|
||||||
u32MotionStaticSwitchFrmQp: 50,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_VIDEO_RC_CONFIG: VideoRcConfig = {
|
|
||||||
h264: { ...DEFAULT_RC_CODEC },
|
|
||||||
h265: { ...DEFAULT_RC_CODEC },
|
|
||||||
};
|
|
||||||
|
|
||||||
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
|
||||||
typeof value === "object" && value !== null;
|
|
||||||
|
|
||||||
const toVideoRcConfigOrDefault = (value: unknown): VideoRcConfig => {
|
|
||||||
if (!isObjectRecord(value)) return DEFAULT_VIDEO_RC_CONFIG;
|
|
||||||
if (!("h264" in value) || !("h265" in value)) return DEFAULT_VIDEO_RC_CONFIG;
|
|
||||||
return value as VideoRcConfig;
|
|
||||||
};
|
|
||||||
|
|
||||||
const RC_LIMITS = {
|
|
||||||
stepQp: { min: 1, max: 51 },
|
|
||||||
maxQp: { min: 1, max: 51 },
|
|
||||||
maxIQp: { min: 1, max: 51 },
|
|
||||||
deltIpQp: { min: -7, max: 7 },
|
|
||||||
maxReEncodeTimes: { min: 0, max: 3 },
|
|
||||||
frmQp: { min: 1, max: 51 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeRcCodec = (codec: RcQpParams): RcQpParams => {
|
|
||||||
const maxQp = clamp(Number(codec.u32MaxQp), RC_LIMITS.maxQp.min, RC_LIMITS.maxQp.max);
|
|
||||||
const maxIQp = clamp(Number(codec.u32MaxIQp), RC_LIMITS.maxIQp.min, RC_LIMITS.maxIQp.max);
|
|
||||||
const minQp = clamp(Number(codec.u32MinQp), RC_LIMITS.maxQp.min, maxQp);
|
|
||||||
const minIQp = clamp(Number(codec.u32MinIQp), RC_LIMITS.maxIQp.min, maxIQp);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...codec,
|
|
||||||
s32FirstFrameStartQp: clamp(Number(codec.s32FirstFrameStartQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max),
|
|
||||||
u32StepQp: clamp(Number(codec.u32StepQp), RC_LIMITS.stepQp.min, RC_LIMITS.stepQp.max),
|
|
||||||
u32MaxQp: maxQp,
|
|
||||||
u32MinQp: minQp,
|
|
||||||
u32MaxIQp: maxIQp,
|
|
||||||
u32MinIQp: minIQp,
|
|
||||||
s32DeltIpQp: clamp(Number(codec.s32DeltIpQp), RC_LIMITS.deltIpQp.min, RC_LIMITS.deltIpQp.max),
|
|
||||||
s32MaxReEncodeTimes: clamp(
|
|
||||||
Number(codec.s32MaxReEncodeTimes),
|
|
||||||
RC_LIMITS.maxReEncodeTimes.min,
|
|
||||||
RC_LIMITS.maxReEncodeTimes.max,
|
|
||||||
),
|
|
||||||
u32FrmMaxQp: clamp(Number(codec.u32FrmMaxQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max),
|
|
||||||
u32FrmMinQp: clamp(Number(codec.u32FrmMinQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max),
|
|
||||||
u32FrmMinIQp: clamp(Number(codec.u32FrmMinIQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max),
|
|
||||||
u32FrmMaxIQp: clamp(Number(codec.u32FrmMaxIQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max),
|
|
||||||
u32MotionStaticSwitchFrmQp: clamp(
|
|
||||||
Number(codec.u32MotionStaticSwitchFrmQp),
|
|
||||||
RC_LIMITS.frmQp.min,
|
|
||||||
RC_LIMITS.frmQp.max,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeVideoRcConfig = (config: VideoRcConfig): VideoRcConfig => ({
|
|
||||||
h264: normalizeRcCodec(config.h264),
|
|
||||||
h265: normalizeRcCodec(config.h265),
|
|
||||||
});
|
|
||||||
|
|
||||||
const sliderValuesFromCodec = (codec: RcQpParams): RcSliderValues => ({
|
|
||||||
stepQp: clamp(Number(codec.u32StepQp), 1, 50),
|
|
||||||
minQp: clamp(Number(codec.u32MinQp), 1, 50),
|
|
||||||
minIQp: clamp(Number(codec.u32MinIQp), 1, 50),
|
|
||||||
deltIpQp: clamp(Number(codec.s32DeltIpQp), -7, 7),
|
|
||||||
});
|
|
||||||
|
|
||||||
const sliderStateFromConfig = (config: VideoRcConfig): RcSliderState => ({
|
|
||||||
h264: sliderValuesFromCodec(config.h264),
|
|
||||||
h265: sliderValuesFromCodec(config.h265),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function SettingsVideoSide() {
|
export default function SettingsVideoSide() {
|
||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
@@ -182,12 +54,6 @@ export default function SettingsVideoSide() {
|
|||||||
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
|
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
|
||||||
const [edid, setEdid] = useState<string | null>(null);
|
const [edid, setEdid] = useState<string | null>(null);
|
||||||
const [forceHpd, setForceHpd] = useState(false);
|
const [forceHpd, setForceHpd] = useState(false);
|
||||||
const [videoRcConfig, setVideoRcConfig] = useState<VideoRcConfig>(DEFAULT_VIDEO_RC_CONFIG);
|
|
||||||
const [rcSliderValues, setRcSliderValues] = useState<RcSliderState>(
|
|
||||||
sliderStateFromConfig(DEFAULT_VIDEO_RC_CONFIG),
|
|
||||||
);
|
|
||||||
const [showRcAdvanced, setShowRcAdvanced] = useState(false);
|
|
||||||
const [rcDraftConfig, setRcDraftConfig] = useState<VideoRcConfig | null>(null);
|
|
||||||
|
|
||||||
// Video enhancement settings from store
|
// Video enhancement settings from store
|
||||||
const videoSaturation = useSettingsStore(state => state.videoSaturation);
|
const videoSaturation = useSettingsStore(state => state.videoSaturation);
|
||||||
@@ -197,129 +63,6 @@ export default function SettingsVideoSide() {
|
|||||||
const videoContrast = useSettingsStore(state => state.videoContrast);
|
const videoContrast = useSettingsStore(state => state.videoContrast);
|
||||||
const setVideoContrast = useSettingsStore(state => state.setVideoContrast);
|
const setVideoContrast = useSettingsStore(state => state.setVideoContrast);
|
||||||
|
|
||||||
const currentCodec: "h264" | "h265" = streamEncodecType === "hevc" ? "h265" : "h264";
|
|
||||||
const currentSliders = rcSliderValues[currentCodec];
|
|
||||||
|
|
||||||
const applySliderToCodec = (codec: RcQpParams, sliders: RcSliderValues): RcQpParams => ({
|
|
||||||
...codec,
|
|
||||||
u32StepQp: sliders.stepQp,
|
|
||||||
u32MinQp: sliders.minQp,
|
|
||||||
u32MinIQp: sliders.minIQp,
|
|
||||||
s32DeltIpQp: sliders.deltIpQp,
|
|
||||||
});
|
|
||||||
|
|
||||||
const applyRcBasicConfig = () => {
|
|
||||||
const nextRcConfig = normalizeVideoRcConfig({
|
|
||||||
...videoRcConfig,
|
|
||||||
[currentCodec]: applySliderToCodec(videoRcConfig[currentCodec], currentSliders),
|
|
||||||
});
|
|
||||||
send("setVideoRc", { params: nextRcConfig }, resp => {
|
|
||||||
if ("error" in resp) {
|
|
||||||
notifications.error(`Failed to set video RC: ${resp.error.data || "Unknown error"}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setVideoRcConfig(nextRcConfig);
|
|
||||||
setRcDraftConfig(nextRcConfig);
|
|
||||||
setRcSliderValues(sliderStateFromConfig(nextRcConfig));
|
|
||||||
notifications.success("Video RC updated");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateRcDraftField = (
|
|
||||||
codec: "h264" | "h265",
|
|
||||||
field: keyof RcQpParams,
|
|
||||||
value: number,
|
|
||||||
) => {
|
|
||||||
setRcDraftConfig(prev => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
const nextCodec = { ...prev[codec], [field]: value };
|
|
||||||
const normalizedCodec = normalizeRcCodec(nextCodec);
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
[codec]: normalizedCodec,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const openRcAdvancedModal = () => {
|
|
||||||
const nextDraft = normalizeVideoRcConfig({
|
|
||||||
...videoRcConfig,
|
|
||||||
[currentCodec]: applySliderToCodec(videoRcConfig[currentCodec], currentSliders),
|
|
||||||
});
|
|
||||||
setRcDraftConfig(nextDraft);
|
|
||||||
setShowRcAdvanced(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyRcAdvancedConfig = () => {
|
|
||||||
if (!rcDraftConfig) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const normalized = normalizeVideoRcConfig(rcDraftConfig);
|
|
||||||
send("setVideoRc", { params: normalized }, resp => {
|
|
||||||
if ("error" in resp) {
|
|
||||||
notifications.error(`Failed to set video RC: ${resp.error.data || "Unknown error"}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setVideoRcConfig(normalized);
|
|
||||||
setRcDraftConfig(normalized);
|
|
||||||
setRcSliderValues(sliderStateFromConfig(normalized));
|
|
||||||
notifications.success("Video RC updated");
|
|
||||||
setShowRcAdvanced(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderRcAdvancedForm = (codec: "h264" | "h265") => {
|
|
||||||
const current = rcDraftConfig?.[codec];
|
|
||||||
if (!current) return null;
|
|
||||||
|
|
||||||
const fields: Array<{
|
|
||||||
key: keyof RcQpParams;
|
|
||||||
label: string;
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
}> = [
|
|
||||||
{ key: "s32FirstFrameStartQp", label: "FirstFrameStartQp", min: 1, max: 51 },
|
|
||||||
{ key: "u32StepQp", label: "StepQp", min: 1, max: 51 },
|
|
||||||
{ key: "u32MaxQp", label: "MaxQp", min: 1, max: 51 },
|
|
||||||
{ key: "u32MinQp", label: "MinQp", min: 1, max: Number(current.u32MaxQp) || 51 },
|
|
||||||
{ key: "u32MaxIQp", label: "MaxIQp", min: 1, max: 51 },
|
|
||||||
{ key: "u32MinIQp", label: "MinIQp", min: 1, max: Number(current.u32MaxIQp) || 51 },
|
|
||||||
{ key: "s32DeltIpQp", label: "DeltIpQp", min: -7, max: 7 },
|
|
||||||
{ key: "s32MaxReEncodeTimes", label: "MaxReEncodeTimes", min: 0, max: 3 },
|
|
||||||
{ key: "u32FrmMaxQp", label: "FrmMaxQp", min: 1, max: 51 },
|
|
||||||
{ key: "u32FrmMinQp", label: "FrmMinQp", min: 1, max: 51 },
|
|
||||||
{ key: "u32FrmMaxIQp", label: "FrmMaxIQp", min: 1, max: 51 },
|
|
||||||
{ key: "u32FrmMinIQp", label: "FrmMinIQp", min: 1, max: 51 },
|
|
||||||
{ key: "u32MotionStaticSwitchFrmQp", label: "MotionStaticSwitchFrmQp", min: 1, max: 51 },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 gap-3">
|
|
||||||
{fields.map(field => (
|
|
||||||
<div key={`${codec}-${field.key}`} className="flex items-center justify-between gap-3">
|
|
||||||
<Text className="text-xs">{field.label}</Text>
|
|
||||||
<InputNumber
|
|
||||||
size="small"
|
|
||||||
value={current[field.key]}
|
|
||||||
min={field.min}
|
|
||||||
max={field.max}
|
|
||||||
step={1}
|
|
||||||
style={{ width: 140 }}
|
|
||||||
onChange={val => {
|
|
||||||
if (typeof val !== "number") return;
|
|
||||||
const safeValue =
|
|
||||||
field.min !== undefined && field.max !== undefined
|
|
||||||
? clamp(Number(val), field.min, field.max)
|
|
||||||
: Number(val);
|
|
||||||
updateRcDraftField(codec, field.key, safeValue);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
send("getNpuAppStatus", {}, resp => {
|
send("getNpuAppStatus", {}, resp => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
@@ -336,20 +79,6 @@ export default function SettingsVideoSide() {
|
|||||||
setStreamQuality(String(resp.result));
|
setStreamQuality(String(resp.result));
|
||||||
});
|
});
|
||||||
|
|
||||||
send("getVideoRc", {}, resp => {
|
|
||||||
if ("error" in resp) {
|
|
||||||
notifications.error(`Failed to get video RC: ${resp.error.data || "Unknown error"}`);
|
|
||||||
const fallbackRc = normalizeVideoRcConfig(DEFAULT_VIDEO_RC_CONFIG);
|
|
||||||
setVideoRcConfig(fallbackRc);
|
|
||||||
setRcSliderValues(sliderStateFromConfig(fallbackRc));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rc = normalizeVideoRcConfig(toVideoRcConfigOrDefault(resp.result));
|
|
||||||
setVideoRcConfig(rc);
|
|
||||||
setRcSliderValues(sliderStateFromConfig(rc));
|
|
||||||
});
|
|
||||||
|
|
||||||
send("getEDID", {}, resp => {
|
send("getEDID", {}, resp => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`);
|
notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`);
|
||||||
@@ -489,116 +218,6 @@ export default function SettingsVideoSide() {
|
|||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|
||||||
<SettingsItem
|
|
||||||
title={$at("RC Control")}
|
|
||||||
description={$at("Adjust rate control QP settings for better balance between quality and bitrate")}
|
|
||||||
/>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<SettingsItemNew
|
|
||||||
title={$at("StepQp")}
|
|
||||||
description={String(currentSliders.stepQp)}
|
|
||||||
className={"flex-col w-full h-[40px]"}
|
|
||||||
>
|
|
||||||
<Slider
|
|
||||||
min={1}
|
|
||||||
max={51}
|
|
||||||
step={1}
|
|
||||||
value={currentSliders.stepQp}
|
|
||||||
onChange={value => {
|
|
||||||
const nextStepQp = clamp(sliderValueToNumber(value), 1, 50);
|
|
||||||
setRcSliderValues(prev => ({
|
|
||||||
...prev,
|
|
||||||
[currentCodec]: {
|
|
||||||
...prev[currentCodec],
|
|
||||||
stepQp: nextStepQp,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
className={"w-full"}
|
|
||||||
/>
|
|
||||||
</SettingsItemNew>
|
|
||||||
|
|
||||||
<SettingsItemNew
|
|
||||||
title={$at("MinQp")}
|
|
||||||
description={String(currentSliders.minQp)}
|
|
||||||
className={"flex-col w-full h-[40px]"}
|
|
||||||
>
|
|
||||||
<Slider
|
|
||||||
min={1}
|
|
||||||
max={50}
|
|
||||||
step={1}
|
|
||||||
value={currentSliders.minQp}
|
|
||||||
onChange={value => {
|
|
||||||
const nextMinQp = clamp(sliderValueToNumber(value), 1, 50);
|
|
||||||
setRcSliderValues(prev => ({
|
|
||||||
...prev,
|
|
||||||
[currentCodec]: {
|
|
||||||
...prev[currentCodec],
|
|
||||||
minQp: nextMinQp,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
className={"w-full"}
|
|
||||||
/>
|
|
||||||
</SettingsItemNew>
|
|
||||||
|
|
||||||
<SettingsItemNew
|
|
||||||
title={$at("MinIQp")}
|
|
||||||
description={String(currentSliders.minIQp)}
|
|
||||||
className={"flex-col w-full h-[40px]"}
|
|
||||||
>
|
|
||||||
<Slider
|
|
||||||
min={1}
|
|
||||||
max={50}
|
|
||||||
step={1}
|
|
||||||
value={currentSliders.minIQp}
|
|
||||||
onChange={value => {
|
|
||||||
const nextMinIQp = clamp(sliderValueToNumber(value), 1, 50);
|
|
||||||
setRcSliderValues(prev => ({
|
|
||||||
...prev,
|
|
||||||
[currentCodec]: {
|
|
||||||
...prev[currentCodec],
|
|
||||||
minIQp: nextMinIQp,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
className={"w-full"}
|
|
||||||
/>
|
|
||||||
</SettingsItemNew>
|
|
||||||
|
|
||||||
<SettingsItemNew
|
|
||||||
title={$at("DetlpQp")}
|
|
||||||
description={String(currentSliders.deltIpQp)}
|
|
||||||
className={"flex-col w-full h-[40px]"}
|
|
||||||
>
|
|
||||||
<Slider
|
|
||||||
min={-7}
|
|
||||||
max={7}
|
|
||||||
step={1}
|
|
||||||
value={currentSliders.deltIpQp}
|
|
||||||
onChange={value => {
|
|
||||||
const nextDeltIpQp = clamp(sliderValueToNumber(value), -7, 7);
|
|
||||||
setRcSliderValues(prev => ({
|
|
||||||
...prev,
|
|
||||||
[currentCodec]: {
|
|
||||||
...prev[currentCodec],
|
|
||||||
deltIpQp: nextDeltIpQp,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
className={"w-full"}
|
|
||||||
/>
|
|
||||||
</SettingsItemNew>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<AntdButton onClick={openRcAdvancedModal}>
|
|
||||||
{$at("Advanced")}
|
|
||||||
</AntdButton>
|
|
||||||
<AntdButton type="primary" onClick={applyRcBasicConfig}>
|
|
||||||
{$at("Apply")}
|
|
||||||
</AntdButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
title={$at("NPU Application")}
|
title={$at("NPU Application")}
|
||||||
@@ -743,7 +362,25 @@ export default function SettingsVideoSide() {
|
|||||||
}}
|
}}
|
||||||
options={[...edids, { value: "custom", label: "Custom" }]}
|
options={[...edids, { value: "custom", label: "Custom" }]}
|
||||||
/>
|
/>
|
||||||
|
{/* options={[...edids, { value: "custom", label: "Custom" }]}*/}
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
{/*<SelectMenuBasic*/}
|
||||||
|
{/* size="SM"*/}
|
||||||
|
{/* label=""*/}
|
||||||
|
{/* fullWidth*/}
|
||||||
|
{/* value={customEdidValue ? "custom" : edid || "asd"}*/}
|
||||||
|
{/* onChange={e => {*/}
|
||||||
|
{/* console.log(e.target.value)*/}
|
||||||
|
{/* if (e.target.value === "custom") {*/}
|
||||||
|
{/* setEdid("custom");*/}
|
||||||
|
{/* setCustomEdidValue("");*/}
|
||||||
|
{/* } else {*/}
|
||||||
|
{/* setCustomEdidValue(null);*/}
|
||||||
|
{/* handleEDIDChange(e.target.value as string);*/}
|
||||||
|
{/* }*/}
|
||||||
|
{/* }}*/}
|
||||||
|
{/* options={[...edids, { value: "custom", label: "Custom" }]}*/}
|
||||||
|
{/*/>*/}
|
||||||
|
|
||||||
{customEdidValue !== null && (
|
{customEdidValue !== null && (
|
||||||
<>
|
<>
|
||||||
@@ -781,39 +418,6 @@ export default function SettingsVideoSide() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={"h-[10vh]"}></div>
|
<div className={"h-[10vh]"}></div>
|
||||||
|
|
||||||
<Modal
|
|
||||||
title={
|
|
||||||
<Text strong style={{ fontSize: "16px" }}>
|
|
||||||
{$at("RC Advanced Config")}
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
open={showRcAdvanced}
|
|
||||||
onCancel={() => setShowRcAdvanced(false)}
|
|
||||||
onOk={applyRcAdvancedConfig}
|
|
||||||
okText={$at("Apply")}
|
|
||||||
cancelText={$at("Cancel")}
|
|
||||||
maskClosable={true}
|
|
||||||
keyboard={true}
|
|
||||||
width={520}
|
|
||||||
styles={{
|
|
||||||
body: {
|
|
||||||
padding: "20px 24px",
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
borderBottom: "1px solid #f0f0f0",
|
|
||||||
padding: "16px 24px",
|
|
||||||
marginBottom: 0,
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tabs
|
|
||||||
items={[
|
|
||||||
{ key: "h264", label: "H264", children: renderRcAdvancedForm("h264") },
|
|
||||||
{ key: "h265", label: "H265", children: renderRcAdvancedForm("h265") },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,6 @@ export default function ImageManager({
|
|||||||
const [sdMountStatus, setSDMountStatus] = useState<"ok" | "none" | "fail" | null>(storageType === 'sd' ? null : 'ok');
|
const [sdMountStatus, setSDMountStatus] = useState<"ok" | "none" | "fail" | null>(storageType === 'sd' ? null : 'ok');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [uploadFile, setUploadFile] = useState<string | null>(null);
|
const [uploadFile, setUploadFile] = useState<string | null>(null);
|
||||||
const [fsType, setFsType] = useState<'exfat' | 'fat32'>('fat32');
|
|
||||||
const filesPerPage = 5;
|
const filesPerPage = 5;
|
||||||
|
|
||||||
const percentageUsed = useMemo(() => {
|
const percentageUsed = useMemo(() => {
|
||||||
@@ -171,7 +170,7 @@ export default function ImageManager({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
send("formatSDStorage", { confirm: true, fsType }, res => {
|
send("formatSDStorage", { confirm: true }, res => {
|
||||||
if ("error" in res) {
|
if ("error" in res) {
|
||||||
notifications.error(res.error.data || res.error.message);
|
notifications.error(res.error.data || res.error.message);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -348,18 +347,6 @@ export default function ImageManager({
|
|||||||
</p>
|
</p>
|
||||||
{sdMountStatus !== "none" && (
|
{sdMountStatus !== "none" && (
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<div className="mx-auto w-full max-w-[360px] space-y-2">
|
|
||||||
<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
|
<AntdButton
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
danger={true}
|
danger={true}
|
||||||
@@ -367,10 +354,9 @@ export default function ImageManager({
|
|||||||
onClick={handleFormatSDStorage}
|
onClick={handleFormatSDStorage}
|
||||||
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
|
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
|
||||||
>
|
>
|
||||||
{$at("Format MicroSD Card")} ({fsType})
|
{$at("Format MicroSD Card")}
|
||||||
</AntdButton>
|
</AntdButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -507,29 +493,16 @@ export default function ImageManager({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{unmountApi && storageType === 'sd' && (
|
{unmountApi && storageType === 'sd' && (
|
||||||
<div className="animate-fadeIn space-y-2 opacity-0"
|
<div className="flex animate-fadeIn justify-between gap-2 opacity-0"
|
||||||
style={{ animationDuration: "0.7s", animationDelay: "0.25s" }}
|
style={{ animationDuration: "0.7s", animationDelay: "0.25s" }}
|
||||||
>
|
>
|
||||||
<div className="w-full space-y-2">
|
|
||||||
<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
|
<AntdButton
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
type="primary"
|
type="primary"
|
||||||
danger={true}
|
danger={true}
|
||||||
onClick={handleFormatSDStorage}
|
onClick={handleFormatSDStorage}
|
||||||
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
|
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
|
||||||
>{$at("Format MicroSD Card")} ({fsType})</AntdButton>
|
>{$at("Format MicroSD Card")}</AntdButton>
|
||||||
</div>
|
|
||||||
<AntdButton
|
<AntdButton
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
type="primary"
|
type="primary"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { Button as AntdButton, Typography } from "antd";
|
import { Button as AntdButton, Typography } from "antd";
|
||||||
import { useReactAt } from "i18n-auto-extractor/react";
|
import { useReactAt } from "i18n-auto-extractor/react";
|
||||||
import KeyboardSVG from "@assets/second/keyboard.svg?react";
|
import KeyboardSVG from "@assets/second/keyboard.svg?react";
|
||||||
@@ -22,10 +22,10 @@ import {
|
|||||||
useRTCStore,
|
useRTCStore,
|
||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
useUiStore,
|
useUiStore,
|
||||||
|
useVideoStore,
|
||||||
useVpnStore,
|
useVpnStore,
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { keyboards } from "@/keyboardLayouts";
|
|
||||||
import {
|
import {
|
||||||
button_primary_color,
|
button_primary_color,
|
||||||
dark_bd_style,
|
dark_bd_style,
|
||||||
@@ -48,13 +48,10 @@ const views = [
|
|||||||
export default function BottomBarMobile() {
|
export default function BottomBarMobile() {
|
||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
const keyboardLedState = useHidStore(state => state.keyboardLedState);
|
const keyboardLedState = useHidStore(state => state.keyboardLedState);
|
||||||
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
|
const videoSize = useVideoStore(
|
||||||
|
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
|
||||||
|
);
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
|
|
||||||
const layoutAbbrev = useMemo(() => {
|
|
||||||
if (!keyboardLayout) return "en_US";
|
|
||||||
return keyboardLayout;
|
|
||||||
}, [keyboardLayout]);
|
|
||||||
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||||
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
|
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
|
||||||
const forceHttp = useSettingsStore(state => state.forceHttp);
|
const forceHttp = useSettingsStore(state => state.forceHttp);
|
||||||
@@ -82,7 +79,10 @@ export default function BottomBarMobile() {
|
|||||||
|
|
||||||
stats?.forEach(report => {
|
stats?.forEach(report => {
|
||||||
if (report.type === "inbound-rtp") {
|
if (report.type === "inbound-rtp") {
|
||||||
setFps(report.framesPerSecond ?? 0);
|
if(report.framesPerSecond){
|
||||||
|
setFps(report.framesPerSecond)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
@@ -117,7 +117,7 @@ export default function BottomBarMobile() {
|
|||||||
{ icon: isDark ? Video2SVG : VideoSVG, label: $at("video") },
|
{ icon: isDark ? Video2SVG : VideoSVG, label: $at("video") },
|
||||||
{ icon: StateSvg, label: $at("status") },
|
{ icon: StateSvg, label: $at("status") },
|
||||||
];
|
];
|
||||||
const videoButtonLabel = forceHttp ? "N/A fps" : `${Math.round(fps)}fps`;
|
const videoButtonLabel = forceHttp ? `${videoSize}` : `${videoSize} ${fps}fps `;
|
||||||
if(isVirtualKeyboardEnabled){
|
if(isVirtualKeyboardEnabled){
|
||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
@@ -151,7 +151,6 @@ export default function BottomBarMobile() {
|
|||||||
<LedStatusButton ledState={keyboardLedState?.num_lock} text={$at("Num")} />
|
<LedStatusButton ledState={keyboardLedState?.num_lock} text={$at("Num")} />
|
||||||
<LedStatusButton ledState={keyboardLedState?.caps_lock} text={$at("Caps")} />
|
<LedStatusButton ledState={keyboardLedState?.caps_lock} text={$at("Caps")} />
|
||||||
<LedStatusButton ledState={keyboardLedState?.scroll_lock} text={$at("Scroll")} />
|
<LedStatusButton ledState={keyboardLedState?.scroll_lock} text={$at("Scroll")} />
|
||||||
<span className="pl-2 text-xs opacity-70">{layoutAbbrev}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[20%] flex flex-row flex-wrap items-center justify-end">
|
<div className="w-[20%] flex flex-row flex-wrap items-center justify-end">
|
||||||
<AntdButton
|
<AntdButton
|
||||||
@@ -176,13 +175,11 @@ export default function BottomBarMobile() {
|
|||||||
text={$at("HDMI")}
|
text={$at("HDMI")}
|
||||||
isActive={!!peerConnectionState}
|
isActive={!!peerConnectionState}
|
||||||
/>
|
/>
|
||||||
<div onClick={() => toggleSidebarView("UsbStatusPanel")}>
|
|
||||||
<ConnectionStatusButton
|
<ConnectionStatusButton
|
||||||
icon={usbState === "configured" ? <Usb2SVG /> : <UsbSVG />}
|
icon={usbState === "configured" ? <Usb2SVG /> : <UsbSVG />}
|
||||||
text={$at("USB")}
|
text={$at("USB")}
|
||||||
isActive={usbState === "configured"}
|
isActive={usbState === "configured"}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<VpnStatusButton
|
<VpnStatusButton
|
||||||
text={$at("TailScale")}
|
text={$at("TailScale")}
|
||||||
peerState={peerConnectionState}
|
peerState={peerConnectionState}
|
||||||
|
|||||||
@@ -27,12 +27,10 @@ import {
|
|||||||
useVpnStore,
|
useVpnStore,
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
import { keys, modifiers } from "@/keyboardMappings";
|
import { keys, modifiers } from "@/keyboardMappings";
|
||||||
import { keyboards } from "@/keyboardLayouts";
|
|
||||||
import BottomPopoverButton from "@components/PopoverButton";
|
import BottomPopoverButton from "@components/PopoverButton";
|
||||||
import MousePanel from "@components/MousePanel";
|
import MousePanel from "@components/MousePanel";
|
||||||
import KeyboardPanel from "@/layout/components_bottom/keyboard/KeyboardPanel";
|
import KeyboardPanel from "@/layout/components_bottom/keyboard/KeyboardPanel";
|
||||||
import UsbEpModeSelect from "@/layout/components_bottom/usbepmode/UsbEpModeSelect";
|
import UsbEpModeSelect from "@/layout/components_bottom/usbepmode/UsbEpModeSelect";
|
||||||
import UsbStatusPanel from "@/layout/components_bottom/usb_status/UsbStatusPanel";
|
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { dark_bg2_style, selected_bt_bg } from "@/layout/theme_color";
|
import { dark_bg2_style, selected_bt_bg } from "@/layout/theme_color";
|
||||||
import { useThemeSettings } from "@routes/login_page/useLocalAuth";
|
import { useThemeSettings } from "@routes/login_page/useLocalAuth";
|
||||||
@@ -46,6 +44,12 @@ export default function BottomBarPC() {
|
|||||||
const activeModifiers = useHidStore(state => state.activeModifiers);
|
const activeModifiers = useHidStore(state => state.activeModifiers);
|
||||||
const audioMode = useAudioModeStore(state => state.audioMode);
|
const audioMode = useAudioModeStore(state => state.audioMode);
|
||||||
const usbEpMode = useUsbEpModeStore(state => state.usbEpMode);
|
const usbEpMode = useUsbEpModeStore(state => state.usbEpMode);
|
||||||
|
// const videoSize = useVideoStore(
|
||||||
|
// state => `${Math.round(state.width)}x${Math.round(state.height)}`,
|
||||||
|
// );
|
||||||
|
const videoSize = useVideoStore(
|
||||||
|
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
|
||||||
|
);
|
||||||
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||||
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
|
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
|
||||||
const showPressedKeys = useSettingsStore(state => state.showPressedKeys);
|
const showPressedKeys = useSettingsStore(state => state.showPressedKeys);
|
||||||
@@ -59,14 +63,8 @@ export default function BottomBarPC() {
|
|||||||
|
|
||||||
|
|
||||||
const keyboardLedState = useHidStore(state => state.keyboardLedState);
|
const keyboardLedState = useHidStore(state => state.keyboardLedState);
|
||||||
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
|
|
||||||
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
|
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
|
||||||
|
|
||||||
const layoutAbbrev = useMemo(() => {
|
|
||||||
if (!keyboardLayout) return "en_US";
|
|
||||||
return keyboardLayout;
|
|
||||||
}, [keyboardLayout]);
|
|
||||||
|
|
||||||
const [hostname, setHostname] = useState("");
|
const [hostname, setHostname] = useState("");
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
const peerConnection = useRTCStore(state => state.peerConnection);
|
const peerConnection = useRTCStore(state => state.peerConnection);
|
||||||
@@ -91,10 +89,10 @@ export default function BottomBarPC() {
|
|||||||
|
|
||||||
const videoButtonLabel = useMemo(() => {
|
const videoButtonLabel = useMemo(() => {
|
||||||
if (forceHttp) {
|
if (forceHttp) {
|
||||||
return "N/A fps";
|
return `${videoSize}`;
|
||||||
}
|
}
|
||||||
return `${Math.round(fps)}fps`;
|
return `${videoSize} ${fps}fps `;
|
||||||
}, [forceHttp, fps]);
|
}, [forceHttp, videoSize, fps]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
send("getNetworkSettings", {}, resp => {
|
send("getNetworkSettings", {}, resp => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
@@ -116,12 +114,10 @@ export default function BottomBarPC() {
|
|||||||
isActive={hdmiState === "ready"}
|
isActive={hdmiState === "ready"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BottomPopoverButton
|
<ConnectionStatusButton
|
||||||
buttonIconNode={usbState === "configured" ? <Usb2SVG fontSize={16} /> : <UsbSVG fontSize={16} />}
|
icon={usbState === "configured" ? <Usb2SVG fontSize={16} /> : <UsbSVG fontSize={16} />}
|
||||||
buttonText={$at("USB")}
|
text={$at("USB")}
|
||||||
style={{ color: usbState === "configured" ? "rgba(0, 205, 27, 1)" : "inherit" }}
|
isActive={usbState === "configured"}
|
||||||
panelContent={<UsbStatusPanel />}
|
|
||||||
align="left"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VpnStatusButton
|
<VpnStatusButton
|
||||||
@@ -174,7 +170,6 @@ export default function BottomBarPC() {
|
|||||||
ledState={keyboardLedState?.scroll_lock}
|
ledState={keyboardLedState?.scroll_lock}
|
||||||
text={$at("Scroll")}
|
text={$at("Scroll")}
|
||||||
/>
|
/>
|
||||||
<span className="pl-1 text-xs opacity-70" style={{ fontSize: 12 }}>{layoutAbbrev}</span>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
align="left"
|
align="left"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { BsKeyboardFill, BsLockFill, BsUnlockFill } from "react-icons/bs";
|
import { BsMouseFill, BsLockFill, BsUnlockFill } from "react-icons/bs";
|
||||||
import { useReactAt } from "i18n-auto-extractor/react";
|
import { useReactAt } from "i18n-auto-extractor/react";
|
||||||
|
|
||||||
import VirtualKeyboard from "@components/VirtualKeyboard";
|
import VirtualKeyboard from "@components/VirtualKeyboard";
|
||||||
@@ -24,7 +24,7 @@ import KeyboardPanel from "@/layout/components_bottom/keyboard/KeyboardPanel";
|
|||||||
import Clipboard from "@/layout/components_side/Clipboard/Clipboard";
|
import Clipboard from "@/layout/components_side/Clipboard/Clipboard";
|
||||||
import SettingsModal from "@/layout/components_setting";
|
import SettingsModal from "@/layout/components_setting";
|
||||||
import { MacroMoreList } from "@/layout/components_side/Macros/MacroTopBar";
|
import { MacroMoreList } from "@/layout/components_side/Macros/MacroTopBar";
|
||||||
import { useMacrosSideTitleState , useHidStore, useMouseStore, useSettingsStore, useUiStore } from "@/hooks/stores";
|
import { useMacrosSideTitleState , useHidStore, useMouseStore, useSettingsStore } from "@/hooks/stores";
|
||||||
import MobileTerminal from "@/layout/components_bottom/terminal/index.mobile";
|
import MobileTerminal from "@/layout/components_bottom/terminal/index.mobile";
|
||||||
import { dark_bg_desktop, dark_bg_style_fun } from "@/layout/theme_color";
|
import { dark_bg_desktop, dark_bg_style_fun } from "@/layout/theme_color";
|
||||||
import PowerControl from "@/layout/components_side/Power";
|
import PowerControl from "@/layout/components_side/Power";
|
||||||
@@ -37,52 +37,10 @@ import SettingsMacros from "@/layout/components_side/Macros";
|
|||||||
import { useTouchZoom } from "@/layout/core/desktop/hooks/useTouchZoom";
|
import { useTouchZoom } from "@/layout/core/desktop/hooks/useTouchZoom";
|
||||||
import { usePasteHandler } from "@/layout/core/desktop/hooks/usePasteHandler";
|
import { usePasteHandler } from "@/layout/core/desktop/hooks/usePasteHandler";
|
||||||
import UsbEpModeSelect from "@/layout/components_bottom/usbepmode/UsbEpModeSelect";
|
import UsbEpModeSelect from "@/layout/components_bottom/usbepmode/UsbEpModeSelect";
|
||||||
import UsbStatusPanel from "@/layout/components_bottom/usb_status/UsbStatusPanel";
|
|
||||||
import VirtualMediaSource from "@/layout/components_side/VirtualMediaSource";
|
import VirtualMediaSource from "@/layout/components_side/VirtualMediaSource";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import OcrOverlay from "@components/OcrOverlay";
|
|
||||||
|
|
||||||
const GestureIcon = ({ className = "h-4 w-4" }: { className?: string }) => (
|
|
||||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
||||||
<circle cx="10" cy="10" r="6.5" />
|
|
||||||
<path d="M14.8 14.8L21 21" />
|
|
||||||
<path d="M10 7.5V12.5" />
|
|
||||||
<path d="M7.5 10H12.5" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ResetViewIcon = ({ className = "h-4 w-4" }: { className?: string }) => (
|
|
||||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
||||||
<path d="M19 12A7 7 0 1 1 12 5" />
|
|
||||||
<path d="M12 2L12 6L16 6" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const MouseStickIcon = ({ className = "h-4 w-4" }: { className?: string }) => (
|
|
||||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
||||||
<rect x="7" y="3" width="10" height="18" rx="5" />
|
|
||||||
<path d="M12 3V8" />
|
|
||||||
<circle cx="12" cy="13" r="1.5" fill="currentColor" stroke="none" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const FourWayMoveIcon = ({ className = "h-5 w-5" }: { className?: string }) => (
|
|
||||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
||||||
<path d="M12 3V21" />
|
|
||||||
<path d="M3 12H21" />
|
|
||||||
<path d="M12 3L9 6" />
|
|
||||||
<path d="M12 3L15 6" />
|
|
||||||
<path d="M12 21L9 18" />
|
|
||||||
<path d="M12 21L15 18" />
|
|
||||||
<path d="M3 12L6 9" />
|
|
||||||
<path d="M3 12L6 15" />
|
|
||||||
<path d="M21 12L18 9" />
|
|
||||||
<path d="M21 12L18 15" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number }) {
|
export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number }) {
|
||||||
const joystickSpeedLevels = [1.4, 1.05, 0.7];
|
|
||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
const videoElm = useRef<HTMLVideoElement>(null);
|
const videoElm = useRef<HTMLVideoElement>(null);
|
||||||
@@ -91,126 +49,56 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
|
|||||||
const zoomContainerRef = useRef<HTMLDivElement>(null);
|
const zoomContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const pasteCaptureRef = useRef<HTMLTextAreaElement>(null);
|
const pasteCaptureRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||||
const isOcrMode = useUiStore(state => state.isOcrMode);
|
|
||||||
const macrosSideTitle = useMacrosSideTitleState(state => state.sideTitle);
|
const macrosSideTitle = useMacrosSideTitleState(state => state.sideTitle);
|
||||||
|
|
||||||
const videoEffects = useVideoEffects();
|
const videoEffects = useVideoEffects();
|
||||||
const videoStream = useVideoStream(videoElm as React.RefObject<HTMLVideoElement>, audioElm as React.RefObject<HTMLAudioElement>);
|
const videoStream = useVideoStream(videoElm as React.RefObject<HTMLVideoElement>, audioElm as React.RefObject<HTMLAudioElement>);
|
||||||
const pointerLock = usePointerLock(videoElm as React.RefObject<HTMLVideoElement>);
|
const pointerLock = usePointerLock(videoElm as React.RefObject<HTMLVideoElement>);
|
||||||
useFullscreen(videoElm as React.RefObject<HTMLVideoElement>, pointerLock, isFullscreen);
|
useFullscreen(videoElm as React.RefObject<HTMLVideoElement>, pointerLock, isFullscreen);
|
||||||
const [isTouchGestureEnabled, setIsTouchGestureEnabled] = useState(true);
|
const touchZoom = useTouchZoom(zoomContainerRef as React.RefObject<HTMLDivElement>);
|
||||||
const touchZoom = useTouchZoom(zoomContainerRef as React.RefObject<HTMLDivElement>, isTouchGestureEnabled);
|
|
||||||
const { handleGlobalPaste } = usePasteHandler(pasteCaptureRef as React.RefObject<HTMLTextAreaElement>);
|
const { handleGlobalPaste } = usePasteHandler(pasteCaptureRef as React.RefObject<HTMLTextAreaElement>);
|
||||||
const keyboardEvents = useKeyboardEvents(pasteCaptureRef as React.RefObject<HTMLTextAreaElement>, isReinitializingGadget);
|
const keyboardEvents = useKeyboardEvents(pasteCaptureRef as React.RefObject<HTMLTextAreaElement>, isReinitializingGadget);
|
||||||
const [showVirtualMouseButtons, setShowVirtualMouseButtons] = useState(false);
|
const [showVirtualMouseButtons, setShowVirtualMouseButtons] = useState(false);
|
||||||
const [showVirtualJoystick, setShowVirtualJoystick] = useState(false);
|
|
||||||
const [joystickVector, setJoystickVector] = useState({ x: 0, y: 0 });
|
|
||||||
const [joystickSensitivity, setJoystickSensitivity] = useState(1);
|
|
||||||
const [joystickPos, setJoystickPos] = useState({ x: 16, y: 24 });
|
|
||||||
const [lockedButtons, setLockedButtons] = useState(0);
|
const [lockedButtons, setLockedButtons] = useState(0);
|
||||||
const mouseEvents = useMouseEvents(videoElm as React.RefObject<HTMLVideoElement>, pointerLock, touchZoom, showVirtualMouseButtons, lockedButtons);
|
const mouseEvents = useMouseEvents(videoElm as React.RefObject<HTMLVideoElement>, pointerLock, touchZoom, showVirtualMouseButtons, lockedButtons);
|
||||||
const overlays = useVideoOverlays(videoStream, pointerLock, videoEffects);
|
const overlays = useVideoOverlays(videoStream, pointerLock, videoEffects);
|
||||||
|
|
||||||
const forceHttp = useSettingsStore(state => state.forceHttp);
|
const forceHttp = useSettingsStore(state => state.forceHttp);
|
||||||
const mouseMode = useSettingsStore(state => state.mouseMode);
|
|
||||||
const mouseX = useMouseStore(state => state.mouseX);
|
const mouseX = useMouseStore(state => state.mouseX);
|
||||||
const mouseY = useMouseStore(state => state.mouseY);
|
const mouseY = useMouseStore(state => state.mouseY);
|
||||||
const allowTapToOpenVirtualKeyboard = useHidStore(state => state.allowTapToOpenVirtualKeyboard);
|
|
||||||
const setAllowTapToOpenVirtualKeyboard = useHidStore(state => state.setAllowTapToOpenVirtualKeyboard);
|
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
const [leftBtnPos, setLeftBtnPos] = useState({ x: 40, y: 40 });
|
const [leftBtnPos, setLeftBtnPos] = useState({ x: 40, y: 40 });
|
||||||
const [rightBtnPos, setRightBtnPos] = useState({ x: 120, y: 40 });
|
const [rightBtnPos, setRightBtnPos] = useState({ x: 120, y: 40 });
|
||||||
const [wheelPos, setWheelPos] = useState({ x: 184, y: 140 });
|
const [leftLockPos, setLeftLockPos] = useState({ x: 40, y: 110 });
|
||||||
|
const [rightLockPos, setRightLockPos] = useState({ x: 120, y: 110 });
|
||||||
|
|
||||||
const [draggingBtn, setDraggingBtn] = useState<"left" | "right" | "wheel" | null>(null);
|
const [draggingBtn, setDraggingBtn] = useState<"left" | "right" | "leftLock" | "rightLock" | null>(null);
|
||||||
const dragOffset = useRef({ x: 0, y: 0 });
|
const dragOffset = useRef({ x: 0, y: 0 });
|
||||||
const joystickAreaRef = useRef<HTMLDivElement>(null);
|
|
||||||
const joystickPointerIdRef = useRef<number | null>(null);
|
|
||||||
const joystickVectorRef = useRef({ x: 0, y: 0 });
|
|
||||||
const joystickFrameRef = useRef<number | null>(null);
|
|
||||||
const joystickLastTsRef = useRef<number | null>(null);
|
|
||||||
const joystickMovePointerIdRef = useRef<number | null>(null);
|
|
||||||
const joystickMoveHoldTimerRef = useRef<number | null>(null);
|
|
||||||
const joystickMoveEnabledRef = useRef(false);
|
|
||||||
|
|
||||||
const activeButtonsRef = useRef(0);
|
const activeButtonsRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isFullscreen) {
|
if (isFullscreen) {
|
||||||
setShowVirtualMouseButtons(false);
|
setShowVirtualMouseButtons(false);
|
||||||
setShowVirtualJoystick(false);
|
|
||||||
}
|
}
|
||||||
}, [isFullscreen]);
|
}, [isFullscreen]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
joystickVectorRef.current = joystickVector;
|
|
||||||
}, [joystickVector]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showVirtualJoystick) {
|
|
||||||
joystickPointerIdRef.current = null;
|
|
||||||
joystickMovePointerIdRef.current = null;
|
|
||||||
if (joystickMoveHoldTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(joystickMoveHoldTimerRef.current);
|
|
||||||
joystickMoveHoldTimerRef.current = null;
|
|
||||||
}
|
|
||||||
joystickMoveEnabledRef.current = false;
|
|
||||||
joystickLastTsRef.current = null;
|
|
||||||
setJoystickVector({ x: 0, y: 0 });
|
|
||||||
if (joystickFrameRef.current !== null) {
|
|
||||||
cancelAnimationFrame(joystickFrameRef.current);
|
|
||||||
joystickFrameRef.current = null;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tick = (timestamp: number) => {
|
|
||||||
const prevTs = joystickLastTsRef.current ?? timestamp;
|
|
||||||
joystickLastTsRef.current = timestamp;
|
|
||||||
const frameScale = Math.min(2, Math.max(0.5, (timestamp - prevTs) / 16.67));
|
|
||||||
const vector = joystickVectorRef.current;
|
|
||||||
const container = containerRef.current;
|
|
||||||
const containerWidth = container?.clientWidth ?? 1280;
|
|
||||||
// Reduce sensitivity on small screens to avoid over-shooting.
|
|
||||||
const resolutionFactor = Math.max(0.35, Math.min(1, containerWidth / 1280));
|
|
||||||
const speed = 12 * resolutionFactor * joystickSensitivity;
|
|
||||||
const dx = Math.round(vector.x * speed * frameScale);
|
|
||||||
const dy = Math.round(vector.y * speed * frameScale);
|
|
||||||
if (dx !== 0 || dy !== 0) {
|
|
||||||
mouseEvents.sendVirtualRelativeMovement(dx, dy, 0);
|
|
||||||
}
|
|
||||||
joystickFrameRef.current = requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
|
|
||||||
joystickFrameRef.current = requestAnimationFrame(tick);
|
|
||||||
return () => {
|
|
||||||
if (joystickFrameRef.current !== null) {
|
|
||||||
cancelAnimationFrame(joystickFrameRef.current);
|
|
||||||
joystickFrameRef.current = null;
|
|
||||||
}
|
|
||||||
joystickLastTsRef.current = null;
|
|
||||||
};
|
|
||||||
}, [showVirtualJoystick, mouseEvents, joystickSensitivity]);
|
|
||||||
|
|
||||||
const updateButtons = (mask: number, isDown: boolean) => {
|
const updateButtons = (mask: number, isDown: boolean) => {
|
||||||
if (isReinitializingGadget) return;
|
if (isReinitializingGadget) return;
|
||||||
|
|
||||||
let newButtons = activeButtonsRef.current;
|
let newButtons = activeButtonsRef.current;
|
||||||
if (isDown) {
|
if (isDown) {
|
||||||
newButtons |= mask;
|
newButtons |= mask;
|
||||||
} else if (lockedButtons & mask) {
|
|
||||||
// Keep pressed while lock is enabled.
|
|
||||||
newButtons |= mask;
|
|
||||||
} else {
|
} else {
|
||||||
newButtons &= ~mask;
|
newButtons &= ~mask;
|
||||||
}
|
}
|
||||||
|
|
||||||
activeButtonsRef.current = newButtons;
|
if (!isDown && (lockedButtons & mask)) {
|
||||||
if (mouseMode === "relative") {
|
setLockedButtons(prev => prev & ~mask);
|
||||||
mouseEvents.sendVirtualRelativeMovement(0, 0, newButtons);
|
|
||||||
} else {
|
|
||||||
send("absMouseReport", { x: mouseX, y: mouseY, buttons: newButtons });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activeButtonsRef.current = newButtons;
|
||||||
|
send("absMouseReport", { x: mouseX, y: mouseY, buttons: newButtons });
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleLock = (mask: number) => {
|
const toggleLock = (mask: number) => {
|
||||||
@@ -227,14 +115,10 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
|
|||||||
}
|
}
|
||||||
|
|
||||||
activeButtonsRef.current = newButtons;
|
activeButtonsRef.current = newButtons;
|
||||||
if (mouseMode === "relative") {
|
|
||||||
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" | "wheel") => {
|
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>, type: "left" | "right" | "leftLock" | "rightLock") => {
|
||||||
const target = e.currentTarget;
|
const target = e.currentTarget;
|
||||||
const rect = target.getBoundingClientRect();
|
const rect = target.getBoundingClientRect();
|
||||||
dragOffset.current = {
|
dragOffset.current = {
|
||||||
@@ -252,121 +136,26 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
|
|||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
const x = e.clientX - containerRect.left - dragOffset.current.x;
|
const x = e.clientX - containerRect.left - dragOffset.current.x;
|
||||||
const y = e.clientY - containerRect.top - dragOffset.current.y;
|
const y = e.clientY - containerRect.top - dragOffset.current.y;
|
||||||
const dragWidth = draggingBtn === "wheel" ? 32 : 56;
|
const clampedX = Math.max(0, Math.min(containerRect.width - 56, x));
|
||||||
const dragHeight = draggingBtn === "wheel" ? 68 : 56;
|
const clampedY = Math.max(0, Math.min(containerRect.height - 56, y));
|
||||||
const clampedX = Math.max(0, Math.min(containerRect.width - dragWidth, x));
|
|
||||||
const clampedY = Math.max(0, Math.min(containerRect.height - dragHeight, y));
|
|
||||||
if (draggingBtn === "left") {
|
if (draggingBtn === "left") {
|
||||||
setLeftBtnPos({ x: clampedX, y: clampedY });
|
setLeftBtnPos({ x: clampedX, y: clampedY });
|
||||||
} else if (draggingBtn === "right") {
|
} else if (draggingBtn === "right") {
|
||||||
setRightBtnPos({ x: clampedX, y: clampedY });
|
setRightBtnPos({ x: clampedX, y: clampedY });
|
||||||
} else if (draggingBtn === "wheel") {
|
} else if (draggingBtn === "leftLock") {
|
||||||
setWheelPos({ x: clampedX, y: clampedY });
|
setLeftLockPos({ x: clampedX, y: clampedY });
|
||||||
|
} else if (draggingBtn === "rightLock") {
|
||||||
|
setRightLockPos({ x: clampedX, y: clampedY });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>, type: "left" | "right" | "wheel") => {
|
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>, type: "left" | "right" | "leftLock" | "rightLock") => {
|
||||||
const wasDragging = draggingBtn === type;
|
const wasDragging = draggingBtn === type;
|
||||||
setDraggingBtn(null);
|
setDraggingBtn(null);
|
||||||
(e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
|
(e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
|
||||||
if (!wasDragging) return;
|
if (!wasDragging) return;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateJoystickVector = (clientX: number, clientY: number) => {
|
|
||||||
const joystickElm = joystickAreaRef.current;
|
|
||||||
if (!joystickElm) return;
|
|
||||||
const rect = joystickElm.getBoundingClientRect();
|
|
||||||
const centerX = rect.left + rect.width / 2;
|
|
||||||
const centerY = rect.top + rect.height / 2;
|
|
||||||
const rawX = clientX - centerX;
|
|
||||||
const rawY = clientY - centerY;
|
|
||||||
const maxRadius = 32;
|
|
||||||
const length = Math.hypot(rawX, rawY);
|
|
||||||
if (!length || length <= maxRadius) {
|
|
||||||
setJoystickVector({ x: rawX / maxRadius, y: rawY / maxRadius });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const scale = maxRadius / length;
|
|
||||||
setJoystickVector({ x: (rawX * scale) / maxRadius, y: (rawY * scale) / maxRadius });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJoystickPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
||||||
if (joystickMovePointerIdRef.current !== null) return;
|
|
||||||
e.preventDefault();
|
|
||||||
joystickPointerIdRef.current = e.pointerId;
|
|
||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
|
||||||
updateJoystickVector(e.clientX, e.clientY);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJoystickPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
||||||
if (joystickPointerIdRef.current !== e.pointerId) return;
|
|
||||||
e.preventDefault();
|
|
||||||
updateJoystickVector(e.clientX, e.clientY);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJoystickPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
||||||
if (joystickPointerIdRef.current !== e.pointerId) return;
|
|
||||||
joystickPointerIdRef.current = null;
|
|
||||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
||||||
setJoystickVector({ x: 0, y: 0 });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJoystickMoveStart = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
||||||
joystickMovePointerIdRef.current = e.pointerId;
|
|
||||||
joystickMoveEnabledRef.current = false;
|
|
||||||
if (joystickMoveHoldTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(joystickMoveHoldTimerRef.current);
|
|
||||||
}
|
|
||||||
joystickMoveHoldTimerRef.current = window.setTimeout(() => {
|
|
||||||
joystickMoveEnabledRef.current = true;
|
|
||||||
}, 350);
|
|
||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJoystickMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
||||||
if (joystickMovePointerIdRef.current !== e.pointerId) return;
|
|
||||||
if (!joystickMoveEnabledRef.current) return;
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
|
||||||
const joystickSize = 80;
|
|
||||||
const nextLeft = e.clientX - containerRect.left - joystickSize / 2;
|
|
||||||
const nextTop = e.clientY - containerRect.top - joystickSize / 2;
|
|
||||||
const clampedLeft = Math.max(0, Math.min(containerRect.width - joystickSize, nextLeft));
|
|
||||||
const clampedTop = Math.max(0, Math.min(containerRect.height - joystickSize, nextTop));
|
|
||||||
const nextBottom = containerRect.height - joystickSize - clampedTop;
|
|
||||||
setJoystickPos({ x: clampedLeft, y: nextBottom });
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJoystickMoveEnd = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
||||||
if (joystickMovePointerIdRef.current !== e.pointerId) return;
|
|
||||||
if (joystickMoveHoldTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(joystickMoveHoldTimerRef.current);
|
|
||||||
joystickMoveHoldTimerRef.current = null;
|
|
||||||
}
|
|
||||||
joystickMoveEnabledRef.current = false;
|
|
||||||
joystickMovePointerIdRef.current = null;
|
|
||||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
const isMouseControlEnabled = showVirtualJoystick || showVirtualMouseButtons;
|
|
||||||
const joystickSpeedIndex = joystickSpeedLevels.reduce((bestIndex, value, index, arr) => {
|
|
||||||
const bestDistance = Math.abs(arr[bestIndex] - joystickSensitivity);
|
|
||||||
const currentDistance = Math.abs(value - joystickSensitivity);
|
|
||||||
return currentDistance < bestDistance ? index : bestIndex;
|
|
||||||
}, 0);
|
|
||||||
const toggleMouseControl = () => {
|
|
||||||
const nextEnabled = !isMouseControlEnabled;
|
|
||||||
setShowVirtualJoystick(nextEnabled);
|
|
||||||
setShowVirtualMouseButtons(nextEnabled);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const keyboardCleanup = keyboardEvents.setupKeyboardEvents();
|
const keyboardCleanup = keyboardEvents.setupKeyboardEvents();
|
||||||
const videoCleanup = videoStream.setupVideoEventListeners();
|
const videoCleanup = videoStream.setupVideoEventListeners();
|
||||||
@@ -424,11 +213,6 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
|
|||||||
drawerRender={() => (<UsbEpModeSelect/>)}
|
drawerRender={() => (<UsbEpModeSelect/>)}
|
||||||
className={"px-[20px]"}
|
className={"px-[20px]"}
|
||||||
/>
|
/>
|
||||||
<EnhancedDrawer
|
|
||||||
targetView={"UsbStatusPanel"}
|
|
||||||
placement={"bottom"}
|
|
||||||
drawerRender={() => (<UsbStatusPanel/>)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EnhancedDrawer
|
<EnhancedDrawer
|
||||||
title={$at("Virtual Media Source")}
|
title={$at("Virtual Media Source")}
|
||||||
@@ -483,7 +267,6 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
|
|||||||
`h-full w-full ${dark_bg_style_fun(isDark)} object-contain transition-all duration-1000`,
|
`h-full w-full ${dark_bg_style_fun(isDark)} object-contain transition-all duration-1000`,
|
||||||
{
|
{
|
||||||
"cursor-none": videoEffects.settings.isCursorHidden,
|
"cursor-none": videoEffects.settings.isCursorHidden,
|
||||||
"pointer-events-none": isOcrMode,
|
|
||||||
"opacity-0": overlays.shouldHideVideo,
|
"opacity-0": overlays.shouldHideVideo,
|
||||||
"opacity-60!": overlays.showPointerLockBar,
|
"opacity-60!": overlays.showPointerLockBar,
|
||||||
"animate-slideUpFade dark:border-slate-300/20":
|
"animate-slideUpFade dark:border-slate-300/20":
|
||||||
@@ -491,10 +274,6 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
|
|||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<OcrOverlay
|
|
||||||
videoRef={videoElm as React.RefObject<HTMLVideoElement>}
|
|
||||||
containerRef={zoomContainerRef as React.RefObject<HTMLDivElement>}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(videoStream.peerConnectionState === "connected" || forceHttp) && (
|
{(videoStream.peerConnectionState === "connected" || forceHttp) && (
|
||||||
<div
|
<div
|
||||||
@@ -517,292 +296,107 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
|
|||||||
className="pointer-events-none absolute inset-0"
|
className="pointer-events-none absolute inset-0"
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
>
|
>
|
||||||
<div className="pointer-events-auto absolute right-3 top-3 grid grid-cols-[auto_auto_auto] grid-rows-2 gap-1.5">
|
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
"flex h-8 w-8 items-center justify-center rounded-full text-white",
|
"pointer-events-auto absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full text-white text-xs",
|
||||||
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={() => touchZoom.resetTransform()}
|
onClick={() => setShowVirtualMouseButtons(prev => !prev)}
|
||||||
>
|
>
|
||||||
<ResetViewIcon />
|
<BsMouseFill className="h-4 w-4" />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{showVirtualMouseButtons && (
|
{showVirtualMouseButtons && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100 relative",
|
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100",
|
||||||
(lockedButtons & 1) ? "bg-green-600/80" : (isDark ? "bg-gray-500/70" : "bg-black/30"),
|
isDark ? "bg-gray-500/70" : "bg-black/30",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
left: leftBtnPos.x,
|
left: leftBtnPos.x,
|
||||||
top: leftBtnPos.y,
|
top: leftBtnPos.y,
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
}}
|
}}
|
||||||
onPointerDown={() => {
|
onPointerDown={e => {
|
||||||
|
handlePointerDown(e, "left");
|
||||||
updateButtons(1, true);
|
updateButtons(1, true);
|
||||||
}}
|
}}
|
||||||
onPointerUp={() => {
|
onPointerUp={e => {
|
||||||
|
handlePointerUp(e, "left");
|
||||||
updateButtons(1, false);
|
updateButtons(1, false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
L
|
L
|
||||||
<div
|
|
||||||
className="absolute -left-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/45 text-white"
|
|
||||||
onPointerDown={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handlePointerDown(e, "left");
|
|
||||||
}}
|
|
||||||
onPointerMove={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handlePointerMove(e);
|
|
||||||
}}
|
|
||||||
onPointerUp={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handlePointerUp(e, "left");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FourWayMoveIcon className="h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cx(
|
|
||||||
"absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full text-white",
|
|
||||||
(lockedButtons & 1) ? "bg-green-600/90" : "bg-black/45",
|
|
||||||
)}
|
|
||||||
onPointerDown={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggleLock(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(lockedButtons & 1) ? <BsLockFill className="h-3 w-3" /> : <BsUnlockFill className="h-3 w-3" />}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100 relative",
|
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100",
|
||||||
(lockedButtons & 2) ? "bg-green-600/80" : (isDark ? "bg-gray-500/70" : "bg-black/30"),
|
(lockedButtons & 1) ? "bg-green-600/80" : (isDark ? "bg-gray-500/70" : "bg-black/30"),
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: leftLockPos.x,
|
||||||
|
top: leftLockPos.y,
|
||||||
|
touchAction: "none",
|
||||||
|
}}
|
||||||
|
onPointerDown={e => {
|
||||||
|
handlePointerDown(e, "leftLock");
|
||||||
|
}}
|
||||||
|
onPointerUp={e => {
|
||||||
|
handlePointerUp(e, "leftLock");
|
||||||
|
}}
|
||||||
|
onClick={() => toggleLock(1)}
|
||||||
|
>
|
||||||
|
{(lockedButtons & 1) ? <BsLockFill /> : <BsUnlockFill />} L
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100",
|
||||||
|
isDark ? "bg-gray-500/70" : "bg-black/30",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
left: rightBtnPos.x,
|
left: rightBtnPos.x,
|
||||||
top: rightBtnPos.y,
|
top: rightBtnPos.y,
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
}}
|
}}
|
||||||
onPointerDown={() => {
|
onPointerDown={e => {
|
||||||
|
handlePointerDown(e, "right");
|
||||||
updateButtons(2, true);
|
updateButtons(2, true);
|
||||||
}}
|
}}
|
||||||
onPointerUp={() => {
|
onPointerUp={e => {
|
||||||
|
handlePointerUp(e, "right");
|
||||||
updateButtons(2, false);
|
updateButtons(2, false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
R
|
R
|
||||||
<div
|
|
||||||
className="absolute -left-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/45 text-white"
|
|
||||||
onPointerDown={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handlePointerDown(e, "right");
|
|
||||||
}}
|
|
||||||
onPointerMove={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handlePointerMove(e);
|
|
||||||
}}
|
|
||||||
onPointerUp={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handlePointerUp(e, "right");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FourWayMoveIcon className="h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cx(
|
|
||||||
"absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full text-white",
|
|
||||||
(lockedButtons & 2) ? "bg-green-600/90" : "bg-black/45",
|
|
||||||
)}
|
|
||||||
onPointerDown={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggleLock(2);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(lockedButtons & 2) ? <BsLockFill className="h-3 w-3" /> : <BsUnlockFill className="h-3 w-3" />}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="pointer-events-auto absolute"
|
className={cx(
|
||||||
|
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full text-white text-xs active:scale-90 transition-transform duration-100",
|
||||||
|
(lockedButtons & 2) ? "bg-green-600/80" : (isDark ? "bg-gray-500/70" : "bg-black/30"),
|
||||||
|
)}
|
||||||
style={{
|
style={{
|
||||||
left: wheelPos.x,
|
left: rightLockPos.x,
|
||||||
top: wheelPos.y,
|
top: rightLockPos.y,
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
<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 => {
|
||||||
e.stopPropagation();
|
handlePointerDown(e, "rightLock");
|
||||||
handlePointerDown(e, "wheel");
|
|
||||||
}}
|
|
||||||
onPointerMove={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handlePointerMove(e);
|
|
||||||
}}
|
}}
|
||||||
onPointerUp={e => {
|
onPointerUp={e => {
|
||||||
e.stopPropagation();
|
handlePointerUp(e, "rightLock");
|
||||||
handlePointerUp(e, "wheel");
|
|
||||||
}}
|
}}
|
||||||
|
onClick={() => toggleLock(2)}
|
||||||
>
|
>
|
||||||
<FourWayMoveIcon className="h-3 w-3" />
|
{(lockedButtons & 2) ? <BsLockFill /> : <BsUnlockFill />} R
|
||||||
</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 />
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import { MacroMoreList } from "@/layout/components_side/Macros/MacroTopBar";
|
|||||||
import { useUiStore, useHidStore, useSettingsStore } from "@/hooks/stores";
|
import { useUiStore, useHidStore, useSettingsStore } from "@/hooks/stores";
|
||||||
import { useTouchZoom } from "@/layout/core/desktop/hooks/useTouchZoom";
|
import { useTouchZoom } from "@/layout/core/desktop/hooks/useTouchZoom";
|
||||||
import { usePasteHandler } from "@/layout/core/desktop/hooks/usePasteHandler";
|
import { usePasteHandler } from "@/layout/core/desktop/hooks/usePasteHandler";
|
||||||
import OcrOverlay from "@components/OcrOverlay";
|
|
||||||
|
|
||||||
export default function PCDesktop({ isFullscreen }: { isFullscreen?: number }) {
|
export default function PCDesktop({ isFullscreen }: { isFullscreen?: number }) {
|
||||||
const videoElm = useRef<HTMLVideoElement>(null);
|
const videoElm = useRef<HTMLVideoElement>(null);
|
||||||
@@ -39,7 +38,6 @@ export default function PCDesktop({ isFullscreen }: { isFullscreen?: number }) {
|
|||||||
const setTerminalType = useUiStore(state => state.setTerminalType);
|
const setTerminalType = useUiStore(state => state.setTerminalType);
|
||||||
const terminalType = useUiStore(state => state.terminalType);
|
const terminalType = useUiStore(state => state.terminalType);
|
||||||
const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled);
|
const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled);
|
||||||
const isOcrMode = useUiStore(state => state.isOcrMode);
|
|
||||||
|
|
||||||
const forceHttp = useSettingsStore(state => state.forceHttp);
|
const forceHttp = useSettingsStore(state => state.forceHttp);
|
||||||
|
|
||||||
@@ -118,7 +116,6 @@ export default function PCDesktop({ isFullscreen }: { isFullscreen?: number }) {
|
|||||||
`max-h-full min-h-[384px] max-w-full min-w-[512px] object-contain transition-all duration-1000`,
|
`max-h-full min-h-[384px] max-w-full min-w-[512px] object-contain transition-all duration-1000`,
|
||||||
{
|
{
|
||||||
"cursor-none": videoEffects.settings.isCursorHidden,
|
"cursor-none": videoEffects.settings.isCursorHidden,
|
||||||
"pointer-events-none": isOcrMode,
|
|
||||||
"opacity-0": overlays.shouldHideVideo,
|
"opacity-0": overlays.shouldHideVideo,
|
||||||
"opacity-60!": overlays.showPointerLockBar,
|
"opacity-60!": overlays.showPointerLockBar,
|
||||||
"animate-slideUpFade shadow-xs ":
|
"animate-slideUpFade shadow-xs ":
|
||||||
@@ -126,10 +123,6 @@ export default function PCDesktop({ isFullscreen }: { isFullscreen?: number }) {
|
|||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<OcrOverlay
|
|
||||||
videoRef={videoElm as React.RefObject<HTMLVideoElement>}
|
|
||||||
containerRef={zoomContainerRef as React.RefObject<HTMLDivElement>}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(videoStream.peerConnectionState === "connected" || forceHttp) && (
|
{(videoStream.peerConnectionState === "connected" || forceHttp) && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
import useKeyboard from "@/hooks/useKeyboard";
|
import useKeyboard from "@/hooks/useKeyboard";
|
||||||
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
|
import { useHidStore, useSettingsStore } from "@/hooks/stores";
|
||||||
import { keys, modifiers } from "@/keyboardMappings";
|
import { keys, modifiers } from "@/keyboardMappings";
|
||||||
import { keyboards } from "@/keyboardLayouts";
|
|
||||||
import { eventMatchesShortcut } from "@/utils/shortcuts";
|
|
||||||
|
|
||||||
export const useKeyboardEvents = (
|
export const useKeyboardEvents = (
|
||||||
pasteCaptureRef?: React.RefObject<HTMLTextAreaElement>,
|
pasteCaptureRef?: React.RefObject<HTMLTextAreaElement>,
|
||||||
@@ -16,31 +14,7 @@ export const useKeyboardEvents = (
|
|||||||
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
|
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
|
||||||
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
|
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
|
||||||
const isKeyboardLedManagedByHost = keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable;
|
const isKeyboardLedManagedByHost = keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable;
|
||||||
const pasteShortcutEnabled = useSettingsStore(state => state.pasteShortcutEnabled);
|
const overrideCtrlV = useSettingsStore(state => state.overrideCtrlV);
|
||||||
const pasteShortcut = useSettingsStore(state => state.pasteShortcut);
|
|
||||||
const isOcrMode = useUiStore(state => state.isOcrMode);
|
|
||||||
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
|
|
||||||
|
|
||||||
const remapCode = useCallback((code: string, key: string): string => {
|
|
||||||
const modifierCodes = ["ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight", "AltLeft", "AltRight", "MetaLeft", "MetaRight", "CapsLock", "Tab", "Enter", "Backspace", "Delete", "Insert", "Home", "End", "PageUp", "PageDown", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Escape", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12", "PrintScreen", "ScrollLock", "Pause", "ContextMenu", "Menu"];
|
|
||||||
if (modifierCodes.includes(code)) return code;
|
|
||||||
if (code.startsWith("Digit") || code.startsWith("Numpad")) return code;
|
|
||||||
if (code.startsWith("Key") && code.length === 4) {
|
|
||||||
const letter = code.charAt(3);
|
|
||||||
if (letter >= "A" && letter <= "Z") {
|
|
||||||
const isoCode = (keyboardLayout || "en-US").replace("_", "-");
|
|
||||||
const layout = keyboards.find(k => k.isoCode === isoCode);
|
|
||||||
if (layout && layout.chars) {
|
|
||||||
const charLower = key.toLowerCase();
|
|
||||||
const charEntry = layout.chars[charLower] || layout.chars[key];
|
|
||||||
if (charEntry && charEntry.key && typeof charEntry.key === "string" && charEntry.key !== code) {
|
|
||||||
return charEntry.key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return code;
|
|
||||||
}, [keyboardLayout]);
|
|
||||||
|
|
||||||
const handleModifierKeys = useCallback((e: KeyboardEvent, activeModifiers: number[]) => {
|
const handleModifierKeys = useCallback((e: KeyboardEvent, activeModifiers: number[]) => {
|
||||||
const { shiftKey, ctrlKey, altKey, metaKey } = e;
|
const { shiftKey, ctrlKey, altKey, metaKey } = e;
|
||||||
@@ -54,8 +28,8 @@ export const useKeyboardEvents = (
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const keyDownHandler = useCallback(async (e: KeyboardEvent) => {
|
const keyDownHandler = useCallback(async (e: KeyboardEvent) => {
|
||||||
if (isOcrMode) return;
|
if (overrideCtrlV && (e.code === "KeyV" || e.key.toLowerCase() === "v") && (e.ctrlKey || e.metaKey)) {
|
||||||
if (pasteShortcutEnabled && eventMatchesShortcut(e, pasteShortcut)) {
|
console.log("Override Ctrl V");
|
||||||
if (isReinitializingGadget) return;
|
if (isReinitializingGadget) return;
|
||||||
if (pasteCaptureRef && pasteCaptureRef.current) {
|
if (pasteCaptureRef && pasteCaptureRef.current) {
|
||||||
pasteCaptureRef.current.value = "";
|
pasteCaptureRef.current.value = "";
|
||||||
@@ -65,11 +39,6 @@ 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;
|
||||||
@@ -87,8 +56,6 @@ export const useKeyboardEvents = (
|
|||||||
code = "IntlBackslash";
|
code = "IntlBackslash";
|
||||||
}
|
}
|
||||||
|
|
||||||
code = remapCode(code, key);
|
|
||||||
|
|
||||||
const newKeys = [...prev.activeKeys, keys[code]].filter(Boolean);
|
const newKeys = [...prev.activeKeys, keys[code]].filter(Boolean);
|
||||||
const newModifiers = handleModifierKeys(e, [...prev.activeModifiers, modifiers[code]]);
|
const newModifiers = handleModifierKeys(e, [...prev.activeModifiers, modifiers[code]]);
|
||||||
|
|
||||||
@@ -100,15 +67,12 @@ export const useKeyboardEvents = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
|
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
|
||||||
}, [handleModifierKeys, remapCode, sendKeyboardEvent, isKeyboardLedManagedByHost, setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive, pasteShortcutEnabled, pasteShortcut, pasteCaptureRef, isReinitializingGadget, isOcrMode]);
|
}, [handleModifierKeys, sendKeyboardEvent, isKeyboardLedManagedByHost, setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive, overrideCtrlV, pasteCaptureRef, isReinitializingGadget]);
|
||||||
|
|
||||||
const keyUpHandler = useCallback((e: KeyboardEvent) => {
|
const keyUpHandler = useCallback((e: KeyboardEvent) => {
|
||||||
if (isOcrMode) return;
|
|
||||||
if (isReinitializingGadget) return;
|
if (isReinitializingGadget) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const prev = useHidStore.getState();
|
const prev = useHidStore.getState();
|
||||||
const key = e.key;
|
|
||||||
let code = remapCode(e.code, key);
|
|
||||||
|
|
||||||
if (!isKeyboardLedManagedByHost) {
|
if (!isKeyboardLedManagedByHost) {
|
||||||
setIsNumLockActive(e.getModifierState("NumLock"));
|
setIsNumLockActive(e.getModifierState("NumLock"));
|
||||||
@@ -116,14 +80,14 @@ export const useKeyboardEvents = (
|
|||||||
setIsScrollLockActive(e.getModifierState("ScrollLock"));
|
setIsScrollLockActive(e.getModifierState("ScrollLock"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const newKeys = prev.activeKeys.filter(k => k !== keys[code]).filter(Boolean);
|
const newKeys = prev.activeKeys.filter(k => k !== keys[e.code]).filter(Boolean);
|
||||||
const newModifiers = handleModifierKeys(
|
const newModifiers = handleModifierKeys(
|
||||||
e,
|
e,
|
||||||
prev.activeModifiers.filter(k => k !== modifiers[code]),
|
prev.activeModifiers.filter(k => k !== modifiers[e.code]),
|
||||||
);
|
);
|
||||||
|
|
||||||
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
|
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
|
||||||
}, [handleModifierKeys, remapCode, sendKeyboardEvent, isKeyboardLedManagedByHost, setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive, isOcrMode, isReinitializingGadget]);
|
}, [handleModifierKeys, sendKeyboardEvent, isKeyboardLedManagedByHost, setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive]);
|
||||||
|
|
||||||
const setupKeyboardEvents = useCallback(() => {
|
const setupKeyboardEvents = useCallback(() => {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|||||||
@@ -25,18 +25,15 @@ export const useMouseEvents = (
|
|||||||
const { setMousePosition, setMouseMove } = useMouseStore();
|
const { setMousePosition, setMouseMove } = useMouseStore();
|
||||||
const { width: videoWidth, height: videoHeight } = useVideoStore();
|
const { width: videoWidth, height: videoHeight } = useVideoStore();
|
||||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||||
const touchDragActiveRef = useRef(false);
|
|
||||||
|
|
||||||
const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos);
|
const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos);
|
||||||
|
|
||||||
const sendRelMouseMovement = useCallback(
|
const sendRelMouseMovement = useCallback(
|
||||||
(x: number, y: number, buttons: number, force = false) => {
|
(x: number, y: number, buttons: number) => {
|
||||||
if (!force && settings.mouseMode !== "relative") return;
|
if (settings.mouseMode !== "relative") return;
|
||||||
// Don't send mouse events while reinitializing gadget
|
// Don't send mouse events while reinitializing gadget
|
||||||
if (isReinitializingGadget) return;
|
if (isReinitializingGadget) return;
|
||||||
const dx = calcDelta(x);
|
send("relMouseReport", { dx: calcDelta(x), dy: calcDelta(y), buttons });
|
||||||
const dy = calcDelta(y);
|
|
||||||
send("relMouseReport", { dx, dy, buttons });
|
|
||||||
setMouseMove({ x, y, buttons });
|
setMouseMove({ x, y, buttons });
|
||||||
},
|
},
|
||||||
[send, setMouseMove, settings.mouseMode, isReinitializingGadget],
|
[send, setMouseMove, settings.mouseMode, isReinitializingGadget],
|
||||||
@@ -53,13 +50,6 @@ export const useMouseEvents = (
|
|||||||
[send, setMousePosition, settings.mouseMode, isReinitializingGadget],
|
[send, setMousePosition, settings.mouseMode, isReinitializingGadget],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendVirtualRelativeMovement = useCallback(
|
|
||||||
(x: number, y: number, buttons = 0) => {
|
|
||||||
sendRelMouseMovement(x, y, buttons, true);
|
|
||||||
},
|
|
||||||
[sendRelMouseMovement],
|
|
||||||
);
|
|
||||||
|
|
||||||
const relMouseMoveHandler = useCallback(
|
const relMouseMoveHandler = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
const pt = (e as unknown as PointerEvent).pointerType as unknown as string;
|
const pt = (e as unknown as PointerEvent).pointerType as unknown as string;
|
||||||
@@ -86,17 +76,11 @@ export const useMouseEvents = (
|
|||||||
const absMouseMoveHandler = useCallback(
|
const absMouseMoveHandler = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
const pt = (e as unknown as PointerEvent).pointerType as unknown as string;
|
const pt = (e as unknown as PointerEvent).pointerType as unknown as string;
|
||||||
const pointerEvent = e as unknown as PointerEvent;
|
|
||||||
const eventType = pointerEvent.type;
|
|
||||||
if (pt === "touch") {
|
if (pt === "touch") {
|
||||||
if (touchZoom) {
|
if (touchZoom) {
|
||||||
const touchCount = touchZoom.activeTouchPointers.current.size;
|
const touchCount = touchZoom.activeTouchPointers.current.size;
|
||||||
if (touchCount >= 2) {
|
const eventType = (e as unknown as PointerEvent).type;
|
||||||
if (eventType === "pointerup" || eventType === "pointercancel") {
|
if (touchCount >= 2 && eventType !== "pointerup") return;
|
||||||
touchDragActiveRef.current = false;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,20 +135,28 @@ export const useMouseEvents = (
|
|||||||
let buttons = e.buttons;
|
let buttons = e.buttons;
|
||||||
|
|
||||||
if (pt === "touch") {
|
if (pt === "touch") {
|
||||||
if (eventType === "pointerdown") {
|
const touchCount = touchZoom ? touchZoom.activeTouchPointers.current.size : 1;
|
||||||
touchDragActiveRef.current = !disableTouchClick;
|
const pointerEvent = e as unknown as PointerEvent;
|
||||||
}
|
const eventType = pointerEvent.type;
|
||||||
if (eventType === "pointerup" || eventType === "pointercancel") {
|
|
||||||
|
if (eventType === "pointerup") {
|
||||||
|
if (touchCount >= 2 || disableTouchClick) {
|
||||||
buttons = 0;
|
buttons = 0;
|
||||||
touchDragActiveRef.current = false;
|
|
||||||
} else {
|
} else {
|
||||||
buttons = touchDragActiveRef.current ? 1 : 0;
|
buttons = 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
buttons = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buttons |= externalButtons;
|
buttons |= externalButtons;
|
||||||
|
|
||||||
sendAbsMouseMovement(x, y, buttons);
|
sendAbsMouseMovement(x, y, buttons);
|
||||||
|
|
||||||
|
if (pt === "touch" && buttons !== externalButtons && (e as unknown as PointerEvent).type === "pointerup") {
|
||||||
|
sendAbsMouseMovement(x, y, externalButtons);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[settings.mouseMode, videoElm, videoWidth, videoHeight, sendAbsMouseMovement, touchZoom, disableTouchClick, externalButtons],
|
[settings.mouseMode, videoElm, videoWidth, videoHeight, sendAbsMouseMovement, touchZoom, disableTouchClick, externalButtons],
|
||||||
);
|
);
|
||||||
@@ -224,10 +216,8 @@ export const useMouseEvents = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
videoElmRefValue.addEventListener("mousemove", eventHandler, { signal });
|
videoElmRefValue.addEventListener("mousemove", eventHandler, { signal });
|
||||||
videoElmRefValue.addEventListener("pointermove", eventHandler, { signal });
|
|
||||||
videoElmRefValue.addEventListener("pointerdown", eventHandler, { signal });
|
videoElmRefValue.addEventListener("pointerdown", eventHandler, { signal });
|
||||||
videoElmRefValue.addEventListener("pointerup", eventHandler, { signal });
|
videoElmRefValue.addEventListener("pointerup", eventHandler, { signal });
|
||||||
videoElmRefValue.addEventListener("pointercancel", eventHandler, { signal });
|
|
||||||
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
|
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
|
||||||
signal,
|
signal,
|
||||||
passive: true,
|
passive: true,
|
||||||
@@ -265,6 +255,5 @@ export const useMouseEvents = (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
setupMouseEvents,
|
setupMouseEvents,
|
||||||
sendVirtualRelativeMovement,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ import { useSettingsStore, useHidStore, useUiStore } from "@/hooks/stores";
|
|||||||
import { keys, modifiers } from "@/keyboardMappings";
|
import { keys, modifiers } from "@/keyboardMappings";
|
||||||
import { chars } from "@/keyboardLayouts";
|
import { chars } from "@/keyboardLayouts";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import { eventMatchesShortcut } from "@/utils/shortcuts";
|
|
||||||
|
|
||||||
export const usePasteHandler = (pasteCaptureRef?: React.RefObject<HTMLTextAreaElement>) => {
|
export const usePasteHandler = (pasteCaptureRef?: React.RefObject<HTMLTextAreaElement>) => {
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
const pasteShortcutEnabled = useSettingsStore(state => state.pasteShortcutEnabled);
|
const overrideCtrlV = useSettingsStore(state => state.overrideCtrlV);
|
||||||
const pasteShortcut = useSettingsStore(state => state.pasteShortcut);
|
|
||||||
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
|
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
|
||||||
const setKeyboardLayout = useSettingsStore(state => state.setKeyboardLayout);
|
const setKeyboardLayout = useSettingsStore(state => state.setKeyboardLayout);
|
||||||
const debugMode = useSettingsStore(state => state.debugMode);
|
const debugMode = useSettingsStore(state => state.debugMode);
|
||||||
@@ -142,11 +140,12 @@ export const usePasteHandler = (pasteCaptureRef?: React.RefObject<HTMLTextAreaEl
|
|||||||
}, [log, send, setKeyboardLayout]);
|
}, [log, send, setKeyboardLayout]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pasteShortcutEnabled) return;
|
if (!overrideCtrlV) return;
|
||||||
|
|
||||||
const onKeyDownCapture = (e: KeyboardEvent) => {
|
const onKeyDownCapture = (e: KeyboardEvent) => {
|
||||||
if (!pasteShortcutEnabled) return;
|
if (!overrideCtrlV) return;
|
||||||
if (!eventMatchesShortcut(e, pasteShortcut)) return;
|
if (!(e.ctrlKey || e.metaKey)) return;
|
||||||
|
if (e.code !== "KeyV" && e.key.toLowerCase() !== "v") return;
|
||||||
if (isReinitializingGadget) return;
|
if (isReinitializingGadget) return;
|
||||||
|
|
||||||
const activeElement = document.activeElement as HTMLElement | null;
|
const activeElement = document.activeElement as HTMLElement | null;
|
||||||
@@ -197,20 +196,20 @@ export const usePasteHandler = (pasteCaptureRef?: React.RefObject<HTMLTextAreaEl
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("keydown", onKeyDownCapture, { capture: true });
|
document.removeEventListener("keydown", onKeyDownCapture, { capture: true });
|
||||||
};
|
};
|
||||||
}, [ensureFocusTrapPaused, isReinitializingGadget, log, pasteShortcutEnabled, pasteShortcut, pasteCaptureRef, safeKeyboardLayout, sendTextToRemote]);
|
}, [ensureFocusTrapPaused, isReinitializingGadget, log, overrideCtrlV, pasteCaptureRef, safeKeyboardLayout, sendTextToRemote]);
|
||||||
|
|
||||||
const handleGlobalPaste = useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement> | ClipboardEvent) => {
|
const handleGlobalPaste = useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement> | ClipboardEvent) => {
|
||||||
if (!pasteShortcutEnabled) return;
|
if (!overrideCtrlV) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const clipboardData = (e as React.ClipboardEvent).clipboardData || (e as ClipboardEvent).clipboardData;
|
const clipboardData = (e as React.ClipboardEvent).clipboardData || (e as ClipboardEvent).clipboardData;
|
||||||
const txt = clipboardData?.getData("text") || "";
|
const txt = clipboardData?.getData("text") || "";
|
||||||
|
|
||||||
await sendTextToRemote(txt);
|
await sendTextToRemote(txt);
|
||||||
}, [log, pasteShortcutEnabled, safeKeyboardLayout, sendTextToRemote]);
|
}, [log, overrideCtrlV, safeKeyboardLayout, sendTextToRemote]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleGlobalPaste,
|
handleGlobalPaste,
|
||||||
pasteShortcutEnabled,
|
overrideCtrlV,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,17 +7,31 @@ export const usePointerLock = (videoElm: React.RefObject<HTMLVideoElement>) => {
|
|||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost";
|
const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost";
|
||||||
|
|
||||||
const requestPointerLock = useCallback(async () => {
|
const checkNavigatorPermissions = useCallback(async (permissionName: string) => {
|
||||||
if (!isPointerLockPossible || !videoElm.current || document.pointerLockElement || settings.mouseMode !== "relative") {
|
if (!navigator.permissions?.query) return false;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await videoElm.current.requestPointerLock();
|
const { state } = await navigator.permissions.query({
|
||||||
} catch (err) {
|
name: permissionName as PermissionName
|
||||||
console.warn("[pointer-lock] requestPointerLock failed:", err);
|
});
|
||||||
|
return state === "granted";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}, [isPointerLockPossible, settings.mouseMode, videoElm]);
|
}, []);
|
||||||
|
|
||||||
|
const requestPointerLock = useCallback(async () => {
|
||||||
|
if (!isPointerLockPossible || !videoElm.current || document.pointerLockElement) return;
|
||||||
|
|
||||||
|
const isPointerLockGranted = await checkNavigatorPermissions("pointer-lock");
|
||||||
|
if (isPointerLockGranted && settings.mouseMode === "relative") {
|
||||||
|
try {
|
||||||
|
await videoElm.current.requestPointerLock();
|
||||||
|
} catch {
|
||||||
|
// ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [checkNavigatorPermissions, isPointerLockPossible, settings.mouseMode, videoElm]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPointerLockPossible || !videoElm.current) return;
|
if (!isPointerLockPossible || !videoElm.current) return;
|
||||||
@@ -26,7 +40,6 @@ export const usePointerLock = (videoElm: React.RefObject<HTMLVideoElement>) => {
|
|||||||
setIsPointerLockActive(!!document.pointerLockElement);
|
setIsPointerLockActive(!!document.pointerLockElement);
|
||||||
};
|
};
|
||||||
|
|
||||||
handlePointerLockChange();
|
|
||||||
document.addEventListener("pointerlockchange", handlePointerLockChange);
|
document.addEventListener("pointerlockchange", handlePointerLockChange);
|
||||||
return () => document.removeEventListener("pointerlockchange", handlePointerLockChange);
|
return () => document.removeEventListener("pointerlockchange", handlePointerLockChange);
|
||||||
}, [isPointerLockPossible, videoElm]);
|
}, [isPointerLockPossible, videoElm]);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
export const useTouchZoom = (
|
export const useTouchZoom = (
|
||||||
containerRef: React.RefObject<HTMLDivElement>,
|
containerRef: React.RefObject<HTMLDivElement>
|
||||||
gestureEnabled = true,
|
|
||||||
) => {
|
) => {
|
||||||
const [mobileScale, setMobileScale] = useState(1);
|
const [mobileScale, setMobileScale] = useState(1);
|
||||||
const [mobileTx, setMobileTx] = useState(0);
|
const [mobileTx, setMobileTx] = useState(0);
|
||||||
@@ -18,83 +17,67 @@ export const useTouchZoom = (
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const signal = abortController.signal;
|
const signal = abortController.signal;
|
||||||
const isPointInVideo = (x: number, y: number) => {
|
|
||||||
const video = el.querySelector("video") as HTMLVideoElement | null;
|
|
||||||
if (!video) return false;
|
|
||||||
const vRect = video.getBoundingClientRect();
|
|
||||||
return x >= vRect.left && x <= vRect.right && y >= vRect.top && y <= vRect.bottom;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPointerDown = (e: PointerEvent) => {
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
if (e.pointerType !== "touch") return;
|
if (e.pointerType !== "touch") return;
|
||||||
activeTouchPointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
activeTouchPointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
if (!gestureEnabled) return;
|
|
||||||
let shouldHandleLocalGesture = false;
|
|
||||||
|
|
||||||
if (activeTouchPointers.current.size === 1) {
|
if (activeTouchPointers.current.size === 1) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const isInVideo = isPointInVideo(e.clientX, e.clientY);
|
let isInVideo = false;
|
||||||
|
const video = el.querySelector("video") as HTMLVideoElement | null;
|
||||||
|
if (video) {
|
||||||
|
const vRect = video.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
e.clientX >= vRect.left &&
|
||||||
|
e.clientX <= vRect.right &&
|
||||||
|
e.clientY >= vRect.top &&
|
||||||
|
e.clientY <= vRect.bottom
|
||||||
|
) {
|
||||||
|
isInVideo = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!isInVideo) {
|
if (!isInVideo) {
|
||||||
if (now - lastTapAt.current < 300) {
|
if (now - lastTapAt.current < 300) {
|
||||||
setMobileScale(1);
|
setMobileScale(1);
|
||||||
setMobileTx(0);
|
setMobileTx(0);
|
||||||
setMobileTy(0);
|
setMobileTy(0);
|
||||||
}
|
}
|
||||||
shouldHandleLocalGesture = true;
|
|
||||||
}
|
}
|
||||||
lastTapAt.current = now;
|
lastTapAt.current = now;
|
||||||
if (mobileScale > 1) {
|
|
||||||
lastPanPoint.current = { x: e.clientX, y: e.clientY };
|
lastPanPoint.current = { x: e.clientX, y: e.clientY };
|
||||||
shouldHandleLocalGesture = true;
|
|
||||||
} else {
|
|
||||||
lastPanPoint.current = null;
|
|
||||||
}
|
|
||||||
} else if (activeTouchPointers.current.size === 2) {
|
} else if (activeTouchPointers.current.size === 2) {
|
||||||
const pts = Array.from(activeTouchPointers.current.values());
|
const pts = Array.from(activeTouchPointers.current.values());
|
||||||
const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
||||||
initialPinchDistance.current = d;
|
initialPinchDistance.current = d;
|
||||||
initialPinchScale.current = mobileScale;
|
initialPinchScale.current = mobileScale;
|
||||||
shouldHandleLocalGesture = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldHandleLocalGesture) {
|
|
||||||
e.preventDefault();
|
e.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);
|
||||||
shouldHandleLocalGesture = true;
|
} else if (pts.length === 1 && lastPanPoint.current && prev) {
|
||||||
} else if (pts.length === 1 && mobileScale > 1 && lastPanPoint.current && prev) {
|
|
||||||
const dx = e.clientX - lastPanPoint.current.x;
|
const dx = e.clientX - lastPanPoint.current.x;
|
||||||
const dy = e.clientY - lastPanPoint.current.y;
|
const dy = e.clientY - lastPanPoint.current.y;
|
||||||
lastPanPoint.current = { x: e.clientX, y: e.clientY };
|
lastPanPoint.current = { x: e.clientX, y: e.clientY };
|
||||||
setMobileTx(v => v + dx);
|
setMobileTx(v => v + dx);
|
||||||
setMobileTy(v => v + dy);
|
setMobileTy(v => v + dy);
|
||||||
shouldHandleLocalGesture = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldHandleLocalGesture) {
|
|
||||||
e.preventDefault();
|
e.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;
|
||||||
@@ -102,11 +85,8 @@ 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 });
|
||||||
@@ -115,15 +95,7 @@ export const useTouchZoom = (
|
|||||||
el.addEventListener("pointercancel", onPointerUp, { signal });
|
el.addEventListener("pointercancel", onPointerUp, { signal });
|
||||||
|
|
||||||
return () => abortController.abort();
|
return () => abortController.abort();
|
||||||
}, [mobileScale, containerRef, gestureEnabled]);
|
}, [mobileScale, containerRef]);
|
||||||
|
|
||||||
const resetTransform = () => {
|
|
||||||
initialPinchDistance.current = null;
|
|
||||||
lastPanPoint.current = null;
|
|
||||||
setMobileScale(1);
|
|
||||||
setMobileTx(0);
|
|
||||||
setMobileTy(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@@ -143,6 +115,5 @@ export const useTouchZoom = (
|
|||||||
mobileTy,
|
mobileTy,
|
||||||
activeTouchPointers,
|
activeTouchPointers,
|
||||||
lastPanPoint,
|
lastPanPoint,
|
||||||
resetTransform,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
useVpnStore } from "@/hooks/stores";
|
useVpnStore } from "@/hooks/stores";
|
||||||
import { JsonRpcRequest, useJsonRpc, resetHttpSessionId } from "@/hooks/useJsonRpc";
|
import { JsonRpcRequest, useJsonRpc, resetHttpSessionId } from "@/hooks/useJsonRpc";
|
||||||
import api from "@/api";
|
|
||||||
import Modal from "@components/Modal";
|
import Modal from "@components/Modal";
|
||||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||||
import {
|
import {
|
||||||
@@ -352,18 +351,6 @@ export default function MobileHome() {
|
|||||||
try {
|
try {
|
||||||
console.log("[setupPeerConnection] Creating peer connection");
|
console.log("[setupPeerConnection] Creating peer connection");
|
||||||
setLoadingMessage("Creating peer connection...");
|
setLoadingMessage("Creating peer connection...");
|
||||||
let fetchedIceServers: RTCIceServer[] = [];
|
|
||||||
if (!iceConfig?.iceServers) {
|
|
||||||
try {
|
|
||||||
const res = await api.GET("/api/ice-servers");
|
|
||||||
const data = await res.json();
|
|
||||||
fetchedIceServers = data.iceServers ?? [];
|
|
||||||
} catch (e) {
|
|
||||||
console.error("failed to fetch ICE servers, fallback", e);
|
|
||||||
fetchedIceServers = [{ urls: ["stun:stun.l.google.com:19302"] }];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pc = new RTCPeerConnection({
|
pc = new RTCPeerConnection({
|
||||||
// We only use STUN or TURN servers if we're in the cloud
|
// We only use STUN or TURN servers if we're in the cloud
|
||||||
//...(isInCloud && iceConfig?.iceServers
|
//...(isInCloud && iceConfig?.iceServers
|
||||||
@@ -371,7 +358,13 @@ export default function MobileHome() {
|
|||||||
// : {}),
|
// : {}),
|
||||||
...(iceConfig?.iceServers
|
...(iceConfig?.iceServers
|
||||||
? { iceServers: [iceConfig?.iceServers] }
|
? { iceServers: [iceConfig?.iceServers] }
|
||||||
: { iceServers: fetchedIceServers }),
|
: {
|
||||||
|
iceServers: [
|
||||||
|
{
|
||||||
|
urls: ['stun:stun.l.google.com:19302']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
setPeerConnectionState(pc.connectionState);
|
setPeerConnectionState(pc.connectionState);
|
||||||
|
|||||||
@@ -363,18 +363,6 @@ export default function PCHome() {
|
|||||||
try {
|
try {
|
||||||
console.log("[setupPeerConnection] Creating peer connection");
|
console.log("[setupPeerConnection] Creating peer connection");
|
||||||
setLoadingMessage("Creating peer connection...");
|
setLoadingMessage("Creating peer connection...");
|
||||||
let fetchedIceServers: RTCIceServer[] = [];
|
|
||||||
if (!iceConfig?.iceServers) {
|
|
||||||
try {
|
|
||||||
const res = await api.GET("/api/ice-servers");
|
|
||||||
const data = await res.json();
|
|
||||||
fetchedIceServers = data.iceServers ?? [];
|
|
||||||
} catch (e) {
|
|
||||||
console.error("failed to fetch ICE servers, fallback", e);
|
|
||||||
fetchedIceServers = [{ urls: ["stun:stun.l.google.com:19302"] }];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pc = new RTCPeerConnection({
|
pc = new RTCPeerConnection({
|
||||||
// We only use STUN or TURN servers if we're in the cloud
|
// We only use STUN or TURN servers if we're in the cloud
|
||||||
//...(isInCloud && iceConfig?.iceServers
|
//...(isInCloud && iceConfig?.iceServers
|
||||||
@@ -382,7 +370,13 @@ export default function PCHome() {
|
|||||||
// : {}),
|
// : {}),
|
||||||
...(iceConfig?.iceServers
|
...(iceConfig?.iceServers
|
||||||
? { iceServers: [iceConfig?.iceServers] }
|
? { iceServers: [iceConfig?.iceServers] }
|
||||||
: { iceServers: fetchedIceServers }),
|
: {
|
||||||
|
iceServers: [
|
||||||
|
{
|
||||||
|
urls: ['stun:stun.l.google.com:19302']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
setPeerConnectionState(pc.connectionState);
|
setPeerConnectionState(pc.connectionState);
|
||||||
@@ -843,9 +837,9 @@ export default function PCHome() {
|
|||||||
<Desktop isFullscreen={isFullscreen} />
|
<Desktop isFullscreen={isFullscreen} />
|
||||||
<div
|
<div
|
||||||
style={{ animationDuration: "500ms" }}
|
style={{ animationDuration: "500ms" }}
|
||||||
className="animate-slideUpFade pointer-events-none absolute inset-0 z-20 flex items-center justify-center"
|
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center p-4"
|
||||||
>
|
>
|
||||||
<div className="relative h-full w-full">
|
<div className={`relative h-full max-h-[720px] w-full rounded-md`}>
|
||||||
{!!ConnectionStatusElement && ConnectionStatusElement}
|
{!!ConnectionStatusElement && ConnectionStatusElement}
|
||||||
{/*<ConnectionFailedOverlay show={true} setupPeerConnection={setupPeerConnection} />*/}
|
{/*<ConnectionFailedOverlay show={true} setupPeerConnection={setupPeerConnection} />*/}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -104,14 +104,6 @@
|
|||||||
"f9099fc033": "Renew DHCP Lease",
|
"f9099fc033": "Renew DHCP Lease",
|
||||||
"6e188f5984": "IPv6 Information",
|
"6e188f5984": "IPv6 Information",
|
||||||
"cfdffa4fc7": "Link-local",
|
"cfdffa4fc7": "Link-local",
|
||||||
"a588b1abc5": "Copied",
|
|
||||||
"c5505be6c3": "No text detected",
|
|
||||||
"a29aaf6603": "OCR failed",
|
|
||||||
"dc67ba5f40": "Drag to select text area",
|
|
||||||
"b603b043e8": "Copy text",
|
|
||||||
"f5f78c501b": "Recognizing text...",
|
|
||||||
"5df17e67f5": "Review the recognized text before copying.",
|
|
||||||
"4dfb4a614f": "Please wait while OCR is running.",
|
|
||||||
"d6e24388b4": "Continue Uploading",
|
"d6e24388b4": "Continue Uploading",
|
||||||
"62a5e49088": "Hide",
|
"62a5e49088": "Hide",
|
||||||
"b34e855501": "USB Expansion Function",
|
"b34e855501": "USB Expansion Function",
|
||||||
@@ -181,24 +173,6 @@
|
|||||||
"c40a72ad93": "KVM Terminal",
|
"c40a72ad93": "KVM Terminal",
|
||||||
"dd110018d2": "Serial Console",
|
"dd110018d2": "Serial Console",
|
||||||
"7b277018e4": "Not connected",
|
"7b277018e4": "Not connected",
|
||||||
"02d2f33ec9": "Reinitialize USB Gadget",
|
|
||||||
"c8e875e1c9": "Version Manager",
|
|
||||||
"63a6a88c06": "Refresh",
|
|
||||||
"d15f6e3312": "Hide Install Actions",
|
|
||||||
"2b9b84a087": "Show Install Actions",
|
|
||||||
"81dfff9d6f": "Install Status",
|
|
||||||
"98dd43dfae": "Installed",
|
|
||||||
"ddd8eef6f8": "Not Installed",
|
|
||||||
"e2dc83997b": "Detected Version",
|
|
||||||
"e3cc7e7df9": "System Architecture",
|
|
||||||
"bc56777902": "Release Version",
|
|
||||||
"c319e3982f": "Release Asset",
|
|
||||||
"06933067aa": "Update",
|
|
||||||
"a27dfe7717": "Uninstall",
|
|
||||||
"349838fb1d": "Install",
|
|
||||||
"4a0764788d": "Install Task",
|
|
||||||
"46b5f8c58b": "Progress",
|
|
||||||
"5e4caae72b": "Installed Versions",
|
|
||||||
"bf733d8a93": "Access",
|
"bf733d8a93": "Access",
|
||||||
"70e23e7d6f": "Manage the Access Control of the device",
|
"70e23e7d6f": "Manage the Access Control of the device",
|
||||||
"509820290d": "Local",
|
"509820290d": "Local",
|
||||||
@@ -221,8 +195,6 @@
|
|||||||
"efd3cc702e": "Enable Password",
|
"efd3cc702e": "Enable Password",
|
||||||
"8f1e77e0d2": "Change Password",
|
"8f1e77e0d2": "Change Password",
|
||||||
"3d0de21428": "Update your device access password",
|
"3d0de21428": "Update your device access password",
|
||||||
"d6d56d5972": "WebRTC Servers",
|
|
||||||
"8bea04c48e": "STUN and TURN servers used for peer connections",
|
|
||||||
"f8508f576c": "Remote",
|
"f8508f576c": "Remote",
|
||||||
"a8bb6f5f9f": "Manage the mode of Remote access to the device",
|
"a8bb6f5f9f": "Manage the mode of Remote access to the device",
|
||||||
"0b86461350": "TailScale use xEdge server",
|
"0b86461350": "TailScale use xEdge server",
|
||||||
@@ -295,6 +267,7 @@
|
|||||||
"324118a672": "Input",
|
"324118a672": "Input",
|
||||||
"29c2c02a36": "Output",
|
"29c2c02a36": "Output",
|
||||||
"67d2f6740a": "Forward",
|
"67d2f6740a": "Forward",
|
||||||
|
"63a6a88c06": "Refresh",
|
||||||
"a4d3b161ce": "Submit",
|
"a4d3b161ce": "Submit",
|
||||||
"ec211f7c20": "Add",
|
"ec211f7c20": "Add",
|
||||||
"5320550175": "Chain",
|
"5320550175": "Chain",
|
||||||
@@ -315,22 +288,6 @@
|
|||||||
"64ae5dd047": "Destination Port",
|
"64ae5dd047": "Destination Port",
|
||||||
"fbb3878eb7": "Submit Firewall Policies?",
|
"fbb3878eb7": "Submit Firewall Policies?",
|
||||||
"1c17a7e15b": "Warning: Adjusting some policies may cause network address loss, leading to device unavailability.",
|
"1c17a7e15b": "Warning: Adjusting some policies may cause network address loss, leading to device unavailability.",
|
||||||
"6f20995c95": "Failed to load WebRTC servers",
|
|
||||||
"aee9784c03": "Unknown error",
|
|
||||||
"6ef7ce5b80": "Failed to save STUN",
|
|
||||||
"4cd48aed7f": "STUN server saved",
|
|
||||||
"4b5d050e51": "Failed to save TURN",
|
|
||||||
"f6f10b4517": "TURN servers saved",
|
|
||||||
"d235995f96": "STUN Server",
|
|
||||||
"fc1bd2c935": "Public STUN server for NAT traversal",
|
|
||||||
"289929755b": "Restore Default",
|
|
||||||
"0268827609": "TURN Servers",
|
|
||||||
"b3c14a0273": "Used as relay when direct peer-to-peer connection fails",
|
|
||||||
"bbc48fb751": "No TURN servers configured",
|
|
||||||
"f6039d44b2": "Username",
|
|
||||||
"03bc142e64": "Credential",
|
|
||||||
"ca3e8baee9": "Add TURN Server",
|
|
||||||
"0acde1b3e3": "Save TURN Servers",
|
|
||||||
"867cee98fd": "Passwords do not match",
|
"867cee98fd": "Passwords do not match",
|
||||||
"9864ff9420": "Please enter your old password",
|
"9864ff9420": "Please enter your old password",
|
||||||
"14a714ab22": "Please enter a new password",
|
"14a714ab22": "Please enter a new password",
|
||||||
@@ -371,12 +328,6 @@
|
|||||||
"3369c97f56": "Enter your SSH public key",
|
"3369c97f56": "Enter your SSH public key",
|
||||||
"81bafb2833": "The default SSH user is ",
|
"81bafb2833": "The default SSH user is ",
|
||||||
"7a941a0f87": "Update SSH Key",
|
"7a941a0f87": "Update SSH Key",
|
||||||
"d876ff8da6": "API Key",
|
|
||||||
"96164f17cf": "API key for MCP and REST API authentication",
|
|
||||||
"538ce1893b": "Enter API key or leave empty to auto-generate",
|
|
||||||
"a55c6d580b": "Used for authenticating MCP and REST API requests.",
|
|
||||||
"e85c388332": "Save API Key",
|
|
||||||
"927c25953b": "Generate New",
|
|
||||||
"fdad65b7be": "Force HTTP Transmission",
|
"fdad65b7be": "Force HTTP Transmission",
|
||||||
"e033b5e2a3": "Force using HTTP for video streaming instead of WebRTC",
|
"e033b5e2a3": "Force using HTTP for video streaming instead of WebRTC",
|
||||||
"bda22ca687": "USB detection enhancement",
|
"bda22ca687": "USB detection enhancement",
|
||||||
@@ -387,6 +338,7 @@
|
|||||||
"020b92cfbb": "Enable USB Emulation",
|
"020b92cfbb": "Enable USB Emulation",
|
||||||
"9f55f64b0f": "USB Gadget Reinitialize",
|
"9f55f64b0f": "USB Gadget Reinitialize",
|
||||||
"40dc677a89": "Reinitialize USB gadget configuration",
|
"40dc677a89": "Reinitialize USB gadget configuration",
|
||||||
|
"02d2f33ec9": "Reinitialize USB Gadget",
|
||||||
"f5ddf02991": "Reboot System",
|
"f5ddf02991": "Reboot System",
|
||||||
"1dbbf194af": "Restart the device system",
|
"1dbbf194af": "Restart the device system",
|
||||||
"1de72c4fc6": "Reboot",
|
"1de72c4fc6": "Reboot",
|
||||||
@@ -395,10 +347,6 @@
|
|||||||
"f43c0398a4": "Reset Configuration",
|
"f43c0398a4": "Reset Configuration",
|
||||||
"0031dbef48": "Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.",
|
"0031dbef48": "Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.",
|
||||||
"0d784092e8": "Reset Config",
|
"0d784092e8": "Reset Config",
|
||||||
"115082e888": "Clear API Key?",
|
|
||||||
"78fcaed30d": "Setting the API key to empty will auto-generate a new random key.",
|
|
||||||
"81e8b4cd6b": "Make sure to update your clients with the new key after saving.",
|
|
||||||
"211730be68": "Generate New Key",
|
|
||||||
"a776e925bf": "Reboot System?",
|
"a776e925bf": "Reboot System?",
|
||||||
"1f070051ff": "Are you sure you want to reboot the system?",
|
"1f070051ff": "Are you sure you want to reboot the system?",
|
||||||
"f1a79f466e": "The device will restart and you will be disconnected from the web interface.",
|
"f1a79f466e": "The device will restart and you will be disconnected from the web interface.",
|
||||||
@@ -455,12 +403,6 @@
|
|||||||
"ef64a3770e": "Control mDNS (multicast DNS) operational mode",
|
"ef64a3770e": "Control mDNS (multicast DNS) operational mode",
|
||||||
"be8226fe0c": "Time synchronization",
|
"be8226fe0c": "Time synchronization",
|
||||||
"7e06bd28a6": "Configure time synchronization settings",
|
"7e06bd28a6": "Configure time synchronization settings",
|
||||||
"a2323452ba": "HTTP Proxy",
|
|
||||||
"7fedd1ea53": "Configure program HTTP proxy (optional)",
|
|
||||||
"a30d487cba": "HTTPS Proxy",
|
|
||||||
"3f4c7e23cb": "Configure program HTTPS proxy (optional)",
|
|
||||||
"48d941841f": "ALL Proxy",
|
|
||||||
"3a2cd1e4a7": "Configure program ALL proxy (optional)",
|
|
||||||
"d4dccb8ca2": "Save settings",
|
"d4dccb8ca2": "Save settings",
|
||||||
"8750a898cb": "IPv4 Mode",
|
"8750a898cb": "IPv4 Mode",
|
||||||
"72c2543791": "Configure IPv4 mode",
|
"72c2543791": "Configure IPv4 mode",
|
||||||
@@ -502,16 +444,7 @@
|
|||||||
"5b404b3c98": "Update in Background",
|
"5b404b3c98": "Update in Background",
|
||||||
"3723a3f846": "System is up to date",
|
"3723a3f846": "System is up to date",
|
||||||
"a6b8796d51": "Your system is running the latest version. No updates are currently available.",
|
"a6b8796d51": "Your system is running the latest version. No updates are currently available.",
|
||||||
"a8d2a89696": "Missing Signature File",
|
|
||||||
"480f05b41b": "The current firmware is missing signature files. Integrity cannot be fully verified.",
|
|
||||||
"323ebc9620": "Signature Verification Failed",
|
|
||||||
"21355c9ecb": "The signature file exists but does not match the firmware. This may indicate tampering.",
|
|
||||||
"0b54a6c322": "No Embedded Public Key",
|
|
||||||
"9beb932d5a": "This build does not have an OTA public key embedded. Signature verification is unavailable.",
|
|
||||||
"7c81553358": "Check Again",
|
"7c81553358": "Check Again",
|
||||||
"3b5f9a1f01": "Update Signatures",
|
|
||||||
"d25bfbb978": "Update the signature of kvm_app to the latest version. If the current version is not up to date, signature verification will fail.",
|
|
||||||
"1bdd158a19": "Signature Update Result",
|
|
||||||
"217b416896": "Update available",
|
"217b416896": "Update available",
|
||||||
"f00af8c98f": "A new update is available to enhance system performance and improve compatibility. We recommend updating to ensure everything runs smoothly.",
|
"f00af8c98f": "A new update is available to enhance system performance and improve compatibility. We recommend updating to ensure everything runs smoothly.",
|
||||||
"8977c3f0b7": "Download Proxy Prefix",
|
"8977c3f0b7": "Download Proxy Prefix",
|
||||||
@@ -520,22 +453,11 @@
|
|||||||
"cffae9918d": "Update Error",
|
"cffae9918d": "Update Error",
|
||||||
"49cba7cadf": "An error occurred while updating your device. Please try again later.",
|
"49cba7cadf": "An error occurred while updating your device. Please try again later.",
|
||||||
"d849d5b330": "Error details:",
|
"d849d5b330": "Error details:",
|
||||||
"fcfc8da1a3": "Verifying signature...",
|
"2b2f7a6d7c": "Use Ctrl+V to paste clipboard to remote",
|
||||||
"4e16083ef8": "Please wait while verifying firmware signature.",
|
|
||||||
"5367acff78": "Signature Status Unavailable",
|
|
||||||
"634aac26af": "Unable to retrieve signature verification status.",
|
|
||||||
"77de342f39": "Signature Verified",
|
|
||||||
"20ac4a17cc": "Firmware signature has been verified and is valid.",
|
|
||||||
"36a41c937c": "No video signal",
|
|
||||||
"d34da01de2": "Enable paste shortcut",
|
|
||||||
"2b1a1676d1": "Copy text from your client to the remote host",
|
"2b1a1676d1": "Copy text from your client to the remote host",
|
||||||
"604c45fbf2": "The following characters will not be pasted:",
|
"604c45fbf2": "The following characters will not be pasted:",
|
||||||
"a7eb9efa0b": "Sending text using keyboard layout:",
|
"a7eb9efa0b": "Sending text using keyboard layout:",
|
||||||
"2593d0a3d5": "Confirm paste",
|
"2593d0a3d5": "Confirm paste",
|
||||||
"ac7e2a9783": "Enable OCR shortcut",
|
|
||||||
"f529c51ee6": "OCR",
|
|
||||||
"607440855c": "Open OCR selection mode on the video area",
|
|
||||||
"c3bf447eab": "Open",
|
|
||||||
"a0e3947a02": "Macros",
|
"a0e3947a02": "Macros",
|
||||||
"276043dbf0": "Create a new keyboard macro",
|
"276043dbf0": "Create a new keyboard macro",
|
||||||
"9605ef9593": "Modify your keyboard macro",
|
"9605ef9593": "Modify your keyboard macro",
|
||||||
@@ -565,7 +487,6 @@
|
|||||||
"21d104a54f": "Processing...",
|
"21d104a54f": "Processing...",
|
||||||
"9844086d90": "No SD Card Detected",
|
"9844086d90": "No SD Card Detected",
|
||||||
"b56e598918": "SD Card Mount Failed",
|
"b56e598918": "SD Card Mount Failed",
|
||||||
"d646589704": "Choose the file system for MicroSD formatting",
|
|
||||||
"16eb8ed6c8": "Format MicroSD Card",
|
"16eb8ed6c8": "Format MicroSD Card",
|
||||||
"a63d5e0260": "Manage Shared Folders in KVM MicroSD Card",
|
"a63d5e0260": "Manage Shared Folders in KVM MicroSD Card",
|
||||||
"1d50425f88": "Manage Shared Folders in KVM Storage",
|
"1d50425f88": "Manage Shared Folders in KVM Storage",
|
||||||
@@ -577,15 +498,10 @@
|
|||||||
"8bf8854beb": "of",
|
"8bf8854beb": "of",
|
||||||
"53e61336bb": "results",
|
"53e61336bb": "results",
|
||||||
"d1f5a81904": "Unmount MicroSD Card",
|
"d1f5a81904": "Unmount MicroSD Card",
|
||||||
|
"827048afc2": "Formatting the SD card will erase all data. Continue?",
|
||||||
"5874cf46ff": "SD card formatted successfully",
|
"5874cf46ff": "SD card formatted successfully",
|
||||||
"7dc25305d4": "Encodec Type",
|
"7dc25305d4": "Encodec Type",
|
||||||
"41d263a988": "Stream Quality",
|
"41d263a988": "Stream Quality",
|
||||||
"d8d7a5377b": "RC Control",
|
|
||||||
"a09ff5b350": "Adjust rate control QP settings for better balance between quality and bitrate",
|
|
||||||
"b1f9d95e72": "StepQp",
|
|
||||||
"bf1af7559b": "MinQp",
|
|
||||||
"6e4e284fcc": "MinIQp",
|
|
||||||
"df1b8f72f2": "DetlpQp",
|
|
||||||
"41f5283941": "NPU Application",
|
"41f5283941": "NPU Application",
|
||||||
"466990072d": "Enable NPU to Object Detection",
|
"466990072d": "Enable NPU to Object Detection",
|
||||||
"a6c2e30b8e": "Video Enhancement",
|
"a6c2e30b8e": "Video Enhancement",
|
||||||
@@ -602,8 +518,6 @@
|
|||||||
"85a003df9b": "EDID File",
|
"85a003df9b": "EDID File",
|
||||||
"5c7a666766": "Set Custom EDID",
|
"5c7a666766": "Set Custom EDID",
|
||||||
"7dad0ba758": "Restore to default",
|
"7dad0ba758": "Restore to default",
|
||||||
"7b2fb72a68": "RC Advanced Config",
|
|
||||||
"827048afc2": "Formatting the SD card will erase all data. Continue?",
|
|
||||||
"556c7553f1": "KVM MicroSD Card Mount",
|
"556c7553f1": "KVM MicroSD Card Mount",
|
||||||
"b99cf1ecb7": "Manage and mount images from MicroSD card",
|
"b99cf1ecb7": "Manage and mount images from MicroSD card",
|
||||||
"0a01e80566": "Mounted from KVM storage",
|
"0a01e80566": "Mounted from KVM storage",
|
||||||
|
|||||||
@@ -104,14 +104,6 @@
|
|||||||
"f9099fc033": "重订 DHCP 租约",
|
"f9099fc033": "重订 DHCP 租约",
|
||||||
"6e188f5984": "IPv6 信息",
|
"6e188f5984": "IPv6 信息",
|
||||||
"cfdffa4fc7": "本地链路",
|
"cfdffa4fc7": "本地链路",
|
||||||
"a588b1abc5": "已复制",
|
|
||||||
"c5505be6c3": "未检测到文本",
|
|
||||||
"a29aaf6603": "OCR 失败",
|
|
||||||
"dc67ba5f40": "拖动选择文本区域",
|
|
||||||
"b603b043e8": "复制文本",
|
|
||||||
"f5f78c501b": "正在识别文本...",
|
|
||||||
"5df17e67f5": "复制前请检查识别出的文本。",
|
|
||||||
"4dfb4a614f": "请等待 OCR 运行完成。",
|
|
||||||
"d6e24388b4": "继续上传",
|
"d6e24388b4": "继续上传",
|
||||||
"62a5e49088": "隐藏",
|
"62a5e49088": "隐藏",
|
||||||
"b34e855501": "USB拓展功能",
|
"b34e855501": "USB拓展功能",
|
||||||
@@ -181,24 +173,6 @@
|
|||||||
"c40a72ad93": "KVM 终端",
|
"c40a72ad93": "KVM 终端",
|
||||||
"dd110018d2": "串口控制台",
|
"dd110018d2": "串口控制台",
|
||||||
"7b277018e4": "未连接",
|
"7b277018e4": "未连接",
|
||||||
"02d2f33ec9": "重新初始化 USB 设备",
|
|
||||||
"c8e875e1c9": "版本管理器",
|
|
||||||
"63a6a88c06": "刷新",
|
|
||||||
"d15f6e3312": "隐藏安装操作",
|
|
||||||
"2b9b84a087": "显示安装操作",
|
|
||||||
"81dfff9d6f": "安装状态",
|
|
||||||
"98dd43dfae": "已安装",
|
|
||||||
"ddd8eef6f8": "未安装",
|
|
||||||
"e2dc83997b": "检测到的版本",
|
|
||||||
"e3cc7e7df9": "系统架构",
|
|
||||||
"bc56777902": "发布版本",
|
|
||||||
"c319e3982f": "发布资源",
|
|
||||||
"06933067aa": "更新",
|
|
||||||
"a27dfe7717": "卸载",
|
|
||||||
"349838fb1d": "安装",
|
|
||||||
"4a0764788d": "安装任务",
|
|
||||||
"46b5f8c58b": "进度",
|
|
||||||
"5e4caae72b": "已安装版本",
|
|
||||||
"bf733d8a93": "访问",
|
"bf733d8a93": "访问",
|
||||||
"70e23e7d6f": "管理设备的访问控制",
|
"70e23e7d6f": "管理设备的访问控制",
|
||||||
"509820290d": "本地",
|
"509820290d": "本地",
|
||||||
@@ -221,8 +195,6 @@
|
|||||||
"efd3cc702e": "启用密码",
|
"efd3cc702e": "启用密码",
|
||||||
"8f1e77e0d2": "更改密码",
|
"8f1e77e0d2": "更改密码",
|
||||||
"3d0de21428": "更新设备访问密码",
|
"3d0de21428": "更新设备访问密码",
|
||||||
"d6d56d5972": "WebRTC 服务器",
|
|
||||||
"8bea04c48e": "用于点对点连接的 STUN 和 TURN 服务器",
|
|
||||||
"f8508f576c": "远程",
|
"f8508f576c": "远程",
|
||||||
"a8bb6f5f9f": "管理远程访问设备的方式",
|
"a8bb6f5f9f": "管理远程访问设备的方式",
|
||||||
"0b86461350": "TailScale 使用 xEdge 服务器",
|
"0b86461350": "TailScale 使用 xEdge 服务器",
|
||||||
@@ -295,6 +267,7 @@
|
|||||||
"324118a672": "输入",
|
"324118a672": "输入",
|
||||||
"29c2c02a36": "输出",
|
"29c2c02a36": "输出",
|
||||||
"67d2f6740a": "转发",
|
"67d2f6740a": "转发",
|
||||||
|
"63a6a88c06": "刷新",
|
||||||
"a4d3b161ce": "提交",
|
"a4d3b161ce": "提交",
|
||||||
"ec211f7c20": "添加",
|
"ec211f7c20": "添加",
|
||||||
"5320550175": "链",
|
"5320550175": "链",
|
||||||
@@ -315,22 +288,6 @@
|
|||||||
"64ae5dd047": "目标端口",
|
"64ae5dd047": "目标端口",
|
||||||
"fbb3878eb7": "提交防火墙策略?",
|
"fbb3878eb7": "提交防火墙策略?",
|
||||||
"1c17a7e15b": "警告:调整某些策略可能会导致网络地址丢失,导致设备不可用。",
|
"1c17a7e15b": "警告:调整某些策略可能会导致网络地址丢失,导致设备不可用。",
|
||||||
"6f20995c95": "加载 WebRTC 服务器失败",
|
|
||||||
"aee9784c03": "未知错误",
|
|
||||||
"6ef7ce5b80": "保存 STUN 失败",
|
|
||||||
"4cd48aed7f": "STUN 服务器已保存",
|
|
||||||
"4b5d050e51": "保存 TURN 失败",
|
|
||||||
"f6f10b4517": "TURN 服务器已保存",
|
|
||||||
"d235995f96": "STUN 服务器",
|
|
||||||
"fc1bd2c935": "用于 NAT 穿透的公共 STUN 服务器",
|
|
||||||
"289929755b": "恢复默认",
|
|
||||||
"0268827609": "TURN 服务器",
|
|
||||||
"b3c14a0273": "当直接点对点连接失败时用作中继",
|
|
||||||
"bbc48fb751": "未配置 TURN 服务器",
|
|
||||||
"f6039d44b2": "用户名",
|
|
||||||
"03bc142e64": "凭据",
|
|
||||||
"ca3e8baee9": "添加 TURN 服务器",
|
|
||||||
"0acde1b3e3": "保存 TURN 服务器",
|
|
||||||
"867cee98fd": "密码不一致",
|
"867cee98fd": "密码不一致",
|
||||||
"9864ff9420": "请输入旧密码",
|
"9864ff9420": "请输入旧密码",
|
||||||
"14a714ab22": "请输入新密码",
|
"14a714ab22": "请输入新密码",
|
||||||
@@ -371,12 +328,6 @@
|
|||||||
"3369c97f56": "输入您的 SSH 公钥",
|
"3369c97f56": "输入您的 SSH 公钥",
|
||||||
"81bafb2833": "默认 SSH 用户是 ",
|
"81bafb2833": "默认 SSH 用户是 ",
|
||||||
"7a941a0f87": "更新 SSH 密钥",
|
"7a941a0f87": "更新 SSH 密钥",
|
||||||
"d876ff8da6": "API 密钥",
|
|
||||||
"96164f17cf": "用于 MCP 和 REST API 认证的 API 密钥",
|
|
||||||
"538ce1893b": "输入 API 密钥或留空以自动生成",
|
|
||||||
"a55c6d580b": "用于认证 MCP 和 REST API 请求。",
|
|
||||||
"e85c388332": "保存 API 密钥",
|
|
||||||
"927c25953b": "生成新密钥",
|
|
||||||
"fdad65b7be": "强制 HTTP 传输",
|
"fdad65b7be": "强制 HTTP 传输",
|
||||||
"e033b5e2a3": "强制使用 HTTP 传输数据替代 WebRTC",
|
"e033b5e2a3": "强制使用 HTTP 传输数据替代 WebRTC",
|
||||||
"bda22ca687": "USB 检测增强",
|
"bda22ca687": "USB 检测增强",
|
||||||
@@ -387,6 +338,7 @@
|
|||||||
"020b92cfbb": "启用 USB 复用",
|
"020b92cfbb": "启用 USB 复用",
|
||||||
"9f55f64b0f": "USB 设备重新初始化",
|
"9f55f64b0f": "USB 设备重新初始化",
|
||||||
"40dc677a89": "重新初始化 USB 设备配置",
|
"40dc677a89": "重新初始化 USB 设备配置",
|
||||||
|
"02d2f33ec9": "重新初始化 USB 设备",
|
||||||
"f5ddf02991": "重启系统",
|
"f5ddf02991": "重启系统",
|
||||||
"1dbbf194af": "重启设备系统",
|
"1dbbf194af": "重启设备系统",
|
||||||
"1de72c4fc6": "重启",
|
"1de72c4fc6": "重启",
|
||||||
@@ -395,10 +347,6 @@
|
|||||||
"f43c0398a4": "重置配置",
|
"f43c0398a4": "重置配置",
|
||||||
"0031dbef48": "重置配置,这将使你退出登录。部分配置重启后生效。",
|
"0031dbef48": "重置配置,这将使你退出登录。部分配置重启后生效。",
|
||||||
"0d784092e8": "重置配置",
|
"0d784092e8": "重置配置",
|
||||||
"115082e888": "清除 API 密钥?",
|
|
||||||
"78fcaed30d": "将 API 密钥留空将自动生成一个新的随机密钥。",
|
|
||||||
"81e8b4cd6b": "请确保在保存后使用新密钥更新您的客户端。",
|
|
||||||
"211730be68": "生成新密钥",
|
|
||||||
"a776e925bf": "重启系统?",
|
"a776e925bf": "重启系统?",
|
||||||
"1f070051ff": "你确定重启系统吗?",
|
"1f070051ff": "你确定重启系统吗?",
|
||||||
"f1a79f466e": "设备将重启,你将从 Web 界面断开连接。",
|
"f1a79f466e": "设备将重启,你将从 Web 界面断开连接。",
|
||||||
@@ -455,12 +403,6 @@
|
|||||||
"ef64a3770e": "控制 mDNS(多播 DNS)运行模式",
|
"ef64a3770e": "控制 mDNS(多播 DNS)运行模式",
|
||||||
"be8226fe0c": "时间同步",
|
"be8226fe0c": "时间同步",
|
||||||
"7e06bd28a6": "配置时间同步设置",
|
"7e06bd28a6": "配置时间同步设置",
|
||||||
"a2323452ba": "HTTP 代理",
|
|
||||||
"7fedd1ea53": "配置程序 HTTP 代理(可选)",
|
|
||||||
"a30d487cba": "HTTPS 代理",
|
|
||||||
"3f4c7e23cb": "配置程序 HTTPS 代理(可选)",
|
|
||||||
"48d941841f": "ALL 代理",
|
|
||||||
"3a2cd1e4a7": "配置程序 ALL 代理(可选)",
|
|
||||||
"d4dccb8ca2": "保存设置",
|
"d4dccb8ca2": "保存设置",
|
||||||
"8750a898cb": "IPv4 模式",
|
"8750a898cb": "IPv4 模式",
|
||||||
"72c2543791": "配置 IPv4 模式",
|
"72c2543791": "配置 IPv4 模式",
|
||||||
@@ -502,16 +444,7 @@
|
|||||||
"5b404b3c98": "后台更新",
|
"5b404b3c98": "后台更新",
|
||||||
"3723a3f846": "系统已更新到最新版本",
|
"3723a3f846": "系统已更新到最新版本",
|
||||||
"a6b8796d51": "您的系统已运行最新版本。当前没有可用的更新。",
|
"a6b8796d51": "您的系统已运行最新版本。当前没有可用的更新。",
|
||||||
"a8d2a89696": "签名文件缺失",
|
|
||||||
"480f05b41b": "当前固件缺少签名文件,无法完全验证完整性。",
|
|
||||||
"323ebc9620": "签名验证失败",
|
|
||||||
"21355c9ecb": "签名文件存在但与固件不匹配,可能存在篡改。",
|
|
||||||
"0b54a6c322": "未嵌入公钥",
|
|
||||||
"9beb932d5a": "此版本未嵌入 OTA 公钥,无法进行签名验证。",
|
|
||||||
"7c81553358": "再次检查",
|
"7c81553358": "再次检查",
|
||||||
"3b5f9a1f01": "更新签名",
|
|
||||||
"d25bfbb978": "更新 kvm_app 的签名到最新版本。如果当前版本不是最新版本,签名验证将会失败。",
|
|
||||||
"1bdd158a19": "签名更新结果",
|
|
||||||
"217b416896": "更新可用",
|
"217b416896": "更新可用",
|
||||||
"f00af8c98f": "新的更新可用,以增强系统性能和提高兼容性。我们建议更新以确保一切正常运行。",
|
"f00af8c98f": "新的更新可用,以增强系统性能和提高兼容性。我们建议更新以确保一切正常运行。",
|
||||||
"8977c3f0b7": "下载代理加速前缀",
|
"8977c3f0b7": "下载代理加速前缀",
|
||||||
@@ -520,22 +453,11 @@
|
|||||||
"cffae9918d": "更新错误",
|
"cffae9918d": "更新错误",
|
||||||
"49cba7cadf": "更新设备时发生错误。请稍后重试。",
|
"49cba7cadf": "更新设备时发生错误。请稍后重试。",
|
||||||
"d849d5b330": "错误详情:",
|
"d849d5b330": "错误详情:",
|
||||||
"fcfc8da1a3": "正在验证签名...",
|
"2b2f7a6d7c": "按下 Ctrl+V 直接将本地剪贴板内容发送到远端主机",
|
||||||
"4e16083ef8": "请等待固件签名验证完成。",
|
|
||||||
"5367acff78": "签名状态不可用",
|
|
||||||
"634aac26af": "无法获取签名验证状态。",
|
|
||||||
"77de342f39": "签名已验证",
|
|
||||||
"20ac4a17cc": "固件签名已验证且有效。",
|
|
||||||
"36a41c937c": "无视频信号",
|
|
||||||
"d34da01de2": "启用粘贴快捷键",
|
|
||||||
"2b1a1676d1": "将文本从您的客户端复制到远程主机",
|
"2b1a1676d1": "将文本从您的客户端复制到远程主机",
|
||||||
"604c45fbf2": "以下字符将不会被粘贴:",
|
"604c45fbf2": "以下字符将不会被粘贴:",
|
||||||
"a7eb9efa0b": "使用键盘布局发送文本:",
|
"a7eb9efa0b": "使用键盘布局发送文本:",
|
||||||
"2593d0a3d5": "确认粘贴",
|
"2593d0a3d5": "确认粘贴",
|
||||||
"ac7e2a9783": "启用 OCR 快捷键",
|
|
||||||
"f529c51ee6": "OCR",
|
|
||||||
"607440855c": "在视频区域打开 OCR 选择模式",
|
|
||||||
"c3bf447eab": "打开",
|
|
||||||
"a0e3947a02": "宏",
|
"a0e3947a02": "宏",
|
||||||
"276043dbf0": "创建新的键盘宏",
|
"276043dbf0": "创建新的键盘宏",
|
||||||
"9605ef9593": "修改键盘宏",
|
"9605ef9593": "修改键盘宏",
|
||||||
@@ -565,7 +487,6 @@
|
|||||||
"21d104a54f": "处理中...",
|
"21d104a54f": "处理中...",
|
||||||
"9844086d90": "未检测到 SD 卡",
|
"9844086d90": "未检测到 SD 卡",
|
||||||
"b56e598918": "SD 卡挂载失败",
|
"b56e598918": "SD 卡挂载失败",
|
||||||
"d646589704": "选择 MicroSD 格式化的文件系统",
|
|
||||||
"16eb8ed6c8": "格式化 MicroSD 卡",
|
"16eb8ed6c8": "格式化 MicroSD 卡",
|
||||||
"a63d5e0260": "管理 KVM MicroSD Card 的共享文件夹",
|
"a63d5e0260": "管理 KVM MicroSD Card 的共享文件夹",
|
||||||
"1d50425f88": "管理 KVM 存储中的共享文件夹",
|
"1d50425f88": "管理 KVM 存储中的共享文件夹",
|
||||||
@@ -577,15 +498,10 @@
|
|||||||
"8bf8854beb": "共",
|
"8bf8854beb": "共",
|
||||||
"53e61336bb": "条结果",
|
"53e61336bb": "条结果",
|
||||||
"d1f5a81904": "卸载 MicroSD 卡",
|
"d1f5a81904": "卸载 MicroSD 卡",
|
||||||
|
"827048afc2": "格式化 SD 卡会清除所有数据。是否继续?",
|
||||||
"5874cf46ff": "SD 卡格式化成功",
|
"5874cf46ff": "SD 卡格式化成功",
|
||||||
"7dc25305d4": "视频编码类型",
|
"7dc25305d4": "视频编码类型",
|
||||||
"41d263a988": "视频质量",
|
"41d263a988": "视频质量",
|
||||||
"d8d7a5377b": "RC 控制",
|
|
||||||
"a09ff5b350": "调整码率控制 QP 参数,在质量和码率之间取得更好的平衡",
|
|
||||||
"b1f9d95e72": "StepQp",
|
|
||||||
"bf1af7559b": "MinQp",
|
|
||||||
"6e4e284fcc": "MinIQp",
|
|
||||||
"df1b8f72f2": "DetlpQp",
|
|
||||||
"41f5283941": "NPU 应用",
|
"41f5283941": "NPU 应用",
|
||||||
"466990072d": "启用 NPU 以进行物体检测",
|
"466990072d": "启用 NPU 以进行物体检测",
|
||||||
"a6c2e30b8e": "视频增强",
|
"a6c2e30b8e": "视频增强",
|
||||||
@@ -602,8 +518,6 @@
|
|||||||
"85a003df9b": "EDID 文件",
|
"85a003df9b": "EDID 文件",
|
||||||
"5c7a666766": "设置自定义 EDID",
|
"5c7a666766": "设置自定义 EDID",
|
||||||
"7dad0ba758": "恢复默认",
|
"7dad0ba758": "恢复默认",
|
||||||
"7b2fb72a68": "RC 高级配置",
|
|
||||||
"827048afc2": "格式化 SD 卡会清除所有数据。是否继续?",
|
|
||||||
"556c7553f1": "从 KVM MicroSD 卡挂载",
|
"556c7553f1": "从 KVM MicroSD 卡挂载",
|
||||||
"b99cf1ecb7": "从 MicroSD 卡中管理和挂载镜像",
|
"b99cf1ecb7": "从 MicroSD 卡中管理和挂载镜像",
|
||||||
"0a01e80566": "已从 KVM 存储挂载",
|
"0a01e80566": "已从 KVM 存储挂载",
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
const MODIFIER_KEYS = new Set([
|
|
||||||
"Control",
|
|
||||||
"Shift",
|
|
||||||
"Alt",
|
|
||||||
"Meta",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const SPECIAL_CODE_TO_KEY: Record<string, string> = {
|
|
||||||
Space: "Space",
|
|
||||||
Enter: "Enter",
|
|
||||||
Escape: "Esc",
|
|
||||||
Tab: "Tab",
|
|
||||||
Backspace: "Backspace",
|
|
||||||
Delete: "Delete",
|
|
||||||
ArrowUp: "Up",
|
|
||||||
ArrowDown: "Down",
|
|
||||||
ArrowLeft: "Left",
|
|
||||||
ArrowRight: "Right",
|
|
||||||
};
|
|
||||||
|
|
||||||
type ShortcutSpec = {
|
|
||||||
ctrl: boolean;
|
|
||||||
shift: boolean;
|
|
||||||
alt: boolean;
|
|
||||||
meta: boolean;
|
|
||||||
key: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeShortcutToken(token: string) {
|
|
||||||
return token.trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeKeyName(key: string) {
|
|
||||||
const trimmed = key.trim();
|
|
||||||
if (trimmed.length === 1) return trimmed.toUpperCase();
|
|
||||||
const lower = trimmed.toLowerCase();
|
|
||||||
if (lower === "escape") return "Esc";
|
|
||||||
if (lower === " ") return "Space";
|
|
||||||
if (lower === "arrowup") return "Up";
|
|
||||||
if (lower === "arrowdown") return "Down";
|
|
||||||
if (lower === "arrowleft") return "Left";
|
|
||||||
if (lower === "arrowright") return "Right";
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function keyFromEvent(e: KeyboardEvent) {
|
|
||||||
const { code, key } = e;
|
|
||||||
if (code.startsWith("Key")) return code.slice(3).toUpperCase();
|
|
||||||
if (code.startsWith("Digit")) return code.slice(5);
|
|
||||||
if (SPECIAL_CODE_TO_KEY[code]) return SPECIAL_CODE_TO_KEY[code];
|
|
||||||
return normalizeKeyName(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseShortcut(shortcut: string): ShortcutSpec | null {
|
|
||||||
if (!shortcut) return null;
|
|
||||||
const tokens = shortcut
|
|
||||||
.split("+")
|
|
||||||
.map(token => token.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (tokens.length === 0) return null;
|
|
||||||
|
|
||||||
const spec: ShortcutSpec = {
|
|
||||||
ctrl: false,
|
|
||||||
shift: false,
|
|
||||||
alt: false,
|
|
||||||
meta: false,
|
|
||||||
key: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const token of tokens) {
|
|
||||||
const normalized = normalizeShortcutToken(token);
|
|
||||||
if (normalized === "ctrl" || normalized === "control") {
|
|
||||||
spec.ctrl = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (normalized === "shift") {
|
|
||||||
spec.shift = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (normalized === "alt" || normalized === "option") {
|
|
||||||
spec.alt = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (normalized === "meta" || normalized === "cmd" || normalized === "command") {
|
|
||||||
spec.meta = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
spec.key = normalizeKeyName(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!spec.key) return null;
|
|
||||||
return spec;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function eventMatchesShortcut(e: KeyboardEvent, shortcut: string) {
|
|
||||||
const spec = parseShortcut(shortcut);
|
|
||||||
if (!spec) return false;
|
|
||||||
const eventKey = keyFromEvent(e);
|
|
||||||
return (
|
|
||||||
e.ctrlKey === spec.ctrl
|
|
||||||
&& e.shiftKey === spec.shift
|
|
||||||
&& e.altKey === spec.alt
|
|
||||||
&& e.metaKey === spec.meta
|
|
||||||
&& eventKey === spec.key
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shortcutFromKeyboardEvent(e: KeyboardEvent) {
|
|
||||||
if (MODIFIER_KEYS.has(e.key)) return null;
|
|
||||||
const key = keyFromEvent(e);
|
|
||||||
const modifiers: string[] = [];
|
|
||||||
if (e.ctrlKey) modifiers.push("Ctrl");
|
|
||||||
if (e.shiftKey) modifiers.push("Shift");
|
|
||||||
if (e.altKey) modifiers.push("Alt");
|
|
||||||
if (e.metaKey) modifiers.push("Meta");
|
|
||||||
if (modifiers.length === 0) return null;
|
|
||||||
return [...modifiers, key].join("+");
|
|
||||||
}
|
|
||||||
10
usb.go
10
usb.go
@@ -37,7 +37,7 @@ func initUsbGadget() {
|
|||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
checkUSBState()
|
checkUSBState()
|
||||||
time.Sleep(2500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -111,14 +111,6 @@ func rpcKeyboardReport(modifier uint8, keys []uint8) error {
|
|||||||
return gadget.KeyboardReport(modifier, keys)
|
return gadget.KeyboardReport(modifier, keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcKeypressReport(key uint8, press bool) error {
|
|
||||||
return gadget.KeypressReport(key, press)
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcKeypressKeepAlive() error {
|
|
||||||
return gadget.KeypressKeepAlive()
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcAbsMouseReport(x, y int, buttons uint8) error {
|
func rpcAbsMouseReport(x, y int, buttons uint8) error {
|
||||||
return gadget.AbsMouseReport(x, y, buttons)
|
return gadget.AbsMouseReport(x, y, buttons)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -782,11 +782,7 @@ func rpcUnmountSDStorage() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcFormatSDStorage(confirm bool, fsType string) error {
|
func rpcFormatSDStorage(confirm bool) error {
|
||||||
validFsTypes := map[string]bool{"exfat": true, "fat32": true}
|
|
||||||
if !validFsTypes[fsType] {
|
|
||||||
fsType = "fat32"
|
|
||||||
}
|
|
||||||
if !confirm {
|
if !confirm {
|
||||||
return fmt.Errorf("format not confirmed")
|
return fmt.Errorf("format not confirmed")
|
||||||
}
|
}
|
||||||
@@ -868,13 +864,7 @@ func rpcFormatSDStorage(confirm bool, fsType string) error {
|
|||||||
return fmt.Errorf("failed to stat sd partition: %w", err)
|
return fmt.Errorf("failed to stat sd partition: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var mkfsCmd *exec.Cmd
|
mkfsOut, mkfsErr := exec.Command("mkfs.vfat", "-F", "32", "-n", "PICOKVM", "/dev/mmcblk1p1").CombinedOutput()
|
||||||
if fsType == "exfat" {
|
|
||||||
mkfsCmd = exec.Command("mkfs.exfat", "-n", "PICOKVM", "/dev/mmcblk1p1")
|
|
||||||
} else {
|
|
||||||
mkfsCmd = exec.Command("mkfs.vfat", "-F", "32", "-n", "PICOKVM", "/dev/mmcblk1p1")
|
|
||||||
}
|
|
||||||
mkfsOut, mkfsErr := mkfsCmd.CombinedOutput()
|
|
||||||
if mkfsErr != nil {
|
if mkfsErr != nil {
|
||||||
return fmt.Errorf("failed to format sdcard: %w: %s", mkfsErr, strings.TrimSpace(string(mkfsOut)))
|
return fmt.Errorf("failed to format sdcard: %w: %s", mkfsErr, strings.TrimSpace(string(mkfsOut)))
|
||||||
}
|
}
|
||||||
|
|||||||
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:"frame_per_second"`
|
FramePerSecond float64 `json:"fps"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastVideoState VideoInputState
|
var lastVideoState VideoInputState
|
||||||
|
|||||||
16
vpn.go
16
vpn.go
@@ -332,7 +332,7 @@ func rpcStartFrpc(frpcToml string) error {
|
|||||||
if err := os.WriteFile(frpcTomlPath, []byte(frpcToml), 0600); err != nil {
|
if err := os.WriteFile(frpcTomlPath, []byte(frpcToml), 0600); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cmd := exec.Command(resolveVpnToolBinary("frpc", "frpc"), "-c", frpcTomlPath)
|
cmd := exec.Command("frpc", "-c", frpcTomlPath)
|
||||||
cmd.Stdout = nil
|
cmd.Stdout = nil
|
||||||
cmd.Stderr = nil
|
cmd.Stderr = nil
|
||||||
logFile, err := os.OpenFile(frpcLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
logFile, err := os.OpenFile(frpcLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||||
@@ -386,9 +386,7 @@ type CloudflaredStatus struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cloudflaredRunning() bool {
|
func cloudflaredRunning() bool {
|
||||||
// Only treat long-running tunnel process as running.
|
cmd := exec.Command("pgrep", "-x", "cloudflared")
|
||||||
// This avoids false positives from short-lived version checks like `cloudflared -v`.
|
|
||||||
cmd := exec.Command("pgrep", "-f", `cloudflared.*tunnel.*run`)
|
|
||||||
return cmd.Run() == nil
|
return cmd.Run() == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,7 +401,7 @@ func rpcStartCloudflared(token string) error {
|
|||||||
if token == "" {
|
if token == "" {
|
||||||
return fmt.Errorf("cloudflared token is empty")
|
return fmt.Errorf("cloudflared token is empty")
|
||||||
}
|
}
|
||||||
cmd := exec.Command(resolveVpnToolBinary("cloudflared", "cloudflared"), "tunnel", "run", "--token", token)
|
cmd := exec.Command("cloudflared", "tunnel", "run", "--token", token)
|
||||||
logFile, err := os.OpenFile(cloudflaredLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
logFile, err := os.OpenFile(cloudflaredLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -523,7 +521,7 @@ func rpcGetEasyTierLog() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func rpcGetEasyTierNodeInfo() (string, error) {
|
func rpcGetEasyTierNodeInfo() (string, error) {
|
||||||
cmd := exec.Command(resolveVpnToolBinary("easytier", "easytier-cli"), "node")
|
cmd := exec.Command("easytier-cli", "node")
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get easytier node info: %w", err)
|
return "", fmt.Errorf("failed to get easytier node info: %w", err)
|
||||||
@@ -545,7 +543,7 @@ func rpcStartEasyTier(name, secret, node string) error {
|
|||||||
return fmt.Errorf("easytier config is invalid")
|
return fmt.Errorf("easytier config is invalid")
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command(resolveVpnToolBinary("easytier", "easytier-core"), "-d", "--network-name", name, "--network-secret", secret, "-p", node)
|
cmd := exec.Command("easytier-core", "-d", "--network-name", name, "--network-secret", secret, "-p", node)
|
||||||
cmd.Stdout = nil
|
cmd.Stdout = nil
|
||||||
cmd.Stderr = nil
|
cmd.Stderr = nil
|
||||||
logFile, err := os.OpenFile(easytierLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
logFile, err := os.OpenFile(easytierLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||||
@@ -641,7 +639,7 @@ func rpcGetVntLog() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func rpcGetVntInfo() (string, error) {
|
func rpcGetVntInfo() (string, error) {
|
||||||
cmd := exec.Command(resolveVpnToolBinary("vnt", "vnt-cli"), "--info")
|
cmd := exec.Command("vnt-cli", "--info")
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get vnt info: %w", err)
|
return "", fmt.Errorf("failed to get vnt info: %w", err)
|
||||||
@@ -709,7 +707,7 @@ func rpcStartVnt(configMode, token, deviceId, name, serverAddr, configFile strin
|
|||||||
args = append(args, "--compressor", "lz4")
|
args = append(args, "--compressor", "lz4")
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command(resolveVpnToolBinary("vnt", "vnt-cli"), args...)
|
cmd := exec.Command("vnt-cli", args...)
|
||||||
cmd.Stdout = nil
|
cmd.Stdout = nil
|
||||||
cmd.Stderr = nil
|
cmd.Stderr = nil
|
||||||
logFile, err := os.OpenFile(vntLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
logFile, err := os.OpenFile(vntLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||||
|
|||||||
16
web.go
16
web.go
@@ -168,7 +168,6 @@ func setupRouter() *gin.Engine {
|
|||||||
protected.GET("/storage/download", handleDownloadHttp)
|
protected.GET("/storage/download", handleDownloadHttp)
|
||||||
protected.GET("/storage/sd-download", handleSDDownloadHttp)
|
protected.GET("/storage/sd-download", handleSDDownloadHttp)
|
||||||
protected.POST("/api/rpc", handleRpcRequest)
|
protected.POST("/api/rpc", handleRpcRequest)
|
||||||
protected.GET("/api/ice-servers", handleGetIceServers)
|
|
||||||
protected.GET("/terminal/ws", handleTerminalWS)
|
protected.GET("/terminal/ws", handleTerminalWS)
|
||||||
protected.GET("/serial/ws", handleSerialWS)
|
protected.GET("/serial/ws", handleSerialWS)
|
||||||
protected.GET("/video/stream", handleVideoStream)
|
protected.GET("/video/stream", handleVideoStream)
|
||||||
@@ -908,16 +907,6 @@ func handleRpcRequest(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, response)
|
c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleGetIceServers(c *gin.Context) {
|
|
||||||
LoadConfig()
|
|
||||||
servers, err := rpcGetIceServers()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"iceServers": servers})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleVideoStream(c *gin.Context) {
|
func handleVideoStream(c *gin.Context) {
|
||||||
logger.Info().Msg("HTTP video stream request received")
|
logger.Info().Msg("HTTP video stream request received")
|
||||||
|
|
||||||
@@ -942,23 +931,20 @@ func handleVideoStream(c *gin.Context) {
|
|||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case frame, ok := <-ch:
|
case data, ok := <-ch:
|
||||||
if !ok {
|
if !ok {
|
||||||
logger.Info().Int("total_frames", frameCount).Msg("video broadcaster channel closed")
|
logger.Info().Int("total_frames", frameCount).Msg("video broadcaster channel closed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := frame.Data()
|
|
||||||
frameCount++
|
frameCount++
|
||||||
if frameCount == 1 {
|
if frameCount == 1 {
|
||||||
logger.Info().Int("size", len(data)).Msg("first video frame received")
|
logger.Info().Int("size", len(data)).Msg("first video frame received")
|
||||||
}
|
}
|
||||||
if _, err := c.Writer.Write(data); err != nil {
|
if _, err := c.Writer.Write(data); err != nil {
|
||||||
logger.Warn().Err(err).Int("total_frames", frameCount).Msg("error writing video data")
|
logger.Warn().Err(err).Int("total_frames", frameCount).Msg("error writing video data")
|
||||||
frame.Release()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Writer.Flush()
|
c.Writer.Flush()
|
||||||
frame.Release()
|
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
logger.Info().Int("total_frames", frameCount).Msg("client disconnected")
|
logger.Info().Int("total_frames", frameCount).Msg("client disconnected")
|
||||||
return
|
return
|
||||||
|
|||||||
39
webrtc.go
39
webrtc.go
@@ -22,6 +22,7 @@ type Session struct {
|
|||||||
//AudioTrack *webrtc.TrackLocalStaticSample
|
//AudioTrack *webrtc.TrackLocalStaticSample
|
||||||
ControlChannel *webrtc.DataChannel
|
ControlChannel *webrtc.DataChannel
|
||||||
RPCChannel *webrtc.DataChannel
|
RPCChannel *webrtc.DataChannel
|
||||||
|
HidChannel *webrtc.DataChannel
|
||||||
DiskChannel *webrtc.DataChannel
|
DiskChannel *webrtc.DataChannel
|
||||||
shouldUmountVirtualMedia bool
|
shouldUmountVirtualMedia bool
|
||||||
}
|
}
|
||||||
@@ -33,33 +34,6 @@ type SessionConfig struct {
|
|||||||
Logger *zerolog.Logger
|
Logger *zerolog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
const DefaultSTUN = "stun:stun.l.google.com:19302"
|
|
||||||
|
|
||||||
func buildICEServers() []webrtc.ICEServer {
|
|
||||||
if config == nil {
|
|
||||||
LoadConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
stunURL := config.STUN
|
|
||||||
if stunURL == "" {
|
|
||||||
stunURL = DefaultSTUN
|
|
||||||
}
|
|
||||||
|
|
||||||
servers := []webrtc.ICEServer{{URLs: []string{stunURL}}}
|
|
||||||
for _, turnServer := range config.TurnServers {
|
|
||||||
if turnServer.URL == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
servers = append(servers, webrtc.ICEServer{
|
|
||||||
URLs: []string{turnServer.URL},
|
|
||||||
Username: turnServer.Username,
|
|
||||||
Credential: turnServer.Credential,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return servers
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Session) ExchangeOffer(offerStr string) (string, error) {
|
func (s *Session) ExchangeOffer(offerStr string) (string, error) {
|
||||||
b, err := base64.StdEncoding.DecodeString(offerStr)
|
b, err := base64.StdEncoding.DecodeString(offerStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -112,7 +86,16 @@ func newSession(sessionConfig SessionConfig) (*Session, error) {
|
|||||||
scopedLogger = webrtcLogger
|
scopedLogger = webrtcLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
iceServers := buildICEServers()
|
iceServers := []webrtc.ICEServer{
|
||||||
|
{
|
||||||
|
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if config.STUN != "" {
|
||||||
|
iceServers = append(iceServers, webrtc.ICEServer{
|
||||||
|
URLs: []string{config.STUN},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
api := webrtc.NewAPI(webrtc.WithSettingEngine(webrtcSettingEngine))
|
api := webrtc.NewAPI(webrtc.WithSettingEngine(webrtcSettingEngine))
|
||||||
peerConnection, err := api.NewPeerConnection(webrtc.Configuration{
|
peerConnection, err := api.NewPeerConnection(webrtc.Configuration{
|
||||||
|
|||||||
Reference in New Issue
Block a user