Compare commits

..

15 Commits
2.4.1 ... 2.4.2

Author SHA1 Message Date
wenjie
863fea9ab8 release: nanokvm@2.4.2 2026-05-20 16:23:16 +08:00
wenjie
f65c1b06aa chore: refresh desktop menu ordering and community links
- Replace the Discussion community link with Discord
- Align terminal and script ordering in the menu and appearance settings
- Remove the divider before the leader key menu item
2026-05-20 15:07:59 +08:00
wenjie
fe34008ce3 fix: handle left-button touch cancel and contextmenu cleanup 2026-05-20 14:49:33 +08:00
wenjie
961d7f2e89 fix: validate Wi-Fi password field 2026-05-20 11:53:53 +08:00
wenjie
94749ea621 fix: harden Wake-on-LAN MAC handling
Normalize stored MAC addresses, avoid shell execution for ether-wake, handle missing WOL history gracefully, and improve the WOL menu display for named MAC entries.
2026-05-20 11:38:14 +08:00
wenjie
6ae742deed fix: improve WebRTC loading indicator 2026-05-19 17:31:19 +08:00
wenjie
b653f905e4 perf: improve H264 streaming client handling
Use client snapshots for stream fanout, reduce frame queue latency, expose backend ICE server configuration to the WebRTC client, and clean up disconnected WebRTC clients more aggressively.
2026-05-19 17:04:57 +08:00
wenjie
af19795c29 refactor: remove legacy h264 stream package 2026-05-19 16:49:41 +08:00
wenjie
be7584d908 feat: show HDMI capture status on desktop screen
Track capture results for each stream mode, broadcast status updates over WebSocket, and display localized warning/error overlays in the desktop view.
2026-05-19 15:02:34 +08:00
wenjie
a960417739 fix: stabilize PicoClaw session switching
- Wait for gateway session release before reconnecting to a new or selected session
- Refresh runtime status before deciding whether a selected history session can reconnect
- Render structured thought messages from gateway payload metadata
- Prevent PicoClaw history rows from overflowing horizontally
- Add localized labels for the verifying install stage
2026-05-18 17:26:00 +08:00
wenjie
3f0621e7ed Merge pull request #799 from BeaconCat/feat/picoclaw-v3-session-history
feat(picoclaw): support sk_v1_ opaque session keys for V3 history
2026-05-18 15:31:39 +08:00
wenjie
515c5e2a76 Merge pull request #798 from BeaconCat/feat/picoclaw-v3-config
feat(picoclaw): adapt to PicoClaw V3 config format
2026-05-18 15:23:05 +08:00
wenjie
cb025f04b4 chore(web): update PostCSS and MSW worker
- Bump PostCSS to 8.5.10 and refresh the pnpm lockfile
- Regenerate the MSW service worker for msw 2.12.10
- Add pnpm build approvals for frontend tooling
2026-05-18 15:02:31 +08:00
BeaconCat
dff794cee4 feat(picoclaw): adapt to PicoClaw V3 config format
- channels -> channel_list with nested settings struct (V3 format)
- security channels -> channel_list.pico.settings.token
- Remove broken pico- prefix token composition logic
- Token now uses exact match with PicoClaw gateway
- Add GitHub Actions CI for building update package
- Version: 2.4.0.Beacon
2026-05-08 08:30:08 +08:00
BeaconCat
8eca1edf47 feat(picoclaw): support sk_v1_ opaque session keys for V3 history 2026-05-06 10:21:52 +08:00
72 changed files with 1686 additions and 872 deletions

View File

@@ -1,3 +1,30 @@
## 2.4.2 (2026-05-20)
### Features
* Added HDMI capture status detection with localized warning and error overlays on the desktop screen
### Bug Fixes
* Fixed left-button touch cancellation and context menu cleanup for mouse input
* Improved the WebRTC loading indicator
* Added Wi-Fi password field validation
* Hardened Wake-on-LAN MAC handling by normalizing stored addresses, avoiding shell execution, handling missing history gracefully, and improving named MAC display
* Stabilized PicoClaw session switching by waiting for gateway release, refreshing runtime status before reconnecting, rendering structured thought messages, and preventing history row overflow
### Performance
* Improved H.264 streaming client handling by reducing frame queue latency, using client snapshots for fanout, applying backend ICE server configuration, and cleaning up disconnected WebRTC clients more aggressively
### UI Improvements
* Refreshed desktop menu ordering and community links, including replacing the Discussion link with Discord
### Chores
* Removed the legacy H.264 stream package
* Updated PostCSS, refreshed the MSW worker, and added pnpm workspace build approvals
## 2.4.1 (2026-05-08)
### Features

View File

@@ -26,7 +26,7 @@ type GetWifiRsp struct {
type ConnectWifiReq struct {
Ssid string `validate:"required"`
Password string `valid:"required"`
Password string `validate:"required"`
}
type GetDNSRsp struct {

View File

@@ -1,6 +1,7 @@
package network
import (
"errors"
"fmt"
"os"
"os/exec"
@@ -33,8 +34,7 @@ func (s *Service) WakeOnLAN(c *gin.Context) {
return
}
command := fmt.Sprintf("ether-wake -b %s", mac)
cmd := exec.Command("sh", "-c", command)
cmd := exec.Command("ether-wake", "-b", mac)
output, err := cmd.CombinedOutput()
if err != nil {
@@ -43,7 +43,7 @@ func (s *Service) WakeOnLAN(c *gin.Context) {
return
}
go saveMac(mac)
saveMac(mac)
rsp.OkRsp(c)
log.Debugf("wake on lan: %s", mac)
@@ -52,14 +52,19 @@ func (s *Service) WakeOnLAN(c *gin.Context) {
func (s *Service) GetMac(c *gin.Context) {
var rsp proto.Response
content, err := os.ReadFile(WolMacFile)
macs, err := readWolMacs()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
rsp.OkRspWithData(c, &proto.GetMacRsp{Macs: []string{}})
return
}
rsp.ErrRsp(c, -2, "open file error")
return
}
data := &proto.GetMacRsp{
Macs: strings.Split(string(content), "\n"),
Macs: macs,
}
rsp.OkRspWithData(c, data)
@@ -74,36 +79,54 @@ func (s *Service) SetMacName(c *gin.Context) {
return
}
content, err := os.ReadFile(WolMacFile)
mac, err := parseMAC(req.Mac)
if err != nil {
rsp.ErrRsp(c, -2, "invalid MAC address")
return
}
name := sanitizeWolMacName(req.Name)
if name == "" {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
macs, err := readWolMacs()
if err != nil {
log.Errorf("failed to open %s: %s", WolMacFile, err)
rsp.ErrRsp(c, -2, "read failed")
return
}
macs := strings.Split(string(content), "\n")
var newLines []string
macFound := false
for _, line := range macs {
parts := strings.Split(line, " ")
if req.Mac != parts[0] {
newLines = append(newLines, line)
itemMac, itemName, ok := splitWolMacLine(line)
if !ok {
continue
}
newLines = append(newLines, parts[0]+" "+req.Name)
normalizedMac, err := parseMAC(itemMac)
if err != nil {
newLines = append(newLines, formatWolMacLine(itemMac, itemName))
continue
}
if mac != normalizedMac {
newLines = append(newLines, formatWolMacLine(normalizedMac, itemName))
continue
}
newLines = append(newLines, formatWolMacLine(normalizedMac, name))
macFound = true
}
if !macFound {
log.Errorf("failed to found mac %s: %s", req.Mac, err)
log.Errorf("failed to find mac %s", req.Mac)
rsp.ErrRsp(c, -3, "write failed")
return
}
data := strings.Join(newLines, "\n")
err = os.WriteFile(WolMacFile, []byte(data), 0o644)
if err != nil {
if err = writeWolMacs(newLines); err != nil {
log.Errorf("failed to write %s: %s", WolMacFile, err)
rsp.ErrRsp(c, -3, "write failed")
return
@@ -122,26 +145,37 @@ func (s *Service) DeleteMac(c *gin.Context) {
return
}
content, err := os.ReadFile(WolMacFile)
mac, err := parseMAC(req.Mac)
if err != nil {
rsp.ErrRsp(c, -2, "invalid MAC address")
return
}
macs, err := readWolMacs()
if err != nil {
log.Errorf("failed to open %s: %s", WolMacFile, err)
rsp.ErrRsp(c, -2, "read failed")
return
}
macs := strings.Split(string(content), "\n")
var newMacs []string
for _, mac := range macs {
parts := strings.Split(mac, " ")
if req.Mac != parts[0] {
newMacs = append(newMacs, mac)
for _, line := range macs {
itemMac, itemName, ok := splitWolMacLine(line)
if !ok {
continue
}
normalizedMac, err := parseMAC(itemMac)
if err != nil {
newMacs = append(newMacs, formatWolMacLine(itemMac, itemName))
continue
}
if mac != normalizedMac {
newMacs = append(newMacs, formatWolMacLine(normalizedMac, itemName))
}
}
data := strings.Join(newMacs, "\n")
err = os.WriteFile(WolMacFile, []byte(data), 0o644)
if err != nil {
if err = writeWolMacs(newMacs); err != nil {
log.Errorf("failed to write %s: %s", WolMacFile, err)
rsp.ErrRsp(c, -3, "write failed")
return
@@ -182,7 +216,7 @@ func saveMac(mac string) {
return
}
err := os.MkdirAll(filepath.Dir(WolMacFile), 0o644)
err := os.MkdirAll(filepath.Dir(WolMacFile), 0o755)
if err != nil {
log.Errorf("failed to create dir: %s", err)
return
@@ -206,18 +240,85 @@ func saveMac(mac string) {
}
func isMacExist(mac string) bool {
content, err := os.ReadFile(WolMacFile)
macs, err := readWolMacs()
if err != nil {
return false
}
macs := strings.Split(string(content), "\n")
for _, item := range macs {
parts := strings.Split(item, " ")
if mac == parts[0] {
itemMac, _, ok := splitWolMacLine(item)
if !ok {
continue
}
normalizedMac, err := parseMAC(itemMac)
if err != nil {
continue
}
if mac == normalizedMac {
return true
}
}
return false
}
func readWolMacs() ([]string, error) {
content, err := os.ReadFile(WolMacFile)
if err != nil {
return nil, err
}
lines := strings.Split(string(content), "\n")
macs := make([]string, 0, len(lines))
for _, line := range lines {
mac, name, ok := splitWolMacLine(line)
if !ok {
continue
}
macs = append(macs, formatWolMacLine(mac, name))
}
return macs, nil
}
func writeWolMacs(macs []string) error {
data := ""
if len(macs) > 0 {
data = strings.Join(macs, "\n") + "\n"
}
return os.WriteFile(WolMacFile, []byte(data), 0o644)
}
func splitWolMacLine(line string) (string, string, bool) {
line = strings.TrimSpace(line)
if line == "" {
return "", "", false
}
fields := strings.Fields(line)
if len(fields) == 0 {
return "", "", false
}
mac := fields[0]
if len(line) == len(mac) {
return mac, "", true
}
name := line[len(mac):]
return mac, strings.TrimSpace(name), true
}
func formatWolMacLine(mac string, name string) string {
if name == "" {
return mac
}
return mac + " " + name
}
func sanitizeWolMacName(name string) string {
return strings.Join(strings.Fields(name), " ")
}

View File

@@ -136,10 +136,15 @@ func loadPicoclawGatewaySettings() (picoclawGatewaySettings, error) {
port = defaultPicoclawGatewayPort
}
picoSettings := picoclawPicoSettingsV3{}
if pico, ok := cfg.Channels["pico"]; ok {
picoSettings = pico.Settings
}
settings := picoclawGatewaySettings{
GatewayURL: fmt.Sprintf("ws://%s:%d%s", host, port, picoclawGatewayPath),
Token: doc.resolvedGatewayToken(),
AllowTokenQuery: cfg.Channels.Pico.AllowTokenQuery,
AllowTokenQuery: picoSettings.AllowTokenQuery,
}
settings.TargetModelName = resolvePicoclawTargetModelName(cfg)
@@ -148,11 +153,11 @@ func loadPicoclawGatewaySettings() (picoclawGatewaySettings, error) {
settings.ModelName = settings.TargetModelName
}
if cfg.Channels.Pico.PingInterval > 0 {
settings.PingIntervalMs = cfg.Channels.Pico.PingInterval * 1000
if picoSettings.PingInterval > 0 {
settings.PingIntervalMs = picoSettings.PingInterval * 1000
}
if cfg.Channels.Pico.ReadTimeout > 0 {
settings.ReadTimeoutMs = cfg.Channels.Pico.ReadTimeout * 1000
if picoSettings.ReadTimeout > 0 {
settings.ReadTimeoutMs = picoSettings.ReadTimeout * 1000
}
return settings, nil

View File

@@ -12,11 +12,6 @@ import (
"gopkg.in/yaml.v3"
)
const (
picoclawPIDFileName = ".picoclaw.pid"
picoclawGatewayTokenPrefix = "pico-"
)
type picoclawConfigFile struct {
Agents struct {
Defaults struct {
@@ -36,15 +31,22 @@ type picoclawConfigFile struct {
APIKey string `json:"api_key"`
APIKeys []string `json:"api_keys,omitempty"`
} `json:"model_list"`
Channels struct {
Pico struct {
Enabled bool `json:"enabled"`
Token string `json:"token"`
AllowTokenQuery bool `json:"allow_token_query"`
PingInterval int `json:"ping_interval"`
ReadTimeout int `json:"read_timeout"`
} `json:"pico"`
} `json:"channels"`
Channels map[string]picoclawChannelEntry `json:"channel_list"`
}
type picoclawChannelEntry struct {
Type string `json:"type"`
Enabled bool `json:"enabled"`
Settings picoclawPicoSettingsV3 `json:"settings"`
}
type picoclawPicoSettingsV3 struct {
Token string `json:"token,omitempty"`
AllowTokenQuery bool `json:"allow_token_query,omitempty"`
PingInterval int `json:"ping_interval,omitempty"`
ReadTimeout int `json:"read_timeout,omitempty"`
WriteTimeout int `json:"write_timeout,omitempty"`
MaxConnections int `json:"max_connections,omitempty"`
}
type picoclawConfigDocument struct {
@@ -154,88 +156,45 @@ func (d *picoclawConfigDocument) saveSecurity() error {
}
type picoclawSecurityConfig struct {
ModelList map[string]picoclawModelSecurityEntry `yaml:"model_list,omitempty"`
Channels picoclawSecurityChannels `yaml:"channels,omitempty"`
ModelList map[string]picoclawModelSecurityEntry `yaml:"model_list,omitempty"`
ChannelList map[string]picoclawChannelSecurityEntry `yaml:"channel_list,omitempty"`
}
type picoclawModelSecurityEntry struct {
APIKeys []string `yaml:"api_keys,omitempty"`
}
type picoclawSecurityChannels struct {
Pico *picoclawPicoSecurity `yaml:"pico,omitempty"`
type picoclawChannelSecurityEntry struct {
Token string `yaml:"token,omitempty"`
Settings *picoclawChannelSecuritySettings `yaml:"settings,omitempty"`
}
type picoclawPicoSecurity struct {
type picoclawChannelSecuritySettings struct {
Token string `yaml:"token,omitempty"`
}
type picoclawPIDFile struct {
Token string `json:"token"`
}
func (d *picoclawConfigDocument) resolvedPicoToken() string {
if d == nil {
return ""
}
if d.security.Channels.Pico != nil {
if token := d.security.Channels.Pico.Token; token != "" {
return token
// Check security config (channel_list.pico with nested settings)
if entry, ok := d.security.ChannelList["pico"]; ok {
if entry.Settings != nil && entry.Settings.Token != "" {
return entry.Settings.Token
}
if entry.Token != "" {
return entry.Token
}
}
return d.config.Channels.Pico.Token
// Fall back to config.json channel_list.pico.settings.token
if pico, ok := d.config.Channels["pico"]; ok {
return pico.Settings.Token
}
return ""
}
func (d *picoclawConfigDocument) resolvedGatewayToken() string {
baseToken := strings.TrimSpace(d.resolvedPicoToken())
if baseToken == "" {
return ""
}
runtimeToken, err := d.resolvedPIDToken()
if err != nil {
return baseToken
}
return composePicoclawGatewayToken(baseToken, runtimeToken)
}
func (d *picoclawConfigDocument) resolvedPIDToken() (string, error) {
if d == nil || d.configPath == "" {
return "", nil
}
pidPath := filepath.Join(filepath.Dir(d.configPath), picoclawPIDFileName)
data, err := os.ReadFile(pidPath)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("failed to read picoclaw pid file: %w", err)
}
var pidFile picoclawPIDFile
if err := json.Unmarshal(data, &pidFile); err != nil {
return "", fmt.Errorf("failed to parse picoclaw pid file: %w", err)
}
return strings.TrimSpace(pidFile.Token), nil
}
func composePicoclawGatewayToken(baseToken, runtimeToken string) string {
baseToken = strings.TrimSpace(baseToken)
runtimeToken = strings.TrimSpace(runtimeToken)
switch {
case baseToken == "":
return ""
case strings.HasPrefix(baseToken, picoclawGatewayTokenPrefix):
return baseToken
case runtimeToken == "":
return baseToken
default:
return picoclawGatewayTokenPrefix + runtimeToken + baseToken
}
return strings.TrimSpace(d.resolvedPicoToken())
}
func loadPicoclawSecurityConfig(securityPath string) (picoclawSecurityConfig, error) {

View File

@@ -7,8 +7,8 @@ import (
const (
picoclawBinaryPath = "/usr/bin/picoclaw"
picoclawCacheDir = "/root/.picoclaw-cache"
picoclawDownloadURL = "https://cdn.sipeed.com/nanokvm/resources/picoclaw/v0.2.6/picoclaw_Linux_riscv64.tar.gz"
picoclawChecksumURL = "https://cdn.sipeed.com/nanokvm/resources/picoclaw/v0.2.6/sha512.txt"
picoclawDownloadURL = "https://cdn.sipeed.com/nanokvm/resources/picoclaw/v0.2.8/picoclaw_Linux_riscv64.tar.gz"
picoclawChecksumURL = "https://cdn.sipeed.com/nanokvm/resources/picoclaw/v0.2.8/sha512.txt"
etcInitPicoclawScript = "/etc/init.d/S96picoclaw"
kvmappPicoclawScript = "/kvmapp/system/init.d/S96picoclaw"
picoclawStartTimeout = 15 * time.Second

View File

@@ -32,11 +32,12 @@ var picoclawNanoKVMDefaults = []picoclawConfigDefault{
{path: []string{"tools", "cron", "allow_command"}, value: true},
{path: []string{"tools", "exec", "allow_remote"}, value: true},
{path: []string{"tools", "exec", "enable_deny_patterns"}, value: false},
{path: []string{"channels", "pico", "allow_token_query"}, value: false},
{path: []string{"channels", "pico", "ping_interval"}, value: defaultPicoclawPingSec},
{path: []string{"channels", "pico", "read_timeout"}, value: defaultPicoclawReadSec},
{path: []string{"channels", "pico", "write_timeout"}, value: defaultPicoclawWriteSec},
{path: []string{"channels", "pico", "max_connections"}, value: defaultPicoclawMaxConns},
{path: []string{"channel_list", "pico", "type"}, value: "pico"},
{path: []string{"channel_list", "pico", "settings", "allow_token_query"}, value: false},
{path: []string{"channel_list", "pico", "settings", "ping_interval"}, value: defaultPicoclawPingSec},
{path: []string{"channel_list", "pico", "settings", "read_timeout"}, value: defaultPicoclawReadSec},
{path: []string{"channel_list", "pico", "settings", "write_timeout"}, value: defaultPicoclawWriteSec},
{path: []string{"channel_list", "pico", "settings", "max_connections"}, value: defaultPicoclawMaxConns},
{path: []string{"tools", "mcp", "enabled"}, value: true},
}
@@ -72,7 +73,10 @@ func ensurePicoclawStartupDefaults() error {
}
func ensurePicoclawPicoChannelEnabled(doc *picoclawConfigDocument) error {
if doc == nil || doc.config.Channels.Pico.Enabled {
if doc == nil {
return nil
}
if pico, ok := doc.config.Channels["pico"]; ok && pico.Enabled {
return nil
}
@@ -98,12 +102,20 @@ func applyPicoclawStartupDefaults(editor *picoclawConfigEditor) error {
}
func forceEnablePicoclawPicoChannel(doc *picoclawConfigDocument, editor *picoclawConfigEditor) {
if doc == nil || editor == nil || doc.config.Channels.Pico.Enabled {
if doc == nil || editor == nil {
return
}
if pico, ok := doc.config.Channels["pico"]; ok && pico.Enabled {
return
}
editor.setValue(true, "channels", "pico", "enabled")
doc.config.Channels.Pico.Enabled = true
editor.setValue(true, "channel_list", "pico", "enabled")
if doc.config.Channels == nil {
doc.config.Channels = make(map[string]picoclawChannelEntry)
}
pico := doc.config.Channels["pico"]
pico.Enabled = true
doc.config.Channels["pico"] = pico
}
func defaultPicoclawMCPServer() (map[string]any, error) {
@@ -197,14 +209,19 @@ func ensurePicoclawPicoToken(doc *picoclawConfigDocument) (bool, error) {
return false, err
}
if doc.security.Channels.Pico == nil {
doc.security.Channels.Pico = &picoclawPicoSecurity{}
if doc.security.ChannelList == nil {
doc.security.ChannelList = make(map[string]picoclawChannelSecurityEntry)
}
if strings.TrimSpace(doc.security.Channels.Pico.Token) == token {
entry := doc.security.ChannelList["pico"]
if entry.Settings == nil {
entry.Settings = &picoclawChannelSecuritySettings{}
}
if strings.TrimSpace(entry.Settings.Token) == token {
return false, nil
}
doc.security.Channels.Pico.Token = token
entry.Settings.Token = token
doc.security.ChannelList["pico"] = entry
return true, nil
}
@@ -213,14 +230,23 @@ func picoSecurityToken(doc *picoclawConfigDocument) (string, error) {
return "", nil
}
if doc.security.Channels.Pico != nil {
if token := strings.TrimSpace(doc.security.Channels.Pico.Token); token != "" {
// Check security channel_list.pico.settings.token
if entry, ok := doc.security.ChannelList["pico"]; ok {
if entry.Settings != nil {
if token := strings.TrimSpace(entry.Settings.Token); token != "" {
return token, nil
}
}
if token := strings.TrimSpace(entry.Token); token != "" {
return token, nil
}
}
if token := strings.TrimSpace(doc.config.Channels.Pico.Token); token != "" {
return token, nil
// Fall back to config channel_list.pico.settings.token
if pico, ok := doc.config.Channels["pico"]; ok {
if token := strings.TrimSpace(pico.Settings.Token); token != "" {
return token, nil
}
}
return generatePicoclawToken()

View File

@@ -18,6 +18,7 @@ import (
const (
picoSessionPrefix = "agent:main:pico:direct:pico:"
sanitizedPicoSessionPrefix = "agent_main_pico_direct_pico_"
opaqueSessionKeyPrefix = "sk_v1_"
maxSessionJSONLLineSize = 10 * 1024 * 1024
maxSessionPreviewRunes = 60
defaultSessionLimit = 20
@@ -109,6 +110,21 @@ func extractPicoSessionIDFromSanitizedKey(key string) (string, bool) {
return "", false
}
func isOpaqueSessionKey(key string) bool {
return strings.HasPrefix(key, opaqueSessionKeyPrefix)
}
func extractOpaqueSessionID(fileName string) (string, bool) {
base := strings.TrimSuffix(fileName, filepath.Ext(fileName))
if strings.HasSuffix(fileName, ".meta.json") {
base = strings.TrimSuffix(fileName, ".meta.json")
}
if isOpaqueSessionKey(base) {
return base, true
}
return "", false
}
func (s *Service) ListSessions(c *gin.Context) {
dir, err := resolvePicoclawSessionsPath()
if err != nil {
@@ -144,11 +160,17 @@ func (s *Service) ListSessions(c *gin.Context) {
switch {
case strings.HasSuffix(name, ".jsonl"):
sessionID, ok = extractPicoSessionIDFromSanitizedKey(strings.TrimSuffix(name, ".jsonl"))
if !ok {
continue
baseName := strings.TrimSuffix(name, ".jsonl")
if opaqueID, found := extractOpaqueSessionID(name); found {
sessionID = opaqueID
sess, loadErr = readOpaqueJSONLSession(dir, sessionID)
} else {
sessionID, ok = extractPicoSessionIDFromSanitizedKey(baseName)
if !ok {
continue
}
sess, loadErr = readJSONLSession(dir, sessionID)
}
sess, loadErr = readJSONLSession(dir, sessionID)
if loadErr == nil && isEmptySession(sess) {
continue
}
@@ -228,12 +250,17 @@ func (s *Service) GetSession(c *gin.Context) {
return
}
sess, err := readJSONLSession(dir, sessionID)
var sess sessionStoredFile
if isOpaqueSessionKey(sessionID) {
sess, err = readOpaqueJSONLSession(dir, sessionID)
} else {
sess, err = readJSONLSession(dir, sessionID)
}
if err == nil && isEmptySession(sess) {
err = os.ErrNotExist
}
if err != nil {
if errors.Is(err, os.ErrNotExist) {
if errors.Is(err, os.ErrNotExist) && !isOpaqueSessionKey(sessionID) {
sess, err = readLegacySession(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)+".json")
if err == nil && isEmptySession(sess) {
err = os.ErrNotExist
@@ -279,7 +306,12 @@ func (s *Service) DeleteSession(c *gin.Context) {
return
}
base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID))
var base string
if isOpaqueSessionKey(sessionID) {
base = filepath.Join(dir, sessionID)
} else {
base = filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID))
}
paths := []string{base + ".jsonl", base + ".meta.json", base + ".json"}
removed := false
@@ -378,6 +410,43 @@ func readSessionMessages(path string, skip int) ([]sessionStoredMessage, error)
return messages, nil
}
func readOpaqueJSONLSession(dir, opaqueKey string) (sessionStoredFile, error) {
base := filepath.Join(dir, opaqueKey)
jsonlPath := base + ".jsonl"
metaPath := base + ".meta.json"
meta, err := readSessionMeta(metaPath, opaqueKey)
if err != nil {
return sessionStoredFile{}, err
}
messages, err := readSessionMessages(jsonlPath, meta.Skip)
if err != nil {
return sessionStoredFile{}, err
}
created := meta.CreatedAt
updated := meta.UpdatedAt
if created.IsZero() || updated.IsZero() {
if info, statErr := os.Stat(jsonlPath); statErr == nil {
if created.IsZero() {
created = info.ModTime()
}
if updated.IsZero() {
updated = info.ModTime()
}
}
}
return sessionStoredFile{
Key: meta.Key,
Messages: messages,
Summary: meta.Summary,
Created: created,
Updated: updated,
}, nil
}
func readJSONLSession(dir, sessionID string) (sessionStoredFile, error) {
sessionKey := picoSessionPrefix + sessionID
base := filepath.Join(dir, sanitizeSessionKey(sessionKey))

View File

@@ -0,0 +1,167 @@
package stream
import (
"sort"
"sync"
"time"
)
const (
CaptureStatusEvent = "capture-status"
CaptureModeDirect = "direct"
CaptureModeH264 = "h264"
CaptureModeMJPEG = "mjpeg"
CaptureSeverityError = "error"
CaptureSeverityWarning = "warning"
)
type CaptureStatus struct {
Ok bool `json:"ok"`
Result int `json:"result"`
Message string `json:"message"`
Mode string `json:"mode"`
Severity string `json:"severity"`
UpdatedAt time.Time `json:"updatedAt"`
}
type CaptureStatusSubscriber func(CaptureStatus)
var defaultStore = newStore(time.Now)
func SubscribeCaptureStatus(subscriber CaptureStatusSubscriber) func() {
return defaultStore.Subscribe(subscriber)
}
func UpdateCaptureStatus(mode string, result int) {
defaultStore.UpdateCaptureStatus(mode, result)
}
func LatestCaptureStatuses() []CaptureStatus {
return defaultStore.LatestCaptureStatuses()
}
type store struct {
mutex sync.Mutex
latestByMode map[string]CaptureStatus
now func() time.Time
subscribers map[int]CaptureStatusSubscriber
nextSubscriberID int
}
func newStore(now func() time.Time) *store {
return &store{
now: now,
latestByMode: make(map[string]CaptureStatus),
subscribers: make(map[int]CaptureStatusSubscriber),
}
}
func (s *store) Subscribe(subscriber CaptureStatusSubscriber) func() {
s.mutex.Lock()
id := s.nextSubscriberID
s.nextSubscriberID++
s.subscribers[id] = subscriber
s.mutex.Unlock()
return func() {
s.mutex.Lock()
delete(s.subscribers, id)
s.mutex.Unlock()
}
}
func (s *store) UpdateCaptureStatus(mode string, result int) {
next := newCaptureStatus(mode, result, s.now())
subscribers := s.update(next)
for _, subscriber := range subscribers {
subscriber(next)
}
}
func (s *store) update(next CaptureStatus) []CaptureStatusSubscriber {
s.mutex.Lock()
defer s.mutex.Unlock()
last, ok := s.latestByMode[next.Mode]
if ok && samePublicStatus(last, next) {
return nil
}
s.latestByMode[next.Mode] = next
subscribers := make([]CaptureStatusSubscriber, 0, len(s.subscribers))
for _, subscriber := range s.subscribers {
subscribers = append(subscribers, subscriber)
}
return subscribers
}
func (s *store) LatestCaptureStatus(mode string) (CaptureStatus, bool) {
s.mutex.Lock()
defer s.mutex.Unlock()
status, ok := s.latestByMode[mode]
return status, ok
}
func (s *store) LatestCaptureStatuses() []CaptureStatus {
s.mutex.Lock()
defer s.mutex.Unlock()
modes := make([]string, 0, len(s.latestByMode))
for mode := range s.latestByMode {
modes = append(modes, mode)
}
sort.Strings(modes)
statuses := make([]CaptureStatus, 0, len(modes))
for _, mode := range modes {
statuses = append(statuses, s.latestByMode[mode])
}
return statuses
}
func samePublicStatus(a CaptureStatus, b CaptureStatus) bool {
if a.Ok && b.Ok {
return true
}
return a.Ok == b.Ok && a.Result == b.Result && a.Mode == b.Mode
}
func newCaptureStatus(mode string, result int, updatedAt time.Time) CaptureStatus {
message, severity := captureResultMessage(result)
return CaptureStatus{
Ok: result >= 0,
Result: result,
Message: message,
Mode: mode,
Severity: severity,
UpdatedAt: updatedAt,
}
}
func captureResultMessage(result int) (string, string) {
switch result {
case -7:
return "HDMI input resolution error", CaptureSeverityError
case -6:
return "Unsupported HDMI resolution", CaptureSeverityError
case -5:
return "Retrieving image", CaptureSeverityWarning
case -4:
return "Changing image resolution", CaptureSeverityWarning
case -3:
return "Image buffer full", CaptureSeverityError
case -2:
return "Encoder error", CaptureSeverityError
case -1:
return "No image captured", CaptureSeverityError
default:
if result < 0 {
return "Capture failed", CaptureSeverityError
}
return "", ""
}
}

View File

@@ -12,6 +12,7 @@ import (
var (
streamer = newStreamer()
upgrader = websocket.Upgrader{
WriteBufferSize: 256 * 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
@@ -36,7 +37,7 @@ func Connect(c *gin.Context) {
defer streamer.removeClient(ws)
for {
if _, _, err := ws.ReadMessage(); err != nil {
if _, _, err := ws.NextReader(); err != nil {
log.Debugf("failed to read message (client disconnected): %s", err)
return
}

View File

@@ -14,20 +14,25 @@ import (
)
type Streamer struct {
mutex sync.RWMutex
clients map[*websocket.Conn]bool
running int32
mutex sync.Mutex
clients map[*websocket.Conn]bool
clientSnapshot atomic.Pointer[[]*websocket.Conn]
running int32
}
func newStreamer() *Streamer {
return &Streamer{
s := &Streamer{
clients: make(map[*websocket.Conn]bool),
}
s.updateClientSnapshotLocked()
return s
}
func (s *Streamer) addClient(ws *websocket.Conn) {
s.mutex.Lock()
s.clients[ws] = true
s.updateClientSnapshotLocked()
s.mutex.Unlock()
if atomic.CompareAndSwapInt32(&s.running, 0, 1) {
@@ -39,36 +44,53 @@ func (s *Streamer) addClient(ws *websocket.Conn) {
func (s *Streamer) removeClient(ws *websocket.Conn) {
s.mutex.Lock()
delete(s.clients, ws)
count := s.updateClientSnapshotLocked()
s.mutex.Unlock()
log.Debugf("h264 websocket disconnected, remaining clients: %d", len(s.clients))
log.Debugf("h264 websocket disconnected, remaining clients: %d", count)
}
func (s *Streamer) getClientCount() int {
s.mutex.RLock()
defer s.mutex.RUnlock()
func (s *Streamer) updateClientSnapshotLocked() int {
clients := make([]*websocket.Conn, 0, len(s.clients))
for client := range s.clients {
clients = append(clients, client)
}
s.clientSnapshot.Store(&clients)
return len(s.clients)
return len(clients)
}
func (s *Streamer) getClients() []*websocket.Conn {
clients := s.clientSnapshot.Load()
if clients == nil {
return nil
}
return *clients
}
func (s *Streamer) run() {
defer atomic.StoreInt32(&s.running, 0)
duration := time.Second / time.Duration(120)
ticker := time.NewTicker(duration)
screen := common.GetScreen()
common.CheckScreen()
fps := screen.FPS
ticker := time.NewTicker(time.Second / time.Duration(fps))
defer ticker.Stop()
screen := common.GetScreen()
vision := common.GetKvmVision()
startTime := time.Now()
for range ticker.C {
if s.getClientCount() == 0 {
clients := s.getClients()
if len(clients) == 0 {
log.Debug("h264 stream stopped due to no clients")
return
}
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
stream.UpdateCaptureStatus(stream.CaptureModeDirect, result)
if result < 0 || len(data) == 0 {
continue
}
@@ -80,28 +102,34 @@ func (s *Streamer) run() {
timestamp := time.Since(startTime).Microseconds()
if err := s.send(isKeyFrame, timestamp, data); err != nil {
if err := s.send(clients, isKeyFrame, timestamp, data); err != nil {
continue
}
if screen.FPS != fps && screen.FPS != 0 {
fps = screen.FPS
ticker.Reset(time.Second / time.Duration(fps))
}
stream.GetFrameRateCounter().Update()
}
}
func (s *Streamer) send(isKeyFrame byte, timestamp int64, data []byte) error {
func (s *Streamer) send(clients []*websocket.Conn, isKeyFrame byte, timestamp int64, data []byte) error {
buf := BufferPool.Get().(*bytes.Buffer)
defer BufferPool.Put(buf)
buf.Reset()
buf.Grow(1 + 8 + len(data))
if err := buf.WriteByte(isKeyFrame); err != nil {
log.Errorf("failed to write keyframe flag: %s", err)
return err
}
tsBytes := make([]byte, 8)
binary.LittleEndian.PutUint64(tsBytes, uint64(timestamp))
if _, err := buf.Write(tsBytes); err != nil {
var tsBytes [8]byte
binary.LittleEndian.PutUint64(tsBytes[:], uint64(timestamp))
if _, err := buf.Write(tsBytes[:]); err != nil {
log.Errorf("failed to write timestamp: %s", err)
return err
}
@@ -111,7 +139,7 @@ func (s *Streamer) send(isKeyFrame byte, timestamp int64, data []byte) error {
return err
}
for client := range s.clients {
for _, client := range clients {
if err := client.WriteMessage(websocket.BinaryMessage, buf.Bytes()); err != nil {
log.Errorf("failed to write message to client %s: %s.", client.RemoteAddr(), err)

View File

@@ -16,9 +16,8 @@ var (
)
type FrameRateCounter struct {
frameCount int32
fps int32
mutex sync.Mutex
frameCount atomic.Int32
fps atomic.Int32
}
func GetFrameRateCounter() *FrameRateCounter {
@@ -30,16 +29,10 @@ func GetFrameRateCounter() *FrameRateCounter {
defer ticker.Stop()
for range ticker.C {
counter.mutex.Lock()
currentCount := counter.frameCount.Swap(0)
counter.fps.Store(currentCount / 3)
currentCount := atomic.LoadInt32(&counter.frameCount)
counter.fps = currentCount / 3
atomic.StoreInt32(&counter.frameCount, 0)
counter.mutex.Unlock()
data := fmt.Sprintf("%d", counter.fps)
data := fmt.Sprintf("%d", counter.fps.Load())
err := os.WriteFile("/kvmapp/kvm/now_fps", []byte(data), 0o666)
if err != nil {
log.Errorf("failed to write fps: %s", err)
@@ -52,12 +45,9 @@ func GetFrameRateCounter() *FrameRateCounter {
}
func (f *FrameRateCounter) Update() {
atomic.AddInt32(&f.frameCount, 1)
f.frameCount.Add(1)
}
func (f *FrameRateCounter) GetFPS() int32 {
f.mutex.Lock()
defer f.mutex.Unlock()
return f.fps
return f.fps.Load()
}

View File

@@ -1,167 +0,0 @@
package h264
import (
"encoding/json"
"sync"
"github.com/gorilla/websocket"
"github.com/pion/webrtc/v4"
log "github.com/sirupsen/logrus"
)
type Client struct {
ws *websocket.Conn
pc *webrtc.PeerConnection
mutex sync.Mutex
}
type Message struct {
Event string `json:"event"`
Data string `json:"data"`
}
// add video track
func (c *Client) addTrack() {
videoTrack, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264},
"video",
"pion",
)
if err != nil {
log.Errorf("failed to create video track: %s", err)
return
}
_, err = c.pc.AddTrack(videoTrack)
if err != nil {
log.Errorf("failed to add video track: %s", err)
return
}
trackMap[c.ws] = videoTrack
}
// register callback events
func (c *Client) register() {
// new ICE candidate found
c.pc.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate == nil {
return
}
candidateByte, err := json.Marshal(candidate.ToJSON())
if err != nil {
log.Errorf("failed to marshal candidate: %s", err)
return
}
_ = c.sendMessage("candidate", string(candidateByte))
})
// ICE connection state has changed
c.pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
if state == webrtc.ICEConnectionStateConnected && !isSending {
// start sending h264 data
go send()
isSending = true
}
log.Debugf("ice connection state has changed to %s", state.String())
})
}
// read websocket message
func (c *Client) readMessage() {
message := &Message{}
for {
_, raw, err := c.ws.ReadMessage()
if err != nil {
delete(trackMap, c.ws)
if isSending && len(trackMap) == 0 {
// stop sending when all websocket connections are closed
isSending = false
}
log.Debugf("failed to read message: %s", err)
return
}
if err := json.Unmarshal(raw, &message); err != nil {
log.Errorf("failed to unmarshal message: %s", err)
continue
}
log.Debugf("receive message event: %s", message.Event)
switch message.Event {
case "offer":
offer := webrtc.SessionDescription{}
if err := json.Unmarshal([]byte(message.Data), &offer); err != nil {
log.Errorf("failed to unmarshal offer message: %s", err)
return
}
if err := c.pc.SetRemoteDescription(offer); err != nil {
log.Errorf("failed to set remote description: %s", err)
return
}
answer, answerErr := c.pc.CreateAnswer(nil)
if answerErr != nil {
log.Errorf("failed to create answer: %s", answerErr)
return
}
if err := c.pc.SetLocalDescription(answer); err != nil {
log.Errorf("failed to set local description: %s", err)
return
}
answerByte, answerByteErr := json.Marshal(answer)
if answerByteErr != nil {
log.Errorf("failed to marshal answer: %s", answerByteErr)
return
}
_ = c.sendMessage("answer", string(answerByte))
case "candidate":
candidate := webrtc.ICECandidateInit{}
if err := json.Unmarshal([]byte(message.Data), &candidate); err != nil {
log.Errorf("failed to unmarshal candidate message: %s", err)
return
}
if err := c.pc.AddICECandidate(candidate); err != nil {
log.Errorf("failed to add ICE candidate: %s", err)
return
}
case "heartbeat":
_ = c.sendMessage("heartbeat", "")
default:
log.Debugf("unhandled message event: %s", message.Event)
}
}
}
// send websocket message
func (c *Client) sendMessage(event string, data string) error {
c.mutex.Lock()
defer c.mutex.Unlock()
message := &Message{
Event: event,
Data: data,
}
if err := c.ws.WriteJSON(message); err != nil {
log.Errorf("failed to send message %s: %s", event, err)
return err
}
log.Debugf("send message %s", message.Event)
return nil
}

View File

@@ -1,80 +0,0 @@
package h264
import (
"NanoKVM-Server/config"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/pion/webrtc/v4"
log "github.com/sirupsen/logrus"
)
var (
upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
trackMap = make(map[*websocket.Conn]*webrtc.TrackLocalStaticSample)
isSending = false
)
func Connect(c *gin.Context) {
wsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Errorf("failed to create websocket: %s", err)
return
}
defer func() {
_ = wsConn.Close()
log.Debugf("h264 websocket disconnected")
}()
var zeroTime time.Time
_ = wsConn.SetReadDeadline(zeroTime)
conf := config.GetInstance()
var iceServers []webrtc.ICEServer
if conf.Stun != "" && conf.Stun != "disable" {
iceServers = append(iceServers, webrtc.ICEServer{
URLs: []string{"stun:" + conf.Stun},
})
}
if conf.Turn.TurnAddr != "" && conf.Turn.TurnUser != "" && conf.Turn.TurnCred != "" {
iceServers = append(iceServers, webrtc.ICEServer{
URLs: []string{"turn:" + conf.Turn.TurnAddr},
Username: conf.Turn.TurnUser,
Credential: conf.Turn.TurnCred,
})
}
peerConn, err := webrtc.NewPeerConnection(webrtc.Configuration{
ICEServers: iceServers,
})
if err != nil {
log.Errorf("failed to create PeerConnection: %s", err)
return
}
defer func() {
_ = peerConn.Close()
log.Debugf("PeerConnection disconnected")
}()
client := &Client{
ws: wsConn,
pc: peerConn,
mutex: sync.Mutex{},
}
client.addTrack()
client.register()
client.readMessage()
}

View File

@@ -1,49 +0,0 @@
package h264
import (
"NanoKVM-Server/common"
"time"
"github.com/pion/webrtc/v4/pkg/media"
log "github.com/sirupsen/logrus"
)
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()
for range ticker.C {
if !isSending && len(trackMap) == 0 {
return
}
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
if result < 0 || len(data) == 0 {
continue
}
sample := media.Sample{
Data: data,
Duration: duration,
}
for _, track := range trackMap {
if err := track.WriteSample(sample); err != nil {
log.Errorf("failed to send h264 data: %s", err)
}
}
if screen.FPS != fps {
fps = screen.FPS
duration = time.Second / time.Duration(fps)
ticker.Reset(duration)
}
}
}

View File

@@ -13,24 +13,31 @@ import (
log "github.com/sirupsen/logrus"
)
var crlf = []byte("\r\n")
type Streamer struct {
mutex sync.RWMutex
clients map[*gin.Context]bool
running int32
frameMutex sync.RWMutex
latestFrame LatestFrame
cacheRefs int32
mutex sync.Mutex
clients map[*gin.Context]bool
clientSnapshot atomic.Pointer[[]*gin.Context]
running int32
frameMutex sync.RWMutex
latestFrame LatestFrame
cacheRefs int32
}
func NewStreamer() *Streamer {
return &Streamer{
s := &Streamer{
clients: make(map[*gin.Context]bool),
}
s.updateClientSnapshotLocked()
return s
}
func (s *Streamer) AddClient(c *gin.Context) {
s.mutex.Lock()
s.clients[c] = true
s.updateClientSnapshotLocked()
s.mutex.Unlock()
if atomic.CompareAndSwapInt32(&s.running, 0, 1) {
@@ -42,28 +49,29 @@ func (s *Streamer) AddClient(c *gin.Context) {
func (s *Streamer) RemoveClient(c *gin.Context) {
s.mutex.Lock()
delete(s.clients, c)
count := s.updateClientSnapshotLocked()
s.mutex.Unlock()
log.Debugf("mjpeg connection removed, remaining clients: %d", len(s.clients))
log.Debugf("mjpeg connection removed, remaining clients: %d", count)
}
func (s *Streamer) getClients() []*gin.Context {
s.mutex.RLock()
defer s.mutex.RUnlock()
func (s *Streamer) updateClientSnapshotLocked() int {
clients := make([]*gin.Context, 0, len(s.clients))
for c := range s.clients {
clients = append(clients, c)
}
s.clientSnapshot.Store(&clients)
return clients
return len(clients)
}
func (s *Streamer) getClientCount() int {
s.mutex.RLock()
defer s.mutex.RUnlock()
func (s *Streamer) getClients() []*gin.Context {
clients := s.clientSnapshot.Load()
if clients == nil {
return nil
}
return len(s.clients)
return *clients
}
func (s *Streamer) run() {
@@ -79,12 +87,14 @@ func (s *Streamer) run() {
defer ticker.Stop()
for range ticker.C {
if s.getClientCount() == 0 {
clients := s.getClients()
if len(clients) == 0 {
log.Debug("mjpeg stream stopped due to no clients")
return
}
data, result := vision.ReadMjpeg(screen.Width, screen.Height, screen.Quality)
stream.UpdateCaptureStatus(stream.CaptureModeMJPEG, result)
if result < 0 || result == 5 || len(data) == 0 {
continue
}
@@ -93,7 +103,6 @@ func (s *Streamer) run() {
s.setLatestFrame(data, screen.Width, screen.Height)
}
clients := s.getClients()
for _, client := range clients {
if err := writeFrame(client, data); err != nil {
log.Errorf("failed to write mjpeg frame for client %s: %s", client.Request.RemoteAddr, err)
@@ -194,7 +203,7 @@ func writeFrame(c *gin.Context, data []byte) (err error) {
return err
}
if _, err = c.Writer.Write([]byte("\r\n")); err != nil {
if _, err = c.Writer.Write(crlf); err != nil {
return err
}

View File

@@ -21,6 +21,20 @@ func NewClient(ws *websocket.Conn, videoConn *webrtc.PeerConnection) *Client {
}
}
func (c *Client) Close() {
if c.video != nil {
if err := c.video.Close(); err != nil {
log.Debugf("failed to close video peer connection: %s", err)
}
}
if c.ws != nil {
if err := c.ws.Close(); err != nil {
log.Debugf("failed to close websocket: %s", err)
}
}
}
func (c *Client) WriteMessage(event string, data string) error {
c.mutex.Lock()
defer c.mutex.Unlock()

View File

@@ -2,6 +2,7 @@ package webrtc
import (
"NanoKVM-Server/config"
"encoding/json"
"net/http"
"sync"
"time"
@@ -15,6 +16,7 @@ import (
var (
upgrader = websocket.Upgrader{
WriteBufferSize: 256 * 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
@@ -79,6 +81,10 @@ func Connect(c *gin.Context) {
// handle signaling
signalingHandler := NewSignalingHandler(client)
signalingHandler.RegisterCallbacks()
if err := sendICEServers(client, iceServers); err != nil {
log.Errorf("failed to send ICE servers: %s", err)
return
}
// read and wait
for {
@@ -116,6 +122,30 @@ func createICEServers() []webrtc.ICEServer {
return iceServers
}
type clientICEServer struct {
URLs []string `json:"urls"`
Username string `json:"username,omitempty"`
Credential interface{} `json:"credential,omitempty"`
}
func sendICEServers(client *Client, iceServers []webrtc.ICEServer) error {
clientServers := make([]clientICEServer, 0, len(iceServers))
for _, server := range iceServers {
clientServers = append(clientServers, clientICEServer{
URLs: server.URLs,
Username: server.Username,
Credential: server.Credential,
})
}
data, err := json.Marshal(clientServers)
if err != nil {
return err
}
return client.WriteMessage("ice-servers", string(data))
}
func createMediaEngine() (*webrtc.MediaEngine, error) {
mediaEngine := &webrtc.MediaEngine{}

View File

@@ -3,7 +3,6 @@ package webrtc
import (
"NanoKVM-Server/common"
"NanoKVM-Server/service/stream"
"sync"
"sync/atomic"
"time"
@@ -13,11 +12,13 @@ import (
)
func NewWebRTCManager() *WebRTCManager {
return &WebRTCManager{
m := &WebRTCManager{
clients: make(map[*websocket.Conn]*Client),
videoSending: 0,
mutex: sync.RWMutex{},
}
m.updateClientSnapshotLocked()
return m
}
func (m *WebRTCManager) AddClient(ws *websocket.Conn, client *Client) {
@@ -25,24 +26,42 @@ func (m *WebRTCManager) AddClient(ws *websocket.Conn, client *Client) {
m.mutex.Lock()
m.clients[ws] = client
count := m.updateClientSnapshotLocked()
m.mutex.Unlock()
log.Debugf("added client %s, total clients: %d", ws.RemoteAddr(), len(m.clients))
log.Debugf("added client %s, total clients: %d", ws.RemoteAddr(), count)
}
func (m *WebRTCManager) RemoveClient(ws *websocket.Conn) {
m.mutex.Lock()
delete(m.clients, ws)
count := m.updateClientSnapshotLocked()
m.mutex.Unlock()
log.Debugf("removed client %s, total clients: %d", ws.RemoteAddr(), len(m.clients))
log.Debugf("removed client %s, total clients: %d", ws.RemoteAddr(), count)
}
func (m *WebRTCManager) GetClientCount() int {
m.mutex.RLock()
defer m.mutex.RUnlock()
return len(m.getClients())
}
return len(m.clients)
func (m *WebRTCManager) updateClientSnapshotLocked() int {
clients := make([]*Client, 0, len(m.clients))
for _, client := range m.clients {
clients = append(clients, client)
}
m.clientSnapshot.Store(&clients)
return len(clients)
}
func (m *WebRTCManager) getClients() []*Client {
clients := m.clientSnapshot.Load()
if clients == nil {
return nil
}
return *clients
}
func (m *WebRTCManager) StartVideoStream() {
@@ -66,12 +85,14 @@ func (m *WebRTCManager) sendVideoStream() {
defer ticker.Stop()
for range ticker.C {
if m.GetClientCount() == 0 {
clients := m.getClients()
if len(clients) == 0 {
log.Debugf("stop sending h264 stream")
return
}
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
stream.UpdateCaptureStatus(stream.CaptureModeH264, result)
if result < 0 || len(data) == 0 {
continue
}
@@ -81,8 +102,12 @@ func (m *WebRTCManager) sendVideoStream() {
Duration: duration,
}
for _, client := range m.clients {
client.track.writeVideo(sample)
for _, client := range clients {
if err := client.track.writeVideoSample(sample); err != nil {
log.Errorf("failed to write h264 video to client: %s", err)
m.RemoveClient(client.WsConn())
client.Close()
}
}
if screen.FPS != fps && screen.FPS != 0 {

View File

@@ -11,7 +11,7 @@ func (t *Track) updateExtension() {
t.playoutDelayExtensionID = 5
}
if t.playoutDelayExtensionData == nil || len(t.playoutDelayExtensionData) == 0 {
if len(t.playoutDelayExtensionData) == 0 {
playoutDelay := &rtp.PlayoutDelayExtension{
MinDelay: 0,
MaxDelay: 0,
@@ -44,10 +44,3 @@ func (t *Track) writeVideoSample(sample media.Sample) error {
return nil
}
func (t *Track) writeVideo(sample media.Sample) {
err := t.writeVideoSample(sample)
if err != nil {
log.Errorf("failed to write h264 video: %s", err)
}
}

View File

@@ -2,6 +2,7 @@ package webrtc
import (
"sync"
"sync/atomic"
"github.com/gorilla/websocket"
"github.com/pion/rtp"
@@ -9,9 +10,10 @@ import (
)
type WebRTCManager struct {
clients map[*websocket.Conn]*Client
videoSending int32
mutex sync.RWMutex
clients map[*websocket.Conn]*Client
clientSnapshot atomic.Pointer[[]*Client]
videoSending int32
mutex sync.Mutex
}
type Client struct {
@@ -21,6 +23,10 @@ type Client struct {
mutex sync.Mutex
}
func (c *Client) WsConn() *websocket.Conn {
return c.ws
}
type SignalingHandler struct {
client *Client
}

View File

@@ -0,0 +1,111 @@
package ws
import (
"encoding/json"
"sort"
"sync"
"NanoKVM-Server/service/stream"
log "github.com/sirupsen/logrus"
)
var captureBroadcaster = newCaptureStatusBroadcaster(broadcastCaptureStatus)
func init() {
go captureBroadcaster.Run()
stream.SubscribeCaptureStatus(func(status stream.CaptureStatus) {
captureBroadcaster.Enqueue(status)
})
}
type captureStatusBroadcaster struct {
mutex sync.Mutex
pending map[string]stream.CaptureStatus
notify chan struct{}
broadcast func(stream.CaptureStatus)
}
func newCaptureStatusBroadcaster(broadcast func(stream.CaptureStatus)) *captureStatusBroadcaster {
return &captureStatusBroadcaster{
pending: make(map[string]stream.CaptureStatus),
notify: make(chan struct{}, 1),
broadcast: broadcast,
}
}
func (b *captureStatusBroadcaster) Enqueue(status stream.CaptureStatus) {
b.mutex.Lock()
b.pending[status.Mode] = status
b.mutex.Unlock()
select {
case b.notify <- struct{}{}:
default:
}
}
func (b *captureStatusBroadcaster) Run() {
for range b.notify {
for {
statuses := b.takePending()
if len(statuses) == 0 {
break
}
for _, status := range statuses {
b.broadcast(status)
}
}
}
}
func (b *captureStatusBroadcaster) takePending() []stream.CaptureStatus {
b.mutex.Lock()
defer b.mutex.Unlock()
if len(b.pending) == 0 {
return nil
}
modes := make([]string, 0, len(b.pending))
for mode := range b.pending {
modes = append(modes, mode)
}
sort.Strings(modes)
statuses := make([]stream.CaptureStatus, 0, len(modes))
for _, mode := range modes {
statuses = append(statuses, b.pending[mode])
delete(b.pending, mode)
}
return statuses
}
func sendCaptureStatusSnapshot(client *Client) {
for _, status := range stream.LatestCaptureStatuses() {
if err := sendCaptureStatus(client, status); err != nil {
log.Errorf("failed to send capture status snapshot: %s", err)
}
}
}
func broadcastCaptureStatus(status stream.CaptureStatus) {
for _, client := range GetManager().GetClients() {
if err := sendCaptureStatus(client, status); err != nil {
log.Errorf("failed to send capture status: %s", err)
}
}
}
func sendCaptureStatus(client *Client, status stream.CaptureStatus) error {
payload, err := json.Marshal(status)
if err != nil {
log.Errorf("failed to marshal capture status: %s", err)
return err
}
return client.Write(stream.CaptureStatusEvent, string(payload))
}

View File

@@ -37,5 +37,7 @@ func (s *Service) Connect(c *gin.Context) {
manager.AddClient(ws, client)
defer manager.RemoveClient(ws)
sendCaptureStatusSnapshot(client)
client.Start()
}

View File

@@ -61,7 +61,7 @@
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^17.3.0",
"msw": "^2.12.10",
"postcss": "^8.5.6",
"postcss": "^8.5.10",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^3.4.19",

20
web/pnpm-lock.yaml generated
View File

@@ -128,7 +128,7 @@ importers:
version: 4.7.0(vite@7.3.2(@types/node@25.3.2)(jiti@1.21.7)(lightningcss@1.32.0)(yaml@2.8.3))
autoprefixer:
specifier: ^10.4.27
version: 10.4.27(postcss@8.5.6)
version: 10.4.27(postcss@8.5.10)
eslint:
specifier: ^9.39.4
version: 9.39.4(jiti@1.21.7)
@@ -151,8 +151,8 @@ importers:
specifier: ^2.12.10
version: 2.12.10(@types/node@25.3.2)(typescript@5.9.3)
postcss:
specifier: ^8.5.6
version: 8.5.6
specifier: ^8.5.10
version: 8.5.10
prettier:
specifier: ^3.8.1
version: 3.8.1
@@ -2520,10 +2520,6 @@ packages:
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -4536,13 +4532,13 @@ snapshots:
asynckit@0.4.0: {}
autoprefixer@10.4.27(postcss@8.5.6):
autoprefixer@10.4.27(postcss@8.5.10):
dependencies:
browserslist: 4.28.1
caniuse-lite: 1.0.30001774
fraction.js: 5.3.4
picocolors: 1.1.1
postcss: 8.5.6
postcss: 8.5.10
postcss-value-parser: 4.2.0
available-typed-arrays@1.0.7:
@@ -6099,12 +6095,6 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
prelude-ls@1.2.1: {}
prettier-plugin-tailwindcss@0.6.14(@ianvs/prettier-plugin-sort-imports@4.7.1(prettier@3.8.1))(prettier@3.8.1):

6
web/pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,6 @@
allowBuilds:
bufferutil: true
es5-ext: true
esbuild: true
msw: true
utf-8-validate: true

View File

@@ -5,24 +5,23 @@
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/
const PACKAGE_VERSION = '2.6.0'
const INTEGRITY_CHECKSUM = '07a8241b182f8a246a7cd39894799a9e'
const PACKAGE_VERSION = '2.12.10'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
self.addEventListener('install', function () {
addEventListener('install', function () {
self.skipWaiting()
})
self.addEventListener('activate', function (event) {
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
self.addEventListener('message', async function (event) {
const clientId = event.source.id
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
@@ -72,11 +71,6 @@ self.addEventListener('message', async function (event) {
break
}
case 'MOCK_DEACTIVATE': {
activeClientIds.delete(clientId)
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
@@ -94,69 +88,92 @@ self.addEventListener('message', async function (event) {
}
})
self.addEventListener('fetch', function (event) {
const { request } = event
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (request.mode === 'navigate') {
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been deleted (still remains active until the next reload).
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
// Generate unique request ID.
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId))
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
async function handleRequest(event, requestId) {
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const response = await getResponse(event, client, requestId)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
;(async function () {
const responseClone = response.clone()
const serializedRequest = await serializeRequest(requestCloneForEvents)
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
requestId,
isMockedResponse: IS_MOCKED_RESPONSE in response,
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
body: responseClone.body,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
[responseClone.body],
)
})()
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
)
}
return response
}
// Resolve the main client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
@@ -184,20 +201,39 @@ async function resolveMainClient(event) {
})
}
async function getResponse(event, client, requestId) {
const { request } = event
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = request.clone()
const requestClone = event.request.clone()
function passthrough() {
const headers = Object.fromEntries(requestClone.headers.entries())
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove internal MSW request header so the passthrough request
// complies with any potential CORS preflight checks on the server.
// Some servers forbid unknown request headers.
delete headers['x-msw-intention']
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
@@ -216,29 +252,18 @@ async function getResponse(event, client, requestId) {
}
// Notify the client that a request has been intercepted.
const requestBuffer = await request.arrayBuffer()
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: requestBuffer,
keepalive: request.keepalive,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[requestBuffer],
[serializedRequest.body],
)
switch (clientMessage.type) {
@@ -254,6 +279,12 @@ async function getResponse(event, client, requestId) {
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
@@ -266,14 +297,18 @@ function sendToClient(client, message, transferrables = []) {
resolve(event.data)
}
client.postMessage(
message,
[channel.port2].concat(transferrables.filter(Boolean)),
)
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
async function respondWithMock(response) {
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
@@ -291,3 +326,24 @@ async function respondWithMock(response) {
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}

View File

@@ -109,14 +109,14 @@ export function sendStopMessage() {
return picoclawGateway.sendStopMessage();
}
export function closeGateway() {
export async function closeGateway() {
const activeSessionId = picoclawGateway.getSessionId();
picoclawGateway.close();
if (!activeSessionId) {
return;
}
void releaseRuntimeSession(activeSessionId).catch(() => undefined);
await releaseRuntimeSession(activeSessionId).catch(() => undefined);
}
export function onGatewayConnectionState(listener: (state: GatewayTransportState) => void) {

View File

@@ -77,7 +77,17 @@ const ca = {
frameDetect: 'Detecció de fotogrames',
frameDetectTip:
"Calcula la diferència entre fotogrames. S'atura la transmissió si no hi ha canvis a la pantalla de l'amfitrió remot.",
resetHdmi: 'Restablir HDMI'
resetHdmi: 'Restablir HDMI',
captureStatus: {
hdmiError: 'Error a la pantalla HDMI',
unsupportedResolution: 'La resolució actual no és compatible',
retrieving: "S'està obtenint la pantalla...",
changingResolution: "S'està canviant la resolució...",
updateFailed: 'La pantalla no es pot actualitzar ara',
videoError: 'Error de visualització de vídeo',
noHdmi: "No s'ha detectat cap senyal HDMI",
unavailable: 'La pantalla no es pot mostrar ara'
}
},
keyboard: {
title: 'Teclat',
@@ -532,6 +542,7 @@ const ca = {
success: 'PicoClaw instal·lat correctament',
failed: "No s'ha pogut instal·lar PicoClaw",
uninstalling: "S'està desinstal·lant el temps d'execució...",
uninstaling: "S'està desinstal·lant el temps d'execució...",
uninstalled: "El temps d'execució s'ha desinstal·lat correctament.",
uninstallFailed: 'La desinstal·lació ha fallat.',
requiredTitle: 'PicoClaw no està instal·lat',
@@ -541,6 +552,7 @@ const ca = {
preparing: 'Preparant',
downloading: "S'està baixant",
extracting: 'Extracció',
verifying: 'Verificant',
installing: 'Instal·lant',
installed: 'Instal·lat',
install_timeout: 'Temps esgotat',

View File

@@ -78,7 +78,17 @@ const cz = {
frameDetect: 'Detekce snímků',
frameDetectTip:
'Vypočítá rozdíl mezi snímky. Přenos video streamu se zastaví, pokud nejsou detekovány změny na obrazovce vzdáleného hostitele.',
resetHdmi: 'Resetovat HDMI'
resetHdmi: 'Resetovat HDMI',
captureStatus: {
hdmiError: 'Chyba obrazu HDMI',
unsupportedResolution: 'Aktuální rozlišení není podporováno',
retrieving: 'Načítá se obraz...',
changingResolution: 'Přepíná se rozlišení...',
updateFailed: 'Obraz se teď nemůže aktualizovat',
videoError: 'Chyba zobrazení videa',
noHdmi: 'Nebyl zjištěn signál HDMI',
unavailable: 'Obraz teď nelze zobrazit'
}
},
keyboard: {
title: 'Klávesnice',
@@ -546,6 +556,7 @@ const cz = {
preparing: 'Příprava',
downloading: 'Stahování',
extracting: 'Extrakce',
verifying: 'Ověřování',
installing: 'Instalace probíhá',
installed: 'Instalováno',
install_timeout: 'Vypršel časový limit',

View File

@@ -77,7 +77,17 @@ const da = {
frameDetect: 'Beregn frames',
frameDetectTip:
'Beregner forskellen mellem hver frame. Stopper med at sende et video stream hvis der ikke registreres ændringer på fjerncomputerens skærm.',
resetHdmi: 'Nulstil HDMI'
resetHdmi: 'Nulstil HDMI',
captureStatus: {
hdmiError: 'Fejl i HDMI-billedet',
unsupportedResolution: 'Den aktuelle opløsning understøttes ikke',
retrieving: 'Henter skærmbillede...',
changingResolution: 'Skifter opløsning...',
updateFailed: 'Skærmbilledet kan ikke opdateres lige nu',
videoError: 'Fejl i videovisning',
noHdmi: 'Intet HDMI-signal registreret',
unavailable: 'Skærmbilledet kan ikke vises lige nu'
}
},
keyboard: {
title: 'Tastatur',
@@ -545,6 +555,7 @@ const da = {
preparing: 'Forberedelse',
downloading: 'Downloader',
extracting: 'Udpakning',
verifying: 'Bekræfter',
installing: 'Installerer',
installed: 'Installeret',
install_timeout: 'Timeout',

View File

@@ -80,7 +80,17 @@ const de = {
frameDetect: 'Bilderkennung',
frameDetectTip:
'Berechnet den Unterschied zwischen den Einzelbildern. Beendet die Liveübertragung des Videostreams wenn keine Änderungen auf dem Bildschirm des Hosts festgestellt werden kann.',
resetHdmi: 'HDMI zurücksetzen'
resetHdmi: 'HDMI zurücksetzen',
captureStatus: {
hdmiError: 'HDMI-Bildschirmfehler',
unsupportedResolution: 'Die aktuelle Auflösung wird nicht unterstützt',
retrieving: 'Bildschirm wird abgerufen...',
changingResolution: 'Auflösung wird gewechselt...',
updateFailed: 'Der Bildschirm kann derzeit nicht aktualisiert werden',
videoError: 'Fehler bei der Videoanzeige',
noHdmi: 'Kein HDMI-Signal erkannt',
unavailable: 'Der Bildschirm kann derzeit nicht angezeigt werden'
}
},
keyboard: {
title: 'Tastatur',
@@ -551,6 +561,7 @@ const de = {
preparing: 'Vorbereiten',
downloading: 'Wird heruntergeladen',
extracting: 'Extrahieren',
verifying: 'Überprüfen',
installing: 'Installiere',
installed: 'Installiert',
install_timeout: 'Zeitüberschreitung',

View File

@@ -76,7 +76,17 @@ const en = {
frameDetect: 'Frame Detect',
frameDetectTip:
"Calculate the difference between frames. Stop transmitting video stream when no changes are detected on the remote host's screen.",
resetHdmi: 'Reset HDMI'
resetHdmi: 'Reset HDMI',
captureStatus: {
hdmiError: 'HDMI screen error',
unsupportedResolution: 'Current resolution is not supported',
retrieving: 'Getting screen...',
changingResolution: 'Switching resolution...',
updateFailed: 'Screen cannot update right now',
videoError: 'Video display error',
noHdmi: 'No HDMI signal detected',
unavailable: 'Screen cannot be displayed right now'
}
},
keyboard: {
title: 'Keyboard',
@@ -543,6 +553,7 @@ const en = {
preparing: 'Preparing',
downloading: 'Downloading',
extracting: 'Extracting',
verifying: 'Verifying',
installing: 'Installing',
installed: 'Installed',
install_timeout: 'Timed Out',

View File

@@ -78,7 +78,17 @@ const es = {
frameDetect: 'Detectar fotogramas',
frameDetectTip:
'Calcula la diferencia entre fotogramas. Para de transmitir vídeo cuando no se detectan cambios en la pantalla del host remoto.',
resetHdmi: 'Reiniciar HDMI'
resetHdmi: 'Reiniciar HDMI',
captureStatus: {
hdmiError: 'Error de imagen HDMI',
unsupportedResolution: 'La resolución actual no es compatible',
retrieving: 'Obteniendo pantalla...',
changingResolution: 'Cambiando resolución...',
updateFailed: 'La pantalla no puede actualizarse ahora',
videoError: 'Error de visualización de video',
noHdmi: 'No se detectó señal HDMI',
unavailable: 'La pantalla no puede mostrarse ahora'
}
},
keyboard: {
title: 'Teclado',
@@ -550,6 +560,7 @@ const es = {
preparing: 'Preparando',
downloading: 'Descargando',
extracting: 'Extrayendo',
verifying: 'Verificando',
installing: 'Instalando',
installed: 'Instalado',
install_timeout: 'Tiempo de espera agotado',

View File

@@ -80,7 +80,17 @@ const fr = {
frameDetect: 'Détection de trame',
frameDetectTip:
"Calcule la différence entre les images. Arrête la transmission du flux vidéo lorsqu'aucun changement n'est détecté sur l'écran de l'hôte distant",
resetHdmi: 'Réinitialiser le HDMI'
resetHdmi: 'Réinitialiser le HDMI',
captureStatus: {
hdmiError: 'Erreur d’image HDMI',
unsupportedResolution: 'La résolution actuelle n’est pas prise en charge',
retrieving: 'Récupération de l’image...',
changingResolution: 'Changement de résolution...',
updateFailed: 'L’image ne peut pas être mise à jour pour le moment',
videoError: 'Erreur d’affichage vidéo',
noHdmi: 'Aucun signal HDMI détecté',
unavailable: 'L’image ne peut pas être affichée pour le moment'
}
},
keyboard: {
title: 'Clavier',
@@ -551,6 +561,7 @@ const fr = {
preparing: 'Préparation',
downloading: 'Téléchargement',
extracting: 'Extraction',
verifying: 'Vérification',
installing: 'Installation',
installed: 'Installé',
install_timeout: 'Délai expiré',

View File

@@ -79,7 +79,17 @@ const hu = {
frameDetect: 'Képkocka-figyelés',
frameDetectTip:
'Elemzi a képkockák közötti különbségeket. A videó stream küldése leáll, ha a távoli gép képernyőjén nem történik változás.',
resetHdmi: 'HDMI visszaállítása'
resetHdmi: 'HDMI visszaállítása',
captureStatus: {
hdmiError: 'HDMI-képernyőhiba',
unsupportedResolution: 'A jelenlegi felbontás nem támogatott',
retrieving: 'Kép lekérése...',
changingResolution: 'Felbontás váltása...',
updateFailed: 'A kép jelenleg nem frissíthető',
videoError: 'Videómegjelenítési hiba',
noHdmi: 'Nem észlelhető HDMI-jel',
unavailable: 'A kép jelenleg nem jeleníthető meg'
}
},
keyboard: {
title: 'Billentyűzet',
@@ -549,6 +559,7 @@ const hu = {
preparing: 'Felkészülés',
downloading: 'Letöltés',
extracting: 'Kibontás',
verifying: 'Ellenőrzés',
installing: 'Telepítés folyamatban',
installed: 'Telepítve',
install_timeout: 'Időtúllépés',

View File

@@ -77,7 +77,17 @@ const id = {
frameDetect: 'Deteksi bingkai',
frameDetectTip:
'Hitung selisih antar frame. Hentikan transmisi aliran video saat tidak ada perubahan yang terdeteksi di layar host jarak jauh.',
resetHdmi: 'Atur ulang HDMI'
resetHdmi: 'Atur ulang HDMI',
captureStatus: {
hdmiError: 'Kesalahan layar HDMI',
unsupportedResolution: 'Resolusi saat ini tidak didukung',
retrieving: 'Mengambil layar...',
changingResolution: 'Mengganti resolusi...',
updateFailed: 'Layar tidak dapat diperbarui saat ini',
videoError: 'Kesalahan tampilan video',
noHdmi: 'Sinyal HDMI tidak terdeteksi',
unavailable: 'Layar tidak dapat ditampilkan saat ini'
}
},
keyboard: {
title: 'Keyboard',
@@ -545,6 +555,7 @@ const id = {
preparing: 'Mempersiapkan',
downloading: 'Mengunduh',
extracting: 'Mengekstraksi',
verifying: 'Memverifikasi',
installing: 'Memasangkan',
installed: 'Terpasang',
install_timeout: 'Waktu Habis',

View File

@@ -79,7 +79,17 @@ const it = {
frameDetect: 'Rilevamento Frame',
frameDetectTip:
'Calcola la differenza tra i frame. Interrompe la trasmissione del flusso video quando non vengono rilevate modifiche sullo schermo del dispositivo remoto.',
resetHdmi: 'Reimposta HDMI'
resetHdmi: 'Reimposta HDMI',
captureStatus: {
hdmiError: 'Errore schermata HDMI',
unsupportedResolution: 'La risoluzione attuale non è supportata',
retrieving: 'Acquisizione schermata...',
changingResolution: 'Cambio risoluzione...',
updateFailed: 'Lo schermo non può aggiornarsi al momento',
videoError: 'Errore di visualizzazione video',
noHdmi: 'Nessun segnale HDMI rilevato',
unavailable: 'Lo schermo non può essere visualizzato al momento'
}
},
keyboard: {
title: 'Tastiera',
@@ -549,6 +559,7 @@ const it = {
preparing: 'Preparazione',
downloading: 'Download in corso',
extracting: 'Estrazione',
verifying: 'Verifica in corso',
installing: 'Installazione in corso',
installed: 'Installato',
install_timeout: 'Timeout',

View File

@@ -77,7 +77,17 @@ const ja = {
frameDetect: 'フレーム差分検出',
frameDetectTip:
'フレーム間の差異を計算し、リモートホストの画面が変更されない場合はビデオストリームの送信を停止します',
resetHdmi: 'HDMI をリセット'
resetHdmi: 'HDMI をリセット',
captureStatus: {
hdmiError: 'HDMI 画面エラー',
unsupportedResolution: '現在の解像度はサポートされていません',
retrieving: '画面を取得中...',
changingResolution: '解像度を切り替え中...',
updateFailed: '現在、画面を更新できません',
videoError: '映像表示エラー',
noHdmi: 'HDMI 信号が検出されません',
unavailable: '現在、画面を表示できません'
}
},
keyboard: {
title: 'キーボード',
@@ -549,6 +559,7 @@ const ja = {
preparing: '準備中',
downloading: 'ダウンロード中',
extracting: '展開中',
verifying: '検証中',
installing: 'インストール中',
installed: 'インストール完了',
install_timeout: 'タイムアウト',

View File

@@ -76,7 +76,17 @@ const ko = {
frameDetect: '프레임 탐지',
frameDetectTip:
'프레임 간의 차이를 계산합니다. 원격 호스트 화면에 변경 사항이 감지되지 않으면 비디오 스트림 전송을 중지합니다.',
resetHdmi: 'HDMI 초기화'
resetHdmi: 'HDMI 초기화',
captureStatus: {
hdmiError: 'HDMI 화면 오류',
unsupportedResolution: '현재 해상도는 지원되지 않습니다',
retrieving: '화면을 가져오는 중...',
changingResolution: '해상도를 전환하는 중...',
updateFailed: '지금은 화면을 업데이트할 수 없습니다',
videoError: '비디오 표시 오류',
noHdmi: 'HDMI 신호가 감지되지 않았습니다',
unavailable: '지금은 화면을 표시할 수 없습니다'
}
},
keyboard: {
title: '키보드',
@@ -539,6 +549,7 @@ const ko = {
preparing: '준비 중',
downloading: '다운로드 중',
extracting: '압축 해제 중',
verifying: '검증 중',
installing: '설치 중',
installed: '설치 완료',
install_timeout: '시간 초과',

View File

@@ -78,7 +78,17 @@ const nb = {
frameDetect: 'Bildefrekvensoppdagelse',
frameDetectTip:
'Kalkuler forskjellen mellom bilder. Stopper overføring av video når det ikke oppdages forskjell på den eksterne vertens skjerm.',
resetHdmi: 'Tilbakestill HDMI'
resetHdmi: 'Tilbakestill HDMI',
captureStatus: {
hdmiError: 'HDMI-skjermfeil',
unsupportedResolution: 'Gjeldende oppløsning støttes ikke',
retrieving: 'Henter skjermbilde...',
changingResolution: 'Bytter oppløsning...',
updateFailed: 'Skjermen kan ikke oppdateres akkurat nå',
videoError: 'Feil ved videovisning',
noHdmi: 'Ingen HDMI-signal oppdaget',
unavailable: 'Skjermen kan ikke vises akkurat nå'
}
},
keyboard: {
title: 'Åpne tastatur',
@@ -545,6 +555,7 @@ const nb = {
preparing: 'Forbereder',
downloading: 'Laster ned',
extracting: 'Pakker ut',
verifying: 'Verifiserer',
installing: 'Installerer',
installed: 'Installert',
install_timeout: 'Tidsavbrudd',

View File

@@ -79,7 +79,17 @@ const nl = {
frameDetect: 'Frame detectie',
frameDetectTip:
'Berekent het verschil tussen frames. Stopt met het verzenden van de videostream wanneer er geen veranderingen worden gedetecteerd op het scherm van de externe host.',
resetHdmi: 'Reset HDMI'
resetHdmi: 'Reset HDMI',
captureStatus: {
hdmiError: 'HDMI-schermfout',
unsupportedResolution: 'De huidige resolutie wordt niet ondersteund',
retrieving: 'Scherm ophalen...',
changingResolution: 'Resolutie wisselen...',
updateFailed: 'Het scherm kan nu niet worden bijgewerkt',
videoError: 'Fout bij videoweergave',
noHdmi: 'Geen HDMI-signaal gedetecteerd',
unavailable: 'Het scherm kan nu niet worden weergegeven'
}
},
keyboard: {
title: 'Toetsenbord',
@@ -549,6 +559,7 @@ const nl = {
preparing: 'Voorbereiden',
downloading: 'Downloaden',
extracting: 'Uitpakken',
verifying: 'Verifiëren',
installing: 'Installeren',
installed: 'Geïnstalleerd',
install_timeout: 'Time-out',

View File

@@ -78,7 +78,17 @@ const pl = {
frameDetect: 'Wykrywanie klatek',
frameDetectTip:
'Obliczanie różnicy między klatkami. Zatrzymaj transmisję strumienia wideo, gdy na ekranie zdalnego hosta nie zostaną wykryte żadne zmiany.',
resetHdmi: 'Resetuj HDMI'
resetHdmi: 'Resetuj HDMI',
captureStatus: {
hdmiError: 'Błąd obrazu HDMI',
unsupportedResolution: 'Bieżąca rozdzielczość nie jest obsługiwana',
retrieving: 'Pobieranie obrazu...',
changingResolution: 'Przełączanie rozdzielczości...',
updateFailed: 'Nie można teraz zaktualizować obrazu',
videoError: 'Błąd wyświetlania wideo',
noHdmi: 'Nie wykryto sygnału HDMI',
unavailable: 'Nie można teraz wyświetlić obrazu'
}
},
keyboard: {
title: 'Klawiatura',
@@ -548,6 +558,7 @@ const pl = {
preparing: 'Przygotowanie',
downloading: 'Pobieranie',
extracting: 'Wypakowywanie',
verifying: 'Weryfikowanie',
installing: 'Instalowanie',
installed: 'Zainstalowano',
install_timeout: 'Upłynął limit czasu',

View File

@@ -77,7 +77,17 @@ const pt_br = {
frameDetect: 'Detecção de Quadros',
frameDetectTip:
'Calcular a diferença entre os quadros. Parar a transmissão de vídeo quando nenhuma alteração for detectada na tela do host remoto.',
resetHdmi: 'Redefinir HDMI'
resetHdmi: 'Redefinir HDMI',
captureStatus: {
hdmiError: 'Erro na imagem HDMI',
unsupportedResolution: 'A resolução atual não é compatível',
retrieving: 'Obtendo tela...',
changingResolution: 'Alterando resolução...',
updateFailed: 'A tela não pode ser atualizada agora',
videoError: 'Erro na exibição de vídeo',
noHdmi: 'Nenhum sinal HDMI detectado',
unavailable: 'A tela não pode ser exibida agora'
}
},
keyboard: {
title: 'Teclado',
@@ -546,6 +556,7 @@ const pt_br = {
preparing: 'Preparando',
downloading: 'Baixando',
extracting: 'Extraindo',
verifying: 'Verificando',
installing: 'Instalando',
installed: 'Instalado',
install_timeout: 'Tempo limite esgotado',

View File

@@ -77,7 +77,17 @@ const ru = {
frameDetect: 'Экономия трафика',
frameDetectTip:
'Вычисляет разницу между кадрами и прекращает передачу видеопотока, если на экране удаленного узла не обнаружено никаких изменений.',
resetHdmi: 'Перезагрузить HDMI подсистему'
resetHdmi: 'Перезагрузить HDMI подсистему',
captureStatus: {
hdmiError: 'Ошибка изображения HDMI',
unsupportedResolution: 'Текущее разрешение не поддерживается',
retrieving: 'Получение изображения...',
changingResolution: 'Переключение разрешения...',
updateFailed: 'Сейчас невозможно обновить изображение',
videoError: 'Ошибка отображения видео',
noHdmi: 'Сигнал HDMI не обнаружен',
unavailable: 'Сейчас невозможно отобразить изображение'
}
},
keyboard: {
title: 'Клавиатура',
@@ -547,6 +557,7 @@ const ru = {
preparing: 'Подготовка',
downloading: 'Загрузка',
extracting: 'Распаковка',
verifying: 'Проверка',
installing: 'Установка',
installed: 'Установлено',
install_timeout: 'Время ожидания истекло',

View File

@@ -75,7 +75,17 @@ const se = {
frameDetect: 'Ramdetection',
frameDetectTip:
'Beräkna skillnaden mellan ramar. Sluta skicka videoström när inga förändringar upptäcks på fjärrvärdens skärm.',
resetHdmi: 'Återställ HDMI'
resetHdmi: 'Återställ HDMI',
captureStatus: {
hdmiError: 'HDMI-skärmfel',
unsupportedResolution: 'Den aktuella upplösningen stöds inte',
retrieving: 'Hämtar skärmen...',
changingResolution: 'Byter upplösning...',
updateFailed: 'Skärmen kan inte uppdateras just nu',
videoError: 'Videovisningsfel',
noHdmi: 'Ingen HDMI-signal upptäcktes',
unavailable: 'Skärmen kan inte visas just nu'
}
},
keyboard: {
title: 'Tangentbord',
@@ -541,6 +551,7 @@ const se = {
preparing: 'Förbereder',
downloading: 'Laddar ner',
extracting: 'Packar upp',
verifying: 'Verifierar',
installing: 'Installerar',
installed: 'Installerad',
install_timeout: 'Timeout',

View File

@@ -75,7 +75,17 @@ const th = {
frameDetect: 'ตรวจจับเฟรม',
frameDetectTip:
'ระบบจะคำนวณความแตกต่างระหว่างเฟรม และหยุดส่งสตรีมวิดีโอเมื่อไม่พบการเปลี่ยนแปลงบนหน้าจอของคอมพิวเตอร์ต้นทาง',
resetHdmi: 'รีเช็ท HDMI'
resetHdmi: 'รีเช็ท HDMI',
captureStatus: {
hdmiError: 'ข้อผิดพลาดหน้าจอ HDMI',
unsupportedResolution: 'ไม่รองรับความละเอียดปัจจุบัน',
retrieving: 'กำลังดึงหน้าจอ...',
changingResolution: 'กำลังเปลี่ยนความละเอียด...',
updateFailed: 'ไม่สามารถอัปเดตหน้าจอได้ในขณะนี้',
videoError: 'ข้อผิดพลาดในการแสดงวิดีโอ',
noHdmi: 'ไม่พบสัญญาณ HDMI',
unavailable: 'ไม่สามารถแสดงหน้าจอได้ในขณะนี้'
}
},
keyboard: {
title: 'คีบอร์ด',
@@ -538,6 +548,7 @@ const th = {
preparing: 'กำลังเตรียมการ',
downloading: 'กำลังดาวน์โหลด',
extracting: 'กำลังแตกไฟล์',
verifying: 'กำลังตรวจสอบ',
installing: 'กำลังติดตั้ง',
installed: 'ติดตั้งแล้ว',
install_timeout: 'หมดเวลา',

View File

@@ -78,7 +78,17 @@ const tr = {
frameDetect: 'Kareleri algıla',
frameDetectTip:
'Gönderilen kareler arasındaki farkı hesaplar. Uzak ana bilgisayardan gönderilen yayında bir değişiklik yoksa görüntü yayınını durdurur.',
resetHdmi: 'HDMI sıfırla'
resetHdmi: 'HDMI sıfırla',
captureStatus: {
hdmiError: 'HDMI ekran hatası',
unsupportedResolution: 'Geçerli çözünürlük desteklenmiyor',
retrieving: 'Ekran alınıyor...',
changingResolution: 'Çözünürlük değiştiriliyor...',
updateFailed: 'Ekran şu anda güncellenemiyor',
videoError: 'Video görüntüleme hatası',
noHdmi: 'HDMI sinyali algılanmadı',
unavailable: 'Ekran şu anda gösterilemiyor'
}
},
keyboard: {
title: 'Klavye',
@@ -545,6 +555,7 @@ const tr = {
preparing: 'Hazırlanıyor',
downloading: 'İndiriliyor',
extracting: 'Çıkarılıyor',
verifying: 'Doğrulanıyor',
installing: 'Yükleniyor',
installed: 'Yüklendi',
install_timeout: 'Zaman Aşımı',

View File

@@ -78,7 +78,17 @@ const uk = {
frameDetect: 'Виявлення кадрів',
frameDetectTip:
'Обчислює різницю між кадрами. Зупиняє передачу відеопотоку, коли на екрані віддаленого хоста не виявлено змін.',
resetHdmi: 'Перезавантажити HDMI підсистему'
resetHdmi: 'Перезавантажити HDMI підсистему',
captureStatus: {
hdmiError: 'Помилка зображення HDMI',
unsupportedResolution: 'Поточна роздільна здатність не підтримується',
retrieving: 'Отримання зображення...',
changingResolution: 'Перемикання роздільної здатності...',
updateFailed: 'Зараз неможливо оновити зображення',
videoError: 'Помилка відображення відео',
noHdmi: 'Сигнал HDMI не виявлено',
unavailable: 'Зараз неможливо показати зображення'
}
},
keyboard: {
title: 'Клавіатура',
@@ -546,6 +556,7 @@ const uk = {
preparing: 'Підготовка',
downloading: 'Завантаження',
extracting: 'Розпакування',
verifying: 'Перевірка',
installing: 'Встановлення',
installed: 'Встановлено',
install_timeout: 'Час очікування минув',

View File

@@ -77,7 +77,17 @@ const vi = {
frameDetect: 'Phát hiện khung hình',
frameDetectTip:
'Tính toán sự khác biệt giữa các khung hình. Dừng truyền video khi không có thay đổi trên màn hình máy chủ từ xa.',
resetHdmi: 'Đặt lại HDMI'
resetHdmi: 'Đặt lại HDMI',
captureStatus: {
hdmiError: 'Lỗi màn hình HDMI',
unsupportedResolution: 'Độ phân giải hiện tại không được hỗ trợ',
retrieving: 'Đang lấy màn hình...',
changingResolution: 'Đang chuyển độ phân giải...',
updateFailed: 'Hiện không thể cập nhật màn hình',
videoError: 'Lỗi hiển thị video',
noHdmi: 'Không phát hiện tín hiệu HDMI',
unavailable: 'Hiện không thể hiển thị màn hình'
}
},
keyboard: {
title: 'Bàn phím',
@@ -544,6 +554,7 @@ const vi = {
preparing: 'Đang chuẩn bị',
downloading: 'Đang tải xuống',
extracting: 'Đang giải nén',
verifying: 'Đang xác minh',
installing: 'Đang cài đặt',
installed: 'Đã cài đặt',
install_timeout: 'Đã hết thời gian',

View File

@@ -73,7 +73,17 @@ const zh = {
qualityLow: '低',
frameDetect: '帧差检测',
frameDetectTip: '计算帧之间的差异,当检测到远程主机画面不变时,停止传输视频流',
resetHdmi: '重置 HDMI'
resetHdmi: '重置 HDMI',
captureStatus: {
hdmiError: 'HDMI 画面异常',
unsupportedResolution: '当前分辨率不支持',
retrieving: '正在获取画面...',
changingResolution: '正在切换分辨率...',
updateFailed: '画面暂时无法更新',
videoError: '视频显示异常',
noHdmi: '未检测到 HDMI 信号',
unavailable: '画面暂时无法显示'
}
},
keyboard: {
title: '键盘',
@@ -533,6 +543,7 @@ const zh = {
preparing: '准备中',
downloading: '下载中',
extracting: '解压中',
verifying: '验证中',
installing: '安装中',
installed: '已安装',
install_timeout: '已超时',

View File

@@ -73,7 +73,17 @@ const zh_tw = {
qualityLow: '低',
frameDetect: '影格檢測',
frameDetectTip: '計算影格之間的差異。當遠端主機畫面未偵測到任何變更時,停止視訊傳輸串流。',
resetHdmi: '重置 HDMI'
resetHdmi: '重置 HDMI',
captureStatus: {
hdmiError: 'HDMI 畫面異常',
unsupportedResolution: '目前解析度不支援',
retrieving: '正在取得畫面...',
changingResolution: '正在切換解析度...',
updateFailed: '畫面暫時無法更新',
videoError: '影片顯示異常',
noHdmi: '未偵測到 HDMI 訊號',
unavailable: '畫面暫時無法顯示'
}
},
keyboard: {
title: '鍵盤',
@@ -533,6 +543,7 @@ const zh_tw = {
preparing: '準備中',
downloading: '下載中',
extracting: '解壓縮中',
verifying: '驗證中',
installing: '安裝中',
installed: '已安裝',
install_timeout: '超時',

View File

@@ -0,0 +1,2 @@
export { CaptureStatusOverlay } from './overlay';
export { useCaptureStatus } from './use-capture-status';

View File

@@ -0,0 +1,74 @@
export const CAPTURE_STATUS_EVENT = 'capture-status';
export type CaptureStatus = {
ok: boolean;
result: number;
message: string;
mode: string;
severity: 'error' | 'warning' | '';
updatedAt: string;
};
export function parseCaptureStatusMessage(message: { data: unknown }): CaptureStatus | null {
if (typeof message.data !== 'string') {
return null;
}
try {
const envelope = JSON.parse(message.data) as { data?: unknown };
if (typeof envelope.data !== 'string') {
return null;
}
const status = JSON.parse(envelope.data) as Partial<CaptureStatus>;
if (typeof status.ok !== 'boolean' || typeof status.result !== 'number') {
return null;
}
return {
ok: status.ok,
result: status.result,
message: typeof status.message === 'string' ? status.message : '',
mode: typeof status.mode === 'string' ? status.mode : '',
severity: status.severity === 'warning' ? 'warning' : status.ok ? '' : 'error',
updatedAt: typeof status.updatedAt === 'string' ? status.updatedAt : ''
};
} catch (error) {
console.log(error);
return null;
}
}
export function getCaptureStatusMessageKey(result: number) {
switch (result) {
case -7:
return 'screen.captureStatus.hdmiError';
case -6:
return 'screen.captureStatus.unsupportedResolution';
case -5:
return 'screen.captureStatus.retrieving';
case -4:
return 'screen.captureStatus.changingResolution';
case -3:
return 'screen.captureStatus.updateFailed';
case -2:
return 'screen.captureStatus.videoError';
case -1:
return 'screen.captureStatus.noHdmi';
default:
return result < 0 ? 'screen.captureStatus.unavailable' : '';
}
}
export function shouldAcceptCaptureStatus(current: CaptureStatus | null, next: CaptureStatus) {
if (!current) {
return true;
}
return getCaptureStatusTimestamp(next) >= getCaptureStatusTimestamp(current);
}
function getCaptureStatusTimestamp(status: CaptureStatus) {
const timestamp = Date.parse(status.updatedAt);
return Number.isFinite(timestamp) ? timestamp : 0;
}

View File

@@ -0,0 +1,30 @@
import { AlertCircle, AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { CaptureStatus, getCaptureStatusMessageKey } from './model';
export function CaptureStatusOverlay({ status }: { status: CaptureStatus | null }) {
const { t } = useTranslation();
if (!status || status.ok) {
return null;
}
const messageKey = getCaptureStatusMessageKey(status.result);
const isWarning = status.severity === 'warning';
const toneClass = isWarning
? 'border-amber-500/50 bg-neutral-900/90 text-amber-400 shadow-amber-900/20'
: 'border-red-500/50 bg-neutral-900/90 text-red-400 shadow-red-900/20';
const Icon = isWarning ? AlertTriangle : AlertCircle;
return (
<div
className={`pointer-events-none absolute left-1/2 top-1/2 z-10 flex max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 items-center gap-2.5 rounded-lg border px-4 py-2.5 text-sm font-medium shadow-xl backdrop-blur-md ${toneClass}`}
>
<Icon className="h-5 w-5 shrink-0 animate-pulse" />
<span className="min-w-0 truncate">{t(messageKey)}</span>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { useEffect, useRef, useState } from 'react';
import { client } from '@/lib/websocket.ts';
import {
CAPTURE_STATUS_EVENT,
CaptureStatus,
parseCaptureStatusMessage,
shouldAcceptCaptureStatus
} from './model';
export function useCaptureStatus(activeVideoMode: string) {
const [captureStatus, setCaptureStatus] = useState<CaptureStatus | null>(null);
const latestStatusRef = useRef<CaptureStatus | null>(null);
useEffect(() => {
latestStatusRef.current = null;
setCaptureStatus(null);
if (!activeVideoMode) {
return;
}
return client.on(CAPTURE_STATUS_EVENT, (message) => {
const status = parseCaptureStatusMessage(message);
if (!status || status.mode !== activeVideoMode) {
return;
}
if (!shouldAcceptCaptureStatus(latestStatusRef.current, status)) {
return;
}
latestStatusRef.current = status;
setCaptureStatus(status.ok ? null : status);
});
}, [activeVideoMode]);
return captureStatus;
}

View File

@@ -10,6 +10,7 @@ import { picoclawChatOpenAtom } from '@/jotai/picoclaw.ts';
import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts';
import { Head } from '@/components/head.tsx';
import { CaptureStatusOverlay, useCaptureStatus } from './capture-status';
import { Keyboard } from './keyboard';
import { Menu } from './menu';
import { Mouse } from './mouse';
@@ -19,10 +20,26 @@ import { ActionOverlay } from './picoclaw/action-overlay.tsx';
import { Screen } from './screen';
import { VirtualKeyboard } from './virtual-keyboard';
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;
}
export const Desktop = () => {
const { t } = useTranslation();
const isBigScreen = useMediaQuery({ minWidth: 850 });
const [activeVideoMode] = useState(getVideoMode);
const [picoclawSidebarWidth, setPicoclawSidebarWidth] = useState(420);
const captureStatus = useCaptureStatus(activeVideoMode);
const [videoMode, setVideoMode] = useAtom(videoModeAtom);
const [resolution, setResolution] = useAtom(resolutionAtom);
@@ -31,8 +48,7 @@ export const Desktop = () => {
useEffect(() => {
client.connect();
const mode = getVideoMode();
setVideoMode(mode);
setVideoMode(activeVideoMode);
const res = storage.getResolution() || { width: 0, height: 0 };
setResolution(res);
@@ -40,21 +56,7 @@ export const Desktop = () => {
return () => {
client.close();
};
}, []);
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;
}
}, [activeVideoMode, setResolution, setVideoMode]);
function handleSplitterResize(sizes: number[]) {
const nextSidebarWidth = sizes[1];
@@ -81,6 +83,7 @@ export const Desktop = () => {
<Splitter.Panel min="45%">
<div className="relative h-full min-h-0 w-full min-w-0 overflow-hidden bg-black">
<Screen />
<CaptureStatusOverlay status={captureStatus} />
</div>
</Splitter.Panel>
<Splitter.Panel

View File

@@ -94,8 +94,8 @@ export const Menu = () => {
{isEnabled('image') && <Image />}
{isEnabled('download') && <DownloadImage />}
{isEnabled('script') && <Script />}
{isEnabled('terminal') && <Terminal />}
{isEnabled('script') && <Script />}
{isEnabled('wol') && <Wol />}
{['image', 'download', 'script', 'terminal', 'wol'].some(isEnabled) && (

View File

@@ -1,4 +1,3 @@
import { Divider } from 'antd';
import { KeyboardIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -17,9 +16,6 @@ export const Keyboard = () => {
<Paste />
<VirtualKeyboard />
<Shortcuts />
<Divider style={{ margin: '5px 0', opacity: 0.5 }} />
<LeaderKey />
</div>
);

View File

@@ -1,5 +1,5 @@
import { GithubOutlined, XOutlined } from '@ant-design/icons';
import { BookOpenIcon, MessageCircleQuestionIcon, MessageSquareIcon } from 'lucide-react';
import { DiscordOutlined, GithubOutlined, XOutlined } from '@ant-design/icons';
import { BookOpenIcon, MessageCircleQuestionIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export const Community = () => {
@@ -18,9 +18,9 @@ export const Community = () => {
url: 'https://twitter.com/SipeedIO'
},
{
name: 'Discussion',
icon: <MessageSquareIcon size={24} />,
url: 'https://maixhub.com/discussion/nanokvm'
name: 'Discord',
icon: <DiscordOutlined style={{ fontSize: '20px' }} width={24} height={24} />,
url: 'https://discord.gg/V4sAZ9XWpN'
},
{
name: 'FAQ',

View File

@@ -24,8 +24,8 @@ export const MenuIcons = () => {
const items = [
{ key: 'image', icon: <DiscIcon size={16} /> },
{ key: 'download', icon: <DownloadIcon size={16} /> },
{ key: 'script', icon: <FileJsonIcon size={16} /> },
{ key: 'terminal', icon: <TerminalSquareIcon size={16} /> },
{ key: 'script', icon: <FileJsonIcon size={16} /> },
{ key: 'wol', icon: <NetworkIcon size={16} /> },
{ key: 'picoclaw', icon: <Robot size={16} /> },
{ key: 'power', icon: <PowerIcon size={16} /> },

View File

@@ -56,33 +56,37 @@ export const Wol = () => {
const isEdit = false;
const macList = rsp.data.macs
.filter((item: string) => item.trim() !== '')
.map((item: string) => item.trim())
.filter((item: string) => item !== '')
.map((item: string) => {
const parts = item.split(' ');
const isName = parts.length > 1;
const name = isName ? parts[1] : '';
const mac = parts[0];
const separator = item.search(/\s/);
const mac = separator === -1 ? item : item.slice(0, separator);
const name = separator === -1 ? '' : item.slice(separator).trim();
const isName = name !== '';
const isShow = !isName;
return { name, mac, isShow, isName, isEdit };
});
setMacList([]);
setMacList(macList);
});
}
function toggleShow(mac: string) {
setMacList(
macList.map((item) => (item.mac === mac ? { ...item, isShow: !item.isShow } : item))
setMacList((list) =>
list.map((item) => (item.mac === mac ? { ...item, isShow: !item.isShow } : item))
);
}
function editMac(mac: string, isEdit: boolean) {
setMacList(macList.map((item) => (item.mac === mac ? { ...item, isEdit: !isEdit } : item)));
setMacList((list) =>
list.map((item) => (item.mac === mac ? { ...item, isEdit: !isEdit } : item))
);
}
async function setMacName(e: React.KeyboardEvent<HTMLInputElement>, mac: string) {
const value: string = e.currentTarget.value;
const value = e.currentTarget.value.trim();
if (!value) return;
const rsp = await setWolMacName(mac, value);
if (rsp.code !== 0) {
console.log(rsp.msg);
@@ -102,7 +106,7 @@ export const Wol = () => {
function wake(mac?: string) {
if (status === 'loading') return;
const value = mac ? mac : input;
const value = (mac ? mac : input).trim();
if (!value) return;
setStatus('loading');
@@ -123,6 +127,7 @@ export const Wol = () => {
})
.catch(() => {
setStatus('failed');
setLog(t('auth.error'));
});
}
@@ -134,15 +139,20 @@ export const Wol = () => {
<Divider style={{ margin: '10px 0 10px 0' }} />
<div className="pb-1 text-neutral-500">{t('wol.input')}</div>
<div className="flex items-center space-x-1">
<Input ref={inputRef} value={input} onChange={handleChange} onPressEnter={() => wake()} />
<Button type="primary" onClick={() => wake()}>
{t('wol.ok')}
</Button>
</div>
<div className="w-full space-y-1 py-3">
<div className="flex items-center space-x-1">
<Input
ref={inputRef}
value={input}
placeholder={t('wol.input')}
onChange={handleChange}
onPressEnter={() => wake()}
/>
<Button type="primary" onClick={() => wake()}>
{t('wol.ok')}
</Button>
</div>
<div className={clsx('py-2')}>
{status && (
<div
className={clsx(
@@ -155,58 +165,60 @@ export const Wol = () => {
)}
</div>
<List
itemLayout="horizontal"
locale={{ emptyText: ' ' }}
dataSource={macList}
renderItem={(item) => (
<List.Item className="flex w-full items-center justify-between">
<div className="h-[24px] max-w-[200px]">
{item.isEdit ? (
<Input
onFocus={() => setIsKeyboardEnable(false)}
onBlur={() => setIsKeyboardEnable(true)}
defaultValue={item.name}
onPressEnter={(e) => setMacName(e, item.mac)}
/>
) : item.isShow ? (
item.mac
) : (
item.name
)}
</div>
{macList.length > 0 && (
<List
itemLayout="horizontal"
dataSource={macList}
renderItem={(item) => (
<List.Item className="flex w-full items-center justify-between">
<div className="h-[24px] max-w-[200px]">
{item.isEdit ? (
<Input
placeholder={item.mac}
onFocus={() => setIsKeyboardEnable(false)}
onBlur={() => setIsKeyboardEnable(true)}
defaultValue={item.name}
onPressEnter={(e) => setMacName(e, item.mac)}
/>
) : item.isShow ? (
item.mac
) : (
item.name
)}
</div>
<div className="flex items-center space-x-1">
{item.isName && (
<div className="flex items-center space-x-1">
{item.isName && (
<div
className="text-500 flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded hover:bg-neutral-700/80"
onClick={() => toggleShow(item.mac)}
>
{item.isShow ? <EyeClosed size={16} /> : <Eye size={16} />}
</div>
)}
<div
className="text-500 flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded hover:bg-neutral-700/80"
onClick={() => toggleShow(item.mac)}
className="text-500 flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded hover:bg-neutral-700"
onClick={() => editMac(item.mac, item.isEdit)}
>
{item.isShow ? <EyeClosed size={16} /> : <Eye size={16} />}
<Pencil size={16} />
</div>
<div
className="flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded text-green-500 hover:bg-neutral-700/80"
onClick={() => wake(item.mac)}
>
<SendIcon size={16} />
</div>
<div
className="flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded text-red-500 hover:bg-neutral-700"
onClick={() => deleteMac(item.mac)}
>
<Trash2Icon size={16} />
</div>
)}
<div
className="text-500 flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded hover:bg-neutral-700"
onClick={() => editMac(item.mac, item.isEdit)}
>
<Pencil size={16} />
</div>
<div
className="flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded text-green-500 hover:bg-neutral-700/80"
onClick={() => wake(item.mac)}
>
<SendIcon size={16} />
</div>
<div
className="flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded text-red-500 hover:bg-neutral-700"
onClick={() => deleteMac(item.mac)}
>
<Trash2Icon size={16} />
</div>
</div>
</List.Item>
)}
/>
</List.Item>
)}
/>
)}
</div>
);

View File

@@ -234,7 +234,7 @@ export const Absolute = () => {
longPressTimerRef.current = null;
}
if (pressedButtonRef.current) {
if (pressedButtonRef.current !== null) {
handleMouseEvent({ type: 'mouseup', button: pressedButtonRef.current! });
}

View File

@@ -33,7 +33,7 @@ export const Relative = () => {
screen.addEventListener('mouseup', handleMouseUp);
screen.addEventListener('mousemove', handleMouseMove);
screen.addEventListener('wheel', handleMouseWheel, { passive: false });
screen.addEventListener('contextmenu', (e) => e.preventDefault());
screen.addEventListener('contextmenu', disableEvent);
document.addEventListener('pointerlockchange', handlePointerLockChange);
// Mouse click event

View File

@@ -10,9 +10,6 @@ type MessageProps = {
message: PicoclawChatMessage;
};
const hiddenAssistantMessagePrefix = '🔧 `message`';
const nullToolFeedbackPattern = /^🔧\s*`[^`]+`\s*```(?:[\w-]+)?\s*null\s*```$/s;
function extractDisplayText(value: unknown): string {
if (typeof value === 'string') {
const normalized = normalizeLiteralEmptyText(value);
@@ -77,15 +74,17 @@ function normalizeLiteralEmptyText(value: string): string {
return trimmed;
}
function shouldHideAssistantMessage(value?: string): boolean {
const normalized = normalizeLiteralEmptyText(value || '');
if (!normalized) {
function isThoughtMessage(raw: unknown): boolean {
if (!raw || typeof raw !== 'object') {
return false;
}
return (
normalized.startsWith(hiddenAssistantMessagePrefix) || nullToolFeedbackPattern.test(normalized)
);
const payload = (raw as Record<string, unknown>).payload;
if (!payload || typeof payload !== 'object') {
return false;
}
return (payload as Record<string, unknown>).kind === 'thought';
}
export const Message = ({ message }: MessageProps) => {
@@ -153,8 +152,17 @@ export const Message = ({ message }: MessageProps) => {
);
}
if (message.kind === 'assistant' && shouldHideAssistantMessage(message.text)) {
return null;
if (message.kind === 'assistant' && isThoughtMessage(message.raw)) {
const thoughtText = extractDisplayText(message.text);
if (!thoughtText) {
return null;
}
return (
<div className="border-white/5 rounded-xl border bg-white/[0.02] px-3 py-2 text-xs text-neutral-400">
<MarkdownContent content={thoughtText} className="break-words" />
</div>
);
}
const isUser = message.kind === 'user';

View File

@@ -145,7 +145,7 @@ export function createPicoclawSidebarActions(options: PicoclawSidebarActionOptio
]);
if (isRuntimeReady) {
closeGateway();
void closeGateway();
setTakeover({
active: false,
sessionId: picoclawGateway.getSessionId(),
@@ -194,7 +194,7 @@ export function createPicoclawSidebarActions(options: PicoclawSidebarActionOptio
]);
if (isRuntimeReady) {
closeGateway();
void closeGateway();
setTakeover({
active: false,
sessionId: picoclawGateway.getSessionId(),

View File

@@ -228,7 +228,7 @@ export function usePicoclawSidebarLifecycle({
return () => {
cancelled = true;
closeGateway();
void closeGateway();
setTakeover((current) => ({
...current,
active: false,

View File

@@ -61,8 +61,8 @@ export const SidebarHistory = ({
}
return (
<div className="picoclaw-sidebar-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto px-3 py-3">
<div className="space-y-2">
<div className="picoclaw-sidebar-scrollbar box-border flex min-h-0 w-full flex-1 flex-col overflow-x-hidden overflow-y-auto px-3 py-3">
<div className="box-border w-full max-w-full space-y-2">
{sessions.map((session) => {
const isActive = session.id === activeSessionId;
return (
@@ -86,7 +86,7 @@ export const SidebarHistory = ({
}
}}
className={[
'group flex w-full items-start gap-3 rounded-2xl border px-3 py-3 text-left transition-colors',
'group box-border flex w-full items-start gap-3 rounded-2xl border px-3 py-3 text-left transition-colors',
isSwitching ? 'pointer-events-none opacity-60' : '',
isActive
? 'border-sky-400/30 bg-sky-400/10'
@@ -98,7 +98,7 @@ export const SidebarHistory = ({
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-neutral-200">
{session.title}
</div>

View File

@@ -32,7 +32,6 @@ type TakeoverSetter = Dispatch<SetStateAction<PicoclawTakeoverState>>;
type PicoclawSidebarSessionActionOptions = {
t: TFunction;
runtimeStatus: PicoclawRuntimeStatus | null;
transportState: PicoclawTransportState;
runState: PicoclawRunState;
config: PicoclawConfigState;
@@ -59,7 +58,6 @@ type PicoclawSidebarSessionActionOptions = {
export function createPicoclawSidebarSessionActions(options: PicoclawSidebarSessionActionOptions) {
const {
t,
runtimeStatus,
transportState,
runState,
config,
@@ -142,7 +140,7 @@ export function createPicoclawSidebarSessionActions(options: PicoclawSidebarSess
return;
}
closeGateway();
const closePromise = closeGateway();
setIsHistoryOpen(false);
setIsModelConfigOpen(false);
setMessages([]);
@@ -159,6 +157,7 @@ export function createPicoclawSidebarSessionActions(options: PicoclawSidebarSess
setActiveSessionId(nextSessionId);
setIsFreshConversation(true);
await closePromise;
const nextRuntimeStatus = await refreshState();
if (!canConnectGateway(nextRuntimeStatus ?? null)) {
return;
@@ -225,7 +224,7 @@ export function createPicoclawSidebarSessionActions(options: PicoclawSidebarSess
})
);
closeGateway();
const closePromise = closeGateway();
setMessages(loadedMessages);
setActiveSessionId(sessionId);
setIsFreshConversation(false);
@@ -238,6 +237,9 @@ export function createPicoclawSidebarSessionActions(options: PicoclawSidebarSess
setOverlay(HIDDEN_OVERLAY);
setTransportState('disconnected');
setRunState('idle');
await closePromise;
const nextRuntimeStatus = await refreshState();
setRuntimeStatus((current) =>
current
? {
@@ -247,12 +249,14 @@ export function createPicoclawSidebarSessionActions(options: PicoclawSidebarSess
: current
);
if (canConnectGateway(runtimeStatus ?? null)) {
try {
await connectGateway(sessionId);
} catch {
// handled by gateway events
}
if (!canConnectGateway(nextRuntimeStatus ?? null)) {
return;
}
try {
await connectGateway(sessionId);
} catch {
// handled by gateway events
}
} catch (error) {
const errorMessage =

View File

@@ -107,7 +107,6 @@ export const useSidebar = () => {
refreshStateRef.current = actions.refreshState;
const sessionActions = createPicoclawSidebarSessionActions({
t,
runtimeStatus,
transportState,
runState,
config,

View File

@@ -3,9 +3,17 @@ import Queue from 'yocto-queue';
let canvas: OffscreenCanvas | null = null;
let ctx: OffscreenCanvasRenderingContext2D | null = null;
let rendering: boolean = false;
let flushScheduled: boolean = false;
let decoder: VideoDecoder | null = null;
const maxQueuedFrames = 3;
const frameQueue = new Queue<VideoFrame>();
const frameChannel = new MessageChannel();
frameChannel.port1.onmessage = () => {
flushScheduled = false;
processFrameQueue();
};
self.onmessage = (event: MessageEvent) => {
const { type, data, canvas: offscreenCanvas } = event.data;
@@ -61,13 +69,13 @@ function initializeDecoder() {
const init = {
output: (frame: VideoFrame) => {
frameQueue.enqueue(frame);
if (frameQueue.size >= 10) {
while (frameQueue.size > maxQueuedFrames) {
frameQueue.dequeue()?.close();
}
if (!rendering) {
rendering = true;
processFrameQueue();
scheduleFrameQueue();
}
},
error: () => {
@@ -110,12 +118,21 @@ function processFrameQueue() {
}
if (frameQueue.size > 0) {
setTimeout(processFrameQueue, 0);
scheduleFrameQueue();
} else {
rendering = false;
}
}
function scheduleFrameQueue() {
if (flushScheduled) {
return;
}
flushScheduled = true;
frameChannel.port2.postMessage(null);
}
function renderFrame(frame: VideoFrame) {
if (!canvas || !ctx) {
frame.close();
@@ -142,6 +159,7 @@ function resetDecoder() {
decoder = null;
rendering = false;
flushScheduled = false;
Array.from(frameQueue.drain()).forEach((frame) => frame.close());
}

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { Spin } from 'antd';
import clsx from 'clsx';
import { useAtom, useAtomValue } from 'jotai';
import { w3cwebsocket as W3cWebSocket } from 'websocket';
@@ -8,6 +9,19 @@ import { getBaseUrl } from '@/lib/service.ts';
import { mouseStyleAtom } from '@/jotai/mouse.ts';
import { resolutionAtom, videoScaleAtom } from '@/jotai/screen.ts';
type SignalingMessage = {
event?: string;
data?: string;
};
const parseSignalingData = <T,>(data?: string): T | null => {
if (!data) {
return null;
}
return JSON.parse(data) as T;
};
export const H264Webrtc = () => {
const resolution = useAtomValue(resolutionAtom);
const mouseStyle = useAtomValue(mouseStyleAtom);
@@ -21,120 +35,11 @@ export const H264Webrtc = () => {
useEffect(() => {
const url = `${getBaseUrl('ws')}/api/stream/h264`;
const ws = new W3cWebSocket(url);
const videoElement = videoRef.current;
const iceServers = [{ urls: ['stun:stun.l.google.com:19302'] }];
const video = new RTCPeerConnection({ iceServers });
// --- Init Video ---
video.onnegotiationneeded = async () => {
if (videoOfferSent.current || video.signalingState !== 'stable') {
console.log('Skipping video negotiation - Waiting for answer or state unstable');
return;
}
try {
videoOfferSent.current = true;
const offer = await video.createOffer({
offerToReceiveVideo: true,
offerToReceiveAudio: false
});
await video.setLocalDescription(offer);
sendMsg('video-offer', JSON.stringify(video.localDescription));
} catch (error) {
videoOfferSent.current = false;
console.error('Video negotiation failed:', error);
}
};
video.onconnectionstatechange = () => {
if (video.iceConnectionState === 'connected') {
setIsLoading(false);
}
};
video.ontrack = (event) => {
if (videoRef.current && event.track.kind === 'video') {
videoRef.current.srcObject = new MediaStream([event.track]);
}
};
ws.onopen = () => {
videoOfferSent.current = false;
video.onicecandidate = (event) => {
if (event.candidate) {
sendMsg('video-candidate', JSON.stringify(event.candidate));
}
};
video.addTransceiver('video', { direction: 'recvonly' });
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data as string);
if (!msg?.data) return;
const data = JSON.parse(msg.data);
if (!data) return;
switch (msg.event) {
case 'video-answer':
handleVideoAnswer(data);
break;
case 'video-candidate':
handleVideoCandidate(data);
break;
case 'heartbeat':
break;
default:
console.log('Unhandled event:', msg.event);
}
} catch (err) {
console.error('Message processing error:', err);
}
};
const handleVideoAnswer = (data: any) => {
if (video.signalingState !== 'have-local-offer') {
videoOfferSent.current = false;
console.warn(`Video signaling state incorrect for answer: ${video.signalingState}`);
return;
}
video
.setRemoteDescription(new RTCSessionDescription(data))
.then(() => {
videoOfferSent.current = false;
videoIceCandidates.current.forEach((candidate) => {
video
.addIceCandidate(candidate)
.catch((e) => console.error('Video candidate failed to add:', e.message));
});
videoIceCandidates.current = [];
})
.catch((error) => {
console.error('Video answer set failed:', error);
videoOfferSent.current = false;
});
};
const handleVideoCandidate = (data: any) => {
if (!data.candidate) {
return;
}
const candidate = new RTCIceCandidate(data);
if (video.remoteDescription) {
video
.addIceCandidate(candidate)
.catch((e) => console.error('Video candidate failed to add:', e.message));
} else {
videoIceCandidates.current.push(candidate);
}
};
let video: RTCPeerConnection | null = null;
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
let disposed = false;
const sendMsg = (event: string, data: string) => {
if (ws.readyState !== WebSocket.OPEN) {
@@ -148,25 +53,167 @@ export const H264Webrtc = () => {
}
};
const heartbeatTimer = setInterval(() => {
sendMsg('heartbeat', '');
}, 60 * 1000);
const startVideo = (iceServers: RTCIceServer[]) => {
if (video || disposed) {
return;
}
setTimeout(() => {
const peer = new RTCPeerConnection({ iceServers });
video = peer;
videoOfferSent.current = false;
videoIceCandidates.current = [];
// --- Init Video ---
peer.onnegotiationneeded = async () => {
if (videoOfferSent.current || peer.signalingState !== 'stable') {
console.log('Skipping video negotiation - Waiting for answer or state unstable');
return;
}
try {
videoOfferSent.current = true;
const offer = await peer.createOffer({
offerToReceiveVideo: true,
offerToReceiveAudio: false
});
await peer.setLocalDescription(offer);
sendMsg('video-offer', JSON.stringify(peer.localDescription));
} catch (error) {
videoOfferSent.current = false;
console.error('Video negotiation failed:', error);
}
};
peer.ontrack = (event) => {
if (videoElement && event.track.kind === 'video') {
videoElement.srcObject = new MediaStream([event.track]);
}
};
peer.onicecandidate = (event) => {
if (event.candidate) {
sendMsg('video-candidate', JSON.stringify(event.candidate));
}
};
peer.addTransceiver('video', { direction: 'recvonly' });
};
const handleVideoAnswer = (data: RTCSessionDescriptionInit) => {
const peer = video;
if (!peer) {
return;
}
if (peer.signalingState !== 'have-local-offer') {
videoOfferSent.current = false;
console.warn(`Video signaling state incorrect for answer: ${peer.signalingState}`);
return;
}
peer
.setRemoteDescription(new RTCSessionDescription(data))
.then(() => {
videoOfferSent.current = false;
videoIceCandidates.current.forEach((candidate) => {
peer
.addIceCandidate(candidate)
.catch((e) => console.error('Video candidate failed to add:', e.message));
});
videoIceCandidates.current = [];
})
.catch((error) => {
console.error('Video answer set failed:', error);
videoOfferSent.current = false;
});
};
const handleVideoCandidate = (data: RTCIceCandidateInit) => {
const peer = video;
if (!peer || !data.candidate) {
return;
}
const candidate = new RTCIceCandidate(data);
if (peer.remoteDescription) {
peer
.addIceCandidate(candidate)
.catch((e) => console.error('Video candidate failed to add:', e.message));
} else {
videoIceCandidates.current.push(candidate);
}
};
ws.onopen = () => {
if (disposed) {
ws.close();
return;
}
heartbeatTimer = setInterval(() => {
sendMsg('heartbeat', '');
}, 60 * 1000);
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data as string) as SignalingMessage;
switch (msg.event) {
case 'ice-servers': {
const iceServers = parseSignalingData<RTCIceServer[]>(msg.data);
startVideo(Array.isArray(iceServers) ? iceServers : []);
break;
}
case 'video-answer': {
const data = parseSignalingData<RTCSessionDescriptionInit>(msg.data);
if (data) {
handleVideoAnswer(data);
}
break;
}
case 'video-candidate': {
const data = parseSignalingData<RTCIceCandidateInit>(msg.data);
if (data) {
handleVideoCandidate(data);
}
break;
}
case 'heartbeat':
break;
default:
console.log('Unhandled event:', msg.event);
}
} catch (err) {
console.error('Message processing error:', err);
}
};
const loadingTimer = setTimeout(() => {
setIsLoading(false);
}, 5 * 1000);
return () => {
if (ws.readyState === WebSocket.OPEN) {
disposed = true;
if (ws.readyState !== WebSocket.CLOSING && ws.readyState !== WebSocket.CLOSED) {
ws.close();
}
video.close();
video?.close();
video = null;
if (videoElement) {
videoElement.srcObject = null;
}
videoOfferSent.current = false;
videoIceCandidates.current = [];
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
}
clearTimeout(loadingTimer);
};
}, []);
@@ -206,10 +253,8 @@ export const H264Webrtc = () => {
</div>
{isLoading && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/20">
<div className="rounded-full bg-neutral-900/85 px-4 py-2 text-sm text-neutral-100 shadow-lg">
Loading
</div>
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-[2px] transition-all duration-300">
<Spin size="large" />
</div>
)}
</div>