mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-11-10 09:59:15 -06:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4978d93d6 | ||
|
|
01e28f10ae | ||
|
|
ea9068fd0d | ||
|
|
d85823abcf | ||
|
|
4c0027ffd3 | ||
|
|
afb868af30 | ||
|
|
66b1d5dc30 | ||
|
|
7d8cdc74c6 | ||
|
|
809581df49 | ||
|
|
5d47a8d48d | ||
|
|
6242f1931e | ||
|
|
b768219c18 | ||
|
|
6935923bd1 | ||
|
|
559e6e2556 | ||
|
|
f354fdfbf4 | ||
|
|
f242b5a378 | ||
|
|
e18ec2219d | ||
|
|
d075ae58d9 | ||
|
|
e8ccac8e61 | ||
|
|
7c8f9f3cd8 | ||
|
|
66e434db24 | ||
|
|
0be553c4c8 | ||
|
|
1dda0f29be | ||
|
|
fbdedc7d8a | ||
|
|
e325352309 | ||
|
|
bf51b50346 | ||
|
|
96b52f3ac0 | ||
|
|
ad44db254b | ||
|
|
736b89d7d1 | ||
|
|
3122531cae | ||
|
|
cbb47ab735 | ||
|
|
fa8d4fd5ca | ||
|
|
423d05a087 | ||
|
|
7036e94dab | ||
|
|
3a19180a51 | ||
|
|
f8f8920fc5 | ||
|
|
507b53c10e | ||
|
|
642a00051e | ||
|
|
7f9bd1c457 | ||
|
|
c4e8d19649 | ||
|
|
82be18c469 |
23
CHANGELOG.md
23
CHANGELOG.md
@@ -1,3 +1,25 @@
|
||||
## 2.2.8 [01e28f1](https://github.com/sipeed/NanoKVM/commit/01e28f10ae8b581d484bb6077ddfe7bbe4e57919) (2025-05-22)
|
||||
|
||||
* feat: add AZERTY virtual keyboard Layout (thanks to [@felix068](https://github.com/felix068))
|
||||
* feat: add support for enabling/disabling HDMI output (PCIe version only) (thanks to [@tpretz](https://github.com/tpretz))
|
||||
* feat: add support for custom mouse wheel speed
|
||||
* fix: prevent direct H.264 stream buffer overflow and replay issues
|
||||
* perf: improve keyboard paste performance (thanks to [@ethanperrine](https://github.com/ethanperrine))
|
||||
* Localization
|
||||
* update Korean translation (thanks to [@kmw0410](https://github.com/kmw0410))
|
||||
* update Ukrainian translation (thanks to [@arbdevml](https://github.com/arbdevml))
|
||||
* update Russian translation (thanks to [@arbdevml](https://github.com/arbdevml))
|
||||
|
||||
## 2.2.7 [e18ec22](https://github.com/sipeed/NanoKVM/commit/e18ec2219d22886529575d1fdaad5c320e05f5b2) (2025-05-08)
|
||||
|
||||
* feat: add HTTPS support
|
||||
* feat: support direct H.264 streaming over HTTP
|
||||
* Localization
|
||||
* update Russian translation (thanks to [polyzium](https://github.com/polyzium))
|
||||
* update Dutch translation (thanks to [LeonStraathof](https://github.com/LeonStraathof))
|
||||
* update Ukrainian translation (thanks to [click0](https://github.com/click0))
|
||||
* update German translation (thanks to [3limin4tor](https://github.com/3limin4tor))
|
||||
|
||||
## 2.2.6 [c83dc55](https://github.com/sipeed/NanoKVM/commit/c83dc5565c9dbed22336661a8832edbd93a06d11) (2025-04-17)
|
||||
|
||||
* feat: add mouse jiggler to prevent system sleep
|
||||
@@ -7,6 +29,7 @@
|
||||
* feat: add support for assigning custom names to Wake-on-LAN MAC addresses
|
||||
* feat: add confirmation prompts for power operations
|
||||
* feat: add logo to the login page (thanks to [S33G](https://github.com/S33G))
|
||||
* fix: fix possible privacy issues with MIC drivers. [Repair Record](https://github.com/sipeed/NanoKVM/commit/f9244b36df090a05cd59ba11ea4fd01e9b638995)
|
||||
* fix: fix iptables rule that could interfere with SSH connections (thanks to [scpcom](https://github.com/scpcom))
|
||||
* fix: fix the static IP gateway configuration might not apply correctly (thanks to [xitation](https://github.com/xitation))
|
||||
* perf: optimized OLED display handling and sleep logic
|
||||
|
||||
45
server/config/file.go
Normal file
45
server/config/file.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const ConfigurationFile = "/etc/kvm/server.yaml"
|
||||
|
||||
func Read() (*Config, error) {
|
||||
data, err := os.ReadFile(ConfigurationFile)
|
||||
if err != nil {
|
||||
log.Errorf("failed to read config: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var conf Config
|
||||
|
||||
if err := yaml.Unmarshal(data, &conf); err != nil {
|
||||
log.Fatalf("failed to unmarshal config: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debugf("read %s successfully", ConfigurationFile)
|
||||
return &conf, nil
|
||||
}
|
||||
|
||||
func Write(conf *Config) error {
|
||||
data, err := yaml.Marshal(&conf)
|
||||
if err != nil {
|
||||
log.Errorf("failed to marshal config: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = os.WriteFile(ConfigurationFile, data, 0644)
|
||||
if err != nil {
|
||||
log.Errorf("failed to write config: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("write to %s successfully", ConfigurationFile)
|
||||
return nil
|
||||
}
|
||||
@@ -3,8 +3,10 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -58,7 +60,6 @@ func run() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
if conf.Authentication == "disable" {
|
||||
r.Use(cors.AllowAll())
|
||||
}
|
||||
@@ -67,20 +68,17 @@ func run() {
|
||||
|
||||
httpAddr := fmt.Sprintf(":%d", conf.Port.Http)
|
||||
httpsAddr := fmt.Sprintf(":%d", conf.Port.Https)
|
||||
log.Printf("proto: %s, port: %d %d\n", conf.Proto, conf.Port.Http, conf.Port.Https)
|
||||
|
||||
if conf.Proto == "https" {
|
||||
r.Use(middleware.Tls())
|
||||
|
||||
go func() {
|
||||
if err := r.Run(httpAddr); err != nil {
|
||||
panic("start http server failed")
|
||||
r.Use(middleware.Tls())
|
||||
err := r.RunTLS(httpsAddr, conf.Cert.Crt, conf.Cert.Key)
|
||||
if err != nil {
|
||||
panic("start https server failed")
|
||||
}
|
||||
}()
|
||||
|
||||
if err := r.RunTLS(httpsAddr, conf.Cert.Crt, conf.Cert.Key); err != nil {
|
||||
panic("start https server failed")
|
||||
}
|
||||
runRedirect(httpAddr, httpsAddr)
|
||||
} else {
|
||||
if err := r.Run(httpAddr); err != nil {
|
||||
panic("start http server failed")
|
||||
@@ -88,6 +86,26 @@ func run() {
|
||||
}
|
||||
}
|
||||
|
||||
func runRedirect(httpPort string, httpsPort string) {
|
||||
err := http.ListenAndServe(httpPort, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
host := req.Host
|
||||
if strings.Contains(host, httpPort) {
|
||||
host = strings.Split(host, httpPort)[0]
|
||||
}
|
||||
|
||||
targetURL := "https://" + host + req.URL.String()
|
||||
if httpsPort != ":443" {
|
||||
targetURL = "https://" + host + httpsPort + req.URL.String()
|
||||
}
|
||||
|
||||
http.Redirect(w, req, targetURL, http.StatusTemporaryRedirect)
|
||||
}))
|
||||
|
||||
if err != nil {
|
||||
panic("start http server failed")
|
||||
}
|
||||
}
|
||||
|
||||
func dispose() {
|
||||
common.GetKvmVision().Close()
|
||||
}
|
||||
|
||||
@@ -87,6 +87,10 @@ type GetOLEDRsp struct {
|
||||
Sleep int `json:"sleep"`
|
||||
}
|
||||
|
||||
type GetGetHdmiStateRsp struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type GetSSHStateRsp struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
@@ -128,3 +132,7 @@ type SetWebTitleReq struct {
|
||||
type GetWebTitleRsp struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type SetTlsReq struct {
|
||||
Enabled bool `validate:"omitempty"`
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/stream/direct"
|
||||
"NanoKVM-Server/service/stream/h264"
|
||||
"NanoKVM-Server/service/stream/mjpeg"
|
||||
|
||||
@@ -15,5 +16,6 @@ func streamRouter(r *gin.Engine) {
|
||||
api.POST("/stream/mjpeg/detect", mjpeg.UpdateFrameDetect) // update frame detect
|
||||
api.POST("/stream/mjpeg/detect/stop", mjpeg.StopFrameDetect) // temporary stop frame detect
|
||||
|
||||
api.GET("/stream/h264", h264.Connect) // h264 stream
|
||||
api.GET("/stream/h264", h264.Connect) // h264 stream (webrtc)
|
||||
api.GET("/stream/h264/direct", direct.Connect) // h264 stream (http)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,11 @@ func vmRouter(r *gin.Engine) {
|
||||
api.GET("/vm/oled", service.GetOLED) // get OLED configuration
|
||||
api.POST("/vm/oled", service.SetOLED) // set OLED configuration
|
||||
|
||||
api.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi (pcie only)
|
||||
// Only supported by PCIe version
|
||||
api.GET("/vm/hdmi", service.GetHdmiState) // get HDMI state
|
||||
api.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi
|
||||
api.POST("/vm/hdmi/enable", service.EnableHdmi) // enable hdmi
|
||||
api.POST("/vm/hdmi/disable", service.DisableHdmi) // disable hdmi
|
||||
|
||||
api.GET("/vm/ssh", service.GetSSHState) // get SSH state
|
||||
api.POST("/vm/ssh/enable", service.EnableSSH) // enable SSH
|
||||
@@ -57,5 +61,7 @@ func vmRouter(r *gin.Engine) {
|
||||
api.POST("/vm/mdns/enable", service.EnableMdns) // enable mDNS
|
||||
api.POST("/vm/mdns/disable", service.DisableMdns) // disable mDNS
|
||||
|
||||
api.POST("/vm/tls", service.SetTls) // enable/disable TLS
|
||||
|
||||
api.POST("/vm/system/reboot", service.Reboot) // reboot system
|
||||
}
|
||||
|
||||
@@ -21,152 +21,94 @@ type PasteReq struct {
|
||||
func (s *Service) Paste(c *gin.Context) {
|
||||
var req PasteReq
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Content) > 1024 {
|
||||
rsp.ErrRsp(c, -2, "")
|
||||
rsp.ErrRsp(c, -2, "content too long")
|
||||
return
|
||||
}
|
||||
|
||||
s.hid.kbMutex.Lock()
|
||||
defer s.hid.kbMutex.Unlock()
|
||||
keyUp := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
|
||||
for _, char := range req.Content {
|
||||
key, ok := charMap[char]
|
||||
if !ok {
|
||||
log.Debugf("unknown key %c", char)
|
||||
log.Debugf("unknown key '%c' (rune: %d)", char, char)
|
||||
continue
|
||||
}
|
||||
|
||||
keyDown := []byte{byte(key.Modifiers), 0x00, byte(key.Code), 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
keyUp := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
|
||||
// only handle shift. Need to handle all modifiers?
|
||||
if key.Modifiers > 0 {
|
||||
keyShift := []byte{0x00, 0x00, byte(225), 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
s.hid.Write(s.hid.g0, keyShift)
|
||||
}
|
||||
|
||||
s.hid.Write(s.hid.g0, keyDown)
|
||||
s.hid.Write(s.hid.g0, keyUp)
|
||||
|
||||
if key.Modifiers > 0 {
|
||||
s.hid.Write(s.hid.g0, keyUp)
|
||||
}
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
s.hid.kbMutex.Unlock()
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("hid paste success, total %d", len(req.Content))
|
||||
log.Debugf("hid paste success, total %d characters processed", len(req.Content))
|
||||
}
|
||||
|
||||
var charMap = map[rune]Char{
|
||||
97: {0, 4}, // a
|
||||
98: {0, 5}, // b
|
||||
99: {0, 6}, // c
|
||||
100: {0, 7}, // d
|
||||
101: {0, 8}, // e
|
||||
102: {0, 9}, // f
|
||||
103: {0, 10}, // g
|
||||
104: {0, 11}, // h
|
||||
105: {0, 12}, // i
|
||||
106: {0, 13}, // j
|
||||
107: {0, 14}, // k
|
||||
108: {0, 15}, // l
|
||||
109: {0, 16}, // m
|
||||
110: {0, 17}, // n
|
||||
111: {0, 18}, // o
|
||||
112: {0, 19}, // p
|
||||
113: {0, 20}, // q
|
||||
114: {0, 21}, // r
|
||||
115: {0, 22}, // s
|
||||
116: {0, 23}, // t
|
||||
117: {0, 24}, // u
|
||||
118: {0, 25}, // v
|
||||
119: {0, 26}, // w
|
||||
120: {0, 27}, // x
|
||||
121: {0, 28}, // y
|
||||
122: {0, 29}, // z
|
||||
// Lowercase letters
|
||||
'a': {0, 4}, 'b': {0, 5}, 'c': {0, 6}, 'd': {0, 7}, 'e': {0, 8},
|
||||
'f': {0, 9}, 'g': {0, 10}, 'h': {0, 11}, 'i': {0, 12}, 'j': {0, 13},
|
||||
'k': {0, 14}, 'l': {0, 15}, 'm': {0, 16}, 'n': {0, 17}, 'o': {0, 18},
|
||||
'p': {0, 19}, 'q': {0, 20}, 'r': {0, 21}, 's': {0, 22}, 't': {0, 23},
|
||||
'u': {0, 24}, 'v': {0, 25}, 'w': {0, 26}, 'x': {0, 27}, 'y': {0, 28},
|
||||
'z': {0, 29},
|
||||
|
||||
49: {0, 30}, // 1
|
||||
50: {0, 31}, // 2
|
||||
51: {0, 32}, // 3
|
||||
52: {0, 33}, // 4
|
||||
53: {0, 34}, // 5
|
||||
54: {0, 35}, // 6
|
||||
55: {0, 36}, // 7
|
||||
56: {0, 37}, // 8
|
||||
57: {0, 38}, // 9
|
||||
48: {0, 39}, // 0
|
||||
// Uppercase letters (Modifier 2 typically means Left Shift)
|
||||
'A': {2, 4}, 'B': {2, 5}, 'C': {2, 6}, 'D': {2, 7}, 'E': {2, 8},
|
||||
'F': {2, 9}, 'G': {2, 10}, 'H': {2, 11}, 'I': {2, 12}, 'J': {2, 13},
|
||||
'K': {2, 14}, 'L': {2, 15}, 'M': {2, 16}, 'N': {2, 17}, 'O': {2, 18},
|
||||
'P': {2, 19}, 'Q': {2, 20}, 'R': {2, 21}, 'S': {2, 22}, 'T': {2, 23},
|
||||
'U': {2, 24}, 'V': {2, 25}, 'W': {2, 26}, 'X': {2, 27}, 'Y': {2, 28},
|
||||
'Z': {2, 29},
|
||||
|
||||
10: {0, 40}, // Enter
|
||||
9: {0, 43}, // Tab
|
||||
32: {0, 44}, // Space
|
||||
45: {0, 45}, // -
|
||||
61: {0, 46}, // =
|
||||
91: {0, 47}, // [
|
||||
93: {0, 48}, // ]
|
||||
92: {0, 49}, // \
|
||||
// Numbers
|
||||
'1': {0, 30}, '2': {0, 31}, '3': {0, 32}, '4': {0, 33}, '5': {0, 34},
|
||||
'6': {0, 35}, '7': {0, 36}, '8': {0, 37}, '9': {0, 38}, '0': {0, 39},
|
||||
|
||||
59: {0, 51}, // ;
|
||||
39: {0, 52}, // '
|
||||
96: {0, 53}, // `
|
||||
44: {0, 54}, // ,
|
||||
46: {0, 55}, // .
|
||||
47: {0, 56}, // /
|
||||
// Shifted numbers / Symbols
|
||||
'!': {2, 30}, // Shift + 1
|
||||
'@': {2, 31}, // Shift + 2
|
||||
'#': {2, 32}, // Shift + 3
|
||||
'$': {2, 33}, // Shift + 4
|
||||
'%': {2, 34}, // Shift + 5
|
||||
'^': {2, 35}, // Shift + 6
|
||||
'&': {2, 36}, // Shift + 7
|
||||
'*': {2, 37}, // Shift + 8
|
||||
'(': {2, 38}, // Shift + 9
|
||||
')': {2, 39}, // Shift + 0
|
||||
|
||||
65: {2, 4}, // A
|
||||
66: {2, 5}, // B
|
||||
67: {2, 6}, // C
|
||||
68: {2, 7}, // D
|
||||
69: {2, 8}, // E
|
||||
70: {2, 9}, // F
|
||||
71: {2, 10}, // G
|
||||
72: {2, 11}, // H
|
||||
73: {2, 12}, // I
|
||||
74: {2, 13}, // J
|
||||
75: {2, 14}, // K
|
||||
76: {2, 15}, // L
|
||||
77: {2, 16}, // M
|
||||
78: {2, 17}, // N
|
||||
79: {2, 18}, // O
|
||||
80: {2, 19}, // P
|
||||
81: {2, 20}, // Q
|
||||
82: {2, 21}, // R
|
||||
83: {2, 22}, // S
|
||||
84: {2, 23}, // T
|
||||
85: {2, 24}, // U
|
||||
86: {2, 25}, // V
|
||||
87: {2, 26}, // W
|
||||
88: {2, 27}, // X
|
||||
89: {2, 28}, // Y
|
||||
90: {2, 29}, // Z
|
||||
// Other common characters
|
||||
'\n': {0, 40}, // Enter (Return)
|
||||
'\t': {0, 43}, // Tab
|
||||
' ': {0, 44}, // Space
|
||||
'-': {0, 45}, // Hyphen / Minus
|
||||
'=': {0, 46}, // Equals
|
||||
'[': {0, 47}, // Left Square Bracket
|
||||
']': {0, 48}, // Right Square Bracket
|
||||
'\\': {0, 49}, // Backslash
|
||||
|
||||
33: {2, 30}, // !
|
||||
64: {2, 31}, // @
|
||||
35: {2, 32}, // #
|
||||
36: {2, 33}, // $
|
||||
37: {2, 34}, // %
|
||||
94: {2, 35}, // ^
|
||||
38: {2, 36}, // &
|
||||
42: {2, 37}, // *
|
||||
40: {2, 38}, // (
|
||||
41: {2, 39}, // )
|
||||
';': {0, 51}, // Semicolon
|
||||
'\'': {0, 52}, // Apostrophe / Single Quote
|
||||
'`': {0, 53}, // Grave Accent / Backtick
|
||||
',': {0, 54}, // Comma
|
||||
'.': {0, 55}, // Period / Dot
|
||||
'/': {0, 56}, // Slash
|
||||
|
||||
95: {2, 45}, // _
|
||||
43: {2, 46}, // +
|
||||
123: {2, 47}, // {
|
||||
125: {2, 48}, // }
|
||||
124: {2, 49}, // |
|
||||
// Shifted symbols
|
||||
'_': {2, 45}, // Underscore (Shift + Hyphen)
|
||||
'+': {2, 46}, // Plus (Shift + Equals)
|
||||
'{': {2, 47}, // Left Curly Brace (Shift + Left Square Bracket)
|
||||
'}': {2, 48}, // Right Curly Brace (Shift + Right Square Bracket)
|
||||
'|': {2, 49}, // Pipe (Shift + Backslash)
|
||||
|
||||
58: {2, 51}, // :
|
||||
34: {2, 52}, // "
|
||||
126: {2, 53}, // ~
|
||||
60: {2, 54}, // <
|
||||
62: {2, 55}, // >
|
||||
63: {2, 56}, // ?
|
||||
':': {2, 51}, // Colon (Shift + Semicolon)
|
||||
'"': {2, 52}, // Double Quote (Shift + Apostrophe)
|
||||
'~': {2, 53}, // Tilde (Shift + Grave Accent)
|
||||
'<': {2, 54}, // Less Than (Shift + Comma)
|
||||
'>': {2, 55}, // Greater Than (Shift + Period)
|
||||
'?': {2, 56}, // Question Mark (Shift + Slash)
|
||||
}
|
||||
|
||||
107
server/service/stream/direct/h264.go
Normal file
107
server/service/stream/direct/h264.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package direct
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"NanoKVM-Server/common"
|
||||
)
|
||||
|
||||
type Frame struct {
|
||||
IsKeyFrame bool `json:"isKeyFrame"`
|
||||
Data string `json:"data"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
var (
|
||||
mutex = sync.Mutex{}
|
||||
wsMap = make(map[*websocket.Conn]bool)
|
||||
isSending = false
|
||||
upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func Connect(c *gin.Context) {
|
||||
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
log.Errorf("failed to create websocket: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = ws.Close()
|
||||
log.Debugf("h264 websocket disconnected")
|
||||
}()
|
||||
|
||||
var zeroTime time.Time
|
||||
_ = ws.SetReadDeadline(zeroTime)
|
||||
|
||||
mutex.Lock()
|
||||
wsMap[ws] = true
|
||||
if len(wsMap) == 1 && !isSending {
|
||||
go send()
|
||||
}
|
||||
mutex.Unlock()
|
||||
|
||||
_, _, err = ws.ReadMessage()
|
||||
if err != nil {
|
||||
mutex.Lock()
|
||||
delete(wsMap, ws)
|
||||
mutex.Unlock()
|
||||
log.Debugf("failed to read message: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func send() {
|
||||
isSending = true
|
||||
screen := common.GetScreen()
|
||||
common.CheckScreen()
|
||||
|
||||
fps := screen.FPS
|
||||
duration := time.Second / time.Duration(fps)
|
||||
|
||||
ticker := time.NewTicker(duration)
|
||||
defer ticker.Stop()
|
||||
|
||||
vision := common.GetKvmVision()
|
||||
startTime := time.Now()
|
||||
|
||||
for range ticker.C {
|
||||
if len(wsMap) == 0 {
|
||||
isSending = false
|
||||
return
|
||||
}
|
||||
|
||||
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
|
||||
if result < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
frameMsg := Frame{
|
||||
IsKeyFrame: result == 3,
|
||||
Data: base64.StdEncoding.EncodeToString(data),
|
||||
Timestamp: time.Since(startTime).Microseconds(),
|
||||
}
|
||||
|
||||
frameJSON, err := json.Marshal(frameMsg)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for ws := range wsMap {
|
||||
if err := ws.WriteMessage(websocket.TextMessage, frameJSON); err != nil {
|
||||
log.Debugf("failed to write message: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
package vm
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/common"
|
||||
"NanoKVM-Server/proto"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var hdmiEnabled = true
|
||||
|
||||
func (s *Service) ResetHdmi(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
@@ -17,7 +20,42 @@ func (s *Service) ResetHdmi(c *gin.Context) {
|
||||
vision.SetHDMI(false)
|
||||
time.Sleep(1 * time.Second)
|
||||
vision.SetHDMI(true)
|
||||
hdmiEnabled = true
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debug("reset hdmi")
|
||||
}
|
||||
|
||||
func (s *Service) EnableHdmi(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
vision := common.GetKvmVision()
|
||||
|
||||
vision.SetHDMI(true)
|
||||
hdmiEnabled = true
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debug("enable hdmi")
|
||||
}
|
||||
|
||||
func (s *Service) DisableHdmi(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
vision := common.GetKvmVision()
|
||||
|
||||
vision.SetHDMI(false)
|
||||
hdmiEnabled = false
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debug("disable hdmi")
|
||||
}
|
||||
|
||||
func (s *Service) GetHdmiState(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetGetHdmiStateRsp{
|
||||
Enabled: hdmiEnabled,
|
||||
})
|
||||
|
||||
log.Debug("get hdmi state")
|
||||
}
|
||||
|
||||
76
server/service/vm/tls.go
Normal file
76
server/service/vm/tls.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package vm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"NanoKVM-Server/config"
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/utils"
|
||||
)
|
||||
|
||||
func (s *Service) SetTls(c *gin.Context) {
|
||||
var req proto.SetTlsReq
|
||||
var rsp proto.Response
|
||||
|
||||
err := proto.ParseFormRequest(c, &req)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, fmt.Sprintf("invalid arguments: %s", err))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Enabled {
|
||||
err = enableTls()
|
||||
} else {
|
||||
err = disableTls()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Errorf("failed to set TLS: %s", err)
|
||||
rsp.ErrRsp(c, -2, "operation failed")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
|
||||
_ = exec.Command("sh", "-c", "/etc/init.d/S95nanokvm restart").Run()
|
||||
}
|
||||
|
||||
func enableTls() error {
|
||||
if err := utils.GenerateCert(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conf, err := config.Read()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conf.Proto = "https"
|
||||
conf.Cert.Crt = "/etc/kvm/server.crt"
|
||||
conf.Cert.Key = "/etc/kvm/server.key"
|
||||
|
||||
if err := config.Write(conf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func disableTls() error {
|
||||
conf, err := config.Read()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conf.Proto = "http"
|
||||
|
||||
if err := config.Write(conf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
100
server/utils/cert.go
Normal file
100
server/utils/cert.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func GenerateCert() error {
|
||||
var (
|
||||
host = "localhost"
|
||||
ipAddress = []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}
|
||||
validFor = time.Hour * 24 * 365 * 10
|
||||
certFile = "/etc/kvm/server.crt"
|
||||
keyFile = "/etc/kvm/server.key"
|
||||
)
|
||||
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
log.Errorf("failed to generate RSA private key: %v", err)
|
||||
return err
|
||||
}
|
||||
publicKey := &privateKey.PublicKey
|
||||
|
||||
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
|
||||
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
|
||||
if err != nil {
|
||||
log.Errorf("failed to generate serial number: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
CommonName: host,
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(validFor),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: false,
|
||||
DNSNames: []string{host},
|
||||
IPAddresses: ipAddress,
|
||||
}
|
||||
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey, privateKey)
|
||||
if err != nil {
|
||||
log.Errorf("failed to create certificate: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// generate certificate
|
||||
certOut, err := os.Create(certFile)
|
||||
if err != nil {
|
||||
log.Errorf("failed to create %s: %v", certFile, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
|
||||
log.Errorf("failed to encode %s: %v", certFile, err)
|
||||
return err
|
||||
}
|
||||
|
||||
_ = certOut.Sync()
|
||||
_ = certOut.Close()
|
||||
log.Debugf("%s generated", certFile)
|
||||
|
||||
// generate private key
|
||||
keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) // 权限 0600
|
||||
if err != nil {
|
||||
log.Errorf("failed to create %s: %v", keyFile, err)
|
||||
return err
|
||||
}
|
||||
|
||||
privateBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
if err != nil {
|
||||
log.Errorf("failed to marshal private key: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privateBytes}); err != nil {
|
||||
log.Errorf("failed to encode %s: %v", keyFile, err)
|
||||
return err
|
||||
}
|
||||
|
||||
_ = keyOut.Sync()
|
||||
_ = keyOut.Close()
|
||||
log.Debugf("%s generated", keyFile)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -34,7 +34,8 @@
|
||||
"react-simple-keyboard": "^3.8.19",
|
||||
"semver": "^7.6.3",
|
||||
"vaul": "^0.9.9",
|
||||
"websocket": "^1.0.35"
|
||||
"websocket": "^1.0.35",
|
||||
"yocto-queue": "^1.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
|
||||
|
||||
9
web/pnpm-lock.yaml
generated
9
web/pnpm-lock.yaml
generated
@@ -80,6 +80,9 @@ importers:
|
||||
websocket:
|
||||
specifier: ^1.0.35
|
||||
version: 1.0.35
|
||||
yocto-queue:
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.1
|
||||
devDependencies:
|
||||
'@ianvs/prettier-plugin-sort-imports':
|
||||
specifier: ^4.3.1
|
||||
@@ -2916,6 +2919,10 @@ packages:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
yocto-queue@1.2.1:
|
||||
resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==}
|
||||
engines: {node: '>=12.20'}
|
||||
|
||||
yoctocolors-cjs@2.1.2:
|
||||
resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5899,4 +5906,6 @@ snapshots:
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
yocto-queue@1.2.1: {}
|
||||
|
||||
yoctocolors-cjs@2.1.2: {}
|
||||
|
||||
@@ -62,6 +62,29 @@ export function resetHdmi() {
|
||||
return http.post('/api/vm/hdmi/reset');
|
||||
}
|
||||
|
||||
// get HDMI state
|
||||
export function getHdmiState() {
|
||||
return http.get('/api/vm/hdmi');
|
||||
}
|
||||
|
||||
// enable HDMI
|
||||
export function enableHdmi() {
|
||||
return http.post('/api/vm/hdmi/enable');
|
||||
}
|
||||
|
||||
// disable HDMI
|
||||
export function disableHdmi() {
|
||||
return http.post('/api/vm/hdmi/disable');
|
||||
}
|
||||
|
||||
// set HDMI state
|
||||
export function setHdmiState(enabled: boolean) {
|
||||
if (enabled) {
|
||||
return enableHdmi();
|
||||
}
|
||||
return disableHdmi();
|
||||
}
|
||||
|
||||
// get SSH state
|
||||
export function getSSHState() {
|
||||
return http.get('/api/vm/ssh');
|
||||
@@ -132,6 +155,11 @@ export function disableMdns() {
|
||||
return http.post('/api/vm/mdns/disable');
|
||||
}
|
||||
|
||||
// enable / disable TLS
|
||||
export function setTLS(enabled: boolean) {
|
||||
return http.post('/api/vm/tls', { enabled });
|
||||
}
|
||||
|
||||
// reboot
|
||||
export function reboot() {
|
||||
return http.post('/api/vm/system/reboot');
|
||||
|
||||
@@ -103,3 +103,7 @@
|
||||
.keyboard-header {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
}
|
||||
|
||||
.hg-candidate-box {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { Helmet, HelmetData } from 'react-helmet-async';
|
||||
|
||||
import { getWebTitle } from '@/api/vm.ts';
|
||||
import { existToken } from '@/lib/cookie.ts';
|
||||
import { webTitleAtom } from '@/jotai/settings.ts';
|
||||
|
||||
type HeadProps = {
|
||||
@@ -11,7 +14,17 @@ type HeadProps = {
|
||||
const helmetData = new HelmetData({});
|
||||
|
||||
export const Head = ({ title = '', description = '' }: HeadProps = {}) => {
|
||||
const webTitle = useAtomValue(webTitleAtom);
|
||||
const [webTitle, setWebTitle] = useAtom(webTitleAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (!existToken()) return;
|
||||
|
||||
getWebTitle().then((rsp) => {
|
||||
if (rsp.data?.title) {
|
||||
setWebTitle(rsp.data.title);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Helmet
|
||||
|
||||
@@ -2,58 +2,57 @@ const de = {
|
||||
translation: {
|
||||
head: {
|
||||
desktop: 'Entfernter Desktop',
|
||||
login: 'Einloggen',
|
||||
login: 'Anmelden',
|
||||
changePassword: 'Passwort ändern',
|
||||
terminal: 'Terminal',
|
||||
wifi: 'Wi-Fi'
|
||||
},
|
||||
auth: {
|
||||
login: 'Einloggen',
|
||||
placeholderUsername: 'Bitte den Benutzernamen eingeben',
|
||||
placeholderPassword: 'Bitte das Passwort eingeben',
|
||||
placeholderPassword2: 'Passwort wiederholen',
|
||||
noEmptyUsername: 'Der Benutzername darf nicht leer sein',
|
||||
noEmptyPassword: 'Das Passwort darf nicht leer sein',
|
||||
noAccount:
|
||||
'Fehler beim Auslsen der Benutzerinformation, bitte die Seite neu laden oder das Passwort zurücksetzen',
|
||||
login: 'Anmelden',
|
||||
placeholderUsername: 'Benutzername',
|
||||
placeholderPassword: 'Passwort',
|
||||
placeholderPassword2: 'Bitte Passwort erneut eingeben',
|
||||
noEmptyUsername: 'Benutzername benötigt',
|
||||
noEmptyPassword: 'Passwort benötigt',
|
||||
noAccount: 'Abfragen der Benutzerdaten fehlgeschlagen, bitte die Seite neu laden oder Passwort zurücksetzen',
|
||||
invalidUser: 'Falscher Benutzername oder falsches Passwort',
|
||||
error: 'Unerwarteter Fehler',
|
||||
changePassword: 'Passwort ändern',
|
||||
changePasswordDesc:
|
||||
'Für die Sicherheit Ihres Geräts ändern Sie bitte das Web-Login-Passwort.',
|
||||
differentPassword: 'Passwörter stimmt nicht überein',
|
||||
illegalUsername: 'Benutzername beinhaltet ungültige Zeichen',
|
||||
illegalPassword: 'Passwort beinhaltet ungültige Zeichen',
|
||||
changePasswordDesc: 'Für die Sicherheit Ihres Geräts ändern Sie bitte das Passwort!',
|
||||
differentPassword: 'Passwörter stimmen nicht überein',
|
||||
illegalUsername: 'Benutzername enthält ungültige Zeichen',
|
||||
illegalPassword: 'Passwort enthält ungültige Zeichen',
|
||||
forgetPassword: 'Passwort vergessen',
|
||||
ok: 'Ok',
|
||||
cancel: 'Abbrechen',
|
||||
loginButtonText: 'Einloggen',
|
||||
loginButtonText: 'Anmelden',
|
||||
tips: {
|
||||
reset1:
|
||||
'To reset the passwords, pressing and holding the BOOT button on the NanoKVM for 10 seconds.',
|
||||
reset2: 'For detailed steps, please consult this document:',
|
||||
reset3: 'Web default account:',
|
||||
reset4: 'SSH default account:',
|
||||
change1: 'Please note that this action will change the following passwords:',
|
||||
change2: 'Web login password',
|
||||
change3: 'System root password (SSH login password)',
|
||||
change4: 'To reset the passwords, press and hold the BOOT button on the NanoKVM.'
|
||||
'Um das Passwort zurückzusetzen, drücken und halten Sie den BOOT Knopf auf dem NanoKVM für 10 Sekunden.',
|
||||
reset2: 'Für detailliertere Anweisungen lesen Sie folgendes Dokument:',
|
||||
reset3: 'Web Standard-Account:',
|
||||
reset4: 'SSH Standard-Account:',
|
||||
change1: 'Bitte beachten Sie, dass diese Aktion folgende Passwörter ändert:',
|
||||
change2: 'Web Anmelde-Passwort',
|
||||
change3: 'System root Passwort (SSH Anmelde-Passwort)',
|
||||
change4: 'Um die Passwörter zurückzusetzen, drücken und halten Sie den BOOT Knopf auf dem NanoKVM.'
|
||||
}
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Configure Wi-Fi for NanoKVM',
|
||||
success: 'Please check the network status of NanoKVM and visit the new IP address.',
|
||||
failed: 'Operation failed, please try again.',
|
||||
description: 'Wi-Fi Konfiguration für NanoKVM',
|
||||
success: 'Bitte überprüfen Sie den Netzwerk-Status des NanoKVM und greifen Sie über die neue IP Adresse darauf zu.',
|
||||
failed: 'Aktion fehlgeschlagen, bitte erneut versuchen.',
|
||||
confirmBtn: 'Ok',
|
||||
finishBtn: 'Finished'
|
||||
finishBtn: 'Fertig'
|
||||
},
|
||||
screen: {
|
||||
video: 'Videomodus',
|
||||
title: 'Bildschirm',
|
||||
video: 'Video Modus',
|
||||
resolution: 'Auflösung',
|
||||
auto: 'Automatisch',
|
||||
autoTips:
|
||||
'Falls es bei bestimmten Auflösungen zu Screen-Tearing oder einem Offset des Mauszeigers kommen sollte, bitte die Auflösung des Remote Hosts anpassen oder den automatischen Modus ausschalten.',
|
||||
"Bildverzerrungen oder ein versetzter Mauszeiger können bei bestimmten Auflösungen auftreten. Versuchen Sie, die Auflösung des entfernten Hosts anzupassen oder den automatischen Modus zu deaktivieren.",
|
||||
fps: 'FPS',
|
||||
customizeFps: 'Anpassen',
|
||||
quality: 'Qualität',
|
||||
@@ -61,193 +60,281 @@ const de = {
|
||||
qualityHigh: 'Hoch',
|
||||
qualityMedium: 'Mittel',
|
||||
qualityLow: 'Niedrig',
|
||||
frameDetect: 'Frame Detect',
|
||||
frameDetect: 'Bilderkennung',
|
||||
frameDetectTip:
|
||||
'Berechnet den Unterschied zwischen den Einzelbildern. Beendet die Liveübertragung des Videostreams wenn keine Änderungen auf dem Bildschirm des Hosts festgestellt werden kann.',
|
||||
resetHdmi: 'Reset HDMI'
|
||||
resetHdmi: 'HDMI zurücksetzen'
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Tastatur',
|
||||
paste: 'Einfügen',
|
||||
tips: 'Nur Standard-Tastaturbuchstaben und Symbole werden unterstützt.',
|
||||
tips: 'Nur Standard-Tastaturbuchstaben und Symbole werden unterstützt',
|
||||
placeholder: 'Bitte eingeben',
|
||||
submit: 'Senden',
|
||||
virtual: 'Virtuelle Tastatur',
|
||||
virtual: 'Tastatur',
|
||||
ctrlaltdel: 'Ctrl+Alt+Del'
|
||||
},
|
||||
mouse: {
|
||||
title: 'Maus',
|
||||
cursor: 'Cursor',
|
||||
default: 'Standard Cursor',
|
||||
pointer: 'Zeiger Cursor',
|
||||
cell: 'Zellen Cursor',
|
||||
text: 'Text Cursor',
|
||||
grab: 'Greif Cursor',
|
||||
hide: 'Cursor ausblenden',
|
||||
hide: 'Versteckter Cursor',
|
||||
mode: 'Maus Modus',
|
||||
absolute: 'Absoluter Modus',
|
||||
relative: 'Relativer Modus',
|
||||
requestPointer:
|
||||
'Der Relative Modus wird benutzt. Bitte auf den Desktop klicken um einen Mauszeiger anzuzeigen.',
|
||||
resetHid: 'Eingeabegeräte zurücksetzen'
|
||||
requestPointer: 'Relativer Modus aktiv. Klicken Sie auf den Desktop um den Mauszeiger zu sehen.',
|
||||
resetHid: 'HID zurücksetzen',
|
||||
hidOnly: {
|
||||
title: 'HID-Only Modus',
|
||||
desc: 'Wenn Ihre Maus und Tastatur nicht mehr reagieren und das Zurücksetzen der HID-Verbindung nicht hilft, könnte es sich um ein Kompatibilitätsproblem zwischen dem NanoKVM und dem Gerät handeln. Versuchen Sie, den HID-Only Modus zu aktivieren, um die Kompatibilität zu verbessern.',
|
||||
tip1: 'Die Aktivierung des HID-Only Modus entfernt das virtuelle U-Laufwerk und das virtuelle Netzwerk.',
|
||||
tip2: 'Im HID-Only Modus ist das Einbinden von Systemabbilder deaktiviert.',
|
||||
tip3: 'NanoKVM wird nach dem Wechsel in den neuen Modus automatisch neu gestartet.',
|
||||
enable: 'HID-Only Modus aktivieren',
|
||||
disable: 'HID-Only Modus deaktivieren'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'ISO oder Festplatten Abbilder',
|
||||
loading: 'Laden...',
|
||||
title: 'Bilder',
|
||||
loading: 'Lädt...',
|
||||
empty: 'Nichts gefunden',
|
||||
mountFailed: 'Mounten fehlgeschlagen',
|
||||
mountDesc:
|
||||
'Bei einigen Systemen muss die virtuelle Festplatte auf dem Remote-Host ausgeworfen werden, bevor das Image gemountet werden kann.',
|
||||
cdrom: 'Binden Sie das Abbild im CD-ROM Modus ein',
|
||||
mountFailed: 'Einbinden fehlgeschlagen',
|
||||
mountDesc:
|
||||
"In einigen Systemen ist es notwendig, die virtuelle Festplatte auf dem entfernten Host auszuwerfen, bevor das Image eingebunden werden kann.",
|
||||
refresh: 'Bilder aktualisieren',
|
||||
tips: {
|
||||
title: 'Wie man Abbilder hochlädt',
|
||||
usb1: 'Den NanoKVM via USB mit dem Computer verbinden.',
|
||||
usb2: 'Stellen sie sicher, das das Virtuelle Festplatte eingebunden ist. (Einstellungen - Virtuelle Festplatte).',
|
||||
usb3: 'Öffnen sie die Virtuelle Festplatte auf ihrem Computer und kopieren sie die Abbilddatei in das root-Verzeichnis des USB-Geräts.',
|
||||
scp1: 'Stellen sie sicher, das sich der NanoKVM und ihr Computer im gleichen lokalen Netzwerk befinden.',
|
||||
scp2: 'Öffnen sie ein Terminal auf ihrem Computer und benutzen sie den SCP-Befehl um die Abbilddatei in das /data Verzeichnis des NanoKVM zu kopieren.',
|
||||
scp3: 'Beispiel: scp ihr-abbild-verzeichnis root@ihre-nanokvm-ip:/data',
|
||||
tfCard: 'TF-Karte',
|
||||
tf1: 'Diese Mehthode wird auf Linuxsystemen unterstützt',
|
||||
tf2: 'Entfernen sie die TF-Karte aus dem NanoKVM (gilt nur für die fertig zusammengebaute Version, bitte das Gehäuse vorher auseinanderbauen).',
|
||||
tf3: 'Legen sie die TF-Karte in den Kartenleser ein und verbinden sie diesen mit dem Computer.',
|
||||
tf4: 'Kopieren sie die Abbild-Datei in das /data Verzeichnis der TF-Karte.',
|
||||
tf5: 'Legen sie die TF-Karte in das NanoKVM ein.'
|
||||
title: 'So laden Sie Dateien hoch',
|
||||
usb1: 'Verbinden Sie den NanoKVM über USB mit Ihrem Computer.',
|
||||
usb2: 'Stellen Sie sicher, dass die virtuelle Festplatte eingebunden ist (Einstellungen – Virtuelle Festplatte).',
|
||||
usb3: 'Öffnen Sie die virtuelle Festplatte auf Ihrem Computer und kopieren Sie die Image-Datei in das Stammverzeichnis der virtuellen Festplatte.',
|
||||
scp1: 'Stellen Sie sicher, dass sich der NanoKVM und Ihr Computer im selben lokalen Netzwerk befinden.',
|
||||
scp2: 'Öffnen Sie ein Terminal auf Ihrem Computer und verwenden Sie den SCP-Befehl, um die Image-Datei in das Verzeichnis /data auf dem NanoKVM hochzuladen.',
|
||||
scp3: 'Beispiel: scp your-image-path root@your-nanokvm-ip:/data',
|
||||
tfCard: 'TF-Karte',
|
||||
tf1: 'Diese Methode wird unter Linux-Systemen unterstützt.',
|
||||
tf2: 'Entnehmen Sie die TF-Karte aus dem NanoKVM (bei der FULL-Version muss zuvor das Gehäuse geöffnet werden).',
|
||||
tf3: 'Stecken Sie die TF-Karte in einen Kartenleser und verbinden Sie diesen mit Ihrem Computer.',
|
||||
tf4: 'Kopieren Sie die Image-Datei in das Verzeichnis /data auf der TF-Karte.',
|
||||
tf5: 'Setzen Sie die TF-Karte wieder in den NanoKVM ein.'
|
||||
}
|
||||
},
|
||||
script: {
|
||||
title: 'Skript',
|
||||
title: 'Skripte',
|
||||
upload: 'Hochladen',
|
||||
run: 'Ausführen',
|
||||
runBackground: 'Im Hintergrund ausführen',
|
||||
runFailed: 'Ausführung abgebrochen',
|
||||
runFailed: '',
|
||||
attention: 'Achtung',
|
||||
delDesc: 'Sind sie sicher, dass sie diese Datei löschen möchten?',
|
||||
delDesc: 'Möchten Sie diese Datei wirklich löschen?',
|
||||
confirm: 'Ja',
|
||||
cancel: 'Nein',
|
||||
delete: 'Löschen',
|
||||
close: 'Schließen'
|
||||
close: 'Schliessen'
|
||||
},
|
||||
terminal: {
|
||||
title: 'Terminal',
|
||||
nanokvm: 'NanoKVM Terminal',
|
||||
serial: 'Terminal über den Seriellen Port',
|
||||
serialPort: 'Serieller Port',
|
||||
serialPortPlaceholder: 'Serielle Portnummer eingeben',
|
||||
serial: 'Serieller Anschluss Terminal',
|
||||
serialPort: 'Serieller Anschluss',
|
||||
serialPortPlaceholder: 'Bitte seriellen Anschluss angeben',
|
||||
baudrate: 'Baudrate',
|
||||
parity: 'Parität',
|
||||
parityNone: 'Keine',
|
||||
parityEven: 'Gerade',
|
||||
parityOdd: 'Ungerade',
|
||||
flowControl: 'Fluss-Kontrolle',
|
||||
flowControlNone: 'Keine',
|
||||
flowControlSoft: 'Wenig',
|
||||
flowControlHard: 'Viel',
|
||||
dataBits: 'Daten bits',
|
||||
stopBits: 'Stopp bits',
|
||||
confirm: 'Ok'
|
||||
},
|
||||
wol: {
|
||||
title: 'Wake-on-LAN',
|
||||
sending: 'Befehl senden...',
|
||||
sending: 'Sende Befehl...',
|
||||
sent: 'Befehl gesendet',
|
||||
input: 'Bitte die MAC eingeben',
|
||||
input: 'Bitte MAC Adresse eingeben',
|
||||
ok: 'Ok'
|
||||
},
|
||||
download: {
|
||||
title: 'Systemabbild Downloader',
|
||||
input: 'Bitte geben Sie die URL für das Remote-Systemabbild ein',
|
||||
ok: 'Ok',
|
||||
disabled: '/data Partition ist nur-lesbar, daher kann das Systemabbild nicht heruntergeladen werden'
|
||||
},
|
||||
power: {
|
||||
title: 'Einschalten',
|
||||
reset: 'Neustart',
|
||||
power: 'Einschalten',
|
||||
powerShort: 'Einschalten (Kurzes Drücken)',
|
||||
powerLong: 'Einschalten (Langes Drücken)'
|
||||
title: 'Power',
|
||||
showConfirm: 'Bestätigung',
|
||||
showConfirmTip: 'Diese Aktionen benötigen eine zusätzliche Bestätigung',
|
||||
reset: 'Zurücksetzen',
|
||||
power: 'Power',
|
||||
powerShort: 'Power (Kurzer Klick)',
|
||||
powerLong: 'Power (Langer Klick)',
|
||||
resetConfirm: 'Reset-Aktion durchführen?',
|
||||
powerConfirm: 'Power-Aktion durchführen?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Nein'
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
title: 'Einstellungen',
|
||||
about: {
|
||||
title: 'Über NanoKVM',
|
||||
information: 'Information',
|
||||
information: 'Informationen',
|
||||
ip: 'IP',
|
||||
mdns: 'mDNS',
|
||||
application: 'Versionsnummer',
|
||||
applicationTip: 'NanoKVM web application version',
|
||||
image: 'Image Version',
|
||||
imageTip: 'NanoKVM system image version',
|
||||
deviceKey: 'Geräte-Key',
|
||||
community: 'Community'
|
||||
application: 'Applikations-Version',
|
||||
applicationTip: 'NanoKVM Web Applikations-Version',
|
||||
image: 'Systemabbild-Version',
|
||||
imageTip: 'NanoKVM Systemabbild-Version',
|
||||
deviceKey: 'Geräte Schlüssel',
|
||||
community: 'Community',
|
||||
hostname: 'Hostname',
|
||||
hostnameUpdated: 'Hostname aktualisiert. Neustarten um zu übernehmen.',
|
||||
ipType: {
|
||||
'Wired': 'Kabel',
|
||||
'Wireless': 'Drahtlos',
|
||||
'Other': 'Andere'
|
||||
}
|
||||
},
|
||||
appearance: {
|
||||
title: 'Appearance',
|
||||
display: 'Display',
|
||||
language: 'Language',
|
||||
menuBar: 'Menu Bar',
|
||||
menuBarDesc: 'Display icons in the menu bar'
|
||||
title: 'Erscheinungsbild',
|
||||
display: 'Bildschirm',
|
||||
language: 'Sprache',
|
||||
menuBar: 'Menu-Leiste',
|
||||
menuBarDesc: 'Symbole in der Menu-Leiste anzeigen',
|
||||
webTitle: 'Web Titel',
|
||||
webTitleDesc: 'Passen Sie den Web-Seite Titel an'
|
||||
},
|
||||
device: {
|
||||
title: 'Device',
|
||||
title: 'Gerät',
|
||||
oled: {
|
||||
title: 'OLED',
|
||||
description: 'OLED screen automatically sleep',
|
||||
0: 'Never',
|
||||
15: '15 sec',
|
||||
30: '30 sec',
|
||||
60: '1 min',
|
||||
180: '3 min',
|
||||
300: '5 min',
|
||||
600: '10 min',
|
||||
1800: '30 min',
|
||||
3600: '1 hour'
|
||||
description: 'Schalte OLED Bildschirm aus nach',
|
||||
0: 'Nie',
|
||||
15: '15 Sek',
|
||||
30: '30 Sek',
|
||||
60: '1 Min',
|
||||
180: '3 Min',
|
||||
300: '5 Min',
|
||||
600: '10 Min',
|
||||
1800: '30 Min',
|
||||
3600: '1 Stunde'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Configure Wi-Fi',
|
||||
setBtn: 'Config'
|
||||
description: 'Wi-Fi konfigurieren',
|
||||
setBtn: 'Konfigurieren'
|
||||
},
|
||||
disk: 'Virtual Disk',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
network: 'Virtual Network',
|
||||
networkDesc: 'Mount virtual network card on the remote host'
|
||||
ssh: {
|
||||
description: 'Aktiviere entfernten SSH-Zugang',
|
||||
tip: 'Setzten Sie ein starkes Passwort vor dem aktivieren (Konto - Passwort ändern)'
|
||||
},
|
||||
advanced: 'Erweiterte Einstellungen',
|
||||
swap: {
|
||||
title: 'Swap',
|
||||
disable: 'Deaktivieren',
|
||||
description: 'Grösse der Swap-Datei festlegen',
|
||||
tip: "Das Aktivieren dieser Funktion kann die Lebensdauer Ihrer SD-Karte verkürzen!"
|
||||
},
|
||||
mouseJiggler: {
|
||||
title: 'Mausaktivitäts-Simulator',
|
||||
description: 'Verhindert, dass der remote Host in den Energiesparmodus wechselt',
|
||||
disable: 'Deaktivieren',
|
||||
absolute: 'Absoluter Modus',
|
||||
relative: 'Relativer Modus'
|
||||
},
|
||||
mdns: {
|
||||
description: 'mDNS-Erkennungsdienst aktivieren',
|
||||
tip: "Deaktivieren Sie den Dienst, wenn Sie ihn nicht benötigen"
|
||||
},
|
||||
hidOnly: 'HID-Only Mode',
|
||||
disk: 'Virtuelle Festplatte',
|
||||
diskDesc: 'Binde das virtuelle U-Laufwerk an den entfernten Host',
|
||||
network: 'Virtuelles Netzwerk',
|
||||
networkDesc: 'Binde die virtuelle Netzwerkkarte an den entfernten Host',
|
||||
reboot: 'Neustarten',
|
||||
rebootDesc: 'Sind Sie sicher dass Sie NanoKVM neustarten möchten?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Nein'
|
||||
},
|
||||
tailscale: {
|
||||
title: 'Tailscale',
|
||||
memory: {
|
||||
title: 'Memory optimization',
|
||||
tip: "When memory usage exceeds the limit, garbage collection is performed more aggressively to attempt to free up memory. it's recommended to set to 50MB if using Tailscale. A Tailscale restart is required for the change to take effect.",
|
||||
disable: 'Disable'
|
||||
title: 'Speicher Optimierung',
|
||||
tip: "Wenn die Speichernutzung das Limit überschreitet, wird die Speicherbereinigung aggressiver durchgeführt, um Speicher freizugeben. Es wird empfohlen, den Wert auf 75 MB zu setzen, wenn Tailscale verwendet wird. Ein Neustart von Tailscale ist erforderlich, damit die Änderung wirksam wird.",
|
||||
disable: 'Deaktivieren'
|
||||
},
|
||||
restart: 'Are you sure to restart Tailscale?',
|
||||
stop: 'Are you sure to stop Tailscale?',
|
||||
stopDesc: 'Log out Tailscale and disable its automatic startup on boot.',
|
||||
loading: 'Lade...',
|
||||
notInstall: 'Tailscale wurde nicht gefunden! Bitte installieren.',
|
||||
restart: 'Tailscale neu starten?',
|
||||
stop: 'Tailscale stoppen?',
|
||||
stopDesc: 'Von Tailscale abmelden und automatischen Start beim Booten deaktivieren.',
|
||||
loading: 'Lädt...',
|
||||
notInstall: 'Tailscale nicht gefunden! Bitte installieren.',
|
||||
install: 'Installieren',
|
||||
installing: 'Installation wird durchgeführt',
|
||||
failed: 'Installation gescheitert',
|
||||
retry:
|
||||
'Bitte neu laden und noch einmal versuchen. Oder versuchen sie Tailscale manuell zu installieren',
|
||||
download: 'Laden sie das',
|
||||
package: 'Installationspaket',
|
||||
unzip: 'und entpacken sie es manuell',
|
||||
upTailscale: 'Hochladen von tailscale in das NanoKVM Verzeichnis /usr/bin/',
|
||||
upTailscaled: 'Hochladen von tailscaled in das NanoKVM Verzeichnis /usr/sbin/',
|
||||
refresh: 'Die aktuelle Seite neu laden',
|
||||
installing: 'Installiere',
|
||||
failed: 'Installation fehlgeschlagen',
|
||||
retry: 'Bitte Seite neu laden und erneut versuchen oder manuelle Installation versuchen.',
|
||||
download: 'Laden Sie das',
|
||||
package: 'Installations-Paket herunter',
|
||||
unzip: 'und entpacken Sie es',
|
||||
upTailscale: 'Tailscale nach /usr/bin/ auf NanoKVM hochladen',
|
||||
upTailscaled: 'Tailscaled nach /usr/bin/ auf NanoKVM hochladen',
|
||||
refresh: 'Aktuelle Seite neu laden',
|
||||
notLogin:
|
||||
'Diese Geräte ist bisher noch nicht verknüpft. Bitte loggen sie sich in ihr Konto ein und verknüpfen sie dieses Gerät mit diesem.',
|
||||
'Das Gerät konnte noch nicht gefunden werden. Bitte melden Sie sich an und verknüpfen Sie dieses Gerät mit Ihrem Konto.',
|
||||
urlPeriod: 'Diese URL ist für 10 Minuten gültig',
|
||||
login: 'Einloggen',
|
||||
loginSuccess: 'Das Einloggen war erfolgreich',
|
||||
enable: 'Tailscale aktivieren',
|
||||
deviceName: 'Name des Geräts',
|
||||
deviceIP: 'Geräte-IP',
|
||||
account: 'Benutzerkonto',
|
||||
logout: 'Ausloggen',
|
||||
logout2: 'Wollen Sie sich wirklich ausloggen?',
|
||||
okBtn: 'Yes',
|
||||
cancelBtn: 'No'
|
||||
login: 'Anmelden',
|
||||
loginSuccess: 'Anmeldung erfolgreich',
|
||||
enable: 'Tailscale einschalten',
|
||||
deviceName: 'Geräte Name',
|
||||
deviceIP: 'Geräte IP',
|
||||
account: 'Konto',
|
||||
logout: 'Abmelden',
|
||||
logoutDesc: 'Möchten Sie sich wirklich abmelden?',
|
||||
uninstall: 'Tailscale deinstallieren',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Nein'
|
||||
},
|
||||
update: {
|
||||
title: 'Nach neuem Update suchen',
|
||||
queryFailed: 'Versionsnummer konnte nicht erkannt werden',
|
||||
updateFailed: 'Update gescheitert. Bitte versuchen sie es erneut.',
|
||||
isLatest: 'Die aktuelle Version ist bereits installiert.',
|
||||
available:
|
||||
'Ein Update ist verfügbar. Sind sie sicher, dass sie diese Version aktualisieren möchten?',
|
||||
updating: 'Update wird gestartet. Bitte warten...',
|
||||
title: 'Nach Aktualisierungen suchen',
|
||||
queryFailed: 'Version konnte nicht abgefragt werden',
|
||||
updateFailed: 'Aktualisierung fehlgeschlagen. Bitte versuchen Sie es erneut.',
|
||||
isLatest: 'Sie haben bereits die aktuellste Version.',
|
||||
available: 'Eine Aktualisierung ist verfügbar. Möchten sie diese jetzt durchführen?',
|
||||
updating: 'Aktualisierung gestartet. Bitte warten...',
|
||||
confirm: 'Bestätigen',
|
||||
cancel: 'Abbrechen'
|
||||
cancel: 'Abbrechen',
|
||||
preview: 'Vorab-Versionen',
|
||||
previewDesc: 'Erhalten Sie vorab Zugriff auf neue Funktionen und Verbesserungen',
|
||||
previewTip:
|
||||
'Bitte beachten Sie, dass Vorab-Versionen womöglich noch Fehler oder unvollständige Funktionen enthalten!'
|
||||
},
|
||||
account: {
|
||||
title: 'Account',
|
||||
webAccount: 'Web Account Name',
|
||||
password: 'Password',
|
||||
updateBtn: 'Update',
|
||||
logoutBtn: 'Logout'
|
||||
title: 'Konto',
|
||||
webAccount: 'Web Konto Name',
|
||||
password: 'Passwort',
|
||||
updateBtn: 'Ändern',
|
||||
logoutBtn: 'Abmelden',
|
||||
logoutDesc: 'Möchten Sie sich wirklich abmelden?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Nein'
|
||||
}
|
||||
},
|
||||
error: {
|
||||
title: "Wir sind auf ein Problem gestossen",
|
||||
refresh: 'Neuladen'
|
||||
},
|
||||
fullscreen: {
|
||||
toggle: 'Vollbild ein/aus'
|
||||
},
|
||||
menu: {
|
||||
collapse: 'Menu einblenden',
|
||||
expand: 'Menu verbergen'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -49,6 +49,7 @@ const en = {
|
||||
screen: {
|
||||
title: 'Screen',
|
||||
video: 'Video Mode',
|
||||
videoDirectTips: 'Enable HTTPS in "Settings > Device" to use this mode',
|
||||
resolution: 'Resolution',
|
||||
auto: 'Automatic',
|
||||
autoTips:
|
||||
@@ -86,6 +87,9 @@ const en = {
|
||||
mode: 'Mouse mode',
|
||||
absolute: 'Absolute mode',
|
||||
relative: 'Relative mode',
|
||||
speed: 'Wheel speed',
|
||||
fast: 'Fast',
|
||||
slow: 'Slow',
|
||||
requestPointer: 'Using relative mode. Please click desktop to get mouse pointer.',
|
||||
resetHid: 'Reset HID',
|
||||
hidOnly: {
|
||||
@@ -195,7 +199,12 @@ const en = {
|
||||
deviceKey: 'Device Key',
|
||||
community: 'Community',
|
||||
hostname: 'Hostname',
|
||||
hostnameUpdated: 'Hostname updated. Reboot to apply.'
|
||||
hostnameUpdated: 'Hostname updated. Reboot to apply.',
|
||||
ipType: {
|
||||
Wired: 'Wired',
|
||||
Wireless: 'Wireless',
|
||||
Other: 'Other'
|
||||
}
|
||||
},
|
||||
appearance: {
|
||||
title: 'Appearance',
|
||||
@@ -230,8 +239,13 @@ const en = {
|
||||
description: 'Enable SSH remote access',
|
||||
tip: 'Set a strong password before enabling (Account - Change Password)'
|
||||
},
|
||||
tls: {
|
||||
description: 'Enable HTTPS protocol',
|
||||
tip: 'Be aware: Using HTTPS can increase latency, especially with MJPEG video mode.'
|
||||
},
|
||||
advanced: 'Advanced Settings',
|
||||
swap: {
|
||||
title: 'Swap',
|
||||
disable: 'Disable',
|
||||
description: 'Set the swap file size',
|
||||
tip: "Enabling this feature could shorten your SD card's usable life!"
|
||||
@@ -247,6 +261,9 @@ const en = {
|
||||
description: 'Enable mDNS discovery service',
|
||||
tip: "Turning it off if it's not needed"
|
||||
},
|
||||
hdmi: {
|
||||
description: 'Enable HDMI/monitor output'
|
||||
},
|
||||
hidOnly: 'HID-Only Mode',
|
||||
disk: 'Virtual Disk',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
|
||||
@@ -40,9 +40,9 @@ const ko = {
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Wi-Fi 설정 for NanoKVM',
|
||||
description: 'NanoKVM Wi-Fi 설정',
|
||||
success: 'NanoKVM의 네트워크 상태를 확인하고 새 IP 주소로 접속하세요.',
|
||||
failed: '작업이 실패했습니다. 다시 시도하세요.',
|
||||
failed: '작업에 실패했습니다. 다시 시도하세요.',
|
||||
confirmBtn: '확인',
|
||||
finishBtn: '완료'
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ const nl = {
|
||||
login: 'Inloggen',
|
||||
changePassword: 'Wachtwoord wijzigen',
|
||||
terminal: 'Terminal',
|
||||
wifi: 'Wi-Fi'
|
||||
wifi: 'Wifi'
|
||||
},
|
||||
auth: {
|
||||
login: 'Inloggen',
|
||||
@@ -29,24 +29,23 @@ const nl = {
|
||||
cancel: 'Annuleren',
|
||||
loginButtonText: 'Inloggen',
|
||||
tips: {
|
||||
reset1:
|
||||
'To reset the passwords, pressing and holding the BOOT button on the NanoKVM for 10 seconds.',
|
||||
reset2: 'For detailed steps, please consult this document:',
|
||||
reset3: 'Web default account:',
|
||||
reset4: 'SSH default account:',
|
||||
change1: 'Please note that this action will change the following passwords:',
|
||||
change2: 'Web login password',
|
||||
change3: 'System root password (SSH login password)',
|
||||
change4: 'To reset the passwords, press and hold the BOOT button on the NanoKVM.'
|
||||
reset1: 'Om de wachtwoorden opnieuw in te stellen, houdt u de BOOT-knop op de NanoKVM 10 seconden lang ingedrukt.',
|
||||
reset2: 'Voor gedetailleerde stappen kunt u dit document raadplegen:',
|
||||
reset3: 'Standaard webaccount:',
|
||||
reset4: 'Standaard SSH-account:',
|
||||
change1: 'Houd er rekening mee dat deze actie de volgende wachtwoorden zal wijzigen:',
|
||||
change2: 'Web login wachtwoord',
|
||||
change3: 'Systeem root-wachtwoord (SSH-inlogwachtwoord)',
|
||||
change4: 'Om de wachtwoorden opnieuw in te stellen, houdt u de BOOT-knop op de NanoKVM ingedrukt.'
|
||||
}
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Configure Wi-Fi for NanoKVM',
|
||||
success: 'Please check the network status of NanoKVM and visit the new IP address.',
|
||||
failed: 'Operation failed, please try again.',
|
||||
title: 'Wifi',
|
||||
description: 'Wifi configureren voor NanoKVM',
|
||||
success: 'Controleer de netwerkstatus van NanoKVM en bezoek het nieuwe IP-adres.',
|
||||
failed: 'De bewerking is mislukt. Probeer het opnieuw.',
|
||||
confirmBtn: 'Ok',
|
||||
finishBtn: 'Finished'
|
||||
finishBtn: 'Gereed'
|
||||
},
|
||||
screen: {
|
||||
video: 'Videomodus',
|
||||
@@ -162,18 +161,18 @@ const nl = {
|
||||
community: 'Community'
|
||||
},
|
||||
appearance: {
|
||||
title: 'Appearance',
|
||||
display: 'Display',
|
||||
language: 'Language',
|
||||
menuBar: 'Menu Bar',
|
||||
menuBarDesc: 'Display icons in the menu bar'
|
||||
title: 'Uiterlijk',
|
||||
display: 'Beeldscherm',
|
||||
language: 'Taal',
|
||||
menuBar: 'Menubalk',
|
||||
menuBarDesc: 'Toon iconen in de menubalk'
|
||||
},
|
||||
device: {
|
||||
title: 'Device',
|
||||
title: 'Apparaat',
|
||||
oled: {
|
||||
title: 'OLED',
|
||||
description: 'OLED screen automatically sleep',
|
||||
0: 'Never',
|
||||
description: 'OLED scherm automatisch slapen',
|
||||
0: 'Nooit',
|
||||
15: '15 sec',
|
||||
30: '30 sec',
|
||||
60: '1 min',
|
||||
@@ -181,32 +180,32 @@ const nl = {
|
||||
300: '5 min',
|
||||
600: '10 min',
|
||||
1800: '30 min',
|
||||
3600: '1 hour'
|
||||
3600: '1 uur'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Configure Wi-Fi',
|
||||
title: 'Wifi',
|
||||
description: 'Configureer wifi',
|
||||
setBtn: 'Config'
|
||||
},
|
||||
disk: 'Virtual Disk',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
network: 'Virtual Network',
|
||||
networkDesc: 'Mount virtual network card on the remote host'
|
||||
disk: 'Virtuele schijf',
|
||||
diskDesc: 'Koppel virtuele U-schijf aan de externe host',
|
||||
network: 'Virtueel Netwerk',
|
||||
networkDesc: 'Koppel virtueel netwerk kaart aan de externe host'
|
||||
},
|
||||
tailscale: {
|
||||
title: 'Tailscale',
|
||||
memory: {
|
||||
title: 'Memory optimization',
|
||||
tip: "When memory usage exceeds the limit, garbage collection is performed more aggressively to attempt to free up memory. it's recommended to set to 50MB if using Tailscale. A Tailscale restart is required for the change to take effect.",
|
||||
disable: 'Disable'
|
||||
title: 'Geheugen optimalisatie',
|
||||
tip: "Wanneer geheugen gebruik de limiet overschreid, garbage collection wordt agressiever uitgevoerd om geheugen vrij te maken. geadviseerd om 50MB te kiezen als Tailscale wordt gebruikt. Tailscale moet worden herstart om de wijziging door te voeren.",
|
||||
disable: 'Uitschakelen'
|
||||
},
|
||||
restart: 'Are you sure to restart Tailscale?',
|
||||
stop: 'Are you sure to stop Tailscale?',
|
||||
stopDesc: 'Log out Tailscale and disable its automatic startup on boot.',
|
||||
restart: 'Weet u zeker dat u Tailscale opnieuw wilt opstarten?',
|
||||
stop: 'Weet u zeker dat u Tailscale wilt stoppen?',
|
||||
stopDesc: 'Meld Tailscale af en schakel het automatisch opstarten bij het opstarten uit.',
|
||||
loading: 'Laden...',
|
||||
notInstall: 'Tailscale niet gevonden! Installeer a.u.b.',
|
||||
install: 'Installeren',
|
||||
installing: 'Installeren',
|
||||
installing: 'Installeren bezig',
|
||||
failed: 'Installatie mislukt',
|
||||
retry: 'Vernieuw en probeer opnieuw. Of probeer handmatig te installeren',
|
||||
download: 'Download het',
|
||||
@@ -226,8 +225,8 @@ const nl = {
|
||||
account: 'Account',
|
||||
logout: 'Uitloggen',
|
||||
logout2: 'Weet u zeker dat u wilt uitloggen?',
|
||||
okBtn: 'Yes',
|
||||
cancelBtn: 'No'
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Nee'
|
||||
},
|
||||
update: {
|
||||
title: 'Controleren op updates',
|
||||
@@ -241,10 +240,10 @@ const nl = {
|
||||
},
|
||||
account: {
|
||||
title: 'Account',
|
||||
webAccount: 'Web Account Name',
|
||||
password: 'Password',
|
||||
webAccount: 'Web Account Naam',
|
||||
password: 'Wachtwoord',
|
||||
updateBtn: 'Update',
|
||||
logoutBtn: 'Logout'
|
||||
logoutBtn: 'Afmelden'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,7 @@ const ru = {
|
||||
placeholderPassword2: 'Введите пароль снова',
|
||||
noEmptyUsername: 'Имя пользователя не может быть пустым',
|
||||
noEmptyPassword: 'Пароль не может быть пустым',
|
||||
noAccount:
|
||||
'Не удалось получить информацию о пользователе, пожалуйста, обновите веб-страницу или сбросьте пароль',
|
||||
noAccount: 'Не удалось получить информацию о пользователе, пожалуйста, обновите веб-страницу или сбросьте пароль',
|
||||
invalidUser: 'Неверное имя пользователя или пароль',
|
||||
error: 'Непредвиденная ошибка',
|
||||
changePassword: 'Изменить пароль',
|
||||
@@ -48,7 +47,9 @@ const ru = {
|
||||
finishBtn: 'Готово'
|
||||
},
|
||||
screen: {
|
||||
title: 'Экран',
|
||||
video: 'Видеорежим',
|
||||
videoDirectTips: 'Включите HTTPS в "Настройки > Устройство", чтобы использовать этот режим',
|
||||
resolution: 'Разрешение',
|
||||
auto: 'Автоматическое',
|
||||
autoTips:
|
||||
@@ -66,6 +67,7 @@ const ru = {
|
||||
resetHdmi: 'Перезагрузить HDMI подсистему'
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Клавиатура',
|
||||
paste: 'Вставка текста',
|
||||
tips: 'Поддерживаются только стандартные буквы и символы клавиатуры',
|
||||
placeholder: 'Текст для ввода',
|
||||
@@ -74,6 +76,8 @@ const ru = {
|
||||
ctrlaltdel: 'Ctrl+Alt+Del'
|
||||
},
|
||||
mouse: {
|
||||
title: 'Мышь',
|
||||
cursor: 'Стиль курсора',
|
||||
default: 'Курсор по умолчанию',
|
||||
pointer: 'Курсор: указатель',
|
||||
cell: 'Курсор: крест',
|
||||
@@ -84,7 +88,16 @@ const ru = {
|
||||
absolute: 'Абсолютный режим',
|
||||
relative: 'Относительный режим',
|
||||
requestPointer: 'Используется относительный режим. Нажмите на рабочий стол для захвата мыши.',
|
||||
resetHid: 'Перезагрузить HID-подсистему'
|
||||
resetHid: 'Перезагрузить HID-подсистему',
|
||||
hidOnly: {
|
||||
title: 'Режим только HID',
|
||||
desc: 'Если мышь и клавиатура перестали отвечать, и перезагрузка HID-подсистемы не помогает, это может быть связано с несовместимостью между NanoKVM и устройством. Попробуйте включить режим только HID для улучшения совместимости.',
|
||||
tip1: 'Включение режима только HID отключит виртуальный USB-диск и виртуальную сеть',
|
||||
tip2: 'В режиме только HID отключена возможность монтирования образов',
|
||||
tip3: 'NanoKVM автоматически перезагрузится после переключения режима',
|
||||
enable: 'Включить режим только HID',
|
||||
disable: 'Выключить режим только HID'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Образы',
|
||||
@@ -94,6 +107,7 @@ const ru = {
|
||||
mountFailed: 'Монтирование образа не удалось',
|
||||
mountDesc:
|
||||
'В некоторых системах необходимо отсоединить виртуальный диск на удаленном хосте перед монтированием образа.',
|
||||
refresh: 'Обновить список образов',
|
||||
tips: {
|
||||
title: 'Как загрузить',
|
||||
usb1: 'Подключите NanoKVM к вашему компьютеру через USB.',
|
||||
@@ -130,6 +144,16 @@ const ru = {
|
||||
serialPort: 'COM-порт',
|
||||
serialPortPlaceholder: 'Введите COM-порт',
|
||||
baudrate: 'Скорость передачи',
|
||||
parity: 'Чётность',
|
||||
parityNone: 'Нет',
|
||||
parityEven: 'Чётная',
|
||||
parityOdd: 'Нечётная',
|
||||
flowControl: 'Управление потоком',
|
||||
flowControlNone: 'Нет',
|
||||
flowControlSoft: 'Программное',
|
||||
flowControlHard: 'Аппаратное',
|
||||
dataBits: 'Бит данных',
|
||||
stopBits: 'Стоп-бит',
|
||||
confirm: 'ОК'
|
||||
},
|
||||
wol: {
|
||||
@@ -147,10 +171,16 @@ const ru = {
|
||||
},
|
||||
power: {
|
||||
title: 'Питание',
|
||||
showConfirm: 'Подтверждение',
|
||||
showConfirmTip: 'Если включено, операции с питанием потребуют дополнительного подтверждения',
|
||||
reset: 'Экстренная перезагрузка',
|
||||
power: 'Питание',
|
||||
powerShort: 'Питание (короткое нажатие)',
|
||||
powerLong: 'Питание (длинное нажатие)'
|
||||
powerLong: 'Питание (длинное нажатие)',
|
||||
resetConfirm: 'Вы уверены, что хотите выполнить экстренную перезагрузку?',
|
||||
powerConfirm: 'Вы уверены, что хотите нажать кнопку питания?',
|
||||
okBtn: 'Да',
|
||||
cancelBtn: 'Нет'
|
||||
},
|
||||
settings: {
|
||||
title: 'Настройки',
|
||||
@@ -164,14 +194,23 @@ const ru = {
|
||||
image: 'Версия прошивки',
|
||||
imageTip: 'Версия системного образа NanoKVM',
|
||||
deviceKey: 'Ключ устройства',
|
||||
community: 'Сообщество'
|
||||
community: 'Сообщество',
|
||||
hostname: 'Имя хоста',
|
||||
hostnameUpdated: 'Имя хоста изменено. Перезагрузите NanoKVM, чтобы настройки вступили в силу.',
|
||||
ipType: {
|
||||
'Wired': 'Проводное',
|
||||
'Wireless': 'Беспроводное',
|
||||
'Other': 'Другое'
|
||||
}
|
||||
},
|
||||
appearance: {
|
||||
title: 'Внешний вид',
|
||||
display: 'Отображение',
|
||||
language: 'Язык',
|
||||
menuBar: 'Панель меню',
|
||||
menuBarDesc: 'Иконки для отображения в панели меню'
|
||||
menuBarDesc: 'Иконки для отображения в панели меню',
|
||||
webTitle: 'Заголовок страницы',
|
||||
webTitleDesc: 'Изменить заголовок страницы'
|
||||
},
|
||||
device: {
|
||||
title: 'Устройство',
|
||||
@@ -197,10 +236,37 @@ const ru = {
|
||||
description: 'Включить удаленный доступ по SSH',
|
||||
tip: 'Задайте сильный пароль перед включением (Аккаунт - Пароль - Обновить)'
|
||||
},
|
||||
tls: {
|
||||
description: 'Включить HTTPS Protocol',
|
||||
tip: 'Имейте в виду: использование HTTPS может увеличить задержку, особенно в режиме видео MJPEG.'
|
||||
},
|
||||
advanced: 'Дополнительные настройки',
|
||||
swap: {
|
||||
title: 'Файл подкачки',
|
||||
disable: 'Отключить',
|
||||
description: 'Задать размер файла подкачки',
|
||||
tip: 'Включение этой опции может негативно сказаться на долговечности SD-карты!',
|
||||
},
|
||||
mouseJiggler: {
|
||||
title: 'Дрожалка для мыши',
|
||||
description: 'Включите данную опцию, если хост засыпает',
|
||||
disable: 'Отключить',
|
||||
absolute: 'Абсолютный режим',
|
||||
relative: 'Относительный режим'
|
||||
},
|
||||
mdns: {
|
||||
description: 'Включить сервис обнаружения mDNS',
|
||||
tip: "Выключите, если нет необходимости"
|
||||
},
|
||||
hidOnly: 'Режим только HID',
|
||||
disk: 'Виртуальный диск',
|
||||
diskDesc: 'Смонтировать виртуальный USB диск на удаленном хосте',
|
||||
network: 'Виртуальная сеть',
|
||||
networkDesc: 'Смонтировать виртуальное сетевое устройство на удаленном хосте'
|
||||
networkDesc: 'Смонтировать виртуальное сетевое устройство на удаленном хосте',
|
||||
reboot: 'Перезагрузка',
|
||||
rebootDesc: 'Вы уверены, что хотите перезагрузить NanoKVM?',
|
||||
okBtn: 'Да',
|
||||
cancelBtn: 'Нет'
|
||||
},
|
||||
tailscale: {
|
||||
title: 'Tailscale',
|
||||
@@ -224,7 +290,8 @@ const ru = {
|
||||
upTailscale: 'Переместите tailscale в каталог /usr/bin/ на NanoKVM',
|
||||
upTailscaled: 'Переместите tailscaled в каталог /usr/sbin/ на NanoKVM',
|
||||
refresh: 'Обновите текущую страницу',
|
||||
notLogin: 'Устройство не привязано. Войдите, чтобы привязать его к аккаунту.',
|
||||
notLogin:
|
||||
'Устройство не привязано. Войдите, чтобы привязать его к аккаунту.',
|
||||
urlPeriod: 'Этот адрес действителен в течение 10 минут',
|
||||
login: 'Войти',
|
||||
loginSuccess: 'Вход выполнен',
|
||||
@@ -233,7 +300,7 @@ const ru = {
|
||||
deviceIP: 'IP адрес устройства',
|
||||
account: 'Аккаунт',
|
||||
logout: 'Выход',
|
||||
logout2: 'Вы действительно хотите выйти?',
|
||||
logoutDesc: 'Вы действительно хотите выйти?',
|
||||
uninstall: 'Удалить Tailscale',
|
||||
okBtn: 'Да',
|
||||
cancelBtn: 'Нет'
|
||||
@@ -246,19 +313,33 @@ const ru = {
|
||||
available: 'Доступно обновление. Вы уверены, что хотите обновить?',
|
||||
updating: 'Начато обновление. Пожалуйста, подождите...',
|
||||
confirm: 'Подтвердить',
|
||||
cancel: 'Отмена'
|
||||
cancel: 'Отмена',
|
||||
preview: 'Ранние обновления',
|
||||
previewDesc: 'Получайте ранний доступ к новым функциям и улучшениям',
|
||||
previewTip:
|
||||
'Обратите внимание: в ранних версиях могут быть ошибки или незавершённый функционал!'
|
||||
},
|
||||
account: {
|
||||
title: 'Аккаунт',
|
||||
webAccount: 'Имя веб-аккаунта',
|
||||
password: 'Пароль',
|
||||
updateBtn: 'Обновить',
|
||||
logoutBtn: 'Выйти'
|
||||
logoutBtn: 'Выйти',
|
||||
logoutDesc: 'Вы действительно хотите выйти?',
|
||||
okBtn: 'Да',
|
||||
cancelBtn: 'Нет'
|
||||
}
|
||||
},
|
||||
error: {
|
||||
title: "У нас возникла проблема",
|
||||
refresh: 'Обновить страницу'
|
||||
},
|
||||
fullscreen: {
|
||||
toggle: 'Полноэкранный режим'
|
||||
},
|
||||
menu: {
|
||||
collapse: 'Свернуть меню',
|
||||
expand: 'Развернуть меню'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,46 +10,46 @@ const uk = {
|
||||
auth: {
|
||||
login: 'Вхід',
|
||||
placeholderUsername: "Введіть ім'я користувача",
|
||||
placeholderPassword: 'введіть пароль',
|
||||
placeholderPassword2: 'введіть пароль ще раз',
|
||||
noEmptyUsername: "ім'я користувача не може бути порожнім",
|
||||
noEmptyPassword: 'пароль не може бути порожнім',
|
||||
noAccount:
|
||||
'Не вдалося отримати інформацію про користувача, оновіть веб-сторінку або скиньте пароль',
|
||||
invalidUser: "недійсне ім'я користувача або пароль",
|
||||
error: 'якась халепа! непередбачена помилка :(',
|
||||
placeholderPassword: 'Введіть пароль',
|
||||
placeholderPassword2: 'Введіть пароль ще раз',
|
||||
noEmptyUsername: "Ім'я користувача не може бути порожнім",
|
||||
noEmptyPassword: 'Пароль не може бути порожнім',
|
||||
noAccount: 'Не вдалося отримати інформацію про користувача, оновіть веб-сторінку або скиньте пароль',
|
||||
invalidUser: "Недійсне ім'я користувача або пароль",
|
||||
error: 'Якась халепа! Непередбачена помилка :(',
|
||||
changePassword: 'Змінити пароль',
|
||||
changePasswordDesc:
|
||||
'Для безпеки вашого пристрою, будь ласка, змініть пароль для входу в веб-інтерфейс.',
|
||||
differentPassword: 'паролі не збігаються',
|
||||
illegalUsername: "ім'я користувача містить недопустимі символи",
|
||||
illegalPassword: 'пароль містить недопустимі символи',
|
||||
changePasswordDesc: 'Для безпеки вашого пристрою, будь ласка, змініть пароль для входу в веб-інтерфейс.',
|
||||
differentPassword: 'Паролі не збігаються',
|
||||
illegalUsername: "Ім'я користувача містить недопустимі символи",
|
||||
illegalPassword: 'Пароль містить недопустимі символи',
|
||||
forgetPassword: 'Забули пароль',
|
||||
ok: 'Ок',
|
||||
cancel: 'Скасувати',
|
||||
loginButtonText: 'Увійти',
|
||||
tips: {
|
||||
reset1:
|
||||
'To reset the passwords, pressing and holding the BOOT button on the NanoKVM for 10 seconds.',
|
||||
reset2: 'For detailed steps, please consult this document:',
|
||||
reset3: 'Web default account:',
|
||||
reset4: 'SSH default account:',
|
||||
change1: 'Please note that this action will change the following passwords:',
|
||||
change2: 'Web login password',
|
||||
change3: 'System root password (SSH login password)',
|
||||
change4: 'To reset the passwords, press and hold the BOOT button on the NanoKVM.'
|
||||
'Щоб скинути паролі, натискаючи та тримаючи кнопку завантаження на Nanokvm протягом 10 секунд.',
|
||||
reset2: 'Щоб отримати детальні кроки, зверніться до цього документа:',
|
||||
reset3: 'Обліковий запис вебінтерфейс за замовчуванням:',
|
||||
reset4: 'Обліковий запис SSH за замовчуванням:',
|
||||
change1: 'Зверніть увагу, що ця дія змінить наступні паролі:',
|
||||
change2: 'Пароль для входу у веб-інтерфейс',
|
||||
change3: 'Системний root пароль (пароль для входу в систему по SSH)',
|
||||
change4: 'Щоб скинути паролі, натисніть та утримуйте кнопку "Завантаження" на Nanokvm.'
|
||||
}
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Configure Wi-Fi for NanoKVM',
|
||||
success: 'Please check the network status of NanoKVM and visit the new IP address.',
|
||||
failed: 'Operation failed, please try again.',
|
||||
confirmBtn: 'Ok',
|
||||
finishBtn: 'Finished'
|
||||
description: 'Налаштування Wi-Fi для Nanokvm',
|
||||
success: 'Будь ласка, перевірте статус мережі NANOKVM та відвідайте нову IP -адресу.',
|
||||
failed: 'Операція не вдалася, спробуйте ще раз.',
|
||||
confirmBtn: 'Так',
|
||||
finishBtn: 'Готово'
|
||||
},
|
||||
screen: {
|
||||
title: 'Екран',
|
||||
video: 'Відеорежим',
|
||||
videoDirectTips: 'Увімкніть HTTPS у "Налаштування > Пристрій", щоб використовувати цей режим',
|
||||
resolution: 'Роздільна здатність',
|
||||
auto: 'Автоматично',
|
||||
autoTips:
|
||||
@@ -64,9 +64,10 @@ const uk = {
|
||||
frameDetect: 'Виявлення кадрів',
|
||||
frameDetectTip:
|
||||
'Обчислює різницю між кадрами. Зупиняє передачу відеопотоку, коли на екрані віддаленого хоста не виявлено змін.',
|
||||
resetHdmi: 'Reset HDMI'
|
||||
resetHdmi: 'Перезавантажити HDMI підсистему'
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Клавіатура',
|
||||
paste: 'Вставити',
|
||||
tips: 'Підтримуються лише стандартні літери та символи клавіатури',
|
||||
placeholder: 'Будь ласка, введіть',
|
||||
@@ -75,6 +76,8 @@ const uk = {
|
||||
ctrlaltdel: 'Ctrl+Alt+Del'
|
||||
},
|
||||
mouse: {
|
||||
title: 'Миша',
|
||||
cursor: 'Стиль курсору',
|
||||
default: 'Курсор за замовчуванням',
|
||||
pointer: 'Курсор-стрілка',
|
||||
cell: 'Курсор-таблиця',
|
||||
@@ -84,17 +87,27 @@ const uk = {
|
||||
mode: 'Режим миші',
|
||||
absolute: 'Абсолютний режим',
|
||||
relative: 'Відносний режим',
|
||||
requestPointer:
|
||||
'Використовується відносний режим. Будь ласка, натисніть на робочий стіл, щоб отримати курсор миші.',
|
||||
resetHid: 'Скинути HID'
|
||||
requestPointer: 'Використовується відносний режим. Будь ласка, натисніть на робочий стіл, щоб отримати курсор миші.',
|
||||
resetHid: 'Скинути HID',
|
||||
hidOnly: {
|
||||
title: 'Режим лише HID',
|
||||
desc: 'Якщо ваша миша та клавіатура перестають відповідати та скинути HID, це не допомагає, це може бути проблемою сумісності між NanoKVM та пристроєм. Спробуйте ввімкнути режим "лише HID" лише для кращої сумісності.',
|
||||
tip1: 'Увімкнення режиму "лише HID" демонтує віртуальний U-диск і віртуальну мережу',
|
||||
tip2: 'У режимі "лише HID" монтування образів буде вимкнено',
|
||||
tip3: 'NANOKVM автоматично перезавантажиться після перемикання режимів',
|
||||
enable: 'Увімкнути режим "лише HID"',
|
||||
disable: 'Ввимкнути режим "лише HID"'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Зображення',
|
||||
title: 'Образи',
|
||||
loading: 'Завантаження...',
|
||||
empty: 'Нічого не знайдено',
|
||||
mountFailed: 'Не вдалося змонтувати',
|
||||
cdrom: 'Встановіть образ в режимі CD-ROM',
|
||||
mountFailed: 'Не вдалося змонтувати образ у режимі CD-ROM',
|
||||
mountDesc:
|
||||
'У деяких системах необхідно витягнути віртуальний диск на віддаленому хості перед монтуванням файлу образа.',
|
||||
refresh: 'Оновіть список образів',
|
||||
tips: {
|
||||
title: 'Як завантажити',
|
||||
usb1: "Під'єднайте NanoKVM до вашого комп'ютера через USB.",
|
||||
@@ -112,7 +125,7 @@ const uk = {
|
||||
}
|
||||
},
|
||||
script: {
|
||||
title: 'Скрипт',
|
||||
title: 'Скрипти',
|
||||
upload: 'Завантажити',
|
||||
run: 'Запустити',
|
||||
runBackground: 'Запустити у фоновому режимі',
|
||||
@@ -131,6 +144,16 @@ const uk = {
|
||||
serialPort: 'Послідовний порт',
|
||||
serialPortPlaceholder: 'Будь ласка, введіть послідовний порт',
|
||||
baudrate: 'Швидкість передачі',
|
||||
parity: 'Парність',
|
||||
parityNone: 'Без парності',
|
||||
parityEven: 'Парний',
|
||||
parityOdd: 'Непарний',
|
||||
flowControl: 'Керування потоком',
|
||||
flowControlNone: 'Без керування',
|
||||
flowControlSoft: "М'яке керування",
|
||||
flowControlHard: 'Жорстке керування',
|
||||
dataBits: 'Біти даних',
|
||||
stopBits: 'Стопові біти',
|
||||
confirm: 'Ок'
|
||||
},
|
||||
wol: {
|
||||
@@ -140,71 +163,123 @@ const uk = {
|
||||
input: 'Будь ласка, введіть MAC',
|
||||
ok: 'Ок'
|
||||
},
|
||||
download: {
|
||||
title: 'Завантажувач образів',
|
||||
input: 'Введіть URL-адресу віддаленого образу',
|
||||
ok: 'Так',
|
||||
disabled: 'Розділ даних /data у режимі лише для читання, тому ми не можемо завантажити образ'
|
||||
},
|
||||
power: {
|
||||
title: 'Живлення',
|
||||
showConfirm: 'Підтвердження',
|
||||
showConfirmTip: 'Енергетичні операції потребують додаткового підтвердження',
|
||||
reset: 'Скидання',
|
||||
power: 'Живлення',
|
||||
powerShort: 'Живлення (коротке натискання)',
|
||||
powerLong: 'Живлення (довге натискання)'
|
||||
powerLong: 'Живлення (довге натискання)',
|
||||
resetConfirm: 'Продовжити роботу зі скиданням?',
|
||||
powerConfirm: 'Продовжувати роботу живлення?',
|
||||
okBtn: 'Так',
|
||||
cancelBtn: 'Ні'
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
title: 'Налаштування',
|
||||
about: {
|
||||
title: 'Про NanoKVM',
|
||||
information: 'Інформація',
|
||||
ip: 'IP',
|
||||
mdns: 'mDNS',
|
||||
application: 'Версія додатку',
|
||||
applicationTip: 'NanoKVM web application version',
|
||||
application: 'Версія додатка',
|
||||
applicationTip: 'Версія веб-додатка NanoKVM',
|
||||
image: 'Версія образу',
|
||||
imageTip: 'NanoKVM system image version',
|
||||
imageTip: 'Версія системного образу NanoKVM',
|
||||
deviceKey: 'Ключ пристрою',
|
||||
community: 'Спільнота'
|
||||
community: 'Спільнота',
|
||||
hostname: "Ім'я вузла",
|
||||
hostnameUpdated: "Ім'я вузла оновлено. Перезавантажте систему, щоб застосувати зміни.",
|
||||
ipType: {
|
||||
Wired: 'Дротове',
|
||||
Wireless: 'Бездротове',
|
||||
Other: 'Інший'
|
||||
}
|
||||
},
|
||||
appearance: {
|
||||
title: 'Appearance',
|
||||
display: 'Display',
|
||||
language: 'Language',
|
||||
menuBar: 'Menu Bar',
|
||||
menuBarDesc: 'Display icons in the menu bar'
|
||||
title: 'Зовнішній вигляд',
|
||||
display: 'Відображення',
|
||||
language: 'Мова',
|
||||
menuBar: 'Панель меню',
|
||||
menuBarDesc: 'Піктограми відображення в панелі меню',
|
||||
webTitle: 'Веб-назва',
|
||||
webTitleDesc: 'Налаштуйте назву веб-сторінки'
|
||||
},
|
||||
device: {
|
||||
title: 'Device',
|
||||
title: 'Пристрій',
|
||||
oled: {
|
||||
title: 'OLED',
|
||||
description: 'OLED screen automatically sleep',
|
||||
0: 'Never',
|
||||
15: '15 sec',
|
||||
30: '30 sec',
|
||||
60: '1 min',
|
||||
180: '3 min',
|
||||
300: '5 min',
|
||||
600: '10 min',
|
||||
1800: '30 min',
|
||||
3600: '1 hour'
|
||||
title: 'OLED-екран',
|
||||
description: 'Автоматичне відключення OLED-екрана',
|
||||
0: 'Ніколи',
|
||||
15: '15 сек',
|
||||
30: '30 сек',
|
||||
60: '1 хв',
|
||||
180: '3 хв',
|
||||
300: '5 хв',
|
||||
600: '10 хв',
|
||||
1800: '30 хв',
|
||||
3600: '1 година'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Configure Wi-Fi',
|
||||
setBtn: 'Config'
|
||||
description: 'Налаштування Wi-Fi',
|
||||
setBtn: 'Конфігурація'
|
||||
},
|
||||
disk: 'Virtual Disk',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
network: 'Virtual Network',
|
||||
networkDesc: 'Mount virtual network card on the remote host'
|
||||
ssh: {
|
||||
description: 'Увімкніть віддалений доступ по SSH',
|
||||
tip: 'Перед ввімкненням встановіть надійний пароль (Обліковий запис - Зміна пароля)'
|
||||
},
|
||||
tls: {
|
||||
description: 'Увімкнути протокол HTTPS',
|
||||
tip: 'Будьте в курсі: Використання HTTPS може збільшити затримку, особливо в режимі відео MJPEG.'
|
||||
},
|
||||
advanced: 'Розширені налаштування',
|
||||
swap: {
|
||||
title: 'Swap (файл підкачки)',
|
||||
disable: 'Відключено',
|
||||
description: 'Встановіть розмір файлу свопу',
|
||||
tip: "Увімкнення цієї опції може вивести з ладу SD карту!"
|
||||
},
|
||||
mouseJiggler: {
|
||||
title: 'Налаштування пацюка',
|
||||
description: 'Не дозволяйте віддаленому хосту спати',
|
||||
disable: 'Відключено',
|
||||
absolute: 'Абсолютний режим',
|
||||
relative: 'Відносний режим'
|
||||
},
|
||||
mdns: {
|
||||
description: 'Увімкнути службу MDNS Discovery',
|
||||
tip: "Вимкнути, якщо це не потрібно"
|
||||
},
|
||||
hidOnly: 'Режим лише HID',
|
||||
disk: 'Віртуальний диск',
|
||||
diskDesc: 'Монтувати віртуальний U-диск на віддаленому хості',
|
||||
network: 'Віртуальна мережа',
|
||||
networkDesc: 'Встановити віртуальну мережеву карту на віддаленому хості',
|
||||
reboot: 'Перезавантажити',
|
||||
rebootDesc: 'Ви впевнені, що хочете перезавантажити Nanokvm?',
|
||||
okBtn: 'Так',
|
||||
cancelBtn: 'Ні'
|
||||
},
|
||||
tailscale: {
|
||||
title: 'Tailscale',
|
||||
memory: {
|
||||
title: 'Memory optimization',
|
||||
tip: "When memory usage exceeds the limit, garbage collection is performed more aggressively to attempt to free up memory. it's recommended to set to 50MB if using Tailscale. A Tailscale restart is required for the change to take effect.",
|
||||
disable: 'Disable'
|
||||
title: "Оптимізація пам'яті",
|
||||
tip: "Коли використання пам’яті перевищує обмеження, збирання сміття проводиться більш агресивно, щоб спробувати звільнити пам’ять. Рекомендується встановити 50 Мб, якщо використовувати Tailscale. Після зміни налаштувань потрібно перезавантажити Tailscale.",
|
||||
disable: 'Відключено'
|
||||
},
|
||||
restart: 'Are you sure to restart Tailscale?',
|
||||
stop: 'Are you sure to stop Tailscale?',
|
||||
stopDesc: 'Log out Tailscale and disable its automatic startup on boot.',
|
||||
restart: 'Ви впевнені, що бажаєте перезавантажити Tailscale?',
|
||||
stop: 'Ви впевнені, що бажаєте зупинити Tailscale?',
|
||||
stopDesc: 'Вийдіть з Tailscale і вимкніть автоматичний запуск при завантаженні.',
|
||||
loading: 'Завантаження...',
|
||||
notInstall: 'Tailscale не знайдено! Будь ласка, встановіть клієнт Tailscale.',
|
||||
notInstall: 'Tailscale не знайдено! Будь ласка, встановіть його.',
|
||||
install: 'Встановити',
|
||||
installing: 'Встановлення',
|
||||
failed: 'Не вдалося встановити',
|
||||
@@ -212,8 +287,8 @@ const uk = {
|
||||
download: 'Завантажте',
|
||||
package: 'пакет встановлення',
|
||||
unzip: 'та розпакуйте його',
|
||||
upTailscale: 'Завантажте tailscale до каталогу /usr/bin/ на NanoKVM',
|
||||
upTailscaled: 'Завантажте tailscaled до каталогу /usr/sbin/ на NanoKVM',
|
||||
upTailscale: 'Завантажте Tailscale до каталогу /usr/bin/ на NanoKVM',
|
||||
upTailscaled: 'Завантажте Tailscaled до каталогу /usr/sbin/ на NanoKVM',
|
||||
refresh: 'Оновіть поточну сторінку',
|
||||
notLogin:
|
||||
"Пристрій ще не прив'язаний. Будь ласка, увійдіть і прив'яжіть цей пристрій до вашого облікового запису.",
|
||||
@@ -225,9 +300,10 @@ const uk = {
|
||||
deviceIP: 'IP пристрою',
|
||||
account: 'Обліковий запис',
|
||||
logout: 'Вийти',
|
||||
logout2: 'Ви впевнені, що хочете вийти?',
|
||||
okBtn: 'Yes',
|
||||
cancelBtn: 'No'
|
||||
logoutDesc: 'Ви впевнені, що хочете вийти?',
|
||||
uninstall: 'Видалити Tailscale',
|
||||
okBtn: 'Так',
|
||||
cancelBtn: 'Ні'
|
||||
},
|
||||
update: {
|
||||
title: 'Перевірити оновлення',
|
||||
@@ -237,15 +313,33 @@ const uk = {
|
||||
available: 'Доступне оновлення. Ви впевнені, що хочете оновити?',
|
||||
updating: 'Оновлення розпочато. Будь ласка, зачекайте...',
|
||||
confirm: 'Підтвердити',
|
||||
cancel: 'Скасувати'
|
||||
cancel: 'Скасувати',
|
||||
preview: 'О бета релізах',
|
||||
previewDesc: 'Отримайте ранній доступ до нових функцій та вдосконалень',
|
||||
previewTip:
|
||||
'Будь ласка, майте на увазі, що випуски бета релізів можуть містити помилки або неповну функціональність!'
|
||||
},
|
||||
account: {
|
||||
title: 'Account',
|
||||
webAccount: 'Web Account Name',
|
||||
password: 'Password',
|
||||
updateBtn: 'Update',
|
||||
logoutBtn: 'Logout'
|
||||
title: 'Обліковий запис',
|
||||
webAccount: "Ім'я облыкового запису у веб-інтерфейсі",
|
||||
password: 'Пароль',
|
||||
updateBtn: 'Зміна',
|
||||
logoutBtn: 'Вийти',
|
||||
logoutDesc: 'Ви впевнені, що хочете вийти?',
|
||||
okBtn: 'Так',
|
||||
cancelBtn: 'Ні'
|
||||
}
|
||||
},
|
||||
error: {
|
||||
title: "Ми зіткнулися з проблемою",
|
||||
refresh: 'Оновити'
|
||||
},
|
||||
fullscreen: {
|
||||
toggle: 'Перемикатися на повному екрані'
|
||||
},
|
||||
menu: {
|
||||
collapse: 'Згорнути меню',
|
||||
expand: 'Розгорнути меню'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -46,7 +46,9 @@ const zh = {
|
||||
finishBtn: '完成'
|
||||
},
|
||||
screen: {
|
||||
title: '屏幕',
|
||||
video: '视频模式',
|
||||
videoDirectTips: '该模式需启用 HTTPS,请前往「设置 - 设备」中开启',
|
||||
resolution: '分辨率',
|
||||
auto: '自动',
|
||||
autoTips:
|
||||
@@ -63,6 +65,7 @@ const zh = {
|
||||
resetHdmi: '重置 HDMI'
|
||||
},
|
||||
keyboard: {
|
||||
title: '键盘',
|
||||
paste: '粘贴',
|
||||
tips: '仅支持标准键盘的字母和符号',
|
||||
placeholder: '请输入内容',
|
||||
@@ -82,6 +85,9 @@ const zh = {
|
||||
mode: '鼠标模式',
|
||||
absolute: '绝对模式',
|
||||
relative: '相对模式',
|
||||
speed: '滚轮速度',
|
||||
fast: '快',
|
||||
slow: '慢',
|
||||
requestPointer: '正在使用鼠标相对模式,请点击桌面获取鼠标指针。',
|
||||
resetHid: '重置 HID',
|
||||
hidOnly: {
|
||||
@@ -214,6 +220,10 @@ const zh = {
|
||||
description: '启用 SSH 远程访问',
|
||||
tip: '启用前请务必设置强密码(帐号 - 修改密码)'
|
||||
},
|
||||
tls: {
|
||||
description: '启用 HTTPS 协议',
|
||||
tip: '注意:使用 HTTPS 可能导致延迟增加,特别是在 MJPEG 视频模式下。'
|
||||
},
|
||||
advanced: '高级设置',
|
||||
swap: {
|
||||
disable: '禁用',
|
||||
@@ -231,6 +241,9 @@ const zh = {
|
||||
description: '启用 mDNS 发现服务',
|
||||
tip: '如果您未使用此功能,建议将其关闭'
|
||||
},
|
||||
hdmi: {
|
||||
description: '启用 HDMI/显示器 输出功能'
|
||||
},
|
||||
hidOnly: 'HID-Only 模式',
|
||||
disk: '虚拟U盘',
|
||||
diskDesc: '在远程主机中挂载虚拟U盘',
|
||||
@@ -272,6 +285,8 @@ const zh = {
|
||||
logout: '退出',
|
||||
logoutDesc: '确定要退出吗?',
|
||||
uninstall: '卸载 Tailscale',
|
||||
reboot: '重启',
|
||||
rebootDesc: '你确定要重启 NanoKVM 吗?',
|
||||
okBtn: '确认',
|
||||
cancelBtn: '取消'
|
||||
},
|
||||
|
||||
@@ -6,5 +6,8 @@ export const mouseStyleAtom = atom('cursor-default');
|
||||
// mouse mode: absolute or relative
|
||||
export const mouseModeAtom = atom('absolute');
|
||||
|
||||
// mouse scroll interval (unit: ms)
|
||||
export const scrollIntervalAtom = atom(0);
|
||||
|
||||
// hid mode: normal or hid-only
|
||||
export const hidModeAtom = atom<'normal' | 'hid-only'>('normal');
|
||||
|
||||
@@ -2,7 +2,12 @@ import { atom } from 'jotai';
|
||||
|
||||
import { Resolution } from '@/types';
|
||||
|
||||
// video mode: h264 or mjpeg
|
||||
export const isHdmiEnabledAtom = atom(true);
|
||||
|
||||
// video mode
|
||||
// direct: stream H.264 over HTTP
|
||||
// h264: stream H.264 over WebRTC
|
||||
// mjpeg: stream JPEG over HTTP
|
||||
export const videoModeAtom = atom('');
|
||||
|
||||
// browser screen resolution
|
||||
|
||||
@@ -9,8 +9,10 @@ const GOP_KEY = 'nano-kvm-gop';
|
||||
const FRAME_DETECT_KEY = 'nano-kvm-frame-detect';
|
||||
const MOUSE_STYLE_KEY = 'nano-kvm-mouse-style';
|
||||
const MOUSE_MODE_KEY = 'nano-kvm-mouse-mode';
|
||||
const MOUSE_SCROLL_INTERVAL_KEY = 'nanokvm-kvm-mouse-scroll-interval';
|
||||
const SKIP_UPDATE_KEY = 'nano-kvm-check-update';
|
||||
const KEYBOARD_LAYOUT_KEY = 'nano-kvm-keyboard-layout';
|
||||
const KEYBOARD_SYSTEM_KEY = 'nano-kvm-keyboard-system';
|
||||
const KEYBOARD_LANGUAGE_KEY = 'nano-kvm-keyboard-language';
|
||||
const SKIP_MODIFY_PASSWORD_KEY = 'nano-kvm-skip-modify-password';
|
||||
const MENU_DISABLED_ITEMS_KEY = 'nano-kvm-menu-disabled-items';
|
||||
const POWER_CONFIRM_KEY = 'nano-kvm-power-confirm';
|
||||
@@ -106,7 +108,7 @@ export function setGop(gop: number) {
|
||||
|
||||
export function getFrameDetect(): boolean {
|
||||
const enabled = localStorage.getItem(FRAME_DETECT_KEY);
|
||||
return enabled ? Boolean(enabled) : true;
|
||||
return enabled === 'true';
|
||||
}
|
||||
|
||||
export function setFrameDetect(enabled: boolean) {
|
||||
@@ -129,9 +131,18 @@ export function setMouseMode(mouse: string) {
|
||||
localStorage.setItem(MOUSE_MODE_KEY, mouse);
|
||||
}
|
||||
|
||||
export function getMouseScrollInterval() {
|
||||
const interval = localStorage.getItem(MOUSE_SCROLL_INTERVAL_KEY);
|
||||
return interval ? Number(interval) : null;
|
||||
}
|
||||
|
||||
export function setMouseScrollInterval(interval: number): void {
|
||||
localStorage.setItem(MOUSE_SCROLL_INTERVAL_KEY, String(interval));
|
||||
}
|
||||
|
||||
export function getSkipUpdate() {
|
||||
const skip = getWithExpiry(SKIP_UPDATE_KEY);
|
||||
return skip ? Boolean(skip) : false;
|
||||
return skip === 'true';
|
||||
}
|
||||
|
||||
export function setSkipUpdate(skip: boolean) {
|
||||
@@ -139,12 +150,20 @@ export function setSkipUpdate(skip: boolean) {
|
||||
setWithExpiry(SKIP_UPDATE_KEY, String(skip), expiry);
|
||||
}
|
||||
|
||||
export function setKeyboardLayout(layout: string) {
|
||||
localStorage.setItem(KEYBOARD_LAYOUT_KEY, layout);
|
||||
export function setKeyboardSystem(system: string) {
|
||||
localStorage.setItem(KEYBOARD_SYSTEM_KEY, system);
|
||||
}
|
||||
|
||||
export function getKeyboardLayout() {
|
||||
return localStorage.getItem(KEYBOARD_LAYOUT_KEY);
|
||||
export function getKeyboardSystem() {
|
||||
return localStorage.getItem(KEYBOARD_SYSTEM_KEY);
|
||||
}
|
||||
|
||||
export function setKeyboardLanguage(language: string) {
|
||||
localStorage.setItem(KEYBOARD_LANGUAGE_KEY, language);
|
||||
}
|
||||
|
||||
export function getKeyboardLanguage() {
|
||||
return localStorage.getItem(KEYBOARD_LANGUAGE_KEY);
|
||||
}
|
||||
|
||||
export function setSkipModifyPassword(skip: boolean) {
|
||||
@@ -154,7 +173,7 @@ export function setSkipModifyPassword(skip: boolean) {
|
||||
|
||||
export function getSkipModifyPassword() {
|
||||
const skip = getWithExpiry(SKIP_MODIFY_PASSWORD_KEY);
|
||||
return skip ? Boolean(skip) : false;
|
||||
return skip === 'true';
|
||||
}
|
||||
|
||||
export function setMenuDisabledItems(items: string[]) {
|
||||
@@ -169,7 +188,7 @@ export function getMenuDisabledItems(): string[] {
|
||||
|
||||
export function getPowerConfirm() {
|
||||
const enabled = localStorage.getItem(POWER_CONFIRM_KEY);
|
||||
return enabled ? Boolean(enabled) : false;
|
||||
return enabled === 'true';
|
||||
}
|
||||
|
||||
export function setPowerConfirm(enabled: boolean) {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
import { getWebTitle } from '@/api/vm.ts';
|
||||
import { getResolution, getVideoMode } from '@/lib/localstorage.ts';
|
||||
import * as storage from '@/lib/localstorage.ts';
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||
import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts';
|
||||
import { webTitleAtom } from '@/jotai/settings.ts';
|
||||
import { Head } from '@/components/head.tsx';
|
||||
|
||||
import { Keyboard } from './keyboard';
|
||||
@@ -25,20 +23,13 @@ export const Desktop = () => {
|
||||
const [videoMode, setVideoMode] = useAtom(videoModeAtom);
|
||||
const [resolution, setResolution] = useAtom(resolutionAtom);
|
||||
const isKeyboardEnable = useAtomValue(isKeyboardEnableAtom);
|
||||
const setWebTitle = useSetAtom(webTitleAtom);
|
||||
|
||||
useEffect(() => {
|
||||
const cookieVideoMode = getVideoMode();
|
||||
setVideoMode(cookieVideoMode ? cookieVideoMode : window.RTCPeerConnection ? 'h264' : 'mjpeg');
|
||||
const mode = getVideoMode();
|
||||
setVideoMode(mode);
|
||||
|
||||
const cookieResolution = getResolution();
|
||||
setResolution(cookieResolution ? cookieResolution : { width: 0, height: 0 });
|
||||
|
||||
getWebTitle().then((rsp) => {
|
||||
if (rsp.data?.title) {
|
||||
setWebTitle(rsp.data.title);
|
||||
}
|
||||
});
|
||||
const res = storage.getResolution() || { width: 0, height: 0 };
|
||||
setResolution(res);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
client.send([0]);
|
||||
@@ -51,6 +42,20 @@ export const Desktop = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
function getVideoMode() {
|
||||
const defaultVideoMode = window.RTCPeerConnection ? 'h264' : 'mjpeg';
|
||||
|
||||
const cookieVideoMode = storage.getVideoMode();
|
||||
if (cookieVideoMode) {
|
||||
if (cookieVideoMode === 'direct' && !window.VideoDecoder) {
|
||||
return defaultVideoMode;
|
||||
}
|
||||
return cookieVideoMode;
|
||||
}
|
||||
|
||||
return defaultVideoMode;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title={t('head.desktop')} />
|
||||
|
||||
@@ -26,6 +26,42 @@ export const KeyboardCodes: Map<string, number> = new Map([
|
||||
['KeyY', 28],
|
||||
['KeyZ', 29],
|
||||
|
||||
['RusA', 4],
|
||||
['RusB', 5],
|
||||
['RusC', 6],
|
||||
['RusD', 7],
|
||||
['RusE', 8],
|
||||
['RusF', 9],
|
||||
['RusG', 10],
|
||||
['RusH', 11],
|
||||
['RusI', 12],
|
||||
['RusJ', 13],
|
||||
['RusK', 14],
|
||||
['RusL', 15],
|
||||
['RusM', 16],
|
||||
['RusN', 17],
|
||||
['RusO', 18],
|
||||
['RusP', 19],
|
||||
['RusQ', 20],
|
||||
['RusR', 21],
|
||||
['RusS', 22],
|
||||
['RusT', 23],
|
||||
['RusU', 24],
|
||||
['RusV', 25],
|
||||
['RusW', 26],
|
||||
['RusX', 27],
|
||||
['RusY', 28],
|
||||
['RusZ', 29],
|
||||
|
||||
['RusBracketLeft', 47],
|
||||
['RusBracketRight', 48],
|
||||
['RusBackslash', 49],
|
||||
['RusSemicolon', 51],
|
||||
['RusQuote', 52],
|
||||
['RusComma', 54],
|
||||
['RusPeriod', 55],
|
||||
['RusSlash', 56],
|
||||
|
||||
['Digit1', 30],
|
||||
['Digit2', 31],
|
||||
['Digit3', 32],
|
||||
@@ -48,6 +84,7 @@ export const KeyboardCodes: Map<string, number> = new Map([
|
||||
['BracketRight', 48],
|
||||
['Backslash', 49],
|
||||
['IntlBackslash', 49],
|
||||
['Backquote_azerty', 100],
|
||||
|
||||
['Semicolon', 51],
|
||||
['Quote', 52],
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AppleOutlined, WindowsOutlined } from '@ant-design/icons';
|
||||
import { ConfigProvider, Segmented, theme } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom } from 'jotai';
|
||||
import { XIcon } from 'lucide-react';
|
||||
@@ -10,9 +9,10 @@ import { Drawer } from 'vaul';
|
||||
import 'react-simple-keyboard/build/css/index.css';
|
||||
import '@/assets/styles/keyboard.css';
|
||||
|
||||
import { ConfigProvider, Segmented, Select, theme } from 'antd';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
import { getKeyboardLayout, setKeyboardLayout } from '@/lib/localstorage.ts';
|
||||
import * as storage from '@/lib/localstorage.ts';
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts';
|
||||
|
||||
@@ -31,18 +31,56 @@ export const VirtualKeyboard = () => {
|
||||
|
||||
const [isKeyboardOpen, setIsKeyboardOpen] = useAtom(isKeyboardOpenAtom);
|
||||
|
||||
const [layout, setLayout] = useState('default');
|
||||
const [keyboardLayout, setKeyboardLayout] = useState('default');
|
||||
const [keyboardSystem, setKeyboardSystem] = useState('win');
|
||||
const [keyboardLanguage, setKeyboardLanguage] = useState('en');
|
||||
const [activeModifierKeys, setActiveModifierKeys] = useState<string[]>([]);
|
||||
|
||||
const keyboardRef = useRef<any>(null);
|
||||
|
||||
const systems = [
|
||||
{ value: 'win', icon: <WindowsOutlined /> },
|
||||
{ value: 'mac', icon: <AppleOutlined /> }
|
||||
];
|
||||
|
||||
const languages = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'French' },
|
||||
{ value: 'ru', label: 'Russian' }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const keyboardLayout = getKeyboardLayout();
|
||||
if (keyboardLayout && ['default', 'mac'].includes(keyboardLayout)) {
|
||||
setLayout(keyboardLayout);
|
||||
const system = storage.getKeyboardSystem();
|
||||
if (system && ['win', 'mac'].includes(system)) {
|
||||
setKeyboardSystem(system);
|
||||
}
|
||||
|
||||
const language = storage.getKeyboardLanguage();
|
||||
if (language && languages.some((lng) => lng.value === language)) {
|
||||
setKeyboardLanguage(language);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const layoutMap = new Map([
|
||||
['en', 'default'],
|
||||
['ru', 'rus'],
|
||||
['fr', 'azerty']
|
||||
]);
|
||||
|
||||
if (keyboardLanguage === 'en' && keyboardSystem === 'mac') {
|
||||
setKeyboardLayout('mac');
|
||||
return;
|
||||
}
|
||||
|
||||
if (layoutMap.has(keyboardLanguage)) {
|
||||
setKeyboardLayout(layoutMap.get(keyboardLanguage)!);
|
||||
return;
|
||||
}
|
||||
|
||||
setKeyboardLayout('default');
|
||||
}, [keyboardSystem, keyboardLanguage]);
|
||||
|
||||
function onKeyPress(key: string) {
|
||||
if (modifierKeys.includes(key)) {
|
||||
if (activeModifierKeys.includes(key)) {
|
||||
@@ -66,8 +104,7 @@ export const VirtualKeyboard = () => {
|
||||
}
|
||||
|
||||
function sendKeydown(key: string) {
|
||||
const specialKey = specialKeyMap.get(key);
|
||||
const code = KeyboardCodes.get(specialKey ? specialKey : key);
|
||||
const code = getKeyCode(key);
|
||||
if (!code) {
|
||||
console.log('unknown code: ', key);
|
||||
return;
|
||||
@@ -78,6 +115,26 @@ export const VirtualKeyboard = () => {
|
||||
client.send([1, code, ...modifiers]);
|
||||
}
|
||||
|
||||
function getKeyCode(key: string) {
|
||||
// AZERTY: swap A↔Q and Z↔W on French physical positions
|
||||
if (keyboardLanguage === 'fr' && key.endsWith('_azerty')) {
|
||||
const base = key.replace('_azerty', '');
|
||||
if (base === 'KeyA') return KeyboardCodes.get('KeyQ');
|
||||
if (base === 'KeyQ') return KeyboardCodes.get('KeyA');
|
||||
if (base === 'KeyZ') return KeyboardCodes.get('KeyW');
|
||||
if (base === 'KeyW') return KeyboardCodes.get('KeyZ');
|
||||
// all other labels use their own code
|
||||
return KeyboardCodes.get(base);
|
||||
}
|
||||
|
||||
const specialKey = specialKeyMap.get(key);
|
||||
if (specialKey) {
|
||||
return KeyboardCodes.get(specialKey);
|
||||
}
|
||||
|
||||
return KeyboardCodes.get(key);
|
||||
}
|
||||
|
||||
function sendKeyup() {
|
||||
sendModifierKeyUp();
|
||||
client.send([1, 0, 0, 0, 0, 0]);
|
||||
@@ -121,6 +178,16 @@ export const VirtualKeyboard = () => {
|
||||
setActiveModifierKeys([]);
|
||||
}
|
||||
|
||||
function selectSystem(system: string) {
|
||||
setKeyboardSystem(system);
|
||||
storage.setKeyboardSystem(system);
|
||||
}
|
||||
|
||||
function selectLanguage(language: string) {
|
||||
setKeyboardLanguage(language);
|
||||
storage.setKeyboardLanguage(language);
|
||||
}
|
||||
|
||||
function getButtonTheme(): KeyboardButtonTheme[] {
|
||||
const theme = [{ class: 'hg-double', buttons: doubleKeys.join(' ') }];
|
||||
|
||||
@@ -132,11 +199,6 @@ export const VirtualKeyboard = () => {
|
||||
return theme;
|
||||
}
|
||||
|
||||
function selectLayout(value: string) {
|
||||
setKeyboardLayout(value);
|
||||
setLayout(value);
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer.Root open={isKeyboardOpen} onOpenChange={setIsKeyboardOpen} modal={false}>
|
||||
<Drawer.Portal>
|
||||
@@ -148,22 +210,30 @@ export const VirtualKeyboard = () => {
|
||||
>
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between px-3 py-1">
|
||||
<div className="flex flex-row">
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: theme.defaultAlgorithm
|
||||
}}
|
||||
>
|
||||
<Segmented
|
||||
options={[
|
||||
{ label: 'Win', value: 'default', icon: <WindowsOutlined /> },
|
||||
{ label: 'Mac', value: 'mac', icon: <AppleOutlined /> }
|
||||
]}
|
||||
value={layout}
|
||||
onChange={selectLayout}
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: theme.defaultAlgorithm
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-5">
|
||||
<Select
|
||||
size="small"
|
||||
style={{ minWidth: 90 }}
|
||||
defaultValue={keyboardLanguage}
|
||||
options={languages}
|
||||
onChange={selectLanguage}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
</div>
|
||||
|
||||
{keyboardLanguage === 'en' && (
|
||||
<Segmented
|
||||
size="small"
|
||||
options={systems}
|
||||
value={keyboardSystem}
|
||||
onChange={selectSystem}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
|
||||
<div className="flex w-[100px] items-center justify-end">
|
||||
<div
|
||||
@@ -184,7 +254,7 @@ export const VirtualKeyboard = () => {
|
||||
keyboardRef={(r) => (keyboardRef.current = r)}
|
||||
onKeyPress={onKeyPress}
|
||||
onKeyReleased={onKeyReleased}
|
||||
layoutName={layout}
|
||||
layoutName={keyboardLayout}
|
||||
{...keyboardOptions}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// TODO: refactor it
|
||||
|
||||
// main keys
|
||||
export const keyboardOptions = {
|
||||
theme: 'simple-keyboard hg-theme-default',
|
||||
@@ -18,6 +20,22 @@ export const keyboardOptions = {
|
||||
'{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}'
|
||||
],
|
||||
rus: [
|
||||
'{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} RusQ RusW RusE RusR RusT RusY RusU RusI RusO RusP RusBracketLeft RusBracketRight RusBackslash',
|
||||
'{capslock} RusA RusS RusD RusF RusG RusH RusJ RusK RusL RusSemicolon RusQuote {enter}',
|
||||
'{shiftleft} RusZ RusX RusC RusV RusB RusN RusM RusComma RusPeriod RusSlash {shiftright}',
|
||||
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
|
||||
],
|
||||
azerty: [
|
||||
'{escape} F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
|
||||
'Backquote_azerty Digit1_azerty Digit2_azerty Digit3_azerty Digit4_azerty Digit5_azerty Digit6_azerty Digit7_azerty Digit8_azerty Digit9_azerty Digit0_azerty Minus_azerty Equal_azerty {backspace}',
|
||||
'{tab} KeyA_azerty KeyZ_azerty KeyE_azerty KeyR_azerty KeyT_azerty KeyY_azerty KeyU_azerty KeyI_azerty KeyO_azerty KeyP_azerty BracketLeft_azerty BracketRight_azerty Backslash_azerty',
|
||||
'{capslock} KeyQ_azerty KeyS_azerty KeyD_azerty KeyF_azerty KeyG_azerty KeyH_azerty KeyJ_azerty KeyK_azerty KeyL_azerty Semicolon_azerty Quote_azerty {enter}',
|
||||
'{shiftleft} KeyW_azerty KeyX_azerty KeyC_azerty KeyV_azerty KeyB_azerty KeyN_azerty KeyM_azerty Comma_azerty Period_azerty Slash_azerty {shiftright}',
|
||||
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
|
||||
]
|
||||
},
|
||||
display: {
|
||||
@@ -88,8 +106,109 @@ export const keyboardOptions = {
|
||||
'{winright}': 'Win',
|
||||
'{altright}': 'Alt',
|
||||
'{menu}': 'Menu',
|
||||
'{controlright}': 'Ctrl'
|
||||
'{controlright}': 'Ctrl',
|
||||
|
||||
RusQ: 'Й',
|
||||
RusW: 'Ц',
|
||||
RusE: 'У',
|
||||
RusR: 'К',
|
||||
RusT: 'Е',
|
||||
RusY: 'Н',
|
||||
RusU: 'Г',
|
||||
RusI: 'Ш',
|
||||
RusO: 'Щ',
|
||||
RusP: 'З',
|
||||
RusBracketLeft: 'Х',
|
||||
RusBracketRight: 'Ъ',
|
||||
RusBackslash: '/<br>\\',
|
||||
|
||||
RusA: 'Ф',
|
||||
RusS: 'Ы',
|
||||
RusD: 'В',
|
||||
RusF: 'А',
|
||||
RusG: 'П',
|
||||
RusH: 'Р',
|
||||
RusJ: 'О',
|
||||
RusK: 'Л',
|
||||
RusL: 'Д',
|
||||
RusSemicolon: 'Ж',
|
||||
RusQuote: 'Э',
|
||||
|
||||
RusZ: 'Я',
|
||||
RusX: 'Ч',
|
||||
RusC: 'С',
|
||||
RusV: 'М',
|
||||
RusB: 'И',
|
||||
RusN: 'Т',
|
||||
RusM: 'Ь',
|
||||
RusComma: 'Б',
|
||||
RusPeriod: 'Ю',
|
||||
RusSlash: ',<br/>.',
|
||||
|
||||
// AZERTY specific display keys
|
||||
// Row 1
|
||||
Backquote_azerty: '<<br/>>',
|
||||
Digit1_azerty: '&<br/>1',
|
||||
Digit2_azerty: 'é<br/>2',
|
||||
Digit3_azerty: '"<br/>#',
|
||||
Digit4_azerty: "'<br/>{",
|
||||
Digit5_azerty: '(<br/>[',
|
||||
Digit6_azerty: '-<br/>|',
|
||||
Digit7_azerty: 'è<br/>`',
|
||||
Digit8_azerty: '_<br/>\\',
|
||||
Digit9_azerty: 'ç<br/>^',
|
||||
Digit0_azerty: 'à<br/>@',
|
||||
Minus_azerty: ')<br/>]',
|
||||
Equal_azerty: '=<br/>}',
|
||||
|
||||
// Row 2
|
||||
KeyA_azerty: 'A',
|
||||
KeyZ_azerty: 'Z',
|
||||
KeyE_azerty: 'E<br/>€',
|
||||
KeyR_azerty: 'R',
|
||||
KeyT_azerty: 'T',
|
||||
KeyY_azerty: 'Y',
|
||||
KeyU_azerty: 'U',
|
||||
KeyI_azerty: 'I',
|
||||
KeyO_azerty: 'O',
|
||||
KeyP_azerty: 'P',
|
||||
BracketLeft_azerty: '¨<br/>^',
|
||||
BracketRight_azerty: '£<br/>$',
|
||||
Backslash_azerty: 'µ<br/>*',
|
||||
|
||||
// Row 3
|
||||
KeyQ_azerty: 'Q',
|
||||
KeyS_azerty: 'S',
|
||||
KeyD_azerty: 'D',
|
||||
KeyF_azerty: 'F',
|
||||
KeyG_azerty: 'G',
|
||||
KeyH_azerty: 'H',
|
||||
KeyJ_azerty: 'J',
|
||||
KeyK_azerty: 'K',
|
||||
KeyL_azerty: 'L',
|
||||
Semicolon_azerty: 'M',
|
||||
Quote_azerty: '%<br/>ù',
|
||||
|
||||
// Row 4
|
||||
KeyW_azerty: 'W',
|
||||
KeyX_azerty: 'X',
|
||||
KeyC_azerty: 'C',
|
||||
KeyV_azerty: 'V',
|
||||
KeyB_azerty: 'B',
|
||||
KeyN_azerty: 'N',
|
||||
KeyM_azerty: '?<br/>,',
|
||||
Comma_azerty: '.<br/>;',
|
||||
Period_azerty: '/<br/>:',
|
||||
Slash_azerty: '§<br/>!'
|
||||
},
|
||||
// Enable layout-specific display
|
||||
mergeDisplay: true,
|
||||
layoutCandidates: {
|
||||
default: 'default',
|
||||
shift: 'shift',
|
||||
azerty: 'azerty'
|
||||
}
|
||||
// ...remaining options...
|
||||
};
|
||||
|
||||
// control keys
|
||||
|
||||
@@ -44,7 +44,7 @@ export const Cursor = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop" arrow={true} trigger="hover">
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<MousePointerIcon size={18} />
|
||||
<span>{t('mouse.cursor')}</span>
|
||||
|
||||
@@ -5,18 +5,21 @@ import { MouseIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as ls from '@/lib/localstorage';
|
||||
import { mouseModeAtom, mouseStyleAtom } from '@/jotai/mouse';
|
||||
import { mouseModeAtom, mouseStyleAtom, scrollIntervalAtom } from '@/jotai/mouse';
|
||||
import { MenuItem } from '@/components/menu-item.tsx';
|
||||
|
||||
import { Cursor } from './cursor.tsx';
|
||||
import { HidMode } from './hid-mode.tsx';
|
||||
import { MouseMode } from './mouse-mode.tsx';
|
||||
import { ResetHid } from './reset-hid.tsx';
|
||||
import { Speed } from './speed.tsx';
|
||||
|
||||
export const Mouse = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const setMouseStyle = useSetAtom(mouseStyleAtom);
|
||||
const setMouseMode = useSetAtom(mouseModeAtom);
|
||||
const setScrollInterval = useSetAtom(scrollIntervalAtom);
|
||||
|
||||
useEffect(() => {
|
||||
const mouseStyle = ls.getMouseStyle();
|
||||
@@ -28,12 +31,18 @@ export const Mouse = () => {
|
||||
if (mouseMode) {
|
||||
setMouseMode(mouseMode);
|
||||
}
|
||||
|
||||
const interval = ls.getMouseScrollInterval();
|
||||
if (interval) {
|
||||
setScrollInterval(interval);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const content = (
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Cursor />
|
||||
<MouseMode />
|
||||
<Speed />
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
|
||||
<HidMode />
|
||||
|
||||
@@ -47,7 +47,7 @@ export const MouseMode = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop" arrow={true} trigger="hover">
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<SquareDashedMousePointerIcon size={18} />
|
||||
<span>{t('mouse.mode')}</span>
|
||||
|
||||
@@ -15,6 +15,7 @@ export const ResetHid = () => {
|
||||
if (isResetting) return;
|
||||
setIsResetting(true);
|
||||
|
||||
client.send([1, 0, 0, 0, 0, 0]);
|
||||
client.close();
|
||||
|
||||
api.reset().finally(() => {
|
||||
|
||||
66
web/src/pages/desktop/menu/mouse/speed.tsx
Normal file
66
web/src/pages/desktop/menu/mouse/speed.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Popover, Slider } from 'antd';
|
||||
import { useAtom } from 'jotai';
|
||||
import { GaugeIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as storage from '@/lib/localstorage.ts';
|
||||
import { scrollIntervalAtom } from '@/jotai/mouse.ts';
|
||||
|
||||
const MAX_INTERVAL = 300;
|
||||
|
||||
export const Speed = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [scrollInterval, setScrollInterval] = useAtom(scrollIntervalAtom);
|
||||
|
||||
const [scrollSpeed, setScrollSpeed] = useState(100);
|
||||
|
||||
useEffect(() => {
|
||||
const speed = interval2Speed(scrollInterval);
|
||||
setScrollSpeed(speed);
|
||||
}, [scrollInterval]);
|
||||
|
||||
function update(speed: number): void {
|
||||
const interval = speed2Interval(speed);
|
||||
setScrollInterval(interval);
|
||||
storage.setMouseScrollInterval(interval);
|
||||
}
|
||||
|
||||
function interval2Speed(interval: number) {
|
||||
if (interval === MAX_INTERVAL) {
|
||||
return 0;
|
||||
}
|
||||
return ((MAX_INTERVAL - interval) * 100) / MAX_INTERVAL;
|
||||
}
|
||||
|
||||
function speed2Interval(speed: number) {
|
||||
return MAX_INTERVAL - speed * (MAX_INTERVAL / 100);
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div className="h-[150px] w-[60px] py-3">
|
||||
<Slider
|
||||
vertical
|
||||
marks={{
|
||||
0: <span>{t('mouse.slow')}</span>,
|
||||
100: <span>{t('mouse.fast')}</span>
|
||||
}}
|
||||
range={false}
|
||||
included={false}
|
||||
step={10}
|
||||
defaultValue={scrollSpeed}
|
||||
onChange={update}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<GaugeIcon size={18} />
|
||||
<span>{t('mouse.speed')}</span>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -31,12 +31,15 @@ export const Power = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
function getLed() {
|
||||
api.getGpio().then((rsp: any) => {
|
||||
async function getLed() {
|
||||
try {
|
||||
const rsp = await api.getGpio();
|
||||
if (rsp.code === 0) {
|
||||
setIsPowerOn(rsp.data.pwr);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
function updateShowConfirm(value: boolean) {
|
||||
|
||||
@@ -50,8 +50,9 @@ export const Fps = ({ fps, setFps }: FpsProps) => {
|
||||
|
||||
setFps(value);
|
||||
setCookie(value);
|
||||
|
||||
isCustomize && setIsCustomize(false);
|
||||
if (isCustomize) {
|
||||
setIsCustomize(false);
|
||||
}
|
||||
}
|
||||
|
||||
const content = (
|
||||
@@ -82,7 +83,7 @@ export const Fps = ({ fps, setFps }: FpsProps) => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex h-[14px] w-[20px] items-end text-blue-500 ">
|
||||
<div className="flex h-[14px] w-[20px] items-end text-blue-500">
|
||||
<CheckIcon size={14} />
|
||||
</div>
|
||||
<span>Customize</span>
|
||||
@@ -106,7 +107,7 @@ export const Fps = ({ fps, setFps }: FpsProps) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop">
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<ScanBarcodeIcon size={18} />
|
||||
<span className="select-none text-sm">{t('screen.fps')}</span>
|
||||
|
||||
@@ -47,7 +47,7 @@ export const Gop = ({ gop, setGop }: GopProps) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop">
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<SquareKanbanIcon size={18} />
|
||||
<span className="select-none text-sm">GOP</span>
|
||||
|
||||
@@ -74,8 +74,7 @@ export const Screen = () => {
|
||||
<Resolution />
|
||||
<Quality quality={quality} setQuality={setQuality} />
|
||||
<Fps fps={fps} setFps={setFps} />
|
||||
{videoMode === 'h264' && <Gop gop={gop} setGop={setGop} />}
|
||||
{videoMode === 'mjpeg' && <FrameDetect />}
|
||||
{videoMode === 'mjpeg' ? <FrameDetect /> : <Gop gop={gop} setGop={setGop} />}
|
||||
<Reset />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -55,7 +55,7 @@ export const Quality = ({ quality, setQuality }: QualityProps) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop">
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<SquareActivityIcon size={18} />
|
||||
<span className="select-none text-sm">{t('screen.quality')}</span>
|
||||
|
||||
@@ -32,7 +32,7 @@ export const Reset = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{isPcie ? (
|
||||
{isPcie && (
|
||||
<div
|
||||
className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70"
|
||||
onClick={reset}
|
||||
@@ -43,8 +43,6 @@ export const Reset = () => {
|
||||
/>
|
||||
<span className="select-none text-sm">{t('screen.resetHdmi')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -66,7 +66,7 @@ export const Resolution = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop">
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<RatioIcon size={18} />
|
||||
<span className="select-none text-sm">{t('screen.resolution')}</span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Popover } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { CheckIcon, TvMinimalPlayIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -7,7 +8,8 @@ import { setVideoMode as setCookie } from '@/lib/localstorage.ts';
|
||||
import { videoModeAtom } from '@/jotai/screen.ts';
|
||||
|
||||
const videoModes = [
|
||||
{ key: 'h264', name: 'H.264' },
|
||||
{ key: 'direct', name: 'H.264 (Direct)' },
|
||||
{ key: 'h264', name: 'H.264 (WebRTC)' },
|
||||
{ key: 'mjpeg', name: 'MJPEG' }
|
||||
];
|
||||
|
||||
@@ -15,6 +17,15 @@ export const VideoMode = () => {
|
||||
const { t } = useTranslation();
|
||||
const videoMode = useAtomValue(videoModeAtom);
|
||||
|
||||
const [isDirectSupported, setIsDirectSupported] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isHttps = window.location.protocol === 'https:';
|
||||
const isDecoderSupported = !!window.VideoDecoder;
|
||||
|
||||
setIsDirectSupported(isHttps && isDecoderSupported);
|
||||
}, []);
|
||||
|
||||
function update(mode: string) {
|
||||
if (mode === videoMode) return;
|
||||
|
||||
@@ -28,24 +39,39 @@ export const VideoMode = () => {
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{videoModes.map((mode) => (
|
||||
<div
|
||||
key={mode.key}
|
||||
className="flex cursor-pointer select-none items-center rounded py-1.5 pl-1 pr-5 hover:bg-neutral-700/70"
|
||||
onClick={() => update(mode.key)}
|
||||
{!isDirectSupported && (
|
||||
<Tooltip
|
||||
placement="right"
|
||||
title={t('screen.videoDirectTips')}
|
||||
overlayStyle={{ maxWidth: '270px' }}
|
||||
>
|
||||
<div className="flex h-[14px] w-[20px] items-end text-blue-500">
|
||||
{mode.key === videoMode && <CheckIcon size={15} />}
|
||||
<div className="flex cursor-not-allowed select-none items-center rounded py-1.5 pl-1 pr-5 text-neutral-500 hover:bg-neutral-700/70">
|
||||
<div className="flex h-[14px] w-[20px] items-end text-blue-500"></div>
|
||||
<span>H.264 (Direct)</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<span className="flex w-[50px]">{mode.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{videoModes.map(
|
||||
(mode) =>
|
||||
(isDirectSupported || mode.key !== 'direct') && (
|
||||
<div
|
||||
key={mode.key}
|
||||
className="flex cursor-pointer select-none items-center rounded py-1.5 pl-1 pr-5 hover:bg-neutral-700/70"
|
||||
onClick={() => update(mode.key)}
|
||||
>
|
||||
<div className="flex h-[14px] w-[20px] items-end text-blue-500">
|
||||
{mode.key === videoMode && <CheckIcon size={15} />}
|
||||
</div>
|
||||
<span>{mode.name}</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop">
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<TvMinimalPlayIcon size={18} />
|
||||
<span className="select-none text-sm">{t('screen.video')}</span>
|
||||
|
||||
@@ -48,7 +48,7 @@ export const Information = () => {
|
||||
<div className="flex flex-col space-y-1">
|
||||
{information.ips.map((ip) => (
|
||||
<div key={ip.addr} className="flex items-center">
|
||||
<Tag>{ip.type}</Tag>
|
||||
<Tag>{t('settings.about.ipType.' + ip.type)}</Tag>
|
||||
<span>{ip.addr}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -13,13 +13,13 @@ export const Appearance = () => {
|
||||
<div className="text-base font-bold">{t('settings.appearance.title')}</div>
|
||||
<Divider />
|
||||
|
||||
<Language />
|
||||
<div className="flex flex-col space-y-6">
|
||||
<Language />
|
||||
<WebTitle />
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
<MenuBar />
|
||||
<Divider />
|
||||
|
||||
<WebTitle />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -57,7 +57,7 @@ export const Swap = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Swap</span>
|
||||
<span>{t('settings.device.swap.title')}</span>
|
||||
|
||||
<Tooltip
|
||||
title={t('settings.device.swap.tip')}
|
||||
|
||||
77
web/src/pages/desktop/menu/settings/device/hdmi.tsx
Normal file
77
web/src/pages/desktop/menu/settings/device/hdmi.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Switch } from 'antd';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/vm.ts';
|
||||
import { isHdmiEnabledAtom } from '@/jotai/screen.ts';
|
||||
|
||||
export const Hdmi = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isHdmiEnabled, setIsHdmiEnabled] = useAtom(isHdmiEnabledAtom);
|
||||
|
||||
const [isPcie, setIsPcie] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getHardware();
|
||||
getHdmiState();
|
||||
}, []);
|
||||
|
||||
async function getHardware() {
|
||||
const rsp = await api.getHardware();
|
||||
if (rsp.code !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPcie(rsp.data?.version === 'PCIE');
|
||||
}
|
||||
|
||||
async function getHdmiState() {
|
||||
setIsLoading(true);
|
||||
|
||||
const rsp = await api.getHdmiState();
|
||||
if (rsp.code === 0) {
|
||||
setIsHdmiEnabled(rsp.data.enabled);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
async function setHdmiState() {
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
|
||||
const enabled = !isHdmiEnabled;
|
||||
|
||||
const rsp = await api.setHdmiState(enabled);
|
||||
if (rsp.code !== 0) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setIsHdmiEnabled(enabled);
|
||||
setIsLoading(false);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isPcie && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span>HDMI</span>
|
||||
|
||||
<span className="text-xs text-neutral-500">
|
||||
{t('settings.device.hdmi.description')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Switch checked={isHdmiEnabled} loading={isLoading} onChange={setHdmiState} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -7,12 +7,14 @@ import * as api from '@/api/hid.ts';
|
||||
import { hidModeAtom } from '@/jotai/mouse.ts';
|
||||
|
||||
import { Advanced } from './advanced';
|
||||
import { Hdmi } from './hdmi.tsx';
|
||||
import { HidMode } from './hid-mode.tsx';
|
||||
import { Mdns } from './mdns.tsx';
|
||||
import { MouseJiggler } from './mouse-jiggler.tsx';
|
||||
import { Oled } from './oled.tsx';
|
||||
import { Reboot } from './reboot.tsx';
|
||||
import { Ssh } from './ssh.tsx';
|
||||
import { Tls } from './tls.tsx';
|
||||
import { VirtualDevices } from './virtual-devices.tsx';
|
||||
import { Wifi } from './wifi.tsx';
|
||||
|
||||
@@ -35,11 +37,18 @@ export const Device = () => {
|
||||
<Divider />
|
||||
|
||||
<div className="flex flex-col space-y-6">
|
||||
<Oled />
|
||||
<Wifi />
|
||||
<Tls />
|
||||
<Ssh />
|
||||
<Mdns />
|
||||
<Hdmi />
|
||||
|
||||
{hidMode === 'normal' ? <VirtualDevices /> : <HidMode />}
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
<div className="flex flex-col space-y-6">
|
||||
<Oled />
|
||||
<Wifi />
|
||||
<MouseJiggler />
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
69
web/src/pages/desktop/menu/settings/device/tls.tsx
Normal file
69
web/src/pages/desktop/menu/settings/device/tls.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Switch, Tooltip } from 'antd';
|
||||
import { CircleAlertIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/vm.ts';
|
||||
|
||||
export const Tls = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsEnabled(window.location.protocol === 'https:');
|
||||
}, []);
|
||||
|
||||
async function update() {
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
|
||||
const enable = !isEnabled;
|
||||
|
||||
const seconds = enable ? 30 : 10;
|
||||
setTimeout(() => {
|
||||
reload(enable);
|
||||
}, seconds * 1000);
|
||||
|
||||
try {
|
||||
const rsp = await api.setTLS(enable);
|
||||
if (rsp.code === 0) {
|
||||
setIsEnabled(enable);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
function reload(enable: boolean) {
|
||||
if (!enable) {
|
||||
const target = window.location.href.replace(/^https:/, 'http:');
|
||||
window.open(target, '_blank');
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>HTTPS</span>
|
||||
|
||||
<Tooltip
|
||||
title={t('settings.device.tls.tip')}
|
||||
className="cursor-pointer"
|
||||
placement="bottom"
|
||||
overlayStyle={{ maxWidth: '300px' }}
|
||||
>
|
||||
<CircleAlertIcon size={15} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-500">{t('settings.device.tls.description')}</span>
|
||||
</div>
|
||||
|
||||
<Switch checked={isEnabled} loading={isLoading} onChange={update} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
import { scrollIntervalAtom } from '@/jotai/mouse.ts';
|
||||
import { resolutionAtom } from '@/jotai/screen.ts';
|
||||
|
||||
import { MouseButton, MouseEvent } from './constants';
|
||||
|
||||
export const Absolute = () => {
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const scrollInterval = useAtomValue(scrollIntervalAtom);
|
||||
|
||||
const lastScrollTimeRef = useRef(0);
|
||||
|
||||
// listen mouse events
|
||||
useEffect(() => {
|
||||
@@ -74,6 +78,12 @@ export const Absolute = () => {
|
||||
const delta = Math.floor(event.deltaY);
|
||||
if (delta === 0) return;
|
||||
|
||||
const currentTime = Date.now();
|
||||
if (currentTime - lastScrollTimeRef.current < scrollInterval) {
|
||||
return;
|
||||
}
|
||||
lastScrollTimeRef.current = currentTime;
|
||||
|
||||
const data = [2, MouseEvent.Scroll, 0, 0, delta];
|
||||
client.send(data);
|
||||
}
|
||||
@@ -82,10 +92,11 @@ export const Absolute = () => {
|
||||
canvas.removeEventListener('mousemove', handleMouseMove);
|
||||
canvas.removeEventListener('mousedown', handleMouseDown);
|
||||
canvas.removeEventListener('mouseup', handleMouseUp);
|
||||
canvas.removeEventListener('wheel', handleWheel);
|
||||
canvas.removeEventListener('click', disableEvent);
|
||||
canvas.removeEventListener('contextmenu', disableEvent);
|
||||
};
|
||||
}, [resolution]);
|
||||
}, [resolution, scrollInterval]);
|
||||
|
||||
// disable default events
|
||||
function disableEvent(event: any) {
|
||||
|
||||
@@ -4,19 +4,21 @@ import { useAtomValue } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
import { scrollIntervalAtom } from '@/jotai/mouse.ts';
|
||||
import { resolutionAtom } from '@/jotai/screen.ts';
|
||||
|
||||
import { MouseButton, MouseEvent } from './constants';
|
||||
|
||||
export const Relative = () => {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const scrollInterval = useAtomValue(scrollIntervalAtom);
|
||||
|
||||
const isLockedRef = useRef(false);
|
||||
const buttonRef = useRef<MouseButton>(MouseButton.None);
|
||||
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const lastScrollTimeRef = useRef(0);
|
||||
|
||||
// listen mouse events
|
||||
useEffect(() => {
|
||||
@@ -113,6 +115,12 @@ export const Relative = () => {
|
||||
const delta = Math.floor(event.deltaY);
|
||||
if (delta === 0) return;
|
||||
|
||||
const currentTime = Date.now();
|
||||
if (currentTime - lastScrollTimeRef.current < scrollInterval) {
|
||||
return;
|
||||
}
|
||||
lastScrollTimeRef.current = currentTime;
|
||||
|
||||
const data = [2, MouseEvent.Scroll, 0, 0, delta];
|
||||
client.send(data);
|
||||
}
|
||||
@@ -124,9 +132,10 @@ export const Relative = () => {
|
||||
canvas.removeEventListener('mousemove', handleMouseMove);
|
||||
canvas.removeEventListener('mousedown', handleMouseDown);
|
||||
canvas.removeEventListener('mouseup', handleMouseUp);
|
||||
canvas.removeEventListener('wheel', handleWheel);
|
||||
canvas.removeEventListener('contextmenu', disableEvent);
|
||||
};
|
||||
}, [resolution]);
|
||||
}, [resolution, scrollInterval]);
|
||||
|
||||
// disable default events
|
||||
function disableEvent(event: any) {
|
||||
|
||||
184
web/src/pages/desktop/screen/h264-direct.tsx
Normal file
184
web/src/pages/desktop/screen/h264-direct.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { w3cwebsocket as W3cWebSocket } from 'websocket';
|
||||
import Queue from 'yocto-queue';
|
||||
|
||||
import { getBaseUrl } from '@/lib/service.ts';
|
||||
import { mouseStyleAtom } from '@/jotai/mouse';
|
||||
import { resolutionAtom } from '@/jotai/screen.ts';
|
||||
|
||||
export const H264Direct = () => {
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const mouseStyle = useAtomValue(mouseStyleAtom);
|
||||
|
||||
const canvasRef = useRef<any>();
|
||||
const renderingRef = useRef(false);
|
||||
const decoderRef = useRef<VideoDecoder | null>(null);
|
||||
|
||||
const frameQueue = new Queue<VideoFrame>();
|
||||
|
||||
// init websocket
|
||||
useEffect(() => {
|
||||
if (!window.VideoDecoder) {
|
||||
console.log('Error: WebCodecs API not supported.');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `${getBaseUrl('ws')}/api/stream/h264/direct`;
|
||||
const ws = new W3cWebSocket(url);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data as string);
|
||||
|
||||
if (!decoderRef.current && message.isKeyFrame) {
|
||||
initializeDecoder();
|
||||
}
|
||||
|
||||
if (decoderRef.current?.state === 'configured') {
|
||||
decode(message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
resetDecoder();
|
||||
};
|
||||
ws.onclose = () => {
|
||||
resetDecoder();
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (ws.readyState === 1) {
|
||||
ws.close();
|
||||
}
|
||||
resetDecoder();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// init video decoder
|
||||
function initializeDecoder() {
|
||||
if (!window.VideoDecoder) {
|
||||
return;
|
||||
}
|
||||
if (decoderRef.current && decoderRef.current.state !== 'unconfigured') {
|
||||
return;
|
||||
}
|
||||
|
||||
const init = {
|
||||
output: (frame: VideoFrame) => {
|
||||
frameQueue.enqueue(frame);
|
||||
if (frameQueue.size >= 10) {
|
||||
frameQueue.dequeue()?.close();
|
||||
}
|
||||
|
||||
if (!renderingRef.current) {
|
||||
requestAnimationFrame(processFrameQueue);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
resetDecoder();
|
||||
}
|
||||
};
|
||||
|
||||
const config = {
|
||||
codec: 'avc1.42E01F',
|
||||
optimizeForLatency: true
|
||||
};
|
||||
|
||||
try {
|
||||
const decoder = new VideoDecoder(init);
|
||||
decoder.configure(config);
|
||||
decoderRef.current = decoder;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
decoderRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
// decode video chunk
|
||||
function decode(message: any) {
|
||||
const byteString = atob(message.data);
|
||||
const byteArray = new Uint8Array(byteString.length);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
byteArray[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
|
||||
const chunk = new EncodedVideoChunk({
|
||||
type: message.isKeyFrame ? 'key' : 'delta',
|
||||
timestamp: message.timestamp,
|
||||
data: byteArray
|
||||
});
|
||||
|
||||
try {
|
||||
decoderRef.current?.decode(chunk);
|
||||
} catch (err: any) {
|
||||
if (err.name === 'TypeError' || err.message.includes('configured')) {
|
||||
resetDecoder();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function processFrameQueue() {
|
||||
renderingRef.current = true;
|
||||
|
||||
const frame = frameQueue.dequeue();
|
||||
if (frame) {
|
||||
renderFrame(frame);
|
||||
}
|
||||
|
||||
requestAnimationFrame(processFrameQueue);
|
||||
}
|
||||
|
||||
function renderFrame(frame: VideoFrame) {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (!canvas || !ctx) {
|
||||
frame.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (canvas.width !== frame.displayWidth || canvas.height !== frame.displayHeight) {
|
||||
canvas.width = frame.displayWidth;
|
||||
canvas.height = frame.displayHeight;
|
||||
}
|
||||
|
||||
ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
|
||||
frame.close();
|
||||
}
|
||||
|
||||
// reset video decoder
|
||||
function resetDecoder() {
|
||||
if (decoderRef.current && decoderRef.current.state !== 'closed') {
|
||||
try {
|
||||
decoderRef.current.close();
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
decoderRef.current = null;
|
||||
renderingRef.current = false;
|
||||
|
||||
Array.from(frameQueue.drain()).forEach((frame) => frame.close());
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-start justify-center xl:items-center">
|
||||
<canvas
|
||||
id="screen"
|
||||
ref={canvasRef}
|
||||
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
|
||||
style={
|
||||
resolution?.width
|
||||
? { width: resolution.width, height: resolution.height, objectFit: 'cover' }
|
||||
: { maxWidth: '100%', maxHeight: '100%', objectFit: 'scale-down' }
|
||||
}
|
||||
></canvas>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -8,7 +8,7 @@ import { getBaseUrl } from '@/lib/service.ts';
|
||||
import { mouseStyleAtom } from '@/jotai/mouse.ts';
|
||||
import { resolutionAtom } from '@/jotai/screen.ts';
|
||||
|
||||
export const H264 = () => {
|
||||
export const H264Webrtc = () => {
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const mouseStyle = useAtomValue(mouseStyleAtom);
|
||||
|
||||
@@ -88,7 +88,7 @@ export const H264 = () => {
|
||||
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 15 * 1000);
|
||||
}, 5 * 1000);
|
||||
|
||||
return () => {
|
||||
ws.close();
|
||||
@@ -101,7 +101,7 @@ export const H264 = () => {
|
||||
|
||||
return (
|
||||
<Spin size="large" tip="Loading" spinning={isLoading}>
|
||||
<div className={clsx('flex h-screen w-screen items-start justify-center xl:items-center')}>
|
||||
<div className="flex h-screen w-screen items-start justify-center xl:items-center">
|
||||
<video
|
||||
id="screen"
|
||||
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
|
||||
@@ -2,11 +2,20 @@ import { useAtomValue } from 'jotai';
|
||||
|
||||
import { videoModeAtom } from '@/jotai/screen.ts';
|
||||
|
||||
import { H264 } from './h264.tsx';
|
||||
import { H264Direct } from './h264-direct.tsx';
|
||||
import { H264Webrtc } from './h264-webrtc.tsx';
|
||||
import { Mjpeg } from './mjpeg.tsx';
|
||||
|
||||
export const Screen = () => {
|
||||
const videoMode = useAtomValue(videoModeAtom);
|
||||
|
||||
return <>{videoMode === 'mjpeg' ? <Mjpeg /> : <H264 />}</>;
|
||||
if (videoMode === 'mjpeg') {
|
||||
return <Mjpeg />;
|
||||
}
|
||||
|
||||
if (videoMode === 'direct') {
|
||||
return <H264Direct />;
|
||||
}
|
||||
|
||||
return <H264Webrtc />;
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ export const Mjpeg = () => {
|
||||
}, [resolution]);
|
||||
|
||||
return (
|
||||
<div className={clsx('flex h-screen w-screen items-start justify-center xl:items-center')}>
|
||||
<div className="flex h-screen w-screen items-start justify-center xl:items-center">
|
||||
<Image
|
||||
id="screen"
|
||||
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
|
||||
|
||||
Reference in New Issue
Block a user