diff --git a/cast-receiver/README.md b/cast-receiver/README.md
new file mode 100644
index 000000000..29dfcf1cc
--- /dev/null
+++ b/cast-receiver/README.md
@@ -0,0 +1,92 @@
+# Chromecast Custom Receiver Setup
+
+This directory contains the custom Google Cast receiver application used for low-latency WebRTC streaming from JetKVM to Chromecast / Google TV devices.
+
+## Prerequisites
+
+- A Google account (personal Gmail works)
+- A Chromecast, Chromecast with Google TV, or Google TV Streamer on the same LAN as the JetKVM
+- An HTTPS web server to host the receiver HTML (e.g., Caddy, nginx, GitHub Pages)
+
+## Setup Steps
+
+### 1. Register as a Cast Developer
+
+1. Go to the [Google Cast SDK Developer Console](https://cast.google.com/publish)
+2. Pay the one-time $5 registration fee
+3. Accept the Terms of Service
+
+### 2. Create a Custom Receiver Application
+
+1. Click **Add New Application**
+2. Select **Custom Receiver**
+3. Fill in:
+ - **Name**: `JetKVM` (or any name you prefer)
+ - **Receiver Application URL**: The HTTPS URL where you'll host `index.html` (e.g., `https://yourdomain.com/cast-receiver/index.html`)
+4. Click **Save**
+5. Note the **Application ID** (e.g., `F311D863`)
+
+### 3. Register Your Device for Testing
+
+Unpublished apps only work on registered test devices:
+
+1. In the Cast Developer Console, go to **Cast Receiver Devices**
+2. Click **Add New Device**
+3. Enter the device's **Cast serial number**:
+ - On Google TV Streamer: **Settings > System > About > Cast serial number** (NOT the Android serial)
+ - On Chromecast: Check the box or **Google Home app > Device > Settings > Serial number**
+4. Wait **15 minutes** for registration to propagate
+5. **Hard reboot** the device (unplug power, wait 10 seconds, plug back in)
+
+### 4. Host the Receiver HTML
+
+The `index.html` file must be served over **HTTPS** with a valid TLS certificate. Options:
+
+**Caddy (recommended for self-hosting):**
+```
+yourdomain.com {
+ handle_path /cast-receiver/* {
+ root * /path/to/cast-receiver
+ file_server
+ }
+}
+```
+
+**GitHub Pages:**
+1. Create a repository and push `index.html`
+2. Enable GitHub Pages in repository settings
+3. Use the resulting `https://username.github.io/repo-name/index.html` URL
+
+### 5. Configure the JetKVM
+
+1. Open the JetKVM web interface
+2. Go to **Settings > Video**
+3. Set the **Cast Receiver App ID** to the Application ID from step 2
+4. Click **Apply**
+
+### 6. Cast
+
+1. In the JetKVM web interface, click the **Cast** button in the toolbar
+2. Select your Chromecast / Google TV device from the list
+3. The receiver loads on the TV and establishes a direct WebRTC connection to the JetKVM
+4. Expected latency: ~300-500ms on LAN
+
+## How It Works
+
+1. The JetKVM connects to the Chromecast via the CASTV2 protocol (TLS on port 8009)
+2. It launches the custom receiver app by Application ID
+3. The Chromecast loads the receiver HTML from your HTTPS server
+4. The JetKVM sends its IP address to the receiver via a custom Cast namespace
+5. The receiver opens a WebSocket to the JetKVM for WebRTC signaling
+6. A peer-to-peer WebRTC connection is established for H.264 video streaming
+
+## Troubleshooting
+
+- **"Timeout waiting for custom receiver"**: The device is not registered as a test device, or the 15-minute propagation hasn't completed. Hard reboot the device after waiting.
+- **Black screen on TV, no video**: Check the receiver's console via `chrome://inspect` in Chrome (device must be on the same network). Look for WebSocket connection errors.
+- **Cast button shows no devices**: Ensure the Chromecast is on the same LAN/subnet as the JetKVM. mDNS discovery requires multicast to work.
+- **Mixed content errors**: The receiver HTML must be served over HTTPS. The WebSocket connection to the JetKVM uses `ws://` which is allowed from Cast receiver contexts.
+
+## Publishing (Optional)
+
+Once testing is complete, you can publish the app in the Cast Developer Console to make it available on all Chromecast devices without per-device registration.
diff --git a/cast-receiver/index.html b/cast-receiver/index.html
new file mode 100644
index 000000000..db35163f0
--- /dev/null
+++ b/cast-receiver/index.html
@@ -0,0 +1,159 @@
+
+
+
+
+
+
+
+Cast receiver loading...
+
+
+
+
+
+
+
+
+
diff --git a/chromecast.go b/chromecast.go
new file mode 100644
index 000000000..346a74ee0
--- /dev/null
+++ b/chromecast.go
@@ -0,0 +1,419 @@
+package kvm
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net"
+ "time"
+
+ "github.com/jetkvm/kvm/internal/sync"
+ "github.com/vishen/go-chromecast/cast"
+ pb "github.com/vishen/go-chromecast/cast/proto"
+ castdns "github.com/vishen/go-chromecast/dns"
+)
+
+// ---------------------------------------------------------------------------
+// bufferedConn wraps cast.Connection with a buffered message channel to
+// prevent the unbuffered recvMsgChan from deadlocking the receiveLoop.
+// ---------------------------------------------------------------------------
+
+type bufferedConn struct {
+ inner *cast.Connection
+ bufferedCh chan *pb.CastMessage
+}
+
+func newBufferedConn() *bufferedConn {
+ return &bufferedConn{
+ inner: cast.NewConnection(),
+ }
+}
+
+func (b *bufferedConn) Start(addr string, port int) error {
+ if err := b.inner.Start(addr, port); err != nil {
+ return err
+ }
+ // Pump from the unbuffered channel to a buffered one so receiveLoop never blocks
+ b.bufferedCh = make(chan *pb.CastMessage, 32)
+ go func() {
+ for msg := range b.inner.MsgChan() {
+ select {
+ case b.bufferedCh <- msg:
+ default:
+ // Drop if buffer full (shouldn't happen)
+ }
+ }
+ close(b.bufferedCh)
+ }()
+ return nil
+}
+
+func (b *bufferedConn) Send(requestID int, payload cast.Payload, sourceID, destinationID, namespace string) error {
+ return b.inner.Send(requestID, payload, sourceID, destinationID, namespace)
+}
+
+func (b *bufferedConn) MsgChan() chan *pb.CastMessage { return b.bufferedCh }
+func (b *bufferedConn) Close() error { return b.inner.Close() }
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+const (
+ jetkvmNamespace = "urn:x-cast:com.jetkvm.cast"
+ nsConnection = "urn:x-cast:com.google.cast.tp.connection"
+ nsReceiver = "urn:x-cast:com.google.cast.receiver"
+ castSender = "sender-0"
+ castReceiver = "receiver-0"
+)
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+type ChromecastDevice struct {
+ Name string `json:"name"`
+ UUID string `json:"uuid"`
+ Address string `json:"address"`
+ Port int `json:"port"`
+}
+
+type CastStatus struct {
+ Active bool `json:"active"`
+ DeviceName string `json:"deviceName,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// CastPreferredDevice is the saved preferred casting target.
+type CastPreferredDevice struct {
+ Name string `json:"name"`
+ Address string `json:"address"`
+ Port int `json:"port"`
+}
+
+// castMessage is a simple payload for custom namespace messages.
+type castMessage struct {
+ Type string `json:"type"`
+ IP string `json:"ip,omitempty"`
+}
+
+func (m *castMessage) SetRequestId(_ int) {}
+
+// receiverStatus is the subset of RECEIVER_STATUS we care about.
+type receiverStatus struct {
+ Status struct {
+ Applications []struct {
+ AppId string `json:"appId"`
+ TransportId string `json:"transportId"`
+ } `json:"applications"`
+ } `json:"status"`
+}
+
+// ---------------------------------------------------------------------------
+// State
+// ---------------------------------------------------------------------------
+
+var castState struct {
+ mu sync.Mutex
+ active bool
+ deviceName string
+ conn *bufferedConn
+}
+
+// ---------------------------------------------------------------------------
+// mDNS discovery
+// ---------------------------------------------------------------------------
+
+func rpcDiscoverChromecasts() ([]ChromecastDevice, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ entryCh, err := castdns.DiscoverCastDNSEntries(ctx, nil)
+ if err != nil {
+ chromecastLogger.Warn().Err(err).Msg("mDNS discovery failed")
+ return nil, fmt.Errorf("mDNS discovery failed: %w", err)
+ }
+
+ var devices []ChromecastDevice
+ for entry := range entryCh {
+ devices = append(devices, ChromecastDevice{
+ Name: entry.GetName(),
+ UUID: entry.GetUUID(),
+ Address: entry.GetAddr(),
+ Port: entry.GetPort(),
+ })
+ }
+
+ chromecastLogger.Info().Int("count", len(devices)).Msg("Chromecast discovery complete")
+ return devices, nil
+}
+
+// ---------------------------------------------------------------------------
+// Cast control — WebRTC via custom receiver
+// ---------------------------------------------------------------------------
+
+func getDeviceLocalIP() (string, error) {
+ state := rpcGetNetworkState()
+ if state != nil && state.IPv4Address != "" {
+ return state.IPv4Address, nil
+ }
+ addrs, err := net.InterfaceAddrs()
+ if err != nil {
+ return "", err
+ }
+ for _, addr := range addrs {
+ if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
+ return ipnet.IP.String(), nil
+ }
+ }
+ return "", fmt.Errorf("no suitable local IP found")
+}
+
+func rpcStartCasting(address string, port int) error {
+ castState.mu.Lock()
+ defer castState.mu.Unlock()
+
+ if castState.active {
+ return fmt.Errorf("already casting")
+ }
+
+ localIP, err := getDeviceLocalIP()
+ if err != nil {
+ return fmt.Errorf("cannot determine local IP: %w", err)
+ }
+
+ chromecastLogger.Info().Str("address", address).Int("port", port).Msg("starting cast")
+
+ // Connect to Chromecast with buffered message channel
+ conn := newBufferedConn()
+ if err := conn.Start(address, port); err != nil {
+ return fmt.Errorf("failed to connect: %w", err)
+ }
+
+ msgCh := conn.MsgChan()
+ transportCh := make(chan string, 1)
+ errCh := make(chan error, 1)
+
+ go func() {
+ timeout := time.After(15 * time.Second)
+ for {
+ select {
+ case msg, ok := <-msgCh:
+ if !ok {
+ errCh <- fmt.Errorf("message channel closed")
+ return
+ }
+ if msg.PayloadUtf8 == nil {
+ continue
+ }
+ payload := *msg.PayloadUtf8
+ chromecastLogger.Info().Str("payload", payload).Msg("received cast message")
+
+ var generic map[string]interface{}
+ if err := json.Unmarshal([]byte(payload), &generic); err != nil {
+ continue
+ }
+ msgType, _ := generic["type"].(string)
+
+ switch msgType {
+ case "LAUNCH_ERROR":
+ reason, _ := generic["reason"].(string)
+ errCh <- fmt.Errorf("LAUNCH_ERROR: %s (is the device registered as a test device?)", reason)
+ return
+
+ case "LAUNCH_STATUS":
+ // App is launching — poll for updated status after a brief delay
+ go func() {
+ time.Sleep(2 * time.Second)
+ poll := cast.GetStatusHeader
+ poll.SetRequestId(10)
+ conn.Send(10, &poll, castSender, castReceiver, nsReceiver) //nolint:errcheck
+ }()
+
+ case "RECEIVER_STATUS":
+ var status receiverStatus
+ if err := json.Unmarshal([]byte(payload), &status); err != nil {
+ continue
+ }
+ for _, app := range status.Status.Applications {
+ if app.AppId == config.CastReceiverAppID {
+ transportCh <- app.TransportId
+ return
+ }
+ }
+ }
+ case <-timeout:
+ errCh <- fmt.Errorf("timeout waiting for custom receiver to start")
+ return
+ }
+ }
+ }()
+
+ // Send CONNECT to the receiver platform
+ chromecastLogger.Info().Msg("sending CONNECT")
+ connectHeader := cast.ConnectHeader // local copy
+ if err := conn.Send(1, &connectHeader, castSender, castReceiver, nsConnection); err != nil {
+ conn.Close() //nolint:errcheck
+ return fmt.Errorf("CONNECT failed: %w", err)
+ }
+
+ // Request initial status (triggers Chromecast to send RECEIVER_STATUS)
+ getStatus := cast.GetStatusHeader // local copy
+ getStatus.SetRequestId(2)
+ if err := conn.Send(2, &getStatus, castSender, castReceiver, nsReceiver); err != nil {
+ conn.Close() //nolint:errcheck
+ return fmt.Errorf("GET_STATUS failed: %w", err)
+ }
+
+ // Launch the custom receiver app
+ chromecastLogger.Info().Str("appId", config.CastReceiverAppID).Msg("sending LAUNCH")
+ launch := cast.LaunchRequest{
+ PayloadHeader: cast.LaunchHeader,
+ AppId: config.CastReceiverAppID,
+ }
+ launch.SetRequestId(3)
+ if err := conn.Send(3, &launch, castSender, castReceiver, nsReceiver); err != nil {
+ conn.Close() //nolint:errcheck
+ return fmt.Errorf("LAUNCH failed: %w", err)
+ }
+
+ // Wait for the custom receiver to start
+ var transportID string
+ select {
+ case transportID = <-transportCh:
+ chromecastLogger.Info().Str("transportId", transportID).Msg("custom receiver launched")
+ case err := <-errCh:
+ conn.Close() //nolint:errcheck
+ return fmt.Errorf("failed to launch receiver: %w", err)
+ }
+
+ // Connect to the app's transport
+ if err := conn.Send(3, &cast.ConnectHeader, castSender, transportID, nsConnection); err != nil {
+ conn.Close() //nolint:errcheck
+ return fmt.Errorf("transport CONNECT failed: %w", err)
+ }
+
+ // Send the JetKVM IP to the receiver so it can establish WebRTC
+ chromecastLogger.Info().Str("ip", localIP).Msg("sending JetKVM IP to receiver")
+ if err := conn.Send(4, &castMessage{
+ Type: "connect",
+ IP: localIP,
+ }, castSender, transportID, jetkvmNamespace); err != nil {
+ conn.Close() //nolint:errcheck
+ return fmt.Errorf("failed to send connect message: %w", err)
+ }
+
+ // Keep connection alive (receiveLoop handles PING/PONG automatically)
+ castState.active = true
+ castState.deviceName = fmt.Sprintf("%s:%d", address, port)
+ castState.conn = conn
+
+ chromecastLogger.Info().Str("device", castState.deviceName).Msg("casting started (WebRTC)")
+ return nil
+}
+
+func rpcStopCasting() error {
+ castState.mu.Lock()
+ defer castState.mu.Unlock()
+
+ if !castState.active {
+ return nil
+ }
+
+ if castState.conn != nil {
+ // Send stop message to receiver (best-effort)
+ _ = castState.conn.Send(0, &cast.StopHeader, castSender, castReceiver, nsReceiver)
+ _ = castState.conn.Close()
+ castState.conn = nil
+ }
+
+ castState.active = false
+ castState.deviceName = ""
+
+ chromecastLogger.Info().Msg("casting stopped")
+ return nil
+}
+
+func rpcGetCastingStatus() CastStatus {
+ castState.mu.Lock()
+ defer castState.mu.Unlock()
+
+ return CastStatus{
+ Active: castState.active,
+ DeviceName: castState.deviceName,
+ }
+}
+
+type CastConfig struct {
+ ReceiverAppID string `json:"receiverAppId"`
+ PreferredDevice *CastPreferredDevice `json:"preferredDevice"`
+}
+
+func rpcGetCastConfig() CastConfig {
+ return CastConfig{
+ ReceiverAppID: config.CastReceiverAppID,
+ PreferredDevice: config.CastPreferredDevice,
+ }
+}
+
+func rpcSetCastConfig(receiverAppID string) error {
+ if receiverAppID == "" {
+ return fmt.Errorf("receiver app ID cannot be empty")
+ }
+ old := config.CastReceiverAppID
+ config.CastReceiverAppID = receiverAppID
+ if err := SaveConfig(); err != nil {
+ config.CastReceiverAppID = old
+ return fmt.Errorf("failed to save config: %w", err)
+ }
+ return nil
+}
+
+func rpcSetPreferredCastDevice(name string, address string, port int) error {
+ if address == "" {
+ // Clear preferred device
+ config.CastPreferredDevice = nil
+ } else {
+ config.CastPreferredDevice = &CastPreferredDevice{
+ Name: name,
+ Address: address,
+ Port: port,
+ }
+ }
+ if err := SaveConfig(); err != nil {
+ return fmt.Errorf("failed to save config: %w", err)
+ }
+ chromecastLogger.Info().Str("name", name).Str("address", address).Msg("preferred cast device updated")
+ return nil
+}
+
+// rpcQuickCast casts to the preferred device, or the first discovered device if none set.
+// Called from the LCD touch screen.
+func rpcQuickCast() error {
+ castState.mu.Lock()
+ if castState.active {
+ castState.mu.Unlock()
+ return rpcStopCasting()
+ }
+ castState.mu.Unlock()
+
+ // Use preferred device if set
+ if dev := config.CastPreferredDevice; dev != nil {
+ chromecastLogger.Info().Str("name", dev.Name).Msg("quick cast to preferred device")
+ return rpcStartCasting(dev.Address, dev.Port)
+ }
+
+ // Otherwise discover and use the first found
+ chromecastLogger.Info().Msg("quick cast: no preferred device, discovering...")
+ devices, err := rpcDiscoverChromecasts()
+ if err != nil {
+ return fmt.Errorf("discovery failed: %w", err)
+ }
+ if len(devices) == 0 {
+ return fmt.Errorf("no Chromecast devices found")
+ }
+
+ dev := devices[0]
+ chromecastLogger.Info().Str("name", dev.Name).Msg("quick cast to first discovered device")
+ return rpcStartCasting(dev.Address, dev.Port)
+}
diff --git a/cloud.go b/cloud.go
index 9a35256cc..2ecc6b9e6 100644
--- a/cloud.go
+++ b/cloud.go
@@ -434,7 +434,7 @@ func handleSessionRequest(
isCloudConnection bool,
source string,
scopedLogger *zerolog.Logger,
-) error {
+) (*Session, error) {
var sourceType string
if isCloudConnection {
sourceType = "cloud"
@@ -451,7 +451,7 @@ func handleSessionRequest(
// If the message is from the cloud, we need to authenticate the session.
if isCloudConnection {
if err := authenticateSession(ctx, c, req); err != nil {
- return err
+ return nil, err
}
}
@@ -465,32 +465,21 @@ func handleSessionRequest(
})
if err != nil {
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
- return err
+ return nil, err
}
sd, err := session.ExchangeOffer(req.Sd)
if err != nil {
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
- return err
- }
- if currentSession != nil {
- writeJSONRPCEvent("otherSessionConnected", nil, currentSession)
- peerConn := currentSession.peerConnection
- go func() {
- time.Sleep(1 * time.Second)
- _ = peerConn.Close()
- }()
+ return nil, err
}
- cloudLogger.Info().Interface("session", session).Msg("new session accepted")
- cloudLogger.Trace().Interface("session", session).Msg("new session accepted")
+ addSession(session)
- // Cancel any ongoing keyboard macro when session changes
- cancelKeyboardMacro()
+ cloudLogger.Info().Interface("session", session).Msg("new session accepted")
- currentSession = session
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
- return nil
+ return session, nil
}
func RunWebsocketClient() {
diff --git a/config.go b/config.go
index 7c5738f91..fe09daafa 100644
--- a/config.go
+++ b/config.go
@@ -117,6 +117,10 @@ type Config struct {
VideoQualityFactor float64 `json:"video_quality_factor"`
NativeMaxRestart uint `json:"native_max_restart_attempts"`
MqttConfig *MQTTConfig `json:"mqtt_config"`
+ RTSPEnabled bool `json:"rtsp_enabled"`
+ RTSPPort int `json:"rtsp_port"`
+ CastReceiverAppID string `json:"cast_receiver_app_id"`
+ CastPreferredDevice *CastPreferredDevice `json:"cast_preferred_device,omitempty"`
}
// GetUpdateAPIURL returns the update API URL
@@ -208,6 +212,9 @@ func getDefaultConfig() Config {
EnableActions: true,
DebounceMs: 500,
},
+ RTSPEnabled: true,
+ RTSPPort: 8554,
+ CastReceiverAppID: "F311D863",
}
}
diff --git a/go.mod b/go.mod
index f4388a4db..abfe82228 100644
--- a/go.mod
+++ b/go.mod
@@ -1,15 +1,17 @@
module github.com/jetkvm/kvm
-go 1.24.4
+go 1.25.0
require (
github.com/Masterminds/semver/v3 v3.4.0
github.com/ProtonMail/go-crypto v1.1.5
github.com/beevik/ntp v1.5.0
+ github.com/bluenviron/gortsplib/v5 v5.5.0
github.com/caarlos0/env/v11 v11.3.1
github.com/coder/websocket v1.8.14
github.com/coreos/go-oidc/v3 v3.16.0
github.com/creack/pty v1.1.24
+ github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/erikdubbelboer/gspt v0.0.0-20210805194459-ce36a5128377
github.com/fsnotify/fsnotify v1.9.0
github.com/gin-contrib/logger v1.2.6
@@ -20,10 +22,10 @@ require (
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f
github.com/insomniacslk/dhcp v0.0.0-20250919081422-f80a1952f48e
github.com/mdlayher/ndp v1.1.0
- github.com/pion/ice/v4 v4.1.0
+ github.com/pion/ice/v4 v4.2.1
github.com/pion/logging v0.2.4
github.com/pion/mdns/v2 v2.1.0
- github.com/pion/webrtc/v4 v4.2.1
+ github.com/pion/webrtc/v4 v4.2.9
github.com/pojntfx/go-nbd v0.3.2
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/common v0.67.2
@@ -34,11 +36,12 @@ require (
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f
github.com/stretchr/testify v1.11.1
github.com/vearutop/statigz v1.5.0
+ github.com/vishen/go-chromecast v0.3.4
github.com/vishvananda/netlink v1.3.1
go.bug.st/serial v1.6.4
- golang.org/x/crypto v0.43.0
- golang.org/x/net v0.46.0
- golang.org/x/sys v0.37.0
+ golang.org/x/crypto v0.49.0
+ golang.org/x/net v0.52.0
+ golang.org/x/sys v0.42.0
google.golang.org/grpc v1.76.0
google.golang.org/protobuf v1.36.10
)
@@ -47,14 +50,16 @@ replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20
require (
github.com/beorn7/perks v1.0.1 // indirect
+ github.com/bluenviron/mediacommon/v2 v2.8.3 // indirect
+ github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
+ github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.3.7 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/creack/goselect v0.1.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
- github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
@@ -62,7 +67,9 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
+ github.com/gogo/protobuf v1.3.2 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
+ github.com/grandcat/zeroconf v1.0.0 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -72,28 +79,31 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mdlayher/packet v1.1.2 // indirect
github.com/mdlayher/socket v0.4.1 // indirect
+ github.com/miekg/dns v1.1.62 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pierrec/lz4/v4 v4.1.14 // indirect
github.com/pilebones/go-udev v0.9.1 // indirect
- github.com/pion/datachannel v1.5.10 // indirect
- github.com/pion/dtls/v3 v3.0.9 // indirect
- github.com/pion/interceptor v0.1.42 // indirect
+ github.com/pion/datachannel v1.6.0 // indirect
+ github.com/pion/dtls/v3 v3.1.2 // indirect
+ github.com/pion/interceptor v0.1.44 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.16 // indirect
- github.com/pion/rtp v1.8.27 // indirect
- github.com/pion/sctp v1.9.0 // indirect
- github.com/pion/sdp/v3 v3.0.17 // indirect
- github.com/pion/srtp/v3 v3.0.9 // indirect
- github.com/pion/stun/v3 v3.0.2 // indirect
- github.com/pion/transport/v3 v3.1.1 // indirect
- github.com/pion/turn/v4 v4.1.3 // indirect
+ github.com/pion/rtp v1.10.1 // indirect
+ github.com/pion/sctp v1.9.2 // indirect
+ github.com/pion/sdp/v3 v3.0.18 // indirect
+ github.com/pion/srtp/v3 v3.0.10 // indirect
+ github.com/pion/stun/v3 v3.1.1 // indirect
+ github.com/pion/transport/v4 v4.0.1 // indirect
+ github.com/pion/turn/v4 v4.1.4 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
+ github.com/sirupsen/logrus v1.9.3 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
@@ -101,9 +111,12 @@ require (
github.com/wlynxg/anet v0.0.5 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/arch v0.20.0 // indirect
+ golang.org/x/mod v0.33.0 // indirect
golang.org/x/oauth2 v0.32.0 // indirect
- golang.org/x/sync v0.17.0 // indirect
- golang.org/x/text v0.30.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/text v0.35.0 // indirect
+ golang.org/x/time v0.10.0 // indirect
+ golang.org/x/tools v0.42.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index 4223acb59..cfe3a5b5a 100644
--- a/go.sum
+++ b/go.sum
@@ -8,14 +8,22 @@ github.com/beevik/ntp v1.5.0 h1:y+uj/JjNwlY2JahivxYvtmv4ehfi3h74fAuABB9ZSM4=
github.com/beevik/ntp v1.5.0/go.mod h1:mJEhBrwT76w9D+IfOEGvuzyuudiW9E52U2BaTrMOYow=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bluenviron/gortsplib/v5 v5.5.0 h1:2wlUKhlSw1ptKEVnkEuZMpQuN8Xt311Fdd/SB124JPA=
+github.com/bluenviron/gortsplib/v5 v5.5.0/go.mod h1:otcPqR836QZej/EYx7njn8vl4TLj8Ya3QAf+GBwh2cQ=
+github.com/bluenviron/mediacommon/v2 v2.8.3 h1:T6xb7ZK3eBixi/HynzhtGRCEIrazwcmGIeu0WDTVISY=
+github.com/bluenviron/mediacommon/v2 v2.8.3/go.mod h1:CsYjGgzIz8RbloQf4BHR4uReogZsB4PEKWfePVIzJv8=
github.com/bool64/dev v0.2.39 h1:kP8DnMGlWXhGYJEZE/J0l/gVBdbuhoPGL+MJG4QbofE=
github.com/bool64/dev v0.2.39/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
+github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
+github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
+github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
+github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b h1:dSbDgy72Y1sjLPWLv7vs0fMFuhMBMViiT9PJZiZWZNs=
@@ -69,6 +77,8 @@ github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAu
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -78,6 +88,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE=
+github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs=
github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ=
github.com/guregu/null/v6 v6.0.0/go.mod h1:hrMIhIfrOZeLPZhROSn149tpw2gHkidAqxoXNyeX3iQ=
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f h1:08t2PbrkDgW2+mwCQ3jhKUBrCM9Bc9SeH5j2Dst3B+0=
@@ -93,6 +105,8 @@ github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtL
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -121,6 +135,9 @@ github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY
github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU+x0kew4=
github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U=
github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA=
+github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
+github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
+github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -134,14 +151,14 @@ github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8=
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
-github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
-github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
-github.com/pion/dtls/v3 v3.0.9 h1:4AijfFRm8mAjd1gfdlB1wzJF3fjjR/VPIpJgkEtvYmM=
-github.com/pion/dtls/v3 v3.0.9/go.mod h1:abApPjgadS/ra1wvUzHLc3o2HvoxppAh+NZkyApL4Os=
-github.com/pion/ice/v4 v4.1.0 h1:YlxIii2bTPWyC08/4hdmtYq4srbrY0T9xcTsTjldGqU=
-github.com/pion/ice/v4 v4.1.0/go.mod h1:5gPbzYxqenvn05k7zKPIZFuSAufolygiy6P1U9HzvZ4=
-github.com/pion/interceptor v0.1.42 h1:0/4tvNtruXflBxLfApMVoMubUMik57VZ+94U0J7cmkQ=
-github.com/pion/interceptor v0.1.42/go.mod h1:g6XYTChs9XyolIQFhRHOOUS+bGVGLRfgTCUzH29EfVU=
+github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
+github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
+github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
+github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
+github.com/pion/ice/v4 v4.2.1 h1:XPRYXaLiFq3LFDG7a7bMrmr3mFr27G/gtXN3v/TVfxY=
+github.com/pion/ice/v4 v4.2.1/go.mod h1:2quLV1S5v1tAx3VvAJaH//KGitRXvo4RKlX6D3tnN+c=
+github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I=
+github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
@@ -150,22 +167,25 @@ github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
-github.com/pion/rtp v1.8.27 h1:kbWTdZr62RDlYjatVAW4qFwrAu9XcGnwMsofCfAHlOU=
-github.com/pion/rtp v1.8.27/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
-github.com/pion/sctp v1.9.0 h1:vajCA6G+1/SEi4vpPmDnpRNXwDNBmAXFBvJx0Le9HrI=
-github.com/pion/sctp v1.9.0/go.mod h1:2wO6HBycUH7iCssuGyc2e9+0giXVW0pyCv3ZuL8LiyY=
-github.com/pion/sdp/v3 v3.0.17 h1:9SfLAW/fF1XC8yRqQ3iWGzxkySxup4k4V7yN8Fs8nuo=
-github.com/pion/sdp/v3 v3.0.17/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
-github.com/pion/srtp/v3 v3.0.9 h1:lRGF4G61xxj+m/YluB3ZnBpiALSri2lTzba0kGZMrQY=
-github.com/pion/srtp/v3 v3.0.9/go.mod h1:E+AuWd7Ug2Fp5u38MKnhduvpVkveXJX6J4Lq4rxUYt8=
-github.com/pion/stun/v3 v3.0.2 h1:BJuGEN2oLrJisiNEJtUTJC4BGbzbfp37LizfqswblFU=
-github.com/pion/stun/v3 v3.0.2/go.mod h1:JFJKfIWvt178MCF5H/YIgZ4VX3LYE77vca4b9HP60SA=
+github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
+github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
+github.com/pion/sctp v1.9.2 h1:HxsOzEV9pWoeggv7T5kewVkstFNcGvhMPx0GvUOUQXo=
+github.com/pion/sctp v1.9.2/go.mod h1:OTOlsQ5EDQ6mQ0z4MUGXt2CgQmKyafBEXhUVqLRB6G8=
+github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI=
+github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8=
+github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
+github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M=
+github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
+github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
-github.com/pion/turn/v4 v4.1.3 h1:jVNW0iR05AS94ysEtvzsrk3gKs9Zqxf6HmnsLfRvlzA=
-github.com/pion/turn/v4 v4.1.3/go.mod h1:TD/eiBUf5f5LwXbCJa35T7dPtTpCHRJ9oJWmyPLVT3A=
-github.com/pion/webrtc/v4 v4.2.1 h1:QgIfJeXf9dg++35y4z8GK3oXHcxWf0y2tUstCry0/V8=
-github.com/pion/webrtc/v4 v4.2.1/go.mod h1:YDcAacHK1DZkkn1vwFn3yiXbixCBsEDaCNzg9PPAACk=
+github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
+github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
+github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
+github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
+github.com/pion/webrtc/v4 v4.2.9 h1:DZIh1HAhPIL3RvwEDFsmL5hfPSLEpxsQk9/Jir2vkJE=
+github.com/pion/webrtc/v4 v4.2.9/go.mod h1:9EmLZve0H76eTzf8v2FmchZ6tcBXtDgpfTEu+drW6SY=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -187,6 +207,8 @@ github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f h1:VgoRCP1efSCEZIcF2THLQ46+pIBzzgNiaUBe9wEDwYU=
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzro7BGorij2WgrjEammtrkbo3+xldxo+KaGLGUiD+Q=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -195,6 +217,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
@@ -208,12 +231,16 @@ github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/vearutop/statigz v1.5.0 h1:FuWwZiT82yBw4xbWdWIawiP2XFTyEPhIo8upRxiKLqk=
github.com/vearutop/statigz v1.5.0/go.mod h1:oHmjFf3izfCO804Di1ZjB666P3fAlVzJEx2k6jNt/Gk=
+github.com/vishen/go-chromecast v0.3.4 h1:ELRwOoNaxwIsKCXuxCXXku92+H/qKLj3a6ZN6LDD+H8=
+github.com/vishen/go-chromecast v0.3.4/go.mod h1:9ht6970KP5YmO0WpJJPMfInai2HA5w+q+UWId3QLxBc=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
@@ -234,26 +261,63 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
-golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
-golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
-golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
-golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
+golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
+golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
+golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
-golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
-golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
-golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
-golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
-golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
-golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
+golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
+golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
+golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8=
diff --git a/hw.go b/hw.go
index d544cf1cf..ef84c8282 100644
--- a/hw.go
+++ b/hw.go
@@ -34,7 +34,7 @@ func extractSerialNumber() (string, error) {
func hwReboot(force bool, postRebootAction *ota.PostRebootAction, delay time.Duration) error {
logger.Info().Dur("delayMs", delay).Msg("reboot requested")
- writeJSONRPCEvent("willReboot", postRebootAction, currentSession)
+ forEachSession(func(s *Session) { writeJSONRPCEvent("willReboot", postRebootAction, s) })
time.Sleep(1 * time.Second) // Wait for the JSONRPCEvent to be sent
nativeInstance.SwitchToScreenIfDifferent("rebooting_screen")
diff --git a/jsonrpc.go b/jsonrpc.go
index 8b1c07ce5..553d9db10 100644
--- a/jsonrpc.go
+++ b/jsonrpc.go
@@ -1053,18 +1053,14 @@ func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacroStep) error {
IsPaste: true,
}
- if currentSession != nil {
- currentSession.reportHidRPCKeyboardMacroState(s)
- }
+ forEachSession(func(sess *Session) { sess.reportHidRPCKeyboardMacroState(s) })
err := rpcDoExecuteKeyboardMacro(ctx, macro)
setKeyboardMacroCancel(nil)
s.State = false
- if currentSession != nil {
- currentSession.reportHidRPCKeyboardMacroState(s)
- }
+ forEachSession(func(sess *Session) { sess.reportHidRPCKeyboardMacroState(s) })
return err
}
@@ -1220,4 +1216,14 @@ var rpcHandlers = map[string]RPCHandler{
"setMqttSettings": {Func: rpcSetMqttSettings, Params: []string{"settings"}},
"getMqttStatus": {Func: rpcGetMqttStatus},
"testMqttConnection": {Func: rpcTestMqttConnection, Params: []string{"settings"}},
+ "getRTSPConfig": {Func: rpcGetRTSPConfig},
+ "setRTSPConfig": {Func: rpcSetRTSPConfig, Params: []string{"enabled", "port"}},
+ "getCastConfig": {Func: rpcGetCastConfig},
+ "setCastConfig": {Func: rpcSetCastConfig, Params: []string{"receiverAppId"}},
+ "setPreferredCastDevice": {Func: rpcSetPreferredCastDevice, Params: []string{"name", "address", "port"}},
+ "discoverChromecasts": {Func: rpcDiscoverChromecasts},
+ "startCasting": {Func: rpcStartCasting, Params: []string{"address", "port"}},
+ "stopCasting": {Func: rpcStopCasting},
+ "getCastingStatus": {Func: rpcGetCastingStatus},
+ "quickCast": {Func: rpcQuickCast},
}
diff --git a/log.go b/log.go
index f84b02f0e..35fbc81b1 100644
--- a/log.go
+++ b/log.go
@@ -30,6 +30,8 @@ var (
wolLogger = logging.GetSubsystemLogger("wol")
usbLogger = logging.GetSubsystemLogger("usb")
tailscaleLogger = logging.GetSubsystemLogger("tailscale")
+ rtspLogger = logging.GetSubsystemLogger("rtsp")
+ chromecastLogger = logging.GetSubsystemLogger("chromecast")
// external components
ginLogger = logging.GetSubsystemLogger("gin")
)
diff --git a/main.go b/main.go
index ac9ec1bb4..2fb171e04 100644
--- a/main.go
+++ b/main.go
@@ -126,6 +126,14 @@ func Main() {
// start video sleep mode timer
startVideoSleepModeTicker()
+ // Start RTSP server if enabled
+ if config.RTSPEnabled {
+ if err := StartRTSPServer(config.RTSPPort); err != nil {
+ rtspLogger.Error().Err(err).Msg("failed to start RTSP server")
+ }
+ }
+
+
go func() {
// wait for 15 minutes before starting auto-update checks
// this is to avoid interfering with initial setup processes
@@ -140,7 +148,7 @@ func Main() {
continue
}
- if currentSession != nil {
+ if anySession() {
logger.Debug().Msg("skipping update since a session is active")
time.Sleep(1 * time.Minute)
continue
diff --git a/native.go b/native.go
index 89d991391..a944800c4 100644
--- a/native.go
+++ b/native.go
@@ -68,20 +68,26 @@ func initNative(systemVersion *semver.Version, appVersion *semver.Version) {
}
},
OnVideoFrameReceived: func(frame []byte, duration time.Duration) {
- if currentSession != nil {
- err := currentSession.VideoTrack.WriteSample(media.Sample{Data: frame, Duration: duration})
- if err != nil {
- nativeLogger.Warn().Err(err).Msg("error writing sample")
+ // 1. WebRTC: fan out to ALL active sessions
+ forEachSession(func(s *Session) {
+ if err := s.VideoTrack.WriteSample(media.Sample{Data: frame, Duration: duration}); err != nil {
+ nativeLogger.Warn().Err(err).Msg("error writing sample to session")
}
+ })
+
+ // 2. RTSP server (always, if enabled and running)
+ if rtspServer != nil {
+ rtspServer.WriteNALU(frame, duration)
}
+
},
GetSessionInfo: func() diagnostics.SessionInfo {
info := diagnostics.SessionInfo{
ActiveSessions: getActiveSessions(),
- HasCurrentSession: currentSession != nil,
+ HasCurrentSession: anySession(),
}
- if currentSession != nil {
- sessionInfo := currentSession.GetDiagnosticsInfo()
+ if s := getFirstSession(); s != nil {
+ sessionInfo := s.GetDiagnosticsInfo()
info.ICEConnectionState = sessionInfo.ICEConnectionState
info.SignalingState = sessionInfo.SignalingState
info.ConnectionState = sessionInfo.ConnectionState
diff --git a/network.go b/network.go
index 8168250df..25702ef8d 100644
--- a/network.go
+++ b/network.go
@@ -122,9 +122,7 @@ func networkStateChanged(_ string, state types.InterfaceState) {
// do not block the main thread
go waitCtrlAndRequestDisplayUpdate(false, "network_state_changed")
- if currentSession != nil {
- writeJSONRPCEvent("networkState", state.ToRpcInterfaceState(), currentSession)
- }
+ forEachSession(func(s *Session) { writeJSONRPCEvent("networkState", state.ToRpcInterfaceState(), s) })
if state.Online {
networkLogger.Info().Msg("network state changed to online, triggering time sync")
@@ -317,7 +315,7 @@ func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, er
// If reboot required, send willReboot event before applying network config
if rebootRequired {
l.Info().Msg("Sending willReboot event before applying network config")
- writeJSONRPCEvent("willReboot", postRebootAction, currentSession)
+ forEachSession(func(s *Session) { writeJSONRPCEvent("willReboot", postRebootAction, s) })
}
_ = setHostname(networkManager, netConfig.Hostname.String, netConfig.Domain.String)
diff --git a/ota.go b/ota.go
index 9ff68d058..85ab48003 100644
--- a/ota.go
+++ b/ota.go
@@ -45,7 +45,7 @@ func initOta() {
}
},
OnProgressUpdate: func(progress float32) {
- writeJSONRPCEvent("otaProgress", progress, currentSession)
+ forEachSession(func(s *Session) { writeJSONRPCEvent("otaProgress", progress, s) })
// Also update MQTT update state for HA progress feedback
if mqttManager != nil {
mqttManager.publishUpdateState()
@@ -56,13 +56,13 @@ func initOta() {
func triggerOTAStateUpdate(state *ota.RPCState) {
go func() {
- if currentSession == nil || (otaState == nil && state == nil) {
+ if !anySession() || (otaState == nil && state == nil) {
return
}
if state == nil {
state = otaState.ToRPCState()
}
- writeJSONRPCEvent("otaState", state, currentSession)
+ forEachSession(func(s *Session) { writeJSONRPCEvent("otaState", state, s) })
}()
}
diff --git a/rtsp.go b/rtsp.go
new file mode 100644
index 000000000..efe767902
--- /dev/null
+++ b/rtsp.go
@@ -0,0 +1,268 @@
+package kvm
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/bluenviron/gortsplib/v5"
+ "github.com/bluenviron/gortsplib/v5/pkg/base"
+ "github.com/bluenviron/gortsplib/v5/pkg/description"
+ "github.com/bluenviron/gortsplib/v5/pkg/format"
+ "github.com/bluenviron/gortsplib/v5/pkg/format/rtph264"
+ "github.com/jetkvm/kvm/internal/sync"
+)
+
+var rtspServer *RTSPServer
+
+// RTSPServer wraps gortsplib.Server to serve the H.264 capture stream over RTSP.
+type RTSPServer struct {
+ server *gortsplib.Server
+ stream *gortsplib.ServerStream
+
+ media *description.Media
+ h264Format *format.H264
+ encoder *rtph264.Encoder
+
+ // SPS/PPS cache — updated from live NALUs, included in SDP for late-joiners
+ mu sync.RWMutex
+ sps []byte
+ pps []byte
+
+ // cumulative PTS for RTP timestamps
+ pts time.Duration
+}
+
+// StartRTSPServer creates and starts the RTSP server on the given port.
+func StartRTSPServer(port int) error {
+ h264Format := &format.H264{
+ PayloadTyp: 96,
+ PacketizationMode: 1,
+ }
+
+ media := &description.Media{
+ Type: description.MediaTypeVideo,
+ Formats: []format.Format{h264Format},
+ }
+
+ desc := &description.Session{
+ Medias: []*description.Media{media},
+ }
+
+ rs := &RTSPServer{
+ h264Format: h264Format,
+ media: media,
+ }
+
+ rs.server = &gortsplib.Server{
+ Handler: rs,
+ RTSPAddress: fmt.Sprintf(":%d", port),
+ }
+
+ if err := rs.server.Start(); err != nil {
+ return fmt.Errorf("failed to start RTSP server: %w", err)
+ }
+
+ rs.stream = &gortsplib.ServerStream{
+ Server: rs.server,
+ Desc: desc,
+ }
+ if err := rs.stream.Initialize(); err != nil {
+ rs.server.Close()
+ return fmt.Errorf("failed to initialize RTSP stream: %w", err)
+ }
+
+ rs.encoder = &rtph264.Encoder{
+ PayloadType: 96,
+ }
+ if err := rs.encoder.Init(); err != nil {
+ rs.stream.Close()
+ rs.server.Close()
+ return fmt.Errorf("failed to init H264 RTP encoder: %w", err)
+ }
+
+ rtspServer = rs
+ rtspLogger.Info().Int("port", port).Msg("RTSP server started")
+ return nil
+}
+
+// StopRTSPServer shuts down the RTSP server.
+func StopRTSPServer() {
+ if rtspServer == nil {
+ return
+ }
+ rtspServer.stream.Close()
+ rtspServer.server.Close()
+ rtspServer = nil
+ rtspLogger.Info().Msg("RTSP server stopped")
+}
+
+// WriteNALU receives a raw H.264 Annex B frame and fans it out to all RTSP clients.
+func (rs *RTSPServer) WriteNALU(frame []byte, duration time.Duration) {
+ rs.pts += duration
+
+ nalus := splitAnnexB(frame)
+ if len(nalus) == 0 {
+ return
+ }
+
+ // Cache SPS/PPS and update the format so the SDP stays current for late-joiners.
+ for _, nalu := range nalus {
+ if len(nalu) == 0 {
+ continue
+ }
+ naluType := nalu[0] & 0x1F
+ switch naluType {
+ case 7: // SPS
+ rs.mu.Lock()
+ rs.sps = make([]byte, len(nalu))
+ copy(rs.sps, nalu)
+ rs.h264Format.SafeSetParams(rs.sps, rs.pps)
+ rs.mu.Unlock()
+ case 8: // PPS
+ rs.mu.Lock()
+ rs.pps = make([]byte, len(nalu))
+ copy(rs.pps, nalu)
+ rs.h264Format.SafeSetParams(rs.sps, rs.pps)
+ rs.mu.Unlock()
+ }
+ }
+
+ // Encode NALUs into RTP packets and write to all connected RTSP sessions.
+ pkts, err := rs.encoder.Encode(nalus)
+ if err != nil {
+ return
+ }
+
+ for _, pkt := range pkts {
+ rs.stream.WritePacketRTP(rs.media, pkt) //nolint:errcheck
+ }
+}
+
+// ---------------------------------------------------------------------------
+// gortsplib ServerHandler interface implementations
+// ---------------------------------------------------------------------------
+
+func (rs *RTSPServer) OnConnOpen(ctx *gortsplib.ServerHandlerOnConnOpenCtx) {
+ rtspLogger.Info().Str("addr", ctx.Conn.NetConn().RemoteAddr().String()).Msg("RTSP client connected")
+}
+
+func (rs *RTSPServer) OnConnClose(ctx *gortsplib.ServerHandlerOnConnCloseCtx) {
+ rtspLogger.Info().Str("addr", ctx.Conn.NetConn().RemoteAddr().String()).Msg("RTSP client disconnected")
+}
+
+func (rs *RTSPServer) OnSessionOpen(ctx *gortsplib.ServerHandlerOnSessionOpenCtx) {
+ rtspLogger.Debug().Msg("RTSP session opened")
+}
+
+func (rs *RTSPServer) OnSessionClose(ctx *gortsplib.ServerHandlerOnSessionCloseCtx) {
+ rtspLogger.Debug().Msg("RTSP session closed")
+}
+
+func (rs *RTSPServer) OnDescribe(_ *gortsplib.ServerHandlerOnDescribeCtx) (*base.Response, *gortsplib.ServerStream, error) {
+ return &base.Response{StatusCode: base.StatusOK}, rs.stream, nil
+}
+
+func (rs *RTSPServer) OnSetup(_ *gortsplib.ServerHandlerOnSetupCtx) (*base.Response, *gortsplib.ServerStream, error) {
+ return &base.Response{StatusCode: base.StatusOK}, rs.stream, nil
+}
+
+func (rs *RTSPServer) OnPlay(_ *gortsplib.ServerHandlerOnPlayCtx) (*base.Response, error) {
+ return &base.Response{StatusCode: base.StatusOK}, nil
+}
+
+// ---------------------------------------------------------------------------
+// Annex B parser — splits an Annex B byte stream into individual NAL units
+// ---------------------------------------------------------------------------
+
+func splitAnnexB(frame []byte) [][]byte {
+ var nalus [][]byte
+ start := -1
+ n := len(frame)
+
+ for i := 0; i <= n-3; i++ {
+ if frame[i] == 0 && frame[i+1] == 0 {
+ // 4-byte start code 00 00 00 01
+ if i <= n-4 && frame[i+2] == 0 && frame[i+3] == 1 {
+ if start >= 0 {
+ nalus = append(nalus, frame[start:i])
+ }
+ start = i + 4
+ i += 3
+ continue
+ }
+ // 3-byte start code 00 00 01
+ if frame[i+2] == 1 {
+ if start >= 0 {
+ nalus = append(nalus, frame[start:i])
+ }
+ start = i + 3
+ i += 2
+ continue
+ }
+ }
+ }
+
+ // Trailing NALU
+ if start >= 0 && start < n {
+ nalus = append(nalus, frame[start:n])
+ }
+
+ // If no start codes were found, treat the entire frame as a single raw NALU.
+ if len(nalus) == 0 && n > 0 {
+ return [][]byte{frame}
+ }
+
+ return nalus
+}
+
+// ---------------------------------------------------------------------------
+// JSON-RPC methods for RTSP configuration
+// ---------------------------------------------------------------------------
+
+type RTSPConfig struct {
+ Enabled bool `json:"enabled"`
+ Port int `json:"port"`
+}
+
+func rpcGetRTSPConfig() RTSPConfig {
+ return RTSPConfig{
+ Enabled: config.RTSPEnabled,
+ Port: config.RTSPPort,
+ }
+}
+
+func rpcSetRTSPConfig(enabled bool, port int) error {
+ if port <= 0 || port > 65535 {
+ port = 8554
+ }
+
+ wasEnabled := config.RTSPEnabled
+ wasPort := config.RTSPPort
+
+ config.RTSPEnabled = enabled
+ config.RTSPPort = port
+ if err := SaveConfig(); err != nil {
+ // Rollback
+ config.RTSPEnabled = wasEnabled
+ config.RTSPPort = wasPort
+ return fmt.Errorf("failed to save config: %w", err)
+ }
+
+ // Apply changes live
+ if wasEnabled && !enabled {
+ StopRTSPServer()
+ } else if !wasEnabled && enabled {
+ if err := StartRTSPServer(port); err != nil {
+ rtspLogger.Error().Err(err).Msg("failed to start RTSP server after config change")
+ return err
+ }
+ } else if enabled && wasPort != port {
+ StopRTSPServer()
+ if err := StartRTSPServer(port); err != nil {
+ rtspLogger.Error().Err(err).Msg("failed to restart RTSP server on new port")
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/serial.go b/serial.go
index 6189c1e03..e5d085f3f 100644
--- a/serial.go
+++ b/serial.go
@@ -68,9 +68,7 @@ func runATXControl() {
HDD: newLedHDDState,
}
- if currentSession != nil {
- writeJSONRPCEvent("atxState", atxState, currentSession)
- }
+ forEachSession(func(s *Session) { writeJSONRPCEvent("atxState", atxState, s) })
if mqttManager != nil {
mqttManager.publishATXState(atxState)
@@ -238,9 +236,7 @@ func runDCControl() {
// Update Prometheus metrics
updateDCMetrics(snapshot)
- if currentSession != nil {
- writeJSONRPCEvent("dcState", snapshot, currentSession)
- }
+ forEachSession(func(s *Session) { writeJSONRPCEvent("dcState", snapshot, s) })
if mqttManager != nil {
mqttManager.publishDCState(snapshot)
diff --git a/ui/src/components/ActionBar.tsx b/ui/src/components/ActionBar.tsx
index 7bde652c4..dfd5a25f4 100644
--- a/ui/src/components/ActionBar.tsx
+++ b/ui/src/components/ActionBar.tsx
@@ -16,6 +16,7 @@ import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { CommandLineIcon } from "@heroicons/react/20/solid";
import { SplitButtonGroup, SplitButtonPrimary, SplitButtonCaret } from "@components/SplitButton";
+import CastButton from "@components/CastButton";
import { cx } from "@/cva.config";
import {
@@ -288,6 +289,8 @@ export default function Actionbar({
}}
/>
+
+
{!isDetachedWindow && (
{
+ refreshStatus();
+ }, [refreshStatus]);
+
+ return (
+
+
+ (
+
+ )}
+ onClick={() => {
+ setDisableVideoFocusTrap(true);
+ if (!isCasting) {
+ discoverDevices();
+ }
+ }}
+ />
+
+
+
+ {isCasting ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+function CastingActive({
+ deviceName,
+ onStop,
+}: {
+ deviceName: string;
+ onStop: () => void;
+}) {
+ return (
+
+
+ Casting to {deviceName}
+
+
+
+ );
+}
+
+function DevicePicker({
+ devices,
+ isDiscovering,
+ isStarting,
+ error,
+ preferredDevice,
+ onSelect,
+ onSetPreferred,
+ onRefresh,
+}: {
+ devices: ChromecastDevice[];
+ isDiscovering: boolean;
+ isStarting: boolean;
+ error: string | null;
+ preferredDevice: { name: string; address: string; port: number } | null;
+ onSelect: (device: ChromecastDevice) => void;
+ onSetPreferred: (device: ChromecastDevice | null) => void;
+ onRefresh: () => void;
+}) {
+ if (isStarting) {
+ return (
+
+
+
+ Starting stream...
+
+
+ );
+ }
+
+ const isPreferred = (device: ChromecastDevice) =>
+ preferredDevice?.address === device.address &&
+ preferredDevice?.port === device.port;
+
+ return (
+
+
+
+ Chromecast Devices
+
+
+
+
+
+
+ {isDiscovering && devices.length === 0 && (
+
+
+
+ Searching...
+
+
+ )}
+
+ {!isDiscovering && devices.length === 0 && !error && (
+
+ No devices found. Make sure your Chromecast is on the same network.
+
+ )}
+
+ {error && (
+
{error}
+ )}
+
+ {devices.length > 0 && (
+
+ {devices.map(device => (
+
+ onSelect(device)}
+ >
+
+ {device.name || device.address}
+ {isPreferred(device) && (
+ (preferred)
+ )}
+
+
+ {
+ e.stopPropagation();
+ onSetPreferred(isPreferred(device) ? null : device);
+ }}
+ title={
+ isPreferred(device)
+ ? "Remove as preferred device"
+ : "Set as preferred device"
+ }
+ >
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/ui/src/hooks/useCast.ts b/ui/src/hooks/useCast.ts
new file mode 100644
index 000000000..e40f71f98
--- /dev/null
+++ b/ui/src/hooks/useCast.ts
@@ -0,0 +1,174 @@
+import { useCallback, useEffect, useState } from "react";
+
+import { useJsonRpc, JsonRpcResponse } from "@/hooks/useJsonRpc";
+import notifications from "@/notifications";
+
+export interface ChromecastDevice {
+ name: string;
+ uuid: string;
+ address: string;
+ port: number;
+}
+
+export interface PreferredDevice {
+ name: string;
+ address: string;
+ port: number;
+}
+
+export interface CastState {
+ isCasting: boolean;
+ activeDevice: { name: string; address: string; port: number } | null;
+ discoveredDevices: ChromecastDevice[];
+ isDiscovering: boolean;
+ isStarting: boolean;
+ error: string | null;
+ preferredDevice: PreferredDevice | null;
+}
+
+export function useCast() {
+ const { send } = useJsonRpc();
+ const [state, setState] = useState({
+ isCasting: false,
+ activeDevice: null,
+ discoveredDevices: [],
+ isDiscovering: false,
+ isStarting: false,
+ error: null,
+ preferredDevice: null,
+ });
+
+ // Load preferred device and casting status on mount
+ useEffect(() => {
+ send("getCastConfig", {}, (resp: JsonRpcResponse) => {
+ if ("error" in resp) return;
+ const cfg = resp.result as { preferredDevice: PreferredDevice | null };
+ setState(s => ({ ...s, preferredDevice: cfg.preferredDevice }));
+ });
+ send("getCastingStatus", {}, (resp: JsonRpcResponse) => {
+ if ("error" in resp) return;
+ const status = resp.result as { active: boolean; deviceName: string };
+ setState(s => ({
+ ...s,
+ isCasting: status.active,
+ activeDevice: status.active
+ ? { name: status.deviceName, address: "", port: 0 }
+ : null,
+ }));
+ });
+ }, [send]);
+
+ const discoverDevices = useCallback(() => {
+ setState(s => ({ ...s, isDiscovering: true, error: null }));
+ send("discoverChromecasts", {}, (resp: JsonRpcResponse) => {
+ if ("error" in resp) {
+ setState(s => ({
+ ...s,
+ isDiscovering: false,
+ error: resp.error?.message || "Discovery failed",
+ }));
+ return;
+ }
+ setState(s => ({
+ ...s,
+ isDiscovering: false,
+ discoveredDevices: (resp.result as ChromecastDevice[]) || [],
+ }));
+ });
+ }, [send]);
+
+ const startCasting = useCallback(
+ (device: ChromecastDevice) => {
+ setState(s => ({ ...s, isStarting: true, error: null }));
+ send(
+ "startCasting",
+ { address: device.address, port: device.port },
+ (resp: JsonRpcResponse) => {
+ if ("error" in resp) {
+ const msg = resp.error?.message || "Failed to start casting";
+ setState(s => ({ ...s, isStarting: false, error: msg }));
+ notifications.error(`Cast failed: ${msg}`);
+ return;
+ }
+ setState(s => ({
+ ...s,
+ isStarting: false,
+ isCasting: true,
+ activeDevice: {
+ name: device.name,
+ address: device.address,
+ port: device.port,
+ },
+ }));
+ notifications.success(`Casting to ${device.name}`);
+ },
+ );
+ },
+ [send],
+ );
+
+ const stopCasting = useCallback(() => {
+ send("stopCasting", {}, (resp: JsonRpcResponse) => {
+ if ("error" in resp) {
+ notifications.error("Failed to stop casting");
+ return;
+ }
+ setState(s => ({
+ ...s,
+ isCasting: false,
+ activeDevice: null,
+ }));
+ });
+ }, [send]);
+
+ const setPreferredDevice = useCallback(
+ (device: ChromecastDevice | null) => {
+ const name = device?.name || "";
+ const address = device?.address || "";
+ const port = device?.port || 0;
+ send(
+ "setPreferredCastDevice",
+ { name, address, port },
+ (resp: JsonRpcResponse) => {
+ if ("error" in resp) {
+ notifications.error("Failed to set preferred device");
+ return;
+ }
+ const pref = device
+ ? { name: device.name, address: device.address, port: device.port }
+ : null;
+ setState(s => ({ ...s, preferredDevice: pref }));
+ notifications.success(
+ device
+ ? `${device.name} set as preferred cast device`
+ : "Preferred cast device cleared",
+ );
+ },
+ );
+ },
+ [send],
+ );
+
+ const refreshStatus = useCallback(() => {
+ send("getCastingStatus", {}, (resp: JsonRpcResponse) => {
+ if ("error" in resp) return;
+ const status = resp.result as { active: boolean; deviceName: string };
+ setState(s => ({
+ ...s,
+ isCasting: status.active,
+ activeDevice: status.active
+ ? { name: status.deviceName, address: "", port: 0 }
+ : null,
+ }));
+ });
+ }, [send]);
+
+ return {
+ ...state,
+ discoverDevices,
+ startCasting,
+ stopCasting,
+ setPreferredDevice,
+ refreshStatus,
+ };
+}
diff --git a/ui/src/routes/devices.$id.settings.video.tsx b/ui/src/routes/devices.$id.settings.video.tsx
index 528f86024..fe9ed670b 100644
--- a/ui/src/routes/devices.$id.settings.video.tsx
+++ b/ui/src/routes/devices.$id.settings.video.tsx
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react";
import { useSettingsStore } from "@hooks/stores";
import { Button } from "@components/Button";
import { TextAreaWithLabel } from "@components/TextArea";
+import { InputFieldWithLabel } from "@components/InputField";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { SettingsItem } from "@components/SettingsItem";
import { SettingsPageHeader } from "@components/SettingsPageheader";
@@ -134,6 +135,73 @@ export default function SettingsVideoRoute() {
});
};
+ // RTSP settings
+ const [rtspEnabled, setRtspEnabled] = useState(true);
+ const [rtspPort, setRtspPort] = useState("8554");
+
+ // Cast settings
+ const [castAppId, setCastAppId] = useState("F311D863");
+ const [preferredDeviceName, setPreferredDeviceName] = useState(null);
+
+ useEffect(() => {
+ send("getRTSPConfig", {}, (resp: JsonRpcResponse) => {
+ if ("error" in resp) return;
+ const cfg = resp.result as { enabled: boolean; port: number };
+ setRtspEnabled(cfg.enabled);
+ setRtspPort(String(cfg.port));
+ });
+ send("getCastConfig", {}, (resp: JsonRpcResponse) => {
+ if ("error" in resp) return;
+ const cfg = resp.result as {
+ receiverAppId: string;
+ preferredDevice: { name: string; address: string; port: number } | null;
+ };
+ setCastAppId(cfg.receiverAppId);
+ setPreferredDeviceName(cfg.preferredDevice?.name || null);
+ });
+ }, [send]);
+
+ const handleRtspToggle = (enabled: boolean) => {
+ const port = parseInt(rtspPort, 10) || 8554;
+ send("setRTSPConfig", { enabled, port }, (resp: JsonRpcResponse) => {
+ if ("error" in resp) {
+ notifications.error(`Failed to update RTSP config: ${resp.error?.message}`);
+ return;
+ }
+ setRtspEnabled(enabled);
+ notifications.success(enabled ? "RTSP server enabled" : "RTSP server disabled");
+ });
+ };
+
+ const handleCastAppIdChange = () => {
+ if (!castAppId.trim()) {
+ notifications.error("App ID cannot be empty");
+ return;
+ }
+ send("setCastConfig", { receiverAppId: castAppId.trim() }, (resp: JsonRpcResponse) => {
+ if ("error" in resp) {
+ notifications.error(`Failed to update Cast config: ${resp.error?.message}`);
+ return;
+ }
+ notifications.success("Cast receiver app ID updated");
+ });
+ };
+
+ const handleRtspPortChange = () => {
+ const port = parseInt(rtspPort, 10);
+ if (!port || port < 1 || port > 65535) {
+ notifications.error("Invalid port number");
+ return;
+ }
+ send("setRTSPConfig", { enabled: rtspEnabled, port }, (resp: JsonRpcResponse) => {
+ if ("error" in resp) {
+ notifications.error(`Failed to update RTSP port: ${resp.error?.message}`);
+ return;
+ }
+ notifications.success(`RTSP port set to ${port}`);
+ });
+ };
+
const [debugInfo, setDebugInfo] = useState(null);
const [debugInfoLoading, setDebugInfoLoading] = useState(false);
const getDebugInfo = useCallback(() => {
@@ -302,6 +370,101 @@ export default function SettingsVideoRoute() {
+ {/* RTSP Streaming */}
+
+
+ handleRtspToggle(e.target.value === "enabled")}
+ />
+
+ {rtspEnabled && (
+
+ :${rtspPort}/stream`}
+ >
+
+ setRtspPort(e.target.value)}
+ size="SM"
+ />
+
+
+
+
+ )}
+
+
+ {/* Chromecast Settings */}
+
+
+ {preferredDeviceName ? (
+ {
+ send("setPreferredCastDevice", { name: "", address: "", port: 0 }, (resp: JsonRpcResponse) => {
+ if ("error" in resp) {
+ notifications.error("Failed to clear preferred device");
+ return;
+ }
+ setPreferredDeviceName(null);
+ notifications.success("Preferred cast device cleared");
+ });
+ }}
+ />
+ ) : (
+ None
+ )}
+
+
+
+ setCastAppId(e.target.value)}
+ size="SM"
+ />
+
+
+
+
+
{debugMode && (
0
+}
+
+// getFirstSession returns an arbitrary session (for diagnostics). May return nil.
+func getFirstSession() *Session {
+ sessionsMu.RLock()
+ defer sessionsMu.RUnlock()
+ for s := range sessions {
+ return s
+ }
+ return nil
+}
func handleWebRTCSession(c *gin.Context) {
var req WebRTCSessionRequest
@@ -257,19 +304,7 @@ func handleWebRTCSession(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err})
return
}
- if currentSession != nil {
- writeJSONRPCEvent("otherSessionConnected", nil, currentSession)
- peerConn := currentSession.peerConnection
- go func() {
- time.Sleep(1 * time.Second)
- _ = peerConn.Close()
- }()
- }
-
- // Cancel any ongoing keyboard macro when session changes
- cancelKeyboardMacro()
-
- currentSession = session
+ addSession(session)
c.JSON(http.StatusOK, gin.H{"sd": sd})
}
@@ -334,6 +369,8 @@ func handleWebRTCSignalWsMessages(
connectionID string,
scopedLogger *zerolog.Logger,
) error {
+ var wsSession *Session // tracks the session for this WebSocket connection
+
runCtx, cancelRun := context.WithCancel(context.Background())
defer func() {
if isCloudConnection {
@@ -464,11 +501,13 @@ func handleWebRTCSignalWsMessages(
metricConnectionSessionRequestCount.WithLabelValues(sourceType, source).Inc()
metricConnectionLastSessionRequestTimestamp.WithLabelValues(sourceType, source).SetToCurrentTime()
- err = handleSessionRequest(runCtx, wsCon, req, isCloudConnection, source, &l)
+ var newSession *Session
+ newSession, err = handleSessionRequest(runCtx, wsCon, req, isCloudConnection, source, &l)
if err != nil {
l.Warn().Str("error", err.Error()).Msg("error starting new session")
continue
}
+ wsSession = newSession
} else if message.Type == "new-ice-candidate" {
l.Info().Str("data", string(message.Data)).Msg("The client sent us a new ICE candidate")
var candidate webrtc.ICECandidateInit
@@ -486,13 +525,13 @@ func handleWebRTCSignalWsMessages(
l.Info().Str("data", fmt.Sprintf("%v", candidate)).Msg("unmarshalled incoming ICE candidate")
- if currentSession == nil {
- l.Warn().Msg("no current session, skipping incoming ICE candidate")
+ if wsSession == nil {
+ l.Warn().Msg("no session for this connection, skipping incoming ICE candidate")
continue
}
- l.Info().Str("data", fmt.Sprintf("%v", candidate)).Msg("adding incoming ICE candidate to current session")
- if err = currentSession.peerConnection.AddICECandidate(candidate); err != nil {
+ l.Info().Str("data", fmt.Sprintf("%v", candidate)).Msg("adding incoming ICE candidate to session")
+ if err = wsSession.peerConnection.AddICECandidate(candidate); err != nil {
l.Warn().Str("error", err.Error()).Msg("failed to add incoming ICE candidate to our peer connection")
}
}
@@ -955,10 +994,10 @@ func handleDiagnosticsDownload(c *gin.Context) {
GetSessionInfo: func() diagnostics.SessionInfo {
info := diagnostics.SessionInfo{
ActiveSessions: getActiveSessions(),
- HasCurrentSession: currentSession != nil,
+ HasCurrentSession: anySession(),
}
- if currentSession != nil {
- sessionInfo := currentSession.GetDiagnosticsInfo()
+ if s := getFirstSession(); s != nil {
+ sessionInfo := s.GetDiagnosticsInfo()
info.ICEConnectionState = sessionInfo.ICEConnectionState
info.SignalingState = sessionInfo.SignalingState
info.ConnectionState = sessionInfo.ConnectionState
diff --git a/webrtc.go b/webrtc.go
index 757961563..9ab60d17e 100644
--- a/webrtc.go
+++ b/webrtc.go
@@ -419,12 +419,8 @@ func newSession(config SessionConfig) (*Session, error) {
_ = peerConnection.Close()
}
if connectionState == webrtc.ICEConnectionStateClosed {
- scopedLogger.Debug().Msg("ICE Connection State is closed, unmounting virtual media")
- if session == currentSession {
- // Cancel any ongoing keyboard report multi when session closes
- cancelKeyboardMacro()
- currentSession = nil
- }
+ scopedLogger.Debug().Msg("ICE Connection State is closed, cleaning up session")
+ removeSession(session)
// Stop RPC processor
if session.rpcQueue != nil {
close(session.rpcQueue)
@@ -462,12 +458,12 @@ func newSession(config SessionConfig) (*Session, error) {
}
func onActiveSessionsChanged() {
- notifyFailsafeMode(currentSession)
+ forEachSession(notifyFailsafeMode)
requestDisplayUpdate(false, "active_sessions_changed")
}
func onFirstSessionConnected() {
- notifyFailsafeMode(currentSession)
+ forEachSession(notifyFailsafeMode)
_ = nativeInstance.VideoStart()
stopVideoSleepModeTicker()
}