mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-11-10 01:49:00 -06:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
863fea9ab8 | ||
|
|
f65c1b06aa | ||
|
|
fe34008ce3 | ||
|
|
961d7f2e89 | ||
|
|
94749ea621 | ||
|
|
6ae742deed | ||
|
|
b653f905e4 | ||
|
|
af19795c29 | ||
|
|
be7584d908 | ||
|
|
a960417739 | ||
|
|
3f0621e7ed | ||
|
|
515c5e2a76 | ||
|
|
cb025f04b4 | ||
|
|
dff794cee4 | ||
|
|
8eca1edf47 |
27
CHANGELOG.md
27
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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), " ")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
167
server/service/stream/capture_status.go
Normal file
167
server/service/stream/capture_status.go
Normal 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 "", ""
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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{}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
111
server/service/ws/capture_status.go
Normal file
111
server/service/ws/capture_status.go
Normal 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))
|
||||
}
|
||||
@@ -37,5 +37,7 @@ func (s *Service) Connect(c *gin.Context) {
|
||||
manager.AddClient(ws, client)
|
||||
defer manager.RemoveClient(ws)
|
||||
|
||||
sendCaptureStatusSnapshot(client)
|
||||
|
||||
client.Start()
|
||||
}
|
||||
|
||||
@@ -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
20
web/pnpm-lock.yaml
generated
@@ -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
6
web/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
allowBuilds:
|
||||
bufferutil: true
|
||||
es5-ext: true
|
||||
esbuild: true
|
||||
msw: true
|
||||
utf-8-validate: true
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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é',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'タイムアウト',
|
||||
|
||||
@@ -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: '시간 초과',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Время ожидания истекло',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'หมดเวลา',
|
||||
|
||||
@@ -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ı',
|
||||
|
||||
@@ -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: 'Час очікування минув',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '已超时',
|
||||
|
||||
@@ -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: '超時',
|
||||
|
||||
2
web/src/pages/desktop/capture-status/index.ts
Normal file
2
web/src/pages/desktop/capture-status/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CaptureStatusOverlay } from './overlay';
|
||||
export { useCaptureStatus } from './use-capture-status';
|
||||
74
web/src/pages/desktop/capture-status/model.ts
Normal file
74
web/src/pages/desktop/capture-status/model.ts
Normal 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;
|
||||
}
|
||||
30
web/src/pages/desktop/capture-status/overlay.tsx
Normal file
30
web/src/pages/desktop/capture-status/overlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
web/src/pages/desktop/capture-status/use-capture-status.ts
Normal file
39
web/src/pages/desktop/capture-status/use-capture-status.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} /> },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -234,7 +234,7 @@ export const Absolute = () => {
|
||||
longPressTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (pressedButtonRef.current) {
|
||||
if (pressedButtonRef.current !== null) {
|
||||
handleMouseEvent({ type: 'mouseup', button: pressedButtonRef.current! });
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -228,7 +228,7 @@ export function usePicoclawSidebarLifecycle({
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
closeGateway();
|
||||
void closeGateway();
|
||||
setTakeover((current) => ({
|
||||
...current,
|
||||
active: false,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -107,7 +107,6 @@ export const useSidebar = () => {
|
||||
refreshStateRef.current = actions.refreshState;
|
||||
const sessionActions = createPicoclawSidebarSessionActions({
|
||||
t,
|
||||
runtimeStatus,
|
||||
transportState,
|
||||
runState,
|
||||
config,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user