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 && (
+ ); +} + +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 && ( + + )} +
+ ); +} 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 ? ( +
+ + + {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() }