Compare commits

..

3 Commits
2.2.4 ... 2.2.5

Author SHA1 Message Date
wj-xiao
3286cc2f85 feat: add HID-Only mode
feat: support preview updates
perf: optimize screen parameters for reading image
2025-03-26 15:26:59 +08:00
BuGu
dbc74834cd Fix the issue of the server frequently crashing in version 2.2.4. 2025-03-24 17:22:48 +08:00
wj-xiao
f1b411e3b6 update CHANGELOG.md 2025-03-21 18:46:21 +08:00
31 changed files with 877 additions and 265 deletions

View File

@@ -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`

View File

@@ -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.

View File

@@ -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
View 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
}

View File

@@ -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
}

View File

@@ -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
}

View 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
}

View File

@@ -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"

View File

@@ -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)

View 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
View 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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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;

View File

@@ -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');
}

View File

@@ -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);
}

View File

@@ -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',

View File

@@ -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: '帐号',

View File

@@ -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');

View 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>
);
};

View 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>
</>
);
};

View File

@@ -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} />;

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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 />

View File

@@ -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} />

View File

@@ -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} />

View File

@@ -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>

View 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>
);
};