Squash integration of the reviewed Go core, frontend audit fixes, release tooling, and deployment hygiene. CI passed all Server/Client jobs.
830 lines
25 KiB
Go
830 lines
25 KiB
Go
package nativecontrol
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"unicode"
|
|
|
|
"github.com/TNTcraftHIM/Screener/internal/client/loopback"
|
|
"github.com/TNTcraftHIM/Screener/internal/client/nativecapture"
|
|
"github.com/TNTcraftHIM/Screener/internal/client/nativehost"
|
|
"github.com/TNTcraftHIM/Screener/internal/client/nativeviewer"
|
|
"github.com/pion/webrtc/v4"
|
|
)
|
|
|
|
const (
|
|
maxSDPBytes = 48 * 1024
|
|
maxCandidateBytes = 4096
|
|
maxSTUNURLBytes = 512
|
|
maxICEServers = 8
|
|
maxURLsPerServer = 8
|
|
maxEdgeCapacity = 3
|
|
)
|
|
|
|
var identityPattern = regexp.MustCompile("^[A-Za-z0-9_-]{8,256}$")
|
|
|
|
type Session struct {
|
|
captureProcess string
|
|
capabilities nativecapture.Capabilities
|
|
portMapping bool
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
events chan any
|
|
hostEvents chan nativehost.Event
|
|
viewerEvents chan nativeviewer.Event
|
|
|
|
mu sync.Mutex
|
|
host *nativehost.Session
|
|
viewer *nativeviewer.Session
|
|
closed bool
|
|
}
|
|
|
|
type outboundMediaSession interface {
|
|
SetAnswer(string, webrtc.SessionDescription) error
|
|
AddCandidate(string, *webrtc.ICECandidateInit) error
|
|
CloseEdge(string)
|
|
}
|
|
|
|
func New(
|
|
captureProcess string,
|
|
capabilities nativecapture.Capabilities,
|
|
portMapping bool,
|
|
) *Session {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
session := &Session{
|
|
captureProcess: captureProcess,
|
|
capabilities: capabilities,
|
|
portMapping: portMapping,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
events: make(chan any, 256),
|
|
hostEvents: make(chan nativehost.Event, 256),
|
|
viewerEvents: make(chan nativeviewer.Event, 256),
|
|
}
|
|
go session.relayEvents()
|
|
return session
|
|
}
|
|
|
|
func (session *Session) Events() <-chan any {
|
|
return session.events
|
|
}
|
|
|
|
func (session *Session) Handle(ctx context.Context, payload []byte) (any, error) {
|
|
var envelope requestEnvelope
|
|
if err := decodeEnvelope(payload, &envelope); err != nil {
|
|
return nil, err
|
|
}
|
|
switch envelope.Type {
|
|
case "capture-options":
|
|
var request captureOptionsRequest
|
|
if err := decodeStrict(payload, &request); err != nil || request.Type != envelope.Type {
|
|
return nil, errors.New("native capture-options request is invalid")
|
|
}
|
|
return captureOptionsResponse{
|
|
responseEnvelope: response(envelope, "capture-options"),
|
|
Adapters: session.capabilities.Adapters,
|
|
}, nil
|
|
case "list-sources":
|
|
var request listSourcesRequest
|
|
if err := decodeStrict(payload, &request); err != nil || request.Type != envelope.Type {
|
|
return nil, errors.New("native list-sources request is invalid")
|
|
}
|
|
targets, err := nativecapture.ListSources(session.ctx, session.captureProcess)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return sourceListResponse{
|
|
responseEnvelope: response(envelope, "source-list"),
|
|
Sources: targets,
|
|
}, nil
|
|
case "source-preview":
|
|
var request sourcePreviewRequest
|
|
if err := decodeStrict(payload, &request); err != nil || request.Type != envelope.Type {
|
|
return nil, errors.New("native source-preview request is invalid")
|
|
}
|
|
preview, err := nativecapture.PreviewSource(
|
|
session.ctx,
|
|
session.captureProcess,
|
|
request.Source,
|
|
)
|
|
data := ""
|
|
if err == nil {
|
|
data = base64.StdEncoding.EncodeToString(preview)
|
|
}
|
|
return sourcePreviewResponse{
|
|
responseEnvelope: response(envelope, "source-preview"),
|
|
SourceKey: captureTargetKey(request.Source),
|
|
Mime: "image/bmp",
|
|
Data: data,
|
|
}, nil
|
|
case "start-share":
|
|
var request startShareRequest
|
|
if err := decodeStrict(payload, &request); err != nil ||
|
|
request.Type != envelope.Type ||
|
|
!identityPattern.MatchString(request.ShareID) ||
|
|
request.EdgeCapacity < 1 || request.EdgeCapacity > maxEdgeCapacity ||
|
|
(request.Codec != "auto" && request.Codec != "h264" && request.Codec != "vp8") ||
|
|
(request.Codec == "vp8" && !session.capabilities.SoftwareVP8) ||
|
|
!validQualitySettings(request.Profile) {
|
|
return nil, errors.New("native start-share request is invalid")
|
|
}
|
|
return session.startShare(ctx, envelope, request)
|
|
case "update-share":
|
|
var request updateShareRequest
|
|
if err := decodeStrict(payload, &request); err != nil ||
|
|
request.Type != envelope.Type ||
|
|
!validIdentities(request.ShareID) ||
|
|
!validQualitySettings(request.Profile) {
|
|
return nil, errors.New("native update-share request is invalid")
|
|
}
|
|
host := session.current(request.ShareID)
|
|
if host == nil {
|
|
return nil, errors.New("native share does not exist")
|
|
}
|
|
if err := host.UpdateProfile(nativeQualityProfile(request.Profile)); err != nil {
|
|
return nil, err
|
|
}
|
|
return shareUpdatedResponse{
|
|
responseEnvelope: response(envelope, "share-updated"),
|
|
ShareID: request.ShareID,
|
|
}, nil
|
|
case "replace-share-source":
|
|
var request replaceShareSourceRequest
|
|
if err := decodeStrict(payload, &request); err != nil ||
|
|
request.Type != envelope.Type ||
|
|
!validIdentities(request.ShareID) {
|
|
return nil, errors.New("native replace-share-source request is invalid")
|
|
}
|
|
host := session.current(request.ShareID)
|
|
if host == nil {
|
|
return nil, errors.New("native share does not exist")
|
|
}
|
|
audio := request.Audio && session.capabilities.Summary().AudioFor(
|
|
request.Source.Kind,
|
|
)
|
|
if err := host.ReplaceSource(ctx, nativecapture.VideoOptions{
|
|
Target: request.Source,
|
|
AdapterIndex: request.AdapterIndex,
|
|
EncoderIndex: request.EncoderIndex,
|
|
}, audio); err != nil {
|
|
return nil, err
|
|
}
|
|
return shareSourceReplacedResponse{
|
|
responseEnvelope: response(envelope, "share-source-replaced"),
|
|
ShareID: request.ShareID,
|
|
}, nil
|
|
case "prepare-edge":
|
|
var request prepareEdgeRequest
|
|
if err := decodeStrict(payload, &request); err != nil ||
|
|
request.Type != envelope.Type ||
|
|
!validIdentities(request.ShareID, request.ConnectionID) {
|
|
return nil, errors.New("native prepare-edge request is invalid")
|
|
}
|
|
return session.prepareEdge(envelope, request)
|
|
case "prepare-local-edge":
|
|
var request prepareLocalEdgeRequest
|
|
if err := decodeStrict(payload, &request); err != nil ||
|
|
request.Type != envelope.Type ||
|
|
!validIdentities(request.ShareID, request.ConnectionID) {
|
|
return nil, errors.New("native prepare-local-edge request is invalid")
|
|
}
|
|
return session.prepareLocalEdge(envelope, request)
|
|
case "receive-offer":
|
|
var request receiveOfferRequest
|
|
if err := decodeStrict(payload, &request); err != nil ||
|
|
request.Type != envelope.Type ||
|
|
!validIdentities(request.ShareID, request.ConnectionID) ||
|
|
request.EdgeCapacity < 1 || request.EdgeCapacity > maxEdgeCapacity ||
|
|
len(request.SDP) == 0 || len(request.SDP) > maxSDPBytes {
|
|
return nil, errors.New("native receive-offer request is invalid")
|
|
}
|
|
return session.receiveOffer(envelope, request)
|
|
case "receive-candidate":
|
|
var request receiveCandidateRequest
|
|
if err := decodeStrict(payload, &request); err != nil ||
|
|
request.Type != envelope.Type ||
|
|
!validIdentities(request.ShareID, request.ConnectionID) ||
|
|
!validCandidate(request.Candidate) {
|
|
return nil, errors.New("native receive-candidate request is invalid")
|
|
}
|
|
viewer := session.currentViewer(request.ShareID)
|
|
if viewer == nil {
|
|
return nil, errors.New("native Viewer session does not exist")
|
|
}
|
|
if err := viewer.AddReceiverCandidate(request.ConnectionID, request.Candidate); err != nil {
|
|
return nil, err
|
|
}
|
|
return response(envelope, "receive-candidate-accepted"), nil
|
|
case "close-receiver":
|
|
var request closeReceiverRequest
|
|
if err := decodeStrict(payload, &request); err != nil ||
|
|
request.Type != envelope.Type ||
|
|
!validIdentities(request.ShareID, request.ConnectionID) {
|
|
return nil, errors.New("native close-receiver request is invalid")
|
|
}
|
|
if viewer := session.currentViewer(request.ShareID); viewer != nil {
|
|
viewer.CloseReceiver(request.ConnectionID)
|
|
}
|
|
return response(envelope, "receiver-closed"), nil
|
|
case "stop-receive":
|
|
var request stopReceiveRequest
|
|
if err := decodeStrict(payload, &request); err != nil ||
|
|
request.Type != envelope.Type || !validIdentities(request.ShareID) {
|
|
return nil, errors.New("native stop-receive request is invalid")
|
|
}
|
|
session.stopViewer(request.ShareID)
|
|
return response(envelope, "receive-stopped"), nil
|
|
case "edge-answer":
|
|
var request edgeAnswerRequest
|
|
if err := decodeStrict(payload, &request); err != nil ||
|
|
request.Type != envelope.Type ||
|
|
!validIdentities(request.ShareID, request.ConnectionID) ||
|
|
len(request.SDP) == 0 || len(request.SDP) > maxSDPBytes {
|
|
return nil, errors.New("native edge-answer request is invalid")
|
|
}
|
|
media := session.currentMedia(request.ShareID)
|
|
if media == nil {
|
|
return nil, errors.New("native media session does not exist")
|
|
}
|
|
if err := media.SetAnswer(
|
|
request.ConnectionID,
|
|
webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: request.SDP},
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
return response(envelope, "edge-answer-accepted"), nil
|
|
case "edge-candidate":
|
|
var request edgeCandidateRequest
|
|
if err := decodeStrict(payload, &request); err != nil ||
|
|
request.Type != envelope.Type ||
|
|
!validIdentities(request.ShareID, request.ConnectionID) ||
|
|
!validCandidate(request.Candidate) {
|
|
return nil, errors.New("native edge-candidate request is invalid")
|
|
}
|
|
media := session.currentMedia(request.ShareID)
|
|
if media == nil {
|
|
return nil, errors.New("native media session does not exist")
|
|
}
|
|
if err := media.AddCandidate(request.ConnectionID, request.Candidate); err != nil {
|
|
return nil, err
|
|
}
|
|
return response(envelope, "edge-candidate-accepted"), nil
|
|
case "close-edge":
|
|
var request closeEdgeRequest
|
|
if err := decodeStrict(payload, &request); err != nil ||
|
|
request.Type != envelope.Type ||
|
|
!validIdentities(request.ShareID, request.ConnectionID) {
|
|
return nil, errors.New("native close-edge request is invalid")
|
|
}
|
|
if media := session.currentMedia(request.ShareID); media != nil {
|
|
media.CloseEdge(request.ConnectionID)
|
|
}
|
|
return response(envelope, "edge-closed"), nil
|
|
case "stop-share":
|
|
var request stopShareRequest
|
|
if err := decodeStrict(payload, &request); err != nil ||
|
|
request.Type != envelope.Type ||
|
|
!identityPattern.MatchString(request.ShareID) {
|
|
return nil, errors.New("native stop-share request is invalid")
|
|
}
|
|
if err := session.stopShare(request.ShareID); err != nil {
|
|
return nil, err
|
|
}
|
|
return response(envelope, "share-stopped"), nil
|
|
case "pause-share":
|
|
var request pauseShareRequest
|
|
if err := decodeStrict(payload, &request); err != nil ||
|
|
request.Type != envelope.Type ||
|
|
!validIdentities(request.ShareID) {
|
|
return nil, errors.New("native pause-share request is invalid")
|
|
}
|
|
host := session.current(request.ShareID)
|
|
if host == nil {
|
|
return nil, errors.New("native share does not exist")
|
|
}
|
|
host.SetPaused(request.Paused)
|
|
return response(envelope, "share-paused"), nil
|
|
default:
|
|
return nil, errors.New("native control message is unsupported")
|
|
}
|
|
}
|
|
|
|
func (session *Session) Close() error {
|
|
session.mu.Lock()
|
|
if session.closed {
|
|
session.mu.Unlock()
|
|
return nil
|
|
}
|
|
session.closed = true
|
|
host := session.host
|
|
viewer := session.viewer
|
|
session.host = nil
|
|
session.viewer = nil
|
|
session.mu.Unlock()
|
|
session.cancel()
|
|
if host != nil {
|
|
_ = host.Close()
|
|
}
|
|
if viewer != nil {
|
|
_ = viewer.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (session *Session) startShare(
|
|
ctx context.Context,
|
|
envelope requestEnvelope,
|
|
request startShareRequest,
|
|
) (any, error) {
|
|
session.mu.Lock()
|
|
if session.closed || session.host != nil || session.viewer != nil {
|
|
session.mu.Unlock()
|
|
return nil, errors.New("native share is already active")
|
|
}
|
|
session.mu.Unlock()
|
|
profile := nativeQualityProfile(request.Profile)
|
|
host, err := nativehost.Start(ctx, nativehost.Options{
|
|
ShareID: request.ShareID,
|
|
CaptureProcess: session.captureProcess,
|
|
Video: nativecapture.VideoOptions{
|
|
Target: request.Source,
|
|
Codec: request.Codec,
|
|
AdapterIndex: request.AdapterIndex,
|
|
EncoderIndex: request.EncoderIndex,
|
|
Profile: profile.Video,
|
|
},
|
|
Profile: profile,
|
|
EdgeCapacity: request.EdgeCapacity,
|
|
AudioEnabled: request.Audio && session.capabilities.Summary().AudioFor(
|
|
request.Source.Kind,
|
|
),
|
|
PortMapping: session.portMapping,
|
|
Events: session.hostEvents,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
session.mu.Lock()
|
|
if session.closed || session.host != nil || session.viewer != nil {
|
|
session.mu.Unlock()
|
|
_ = host.Close()
|
|
return nil, errors.New("native share is unavailable")
|
|
}
|
|
session.host = host
|
|
session.mu.Unlock()
|
|
go session.watchHost(host)
|
|
return shareStartedResponse{
|
|
responseEnvelope: response(envelope, "share-started"),
|
|
ShareID: request.ShareID,
|
|
Audio: host.HasAudio(),
|
|
Codec: host.Codec(),
|
|
}, nil
|
|
}
|
|
|
|
func validQualitySettings(settings qualitySettings) bool {
|
|
return nativeQualityProfile(settings).Valid()
|
|
}
|
|
|
|
func nativeQualityProfile(settings qualitySettings) nativehost.QualityProfile {
|
|
width, height := uint32(0), uint32(0)
|
|
switch settings.Resolution {
|
|
case "480p":
|
|
width, height = 854, 480
|
|
case "720p":
|
|
width, height = 1280, 720
|
|
case "1080p":
|
|
width, height = 1920, 1080
|
|
case "1440p":
|
|
width, height = 2560, 1440
|
|
}
|
|
audioBitrate := 0
|
|
switch settings.ScreenAudioQuality {
|
|
case "", "music":
|
|
audioBitrate = 128_000
|
|
case "saver":
|
|
audioBitrate = 64_000
|
|
case "very-high":
|
|
audioBitrate = 192_000
|
|
}
|
|
return nativehost.QualityProfile{
|
|
Video: nativecapture.VideoProfile{
|
|
Width: width,
|
|
Height: height,
|
|
Framerate: settings.MaxFramerate,
|
|
Bitrate: settings.MaxBitrate,
|
|
Preference: settings.DegradationPreference,
|
|
},
|
|
AudioBitrate: audioBitrate,
|
|
}
|
|
}
|
|
|
|
func (session *Session) prepareEdge(
|
|
envelope requestEnvelope,
|
|
request prepareEdgeRequest,
|
|
) (any, error) {
|
|
servers, err := pionICEServers(request.ICEServers)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var offer webrtc.SessionDescription
|
|
if host := session.current(request.ShareID); host != nil && request.SourceConnectionID == "" {
|
|
offer, err = host.PrepareEdge(request.ConnectionID, servers)
|
|
} else if viewer := session.currentViewer(request.ShareID); viewer != nil &&
|
|
identityPattern.MatchString(request.SourceConnectionID) {
|
|
offer, err = viewer.PrepareEdge(
|
|
request.SourceConnectionID,
|
|
request.ConnectionID,
|
|
servers,
|
|
)
|
|
} else {
|
|
return nil, errors.New("native media source does not exist")
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return edgeOfferResponse{
|
|
responseEnvelope: response(envelope, "edge-offer"),
|
|
ShareID: request.ShareID,
|
|
ConnectionID: request.ConnectionID,
|
|
SDP: offer.SDP,
|
|
}, nil
|
|
}
|
|
|
|
func (session *Session) prepareLocalEdge(
|
|
envelope requestEnvelope,
|
|
request prepareLocalEdgeRequest,
|
|
) (any, error) {
|
|
var offer webrtc.SessionDescription
|
|
var err error
|
|
if host := session.current(request.ShareID); host != nil && request.SourceConnectionID == "" {
|
|
offer, err = host.PrepareLocalEdge(request.ConnectionID)
|
|
} else if viewer := session.currentViewer(request.ShareID); viewer != nil &&
|
|
identityPattern.MatchString(request.SourceConnectionID) {
|
|
offer, err = viewer.PrepareLocalEdge(
|
|
request.SourceConnectionID,
|
|
request.ConnectionID,
|
|
)
|
|
} else {
|
|
return nil, errors.New("native media source does not exist")
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return edgeOfferResponse{
|
|
responseEnvelope: response(envelope, "edge-offer"),
|
|
ShareID: request.ShareID,
|
|
ConnectionID: request.ConnectionID,
|
|
SDP: offer.SDP,
|
|
}, nil
|
|
}
|
|
|
|
func (session *Session) receiveOffer(
|
|
envelope requestEnvelope,
|
|
request receiveOfferRequest,
|
|
) (any, error) {
|
|
servers, err := pionICEServers(request.ICEServers)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
viewer, err := session.ensureViewer(request.ShareID, request.EdgeCapacity)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
answer, audio, codec, err := viewer.AcceptOffer(
|
|
request.ConnectionID,
|
|
webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: request.SDP},
|
|
servers,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return receiveAnswerResponse{
|
|
responseEnvelope: response(envelope, "receive-answer"),
|
|
ShareID: request.ShareID,
|
|
ConnectionID: request.ConnectionID,
|
|
SDP: answer.SDP,
|
|
Audio: audio,
|
|
Codec: codec,
|
|
}, nil
|
|
}
|
|
|
|
func (session *Session) stopShare(shareID string) error {
|
|
session.mu.Lock()
|
|
host := session.host
|
|
if host == nil || host.ShareID() != shareID {
|
|
session.mu.Unlock()
|
|
return errors.New("native share does not exist")
|
|
}
|
|
session.host = nil
|
|
session.mu.Unlock()
|
|
return host.Close()
|
|
}
|
|
|
|
func (session *Session) current(shareID string) *nativehost.Session {
|
|
session.mu.Lock()
|
|
defer session.mu.Unlock()
|
|
if session.closed || session.host == nil || session.host.ShareID() != shareID {
|
|
return nil
|
|
}
|
|
return session.host
|
|
}
|
|
|
|
func (session *Session) ensureViewer(
|
|
shareID string,
|
|
edgeCapacity int,
|
|
) (*nativeviewer.Session, error) {
|
|
session.mu.Lock()
|
|
if session.closed || session.host != nil {
|
|
session.mu.Unlock()
|
|
return nil, errors.New("native media role is unavailable")
|
|
}
|
|
if session.viewer != nil {
|
|
viewer := session.viewer
|
|
session.mu.Unlock()
|
|
if viewer.ShareID() != shareID {
|
|
return nil, errors.New("native Viewer session identity changed")
|
|
}
|
|
return viewer, nil
|
|
}
|
|
session.mu.Unlock()
|
|
viewer, err := nativeviewer.Start(session.ctx, nativeviewer.Options{
|
|
ShareID: shareID, EdgeCapacity: edgeCapacity,
|
|
PortMapping: session.portMapping, Events: session.viewerEvents,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
session.mu.Lock()
|
|
if session.closed || session.host != nil || session.viewer != nil {
|
|
session.mu.Unlock()
|
|
_ = viewer.Close()
|
|
return nil, errors.New("native media role is unavailable")
|
|
}
|
|
session.viewer = viewer
|
|
session.mu.Unlock()
|
|
return viewer, nil
|
|
}
|
|
|
|
func (session *Session) currentViewer(shareID string) *nativeviewer.Session {
|
|
session.mu.Lock()
|
|
defer session.mu.Unlock()
|
|
if session.closed || session.viewer == nil || session.viewer.ShareID() != shareID {
|
|
return nil
|
|
}
|
|
return session.viewer
|
|
}
|
|
|
|
func (session *Session) currentMedia(shareID string) outboundMediaSession {
|
|
session.mu.Lock()
|
|
defer session.mu.Unlock()
|
|
if session.closed {
|
|
return nil
|
|
}
|
|
if session.host != nil && session.host.ShareID() == shareID {
|
|
return session.host
|
|
}
|
|
if session.viewer != nil && session.viewer.ShareID() == shareID {
|
|
return session.viewer
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (session *Session) stopViewer(shareID string) {
|
|
session.mu.Lock()
|
|
viewer := session.viewer
|
|
if viewer == nil || viewer.ShareID() != shareID {
|
|
session.mu.Unlock()
|
|
return
|
|
}
|
|
session.viewer = nil
|
|
session.mu.Unlock()
|
|
_ = viewer.Close()
|
|
}
|
|
|
|
func (session *Session) watchHost(host *nativehost.Session) {
|
|
err, open := <-host.Done()
|
|
session.mu.Lock()
|
|
if session.host == host {
|
|
session.host = nil
|
|
}
|
|
session.mu.Unlock()
|
|
if open {
|
|
session.emit(shareEndedEvent{
|
|
Version: loopback.ProtocolVersion,
|
|
Type: "share-ended",
|
|
ShareID: host.ShareID(),
|
|
Failed: err != nil,
|
|
})
|
|
}
|
|
}
|
|
|
|
func (session *Session) relayEvents() {
|
|
for {
|
|
select {
|
|
case event := <-session.hostEvents:
|
|
session.emit(eventMessage(event))
|
|
case event := <-session.viewerEvents:
|
|
session.emit(viewerEventMessage(event))
|
|
case <-session.ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func viewerEventMessage(event nativeviewer.Event) any {
|
|
base := eventEnvelope{
|
|
Version: loopback.ProtocolVersion,
|
|
Type: event.Type,
|
|
ShareID: event.ShareID,
|
|
ConnectionID: event.ConnectionID,
|
|
}
|
|
switch event.Type {
|
|
case "edge-candidate":
|
|
return edgeCandidateEvent{eventEnvelope: base, Candidate: event.Candidate}
|
|
case "edge-state":
|
|
return edgeStateEvent{eventEnvelope: base, State: event.State}
|
|
case "edge-path":
|
|
return edgePathEvent{
|
|
eventEnvelope: base,
|
|
LocalType: event.LocalType,
|
|
RemoteType: event.RemoteType,
|
|
NatTraversalPath: event.NatTraversalPath,
|
|
}
|
|
case "edge-quality":
|
|
quality := event.Quality
|
|
return edgeQualityEvent{
|
|
eventEnvelope: base,
|
|
SampleTimestampMs: quality.SampleTimestampMs,
|
|
SampleWindowMs: quality.SampleWindowMs,
|
|
RTPStatsID: quality.RTPStatsID,
|
|
TrackIdentifier: quality.TrackIdentifier,
|
|
State: quality.State,
|
|
Reason: quality.Reason,
|
|
IntervalFramesEncoded: quality.IntervalFramesEncoded,
|
|
FramesPerSecond: quality.FramesPerSecond,
|
|
BitrateKbps: quality.BitrateKbps,
|
|
AvailableOutgoingKbps: quality.AvailableOutgoingKbps,
|
|
Width: quality.Width,
|
|
Height: quality.Height,
|
|
}
|
|
default:
|
|
return edgeStateEvent{eventEnvelope: base, State: event.State}
|
|
}
|
|
}
|
|
|
|
func (session *Session) emit(event any) {
|
|
select {
|
|
case session.events <- event:
|
|
case <-session.ctx.Done():
|
|
}
|
|
}
|
|
|
|
func eventMessage(event nativehost.Event) any {
|
|
base := eventEnvelope{
|
|
Version: loopback.ProtocolVersion,
|
|
Type: event.Type,
|
|
ShareID: event.ShareID,
|
|
ConnectionID: event.ConnectionID,
|
|
}
|
|
switch event.Type {
|
|
case "edge-candidate":
|
|
return edgeCandidateEvent{eventEnvelope: base, Candidate: event.Candidate}
|
|
case "edge-state":
|
|
return edgeStateEvent{eventEnvelope: base, State: event.State}
|
|
case "edge-path":
|
|
return edgePathEvent{
|
|
eventEnvelope: base,
|
|
LocalType: event.LocalType,
|
|
RemoteType: event.RemoteType,
|
|
NatTraversalPath: event.NatTraversalPath,
|
|
}
|
|
case "edge-quality":
|
|
quality := event.Quality
|
|
return edgeQualityEvent{
|
|
eventEnvelope: base,
|
|
SampleTimestampMs: quality.SampleTimestampMs,
|
|
SampleWindowMs: quality.SampleWindowMs,
|
|
RTPStatsID: quality.RTPStatsID,
|
|
TrackIdentifier: quality.TrackIdentifier,
|
|
State: quality.State,
|
|
Reason: quality.Reason,
|
|
IntervalFramesEncoded: quality.IntervalFramesEncoded,
|
|
FramesPerSecond: quality.FramesPerSecond,
|
|
BitrateKbps: quality.BitrateKbps,
|
|
AvailableOutgoingKbps: quality.AvailableOutgoingKbps,
|
|
Width: quality.Width,
|
|
Height: quality.Height,
|
|
}
|
|
default:
|
|
return captureStateEvent{eventEnvelope: base, State: event.State}
|
|
}
|
|
}
|
|
|
|
func pionICEServers(values []iceServer) ([]webrtc.ICEServer, error) {
|
|
if len(values) > maxICEServers {
|
|
return nil, errors.New("native ICE server list is too large")
|
|
}
|
|
result := make([]webrtc.ICEServer, 0, len(values))
|
|
for _, server := range values {
|
|
if len(server.URLs) == 0 || len(server.URLs) > maxURLsPerServer {
|
|
return nil, errors.New("native ICE server is invalid")
|
|
}
|
|
urls := make([]string, 0, len(server.URLs))
|
|
for _, value := range server.URLs {
|
|
if !validSTUNURL(value) {
|
|
return nil, errors.New("native STUN URL is invalid")
|
|
}
|
|
urls = append(urls, value)
|
|
}
|
|
result = append(result, webrtc.ICEServer{URLs: urls})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func validSTUNURL(value string) bool {
|
|
if len(value) < 6 || len(value) > maxSTUNURLBytes ||
|
|
!strings.EqualFold(value[:5], "stun:") {
|
|
return false
|
|
}
|
|
authority := value[5:]
|
|
if authority == "" || strings.ContainsAny(authority, "/?#@") ||
|
|
strings.IndexFunc(authority, unicode.IsSpace) >= 0 ||
|
|
strings.HasSuffix(authority, ":") {
|
|
return false
|
|
}
|
|
parsed, err := url.Parse("http://" + authority)
|
|
if err != nil || parsed.Hostname() == "" || parsed.User != nil ||
|
|
(parsed.Path != "" && parsed.Path != "/") ||
|
|
parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
return false
|
|
}
|
|
port := parsed.Port()
|
|
if port == "" {
|
|
return true
|
|
}
|
|
number, err := strconv.Atoi(port)
|
|
return err == nil && number > 0 && number <= 65_535
|
|
}
|
|
|
|
func validCandidate(candidate *webrtc.ICECandidateInit) bool {
|
|
if candidate == nil {
|
|
return true
|
|
}
|
|
return len(candidate.Candidate) <= maxCandidateBytes &&
|
|
(candidate.SDPMid == nil || len(*candidate.SDPMid) <= 128) &&
|
|
(candidate.UsernameFragment == nil || len(*candidate.UsernameFragment) <= 256)
|
|
}
|
|
|
|
func validIdentities(values ...string) bool {
|
|
for _, value := range values {
|
|
if !identityPattern.MatchString(value) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func captureTargetKey(target nativecapture.CaptureTarget) string {
|
|
if target.Kind == "window" {
|
|
return "window:" + target.SourceID + ":" +
|
|
strconv.FormatUint(uint64(target.PID), 10) + ":" + target.CreationTime
|
|
}
|
|
return "display:" + target.SourceID
|
|
}
|
|
|
|
func decodeEnvelope(payload []byte, envelope *requestEnvelope) error {
|
|
decoder := json.NewDecoder(bytes.NewReader(payload))
|
|
if err := decoder.Decode(envelope); err != nil || decoder.Decode(&struct{}{}) != io.EOF ||
|
|
envelope.Version != loopback.ProtocolVersion ||
|
|
!identityPattern.MatchString(envelope.ID) || envelope.Type == "" {
|
|
return errors.New("native control envelope is invalid")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func decodeStrict(payload []byte, value any) error {
|
|
decoder := json.NewDecoder(bytes.NewReader(payload))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(value); err != nil || decoder.Decode(&struct{}{}) != io.EOF {
|
|
return errors.New("native control message is invalid")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func response(request requestEnvelope, responseType string) responseEnvelope {
|
|
return responseEnvelope{
|
|
Version: loopback.ProtocolVersion,
|
|
ID: request.ID,
|
|
Type: responseType,
|
|
}
|
|
}
|