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:
wj-xiao
2025-05-08 14:50:32 +08:00
parent d075ae58d9
commit e18ec2219d
30 changed files with 753 additions and 70 deletions

45
server/config/file.go Normal file
View 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
}

View File

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

View File

@@ -128,3 +128,7 @@ type SetWebTitleReq struct {
type GetWebTitleRsp struct {
Title string `json:"title"`
}
type SetTlsReq struct {
Enabled bool `validate:"omitempty"`
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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: '禁用',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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