Streaming Refactor:

- H.264 WebRTC: Refactored to significantly reduce video latency
- H.264 Direct: Optimized data transmission; data parsing now continues correctly even when the tab is in the background
- MJPEG: Refactored to ensure the correct data length is sent

Bug Fixes
- Fixed an issue where certain keyboard modifier keys were not recognized
- Fixed vertical mouse cursor drift when the page is zoomed in or out

Optimizations
- Optimized HID write logic and updated the HID reset mechanism
- Added support for deleting images
- Added a Swap Memory option to the Tailscale page
- Added a confirmation dialog when uninstalling Tailscale to prevent accidental removal
- Improved the logic for updating the web page title
- Improved the UI for the Clipboard, Settings, Image Mounting, and App Update pages

Security
- Added a mandatory delay after failed login attempts to prevent brute-force attacks
- Updated dependencies to patch known security vulnerabilities
This commit is contained in:
wj-xiao
2025-11-26 10:57:30 +08:00
parent 24a460e64a
commit 0e30a26db4
76 changed files with 3401 additions and 2068 deletions

64
server/build.sh Executable file
View File

@@ -0,0 +1,64 @@
#!/bin/bash
set -e
# Configuration Variables
BINARY_NAME="NanoKVM-Server"
CC_COMPILER="riscv64-unknown-linux-musl-gcc"
CGO_CFLAGS_OPTS="-mcpu=c906fdv -march=rv64imafdcv0p7xthead -mcmodel=medany -mabi=lp64d"
# Define colors for output
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Helper function to check if a command exists
check_dependency() {
if ! command -v "$1" &> /dev/null; then
echo -e "${RED}[ERROR] Required command '$1' not found.${NC}"
echo "Please install it or ensure it is in your PATH."
exit 1
fi
}
# ------------------------------------------------------------------------------
# Step 1: Check Prerequisites
# ------------------------------------------------------------------------------
echo -e "${YELLOW}[INFO] Checking build environment...${NC}"
check_dependency "go"
check_dependency "patchelf"
check_dependency "$CC_COMPILER"
echo -e "${GREEN}[OK] All dependencies found.${NC}"
# ------------------------------------------------------------------------------
# Step 2: Build the Binary
# ------------------------------------------------------------------------------
echo -e "${YELLOW}[INFO] Starting cross-compilation for RISC-V 64-bit (BoringCrypto enabled)...${NC}"
export CGO_ENABLED=1
export GOOS=linux
export GOARCH=riscv64
export GOEXPERIMENT=boringcrypto
export CC="$CC_COMPILER"
export CGO_CFLAGS="$CGO_CFLAGS_OPTS"
go build -o "$BINARY_NAME" -v
if [ -f "$BINARY_NAME" ]; then
echo -e "${GREEN}[SUCCESS] Binary '$BINARY_NAME' created successfully.${NC}"
else
echo -e "${RED}[ERROR] Build failed. Binary not found.${NC}"
exit 1
fi
# ------------------------------------------------------------------------------
# Step 3: Patch RPATH
# ------------------------------------------------------------------------------
echo -e "${YELLOW}[INFO] Patching RPATH with patchelf...${NC}"
patchelf --add-rpath '$ORIGIN/dl_lib' "$BINARY_NAME"
echo -e "${GREEN}[DONE] Build script completed successfully!${NC}"

View File

@@ -7,8 +7,6 @@ package common
*/
import "C"
import (
"NanoKVM-Server/config"
"strings"
"sync"
"unsafe"
@@ -26,15 +24,8 @@ func GetKvmVision() *KvmVision {
kvmVisionOnce.Do(func() {
kvmVision = &KvmVision{}
conf := config.GetInstance()
logLevel := strings.ToLower(conf.Logger.Level)
logEnable := C.uint8_t(0)
if logLevel == "debug" {
logEnable = C.uint8_t(1)
}
C.kvmv_init(logEnable)
logLevel := C.uint8_t(0)
C.kvmv_init(logLevel)
log.Debugf("kvm vision initialized")
})
@@ -62,8 +53,6 @@ func (k *KvmVision) ReadMjpeg(width uint16, height uint16, quality uint16) (data
defer C.free_kvmv_data(&kvmData)
data = C.GoBytes(unsafe.Pointer(kvmData), C.int(dataSize))
log.Debugf("read kvm image: %v", result)
return
}
@@ -88,8 +77,6 @@ func (k *KvmVision) ReadH264(width uint16, height uint16, bitRate uint16) (data
defer C.free_kvmv_data(&kvmData)
data = C.GoBytes(unsafe.Pointer(kvmData), C.int(dataSize))
log.Debugf("read kvm image: %v", result)
return
}

View File

@@ -11,10 +11,8 @@ type HWVersion int
const (
HWVersionAlpha HWVersion = iota
HWVersionPro
HWVersionBeta
HWVersionPcie
HWVersionATX
HWVersionFile = "/etc/kvm/hw"
)
@@ -43,14 +41,6 @@ var HWPcie = Hardware{
GPIOHDDLed: "",
}
var HWPro = Hardware{
Version: HWVersionPro,
GPIOReset: "/sys/class/gpio/gpio35/value",
GPIOPower: "/sys/class/gpio/gpio7/value",
GPIOPowerLED: "/sys/class/gpio/gpio75/value",
GPIOHDDLed: "/sys/class/gpio/gpio74/value",
}
func (h HWVersion) String() string {
switch h {
case HWVersionAlpha:
@@ -59,8 +49,6 @@ func (h HWVersion) String() string {
return "Beta"
case HWVersionPcie:
return "PCIE"
case HWVersionPro:
return "Pro"
default:
return "Unknown"
}
@@ -80,10 +68,6 @@ func GetHwVersion() HWVersion {
return HWVersionBeta
case "pcie":
return HWVersionPcie
case "atx":
return HWVersionATX
case "pro":
return HWVersionPro
default:
return HWVersionAlpha
}
@@ -102,12 +86,10 @@ func getHardware() (h Hardware) {
case HWVersionPcie:
h = HWPcie
case HWVersionPro:
h = HWPro
default:
h = HWAlpha
log.Errorf("Unsupported hardware version: %s", version)
}
return h
return
}

View File

@@ -1,8 +1,6 @@
module NanoKVM-Server
go 1.23.0
toolchain go1.23.2
go 1.24.0
require (
github.com/creack/pty v1.1.24
@@ -12,12 +10,14 @@ require (
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/gorilla/websocket v1.5.3
github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0
github.com/pion/dtls/v3 v3.0.3
github.com/pion/rtp v1.8.18
github.com/pion/webrtc/v4 v4.0.1
github.com/rs/cors/wrapper/gin v0.0.0-20240830163046-1084d89a1692
github.com/sirupsen/logrus v1.9.3
github.com/spf13/viper v1.19.0
github.com/unrolled/secure v1.15.0
golang.org/x/crypto v0.37.0
golang.org/x/crypto v0.45.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -44,14 +44,12 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pion/datachannel v1.5.9 // indirect
github.com/pion/dtls/v3 v3.0.3 // indirect
github.com/pion/ice/v4 v4.0.2 // indirect
github.com/pion/interceptor v0.1.37 // indirect
github.com/pion/logging v0.2.2 // indirect
github.com/pion/interceptor v0.1.39 // indirect
github.com/pion/logging v0.2.3 // indirect
github.com/pion/mdns/v2 v2.0.7 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.14 // indirect
github.com/pion/rtp v1.8.9 // indirect
github.com/pion/rtcp v1.2.15 // indirect
github.com/pion/sctp v1.8.33 // indirect
github.com/pion/sdp/v3 v3.0.9 // indirect
github.com/pion/srtp/v3 v3.0.4 // indirect
@@ -73,9 +71,9 @@ require (
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
)

View File

@@ -34,8 +34,6 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
@@ -80,18 +78,18 @@ github.com/pion/dtls/v3 v3.0.3 h1:j5ajZbQwff7Z8k3pE3S+rQ4STvKvXUdKsi/07ka+OWM=
github.com/pion/dtls/v3 v3.0.3/go.mod h1:weOTUyIV4z0bQaVzKe8kpaP17+us3yAuiQsEAG1STMU=
github.com/pion/ice/v4 v4.0.2 h1:1JhBRX8iQLi0+TfcavTjPjI6GO41MFn4CeTBX+Y9h5s=
github.com/pion/ice/v4 v4.0.2/go.mod h1:DCdqyzgtsDNYN6/3U8044j3U7qsJ9KFJC92VnOWHvXg=
github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI=
github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/interceptor v0.1.39 h1:Y6k0bN9Y3Lg/Wb21JBWp480tohtns8ybJ037AGr9UuA=
github.com/pion/interceptor v0.1.39/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE=
github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
github.com/pion/rtp v1.8.9 h1:E2HX740TZKaqdcPmf4pw6ZZuG8u5RlMMt+l3dxeu6Wk=
github.com/pion/rtp v1.8.9/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
github.com/pion/rtp v1.8.18 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU=
github.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
github.com/pion/sctp v1.8.33 h1:dSE4wX6uTJBcNm8+YlMg7lw1wqyKHggsP5uKbdj+NZw=
github.com/pion/sctp v1.8.33/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM=
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
@@ -141,8 +139,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
@@ -160,35 +159,19 @@ go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTV
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -16,3 +16,7 @@ type GetMountedImageRsp struct {
type GetCdRomRsp struct {
Cdrom int64 `json:"cdrom"`
}
type DeleteImageReq struct {
File string `json:"file" validate:"required"`
}

View File

@@ -16,8 +16,4 @@ func applicationRouter(r *gin.Engine) {
api.GET("/application/preview", service.GetPreview) // get preview updates state
api.POST("/application/preview", service.SetPreview) // set preview updates state
api.GET("/application/settings/disabled-items", service.GetDisableItems) // get disable items list
api.GET("/application/menu/disabled-items", service.GetDisableMenus)
}

View File

@@ -11,9 +11,9 @@ func hidRouter(r *gin.Engine) {
service := hid.NewService()
api := r.Group("/api").Use(middleware.CheckToken())
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
api.POST("/hid/reset", service.ResetHid) // reset hid
}

View File

@@ -15,4 +15,5 @@ func storageRouter(r *gin.Engine) {
api.GET("/storage/image/mounted", service.GetMountedImage) // get mounted image
api.POST("/storage/image/mount", service.MountImage) // mount image
api.GET("/storage/cdrom", service.GetCdRom) // get CD-ROM flag
api.POST("/storage/image/delete", service.DeleteImage) // delete image
}

View File

@@ -3,8 +3,8 @@ package router
import (
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/stream/direct"
"NanoKVM-Server/service/stream/h264"
"NanoKVM-Server/service/stream/mjpeg"
"NanoKVM-Server/service/stream/webrtc"
"github.com/gin-gonic/gin"
)
@@ -16,6 +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 (webrtc)
api.GET("/stream/h264", webrtc.Connect) // h264 stream (webrtc)
api.GET("/stream/h264/direct", direct.Connect) // h264 stream (http)
}

View File

@@ -1,63 +0,0 @@
package application
import (
"NanoKVM-Server/config"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
const (
DeviceTLS = "device:tls"
DeviceSSH = "device:ssh"
DeviceMDNS = "device:mdns"
DeviceHDMI = "device:hdmi"
DeviceWIFI = "device:wifi"
DeviceMouse = "device:mouse"
DeviceReboot = "device:reboot"
DeviceOLED = "device:oled"
DeviceAdvance = "device:advance"
DeviceAdvancedSwap = "device:advance:swap"
)
const (
MenuScript = "script"
MenuImage = "image"
MenuDownload = "download"
MenuTerminal = "terminal"
MenuScreen = "screen"
MenuScreenResolution = "screen:resolution"
)
func (s *Service) GetDisableItems(ctx *gin.Context) {
var rsp proto.Response
switch config.GetHwVersion() {
case config.HWVersionPcie, config.HWVersionATX:
rsp.OkRspWithData(ctx, []string{
DeviceAdvancedSwap,
DeviceAdvance,
MenuScreenResolution,
})
log.Debugf("disable menus items %s", DeviceAdvancedSwap)
return
default:
rsp.OkRspWithData(ctx, []string{})
}
}
func (s *Service) GetDisableMenus(ctx *gin.Context) {
var rsp proto.Response
switch config.GetHwVersion() {
case config.HWVersionPcie, config.HWVersionATX:
rsp.OkRspWithData(ctx, []string{
DeviceAdvancedSwap,
DeviceAdvance,
MenuScreenResolution,
})
log.Debugf("disable menus items %s", DeviceAdvancedSwap)
return
default:
rsp.OkRspWithData(ctx, []string{})
}
}

View File

@@ -8,6 +8,7 @@ import (
"net/http"
"os"
"os/exec"
"sync"
"time"
"github.com/gin-gonic/gin"
@@ -21,9 +22,29 @@ const (
maxTries = 3
)
var (
updateMutex sync.Mutex
isUpdating bool
)
func (s *Service) Update(c *gin.Context) {
var rsp proto.Response
updateMutex.Lock()
if isUpdating {
updateMutex.Unlock()
rsp.ErrRsp(c, -1, "update already in progress")
return
}
isUpdating = true
updateMutex.Unlock()
defer func() {
updateMutex.Lock()
isUpdating = false
updateMutex.Unlock()
}()
if err := update(); err != nil {
rsp.ErrRsp(c, -1, fmt.Sprintf("update failed: %s", err))
return

View File

@@ -4,6 +4,7 @@ import (
"NanoKVM-Server/config"
"NanoKVM-Server/middleware"
"NanoKVM-Server/proto"
"time"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
@@ -23,17 +24,20 @@ func (s *Service) Login(c *gin.Context) {
}
if err := proto.ParseFormRequest(c, &req); err != nil {
time.Sleep(3 * time.Second)
rsp.ErrRsp(c, -1, "invalid parameters")
return
}
if ok := CompareAccount(req.Username, req.Password); !ok {
time.Sleep(2 * time.Second)
rsp.ErrRsp(c, -2, "invalid username or password")
return
}
token, err := middleware.GenerateJWT(req.Username)
if err != nil {
time.Sleep(1 * time.Second)
rsp.ErrRsp(c, -3, "generate token failed")
return
}

View File

@@ -69,8 +69,6 @@ func (c *Cli) Stop() error {
return err
}
_ = os.Remove(ConfigPath)
return os.Remove(ScriptPath)
}

View File

@@ -15,7 +15,6 @@ type Service struct{}
const (
TailscalePath = "/usr/bin/tailscale"
TailscaledPath = "/usr/sbin/tailscaled"
ConfigPath = "etc/sysctl.d/99-tailscale.conf"
GoMemLimit int64 = 75
)
@@ -40,6 +39,8 @@ func (s *Service) Install(c *gin.Context) {
rsp.ErrRsp(c, -1, "install failed")
return
}
_ = NewCli().Start()
}
rsp.OkRsp(c)
@@ -54,7 +55,6 @@ func (s *Service) Uninstall(c *gin.Context) {
_ = os.Remove(TailscalePath)
_ = os.Remove(TailscaledPath)
_ = os.Remove(ConfigPath)
rsp.OkRsp(c)
log.Debugf("uninstall tailscale successfully")

View File

@@ -1,13 +1,12 @@
package hid
import (
"errors"
"os"
"sync"
)
"time"
var (
hid *Hid
hidOnce sync.Once
log "github.com/sirupsen/logrus"
)
type Hid struct {
@@ -18,6 +17,24 @@ type Hid struct {
mouseMutex sync.Mutex
}
const (
HID0 = "/dev/hidg0"
HID1 = "/dev/hidg1"
HID2 = "/dev/hidg2"
)
var (
hid *Hid
hidOnce sync.Once
)
func GetHid() *Hid {
hidOnce.Do(func() {
hid = &Hid{}
})
return hid
}
func (h *Hid) Lock() {
h.kbMutex.Lock()
h.mouseMutex.Lock()
@@ -28,9 +45,117 @@ func (h *Hid) Unlock() {
h.mouseMutex.Unlock()
}
func GetHid() *Hid {
hidOnce.Do(func() {
hid = &Hid{}
})
return hid
func (h *Hid) OpenNoLock() {
var err error
h.CloseNoLock()
h.g0, err = os.OpenFile(HID0, os.O_WRONLY, 0o666)
if err != nil {
log.Errorf("open %s failed: %s", HID0, err)
}
h.g1, err = os.OpenFile(HID1, os.O_WRONLY, 0o666)
if err != nil {
log.Errorf("open %s failed: %s", HID1, err)
}
h.g2, err = os.OpenFile(HID2, os.O_WRONLY, 0o666)
if err != nil {
log.Errorf("open %s failed: %s", HID2, err)
}
}
func (h *Hid) CloseNoLock() {
for _, file := range []*os.File{h.g0, h.g1, h.g2} {
if file != nil {
_ = file.Sync()
_ = file.Close()
}
}
}
func (h *Hid) Open() {
h.kbMutex.Lock()
defer h.kbMutex.Unlock()
h.mouseMutex.Lock()
defer h.mouseMutex.Unlock()
h.CloseNoLock()
h.OpenNoLock()
}
func (h *Hid) Close() {
h.kbMutex.Lock()
defer h.kbMutex.Unlock()
h.mouseMutex.Lock()
defer h.mouseMutex.Unlock()
h.CloseNoLock()
}
func (h *Hid) WriteHid0(data []byte) {
h.kbMutex.Lock()
_, err := h.g0.Write(data)
h.kbMutex.Unlock()
if err != nil {
if errors.Is(err, os.ErrClosed) {
log.Errorf("hid already closed, reopen it...")
h.OpenNoLock()
} else {
log.Debugf("write to %s failed: %s", HID0, err)
}
return
}
log.Debugf("write to %s: %v", HID0, data)
}
func (h *Hid) WriteHid1(data []byte) {
deadline := time.Now().Add(8 * time.Millisecond)
h.mouseMutex.Lock()
_ = h.g1.SetWriteDeadline(deadline)
_, err := h.g1.Write(data)
h.mouseMutex.Unlock()
if err != nil {
switch {
case errors.Is(err, os.ErrClosed):
log.Errorf("hid already closed, reopen it...")
h.OpenNoLock()
case errors.Is(err, os.ErrDeadlineExceeded):
log.Debugf("write to %s timeout", HID1)
default:
log.Errorf("write to %s failed: %s", HID1, err)
}
return
}
log.Debugf("write to %s: %v", HID1, data)
}
func (h *Hid) WriteHid2(data []byte) {
deadline := time.Now().Add(8 * time.Millisecond)
h.mouseMutex.Lock()
_ = h.g2.SetWriteDeadline(deadline)
_, err := h.g2.Write(data)
h.mouseMutex.Unlock()
if err != nil {
switch {
case errors.Is(err, os.ErrClosed):
log.Errorf("hid already closed, reopen it...")
h.OpenNoLock()
case errors.Is(err, os.ErrDeadlineExceeded):
log.Debugf("write to %s timeout", HID2)
default:
log.Errorf("write to %s failed: %s", HID2, err)
}
return
}
log.Debugf("write to %s: %v", HID2, data)
}

View File

@@ -2,20 +2,14 @@ package hid
func (h *Hid) Keyboard(queue <-chan []int) {
for event := range queue {
h.kbMutex.Lock()
h.writeKeyboard(event)
h.kbMutex.Unlock()
code := byte(event[0])
var modifier byte = 0x00
if code > 0 {
modifier = byte(event[1]) | byte(event[2]) | byte(event[3]) | byte(event[4])
}
data := []byte{modifier, 0x00, code, 0x00, 0x00, 0x00, 0x00, 0x00}
h.WriteHid0(data)
}
}
func (h *Hid) writeKeyboard(event []int) {
code := byte(event[0])
var modifier byte = 0x00
if code > 0 {
modifier = byte(event[1]) | byte(event[2]) | byte(event[3]) | byte(event[4])
}
data := []byte{modifier, 0x00, code, 0x00, 0x00, 0x00, 0x00, 0x00}
h.Write(h.g0, data)
}

View File

@@ -2,9 +2,6 @@ package hid
import (
"encoding/binary"
"errors"
"os"
"time"
log "github.com/sirupsen/logrus"
)
@@ -25,7 +22,6 @@ var mouseButtonMap = map[byte]bool{
func (h *Hid) Mouse(queue <-chan []int) {
for event := range queue {
h.mouseMutex.Lock()
switch event[0] {
case MouseDown:
@@ -39,10 +35,8 @@ func (h *Hid) Mouse(queue <-chan []int) {
case MouseScroll:
h.mouseScroll(event)
default:
log.Debugf("invalid mouse event: %+v", event)
log.Debugf("invalid mouse event: %v", event)
}
h.mouseMutex.Unlock()
}
}
@@ -50,17 +44,17 @@ func (h *Hid) mouseDown(event []int) {
button := byte(event[1])
if _, ok := mouseButtonMap[button]; !ok {
log.Debugf("invalid mouse button: %+v", event)
log.Errorf("invalid mouse button: %v", event)
return
}
data := []byte{button, 0, 0, 0}
h.writeWithTimeout(h.g1, data)
h.WriteHid1(data)
}
func (h *Hid) mouseUp() {
data := []byte{0, 0, 0, 0}
h.writeWithTimeout(h.g1, data)
h.WriteHid1(data)
}
func (h *Hid) mouseScroll(event []int) {
@@ -70,9 +64,8 @@ func (h *Hid) mouseScroll(event []int) {
}
data := []byte{0, 0, 0, byte(direction)}
h.writeWithTimeout(h.g1, data)
h.WriteHid1(data)
}
func (h *Hid) mouseMoveAbsolute(event []int) {
x := make([]byte, 2)
y := make([]byte, 2)
@@ -80,32 +73,10 @@ func (h *Hid) mouseMoveAbsolute(event []int) {
binary.LittleEndian.PutUint16(y, uint16(event[3]))
data := []byte{0, x[0], x[1], y[0], y[1], 0}
h.writeWithTimeout(h.g2, data)
h.WriteHid2(data)
}
func (h *Hid) mouseMoveRelative(event []int) {
data := []byte{byte(event[1]), byte(event[2]), byte(event[3]), 0}
h.writeWithTimeout(h.g1, data)
}
func (h *Hid) writeWithTimeout(file *os.File, data []byte) {
deadline := time.Now().Add(8 * time.Millisecond)
_ = file.SetWriteDeadline(deadline)
_, err := file.Write(data)
if err != nil {
switch {
case errors.Is(err, os.ErrClosed):
log.Debugf("hid already closed, reopen it...")
h.OpenNoLock()
case errors.Is(err, os.ErrDeadlineExceeded):
log.Debugf("write to hid timeout")
default:
log.Errorf("write to hid failed: %s", err)
}
return
}
log.Debugf("write to hid: %+v", data)
h.WriteHid1(data)
}

View File

@@ -1,73 +0,0 @@
package hid
import (
"errors"
"os"
log "github.com/sirupsen/logrus"
)
func (h *Hid) OpenNoLock() {
var err error
h.CloseNoLock()
h.g0, err = os.OpenFile("/dev/hidg0", os.O_WRONLY, 0o666)
if err != nil {
log.Errorf("open /dev/hidg0 failed: %s", err)
}
h.g1, err = os.OpenFile("/dev/hidg1", os.O_WRONLY, 0o666)
if err != nil {
log.Errorf("open /dev/hidg1 failed: %s", err)
}
h.g2, err = os.OpenFile("/dev/hidg2", os.O_WRONLY, 0o666)
if err != nil {
log.Errorf("open /dev/hidg2 failed: %s", err)
}
}
func (h *Hid) Open() {
h.kbMutex.Lock()
defer h.kbMutex.Unlock()
h.mouseMutex.Lock()
defer h.mouseMutex.Unlock()
h.CloseNoLock()
h.OpenNoLock()
}
func (h *Hid) CloseNoLock() {
for _, file := range []*os.File{h.g0, h.g1, h.g2} {
if file != nil {
_ = file.Sync()
_ = file.Close()
}
}
}
func (h *Hid) Close() {
h.kbMutex.Lock()
defer h.kbMutex.Unlock()
h.mouseMutex.Lock()
defer h.mouseMutex.Unlock()
h.CloseNoLock()
}
func (h *Hid) Write(file *os.File, data []byte) {
_, err := file.Write(data)
if err != nil {
if errors.Is(err, os.ErrClosed) {
log.Debugf("hid already closed, reopen it...")
h.OpenNoLock()
} else {
log.Errorf("write to hid failed: %s", err)
}
return
}
log.Debugf("write to hid: %+v", data)
}

View File

@@ -16,7 +16,7 @@ type Char struct {
type PasteReq struct {
Content string `form:"content" validate:"required"`
Langue string `form:"langue"`
Langue string `form:"langue"`
}
func LangueSwitch(base map[rune]Char, lang string) map[rune]Char {
@@ -48,41 +48,41 @@ func LangueSwitch(base map[rune]Char, lang string) map[rune]Char {
m['\u00DF'] = Char{0, 45} // ß
//Tauschen
m['^'] = Char{0, 53} // muss doppelt sein
m['/'] = Char{2, 36} // Shift + 7
m['('] = Char{2, 37} // Shift + 8
m['&'] = Char{2, 35} // Shift + 6
m[')'] = Char{2, 38} // Shift + 9
m['`'] = Char{2, 46} // Grave Accent / Backtick
m['"'] = Char{2, 31} // Shift + 2
m['?'] = Char{2, 45} // Shift + ß
m['{'] = Char{0x40, 36} // ALt Gr + 7
m['['] = Char{0x40, 37} // ALt Gr + 8
m[']'] = Char{0x40, 38} // ALt Gr + 6
m['}'] = Char{0x40, 39} // ALt Gr + 0
m['^'] = Char{0, 53} // muss doppelt sein
m['/'] = Char{2, 36} // Shift + 7
m['('] = Char{2, 37} // Shift + 8
m['&'] = Char{2, 35} // Shift + 6
m[')'] = Char{2, 38} // Shift + 9
m['`'] = Char{2, 46} // Grave Accent / Backtick
m['"'] = Char{2, 31} // Shift + 2
m['?'] = Char{2, 45} // Shift + ß
m['{'] = Char{0x40, 36} // ALt Gr + 7
m['['] = Char{0x40, 37} // ALt Gr + 8
m[']'] = Char{0x40, 38} // ALt Gr + 6
m['}'] = Char{0x40, 39} // ALt Gr + 0
m['\\'] = Char{0x40, 45} // ALt Gr + ß
m['@'] = Char{0x40, 20} // ALt Gr + q
m['+'] = Char{0, 48} // Shift + +
m['*'] = Char{2, 48} // Shift + +
m['~'] = Char{0x40, 48} // Shift + +
m['#'] = Char{0, 49} // Shift + #
m['\''] = Char{2, 49} // Shift + #
m['<'] = Char{0, 100} // Shift + <
m['>'] = Char{2, 100} // Shift + <
m['@'] = Char{0x40, 20} // ALt Gr + q
m['+'] = Char{0, 48} // Shift + +
m['*'] = Char{2, 48} // Shift + +
m['~'] = Char{0x40, 48} // Shift + +
m['#'] = Char{0, 49} // Shift + #
m['\''] = Char{2, 49} // Shift + #
m['<'] = Char{0, 100} // Shift + <
m['>'] = Char{2, 100} // Shift + <
m['|'] = Char{0x40, 100} // ALt Gr + <
m[';'] = Char{2, 54} // Shift + ,
m[':'] = Char{2, 55} // Shift + .
m['-'] = Char{0, 56} // Shift + -
m['_'] = Char{2, 56} // Shift + -
m[';'] = Char{2, 54} // Shift + ,
m[':'] = Char{2, 55} // Shift + .
m['-'] = Char{0, 56} // Shift + -
m['_'] = Char{2, 56} // Shift + -
//neu
m['\u00B4'] = Char{0, 46} // ´
m['\u00B0'] = Char{2, 53} // °
m['\u00A7'] = Char{2, 32} // §
m['\u20AC'] = Char{0x40, 8} // €
m['\u00B4'] = Char{0, 46} // ´
m['\u00B0'] = Char{2, 53} // °
m['\u00A7'] = Char{2, 32} // §
m['\u20AC'] = Char{0x40, 8} // €
m['\u00B2'] = Char{0x40, 31} // ²
m['\u00B3'] = Char{0x40, 32} // ³
}
return m
}
@@ -90,17 +90,19 @@ func LangueSwitch(base map[rune]Char, lang string) map[rune]Char {
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
}
charMapLocal := LangueSwitch(charMap, req.Langue)
if len(req.Content) > 1024 {
rsp.ErrRsp(c, -2, "content too long")
return
}
s.hid.kbMutex.Lock()
defer s.hid.kbMutex.Unlock()
charMapLocal := LangueSwitch(charMap, req.Langue)
keyUp := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
for _, char := range req.Content {
@@ -109,10 +111,12 @@ func (s *Service) Paste(c *gin.Context) {
log.Debugf("unknown key '%c' (rune: %d)", char, char)
continue
}
keyDown := []byte{byte(key.Modifiers), 0x00, byte(key.Code), 0x00, 0x00, 0x00, 0x00, 0x00}
s.hid.Write(s.hid.g0, keyDown)
s.hid.Write(s.hid.g0, keyUp)
time.Sleep(50 * time.Millisecond)
hid.WriteHid0(keyDown)
hid.WriteHid0(keyUp)
time.Sleep(30 * time.Millisecond)
}
rsp.OkRsp(c)
@@ -178,16 +182,16 @@ var charMap = map[rune]Char{
'/': {0, 56}, // Slash
// 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)
'_': {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)
':': {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)
':': {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)
}

View File

@@ -1,82 +0,0 @@
package hid
import (
"NanoKVM-Server/proto"
"os"
"time"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
func (s *Service) Reset(c *gin.Context) {
var rsp proto.Response
h := GetHid()
h.Lock()
h.CloseNoLock()
defer func() {
h.OpenNoLock()
h.Unlock()
}()
// reset USB
f, err := os.OpenFile("/sys/kernel/config/usb_gadget/g0/UDC", os.O_WRONLY, 0644)
if err != nil {
log.Errorf("open /sys/kernel/config/usb_gadget/g0/UDC failed: %s", err)
rsp.ErrRsp(c, -1, "open usb gadget file failed")
return
}
err = f.Truncate(0)
if err != nil {
_ = f.Close()
log.Errorf("truncate /sys/kernel/config/usb_gadget/g0/UDC failed: %s", err)
rsp.ErrRsp(c, -1, "truncate usb gadget file failed")
return
}
_, err = f.Seek(0, 0)
if err != nil {
_ = f.Close()
log.Errorf("seek to 0 failed: %s", err)
rsp.ErrRsp(c, -1, "seek to 0 in usb gadget file failed")
return
}
_, err = f.WriteString("\n")
if err != nil {
_ = f.Close()
log.Errorf("write to /sys/kernel/config/usb_gadget/g0/UDC failed: %s", err)
rsp.ErrRsp(c, -1, "write to usb gadget file failed")
return
}
_ = f.Close()
time.Sleep(1 * time.Second)
devices, err := os.ReadDir("/sys/class/udc/")
if err != nil {
log.Errorf("read udc directory failed: %s", err)
rsp.ErrRsp(c, -1, "read udc directory failed")
return
}
f, err = os.OpenFile("/sys/kernel/config/usb_gadget/g0/UDC", os.O_WRONLY, 0644)
if err != nil {
log.Errorf("open /sys/kernel/config/usb_gadget/g0/UDC failed: %s", err)
rsp.ErrRsp(c, -1, "open usb gadget file failed")
return
}
for _, device := range devices {
_, err = f.WriteString(device.Name() + "\n")
if err != nil {
_ = f.Close()
log.Errorf("write to /sys/kernel/config/usb_gadget/g0/UDC failed: %s", err)
rsp.ErrRsp(c, -1, "write to usb gadget file failed")
return
}
}
_ = f.Close()
rsp.OkRsp(c)
log.Debugf("reset hid success")
}

View File

@@ -3,6 +3,7 @@ package hid
import (
"NanoKVM-Server/proto"
"errors"
"fmt"
"io"
"os"
"os/exec"
@@ -16,11 +17,12 @@ import (
const (
ModeNormal = "normal"
ModeHidOnly = "hid-only"
ModeFlag = "/sys/kernel/config/usb_gadget/g0/bcdDevice"
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"
ModeNormalScript = "/kvmapp/system/init.d/S03usbdev"
ModeHidOnlyScript = "/kvmapp/system/init.d/S03usbhid"
USBDevScript = "/etc/init.d/S03usbdev"
)
var modeMap = map[string]string{
@@ -61,9 +63,17 @@ func (s *Service) SetHidMode(c *gin.Context) {
return
}
srcScript := NormalModeScript
h := GetHid()
h.Lock()
h.CloseNoLock()
defer func() {
h.OpenNoLock()
h.Unlock()
}()
srcScript := ModeNormalScript
if req.Mode == ModeHidOnly {
srcScript = HidOnlyModeScript
srcScript = ModeHidOnlyScript
}
if err := copyModeFile(srcScript); err != nil {
@@ -78,6 +88,29 @@ func (s *Service) SetHidMode(c *gin.Context) {
_ = exec.Command("reboot").Run()
}
func (s *Service) ResetHid(c *gin.Context) {
var rsp proto.Response
h := GetHid()
h.Lock()
h.CloseNoLock()
defer func() {
h.OpenNoLock()
h.Unlock()
}()
command := fmt.Sprintf("%s restart_phy", USBDevScript)
err := exec.Command("sh", "-c", command).Run()
if err != nil {
log.Errorf("failed to reset hid: %v", err)
rsp.ErrRsp(c, -1, "failed to reset hid")
return
}
rsp.OkRsp(c)
log.Debugf("reset hid success")
}
func copyModeFile(srcScript string) error {
// open the source file
srcFile, err := os.Open(srcScript)
@@ -98,7 +131,7 @@ func copyModeFile(srcScript string) error {
// 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)
log.Errorf("failed to create temp %s: %s", USBDevScript, err)
return err
}
tmpPath := tmpFile.Name()
@@ -131,12 +164,12 @@ func copyModeFile(srcScript string) error {
}
// replace the target file with the temporary file
if err := os.Rename(tmpPath, TargetModeScript); err != nil {
if err := os.Rename(tmpPath, USBDevScript); err != nil {
log.Errorf("failed to rename %s: %s", tmpPath, err)
return err
}
log.Debugf("copy %s to %s successful", srcScript, TargetModeScript)
log.Debugf("copy %s to %s successful", srcScript, USBDevScript)
return nil
}

View File

@@ -190,3 +190,31 @@ func (s *Service) GetCdRom(c *gin.Context) {
rsp.OkRspWithData(c, data)
}
func (s *Service) DeleteImage(c *gin.Context) {
var req proto.DeleteImageReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
filename := strings.ToLower(req.File)
validPrefix := strings.HasPrefix(filename, imageDirectory)
validSuffix := strings.HasSuffix(filename, ".iso") || strings.HasSuffix(filename, ".img")
if !validPrefix || !validSuffix {
rsp.ErrRsp(c, -2, "invalid arguments")
return
}
if err := os.Remove(req.File); err != nil {
rsp.ErrRsp(c, -3, "remove file failed")
log.Errorf("failed to remove file %s: %s", req.File, err)
return
}
rsp.OkRsp(c)
log.Debugf("delete image %s success", req.File)
}

View File

@@ -1,30 +1,17 @@
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{
streamer = newStreamer()
upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
@@ -34,74 +21,24 @@ var (
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)
log.Errorf("failed to upgrade to websocket: %s", err)
return
}
defer func() {
_ = ws.Close()
log.Debugf("h264 websocket disconnected")
log.Debugf("h264 websocket disconnected: %s", ws.RemoteAddr())
}()
log.Debugf("h264 websocket connected: %s", ws.RemoteAddr())
var zeroTime time.Time
_ = ws.SetReadDeadline(zeroTime)
_ = ws.SetReadDeadline(time.Time{})
mutex.Lock()
wsMap[ws] = true
if len(wsMap) == 1 && !isSending {
go send()
}
mutex.Unlock()
streamer.addClient(ws)
defer streamer.removeClient(ws)
_, _, 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
for {
if _, _, err := ws.ReadMessage(); err != nil {
log.Debugf("failed to read message (client disconnected): %s", err)
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)
}
}
}
}

View File

@@ -0,0 +1,12 @@
package direct
import (
"bytes"
"sync"
)
var BufferPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}

View File

@@ -0,0 +1,123 @@
package direct
import (
"NanoKVM-Server/common"
"NanoKVM-Server/service/stream"
"bytes"
"encoding/binary"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
)
type Streamer struct {
mutex sync.RWMutex
clients map[*websocket.Conn]bool
running int32
}
func newStreamer() *Streamer {
return &Streamer{
clients: make(map[*websocket.Conn]bool),
}
}
func (s *Streamer) addClient(ws *websocket.Conn) {
s.mutex.Lock()
s.clients[ws] = true
s.mutex.Unlock()
if atomic.CompareAndSwapInt32(&s.running, 0, 1) {
go s.run()
log.Debug("h264 stream started")
}
}
func (s *Streamer) removeClient(ws *websocket.Conn) {
s.mutex.Lock()
delete(s.clients, ws)
s.mutex.Unlock()
log.Debugf("h264 websocket disconnected, remaining clients: %d", len(s.clients))
}
func (s *Streamer) getClientCount() int {
s.mutex.RLock()
defer s.mutex.RUnlock()
return len(s.clients)
}
func (s *Streamer) run() {
defer atomic.StoreInt32(&s.running, 0)
duration := time.Second / time.Duration(120)
ticker := time.NewTicker(duration)
defer ticker.Stop()
screen := common.GetScreen()
vision := common.GetKvmVision()
startTime := time.Now()
for range ticker.C {
if s.getClientCount() == 0 {
log.Debug("h264 stream stopped due to no clients")
return
}
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
if result < 0 || len(data) == 0 {
continue
}
isKeyFrame := byte(0)
if result == 3 {
isKeyFrame = byte(1)
}
timestamp := time.Since(startTime).Microseconds()
if err := s.send(isKeyFrame, timestamp, data); err != nil {
continue
}
stream.GetFrameRateCounter().Update()
}
}
func (s *Streamer) send(isKeyFrame byte, timestamp int64, data []byte) error {
buf := BufferPool.Get().(*bytes.Buffer)
defer BufferPool.Put(buf)
buf.Reset()
if err := buf.WriteByte(isKeyFrame); err != nil {
log.Errorf("failed to write keyframe flag: %s", err)
return err
}
tsBytes := make([]byte, 8)
binary.LittleEndian.PutUint64(tsBytes, uint64(timestamp))
if _, err := buf.Write(tsBytes); err != nil {
log.Errorf("failed to write timestamp: %s", err)
return err
}
if _, err := buf.Write(data); err != nil {
log.Errorf("failed to write h264 data: %s", err)
return err
}
for client := range s.clients {
if err := client.WriteMessage(websocket.BinaryMessage, buf.Bytes()); err != nil {
log.Errorf("failed to write message to client %s: %s.", client.RemoteAddr(), err)
s.removeClient(client)
}
}
return nil
}

View File

@@ -25,7 +25,7 @@ func send() {
}
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
if result < 0 {
if result < 0 || len(data) == 0 {
continue
}
@@ -40,8 +40,6 @@ func send() {
}
}
log.Debugf("send h264 data: %d", len(data))
if screen.FPS != fps {
fps = screen.FPS
duration = time.Second / time.Duration(fps)

View File

@@ -1,99 +1,22 @@
package mjpeg
import (
"NanoKVM-Server/common"
"NanoKVM-Server/service/stream"
"sync"
"time"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
var (
chanMap = make(map[*gin.Context]bool)
mutex = sync.Mutex{}
exitSig = make(chan bool, 1)
)
var streamer = NewStreamer()
func Connect(c *gin.Context) {
c.Header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Pragma", "no-cache")
c.Header("X-Server-Date", time.Now().Format(time.RFC1123))
mutex.Lock()
chanMap[c] = true
if len(chanMap) == 1 {
go send()
}
mutex.Unlock()
streamer.AddClient(c)
defer streamer.RemoveClient(c)
<-c.Request.Context().Done()
mutex.Lock()
delete(chanMap, c)
if len(chanMap) == 0 {
exitSig <- true
}
mutex.Unlock()
}
func send() {
screen := common.GetScreen()
common.CheckScreen()
fps := screen.FPS
ticker := time.NewTicker(time.Second / time.Duration(fps))
defer ticker.Stop()
vision := common.GetKvmVision()
for {
select {
case <-ticker.C:
data, result := vision.ReadMjpeg(screen.Width, screen.Height, screen.Quality)
if result < 0 || result == 5 {
continue
}
for c := range chanMap {
if err := write(c, data); err != nil {
log.Debugf("failed to write mjpeg data: %s", err)
}
}
log.Debugf("send mjpeg data: %d", len(data))
stream.GetFrameRateCounter().Update()
if screen.FPS != fps {
fps = screen.FPS
ticker.Reset(time.Second / time.Duration(fps))
}
case <-exitSig:
return
}
}
}
func write(c *gin.Context, data []byte) (err error) {
if _, err = c.Writer.Write([]byte("--frame\r\n")); err != nil {
return
}
if _, err = c.Writer.Write([]byte("Content-Type: image/jpeg\r\n\r\n")); err != nil {
return
}
if _, err = c.Writer.Write(data); err != nil {
return
}
if _, err = c.Writer.Write([]byte("\r\n")); err != nil {
return
}
c.Writer.Flush()
return
}

View File

@@ -0,0 +1,131 @@
package mjpeg
import (
"NanoKVM-Server/common"
"NanoKVM-Server/service/stream"
"fmt"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
type Streamer struct {
mutex sync.RWMutex
clients map[*gin.Context]bool
running int32
}
func NewStreamer() *Streamer {
return &Streamer{
clients: make(map[*gin.Context]bool),
}
}
func (s *Streamer) AddClient(c *gin.Context) {
s.mutex.Lock()
s.clients[c] = true
s.mutex.Unlock()
if atomic.CompareAndSwapInt32(&s.running, 0, 1) {
go s.run()
log.Debug("mjpeg stream started")
}
}
func (s *Streamer) RemoveClient(c *gin.Context) {
s.mutex.Lock()
delete(s.clients, c)
s.mutex.Unlock()
log.Debugf("mjpeg connection removed, remaining clients: %d", len(s.clients))
}
func (s *Streamer) getClients() []*gin.Context {
s.mutex.RLock()
defer s.mutex.RUnlock()
clients := make([]*gin.Context, 0, len(s.clients))
for c := range s.clients {
clients = append(clients, c)
}
return clients
}
func (s *Streamer) getClientCount() int {
s.mutex.RLock()
defer s.mutex.RUnlock()
return len(s.clients)
}
func (s *Streamer) run() {
defer atomic.StoreInt32(&s.running, 0)
screen := common.GetScreen()
common.CheckScreen()
fps := screen.FPS
vision := common.GetKvmVision()
ticker := time.NewTicker(time.Second / time.Duration(fps))
defer ticker.Stop()
for range ticker.C {
if s.getClientCount() == 0 {
log.Debug("mjpeg stream stopped due to no clients")
return
}
data, result := vision.ReadMjpeg(screen.Width, screen.Height, screen.Quality)
if result < 0 || result == 5 || len(data) == 0 {
continue
}
clients := s.getClients()
for _, client := range clients {
if err := writeFrame(client, data); err != nil {
log.Errorf("failed to write mjpeg frame for client %s: %s", client.Request.RemoteAddr, err)
s.RemoveClient(client)
}
}
if screen.FPS != fps && screen.FPS != 0 {
fps = screen.FPS
ticker.Reset(time.Second / time.Duration(fps))
}
stream.GetFrameRateCounter().Update()
}
}
func writeFrame(c *gin.Context, data []byte) (err error) {
defer func() {
if r := recover(); r != nil {
err = c.Request.Context().Err()
if err == nil {
err = fmt.Errorf("panic recovered in writeFrame: %v", r)
}
}
}()
header := "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: " + strconv.Itoa(len(data)) + "\r\n\r\n"
if _, err = c.Writer.WriteString(header); err != nil {
return err
}
if _, err = c.Writer.Write(data); err != nil {
return err
}
if _, err = c.Writer.Write([]byte("\r\n")); err != nil {
return err
}
c.Writer.Flush()
return nil
}

View File

@@ -0,0 +1,112 @@
package webrtc
import (
"encoding/json"
"errors"
"github.com/gorilla/websocket"
"github.com/pion/rtp"
"github.com/pion/rtp/codecs"
"github.com/pion/webrtc/v4"
log "github.com/sirupsen/logrus"
"sync"
)
func NewClient(ws *websocket.Conn, videoConn *webrtc.PeerConnection) *Client {
return &Client{
ws: ws,
video: videoConn,
mutex: sync.Mutex{},
}
}
func (c *Client) WriteMessage(event string, data string) error {
c.mutex.Lock()
defer c.mutex.Unlock()
message := &Message{
Event: event,
Data: data,
}
if err := c.ws.WriteJSON(message); err != nil {
log.Errorf("failed to send message %s: %v", event, err)
return err
}
log.Debugf("sent message %s", event)
return nil
}
func (c *Client) ReadMessage() (*Message, error) {
_, raw, err := c.ws.ReadMessage()
if err != nil {
log.Errorf("failed to read message: %v", err)
return nil, err
}
var message Message
if err := json.Unmarshal(raw, &message); err != nil {
log.Errorf("failed to unmarshal message: %v", err)
return nil, nil
}
return &message, nil
}
func (c *Client) AddTrack() error {
// video track
videoTrack, err := webrtc.NewTrackLocalStaticRTP(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264},
"video",
"pion-video",
)
if err != nil {
log.Errorf("failed to create video track: %s", err)
return err
}
videoPacketizer := rtp.NewPacketizer(
1200,
100,
0x1234ABCD,
&codecs.H264Payloader{},
rtp.NewRandomSequencer(),
90000,
)
if videoPacketizer == nil {
err := errors.New("failed to create rtp packetizer")
log.Error(err)
return err
}
videoSender, err := c.video.AddTrack(videoTrack)
if err != nil {
log.Errorf("failed to add video track: %s", err)
return err
}
go startRTCPReader(videoSender)
track := &Track{
videoPacketizer: videoPacketizer,
video: videoTrack,
}
track.updateExtension()
c.mutex.Lock()
c.track = track
c.mutex.Unlock()
return nil
}
func startRTCPReader(sender *webrtc.RTPSender) {
rtcpBuf := make([]byte, 1500)
for {
if _, _, err := sender.Read(rtcpBuf); err != nil {
log.Debugf("RTCP reader error: %v", err)
return
}
}
}

View File

@@ -0,0 +1,158 @@
package webrtc
import (
"NanoKVM-Server/config"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/pion/dtls/v3"
"github.com/pion/webrtc/v4"
log "github.com/sirupsen/logrus"
)
var (
upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
globalManager *WebRTCManager
managerOnce sync.Once
)
func getManager() *WebRTCManager {
managerOnce.Do(func() {
globalManager = NewWebRTCManager()
})
return globalManager
}
func Connect(c *gin.Context) {
// create WebSocket connection
wsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Errorf("failed to create h264 websocket: %s", err)
return
}
defer func() {
_ = wsConn.Close()
log.Debugf("h264 websocket disconnected: %s", c.ClientIP())
}()
log.Debugf("h264 websocket connected: %s", c.ClientIP())
var zeroTime time.Time
_ = wsConn.SetReadDeadline(zeroTime)
// create video connection
iceServers := createICEServers()
mediaEngine, err := createMediaEngine()
if err != nil {
log.Errorf("failed to create h264 media engine: %s", err)
return
}
videoConn, err := createPeerConnection(iceServers, mediaEngine)
if err != nil {
log.Errorf("failed to create h264 video peer connection: %s", err)
return
}
defer func() {
_ = videoConn.Close()
log.Debugf("h264 video peer disconnected: %s", c.ClientIP())
}()
// create client
client := NewClient(wsConn, videoConn)
if err := client.AddTrack(); err != nil {
log.Errorf("failed to add track: %s", err)
return
}
manager := getManager()
manager.AddClient(wsConn, client)
defer manager.RemoveClient(wsConn)
// handle signaling
signalingHandler := NewSignalingHandler(client)
signalingHandler.RegisterCallbacks()
// read and wait
for {
message, err := client.ReadMessage()
if err != nil {
return
}
if message != nil {
if err := signalingHandler.HandleMessage(message); err != nil {
log.Errorf("failed to handle signaling message: %s", err)
}
}
}
}
func createICEServers() []webrtc.ICEServer {
var iceServers []webrtc.ICEServer
conf := config.GetInstance()
if conf.Stun != "" && conf.Stun != "disable" {
iceServers = append(iceServers, webrtc.ICEServer{
URLs: []string{"stun:" + conf.Stun},
})
}
if conf.Turn.TurnAddr != "" && conf.Turn.TurnUser != "" && conf.Turn.TurnCred != "" {
iceServers = append(iceServers, webrtc.ICEServer{
URLs: []string{"turn:" + conf.Turn.TurnAddr},
Username: conf.Turn.TurnUser,
Credential: conf.Turn.TurnCred,
})
}
return iceServers
}
func createMediaEngine() (*webrtc.MediaEngine, error) {
mediaEngine := &webrtc.MediaEngine{}
if err := mediaEngine.RegisterDefaultCodecs(); err != nil {
log.Errorf("failed to register default codecs: %s", err)
return nil, err
}
if err := mediaEngine.RegisterHeaderExtension(
webrtc.RTPHeaderExtensionCapability{URI: "http://www.webrtc.org/experiments/rtp-hdrext/playout-delay"},
webrtc.RTPCodecTypeVideo,
); err != nil {
log.Errorf("failed to register header extension: %s", err)
return nil, err
}
return mediaEngine, nil
}
func createPeerConnection(iceServers []webrtc.ICEServer, mediaEngine *webrtc.MediaEngine) (*webrtc.PeerConnection, error) {
settingEngine := webrtc.SettingEngine{}
settingEngine.SetSRTPProtectionProfiles(
dtls.SRTP_AEAD_AES_128_GCM,
dtls.SRTP_AES128_CM_HMAC_SHA1_80,
)
apiOptions := []func(api *webrtc.API){
webrtc.WithSettingEngine(settingEngine),
}
if mediaEngine != nil {
apiOptions = append(apiOptions, webrtc.WithMediaEngine(mediaEngine))
}
api := webrtc.NewAPI(apiOptions...)
return api.NewPeerConnection(webrtc.Configuration{
ICEServers: iceServers,
SDPSemantics: webrtc.SDPSemanticsUnifiedPlan,
})
}

View File

@@ -0,0 +1,96 @@
package webrtc
import (
"NanoKVM-Server/common"
"NanoKVM-Server/service/stream"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
"github.com/pion/webrtc/v4/pkg/media"
log "github.com/sirupsen/logrus"
)
func NewWebRTCManager() *WebRTCManager {
return &WebRTCManager{
clients: make(map[*websocket.Conn]*Client),
videoSending: 0,
mutex: sync.RWMutex{},
}
}
func (m *WebRTCManager) AddClient(ws *websocket.Conn, client *Client) {
client.track.updateExtension()
m.mutex.Lock()
m.clients[ws] = client
m.mutex.Unlock()
log.Debugf("added client %s, total clients: %d", ws.RemoteAddr(), len(m.clients))
}
func (m *WebRTCManager) RemoveClient(ws *websocket.Conn) {
m.mutex.Lock()
delete(m.clients, ws)
m.mutex.Unlock()
log.Debugf("removed client %s, total clients: %d", ws.RemoteAddr(), len(m.clients))
}
func (m *WebRTCManager) GetClientCount() int {
m.mutex.RLock()
defer m.mutex.RUnlock()
return len(m.clients)
}
func (m *WebRTCManager) StartVideoStream() {
if atomic.CompareAndSwapInt32(&m.videoSending, 0, 1) {
go m.sendVideoStream()
log.Debugf("start sending h264 stream")
}
}
func (m *WebRTCManager) sendVideoStream() {
defer atomic.StoreInt32(&m.videoSending, 0)
screen := common.GetScreen()
common.CheckScreen()
fps := screen.FPS
duration := time.Second / time.Duration(fps)
vision := common.GetKvmVision()
ticker := time.NewTicker(duration)
defer ticker.Stop()
for range ticker.C {
if m.GetClientCount() == 0 {
log.Debugf("stop sending h264 stream")
return
}
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
if result < 0 || len(data) == 0 {
continue
}
sample := media.Sample{
Data: data,
Duration: duration,
}
for _, client := range m.clients {
client.track.writeVideo(sample)
}
if screen.FPS != fps && screen.FPS != 0 {
fps = screen.FPS
duration = time.Second / time.Duration(fps)
ticker.Reset(duration)
}
stream.GetFrameRateCounter().Update()
}
}

View File

@@ -0,0 +1,149 @@
package webrtc
import (
"encoding/json"
"errors"
"github.com/pion/webrtc/v4"
log "github.com/sirupsen/logrus"
)
func NewSignalingHandler(client *Client) *SignalingHandler {
return &SignalingHandler{
client: client,
}
}
// RegisterCallbacks Register callback functions
func (s *SignalingHandler) RegisterCallbacks() {
// video ICE candidate
s.client.video.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate == nil {
return
}
candidateByte, err := json.Marshal(candidate.ToJSON())
if err != nil {
log.Errorf("failed to marshal video candidate: %s", err)
return
}
if err := s.client.WriteMessage("video-candidate", string(candidateByte)); err != nil {
log.Errorf("failed to send video candidate: %s", err)
}
})
manager := getManager()
// video connection state change
s.client.video.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
if state == webrtc.ICEConnectionStateConnected {
manager.StartVideoStream()
}
log.Debugf("video connection state changed to %s", state.String())
})
}
// HandleMessage handle the received message
func (s *SignalingHandler) HandleMessage(message *Message) error {
switch message.Event {
case "video-offer":
return s.handleVideoOffer(message.Data)
case "video-candidate":
return s.handleVideoCandidate(message.Data)
case "heartbeat":
return s.handleHeartbeat()
default:
log.Debugf("Unhandled message event: %s", message.Event)
return nil
}
}
func (s *SignalingHandler) handleVideoOffer(data string) error {
if s.client.video.SignalingState() != webrtc.SignalingStateStable {
err := errors.New("video signaling is not stable")
log.Error(err)
return err
}
offer := webrtc.SessionDescription{}
if err := json.Unmarshal([]byte(data), &offer); err != nil {
log.Errorf("failed to unmarshal video offer: %s", err)
return err
}
if err := s.client.video.SetRemoteDescription(offer); err != nil {
log.Errorf("failed to set remote description: %s", err)
return err
}
answer, err := s.client.video.CreateAnswer(nil)
if err != nil {
log.Errorf("failed to create answer: %s", err)
return err
}
if err := s.client.video.SetLocalDescription(answer); err != nil {
log.Errorf("failed to set local description: %s", err)
return err
}
if err := s.updateHeaderExtensionID(); err != nil {
log.Errorf("could not update header extension ID: %v", err)
return err
}
answerByte, err := json.Marshal(answer)
if err != nil {
log.Errorf("failed to marshal answer: %s", err)
return err
}
return s.client.WriteMessage("video-answer", string(answerByte))
}
// set extension ID
func (s *SignalingHandler) updateHeaderExtensionID() error {
receivers := s.client.video.GetReceivers()
if len(receivers) == 0 {
return errors.New("no RTP receiver found for video")
}
params := receivers[0].GetParameters()
if len(params.HeaderExtensions) == 0 {
return errors.New("no header extensions found in negotiated parameters")
}
for _, ext := range params.HeaderExtensions {
if ext.URI == "http://www.webrtc.org/experiments/rtp-hdrext/playout-delay" {
s.client.track.playoutDelayExtensionID = uint8(ext.ID)
log.Debugf("found and set playout delay extension ID to: %d", ext.ID)
return nil
}
}
log.Warnf("no track extension found in negotiated parameters, use default value 5")
return nil
}
// handle video candidate
func (s *SignalingHandler) handleVideoCandidate(data string) error {
candidate := webrtc.ICECandidateInit{}
if err := json.Unmarshal([]byte(data), &candidate); err != nil {
log.Errorf("failed to unmarshal candidate: %s", err)
return err
}
if err := s.client.video.AddICECandidate(candidate); err != nil {
log.Errorf("failed to add ICECandidate: %s", err)
return err
}
return nil
}
// handle heartbeat
func (s *SignalingHandler) handleHeartbeat() error {
return s.client.WriteMessage("heartbeat", "")
}

View File

@@ -0,0 +1,53 @@
package webrtc
import (
"github.com/pion/rtp"
"github.com/pion/webrtc/v4/pkg/media"
log "github.com/sirupsen/logrus"
)
func (t *Track) updateExtension() {
if t.playoutDelayExtensionID == 0 {
t.playoutDelayExtensionID = 5
}
if t.playoutDelayExtensionData == nil || len(t.playoutDelayExtensionData) == 0 {
playoutDelay := &rtp.PlayoutDelayExtension{
MinDelay: 0,
MaxDelay: 0,
}
playoutDelayExtensionData, err := playoutDelay.Marshal()
if err == nil {
t.playoutDelayExtensionData = playoutDelayExtensionData
}
}
}
func (t *Track) writeVideoSample(sample media.Sample) error {
samples := uint32(sample.Duration.Seconds() * 90000)
packets := t.videoPacketizer.Packetize(sample.Data, samples)
for _, p := range packets {
p.Header.Extension = true
p.Header.ExtensionProfile = 0xBEDE
if err := p.Header.SetExtension(t.playoutDelayExtensionID, t.playoutDelayExtensionData); err != nil {
log.Errorf("Failed to set extension: %v", err)
return err
}
if err := t.video.WriteRTP(p); err != nil {
log.Errorf("failed to write RTP: %v", err)
return err
}
}
return nil
}
func (t *Track) writeVideo(sample media.Sample) {
err := t.writeVideoSample(sample)
if err != nil {
log.Errorf("failed to write h264 video: %s", err)
}
}

View File

@@ -0,0 +1,38 @@
package webrtc
import (
"sync"
"github.com/gorilla/websocket"
"github.com/pion/rtp"
"github.com/pion/webrtc/v4"
)
type WebRTCManager struct {
clients map[*websocket.Conn]*Client
videoSending int32
mutex sync.RWMutex
}
type Client struct {
ws *websocket.Conn
video *webrtc.PeerConnection
track *Track
mutex sync.Mutex
}
type SignalingHandler struct {
client *Client
}
type Track struct {
playoutDelayExtensionID uint8
playoutDelayExtensionData []byte
videoPacketizer rtp.Packetizer
video *webrtc.TrackLocalStaticRTP
}
type Message struct {
Event string `json:"event"`
Data string `json:"data"`
}

View File

@@ -1,6 +1,7 @@
package jiggler
import (
"NanoKVM-Server/service/hid"
"os"
"strings"
"sync"
@@ -117,3 +118,17 @@ func (j *Jiggler) IsEnabled() bool {
func (j *Jiggler) GetMode() string {
return j.mode
}
func move(mode string) {
h := hid.GetHid()
if mode == "absolute" {
h.WriteHid2([]byte{0x00, 0x00, 0x3f, 0x00, 0x3f, 0x00})
time.Sleep(100 * time.Millisecond)
h.WriteHid2([]byte{0x00, 0xff, 0x3f, 0xff, 0x3f, 0x00})
} else {
h.WriteHid1([]byte{0x00, 0xa, 0xa, 0x00})
time.Sleep(100 * time.Millisecond)
h.WriteHid1([]byte{0x00, 0xf6, 0xf6, 0x00})
}
}

View File

@@ -1,55 +0,0 @@
package jiggler
import (
"os"
"time"
log "github.com/sirupsen/logrus"
)
func move(mode string) {
var (
hid string
data [][]byte
)
if mode == "absolute" {
hid = "/dev/hidg2"
data = [][]byte{
{0x00, 0x00, 0x3f, 0x00, 0x3f, 0x00},
{0x00, 0xff, 0x3f, 0xff, 0x3f, 0x00},
}
} else {
hid = "/dev/hidg1"
data = [][]byte{
{0x00, 0x0a, 0x0a, 0x00},
{0x00, 0xf6, 0xf6, 0x00},
}
}
write(hid, data)
}
func write(hid string, data [][]byte) {
file, err := os.OpenFile(hid, os.O_WRONLY, 0o666)
if err != nil {
log.Errorf("failed to open %s: %s", hid, err)
return
}
defer func() {
_ = file.Close()
}()
for _, b := range data {
deadline := time.Now().Add(8 * time.Millisecond)
if err := file.SetWriteDeadline(deadline); err != nil {
log.Errorf("failed to set deadline: %s", err)
return
}
if _, err := file.Write(b); err != nil {
log.Errorf("failed to write: %s", err)
return
}
}
}

View File

@@ -11,12 +11,12 @@
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.5.1",
"@ant-design/icons": "^5.6.1",
"@xterm/addon-attach": "^0.11.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"antd": "^5.21.6",
"axios": "^1.8.4",
"antd": "^5.29.1",
"axios": "1.12.0",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"i18next": "^23.16.4",
@@ -38,18 +38,18 @@
"yocto-queue": "^1.2.1"
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
"@types/crypto-js": "^4.2.2",
"@types/js-cookie": "^3.0.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/semver": "^7.5.8",
"@types/websocket": "^1.0.10",
"@typescript-eslint/eslint-plugin": "^8.19.1",
"@typescript-eslint/parser": "^8.19.1",
"@vitejs/plugin-react": "^4.3.3",
"@typescript-eslint/eslint-plugin": "^8.48.0",
"@typescript-eslint/parser": "^8.48.0",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint": "^9.39.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
@@ -58,9 +58,9 @@
"postcss": "^8.4.47",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"tailwindcss": "^3.4.14",
"tailwindcss": "^3.4.18",
"typescript": "^5.6.3",
"vite": "^6.2.1",
"vite": "7.1.11",
"vite-tsconfig-paths": "^4.3.2"
},
"msw": {

1851
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,10 +27,3 @@ export function getPreviewUpdates() {
return http.get('/api/application/preview');
}
export function getSettingsDisabledItems() {
return http.get('/api/application/settings/disabled-items');
}
export function getMenuDisableItems(){
return http.get("/api/application/menu/disabled-items")
}

View File

@@ -23,3 +23,10 @@ export function mountImage(file?: string, cdrom?: boolean) {
export function getCdRom() {
return http.get('/api/storage/cdrom');
}
export function deleteImage(file: string) {
const data = {
file
};
return http.post('/api/storage/image/delete', data);
}

View File

@@ -69,21 +69,15 @@ const en = {
keyboard: {
title: 'Keyboard',
paste: 'Paste',
tips: 'Server keyboard layout',
tips: 'Only standard keyboard letters and symbols are supported',
placeholder: 'Please input',
submit: 'Submit',
virtual: 'Keyboard',
ctrlaltdel: 'Ctrl+Alt+Del',
readClipboard: 'Read from Clipboard',
clipboardHint: 'Click to paste from your browser clipboard',
clipboardNotSupported: 'Clipboard API is not supported in this browser',
clipboardEmpty: 'Clipboard is empty',
clipboardRead: 'Clipboard content loaded',
clipboardPermissionDenied:
'Clipboard permission denied. Please allow clipboard access in your browser.',
clipboardReadError: 'Failed to read clipboard',
clipboardTooLong: 'Clipboard content exceeds 1024 characters',
nonAsciiError: 'Only ASCII characters are supported',
dropdownEnglish: 'English',
dropdownGerman: 'German'
},
@@ -118,11 +112,18 @@ const en = {
title: 'Images',
loading: 'Loading...',
empty: 'Nothing Found',
cdrom: 'Mount the image in CD-ROM mode',
mountFailed: 'Mount Failed',
mountMode: 'Mount mode',
mountFailed: 'Mount failed',
mountDesc:
"In some systems, it's necessary to eject the virtual disk on the remote host before mounting the image.",
'On some systems, you need to eject the virtual disk from the remote host before mounting the image.',
unmountFailed: 'Unmount failed',
unmountDesc:
'On some systems, you need to manually eject from the remote host before unmounting the image.',
refresh: 'Refresh the image list',
attention: 'Attention',
deleteConfirm: 'Are you sure you want to delete this image?',
okBtn: 'Yes',
cancelBtn: 'No',
tips: {
title: 'How to upload',
usb1: 'Connect the NanoKVM to your computer via USB.',
@@ -290,8 +291,11 @@ const en = {
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 75MB if using Tailscale. A Tailscale restart is required for the change to take effect.",
disable: 'Disable'
tip: 'When memory usage exceeds the limit, garbage collection is performed more aggressively to attempt to free up memory. A Tailscale restart is required for the change to take effect.'
},
swap: {
title: 'Swap memory',
tip: 'If issues persist after enabling memory optimization, try enabling swap memory. This sets the swap file size to 256MB by default, which can be adjusted in "Settings > Device".'
},
restart: 'Restart Tailscale?',
stop: 'Stop Tailscale?',
@@ -308,6 +312,8 @@ const en = {
upTailscale: 'Upload tailscale to NanoKVM directory /usr/bin/',
upTailscaled: 'Upload tailscaled to NanoKVM directory /usr/sbin/',
refresh: 'Refresh current page',
notRunning: 'Tailscale is not running. Please start it to continue.',
run: 'Start',
notLogin:
'The device has not been bound yet. Please login and bind this device to your account.',
urlPeriod: 'This url is valid for 10 minutes',
@@ -320,6 +326,7 @@ const en = {
logout: 'Logout',
logoutDesc: 'Are you sure you want to logout?',
uninstall: 'Uninstall Tailscale',
uninstallDesc: 'Are you sure you want to uninstall Tailscale?',
okBtn: 'Yes',
cancelBtn: 'No'
},

View File

@@ -104,9 +104,16 @@ const zh = {
title: '镜像',
loading: '加载中',
empty: '无镜像文件',
cdrom: '以 CD-ROM 模式挂载镜像',
mountMode: '挂载模式',
mountFailed: '挂载失败',
mountDesc: '在某些系统中,需要在远程主机中弹出虚拟硬盘后再挂载镜像。',
unmountFailed: '卸载失败',
unmountDesc: '在某些系统中,需要在远程主机中手动弹出后再卸载镜像。',
refresh: '刷新镜像列表',
attention: '注意',
deleteConfirm: '确定要删除该镜像吗?',
okBtn: '确定',
cancelBtn: '取消',
tips: {
title: '如何上传',
usb1: '将 NanoKVM 通过 USB 连接到你的电脑;',
@@ -256,8 +263,11 @@ const zh = {
title: 'Tailscale',
memory: {
title: '内存优化',
tip: '当内存占用超过限制时,会更积极地执行垃圾回收来尝试释放内存。如果使用 Tailscale 推荐设置为 50MB需重启 Tailscale 后生效。',
disable: '关闭'
tip: '当内存占用超过限制时,会更积极地执行垃圾回收来尝试释放内存。需重启 Tailscale 后生效。'
},
swap: {
title: '交换内存',
tip: '如果启用内存优化后依然存在问题可以尝试开启交换内存。启用后会将交换文件设置为256MB可以在「设置 - 设备」中修改该选项。'
},
restart: '取定要重启 Tailscale 吗?',
stop: '确定要停止 Tailscale 吗?',
@@ -274,6 +284,8 @@ const zh = {
upTailscale: '将 tailscale 文件上传到 /usr/bin/ 目录',
upTailscaled: '将 tailscaled 文件上传到 /usr/sbin/ 目录',
refresh: '刷新页面',
notRunning: 'Tailscale 尚未运行,请先执行启动操作',
run: '启动',
notLogin: '该设备尚未绑定,请点击登录并将这台设备绑定到您的账号。',
urlPeriod: '该链接10分钟内有效',
login: '登录',
@@ -285,6 +297,7 @@ const zh = {
logout: '退出',
logoutDesc: '确定要退出吗?',
uninstall: '卸载 Tailscale',
uninstallDesc: '确定要卸载 Tailscale 吗?',
reboot: '重启',
rebootDesc: '你确定要重启 NanoKVM 吗?',
okBtn: '确认',

View File

@@ -3,7 +3,5 @@ import { atom } from 'jotai';
// menu bar disabled items
export const menuDisabledItemsAtom = atom<string[]>([]);
// web title
export const webTitleAtom = atom('');

View File

@@ -2,37 +2,24 @@ import { useEffect, useRef } from 'react';
import { client } from '@/lib/websocket.ts';
import { KeyboardCodes, ModifierCodes } from './mappings.ts';
import { KeyboardCodes } from './mappings.ts';
export const Keyboard = () => {
const lastCodeRef = useRef('');
const modifierRef = useRef({
ctrl: 0,
shift: 0,
alt: 0,
meta: 0
});
const pressedKeys = useRef<Set<string>>(new Set());
// listen keyboard events
useEffect(() => {
const modifiers = ['Control', 'Shift', 'Alt', 'Meta'];
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
window.addEventListener('blur', releaseAllKeys);
document.addEventListener('visibilitychange', handleVisibilityChange);
// press button
function handleKeyDown(event: KeyboardEvent) {
disableEvent(event);
lastCodeRef.current = event.code;
if (modifiers.includes(event.key)) {
const code = ModifierCodes.get(event.code)!;
setModifier(event.key, code);
if (event.key === 'Meta') {
return;
}
if (!pressedKeys.current.has(event.code)) {
pressedKeys.current.add(event.code);
}
sendKeyDown(event);
@@ -42,58 +29,64 @@ export const Keyboard = () => {
function handleKeyUp(event: KeyboardEvent) {
disableEvent(event);
if (modifiers.includes(event.key)) {
if (event.key === 'Meta' && lastCodeRef.current === event.code) {
sendKeyDown(event, true);
sendKeyUp();
}
setModifier(event.key, 0);
if (pressedKeys.current.has(event.code)) {
pressedKeys.current.delete(event.code);
}
if (event.key !== 'Meta') {
sendKeyUp();
sendKeyUp();
}
function releaseAllKeys() {
if (pressedKeys.current.size === 0) {
return;
}
sendKeyUp();
pressedKeys.current.clear();
}
function handleVisibilityChange() {
if (document.hidden) {
releaseAllKeys();
}
}
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
window.removeEventListener('blur', releaseAllKeys);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
function setModifier(key: string, code: number) {
switch (key) {
case 'Control':
modifierRef.current.ctrl = code;
break;
case 'Alt':
modifierRef.current.alt = code;
break;
case 'Shift':
modifierRef.current.shift = code;
break;
case 'Meta':
modifierRef.current.meta = code;
break;
default:
console.log('unknown key: ', key);
}
}
function sendKeyDown(event: KeyboardEvent, isMeta?: boolean) {
function sendKeyDown(event: KeyboardEvent) {
const code = KeyboardCodes.get(event.code);
if (!code) {
console.log('unknown code: ', event.code);
return;
}
const ctrl = event.ctrlKey ? modifierRef.current.ctrl || 1 : 0;
const shift = event.shiftKey ? modifierRef.current.shift || 2 : 0;
const alt = event.altKey ? modifierRef.current.alt || 4 : 0;
const meta = event.metaKey || isMeta ? modifierRef.current.meta || 8 : 0;
let ctrl = 0;
if (event.ctrlKey) {
if (pressedKeys.current.has('ControlLeft')) {
ctrl = 1;
} else if (pressedKeys.current.has('ControlRight')) {
ctrl = 16;
} else if (pressedKeys.current.has('AltRight')) {
ctrl = 0;
} else {
ctrl = 1;
}
}
client.send([1, code, ctrl, shift, alt, meta]);
const modifiers = [
ctrl,
event.shiftKey ? (pressedKeys.current.has('ShiftRight') ? 32 : 2) : 0,
event.altKey ? (pressedKeys.current.has('AltRight') ? 64 : 4) : 0,
event.metaKey ? (pressedKeys.current.has('MetaRight') ? 128 : 8) : 0
];
client.send([1, code, ...modifiers]);
}
function sendKeyUp() {

View File

@@ -1,12 +1,13 @@
import { useEffect, useState } from 'react';
import { notification } from 'antd';
import { Button, Modal, notification, Typography } from 'antd';
import clsx from 'clsx';
import {
ArrowBigDownDashIcon,
ArrowBigUpDashIcon,
LoaderCircleIcon,
PackageIcon,
PackageSearchIcon
PackageSearchIcon,
Trash2Icon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -27,6 +28,9 @@ export const Images = ({ isOpen, cdrom, setIsMounted }: ImagesProps) => {
const [images, setImages] = useState<string[]>([]);
const [mountingImage, setMountingImage] = useState('');
const [mountedImage, setMountedImage] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedImage, setSelectedImage] = useState('');
const [deletingImage, setDeletingImage] = useState('');
useEffect(() => {
if (isOpen) {
@@ -34,6 +38,7 @@ export const Images = ({ isOpen, cdrom, setIsMounted }: ImagesProps) => {
}
}, [isOpen]);
// get image list
function getImages() {
if (isLoading) return;
setIsLoading(true);
@@ -59,32 +64,33 @@ export const Images = ({ isOpen, cdrom, setIsMounted }: ImagesProps) => {
});
}
async function getMountedImage() {
try {
const rsp = await api.getMountedImage();
// get mounted image
function getMountedImage() {
api.getMountedImage().then((rsp) => {
if (rsp.code !== 0) return;
const file = rsp.data?.file;
setMountedImage(file);
setIsMounted(!!file);
} catch (e) {
console.log(e);
}
});
}
// mount/unmount image
function mountImage(image: string) {
if (mountingImage) return;
setMountingImage(image);
client.close();
const filename = mountedImage === image ? '' : image;
const isMounted = mountedImage === image;
const filename = isMounted ? '' : image;
api
.mountImage(filename, cdrom)
.then((rsp) => {
if (rsp.code !== 0) {
openNotification();
console.log(rsp.msg);
openNotification(isMounted);
return;
}
@@ -97,42 +103,90 @@ export const Images = ({ isOpen, cdrom, setIsMounted }: ImagesProps) => {
});
}
function openNotification() {
// show delete image modal
function showDeleteModal(e: any, image: string) {
e.stopPropagation();
const isMounted = mountedImage === image;
const isDeleting = deletingImage !== '';
if (isMounted || isDeleting) {
return;
}
setSelectedImage(image);
setIsModalOpen(true);
}
// delete image
function deleteImage() {
if (!selectedImage || !!deletingImage) return;
setDeletingImage(selectedImage);
setIsModalOpen(false);
api
.deleteImage(selectedImage)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
getImages();
setSelectedImage('');
})
.finally(() => {
setDeletingImage('');
});
}
// show mount/unmount failed notification
function openNotification(isMounted: boolean) {
const message = isMounted ? 'image.unmountFailed' : 'image.mountFailed';
const description = isMounted ? 'image.unmountDesc' : 'image.mountDesc';
notify.open({
message: t('image.mountFailed'),
description: t('image.mountDesc'),
duration: 6
message: t(message),
description: t(description),
duration: 10
});
}
// loading
if (isLoading) {
return (
<div className="flex items-center justify-center space-x-2 py-5 text-neutral-400">
<LoaderCircleIcon className="animate-spin" size={18} />
<span className="text-sm">{t('image.loading')}</span>
</div>
);
}
// empty image
if (images.length === 0) {
return (
<div className="flex items-center justify-center space-x-2 py-5 text-neutral-500">
<PackageSearchIcon size={18} />
<span className="text-sm">{t('image.empty')}</span>
</div>
);
}
return (
<>
{contextHolder}
{isLoading ? (
// loading
<div className="flex items-center space-x-2 py-2 pl-2 pr-4 text-neutral-400">
<LoaderCircleIcon className="animate-spin" size={18} />
<span className="text-sm">{t('image.loading')}</span>
</div>
) : images.length === 0 ? (
// no image
<div className="flex items-center space-x-2 pl-2 pr-4 text-neutral-500">
<PackageSearchIcon size={18} />
<span className="text-sm">{t('image.empty')}</span>
</div>
) : (
// image list
images.map((image) => (
<div className="flex max-h-[400px] flex-col overflow-y-auto pb-2">
{images.map((image) => (
<div
key={image}
className={clsx(
'group flex max-w-[300px] cursor-pointer select-none items-center space-x-1 rounded p-2 hover:bg-neutral-700/70',
'group flex cursor-pointer select-none items-center space-x-1 rounded px-1 py-2 hover:bg-neutral-700/70',
mountedImage === image && 'text-blue-500'
)}
onClick={() => mountImage(image)}
>
<div className="h-[18px] w-[18px]">
<div className="flex h-[24px] w-[24px] items-center justify-center">
{mountingImage === image ? (
<LoaderCircleIcon className="animate-spin" size={18} />
) : (
@@ -142,16 +196,54 @@ export const Images = ({ isOpen, cdrom, setIsMounted }: ImagesProps) => {
<div className="flex-1 truncate">{image.replace(/^.*[\\/]/, '')}</div>
<div className="h-[18px] w-[18px]">
<div className="flex h-[24px] w-[24px] items-center justify-center rounded">
{mountedImage === image ? (
<ArrowBigDownDashIcon size={18} className="hidden text-red-500 group-hover:block" />
<ArrowBigDownDashIcon size={22} className="hidden text-red-500 group-hover:block" />
) : (
<ArrowBigUpDashIcon size={18} className="hidden text-blue-500 group-hover:block" />
<ArrowBigUpDashIcon size={22} className="hidden text-blue-500 group-hover:block" />
)}
</div>
<div
className={clsx(
'flex h-[24px] w-[24px] items-center justify-center rounded hover:bg-neutral-500/50',
mountedImage === image
? 'cursor-not-allowed text-neutral-500'
: 'text-neutral-300 hover:text-red-500'
)}
onClick={(e) => showDeleteModal(e, image)}
>
{deletingImage === image ? (
<LoaderCircleIcon className="animate-spin text-red-500" size={16} />
) : (
<Trash2Icon size={16} />
)}
</div>
</div>
))
)}
))}
</div>
<Modal
title={t('image.attention')}
open={isModalOpen}
width={520}
footer={null}
onCancel={() => setIsModalOpen(false)}
>
<div className="flex flex-col items-center pb-10">
<p>{t('image.deleteConfirm')}</p>
<Typography.Text code>{selectedImage}</Typography.Text>
</div>
<div className="flex justify-center space-x-3 pb-3">
<Button type="primary" danger onClick={deleteImage}>
{t('image.okBtn')}
</Button>
<Button onClick={() => setIsModalOpen(false)}>{t('image.cancelBtn')}</Button>
</div>
</Modal>
{contextHolder}
</>
);
};

View File

@@ -1,11 +1,10 @@
import { useEffect, useState } from 'react';
import { Divider, Switch, Tooltip } from 'antd';
import { Divider, Modal, Segmented } from 'antd';
import clsx from 'clsx';
import { DiscIcon } from 'lucide-react';
import { DiscIcon, HardDriveIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { getCdRom, getMountedImage } from '@/api/storage.ts';
import { MenuItem } from '@/components/menu-item.tsx';
import * as api from '@/api/storage.ts';
import { Images } from './images.tsx';
import { Tips } from './tips.tsx';
@@ -13,64 +12,76 @@ import { Tips } from './tips.tsx';
export const Image = () => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isMounted, setIsMounted] = useState(false);
const [cdrom, setCdrom] = useState(false);
const [mode, setMode] = useState('mass-storage');
const modes = [
{
value: 'mass-storage',
label: (
<div className="flex items-center space-x-1">
<HardDriveIcon size={16} />
<span>Mass Storage</span>
</div>
)
},
{
value: 'cd-rom',
label: (
<div className="flex items-center space-x-1">
<DiscIcon size={16} />
<span>CD ROM</span>
</div>
)
}
];
useEffect(() => {
getMountedImage().then((rsp) => {
api.getMountedImage().then((rsp) => {
if (rsp.code === 0) {
setIsMounted(!!rsp.data?.file);
}
});
getCdRom().then((rsp) => {
api.getCdRom().then((rsp) => {
if (rsp.code === 0) {
setCdrom(rsp.data?.cdrom === 1);
setMode(rsp.data?.cdrom === 1 ? 'cd-rom' : 'mass-storage');
}
});
}, []);
const content = (
<div className="min-w-[300px]">
<div className="flex items-center justify-between px-1">
return (
<>
<div
className={clsx(
'flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded hover:bg-neutral-700',
isMounted ? 'text-blue-500' : 'text-neutral-300 hover:text-white'
)}
onClick={() => setIsModalOpen(true)}
>
<DiscIcon size={18} />
</div>
<Modal open={isModalOpen} footer={null} onCancel={() => setIsModalOpen(false)}>
<div className="flex items-center space-x-1">
<span className="text-base font-bold text-neutral-300">{t('image.title')}</span>
<span className="text-xl font-bold">{t('image.title')}</span>
<Tips />
</div>
<div className="flex items-center space-x-2">
<Tooltip title={t('image.cdrom')} placement="bottom">
<div className="flex items-center space-x-2">
<span className="text-xs text-neutral-400">CD-ROM</span>
<Divider style={{ margin: '24px 0' }} />
<Switch
size="small"
checked={cdrom}
onChange={(checked) => setCdrom(checked)}
></Switch>
</div>
</Tooltip>
<div className="flex flex-col space-y-6">
<div className="flex items-center justify-between">
<span>{t('image.mountMode')}</span>
<Segmented value={mode} options={modes} disabled={isMounted} onChange={setMode} />
</div>
<Divider style={{ margin: '24px 0 0 0' }} />
<Images isOpen={isModalOpen} cdrom={mode === 'cd-rom'} setIsMounted={setIsMounted} />
</div>
</div>
<Divider style={{ margin: '10px 0 15px 0' }} />
<Images isOpen={isPopoverOpen} cdrom={cdrom} setIsMounted={setIsMounted} />
</div>
);
return (
<MenuItem
title={t('image.title')}
icon={<DiscIcon size={17} />}
content={content}
className={clsx(
'flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded hover:bg-neutral-700',
isMounted ? 'text-blue-500' : 'text-neutral-300 hover:text-white'
)}
fresh={true}
onOpenChange={setIsPopoverOpen}
/>
</Modal>
</>
);
};

View File

@@ -51,13 +51,10 @@ export const Tips = () => {
return (
<>
<div
className="flex cursor-pointer items-center space-x-1 text-neutral-400 hover:text-blue-400"
className="flex cursor-pointer items-center space-x-1 text-neutral-500 hover:text-blue-500"
onClick={() => setIsModalOpen(true)}
>
<CircleHelpIcon size={16} />
{/*<span className="text-sm text-neutral-500 hover:text-neutral-400">*/}
{/* {t('image.tips.title')}*/}
{/*</span>*/}
</div>
<Modal

View File

@@ -20,7 +20,6 @@ import { Script } from './script';
import { Settings } from './settings';
import { Terminal } from './terminal';
import { Wol } from './wol';
import { getMenuDisableItems } from '@/api/application.ts';
export const Menu = () => {
const { t } = useTranslation();
@@ -35,13 +34,7 @@ export const Menu = () => {
// disabled menu items
const items = getMenuDisabledItems();
setMenuDisabledItems(items);
getMenuDisableItems().then(res=>{
let disableItems = new Set<string>(items)
if(res.code === 0){
(res.data as string[]).forEach((ele)=>disableItems.add(ele))
}
setMenuDisabledItems([...disableItems.values()])
})
// react-draggable bounds
const handleResize = () => {
if (!nodeRef.current) return;

View File

@@ -1,5 +1,6 @@
import { ChangeEvent, useRef, useState } from 'react';
import { Button, Input, Modal, Select, message, type InputRef } from 'antd';
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { Button, Divider, Input, Modal, Select } from 'antd';
import type { InputRef } from 'antd';
import clsx from 'clsx';
import { useSetAtom } from 'jotai';
import { ClipboardIcon, ClipboardPasteIcon } from 'lucide-react';
@@ -8,9 +9,7 @@ import { useTranslation } from 'react-i18next';
import { paste } from '@/api/hid';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
const { Option } = Select;
const { TextArea } = Input;
type InputStatus = '' | 'error';
export const Paste = () => {
const { t } = useTranslation();
@@ -18,14 +17,24 @@ export const Paste = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [inputValue, setInputValue] = useState('');
const [status, setStatus] = useState<'' | 'error'>('');
const [isLoading, setIsLoading] = useState(false);
const [errMsg, setErrMsg] = useState('');
const [isClipboardSupported, setIsClipboardSupported] = useState(false);
const [isReadingClipboard, setIsReadingClipboard] = useState(false);
const [langue, setLangue] = useState('en');
const [status, setStatus] = useState<InputStatus>('');
const [errMsg, setErrMsg] = useState('');
const [isLoading, setIsLoading] = useState(false);
const inputRef = useRef<InputRef>(null);
const languages = [
{ value: 'en', label: t('keyboard.dropdownEnglish') },
{ value: 'de', label: t('keyboard.dropdownGerman') }
];
useEffect(() => {
setIsClipboardSupported('clipboard' in navigator);
}, []);
function onChange(e: ChangeEvent<HTMLTextAreaElement>) {
const value = e.target.value;
setStatus(isASCII(value) ? '' : 'error');
@@ -34,60 +43,27 @@ export const Paste = () => {
async function readFromClipboard() {
if (isReadingClipboard) return;
setIsReadingClipboard(true);
try {
// Check if clipboard API is available
if (!navigator.clipboard || !navigator.clipboard.readText) {
message.error(t('keyboard.clipboardNotSupported') || 'Clipboard API is not supported in this browser');
return;
}
// Read text from clipboard
const text = await navigator.clipboard.readText();
if (!text) {
message.warning(t('keyboard.clipboardEmpty') || 'Clipboard is empty');
return;
}
// Validate ASCII
if (!isASCII(text)) {
setStatus('error');
setInputValue(text);
message.error(t('keyboard.nonAsciiError') || 'Only ASCII characters are supported');
return;
}
// Check length
if (text.length > 1024) {
message.warning(t('keyboard.clipboardTooLong') || 'Clipboard content exceeds 1024 characters');
setInputValue(text.substring(0, 1024));
setStatus('');
return;
}
setInputValue(text);
setStatus('');
message.success(t('keyboard.clipboardRead') || 'Clipboard content loaded');
setInputValue((value) => value + text);
} catch (error) {
console.error('Failed to read clipboard:', error);
if (error instanceof Error) {
if (error.name === 'NotAllowedError') {
message.error(t('keyboard.clipboardPermissionDenied') || 'Clipboard permission denied. Please allow clipboard access in your browser.');
} else {
message.error(t('keyboard.clipboardReadError') || `Failed to read clipboard: ${error.message}`);
}
} else {
message.error(t('keyboard.clipboardReadError') || 'Failed to read clipboard');
if (error instanceof Error && error.name === 'NotAllowedError') {
setErrMsg(t('keyboard.clipboardPermissionDenied'));
return;
}
setErrMsg(t('keyboard.clipboardReadError'));
} finally {
setIsReadingClipboard(false);
}
}
function submit() {
if (isLoading) return;
if (isLoading || !inputValue) return;
setIsLoading(true);
paste(inputValue, langue)
@@ -139,23 +115,27 @@ export const Paste = () => {
<Modal
open={isModalOpen}
centered={false}
title={t('keyboard.paste')}
footer={null}
onCancel={() => setIsModalOpen(false)}
afterOpenChange={afterOpenChange}
>
<div className="pb-3 text-xs text-neutral-500">{t('keyboard.tips')}</div>
<div className="flex flex-col">
<span className="text-xl">{t('keyboard.paste')}</span>
<span className="text-sm text-neutral-600">{t('keyboard.tips')}</span>
</div>
<div className="space-y-2">
<Select
value={langue}
onChange={(value) => setLangue(value)}
style={{ width: '100%', marginBottom: '12px' }} >
<Option value="en">{t('keyboard.dropdownEnglish')}</Option>
<Option value="de">{t('keyboard.dropdownGerman')}</Option>
</Select>
<div className="flex items-center gap-2">
<Divider style={{ margin: '14px 0' }} />
<div
className={clsx(
'flex w-full items-center space-x-3 pb-2',
isClipboardSupported ? 'justify-start' : 'justify-end'
)}
>
{isClipboardSupported && (
<Button
color="default"
variant="filled"
icon={<ClipboardPasteIcon size={16} />}
loading={isReadingClipboard}
onClick={readFromClipboard}
@@ -163,27 +143,38 @@ export const Paste = () => {
>
{t('keyboard.readClipboard') || 'Read from Clipboard'}
</Button>
<span className="text-xs text-neutral-500">
{t('keyboard.clipboardHint') || 'Click to paste from your browser clipboard'}
</span>
</div>
)}
<TextArea
ref={inputRef}
value={inputValue}
status={status}
showCount
maxLength={1024}
autoSize={{ minRows: 5, maxRows: 12 }}
placeholder={t('keyboard.placeholder')}
onChange={onChange}
/>
<Select
value={langue}
variant="filled"
onChange={(value) => setLangue(value)}
options={languages}
></Select>
</div>
<Input.TextArea
ref={inputRef}
value={inputValue}
status={status}
showCount
maxLength={1024}
autoSize={{ minRows: 6, maxRows: 12 }}
placeholder={t('keyboard.placeholder')}
onFocus={() => setErrMsg('')}
onChange={onChange}
/>
{errMsg && <div className="pt-1 text-sm text-red-500">{errMsg}</div>}
<div className="flex justify-center py-3">
<Button type="primary" loading={isLoading} htmlType="submit" onClick={submit}>
<div className="flex justify-center py-5">
<Button
type="primary"
loading={isLoading}
htmlType="submit"
style={{ width: '300px' }}
onClick={submit}
>
{t('keyboard.submit')}
</Button>
</div>

View File

@@ -16,14 +16,12 @@ import { Quality } from './quality';
import { Reset } from './reset.tsx';
import { Resolution } from './resolution';
import { VideoMode } from './video-mode.tsx';
import { menuDisabledItemsAtom } from '@/jotai/settings.ts';
export const Screen = () => {
const { t } = useTranslation();
const videoMode = useAtomValue(videoModeAtom);
const resolution = useAtomValue(resolutionAtom);
const menuDisabledItems = useAtomValue(menuDisabledItemsAtom);
const [fps, setFps] = useState(30);
const [quality, setQuality] = useState(2);
const [gop, setGop] = useState(30);
@@ -72,10 +70,11 @@ export const Screen = () => {
const content = (
<div className="flex flex-col space-y-1">
<VideoMode />
{!menuDisabledItems.includes('screen:resolution') && <Resolution />}
{!menuDisabledItems.includes('screen:quality') && <Quality quality={quality} setQuality={setQuality} />}
{!menuDisabledItems.includes('screen:fps') && <Fps fps={fps} setFps={setFps} />}
{videoMode === 'mjpeg' ? <FrameDetect /> : <Gop gop={gop} setGop={setGop} />}
<Resolution />
<Quality quality={quality} setQuality={setQuality} />
<Fps fps={fps} setFps={setFps} />
{videoMode !== 'mjpeg' && <Gop gop={gop} setGop={setGop} />}
{videoMode === 'mjpeg' && <FrameDetect />}
<Reset />
</div>
);

View File

@@ -37,7 +37,7 @@ export const Community = () => {
{communities.map((community) => (
<a
key={community.name}
className="flex h-20 w-20 flex-col items-center justify-center space-y-2 rounded-lg text-neutral-300 outline outline-1 outline-neutral-700 hover:bg-neutral-800 hover:text-white focus:bg-neutral-800"
className="flex h-[64px] w-[80px] flex-col items-center justify-center space-y-2 rounded-lg text-neutral-300 outline outline-1 outline-neutral-800 hover:bg-neutral-800 hover:text-white focus:bg-neutral-800 md:h-[72px] md:w-[100px]"
href={community.url}
target="_blank"
>

View File

@@ -1,16 +1,13 @@
import { useEffect, useState } from 'react';
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
import { Button, Input } from 'antd';
import { useSetAtom } from 'jotai';
import { ClipboardPenIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/vm.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
export const Hostname = () => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const [isLoading, setIsLoading] = useState(false);
const [hostname, setHostname] = useState('');
@@ -76,8 +73,6 @@ export const Hostname = () => {
<div className="flex items-center space-x-1">
<Input
disabled={isLoading}
onFocus={() => setIsKeyboardEnable(false)}
onBlur={() => setIsKeyboardEnable(true)}
style={{ width: 150 }}
value={input}
onChange={(e) => setInput(e.target.value)}
@@ -86,13 +81,13 @@ export const Hostname = () => {
<Button size="small" icon={<CloseOutlined />} onClick={() => setEditState('')} />
</div>
) : (
<div className="flex items-start space-x-2">
<div className="flex items-center space-x-2">
<span>{hostname}</span>
<div
className="cursor-pointer text-blue-500 hover:text-blue-500/60"
className="size-[16px] cursor-pointer text-neutral-500 hover:text-blue-500"
onClick={showInput}
>
<ClipboardPenIcon size={18} />
<ClipboardPenIcon size={16} />
</div>
</div>
)}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Tag, Tooltip } from 'antd';
import { CircleHelpIcon } from 'lucide-react';
import { Tooltip } from 'antd';
import { CircleHelpIcon, EthernetPortIcon, WifiIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/vm.ts';
@@ -47,9 +47,15 @@ export const Information = () => {
{information?.ips && information.ips.length > 0 ? (
<div className="flex flex-col space-y-1">
{information.ips.map((ip) => (
<div key={ip.addr} className="flex items-center">
<Tag>{t('settings.about.ipType.' + ip.type)}</Tag>
<div key={ip.addr} className="flex items-center justify-end space-x-2">
<span>{ip.addr}</span>
<div className="size-[16px] text-neutral-500">
{ip.type === 'Wireless' ? (
<WifiIcon size={16} />
) : (
<EthernetPortIcon size={16} />
)}
</div>
</div>
))}
</div>
@@ -97,12 +103,6 @@ export const Information = () => {
<span>{information ? information.application : '-'}</span>
</div>
{/* device key */}
{/*<div className="flex w-full items-center justify-between">*/}
{/* <span>{t('settings.about.deviceKey')}</span>*/}
{/* <span>{information ? information.deviceKey : '-'}</span>*/}
{/*</div>*/}
</div>
</>
);

View File

@@ -1,19 +1,17 @@
import { useEffect, useState } from 'react';
import { Input } from 'antd';
import { useSetAtom } from 'jotai';
import { useAtom } from 'jotai';
import { GlobeIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/vm.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { webTitleAtom } from '@/jotai/settings.ts';
export const WebTitle = () => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const setWebTitle = useSetAtom(webTitleAtom);
const [webTitle, setWebTitle] = useAtom(webTitleAtom);
const [isLoading, setIsLoading] = useState(false);
const [title, setTitle] = useState('NanoKVM');
useEffect(() => {
setIsLoading(true);
@@ -22,7 +20,6 @@ export const WebTitle = () => {
.getWebTitle()
.then((rsp) => {
if (rsp.data?.title) {
setTitle(rsp.data.title);
setWebTitle(rsp.data.title);
}
})
@@ -31,19 +28,17 @@ export const WebTitle = () => {
});
}, []);
function update() {
function submit() {
if (isLoading) return;
setIsLoading(true);
api
.setWebTitle(title)
.setWebTitle(webTitle)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setWebTitle(title === 'NanoKVM' ? '' : title);
})
.finally(() => {
setIsLoading(false);
@@ -53,7 +48,10 @@ export const WebTitle = () => {
return (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-1">
<div className="flex size-[16px] items-center justify-center">
<GlobeIcon size={14} />
</div>
<span>{t('settings.appearance.webTitle')}</span>
</div>
<span className="text-xs text-neutral-500">{t('settings.appearance.webTitleDesc')}</span>
@@ -61,12 +59,12 @@ export const WebTitle = () => {
<Input
disabled={isLoading}
onFocus={() => setIsKeyboardEnable(false)}
onBlur={() => setIsKeyboardEnable(true)}
style={{ width: 180 }}
value={title}
onChange={(e) => setTitle(e.target.value)}
onPressEnter={update}
value={webTitle}
onChange={(e) => setWebTitle(e.target.value)}
onPressEnter={submit}
onBlur={submit}
placeholder="NanoKVM"
/>
</div>
);

View File

@@ -9,13 +9,12 @@ const children = (
</div>
);
export const Advanced = ({disable = false}:{disable?:boolean}) => {
export const Advanced = () => {
const { t } = useTranslation();
return (
<Collapse
ghost
collapsible={disable? 'disabled': 'header'}
expandIconPosition="end"
items={[{ key: 'advanced', label: t('settings.device.advanced'), children }]}
/>

View File

@@ -20,6 +20,10 @@ export const Swap = () => {
];
useEffect(() => {
getSwap();
}, []);
function getSwap() {
setIsLoading(true);
api
@@ -32,9 +36,9 @@ export const Swap = () => {
.finally(() => {
setIsLoading(false);
});
}, []);
}
async function update(value: string) {
function update(value: string) {
if (isLoading) return;
setIsLoading(true);

View File

@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { Divider } from 'antd';
import { useAtom, useAtomValue } from 'jotai';
import { useAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/hid.ts';
@@ -17,11 +17,9 @@ import { Ssh } from './ssh.tsx';
import { Tls } from './tls.tsx';
import { VirtualDevices } from './virtual-devices.tsx';
import { Wifi } from './wifi.tsx';
import { menuDisabledItemsAtom } from '@/jotai/settings.ts';
export const Device = () => {
const { t } = useTranslation();
const disableMenus = useAtomValue(menuDisabledItemsAtom)
const [hidMode, setHidMode] = useAtom(hidModeAtom);
useEffect(() => {
@@ -38,25 +36,23 @@ export const Device = () => {
<Divider />
<div className="flex flex-col space-y-6">
{!disableMenus.includes('device:tls') && <Tls />}
{!disableMenus.includes('device:ssh') && <Ssh />}
{!disableMenus.includes('device:mdns') && <Mdns />}
{!disableMenus.includes('device:hdmi') && <Hdmi />}
<Tls />
<Ssh />
<Mdns />
<Hdmi />
{hidMode === 'normal' ? <VirtualDevices /> : <HidMode />}
</div>
<Divider />
<div className="flex flex-col space-y-6">
{!disableMenus.includes('device:oled') && <Oled />}
{!disableMenus.includes('device:wifi') && <Wifi />}
{!disableMenus.includes('device:mouse') && <MouseJiggler />}
<Oled />
<Wifi />
<MouseJiggler />
</div>
<Divider />
<Advanced disable={disableMenus.includes("device:advance")} />
<Advanced />
<Divider />
<Reboot />

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Badge, Modal, Tooltip } from 'antd';
import clsx from 'clsx';
import { useSetAtom } from 'jotai';
import {
BadgeInfoIcon,
CircleArrowUpIcon,
@@ -14,6 +15,7 @@ import semver from 'semver';
import * as api from '@/api/application.ts';
import * as ls from '@/lib/localstorage.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { Tailscale as TailscaleIcon } from '@/components/icons/tailscale';
import { About } from './about';
@@ -31,6 +33,7 @@ export const Settings = () => {
const [currentTab, setCurrentTab] = useState('about');
const [isUpdateAvailable, setIsUpdateAvailable] = useState(false);
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const tabs = [
{ id: 'about', icon: <BadgeInfoIcon size={16} />, component: <About /> },
@@ -81,11 +84,17 @@ export const Settings = () => {
}
}
function openModal() {
setIsModalOpen(true);
setIsKeyboardEnable(false);
}
function closeModal() {
if (isLocked) {
return;
}
setIsKeyboardEnable(true);
setIsModalOpen(false);
setCurrentTab('about');
}
@@ -95,7 +104,7 @@ export const Settings = () => {
<Tooltip title={t('settings.title')} placement="bottom" mouseEnterDelay={0.6}>
<div
className="flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded text-white hover:bg-neutral-700/80"
onClick={() => setIsModalOpen(true)}
onClick={openModal}
>
<Badge dot={isUpdateAvailable} color="blue" offset={[0, 2]}>
<div className="pt-[3px]">
@@ -107,17 +116,20 @@ export const Settings = () => {
<Modal
open={isModalOpen}
width={900}
width={'80%'}
centered={true}
footer={null}
destroyOnClose={true}
styles={{ content: { padding: 0 } }}
onCancel={closeModal}
style={{ maxWidth: '1080px' }}
styles={{ content: { padding: 0 } }}
>
<div className="flex min-h-[500px] rounded-lg outline outline-1 outline-neutral-700">
<div className="flex flex-col space-y-0.5 rounded-l-lg bg-neutral-800 py-5 sm:w-1/5 sm:px-2">
<div className="hidden px-3 text-lg font-bold sm:block">{t('settings.title')}</div>
<div className="flex h-[80vh] max-h-[700px] rounded-lg outline outline-1 outline-neutral-700">
<div className="flex h-full max-w-[240px] flex-col space-y-0.5 rounded-l-lg bg-neutral-800 px-1 sm:w-1/5 md:px-2">
<div className="hidden px-3 pt-10 text-xl sm:block">{t('settings.title')}</div>
<div className="h-10 sm:h-5" />
<div className="pt-3" />
{tabs.map((tab) => (
<div
key={tab.id}
@@ -144,8 +156,8 @@ export const Settings = () => {
))}
</div>
<div className="flex max-h-[700px] w-full flex-col items-center overflow-y-auto rounded-r-lg bg-neutral-900 px-3 sm:w-4/5">
<div className="w-full max-w-[550px] py-10">
<div className="flex h-full w-full flex-col items-center overflow-y-auto rounded-r-lg bg-neutral-900 px-3">
<div className="w-full max-w-[600px] pb-10 pt-14">
<>{tabs.find((tab) => tab.id === currentTab)?.component}</>
</div>
</div>

View File

@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import * as api from '@/api/extensions/tailscale.ts';
import { Memory } from './memory.tsx';
import { Swap } from './swap.tsx';
import type { State } from './types.ts';
import { Uninstall } from './uninstall.tsx';
@@ -93,6 +94,7 @@ export const Header = ({ state, onSuccess }: HeaderProps) => {
content={
<div className="flex min-w-[250px] flex-col">
<Memory />
<Swap />
<Uninstall onSuccess={onSuccess} />
</div>
}

View File

@@ -9,6 +9,7 @@ import { Device } from './device.tsx';
import { Header } from './header.tsx';
import { Install } from './install.tsx';
import { Login } from './login.tsx';
import { Run } from './run.tsx';
import type { Status } from './types.ts';
type TailscaleProps = {
@@ -61,9 +62,9 @@ export const Tailscale = ({ setIsLocked }: TailscaleProps) => {
<Install setIsLocked={setIsLocked} onSuccess={getStatus} />
)}
{(status?.state === 'notRunning' || status?.state === 'notLogin') && (
<Login onSuccess={getStatus} />
)}
{status?.state === 'notRunning' && <Run onSuccess={getStatus} />}
{status?.state === 'notLogin' && <Login onSuccess={getStatus} />}
{(status?.state === 'stopped' || status?.state === 'running') && (
<Device status={status} onLogout={getStatus} />

View File

@@ -36,54 +36,50 @@ export const Install = ({ setIsLocked, onSuccess }: InstallProps) => {
});
}
if (state === 'failed') {
return (
<Result
status="warning"
title={t('settings.tailscale.failed')}
subTitle={t('settings.tailscale.retry')}
icon={<InfoCircleOutlined />}
extra={
<Card key="tips" styles={{ body: { padding: 0 } }}>
<ul className="list-decimal text-left font-mono text-sm text-neutral-300">
<li>
{t('settings.tailscale.download')}
<a
className="px-1"
href="https://pkgs.tailscale.com/stable/tailscale_latest_riscv64.tgz"
target="_blank"
>
{t('settings.tailscale.package')}
</a>
{t('settings.tailscale.unzip')}
</li>
<li>{t('settings.tailscale.upTailscale')}</li>
<li>{t('settings.tailscale.upTailscaled')}</li>
<li>{t('settings.tailscale.refresh')}</li>
</ul>
</Card>
}
/>
);
}
return (
<>
{state !== 'failed' ? (
<Result
icon={<DownloadOutlined />}
subTitle={t('settings.tailscale.notInstall')}
extra={
<Button
key="install"
type="primary"
size="large"
loading={state === 'installing'}
onClick={install}
>
{state === 'installing'
? t('settings.tailscale.installing')
: t('settings.tailscale.install')}
</Button>
}
/>
) : (
<Result
status="warning"
title={t('settings.tailscale.failed')}
subTitle={t('settings.tailscale.retry')}
icon={<InfoCircleOutlined />}
extra={
<Card key="tips" styles={{ body: { padding: 0 } }}>
<ul className="list-decimal text-left font-mono text-sm text-neutral-300">
<li>
{t('settings.tailscale.download')}
<a
className="px-1"
href="https://pkgs.tailscale.com/stable/tailscale_latest_riscv64.tgz"
target="_blank"
>
{t('settings.tailscale.package')}
</a>
{t('settings.tailscale.unzip')}
</li>
<li>{t('settings.tailscale.upTailscale')}</li>
<li>{t('settings.tailscale.upTailscaled')}</li>
<li>{t('settings.tailscale.refresh')}</li>
</ul>
</Card>
}
/>
)}
</>
<Card>
<Result
icon={<DownloadOutlined />}
subTitle={t('settings.tailscale.notInstall')}
extra={
<Button key="install" type="primary" loading={state === 'installing'} onClick={install}>
{state === 'installing'
? t('settings.tailscale.installing')
: t('settings.tailscale.install')}
</Button>
}
/>
</Card>
);
};

View File

@@ -27,7 +27,7 @@ export const Memory = () => {
setIsLoading(true);
const enabled = !isEnabled;
const limit = isEnabled ? 0 : 50;
const limit = isEnabled ? 0 : 75;
api
.setMemoryLimit(enabled, limit)
@@ -45,22 +45,20 @@ export const Memory = () => {
}
return (
<>
<div className="flex h-[40px] cursor-pointer items-center justify-between space-x-6 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
<div className="flex items-center space-x-1">
<span>{t('settings.tailscale.memory.title')}</span>
<Tooltip
title={t('settings.tailscale.memory.tip')}
className="cursor-pointer text-neutral-500"
placement="top"
overlayStyle={{ maxWidth: '350px' }}
>
<CircleHelpIcon size={15} />
</Tooltip>
</div>
<Switch value={isEnabled} size="small" onClick={update} />
<div className="flex h-[40px] cursor-pointer items-center justify-between space-x-6 rounded px-2 text-neutral-300 hover:bg-neutral-700/70">
<div className="flex items-center space-x-1">
<span>{t('settings.tailscale.memory.title')}</span>
<Tooltip
title={t('settings.tailscale.memory.tip')}
className="cursor-pointer text-neutral-500"
placement="top"
overlayStyle={{ maxWidth: '400px' }}
>
<CircleHelpIcon size={15} />
</Tooltip>
</div>
</>
<Switch value={isEnabled} size="small" onClick={update} />
</div>
);
};

View File

@@ -0,0 +1,54 @@
import { useState } from 'react';
import { PauseCircleOutlined } from '@ant-design/icons';
import { Button, Card, Result } from 'antd';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/extensions/tailscale.ts';
type RunProps = {
onSuccess: () => void;
};
export const Run = ({ onSuccess }: RunProps) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [errMsg, setErrMsg] = useState('');
function run() {
if (isLoading) return;
setIsLoading(true);
api
.start()
.then((rsp) => {
if (rsp.code !== 0) {
setErrMsg(rsp.msg);
return;
}
onSuccess();
})
.finally(() => {
setIsLoading(false);
});
}
return (
<Card>
<Result
icon={<PauseCircleOutlined />}
subTitle={t('settings.tailscale.notRunning')}
extra={
<Button key="install" type="primary" loading={isLoading} onClick={run}>
{t('settings.tailscale.run')}
</Button>
}
/>
<div className="flex justify-center">
{errMsg && <span className="text-red-500">{errMsg}</span>}
</div>
</Card>
);
};

View File

@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';
import { Switch, Tooltip } from 'antd';
import { CircleHelpIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/vm.ts';
export const Swap = () => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [isEnabled, setIsEnabled] = useState(false);
useEffect(() => {
getSwap();
}, []);
function getSwap() {
setIsLoading(true);
api
.getSwap()
.then((rsp) => {
if (rsp.data?.size > 0) {
setIsEnabled(true);
}
})
.finally(() => {
setIsLoading(false);
});
}
function update(enable: boolean) {
if (isLoading) return;
setIsLoading(true);
const size = enable ? 256 : 0;
api
.setSwap(size)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setIsEnabled(enable);
})
.finally(() => {
setIsLoading(false);
});
}
return (
<div className="flex h-[40px] cursor-pointer items-center justify-between space-x-6 rounded px-2 text-neutral-300 hover:bg-neutral-700/70">
<div className="flex items-center space-x-1">
<span>{t('settings.tailscale.swap.title')}</span>
<Tooltip
title={t('settings.tailscale.swap.tip')}
className="cursor-pointer text-neutral-500"
placement="top"
overlayStyle={{ maxWidth: '400px' }}
>
<CircleHelpIcon size={15} />
</Tooltip>
</div>
<Switch value={isEnabled} loading={isLoading} size="small" onChange={update} />
</div>
);
};

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { Button } from 'antd';
import clsx from 'clsx';
import { Modal } from 'antd';
import { Trash2Icon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/extensions/tailscale.ts';
@@ -13,44 +13,49 @@ export const Uninstall = ({ onSuccess }: UninstallProps) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [isConfirmation, setIsConfirmation] = useState(false);
function showConfirmation() {
if (!isConfirmation) {
setIsConfirmation(true);
}
}
const [isModalOpen, setIsModalOpen] = useState(false);
function uninstall() {
if (isLoading) return;
setIsLoading(true);
api.uninstall().finally(() => {
setIsModalOpen(false);
setIsLoading(false);
onSuccess();
});
}
return (
<div
className="flex h-[40px] cursor-pointer items-center justify-between space-x-6 rounded px-3 text-neutral-300 hover:bg-neutral-700/70"
onClick={showConfirmation}
>
<span className={clsx(isConfirmation && 'text-red-500')}>
{t('settings.tailscale.uninstall')}
</span>
{isConfirmation && (
<div className="flex items-center space-x-2">
<Button type="primary" size="small" disabled={isLoading} danger onClick={uninstall}>
{t('settings.tailscale.okBtn')}
</Button>
<Button type="primary" size="small" onClick={() => setIsConfirmation(false)}>
{t('settings.tailscale.cancelBtn')}
</Button>
</div>
)}
const title = (
<div className="flex items-center space-x-1 text-red-500">
<Trash2Icon size={18} />
<span>{t('settings.tailscale.uninstall')}</span>
</div>
);
return (
<>
<div
className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-2 py-1 text-neutral-300 hover:bg-neutral-700/70"
onClick={() => setIsModalOpen(true)}
>
<span>{t('settings.tailscale.uninstall')}</span>
</div>
<Modal
title={title}
open={isModalOpen}
okType="danger"
okText={t('settings.tailscale.okBtn')}
cancelText={t('settings.tailscale.cancelBtn')}
onOk={uninstall}
onCancel={() => setIsModalOpen(false)}
confirmLoading={isLoading}
>
<div className="py-5">
<p className="text-base">{t('settings.tailscale.uninstallDesc')}</p>
</div>
</Modal>
</>
);
};

View File

@@ -12,12 +12,12 @@ type UpdateProps = {
setIsLocked: (isClosable: boolean) => void;
};
type Status = 'loading' | 'updating' | 'outdated' | 'latest' | 'failed';
type Status = '' | 'loading' | 'updating' | 'outdated' | 'latest' | 'failed';
export const Update = ({ setIsLocked }: UpdateProps) => {
const { t } = useTranslation();
const [status, setStatus] = useState<Status>('loading');
const [status, setStatus] = useState<Status>('');
const [currentVersion, setCurrentVersion] = useState('');
const [latestVersion, setLatestVersion] = useState('');
const [errMsg, setErrMsg] = useState('');
@@ -27,12 +27,13 @@ export const Update = ({ setIsLocked }: UpdateProps) => {
}, []);
function checkForUpdates() {
if (status === 'loading') return;
setStatus('loading');
api
.getVersion()
.then((rsp: any) => {
if (rsp.code !== 0) {
if (rsp.code !== 0 || !rsp.data) {
setStatus('failed');
setErrMsg(t('settings.update.queryFailed'));
return;
@@ -80,47 +81,53 @@ export const Update = ({ setIsLocked }: UpdateProps) => {
<Divider />
<Preview />
<Divider />
{status === 'loading' && (
<div className="flex justify-center pt-24">
<Spin indicator={<LoadingOutlined spin />} size="large" />
</div>
)}
<div className="my-[40px] h-px bg-neutral-500/10" />
{status === 'updating' && (
<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>
)}
<div className="flex min-h-[400px] flex-col justify-between">
{status === 'loading' && (
<div className="flex justify-center pt-24">
<Spin indicator={<LoadingOutlined spin />} size="large" />
</div>
)}
{status === 'latest' && (
<Result
status="success"
icon={<SmileOutlined />}
title={currentVersion}
subTitle={t('settings.update.isLatest')}
/>
)}
{status === 'updating' && (
<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>
)}
{status === 'outdated' && (
<Result
status="warning"
icon={<RocketOutlined />}
title={`${currentVersion} -> ${latestVersion}`}
subTitle={t('settings.update.available')}
extra={[
<Button key="confirm" type="primary" onClick={update}>
{t('settings.update.confirm')}
</Button>
]}
/>
)}
{status === 'latest' && (
<Result
status="success"
icon={<SmileOutlined />}
title={currentVersion}
subTitle={t('settings.update.isLatest')}
extra={[
<Button key="confirm" onClick={checkForUpdates}>
{t('settings.update.title')}
</Button>
]}
/>
)}
{status === 'failed' && <Result subTitle={errMsg} />}
{status === 'outdated' && (
<Result
status="warning"
icon={<RocketOutlined />}
title={`${currentVersion} -> ${latestVersion}`}
subTitle={t('settings.update.available')}
extra={[
<Button key="confirm" type="primary" onClick={update}>
{t('settings.update.confirm')}
</Button>
]}
/>
)}
{status === 'failed' && <Result subTitle={errMsg} />}
{status !== 'loading' && (
<div className="flex justify-center">
<Button
type="link"
@@ -131,7 +138,7 @@ export const Update = ({ setIsLocked }: UpdateProps) => {
CHANGELOG
</Button>
</div>
)}
</div>
</>
);
};

View File

@@ -13,9 +13,14 @@ export const Absolute = () => {
const lastScrollTimeRef = useRef(0);
const mouseButtonMapping = (button: number) => {
const mappings = [MouseButton.Left, MouseButton.Wheel, MouseButton.Right];
return mappings[button] || MouseButton.None;
};
// listen mouse events
useEffect(() => {
const canvas = document.getElementById('screen');
const canvas = document.getElementById('screen') as HTMLVideoElement;
if (!canvas) return;
canvas.addEventListener('mousedown', handleMouseDown);
@@ -29,21 +34,8 @@ export const Absolute = () => {
function handleMouseDown(event: any) {
disableEvent(event);
let button: MouseButton;
switch (event.button) {
case 0:
button = MouseButton.Left;
break;
case 1:
button = MouseButton.Wheel;
break;
case 2:
button = MouseButton.Right;
break;
default:
console.log(`unknown button ${event.button}`);
return;
}
const button: MouseButton = mouseButtonMapping(event.button);
if (button === MouseButton.None) return;
const data = [2, MouseEvent.Down, button, 0, 0];
client.send(data);
@@ -61,13 +53,8 @@ export const Absolute = () => {
function handleMouseMove(event: any) {
disableEvent(event);
const rect = canvas!.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width;
const y = (event.clientY - rect.top) / rect.height;
const hexX = x < 0 ? 0x0001 : Math.floor(0x7fff * x) + 0x0001;
const hexY = y < 0 ? 0x0001 : Math.floor(0x7fff * y) + 0x0001;
const data = [2, MouseEvent.MoveAbsolute, MouseButton.None, hexX, hexY];
const { x, y } = getCoordinate(event);
const data = [2, MouseEvent.MoveAbsolute, MouseButton.None, x, y];
client.send(data);
}
@@ -88,6 +75,53 @@ export const Absolute = () => {
client.send(data);
}
function getCorrectedCoords(clientX: number, clientY: number) {
if (!canvas) {
return { x: 0, y: 0 };
}
const rect = canvas.getBoundingClientRect();
if (!canvas.videoWidth || !canvas.videoHeight) {
const x = (clientX - rect.left) / rect.width;
const y = (clientY - rect.top) / rect.height;
return { x, y };
}
const videoRatio = canvas.videoWidth / canvas.videoHeight;
const elementRatio = rect.width / rect.height;
let renderedWidth = rect.width;
let renderedHeight = rect.height;
let offsetX = 0;
let offsetY = 0;
if (videoRatio > elementRatio) {
renderedHeight = rect.width / videoRatio;
offsetY = (rect.height - renderedHeight) / 2;
} else {
renderedWidth = rect.height * videoRatio;
offsetX = (rect.width - renderedWidth) / 2;
}
const x = (clientX - rect.left - offsetX) / renderedWidth;
const y = (clientY - rect.top - offsetY) / renderedHeight;
return { x, y };
}
function getCoordinate(event: any): { x: number; y: number } {
const { x, y } = getCorrectedCoords(event.clientX, event.clientY);
const finalX = Math.max(0, Math.min(1, x));
const finalY = Math.max(0, Math.min(1, y));
const hexX = Math.floor(0x7fff * finalX) + 0x0001;
const hexY = Math.floor(0x7fff * finalY) + 0x0001;
return { x: hexX, y: hexY };
}
return () => {
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mousedown', handleMouseDown);

View File

@@ -0,0 +1,147 @@
import Queue from 'yocto-queue';
let canvas: OffscreenCanvas | null = null;
let ctx: OffscreenCanvasRenderingContext2D | null = null;
let rendering: boolean = false;
let decoder: VideoDecoder | null = null;
const frameQueue = new Queue<VideoFrame>();
self.onmessage = (event: MessageEvent) => {
const { type, data, canvas: offscreenCanvas } = event.data;
switch (type) {
case 'h264':
canvas = offscreenCanvas;
ctx = canvas!.getContext('2d') as OffscreenCanvasRenderingContext2D;
break;
case 'ws_message':
handleWsMessage(data);
break;
case 'error':
case 'close':
resetDecoder();
break;
}
};
function handleWsMessage(message: ArrayBuffer) {
try {
if (message.byteLength < 9) {
return;
}
const view = new DataView(message);
const isKeyFrame = view.getUint8(0) === 1;
const timestamp = Number(view.getBigUint64(1, true));
const data = new Uint8Array(message, 9);
if (!decoder && isKeyFrame) {
initializeDecoder();
}
if (decoder?.state === 'configured') {
decode(isKeyFrame, timestamp, data);
}
} catch (error) {
console.error('Error processing WebSocket message in worker:', error);
}
}
function initializeDecoder() {
if (!self.VideoDecoder) {
console.log('Error: WebCodecs API not supported in this worker.');
return;
}
if (decoder && decoder.state !== 'unconfigured') {
return;
}
const init = {
output: (frame: VideoFrame) => {
frameQueue.enqueue(frame);
if (frameQueue.size >= 10) {
frameQueue.dequeue()?.close();
}
if (!rendering) {
rendering = true;
processFrameQueue();
}
},
error: () => {
resetDecoder();
}
};
try {
decoder = new VideoDecoder(init);
decoder.configure({
codec: 'avc1.42E01F',
optimizeForLatency: true
});
} catch (err) {
console.log(err);
decoder = null;
}
}
function decode(isKeyFrame: boolean, timestamp: number, data: Uint8Array) {
const chunk = new EncodedVideoChunk({
type: isKeyFrame ? 'key' : 'delta',
timestamp: timestamp,
data: data
});
try {
decoder?.decode(chunk);
} catch (err: any) {
if (err.name === 'TypeError' || err.message.includes('configured')) {
resetDecoder();
}
}
}
function processFrameQueue() {
const frame = frameQueue.dequeue();
if (frame) {
renderFrame(frame);
}
if (frameQueue.size > 0) {
setTimeout(processFrameQueue, 0);
} else {
rendering = false;
}
}
function renderFrame(frame: VideoFrame) {
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();
}
function resetDecoder() {
if (decoder && decoder.state !== 'closed') {
try {
decoder.close();
} catch (err) {
console.log(err);
}
}
decoder = null;
rendering = false;
Array.from(frameQueue.drain()).forEach((frame) => frame.close());
}

View File

@@ -2,28 +2,34 @@ 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';
import DirectWorker from './direct.worker.ts?worker';
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 canvasRef = useRef<HTMLCanvasElement>(null);
const workerRef = useRef<Worker | null>(null);
const frameQueue = new Queue<VideoFrame>();
// init websocket
useEffect(() => {
if (!window.VideoDecoder) {
console.log('Error: WebCodecs API not supported.');
return;
}
if (!canvasRef.current) {
return;
}
const worker = new DirectWorker();
workerRef.current = worker;
const offscreen = canvasRef.current.transferControlToOffscreen();
worker.postMessage({ type: 'h264', canvas: offscreen }, [offscreen]);
const url = `${getBaseUrl('ws')}/api/stream/h264/direct`;
const ws = new W3cWebSocket(url);
@@ -31,142 +37,28 @@ export const H264Direct = () => {
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);
}
worker.postMessage({ type: 'ws_message', data: event.data }, [event.data]);
} catch (error) {
console.error('Error processing WebSocket message:', error);
}
};
ws.onerror = () => {
resetDecoder();
worker.postMessage({ type: 'error' });
};
ws.onclose = () => {
resetDecoder();
worker.postMessage({ type: 'close' });
};
return () => {
if (ws.readyState === 1) {
ws.close();
}
resetDecoder();
worker.terminate();
};
}, []);
// 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

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Spin } from 'antd';
import clsx from 'clsx';
import { useAtomValue } from 'jotai';
@@ -14,43 +14,62 @@ export const H264Webrtc = () => {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const videoElement = document.getElementById('screen') as HTMLVideoElement;
const videoRef = useRef<HTMLVideoElement | null>(null);
const videoOfferSent = useRef(false);
const videoIceCandidates = useRef<RTCIceCandidate[]>([]);
useEffect(() => {
const url = `${getBaseUrl('ws')}/api/stream/h264`;
const ws = new W3cWebSocket(url);
const pc = new RTCPeerConnection({
iceServers: [
{
urls: ['stun:stun.l.google.com:19302']
}
]
});
const iceServers = [{ urls: ['stun:stun.l.google.com:19302'] }];
const video = new RTCPeerConnection({ iceServers });
pc.ontrack = function (event) {
if (event.track.kind !== 'video') {
console.log('unhandled track kind: ', event.track.kind);
// --- Init Video ---
video.onnegotiationneeded = async () => {
if (videoOfferSent.current || video.signalingState !== 'stable') {
console.log('Skipping video negotiation - Waiting for answer or state unstable');
return;
}
videoElement.srcObject = event.streams[0];
try {
videoOfferSent.current = true;
const offer = await video.createOffer({
offerToReceiveVideo: true,
offerToReceiveAudio: false
});
await video.setLocalDescription(offer);
sendMsg('video-offer', JSON.stringify(video.localDescription));
} catch (error) {
videoOfferSent.current = false;
console.error('Video negotiation failed:', error);
}
};
video.onconnectionstatechange = () => {
if (video.iceConnectionState === 'connected') {
setIsLoading(false);
}
};
video.ontrack = (event) => {
if (videoRef.current && event.track.kind === 'video') {
videoRef.current.srcObject = new MediaStream([event.track]);
}
};
ws.onopen = () => {
pc.onicecandidate = (event) => {
videoOfferSent.current = false;
video.onicecandidate = (event) => {
if (event.candidate) {
ws.send(JSON.stringify({ event: 'candidate', data: JSON.stringify(event.candidate) }));
sendMsg('video-candidate', JSON.stringify(event.candidate));
}
};
pc.addTransceiver('video', { direction: 'recvonly' });
pc.createOffer({ offerToReceiveVideo: true })
.then((offer) => {
pc.setLocalDescription(offer).catch(console.log);
ws.send(JSON.stringify({ event: 'offer', data: JSON.stringify(offer) }));
})
.catch(console.log);
video.addTransceiver('video', { direction: 'recvonly' });
};
ws.onmessage = (event) => {
@@ -62,28 +81,75 @@ export const H264Webrtc = () => {
if (!data) return;
switch (msg.event) {
case 'answer':
pc.setRemoteDescription(data).catch(console.log);
case 'video-answer':
handleVideoAnswer(data);
break;
case 'candidate':
pc.addIceCandidate(data).catch(console.log);
case 'video-candidate':
handleVideoCandidate(data);
break;
case 'heartbeat':
break;
default:
console.log('unhandled event: ', msg.event);
console.log('Unhandled event:', msg.event);
}
} catch (err) {
console.log(err);
console.error('Message processing error:', err);
}
};
const handleVideoAnswer = (data: any) => {
if (video.signalingState !== 'have-local-offer') {
videoOfferSent.current = false;
console.warn(`Video signaling state incorrect for answer: ${video.signalingState}`);
return;
}
video
.setRemoteDescription(new RTCSessionDescription(data))
.then(() => {
videoOfferSent.current = false;
videoIceCandidates.current.forEach((candidate) => {
video
.addIceCandidate(candidate)
.catch((e) => console.error('Video candidate failed to add:', e.message));
});
videoIceCandidates.current = [];
})
.catch((error) => {
console.error('Video answer set failed:', error);
videoOfferSent.current = false;
});
};
const handleVideoCandidate = (data: any) => {
if (!data.candidate) {
return;
}
const candidate = new RTCIceCandidate(data);
if (video.remoteDescription) {
video
.addIceCandidate(candidate)
.catch((e) => console.error('Video candidate failed to add:', e.message));
} else {
videoIceCandidates.current.push(candidate);
}
};
const sendMsg = (event: string, data: string) => {
if (ws.readyState !== WebSocket.OPEN) {
return;
}
try {
ws.send(JSON.stringify({ event, data }));
} catch (err) {
console.error('Error sending event: ', err);
}
};
const heartbeatTimer = setInterval(() => {
try {
ws.send(JSON.stringify({ event: 'heartbeat', data: '' }));
} catch (err) {
console.log(err);
}
sendMsg('heartbeat', '');
}, 60 * 1000);
setTimeout(() => {
@@ -91,8 +157,13 @@ export const H264Webrtc = () => {
}, 5 * 1000);
return () => {
ws.close();
pc.close();
if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
video.close();
videoOfferSent.current = false;
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
}
@@ -104,6 +175,7 @@ export const H264Webrtc = () => {
<div className="flex h-screen w-screen items-start justify-center xl:items-center">
<video
id="screen"
ref={videoRef}
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
style={
resolution?.width

View File

@@ -2,7 +2,7 @@
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ES2020", "DOM", "DOM.Iterable", "WebWorker"],
"module": "ESNext",
"skipLibCheck": true,