mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-11-11 10:29:19 -06:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3286cc2f85 | ||
|
|
dbc74834cd | ||
|
|
f1b411e3b6 |
25
CHANGELOG.md
25
CHANGELOG.md
@@ -1,3 +1,22 @@
|
||||
## 2.2.4 [1bf986d](https://github.com/sipeed/NanoKVM/commit/1bf986d41b34d568c1ffee5df90ce61b6b08456b) (2025-03-21)
|
||||
|
||||
* fix: resolve USB initialization issue
|
||||
* fix: correct abnormal updates in certain models
|
||||
* perf: add version restrictions for production testing
|
||||
|
||||
## 2.2.3 [6ef83cb](https://github.com/sipeed/NanoKVM/commit/6ef83cb22fcd77f721d32c97c85d12f2bfc3035a) (2025-03-21)
|
||||
|
||||
* feat: add support for setting H.264 GOP
|
||||
* fix: resolve deadlock caused by HDMI resolution errors
|
||||
* perf: merge H.264 SPS and PPS into I-frame
|
||||
* perf: refactor MJPEG frame detection
|
||||
* perf: added more configuration options to the serial port terminal (thanks to [@mekaris](https://github.com/mekaris))
|
||||
* perf: improve the `update-nanokvm.py` script (thanks to [@reishoku](https://github.com/reishoku))
|
||||
* perf: disable mDNS by default in new products
|
||||
* perf: update log timestamp to millisecond precision
|
||||
* chore: bump Go to 1.23
|
||||
* chore: bump `golang.org/x/net` to v0.37.0
|
||||
|
||||
## 2.2.2 [58d5ab2](https://github.com/sipeed/NanoKVM/commit/58d5ab2d37244b1e1a68b925a5c23c324c489ad3) (2025-03-11)
|
||||
|
||||
* feat: add watchdog for NanoKVM-Server
|
||||
@@ -47,9 +66,9 @@ NanoKVM [Image v1.4.0](https://github.com/sipeed/NanoKVM/releases/tag/v1.4.0) ha
|
||||
* perf: download Tailscale installation package from the official source
|
||||
* perf: automatic enable/disable GOMEMLIMIT on tailscale start/stop
|
||||
* perf: add JWT configuration
|
||||
* secretKey: customize secret key. If empty, generated a random 64-byte secret key by default
|
||||
* refreshTokenDuration: customize token expiration time
|
||||
* revokeTokensOnLogout: invalidate all JWT tokens on logout
|
||||
* secretKey: customize secret key. If empty, generated a random 64-byte secret key by default
|
||||
* refreshTokenDuration: customize token expiration time
|
||||
* revokeTokensOnLogout: invalidate all JWT tokens on logout
|
||||
* perf: implement secure password storage using bcrypt hashing
|
||||
* perf: implement integrity checks for online updates
|
||||
* refactor: refactor HDMI module and remove the dependency `libmaixcam_lib.so`
|
||||
|
||||
@@ -78,6 +78,21 @@ func SetScreen(key string, value int) {
|
||||
}
|
||||
}
|
||||
|
||||
func CheckScreen() {
|
||||
if _, ok := ResolutionMap[screen.Height]; !ok {
|
||||
screen.Width = 1920
|
||||
screen.Height = 1080
|
||||
}
|
||||
|
||||
if _, ok := QualityMap[screen.Quality]; !ok {
|
||||
screen.Quality = 80
|
||||
}
|
||||
|
||||
if _, ok := BitRateMap[screen.BitRate]; !ok {
|
||||
screen.BitRate = 3000
|
||||
}
|
||||
}
|
||||
|
||||
func validateFPS(fps int) int {
|
||||
if fps > 60 {
|
||||
return 60
|
||||
|
||||
Binary file not shown.
@@ -4,3 +4,11 @@ type GetVersionRsp struct {
|
||||
Current string `json:"current"`
|
||||
Latest string `json:"latest"`
|
||||
}
|
||||
|
||||
type GetPreviewRsp struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type SetPreviewReq struct {
|
||||
Enable bool `validate:"omitempty"`
|
||||
}
|
||||
|
||||
9
server/proto/hid.go
Normal file
9
server/proto/hid.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package proto
|
||||
|
||||
type GetHidModeRsp struct {
|
||||
Mode string `json:"mode"` // normal or hid-only
|
||||
}
|
||||
|
||||
type SetHidModeReq struct {
|
||||
Mode string `validate:"required"` // normal or hid-only
|
||||
}
|
||||
@@ -13,4 +13,7 @@ func applicationRouter(r *gin.Engine) {
|
||||
|
||||
api.GET("/application/version", service.GetVersion) // get application version
|
||||
api.POST("/application/update", service.Update) // update application
|
||||
|
||||
api.GET("/application/preview", service.GetPreview) // get preview updates state
|
||||
api.POST("/application/preview", service.SetPreview) // set preview updates state
|
||||
}
|
||||
|
||||
@@ -13,4 +13,7 @@ func hidRouter(r *gin.Engine) {
|
||||
|
||||
api.POST("/hid/reset", service.Reset) // reset hid
|
||||
api.POST("/hid/paste", service.Paste) // paste
|
||||
|
||||
api.GET("/hid/mode", service.GetHidMode) // get hid mode
|
||||
api.POST("/hid/mode", service.SetHidMode) // set hid mode
|
||||
}
|
||||
|
||||
60
server/service/application/preview.go
Normal file
60
server/service/application/preview.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/proto"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
PreviewUpdatesFlag = "/etc/kvm/preview_updates"
|
||||
)
|
||||
|
||||
func (s *Service) GetPreview(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
isEnabled := isPreviewEnabled()
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetPreviewRsp{
|
||||
Enabled: isEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) SetPreview(c *gin.Context) {
|
||||
var req proto.SetPreviewReq
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Enable == isPreviewEnabled() {
|
||||
rsp.OkRsp(c)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Enable {
|
||||
if err := os.WriteFile(PreviewUpdatesFlag, []byte("1"), 0o644); err != nil {
|
||||
log.Errorf("failed to write %s: %s", PreviewUpdatesFlag, err)
|
||||
rsp.ErrRsp(c, -2, "enable failed")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := os.Remove(PreviewUpdatesFlag); err != nil {
|
||||
log.Errorf("failed to remove %s: %s", PreviewUpdatesFlag, err)
|
||||
rsp.ErrRsp(c, -3, "disable failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("set preview updates state: %t", req.Enable)
|
||||
}
|
||||
|
||||
func isPreviewEnabled() bool {
|
||||
_, err := os.Stat(PreviewUpdatesFlag)
|
||||
return err == nil
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
package application
|
||||
|
||||
const (
|
||||
BaseURL = "https://cdn.sipeed.com/nanokvm"
|
||||
StableURL = "https://cdn.sipeed.com/nanokvm"
|
||||
PreviewURL = "https://cdn.sipeed.com/nanokvm/preview"
|
||||
|
||||
AppDir = "/kvmapp"
|
||||
BackupDir = "/root/old"
|
||||
|
||||
@@ -3,13 +3,11 @@ package application
|
||||
import (
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -23,39 +21,6 @@ const (
|
||||
maxTries = 3
|
||||
)
|
||||
|
||||
type Latest struct {
|
||||
Version string `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Sha512 string `json:"sha512"`
|
||||
Size uint `json:"size"`
|
||||
}
|
||||
|
||||
func (s *Service) GetVersion(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
// current version
|
||||
currentVersion := "1.0.0"
|
||||
|
||||
versionFile := fmt.Sprintf("%s/version", AppDir)
|
||||
if version, err := os.ReadFile(versionFile); err == nil {
|
||||
currentVersion = strings.ReplaceAll(string(version), "\n", "")
|
||||
}
|
||||
|
||||
log.Debugf("current version: %s", currentVersion)
|
||||
|
||||
// latest version
|
||||
latest, err := getLatest()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, "get latest version failed")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetVersionRsp{
|
||||
Current: currentVersion,
|
||||
Latest: latest.Version,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) Update(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
@@ -87,10 +52,9 @@ func update() error {
|
||||
}
|
||||
|
||||
// download
|
||||
url := fmt.Sprintf("%s/%s", BaseURL, latest.Name)
|
||||
target := fmt.Sprintf("%s/%s", CacheDir, latest.Name)
|
||||
|
||||
if err := download(url, target); err != nil {
|
||||
if err := download(latest.Url, target); err != nil {
|
||||
log.Errorf("download app failed: %s", err)
|
||||
return err
|
||||
}
|
||||
@@ -135,39 +99,6 @@ func update() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func getLatest() (*Latest, error) {
|
||||
url := fmt.Sprintf("%s/latest.json?now=%d", BaseURL, time.Now().Unix())
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
log.Debugf("failed to request version: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Errorf("failed to read response: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Errorf("server responded with status code: %d", resp.StatusCode)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var latest Latest
|
||||
if err := json.Unmarshal(body, &latest); err != nil {
|
||||
log.Errorf("failed to unmarshal response: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debugf("get application latest version: %s", latest.Version)
|
||||
return &latest, nil
|
||||
}
|
||||
|
||||
func download(url string, target string) (err error) {
|
||||
for i := range maxTries {
|
||||
log.Debugf("attempt #%d/%d", i+1, maxTries)
|
||||
|
||||
89
server/service/application/version.go
Normal file
89
server/service/application/version.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/proto"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Latest struct {
|
||||
Version string `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Sha512 string `json:"sha512"`
|
||||
Size uint `json:"size"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
func (s *Service) GetVersion(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
// current version
|
||||
currentVersion := "1.0.0"
|
||||
|
||||
versionFile := fmt.Sprintf("%s/version", AppDir)
|
||||
if version, err := os.ReadFile(versionFile); err == nil {
|
||||
currentVersion = strings.ReplaceAll(string(version), "\n", "")
|
||||
}
|
||||
|
||||
log.Debugf("current version: %s", currentVersion)
|
||||
|
||||
// latest version
|
||||
latest, err := getLatest()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, "get latest version failed")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetVersionRsp{
|
||||
Current: currentVersion,
|
||||
Latest: latest.Version,
|
||||
})
|
||||
}
|
||||
|
||||
func getLatest() (*Latest, error) {
|
||||
baseURL := StableURL
|
||||
if isPreviewEnabled() {
|
||||
baseURL = PreviewURL
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/latest.json?now=%d", baseURL, time.Now().Unix())
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
log.Debugf("failed to request version: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Errorf("failed to read response: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Errorf("server responded with status code: %d", resp.StatusCode)
|
||||
return nil, fmt.Errorf("status code %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var latest Latest
|
||||
if err := json.Unmarshal(body, &latest); err != nil {
|
||||
log.Errorf("failed to unmarshal response: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
latest.Url = fmt.Sprintf("%s/%s", baseURL, latest.Name)
|
||||
|
||||
log.Debugf("get application latest version: %s", latest.Version)
|
||||
return &latest, nil
|
||||
}
|
||||
158
server/service/hid/mode.go
Normal file
158
server/service/hid/mode.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/proto"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
ModeNormal = "normal"
|
||||
ModeHidOnly = "hid-only"
|
||||
|
||||
ModeFlag = "/sys/kernel/config/usb_gadget/g0/bcdDevice"
|
||||
NormalModeScript = "/kvmapp/system/init.d/S03usbdev"
|
||||
HidOnlyModeScript = "/kvmapp/system/init.d/S03usbhid"
|
||||
TargetModeScript = "/etc/init.d/S03usbdev"
|
||||
)
|
||||
|
||||
var modeMap = map[string]string{
|
||||
"0x0510": ModeNormal,
|
||||
"0x0623": ModeHidOnly,
|
||||
}
|
||||
|
||||
func (s *Service) GetHidMode(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
mode, err := getHidMode()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, "get HID mode failed")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetHidModeRsp{
|
||||
Mode: mode,
|
||||
})
|
||||
log.Debugf("get hid mode: %s", mode)
|
||||
}
|
||||
|
||||
func (s *Service) SetHidMode(c *gin.Context) {
|
||||
var req proto.SetHidModeReq
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
return
|
||||
}
|
||||
if req.Mode != ModeNormal && req.Mode != ModeHidOnly {
|
||||
rsp.ErrRsp(c, -2, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
if mode, _ := getHidMode(); req.Mode == mode {
|
||||
rsp.OkRsp(c)
|
||||
return
|
||||
}
|
||||
|
||||
srcScript := NormalModeScript
|
||||
if req.Mode == ModeHidOnly {
|
||||
srcScript = HidOnlyModeScript
|
||||
}
|
||||
|
||||
if err := copyModeFile(srcScript); err != nil {
|
||||
rsp.ErrRsp(c, -3, "operation failed")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
|
||||
log.Println("reboot system...")
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
_ = exec.Command("reboot").Run()
|
||||
}
|
||||
|
||||
func copyModeFile(srcScript string) error {
|
||||
// open the source file
|
||||
srcFile, err := os.Open(srcScript)
|
||||
if err != nil {
|
||||
log.Errorf("failed to open %s: %s", srcScript, err)
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = srcFile.Close()
|
||||
}()
|
||||
|
||||
srcInfo, err := srcFile.Stat()
|
||||
if err != nil {
|
||||
log.Errorf("failed to get %s info: %s", srcScript, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// create and copy to temporary file
|
||||
tmpFile, err := os.CreateTemp("/etc/init.d/", ".S03usbdev-")
|
||||
if err != nil {
|
||||
log.Errorf("failed to create temp %s: %s", TargetModeScript, err)
|
||||
return err
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer func() {
|
||||
_ = os.Remove(tmpPath)
|
||||
}()
|
||||
log.Debugf("create temporary file: %s", tmpPath)
|
||||
|
||||
if err := tmpFile.Chmod(srcInfo.Mode()); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
log.Errorf("failed to set %s mode: %s", tmpPath, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := io.Copy(tmpFile, srcFile); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
log.Errorf("failed to copy %s: %s", srcScript, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tmpFile.Sync(); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
log.Errorf("failed to sync %s: %s", tmpPath, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
log.Errorf("failed to close %s: %s", tmpPath, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// replace the target file with the temporary file
|
||||
if err := os.Rename(tmpPath, TargetModeScript); err != nil {
|
||||
log.Errorf("failed to rename %s: %s", tmpPath, err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("copy %s to %s successful", srcScript, TargetModeScript)
|
||||
return nil
|
||||
}
|
||||
|
||||
func getHidMode() (string, error) {
|
||||
data, err := os.ReadFile(ModeFlag)
|
||||
if err != nil {
|
||||
log.Errorf("failed to read %s: %s", ModeFlag, err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(string(data))
|
||||
mode, ok := modeMap[key]
|
||||
if !ok {
|
||||
log.Errorf("invalid mode flag: %s", key)
|
||||
return "", errors.New("invalid mode flag")
|
||||
}
|
||||
|
||||
return mode, nil
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
|
||||
func send() {
|
||||
screen := common.GetScreen()
|
||||
common.CheckScreen()
|
||||
|
||||
fps := screen.FPS
|
||||
duration := time.Second / time.Duration(fps)
|
||||
|
||||
@@ -22,19 +24,7 @@ func send() {
|
||||
return
|
||||
}
|
||||
|
||||
height := screen.Height
|
||||
width, ok := common.ResolutionMap[height]
|
||||
if !ok {
|
||||
width = 0
|
||||
height = 0
|
||||
}
|
||||
|
||||
bitRate := screen.BitRate
|
||||
if _, ok := common.BitRateMap[bitRate]; !ok {
|
||||
bitRate = 3000
|
||||
}
|
||||
|
||||
data, result := vision.ReadH264(width, height, bitRate)
|
||||
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
|
||||
if result < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ func Connect(c *gin.Context) {
|
||||
|
||||
func send() {
|
||||
screen := common.GetScreen()
|
||||
common.CheckScreen()
|
||||
|
||||
fps := screen.FPS
|
||||
|
||||
ticker := time.NewTicker(time.Second / time.Duration(fps))
|
||||
@@ -50,19 +52,7 @@ func send() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
height := screen.Height
|
||||
width, ok := common.ResolutionMap[height]
|
||||
if !ok {
|
||||
width = 0
|
||||
height = 0
|
||||
}
|
||||
|
||||
quality := screen.Quality
|
||||
if _, ok := common.QualityMap[quality]; !ok {
|
||||
quality = 80
|
||||
}
|
||||
|
||||
data, result := vision.ReadMjpeg(width, height, quality)
|
||||
data, result := vision.ReadMjpeg(screen.Width, screen.Height, screen.Quality)
|
||||
if result < 0 || result == 5 {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1670,28 +1670,33 @@ int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _ql
|
||||
// debug("[kvmv]befor read img: %d \r\n", (int)(time::time_ms() - start_time));
|
||||
image::Image *img = cam->read();
|
||||
// debug("[kvmv]read img: %d \r\n", (int)(time::time_ms() - start_time));
|
||||
if(_type == VENC_MJPEG && kvmv_cfg.frame_detact != 0){
|
||||
if(kvmv_cfg.stream_stop == 0){
|
||||
frame_undetact_count++;
|
||||
} else {
|
||||
frame_undetact_count = kvmv_cfg.frame_detact;
|
||||
}
|
||||
|
||||
if(frame_undetact_count == kvmv_cfg.frame_detact){
|
||||
frame_undetact_count = 0;
|
||||
if(frame_changed(img) == 0){
|
||||
debug("[kvmv]frame not changed...\n");
|
||||
kvmv_cfg.stream_stop = 1;
|
||||
delete img;
|
||||
pthread_mutex_unlock(&vi_mutex);
|
||||
return 5;
|
||||
} else {
|
||||
kvmv_cfg.stream_stop = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(img != NULL){
|
||||
// frame detect
|
||||
if(_type == VENC_MJPEG && kvmv_cfg.frame_detact != 0){
|
||||
debug("[kvmv] read - 4...\n");
|
||||
if(kvmv_cfg.stream_stop == 0){
|
||||
frame_undetact_count++;
|
||||
} else {
|
||||
frame_undetact_count = kvmv_cfg.frame_detact;
|
||||
}
|
||||
|
||||
debug("[kvmv] read - 5...\n");
|
||||
if(frame_undetact_count == kvmv_cfg.frame_detact){
|
||||
frame_undetact_count = 0;
|
||||
|
||||
debug("[kvmv] read - 6...\n");
|
||||
if(frame_changed(img) == 0){
|
||||
debug("[kvmv]frame not changed...\n");
|
||||
kvmv_cfg.stream_stop = 1;
|
||||
delete img;
|
||||
pthread_mutex_unlock(&vi_mutex);
|
||||
return 5;
|
||||
} else {
|
||||
kvmv_cfg.stream_stop = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(kvmv_cfg.cam_state == 0) {
|
||||
kvmv_cfg.cam_state = 1;
|
||||
kvmv_cfg.hdmi_cable_state = 1;
|
||||
|
||||
@@ -13,3 +13,16 @@ export function update() {
|
||||
timeout: 15 * 60 * 1000
|
||||
});
|
||||
}
|
||||
|
||||
// enable/disable preview updates
|
||||
export function setPreviewUpdates(enable: boolean) {
|
||||
const data = {
|
||||
enable
|
||||
};
|
||||
return http.post('/api/application/preview', data);
|
||||
}
|
||||
|
||||
// get preview updates state
|
||||
export function getPreviewUpdates() {
|
||||
return http.get('/api/application/preview');
|
||||
}
|
||||
|
||||
@@ -9,3 +9,16 @@ export function paste(content: string) {
|
||||
export function reset() {
|
||||
return http.post('/api/hid/reset');
|
||||
}
|
||||
|
||||
// get hid mode
|
||||
export function getHidMode() {
|
||||
return http.get('/api/hid/mode');
|
||||
}
|
||||
|
||||
// set hid mode
|
||||
export function setHidMode(mode: string) {
|
||||
const data = {
|
||||
mode
|
||||
};
|
||||
return http.post('/api/hid/mode', data);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ const en = {
|
||||
},
|
||||
mouse: {
|
||||
title: 'Mouse',
|
||||
cursor: 'Cursor style',
|
||||
default: 'Default cursor',
|
||||
pointer: 'Pointer cursor',
|
||||
cell: 'Cell cursor',
|
||||
@@ -86,7 +87,16 @@ const en = {
|
||||
absolute: 'Absolute mode',
|
||||
relative: 'Relative mode',
|
||||
requestPointer: 'Using relative mode. Please click desktop to get mouse pointer.',
|
||||
resetHid: 'Reset HID'
|
||||
resetHid: 'Reset HID',
|
||||
hidOnly: {
|
||||
title: 'HID-Only mode',
|
||||
desc: "If your mouse and keyboard stop responding and resetting HID doesn't help, it could be a compatibility issue between the NanoKVM and the device. Try to enable HID-Only mode for better compatibility.",
|
||||
tip1: 'Enabling HID-Only mode will unmount the virtual U-disk and virtual network',
|
||||
tip2: 'In HID-Only mode, image mounting is disabled',
|
||||
tip3: 'NanoKVM will automatically reboot after switching modes',
|
||||
enable: 'Enable HID-Only mode',
|
||||
disable: 'Disable HID-Only mode'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Images',
|
||||
@@ -214,6 +224,7 @@ const en = {
|
||||
description: 'Enable mDNS discovery service',
|
||||
tip: "Turning it off if it's not needed"
|
||||
},
|
||||
hidOnly: 'HID-Only Mode',
|
||||
disk: 'Virtual Disk',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
network: 'Virtual Network',
|
||||
@@ -268,7 +279,11 @@ const en = {
|
||||
available: 'An update is available. Are you sure you want to update now?',
|
||||
updating: 'Update started. Please wait...',
|
||||
confirm: 'Confirm',
|
||||
cancel: 'Cancel'
|
||||
cancel: 'Cancel',
|
||||
preview: 'Preview Updates',
|
||||
previewDesc: 'Get early access to new features and improvements',
|
||||
previewTip:
|
||||
'Please be aware that preview releases may contain bugs or incomplete functionality!'
|
||||
},
|
||||
account: {
|
||||
title: 'Account',
|
||||
|
||||
@@ -71,7 +71,9 @@ const zh = {
|
||||
ctrlaltdel: 'Ctrl+Alt+Del'
|
||||
},
|
||||
mouse: {
|
||||
default: '默认指针',
|
||||
title: '鼠标',
|
||||
cursor: '光标样式',
|
||||
default: '默认光标',
|
||||
pointer: '悬浮指针',
|
||||
cell: '单元指针',
|
||||
text: '文本指针',
|
||||
@@ -81,7 +83,16 @@ const zh = {
|
||||
absolute: '绝对模式',
|
||||
relative: '相对模式',
|
||||
requestPointer: '正在使用鼠标相对模式,请点击桌面获取鼠标指针。',
|
||||
resetHid: '重置 HID'
|
||||
resetHid: '重置 HID',
|
||||
hidOnly: {
|
||||
title: 'HID-Only 模式',
|
||||
desc: '若使用过程中遇到鼠标键盘无响应,且重置 HID 无效,可能是 NanoKVM 与您的设备存在兼容性问题。建议尝试启用 HID-Only 模式以提升兼容性。',
|
||||
tip1: '启用该模式会禁用虚拟U盘和虚拟网卡',
|
||||
tip2: '该模式下无法使用镜像挂载功能',
|
||||
tip3: '切换模式后将自动重启 NanoKVM',
|
||||
enable: '启用 HID-Only 模式',
|
||||
disable: '关闭 HID-Only 模式'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: '镜像',
|
||||
@@ -197,6 +208,7 @@ const zh = {
|
||||
description: '启用 mDNS 发现服务',
|
||||
tip: '如果您未使用此功能,建议将其关闭'
|
||||
},
|
||||
hidOnly: 'HID-Only 模式',
|
||||
disk: '虚拟U盘',
|
||||
diskDesc: '在远程主机中挂载虚拟U盘',
|
||||
network: '虚拟网卡',
|
||||
@@ -246,7 +258,10 @@ const zh = {
|
||||
available: '有新的可用版本,确定要更新吗?',
|
||||
updating: '更新中,请稍候...',
|
||||
confirm: '确定',
|
||||
cancel: '取消'
|
||||
cancel: '取消',
|
||||
preview: '预览更新',
|
||||
previewDesc: '率先体验即将推出的新功能和优化',
|
||||
previewTip: '预览版更新可能包含一些不稳定因素或未完善的功能!'
|
||||
},
|
||||
account: {
|
||||
title: '帐号',
|
||||
|
||||
@@ -5,3 +5,6 @@ export const mouseStyleAtom = atom('cursor-default');
|
||||
|
||||
// mouse mode: absolute or relative
|
||||
export const mouseModeAtom = atom('absolute');
|
||||
|
||||
// hid mode: normal or hid-only
|
||||
export const hidModeAtom = atom<'normal' | 'hid-only'>('normal');
|
||||
|
||||
54
web/src/pages/desktop/menu/mouse/cursor.tsx
Normal file
54
web/src/pages/desktop/menu/mouse/cursor.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Popover } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom } from 'jotai';
|
||||
import { EyeOffIcon, HandIcon, MousePointerIcon, PlusIcon, TextCursorIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as ls from '@/lib/localstorage.ts';
|
||||
import { mouseStyleAtom } from '@/jotai/mouse.ts';
|
||||
|
||||
export const Cursor = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [mouseStyle, setMouseStyle] = useAtom(mouseStyleAtom);
|
||||
|
||||
const mouseStyles = [
|
||||
{ name: t('mouse.default'), icon: <MousePointerIcon size={14} />, value: 'cursor-default' },
|
||||
{ name: t('mouse.grab'), icon: <HandIcon size={14} />, value: 'cursor-grab' },
|
||||
{ name: t('mouse.cell'), icon: <PlusIcon size={14} />, value: 'cursor-cell' },
|
||||
{ name: t('mouse.text'), icon: <TextCursorIcon size={14} />, value: 'cursor-text' },
|
||||
{ name: t('mouse.hide'), icon: <EyeOffIcon size={14} />, value: 'cursor-none' }
|
||||
];
|
||||
|
||||
function updateMouseStyle(style: string) {
|
||||
setMouseStyle(style);
|
||||
ls.setMouseStyle(style);
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{mouseStyles.map((style) => (
|
||||
<div
|
||||
key={style.value}
|
||||
className={clsx(
|
||||
'flex cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-3 pr-6 hover:bg-neutral-700/70',
|
||||
style.value === mouseStyle && 'text-green-500'
|
||||
)}
|
||||
onClick={() => updateMouseStyle(style.value)}
|
||||
>
|
||||
<div className="flex h-[14px] w-[20px] items-end">{style.icon}</div>
|
||||
<span>{style.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop" arrow={true} trigger="hover">
|
||||
<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>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
112
web/src/pages/desktop/menu/mouse/hid-mode.tsx
Normal file
112
web/src/pages/desktop/menu/mouse/hid-mode.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Divider, Modal, Typography } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom } from 'jotai';
|
||||
import { PenIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/hid.ts';
|
||||
import { hidModeAtom } from '@/jotai/mouse.ts';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
|
||||
export const HidMode = () => {
|
||||
const { t } = useTranslation();
|
||||
const [hidMode, setHidMode] = useAtom(hidModeAtom);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [errMsg, setErrMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getHidMode();
|
||||
}, []);
|
||||
|
||||
function getHidMode() {
|
||||
setIsLoading(true);
|
||||
|
||||
api
|
||||
.getHidMode()
|
||||
.then((rsp) => {
|
||||
if (rsp.code !== 0) {
|
||||
setErrMsg(rsp.msg);
|
||||
return;
|
||||
}
|
||||
|
||||
setHidMode(rsp.data.mode);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
function updateHidMode() {
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 30000);
|
||||
|
||||
const mode = hidMode === 'normal' ? 'hid-only' : 'normal';
|
||||
|
||||
api
|
||||
.setHidMode(mode)
|
||||
.then((rsp) => {
|
||||
if (rsp.code !== 0) {
|
||||
setErrMsg(rsp.msg);
|
||||
setIsLoading(false);
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
setIsLoading(false);
|
||||
clearTimeout(timeoutId);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex h-[30px] cursor-pointer select-none items-center space-x-2 rounded px-3 hover:bg-neutral-700/70',
|
||||
hidMode === 'hid-only' ? 'text-blue-500' : 'text-neutral-300'
|
||||
)}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<PenIcon size={18} />
|
||||
<span>{t('mouse.hidOnly.title')}</span>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={isModalOpen}
|
||||
title={t('mouse.hidOnly.title')}
|
||||
width={580}
|
||||
centered={false}
|
||||
footer={false}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
>
|
||||
<Divider />
|
||||
|
||||
<Paragraph>{t('mouse.hidOnly.desc')}</Paragraph>
|
||||
|
||||
<Paragraph type="secondary">
|
||||
<ul>
|
||||
<li>{t('mouse.hidOnly.tip1')}</li>
|
||||
<li>{t('mouse.hidOnly.tip2')}</li>
|
||||
<li>{t('mouse.hidOnly.tip3')}</li>
|
||||
</ul>
|
||||
</Paragraph>
|
||||
|
||||
{errMsg && <div className="pt-1 text-sm text-red-500">{errMsg}</div>}
|
||||
|
||||
<div className="flex justify-center pt-5">
|
||||
<Button danger type="primary" loading={isLoading} onClick={updateHidMode}>
|
||||
{hidMode === 'normal' ? t('mouse.hidOnly.enable') : t('mouse.hidOnly.disable')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,149 +1,44 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Divider, Popover } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom } from 'jotai';
|
||||
import {
|
||||
CheckIcon,
|
||||
EyeOffIcon,
|
||||
HandIcon,
|
||||
MouseIcon,
|
||||
MousePointerIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
SquareMousePointerIcon,
|
||||
TextCursorIcon
|
||||
} from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { Divider } from 'antd';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { MouseIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/hid';
|
||||
import * as ls from '@/lib/localstorage';
|
||||
import { client } from '@/lib/websocket';
|
||||
import { mouseModeAtom, mouseStyleAtom } 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';
|
||||
|
||||
export const Mouse = () => {
|
||||
const { t } = useTranslation();
|
||||
const [mouseStyle, setMouseStyle] = useAtom(mouseStyleAtom);
|
||||
const [mouseMode, setMouseMode] = useAtom(mouseModeAtom);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const mouseStyles = [
|
||||
{ name: t('mouse.default'), icon: <MousePointerIcon size={14} />, value: 'cursor-default' },
|
||||
{ name: t('mouse.grab'), icon: <HandIcon size={14} />, value: 'cursor-grab' },
|
||||
{ name: t('mouse.cell'), icon: <PlusIcon size={14} />, value: 'cursor-cell' },
|
||||
{ name: t('mouse.text'), icon: <TextCursorIcon size={14} />, value: 'cursor-text' },
|
||||
{ name: t('mouse.hide'), icon: <EyeOffIcon size={14} />, value: 'cursor-none' }
|
||||
];
|
||||
|
||||
const mouseModes = [
|
||||
{ name: t('mouse.absolute'), value: 'absolute' },
|
||||
{ name: t('mouse.relative'), value: 'relative' }
|
||||
];
|
||||
const setMouseStyle = useSetAtom(mouseStyleAtom);
|
||||
const setMouseMode = useSetAtom(mouseModeAtom);
|
||||
|
||||
useEffect(() => {
|
||||
const style = ls.getMouseStyle();
|
||||
if (style && style !== mouseStyle) {
|
||||
setMouseStyle(style);
|
||||
const mouseStyle = ls.getMouseStyle();
|
||||
if (mouseStyle) {
|
||||
setMouseStyle(mouseStyle);
|
||||
}
|
||||
|
||||
const mode = ls.getMouseMode();
|
||||
if (mode && mode !== mouseMode) {
|
||||
setMouseMode(mode);
|
||||
const mouseMode = ls.getMouseMode();
|
||||
if (mouseMode) {
|
||||
setMouseMode(mouseMode);
|
||||
}
|
||||
}, []);
|
||||
|
||||
function updateStyle(style: string) {
|
||||
setMouseStyle(style);
|
||||
ls.setMouseStyle(style);
|
||||
}
|
||||
|
||||
function updateMode(mode: string) {
|
||||
setMouseMode(mode);
|
||||
ls.setMouseMode(mode);
|
||||
|
||||
if (mode === 'relative') {
|
||||
client.close();
|
||||
setTimeout(() => {
|
||||
client.connect();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
function resetHid() {
|
||||
if (isResetting) return;
|
||||
setIsResetting(true);
|
||||
|
||||
client.close();
|
||||
|
||||
api.reset().finally(() => {
|
||||
client.connect();
|
||||
setIsResetting(false);
|
||||
});
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{/* mouse cursor */}
|
||||
{mouseStyles.map((style) => (
|
||||
<div
|
||||
key={style.value}
|
||||
className={clsx(
|
||||
'flex cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-3 pr-6 hover:bg-neutral-700/70',
|
||||
style.value === mouseStyle && 'text-green-500'
|
||||
)}
|
||||
onClick={() => updateStyle(style.value)}
|
||||
>
|
||||
<div className="flex h-[14px] w-[20px] items-end">{style.icon}</div>
|
||||
<span>{style.name}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Cursor />
|
||||
<MouseMode />
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
|
||||
{/* mouse mode */}
|
||||
<Popover
|
||||
content={
|
||||
<>
|
||||
{mouseModes.map((mode) => (
|
||||
<div
|
||||
key={mode.value}
|
||||
className="flex cursor-pointer items-center space-x-1 rounded py-1.5 pl-2 pr-5 hover:bg-neutral-700/70"
|
||||
onClick={() => updateMode(mode.value)}
|
||||
>
|
||||
<div className="flex h-[16px] w-[16px] items-end text-blue-500">
|
||||
{mode.value === mouseMode && <CheckIcon strokeWidth={3} size={16} />}
|
||||
</div>
|
||||
<span>{mode.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
placement="rightTop"
|
||||
arrow={true}
|
||||
trigger="hover"
|
||||
>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-1 rounded pl-3 pr-5 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<div className="flex h-[14px] w-[20px] items-end">
|
||||
<SquareMousePointerIcon size={14} />
|
||||
</div>
|
||||
<span>{t('mouse.mode')}</span>
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
{/* reset HID */}
|
||||
<div
|
||||
className="flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1 hover:bg-neutral-700/70"
|
||||
onClick={resetHid}
|
||||
>
|
||||
<div className="flex h-[14px] w-[20px] items-end">
|
||||
<RefreshCwIcon
|
||||
className={clsx({ 'animate-spin text-blue-500': isResetting })}
|
||||
size={14}
|
||||
/>
|
||||
</div>
|
||||
<span>{t('mouse.resetHid')}</span>
|
||||
</div>
|
||||
</>
|
||||
<HidMode />
|
||||
<ResetHid />
|
||||
</div>
|
||||
);
|
||||
|
||||
return <MenuItem title={t('mouse.title')} icon={<MouseIcon size={18} />} content={content} />;
|
||||
|
||||
57
web/src/pages/desktop/menu/mouse/mouse-mode.tsx
Normal file
57
web/src/pages/desktop/menu/mouse/mouse-mode.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Popover } from 'antd';
|
||||
import { useAtom } from 'jotai';
|
||||
import { CheckIcon, SquareDashedMousePointerIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as ls from '@/lib/localstorage.ts';
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
import { mouseModeAtom } from '@/jotai/mouse.ts';
|
||||
|
||||
export const MouseMode = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [mouseMode, setMouseMode] = useAtom(mouseModeAtom);
|
||||
|
||||
const mouseModes = [
|
||||
{ name: t('mouse.absolute'), value: 'absolute' },
|
||||
{ name: t('mouse.relative'), value: 'relative' }
|
||||
];
|
||||
|
||||
function updateMouseMode(mode: string) {
|
||||
setMouseMode(mode);
|
||||
ls.setMouseMode(mode);
|
||||
|
||||
if (mode === 'relative') {
|
||||
client.close();
|
||||
setTimeout(() => {
|
||||
client.connect();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{mouseModes.map((mode) => (
|
||||
<div
|
||||
key={mode.value}
|
||||
className="flex cursor-pointer items-center space-x-1 rounded py-1.5 pl-2 pr-5 hover:bg-neutral-700/70"
|
||||
onClick={() => updateMouseMode(mode.value)}
|
||||
>
|
||||
<div className="flex h-[16px] w-[16px] items-end text-blue-500">
|
||||
{mode.value === mouseMode && <CheckIcon strokeWidth={3} size={16} />}
|
||||
</div>
|
||||
<span>{mode.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop" arrow={true} trigger="hover">
|
||||
<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>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
35
web/src/pages/desktop/menu/mouse/reset-hid.tsx
Normal file
35
web/src/pages/desktop/menu/mouse/reset-hid.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { RefreshCwIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/hid.ts';
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
|
||||
export const ResetHid = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
function resetHid() {
|
||||
if (isResetting) return;
|
||||
setIsResetting(true);
|
||||
|
||||
client.close();
|
||||
|
||||
api.reset().finally(() => {
|
||||
client.connect();
|
||||
setIsResetting(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-[30px] cursor-pointer select-none items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70"
|
||||
onClick={resetHid}
|
||||
>
|
||||
<RefreshCwIcon className={clsx({ 'animate-spin text-blue-500': isResetting })} size={18} />
|
||||
<span>{t('mouse.resetHid')}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
16
web/src/pages/desktop/menu/settings/device/hid-mode.tsx
Normal file
16
web/src/pages/desktop/menu/settings/device/hid-mode.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Switch } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const HidMode = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span>{t('settings.device.hidOnly')}</span>
|
||||
</div>
|
||||
|
||||
<Switch checked={true} disabled={true} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Divider } from 'antd';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/hid.ts';
|
||||
import { hidModeAtom } from '@/jotai/mouse.ts';
|
||||
|
||||
import { HidMode } from './hid-mode.tsx';
|
||||
import { Mdns } from './mdns.tsx';
|
||||
import { Oled } from './oled.tsx';
|
||||
import { Reboot } from './reboot.tsx';
|
||||
@@ -11,6 +17,16 @@ import { Wifi } from './wifi.tsx';
|
||||
export const Device = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [hidMode, setHidMode] = useAtom(hidModeAtom);
|
||||
|
||||
useEffect(() => {
|
||||
api.getHidMode().then((rsp) => {
|
||||
if (rsp.code === 0) {
|
||||
setHidMode(rsp.data.mode);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-base font-bold">{t('settings.device.title')}</div>
|
||||
@@ -21,7 +37,7 @@ export const Device = () => {
|
||||
<Wifi />
|
||||
<Ssh />
|
||||
<Mdns />
|
||||
<VirtualDevices />
|
||||
{hidMode === 'normal' ? <VirtualDevices /> : <HidMode />}
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
|
||||
@@ -44,18 +44,20 @@ export const Mdns = () => {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span>mDNS</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>mDNS</span>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="text-xs text-neutral-500">{t('settings.device.mdns.description')}</span>
|
||||
<Tooltip
|
||||
title={t('settings.device.mdns.tip')}
|
||||
className="cursor-pointer text-neutral-500"
|
||||
className="cursor-pointer"
|
||||
placement="bottom"
|
||||
overlayStyle={{ maxWidth: '300px' }}
|
||||
>
|
||||
<CircleAlertIcon size={16} />
|
||||
<CircleAlertIcon size={15} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-neutral-500">{t('settings.device.mdns.description')}</span>
|
||||
</div>
|
||||
|
||||
<Switch checked={isEnabled} loading={isLoading} onChange={update} />
|
||||
|
||||
@@ -44,18 +44,19 @@ export const Ssh = () => {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span>SSH</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>SSH</span>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="text-xs text-neutral-500">{t('settings.device.ssh.description')}</span>
|
||||
<Tooltip
|
||||
title={t('settings.device.ssh.tip')}
|
||||
className="cursor-pointer text-neutral-500"
|
||||
className="cursor-pointer"
|
||||
placement="bottom"
|
||||
overlayStyle={{ maxWidth: '300px' }}
|
||||
>
|
||||
<CircleAlertIcon size={16} />
|
||||
<CircleAlertIcon size={15} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-500">{t('settings.device.ssh.description')}</span>
|
||||
</div>
|
||||
|
||||
<Switch checked={isEnabled} loading={isLoading} onChange={update} />
|
||||
|
||||
@@ -6,6 +6,8 @@ import semver from 'semver';
|
||||
|
||||
import * as api from '@/api/application.ts';
|
||||
|
||||
import { Preview } from './preview.tsx';
|
||||
|
||||
type UpdateProps = {
|
||||
setIsLocked: (isClosable: boolean) => void;
|
||||
};
|
||||
@@ -77,6 +79,9 @@ export const Update = ({ setIsLocked }: UpdateProps) => {
|
||||
<div className="text-base font-bold">{t('settings.update.title')}</div>
|
||||
<Divider />
|
||||
|
||||
<Preview />
|
||||
<Divider />
|
||||
|
||||
{status === 'loading' && (
|
||||
<div className="flex justify-center pt-24">
|
||||
<Spin indicator={<LoadingOutlined spin />} size="large" />
|
||||
@@ -84,7 +89,7 @@ export const Update = ({ setIsLocked }: UpdateProps) => {
|
||||
)}
|
||||
|
||||
{status === 'updating' && (
|
||||
<div className="flex flex-col items-center justify-center space-y-10 pt-24">
|
||||
<div className="flex flex-col items-center justify-center space-y-10 pb-10 pt-24">
|
||||
<Spin size="large" />
|
||||
<span className="text-blue-600">{t('settings.update.updating')}</span>
|
||||
</div>
|
||||
|
||||
79
web/src/pages/desktop/menu/settings/update/preview.tsx
Normal file
79
web/src/pages/desktop/menu/settings/update/preview.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
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/application.ts';
|
||||
|
||||
export const Preview = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getPreviewUpdates();
|
||||
}, []);
|
||||
|
||||
function getPreviewUpdates() {
|
||||
setIsLoading(true);
|
||||
|
||||
api
|
||||
.getPreviewUpdates()
|
||||
.then((rsp) => {
|
||||
if (rsp.code !== 0) {
|
||||
console.log(rsp.msg);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEnabled(rsp.data.enabled);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
function setPreviewUpdates() {
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
|
||||
const enable = !isEnabled;
|
||||
|
||||
api
|
||||
.setPreviewUpdates(enable)
|
||||
.then((rsp) => {
|
||||
if (rsp.code !== 0) {
|
||||
console.log(rsp.msg);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEnabled(enable);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>{t('settings.update.preview')}</span>
|
||||
|
||||
<Tooltip
|
||||
title={t('settings.update.previewTip')}
|
||||
className="cursor-pointer"
|
||||
placement="bottom"
|
||||
overlayStyle={{ maxWidth: '350px' }}
|
||||
>
|
||||
<CircleAlertIcon size={15} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-neutral-500">{t('settings.update.previewDesc')}</span>
|
||||
</div>
|
||||
|
||||
<Switch checked={isEnabled} loading={isLoading} onChange={setPreviewUpdates} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user