From e18ec2219d22886529575d1fdaad5c320e05f5b2 Mon Sep 17 00:00:00 2001 From: wj-xiao Date: Thu, 8 May 2025 14:50:32 +0800 Subject: [PATCH] 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 --- server/config/file.go | 45 +++++ server/main.go | 36 +++- server/proto/vm.go | 4 + server/router/stream.go | 4 +- server/router/vm.go | 2 + server/service/stream/direct/h264.go | 104 ++++++++++ server/service/vm/tls.go | 76 ++++++++ server/utils/cert.go | 100 ++++++++++ web/src/api/vm.ts | 5 + web/src/components/head.tsx | 17 +- web/src/i18n/locales/en.ts | 11 +- web/src/i18n/locales/zh.ts | 6 + web/src/jotai/screen.ts | 5 +- web/src/pages/desktop/index.tsx | 35 ++-- web/src/pages/desktop/menu/mouse/cursor.tsx | 2 +- .../pages/desktop/menu/mouse/mouse-mode.tsx | 2 +- web/src/pages/desktop/menu/power/index.tsx | 9 +- web/src/pages/desktop/menu/screen/fps.tsx | 9 +- web/src/pages/desktop/menu/screen/gop.tsx | 2 +- web/src/pages/desktop/menu/screen/index.tsx | 3 +- web/src/pages/desktop/menu/screen/quality.tsx | 2 +- .../pages/desktop/menu/screen/resolution.tsx | 2 +- .../pages/desktop/menu/screen/video-mode.tsx | 52 +++-- .../menu/settings/appearance/index.tsx | 8 +- .../desktop/menu/settings/device/index.tsx | 11 +- .../desktop/menu/settings/device/tls.tsx | 69 +++++++ web/src/pages/desktop/screen/h264-direct.tsx | 181 ++++++++++++++++++ .../screen/{h264.tsx => h264-webrtc.tsx} | 6 +- web/src/pages/desktop/screen/index.tsx | 13 +- web/src/pages/desktop/screen/mjpeg.tsx | 2 +- 30 files changed, 753 insertions(+), 70 deletions(-) create mode 100644 server/config/file.go create mode 100644 server/service/stream/direct/h264.go create mode 100644 server/service/vm/tls.go create mode 100644 server/utils/cert.go create mode 100644 web/src/pages/desktop/menu/settings/device/tls.tsx create mode 100644 web/src/pages/desktop/screen/h264-direct.tsx rename web/src/pages/desktop/screen/{h264.tsx => h264-webrtc.tsx} (95%) diff --git a/server/config/file.go b/server/config/file.go new file mode 100644 index 0000000..1eaa8d3 --- /dev/null +++ b/server/config/file.go @@ -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 +} diff --git a/server/main.go b/server/main.go index 59a3592..cb861fc 100644 --- a/server/main.go +++ b/server/main.go @@ -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() } diff --git a/server/proto/vm.go b/server/proto/vm.go index 9d09882..11d417f 100644 --- a/server/proto/vm.go +++ b/server/proto/vm.go @@ -128,3 +128,7 @@ type SetWebTitleReq struct { type GetWebTitleRsp struct { Title string `json:"title"` } + +type SetTlsReq struct { + Enabled bool `validate:"omitempty"` +} diff --git a/server/router/stream.go b/server/router/stream.go index bcc13d3..b4eb7a6 100644 --- a/server/router/stream.go +++ b/server/router/stream.go @@ -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) } diff --git a/server/router/vm.go b/server/router/vm.go index c841652..0062170 100644 --- a/server/router/vm.go +++ b/server/router/vm.go @@ -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 } diff --git a/server/service/stream/direct/h264.go b/server/service/stream/direct/h264.go new file mode 100644 index 0000000..df43266 --- /dev/null +++ b/server/service/stream/direct/h264.go @@ -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) + } + } + } +} diff --git a/server/service/vm/tls.go b/server/service/vm/tls.go new file mode 100644 index 0000000..4ca216a --- /dev/null +++ b/server/service/vm/tls.go @@ -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 +} diff --git a/server/utils/cert.go b/server/utils/cert.go new file mode 100644 index 0000000..499ffc0 --- /dev/null +++ b/server/utils/cert.go @@ -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 +} diff --git a/web/src/api/vm.ts b/web/src/api/vm.ts index 949dfb9..5e8d237 100644 --- a/web/src/api/vm.ts +++ b/web/src/api/vm.ts @@ -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'); diff --git a/web/src/components/head.tsx b/web/src/components/head.tsx index 5c12746..af68127 100644 --- a/web/src/components/head.tsx +++ b/web/src/components/head.tsx @@ -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 ( 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', diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index 9f15021..0240ae4 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -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: '禁用', diff --git a/web/src/jotai/screen.ts b/web/src/jotai/screen.ts index c1bd29d..a49cec6 100644 --- a/web/src/jotai/screen.ts +++ b/web/src/jotai/screen.ts @@ -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 diff --git a/web/src/pages/desktop/index.tsx b/web/src/pages/desktop/index.tsx index 7a44ecb..f3cad28 100644 --- a/web/src/pages/desktop/index.tsx +++ b/web/src/pages/desktop/index.tsx @@ -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 ( <> diff --git a/web/src/pages/desktop/menu/mouse/cursor.tsx b/web/src/pages/desktop/menu/mouse/cursor.tsx index f7b5b41..87c1b09 100644 --- a/web/src/pages/desktop/menu/mouse/cursor.tsx +++ b/web/src/pages/desktop/menu/mouse/cursor.tsx @@ -44,7 +44,7 @@ export const Cursor = () => { ); return ( - +
{t('mouse.cursor')} diff --git a/web/src/pages/desktop/menu/mouse/mouse-mode.tsx b/web/src/pages/desktop/menu/mouse/mouse-mode.tsx index b0d163a..96191e4 100644 --- a/web/src/pages/desktop/menu/mouse/mouse-mode.tsx +++ b/web/src/pages/desktop/menu/mouse/mouse-mode.tsx @@ -47,7 +47,7 @@ export const MouseMode = () => { ); return ( - +
{t('mouse.mode')} diff --git a/web/src/pages/desktop/menu/power/index.tsx b/web/src/pages/desktop/menu/power/index.tsx index bf294d5..bab5925 100644 --- a/web/src/pages/desktop/menu/power/index.tsx +++ b/web/src/pages/desktop/menu/power/index.tsx @@ -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) { diff --git a/web/src/pages/desktop/menu/screen/fps.tsx b/web/src/pages/desktop/menu/screen/fps.tsx index 554a8b5..9802643 100644 --- a/web/src/pages/desktop/menu/screen/fps.tsx +++ b/web/src/pages/desktop/menu/screen/fps.tsx @@ -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) => { ) : ( <> -
+
Customize @@ -106,7 +107,7 @@ export const Fps = ({ fps, setFps }: FpsProps) => { ); return ( - +
{t('screen.fps')} diff --git a/web/src/pages/desktop/menu/screen/gop.tsx b/web/src/pages/desktop/menu/screen/gop.tsx index 1103d96..ae3aa3c 100644 --- a/web/src/pages/desktop/menu/screen/gop.tsx +++ b/web/src/pages/desktop/menu/screen/gop.tsx @@ -47,7 +47,7 @@ export const Gop = ({ gop, setGop }: GopProps) => { ); return ( - +
GOP diff --git a/web/src/pages/desktop/menu/screen/index.tsx b/web/src/pages/desktop/menu/screen/index.tsx index 451c42a..1d149c8 100644 --- a/web/src/pages/desktop/menu/screen/index.tsx +++ b/web/src/pages/desktop/menu/screen/index.tsx @@ -74,8 +74,7 @@ export const Screen = () => { - {videoMode === 'h264' && } - {videoMode === 'mjpeg' && } + {videoMode === 'mjpeg' ? : }
); diff --git a/web/src/pages/desktop/menu/screen/quality.tsx b/web/src/pages/desktop/menu/screen/quality.tsx index 30a8989..34580b6 100644 --- a/web/src/pages/desktop/menu/screen/quality.tsx +++ b/web/src/pages/desktop/menu/screen/quality.tsx @@ -55,7 +55,7 @@ export const Quality = ({ quality, setQuality }: QualityProps) => { ); return ( - +
{t('screen.quality')} diff --git a/web/src/pages/desktop/menu/screen/resolution.tsx b/web/src/pages/desktop/menu/screen/resolution.tsx index 37661dc..f42b052 100644 --- a/web/src/pages/desktop/menu/screen/resolution.tsx +++ b/web/src/pages/desktop/menu/screen/resolution.tsx @@ -66,7 +66,7 @@ export const Resolution = () => { ); return ( - +
{t('screen.resolution')} diff --git a/web/src/pages/desktop/menu/screen/video-mode.tsx b/web/src/pages/desktop/menu/screen/video-mode.tsx index 8832bbf..9cafb24 100644 --- a/web/src/pages/desktop/menu/screen/video-mode.tsx +++ b/web/src/pages/desktop/menu/screen/video-mode.tsx @@ -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) => ( -
update(mode.key)} + {!isDirectSupported && ( + -
- {mode.key === videoMode && } +
+
+ H.264 (Direct)
+ + )} - {mode.name} -
- ))} + {videoModes.map( + (mode) => + (isDirectSupported || mode.key !== 'direct') && ( +
update(mode.key)} + > +
+ {mode.key === videoMode && } +
+ {mode.name} +
+ ) + )} ); return ( - +
{t('screen.video')} diff --git a/web/src/pages/desktop/menu/settings/appearance/index.tsx b/web/src/pages/desktop/menu/settings/appearance/index.tsx index 6ef4cdf..1c24d20 100644 --- a/web/src/pages/desktop/menu/settings/appearance/index.tsx +++ b/web/src/pages/desktop/menu/settings/appearance/index.tsx @@ -13,13 +13,13 @@ export const Appearance = () => {
{t('settings.appearance.title')}
- +
+ + +
- - - ); }; diff --git a/web/src/pages/desktop/menu/settings/device/index.tsx b/web/src/pages/desktop/menu/settings/device/index.tsx index e9f6392..00d1c31 100644 --- a/web/src/pages/desktop/menu/settings/device/index.tsx +++ b/web/src/pages/desktop/menu/settings/device/index.tsx @@ -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 = () => {
- - + + {hidMode === 'normal' ? : } +
+ + +
+ +
diff --git a/web/src/pages/desktop/menu/settings/device/tls.tsx b/web/src/pages/desktop/menu/settings/device/tls.tsx new file mode 100644 index 0000000..8fa477d --- /dev/null +++ b/web/src/pages/desktop/menu/settings/device/tls.tsx @@ -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 ( +
+
+
+ HTTPS + + + + +
+ {t('settings.device.tls.description')} +
+ + +
+ ); +}; diff --git a/web/src/pages/desktop/screen/h264-direct.tsx b/web/src/pages/desktop/screen/h264-direct.tsx new file mode 100644 index 0000000..6412b67 --- /dev/null +++ b/web/src/pages/desktop/screen/h264-direct.tsx @@ -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(null); + const decoderRef = useRef(null); + const frameQueueRef = useRef([]); + 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 ( +
+ +
+ ); +}; diff --git a/web/src/pages/desktop/screen/h264.tsx b/web/src/pages/desktop/screen/h264-webrtc.tsx similarity index 95% rename from web/src/pages/desktop/screen/h264.tsx rename to web/src/pages/desktop/screen/h264-webrtc.tsx index aefec85..68221fc 100644 --- a/web/src/pages/desktop/screen/h264.tsx +++ b/web/src/pages/desktop/screen/h264-webrtc.tsx @@ -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 ( -
+