mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
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:
64
server/build.sh
Executable file
64
server/build.sh
Executable 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}"
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -16,3 +16,7 @@ type GetMountedImageRsp struct {
|
||||
type GetCdRomRsp struct {
|
||||
Cdrom int64 `json:"cdrom"`
|
||||
}
|
||||
|
||||
type DeleteImageReq struct {
|
||||
File string `json:"file" validate:"required"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -69,8 +69,6 @@ func (c *Cli) Stop() error {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = os.Remove(ConfigPath)
|
||||
|
||||
return os.Remove(ScriptPath)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
server/service/stream/direct/pool.go
Normal file
12
server/service/stream/direct/pool.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package direct
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var BufferPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
return new(bytes.Buffer)
|
||||
},
|
||||
}
|
||||
123
server/service/stream/direct/streamer.go
Normal file
123
server/service/stream/direct/streamer.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
131
server/service/stream/mjpeg/streamer.go
Normal file
131
server/service/stream/mjpeg/streamer.go
Normal 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
|
||||
}
|
||||
112
server/service/stream/webrtc/client.go
Normal file
112
server/service/stream/webrtc/client.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
158
server/service/stream/webrtc/h264.go
Normal file
158
server/service/stream/webrtc/h264.go
Normal 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,
|
||||
})
|
||||
}
|
||||
96
server/service/stream/webrtc/manager.go
Normal file
96
server/service/stream/webrtc/manager.go
Normal 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()
|
||||
}
|
||||
}
|
||||
149
server/service/stream/webrtc/signaling.go
Normal file
149
server/service/stream/webrtc/signaling.go
Normal 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", "")
|
||||
}
|
||||
53
server/service/stream/webrtc/track.go
Normal file
53
server/service/stream/webrtc/track.go
Normal 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)
|
||||
}
|
||||
}
|
||||
38
server/service/stream/webrtc/types.go
Normal file
38
server/service/stream/webrtc/types.go
Normal 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"`
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
1851
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
@@ -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: '确认',
|
||||
|
||||
@@ -3,7 +3,5 @@ import { atom } from 'jotai';
|
||||
// menu bar disabled items
|
||||
export const menuDisabledItemsAtom = atom<string[]>([]);
|
||||
|
||||
|
||||
// web title
|
||||
export const webTitleAtom = atom('');
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 }]}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
54
web/src/pages/desktop/menu/settings/tailscale/run.tsx
Normal file
54
web/src/pages/desktop/menu/settings/tailscale/run.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
71
web/src/pages/desktop/menu/settings/tailscale/swap.tsx
Normal file
71
web/src/pages/desktop/menu/settings/tailscale/swap.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
147
web/src/pages/desktop/screen/direct.worker.ts
Normal file
147
web/src/pages/desktop/screen/direct.worker.ts
Normal 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());
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable", "WebWorker"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user