mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
Support HTTPS and direct H.264
feat: add HTTPS support feat: enable direct H.264 streaming over HTTP style: optimize menu bar and settings styling
This commit is contained in:
45
server/config/file.go
Normal file
45
server/config/file.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const ConfigurationFile = "/etc/kvm/server.yaml"
|
||||
|
||||
func Read() (*Config, error) {
|
||||
data, err := os.ReadFile(ConfigurationFile)
|
||||
if err != nil {
|
||||
log.Errorf("failed to read config: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var conf Config
|
||||
|
||||
if err := yaml.Unmarshal(data, &conf); err != nil {
|
||||
log.Fatalf("failed to unmarshal config: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debugf("read %s successfully", ConfigurationFile)
|
||||
return &conf, nil
|
||||
}
|
||||
|
||||
func Write(conf *Config) error {
|
||||
data, err := yaml.Marshal(&conf)
|
||||
if err != nil {
|
||||
log.Errorf("failed to marshal config: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = os.WriteFile(ConfigurationFile, data, 0644)
|
||||
if err != nil {
|
||||
log.Errorf("failed to write config: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("write to %s successfully", ConfigurationFile)
|
||||
return nil
|
||||
}
|
||||
@@ -3,8 +3,10 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -58,7 +60,6 @@ func run() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
if conf.Authentication == "disable" {
|
||||
r.Use(cors.AllowAll())
|
||||
}
|
||||
@@ -67,20 +68,17 @@ func run() {
|
||||
|
||||
httpAddr := fmt.Sprintf(":%d", conf.Port.Http)
|
||||
httpsAddr := fmt.Sprintf(":%d", conf.Port.Https)
|
||||
log.Printf("proto: %s, port: %d %d\n", conf.Proto, conf.Port.Http, conf.Port.Https)
|
||||
|
||||
if conf.Proto == "https" {
|
||||
r.Use(middleware.Tls())
|
||||
|
||||
go func() {
|
||||
if err := r.Run(httpAddr); err != nil {
|
||||
panic("start http server failed")
|
||||
r.Use(middleware.Tls())
|
||||
err := r.RunTLS(httpsAddr, conf.Cert.Crt, conf.Cert.Key)
|
||||
if err != nil {
|
||||
panic("start https server failed")
|
||||
}
|
||||
}()
|
||||
|
||||
if err := r.RunTLS(httpsAddr, conf.Cert.Crt, conf.Cert.Key); err != nil {
|
||||
panic("start https server failed")
|
||||
}
|
||||
runRedirect(httpAddr, httpsAddr)
|
||||
} else {
|
||||
if err := r.Run(httpAddr); err != nil {
|
||||
panic("start http server failed")
|
||||
@@ -88,6 +86,26 @@ func run() {
|
||||
}
|
||||
}
|
||||
|
||||
func runRedirect(httpPort string, httpsPort string) {
|
||||
err := http.ListenAndServe(httpPort, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
host := req.Host
|
||||
if strings.Contains(host, httpPort) {
|
||||
host = strings.Split(host, httpPort)[0]
|
||||
}
|
||||
|
||||
targetURL := "https://" + host + req.URL.String()
|
||||
if httpsPort != ":443" {
|
||||
targetURL = "https://" + host + httpsPort + req.URL.String()
|
||||
}
|
||||
|
||||
http.Redirect(w, req, targetURL, http.StatusTemporaryRedirect)
|
||||
}))
|
||||
|
||||
if err != nil {
|
||||
panic("start http server failed")
|
||||
}
|
||||
}
|
||||
|
||||
func dispose() {
|
||||
common.GetKvmVision().Close()
|
||||
}
|
||||
|
||||
@@ -128,3 +128,7 @@ type SetWebTitleReq struct {
|
||||
type GetWebTitleRsp struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type SetTlsReq struct {
|
||||
Enabled bool `validate:"omitempty"`
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/stream/direct"
|
||||
"NanoKVM-Server/service/stream/h264"
|
||||
"NanoKVM-Server/service/stream/mjpeg"
|
||||
|
||||
@@ -15,5 +16,6 @@ func streamRouter(r *gin.Engine) {
|
||||
api.POST("/stream/mjpeg/detect", mjpeg.UpdateFrameDetect) // update frame detect
|
||||
api.POST("/stream/mjpeg/detect/stop", mjpeg.StopFrameDetect) // temporary stop frame detect
|
||||
|
||||
api.GET("/stream/h264", h264.Connect) // h264 stream
|
||||
api.GET("/stream/h264", h264.Connect) // h264 stream (webrtc)
|
||||
api.GET("/stream/h264/direct", direct.Connect) // h264 stream (http)
|
||||
}
|
||||
|
||||
@@ -57,5 +57,7 @@ func vmRouter(r *gin.Engine) {
|
||||
api.POST("/vm/mdns/enable", service.EnableMdns) // enable mDNS
|
||||
api.POST("/vm/mdns/disable", service.DisableMdns) // disable mDNS
|
||||
|
||||
api.POST("/vm/tls", service.SetTls) // enable/disable TLS
|
||||
|
||||
api.POST("/vm/system/reboot", service.Reboot) // reboot system
|
||||
}
|
||||
|
||||
104
server/service/stream/direct/h264.go
Normal file
104
server/service/stream/direct/h264.go
Normal file
@@ -0,0 +1,104 @@
|
||||
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)
|
||||
upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func Connect(c *gin.Context) {
|
||||
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
log.Errorf("failed to create websocket: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = ws.Close()
|
||||
log.Debugf("h264 websocket disconnected")
|
||||
}()
|
||||
|
||||
var zeroTime time.Time
|
||||
_ = ws.SetReadDeadline(zeroTime)
|
||||
|
||||
mutex.Lock()
|
||||
wsMap[ws] = true
|
||||
if len(wsMap) == 1 {
|
||||
go send()
|
||||
}
|
||||
mutex.Unlock()
|
||||
|
||||
_, _, err = ws.ReadMessage()
|
||||
if err != nil {
|
||||
mutex.Lock()
|
||||
delete(wsMap, ws)
|
||||
mutex.Unlock()
|
||||
log.Debugf("failed to read message: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func send() {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
76
server/service/vm/tls.go
Normal file
76
server/service/vm/tls.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package vm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"NanoKVM-Server/config"
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/utils"
|
||||
)
|
||||
|
||||
func (s *Service) SetTls(c *gin.Context) {
|
||||
var req proto.SetTlsReq
|
||||
var rsp proto.Response
|
||||
|
||||
err := proto.ParseFormRequest(c, &req)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, fmt.Sprintf("invalid arguments: %s", err))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Enabled {
|
||||
err = enableTls()
|
||||
} else {
|
||||
err = disableTls()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Errorf("failed to set TLS: %s", err)
|
||||
rsp.ErrRsp(c, -2, "operation failed")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
|
||||
_ = exec.Command("sh", "-c", "/etc/init.d/S95nanokvm restart").Run()
|
||||
}
|
||||
|
||||
func enableTls() error {
|
||||
if err := utils.GenerateCert(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conf, err := config.Read()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conf.Proto = "https"
|
||||
conf.Cert.Crt = "/etc/kvm/server.crt"
|
||||
conf.Cert.Key = "/etc/kvm/server.key"
|
||||
|
||||
if err := config.Write(conf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func disableTls() error {
|
||||
conf, err := config.Read()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conf.Proto = "http"
|
||||
|
||||
if err := config.Write(conf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
100
server/utils/cert.go
Normal file
100
server/utils/cert.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func GenerateCert() error {
|
||||
var (
|
||||
host = "localhost"
|
||||
ipAddress = []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}
|
||||
validFor = time.Hour * 24 * 365 * 10
|
||||
certFile = "/etc/kvm/server.crt"
|
||||
keyFile = "/etc/kvm/server.key"
|
||||
)
|
||||
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
log.Errorf("failed to generate RSA private key: %v", err)
|
||||
return err
|
||||
}
|
||||
publicKey := &privateKey.PublicKey
|
||||
|
||||
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
|
||||
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
|
||||
if err != nil {
|
||||
log.Errorf("failed to generate serial number: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
CommonName: host,
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(validFor),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: false,
|
||||
DNSNames: []string{host},
|
||||
IPAddresses: ipAddress,
|
||||
}
|
||||
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey, privateKey)
|
||||
if err != nil {
|
||||
log.Errorf("failed to create certificate: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// generate certificate
|
||||
certOut, err := os.Create(certFile)
|
||||
if err != nil {
|
||||
log.Errorf("failed to create %s: %v", certFile, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
|
||||
log.Errorf("failed to encode %s: %v", certFile, err)
|
||||
return err
|
||||
}
|
||||
|
||||
_ = certOut.Sync()
|
||||
_ = certOut.Close()
|
||||
log.Debugf("%s generated", certFile)
|
||||
|
||||
// generate private key
|
||||
keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) // 权限 0600
|
||||
if err != nil {
|
||||
log.Errorf("failed to create %s: %v", keyFile, err)
|
||||
return err
|
||||
}
|
||||
|
||||
privateBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
if err != nil {
|
||||
log.Errorf("failed to marshal private key: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privateBytes}); err != nil {
|
||||
log.Errorf("failed to encode %s: %v", keyFile, err)
|
||||
return err
|
||||
}
|
||||
|
||||
_ = keyOut.Sync()
|
||||
_ = keyOut.Close()
|
||||
log.Debugf("%s generated", keyFile)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -132,6 +132,11 @@ export function disableMdns() {
|
||||
return http.post('/api/vm/mdns/disable');
|
||||
}
|
||||
|
||||
// enable / disable TLS
|
||||
export function setTLS(enabled: boolean) {
|
||||
return http.post('/api/vm/tls', { enabled });
|
||||
}
|
||||
|
||||
// reboot
|
||||
export function reboot() {
|
||||
return http.post('/api/vm/system/reboot');
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { Helmet, HelmetData } from 'react-helmet-async';
|
||||
|
||||
import { getWebTitle } from '@/api/vm.ts';
|
||||
import { existToken } from '@/lib/cookie.ts';
|
||||
import { webTitleAtom } from '@/jotai/settings.ts';
|
||||
|
||||
type HeadProps = {
|
||||
@@ -11,7 +14,17 @@ type HeadProps = {
|
||||
const helmetData = new HelmetData({});
|
||||
|
||||
export const Head = ({ title = '', description = '' }: HeadProps = {}) => {
|
||||
const webTitle = useAtomValue(webTitleAtom);
|
||||
const [webTitle, setWebTitle] = useAtom(webTitleAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (!existToken()) return;
|
||||
|
||||
getWebTitle().then((rsp) => {
|
||||
if (rsp.data?.title) {
|
||||
setWebTitle(rsp.data.title);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Helmet
|
||||
|
||||
@@ -49,6 +49,7 @@ const en = {
|
||||
screen: {
|
||||
title: 'Screen',
|
||||
video: 'Video Mode',
|
||||
videoDirectTips: 'Enable HTTPS in "Settings > Device" to use this mode',
|
||||
resolution: 'Resolution',
|
||||
auto: 'Automatic',
|
||||
autoTips:
|
||||
@@ -197,9 +198,9 @@ const en = {
|
||||
hostname: 'Hostname',
|
||||
hostnameUpdated: 'Hostname updated. Reboot to apply.',
|
||||
ipType: {
|
||||
'Wired': 'Wired',
|
||||
'Wireless': 'Wireless',
|
||||
'Other': 'Other'
|
||||
Wired: 'Wired',
|
||||
Wireless: 'Wireless',
|
||||
Other: 'Other'
|
||||
}
|
||||
},
|
||||
appearance: {
|
||||
@@ -235,6 +236,10 @@ const en = {
|
||||
description: 'Enable SSH remote access',
|
||||
tip: 'Set a strong password before enabling (Account - Change Password)'
|
||||
},
|
||||
tls: {
|
||||
description: 'Enable HTTPS protocol',
|
||||
tip: 'Be aware: Using HTTPS can increase latency, especially with MJPEG video mode.'
|
||||
},
|
||||
advanced: 'Advanced Settings',
|
||||
swap: {
|
||||
title: 'Swap',
|
||||
|
||||
@@ -46,7 +46,9 @@ const zh = {
|
||||
finishBtn: '完成'
|
||||
},
|
||||
screen: {
|
||||
title: '屏幕',
|
||||
video: '视频模式',
|
||||
videoDirectTips: '该模式需启用 HTTPS,请前往「设置 - 设备」中开启',
|
||||
resolution: '分辨率',
|
||||
auto: '自动',
|
||||
autoTips:
|
||||
@@ -215,6 +217,10 @@ const zh = {
|
||||
description: '启用 SSH 远程访问',
|
||||
tip: '启用前请务必设置强密码(帐号 - 修改密码)'
|
||||
},
|
||||
tls: {
|
||||
description: '启用 HTTPS 协议',
|
||||
tip: '注意:使用 HTTPS 可能导致延迟增加,特别是在 MJPEG 视频模式下。'
|
||||
},
|
||||
advanced: '高级设置',
|
||||
swap: {
|
||||
disable: '禁用',
|
||||
|
||||
@@ -2,7 +2,10 @@ import { atom } from 'jotai';
|
||||
|
||||
import { Resolution } from '@/types';
|
||||
|
||||
// video mode: h264 or mjpeg
|
||||
// video mode
|
||||
// direct: stream H.264 over HTTP
|
||||
// h264: stream H.264 over WebRTC
|
||||
// mjpeg: stream JPEG over HTTP
|
||||
export const videoModeAtom = atom('');
|
||||
|
||||
// browser screen resolution
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
import { getWebTitle } from '@/api/vm.ts';
|
||||
import { getResolution, getVideoMode } from '@/lib/localstorage.ts';
|
||||
import * as storage from '@/lib/localstorage.ts';
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||
import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts';
|
||||
import { webTitleAtom } from '@/jotai/settings.ts';
|
||||
import { Head } from '@/components/head.tsx';
|
||||
|
||||
import { Keyboard } from './keyboard';
|
||||
@@ -25,20 +23,13 @@ export const Desktop = () => {
|
||||
const [videoMode, setVideoMode] = useAtom(videoModeAtom);
|
||||
const [resolution, setResolution] = useAtom(resolutionAtom);
|
||||
const isKeyboardEnable = useAtomValue(isKeyboardEnableAtom);
|
||||
const setWebTitle = useSetAtom(webTitleAtom);
|
||||
|
||||
useEffect(() => {
|
||||
const cookieVideoMode = getVideoMode();
|
||||
setVideoMode(cookieVideoMode ? cookieVideoMode : window.RTCPeerConnection ? 'h264' : 'mjpeg');
|
||||
const mode = getVideoMode();
|
||||
setVideoMode(mode);
|
||||
|
||||
const cookieResolution = getResolution();
|
||||
setResolution(cookieResolution ? cookieResolution : { width: 0, height: 0 });
|
||||
|
||||
getWebTitle().then((rsp) => {
|
||||
if (rsp.data?.title) {
|
||||
setWebTitle(rsp.data.title);
|
||||
}
|
||||
});
|
||||
const res = storage.getResolution() || { width: 0, height: 0 };
|
||||
setResolution(res);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
client.send([0]);
|
||||
@@ -51,6 +42,20 @@ export const Desktop = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
function getVideoMode() {
|
||||
const defaultVideoMode = window.RTCPeerConnection ? 'h264' : 'mjpeg';
|
||||
|
||||
const cookieVideoMode = storage.getVideoMode();
|
||||
if (cookieVideoMode) {
|
||||
if (cookieVideoMode === 'direct' && !window.VideoDecoder) {
|
||||
return defaultVideoMode;
|
||||
}
|
||||
return cookieVideoMode;
|
||||
}
|
||||
|
||||
return defaultVideoMode;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title={t('head.desktop')} />
|
||||
|
||||
@@ -44,7 +44,7 @@ export const Cursor = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop" arrow={true} trigger="hover">
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<MousePointerIcon size={18} />
|
||||
<span>{t('mouse.cursor')}</span>
|
||||
|
||||
@@ -47,7 +47,7 @@ export const MouseMode = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop" arrow={true} trigger="hover">
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<SquareDashedMousePointerIcon size={18} />
|
||||
<span>{t('mouse.mode')}</span>
|
||||
|
||||
@@ -31,12 +31,15 @@ export const Power = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
function getLed() {
|
||||
api.getGpio().then((rsp: any) => {
|
||||
async function getLed() {
|
||||
try {
|
||||
const rsp = await api.getGpio();
|
||||
if (rsp.code === 0) {
|
||||
setIsPowerOn(rsp.data.pwr);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
function updateShowConfirm(value: boolean) {
|
||||
|
||||
@@ -50,8 +50,9 @@ export const Fps = ({ fps, setFps }: FpsProps) => {
|
||||
|
||||
setFps(value);
|
||||
setCookie(value);
|
||||
|
||||
isCustomize && setIsCustomize(false);
|
||||
if (isCustomize) {
|
||||
setIsCustomize(false);
|
||||
}
|
||||
}
|
||||
|
||||
const content = (
|
||||
@@ -82,7 +83,7 @@ export const Fps = ({ fps, setFps }: FpsProps) => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex h-[14px] w-[20px] items-end text-blue-500 ">
|
||||
<div className="flex h-[14px] w-[20px] items-end text-blue-500">
|
||||
<CheckIcon size={14} />
|
||||
</div>
|
||||
<span>Customize</span>
|
||||
@@ -106,7 +107,7 @@ export const Fps = ({ fps, setFps }: FpsProps) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop">
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<ScanBarcodeIcon size={18} />
|
||||
<span className="select-none text-sm">{t('screen.fps')}</span>
|
||||
|
||||
@@ -47,7 +47,7 @@ export const Gop = ({ gop, setGop }: GopProps) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop">
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<SquareKanbanIcon size={18} />
|
||||
<span className="select-none text-sm">GOP</span>
|
||||
|
||||
@@ -74,8 +74,7 @@ export const Screen = () => {
|
||||
<Resolution />
|
||||
<Quality quality={quality} setQuality={setQuality} />
|
||||
<Fps fps={fps} setFps={setFps} />
|
||||
{videoMode === 'h264' && <Gop gop={gop} setGop={setGop} />}
|
||||
{videoMode === 'mjpeg' && <FrameDetect />}
|
||||
{videoMode === 'mjpeg' ? <FrameDetect /> : <Gop gop={gop} setGop={setGop} />}
|
||||
<Reset />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -55,7 +55,7 @@ export const Quality = ({ quality, setQuality }: QualityProps) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop">
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<SquareActivityIcon size={18} />
|
||||
<span className="select-none text-sm">{t('screen.quality')}</span>
|
||||
|
||||
@@ -66,7 +66,7 @@ export const Resolution = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop">
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<RatioIcon size={18} />
|
||||
<span className="select-none text-sm">{t('screen.resolution')}</span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Popover } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { CheckIcon, TvMinimalPlayIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -7,7 +8,8 @@ import { setVideoMode as setCookie } from '@/lib/localstorage.ts';
|
||||
import { videoModeAtom } from '@/jotai/screen.ts';
|
||||
|
||||
const videoModes = [
|
||||
{ key: 'h264', name: 'H.264' },
|
||||
{ key: 'direct', name: 'H.264 (Direct)' },
|
||||
{ key: 'h264', name: 'H.264 (WebRTC)' },
|
||||
{ key: 'mjpeg', name: 'MJPEG' }
|
||||
];
|
||||
|
||||
@@ -15,6 +17,15 @@ export const VideoMode = () => {
|
||||
const { t } = useTranslation();
|
||||
const videoMode = useAtomValue(videoModeAtom);
|
||||
|
||||
const [isDirectSupported, setIsDirectSupported] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isHttps = window.location.protocol === 'https:';
|
||||
const isDecoderSupported = !!window.VideoDecoder;
|
||||
|
||||
setIsDirectSupported(isHttps && isDecoderSupported);
|
||||
}, []);
|
||||
|
||||
function update(mode: string) {
|
||||
if (mode === videoMode) return;
|
||||
|
||||
@@ -28,24 +39,39 @@ export const VideoMode = () => {
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{videoModes.map((mode) => (
|
||||
<div
|
||||
key={mode.key}
|
||||
className="flex cursor-pointer select-none items-center rounded py-1.5 pl-1 pr-5 hover:bg-neutral-700/70"
|
||||
onClick={() => update(mode.key)}
|
||||
{!isDirectSupported && (
|
||||
<Tooltip
|
||||
placement="right"
|
||||
title={t('screen.videoDirectTips')}
|
||||
overlayStyle={{ maxWidth: '270px' }}
|
||||
>
|
||||
<div className="flex h-[14px] w-[20px] items-end text-blue-500">
|
||||
{mode.key === videoMode && <CheckIcon size={15} />}
|
||||
<div className="flex cursor-not-allowed select-none items-center rounded py-1.5 pl-1 pr-5 text-neutral-500 hover:bg-neutral-700/70">
|
||||
<div className="flex h-[14px] w-[20px] items-end text-blue-500"></div>
|
||||
<span>H.264 (Direct)</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<span className="flex w-[50px]">{mode.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{videoModes.map(
|
||||
(mode) =>
|
||||
(isDirectSupported || mode.key !== 'direct') && (
|
||||
<div
|
||||
key={mode.key}
|
||||
className="flex cursor-pointer select-none items-center rounded py-1.5 pl-1 pr-5 hover:bg-neutral-700/70"
|
||||
onClick={() => update(mode.key)}
|
||||
>
|
||||
<div className="flex h-[14px] w-[20px] items-end text-blue-500">
|
||||
{mode.key === videoMode && <CheckIcon size={15} />}
|
||||
</div>
|
||||
<span>{mode.name}</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop">
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<TvMinimalPlayIcon size={18} />
|
||||
<span className="select-none text-sm">{t('screen.video')}</span>
|
||||
|
||||
@@ -13,13 +13,13 @@ export const Appearance = () => {
|
||||
<div className="text-base font-bold">{t('settings.appearance.title')}</div>
|
||||
<Divider />
|
||||
|
||||
<Language />
|
||||
<div className="flex flex-col space-y-6">
|
||||
<Language />
|
||||
<WebTitle />
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
<MenuBar />
|
||||
<Divider />
|
||||
|
||||
<WebTitle />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import { MouseJiggler } from './mouse-jiggler.tsx';
|
||||
import { Oled } from './oled.tsx';
|
||||
import { Reboot } from './reboot.tsx';
|
||||
import { Ssh } from './ssh.tsx';
|
||||
import { Tls } from './tls.tsx';
|
||||
import { VirtualDevices } from './virtual-devices.tsx';
|
||||
import { Wifi } from './wifi.tsx';
|
||||
|
||||
@@ -35,11 +36,17 @@ export const Device = () => {
|
||||
<Divider />
|
||||
|
||||
<div className="flex flex-col space-y-6">
|
||||
<Oled />
|
||||
<Wifi />
|
||||
<Tls />
|
||||
<Ssh />
|
||||
<Mdns />
|
||||
|
||||
{hidMode === 'normal' ? <VirtualDevices /> : <HidMode />}
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
<div className="flex flex-col space-y-6">
|
||||
<Oled />
|
||||
<Wifi />
|
||||
<MouseJiggler />
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
69
web/src/pages/desktop/menu/settings/device/tls.tsx
Normal file
69
web/src/pages/desktop/menu/settings/device/tls.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Switch, Tooltip } from 'antd';
|
||||
import { CircleAlertIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/vm.ts';
|
||||
|
||||
export const Tls = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsEnabled(window.location.protocol === 'https:');
|
||||
}, []);
|
||||
|
||||
async function update() {
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
|
||||
const enable = !isEnabled;
|
||||
|
||||
const seconds = enable ? 30 : 10;
|
||||
setTimeout(() => {
|
||||
reload(enable);
|
||||
}, seconds * 1000);
|
||||
|
||||
try {
|
||||
const rsp = await api.setTLS(enable);
|
||||
if (rsp.code === 0) {
|
||||
setIsEnabled(enable);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
function reload(enable: boolean) {
|
||||
if (!enable) {
|
||||
const target = window.location.href.replace(/^https:/, 'http:');
|
||||
window.open(target, '_blank');
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>HTTPS</span>
|
||||
|
||||
<Tooltip
|
||||
title={t('settings.device.tls.tip')}
|
||||
className="cursor-pointer"
|
||||
placement="bottom"
|
||||
overlayStyle={{ maxWidth: '300px' }}
|
||||
>
|
||||
<CircleAlertIcon size={15} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-500">{t('settings.device.tls.description')}</span>
|
||||
</div>
|
||||
|
||||
<Switch checked={isEnabled} loading={isLoading} onChange={update} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
181
web/src/pages/desktop/screen/h264-direct.tsx
Normal file
181
web/src/pages/desktop/screen/h264-direct.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { w3cwebsocket as W3cWebSocket } from 'websocket';
|
||||
|
||||
import { getBaseUrl } from '@/lib/service.ts';
|
||||
import { mouseStyleAtom } from '@/jotai/mouse';
|
||||
import { resolutionAtom } from '@/jotai/screen.ts';
|
||||
|
||||
export const H264Direct = () => {
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const mouseStyle = useAtomValue(mouseStyleAtom);
|
||||
|
||||
const canvasRef = useRef<any>(null);
|
||||
const decoderRef = useRef<any>(null);
|
||||
const frameQueueRef = useRef<VideoFrame[]>([]);
|
||||
const renderingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.VideoDecoder) {
|
||||
console.log('Error: WebCodecs API not supported.');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `${getBaseUrl('ws')}/api/stream/h264/direct`;
|
||||
const ws = new W3cWebSocket(url);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data as string);
|
||||
|
||||
if (!decoderRef.current && message.isKeyFrame) {
|
||||
initializeDecoder();
|
||||
}
|
||||
|
||||
if (decoderRef.current?.state === 'configured') {
|
||||
decode(message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.log(err);
|
||||
resetDecoder();
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
resetDecoder();
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (ws.readyState === 1) {
|
||||
ws.close();
|
||||
}
|
||||
resetDecoder();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function initializeDecoder() {
|
||||
if (!window.VideoDecoder) {
|
||||
return;
|
||||
}
|
||||
if (decoderRef.current && decoderRef.current.state !== 'unconfigured') {
|
||||
return;
|
||||
}
|
||||
|
||||
const init = {
|
||||
output: (frame: VideoFrame) => {
|
||||
frameQueueRef.current.push(frame);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const processFrameQueue = () => {
|
||||
renderingRef.current = true;
|
||||
const frame = frameQueueRef.current.shift();
|
||||
if (frame) {
|
||||
renderFrame(frame);
|
||||
}
|
||||
|
||||
if (frameQueueRef.current.length === 0) {
|
||||
renderingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(processFrameQueue);
|
||||
};
|
||||
|
||||
const renderFrame = (frame: any) => {
|
||||
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();
|
||||
};
|
||||
|
||||
const resetDecoder = () => {
|
||||
if (decoderRef.current && decoderRef.current.state !== 'closed') {
|
||||
try {
|
||||
decoderRef.current.close();
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
decoderRef.current = null;
|
||||
renderingRef.current = false;
|
||||
|
||||
frameQueueRef.current.forEach((frame) => frame.close());
|
||||
frameQueueRef.current = [];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-start justify-center xl:items-center">
|
||||
<canvas
|
||||
id="screen"
|
||||
ref={canvasRef}
|
||||
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
|
||||
style={
|
||||
resolution?.width
|
||||
? { width: resolution.width, height: resolution.height, objectFit: 'cover' }
|
||||
: { maxWidth: '100%', maxHeight: '100%', objectFit: 'scale-down' }
|
||||
}
|
||||
></canvas>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -8,7 +8,7 @@ import { getBaseUrl } from '@/lib/service.ts';
|
||||
import { mouseStyleAtom } from '@/jotai/mouse.ts';
|
||||
import { resolutionAtom } from '@/jotai/screen.ts';
|
||||
|
||||
export const H264 = () => {
|
||||
export const H264Webrtc = () => {
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const mouseStyle = useAtomValue(mouseStyleAtom);
|
||||
|
||||
@@ -88,7 +88,7 @@ export const H264 = () => {
|
||||
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 15 * 1000);
|
||||
}, 5 * 1000);
|
||||
|
||||
return () => {
|
||||
ws.close();
|
||||
@@ -101,7 +101,7 @@ export const H264 = () => {
|
||||
|
||||
return (
|
||||
<Spin size="large" tip="Loading" spinning={isLoading}>
|
||||
<div className={clsx('flex h-screen w-screen items-start justify-center xl:items-center')}>
|
||||
<div className="flex h-screen w-screen items-start justify-center xl:items-center">
|
||||
<video
|
||||
id="screen"
|
||||
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
|
||||
@@ -2,11 +2,20 @@ import { useAtomValue } from 'jotai';
|
||||
|
||||
import { videoModeAtom } from '@/jotai/screen.ts';
|
||||
|
||||
import { H264 } from './h264.tsx';
|
||||
import { H264Direct } from './h264-direct.tsx';
|
||||
import { H264Webrtc } from './h264-webrtc.tsx';
|
||||
import { Mjpeg } from './mjpeg.tsx';
|
||||
|
||||
export const Screen = () => {
|
||||
const videoMode = useAtomValue(videoModeAtom);
|
||||
|
||||
return <>{videoMode === 'mjpeg' ? <Mjpeg /> : <H264 />}</>;
|
||||
if (videoMode === 'mjpeg') {
|
||||
return <Mjpeg />;
|
||||
}
|
||||
|
||||
if (videoMode === 'direct') {
|
||||
return <H264Direct />;
|
||||
}
|
||||
|
||||
return <H264Webrtc />;
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ export const Mjpeg = () => {
|
||||
}, [resolution]);
|
||||
|
||||
return (
|
||||
<div className={clsx('flex h-screen w-screen items-start justify-center xl:items-center')}>
|
||||
<div className="flex h-screen w-screen items-start justify-center xl:items-center">
|
||||
<Image
|
||||
id="screen"
|
||||
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
|
||||
|
||||
Reference in New Issue
Block a user