mirror of
https://github.com/shtorm-7/sing-box-extended.git
synced 2026-08-26 03:48:19 +03:00
Add call protocol, Rmux. Update AmneziaWG. Fixes and improvements
This commit is contained in:
650
transport/call/dion/api.go
Normal file
650
transport/call/dion/api.go
Normal file
@@ -0,0 +1,650 @@
|
||||
package dion
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sagernet/sing-box/transport/call/common"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
var ErrSessionExpired = errors.New("dion: session expired, re-login required")
|
||||
|
||||
var errLoginEndpointMissing = errors.New("dion: login endpoint not available")
|
||||
|
||||
const (
|
||||
accessCookieName = "vc-access-token"
|
||||
refreshCookieName = "vc-refresh-token"
|
||||
|
||||
loginClientsPath = "/v2/users/login/web"
|
||||
loginPlatformPath = "/platform/v2/auth/auth-providers/dion/login/password"
|
||||
)
|
||||
|
||||
const (
|
||||
refreshSkewSeconds = 60
|
||||
refreshMaxAttempts = 3
|
||||
refreshBaseDelay = 2 * time.Second
|
||||
refreshDelayMultiply = 1.75
|
||||
)
|
||||
|
||||
const (
|
||||
APIBase = "https://api.dion.vc"
|
||||
APIClientsBase = "https://api-clients.dion.vc"
|
||||
WebBase = "https://dion.vc"
|
||||
Origin = "https://dion.vc"
|
||||
CookieDomain = "dion.vc"
|
||||
)
|
||||
|
||||
type GuestUser struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Initials string `json:"initials"`
|
||||
Position string `json:"position"`
|
||||
AvatarHTTPPath string `json:"avatar_http_path"`
|
||||
IsProfileFilledIn bool `json:"is_profile_filled_in"`
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
|
||||
type GuestAuthResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
AuthProvider string `json:"auth_provider"`
|
||||
IsAuthBySSO bool `json:"is_auth_by_sso"`
|
||||
User GuestUser `json:"user"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
AuthProvider string `json:"auth_provider"`
|
||||
IsAuthBySSO bool `json:"is_auth_by_sso"`
|
||||
User GuestUser `json:"user"`
|
||||
}
|
||||
|
||||
type EventInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
OrgID string `json:"org_id"`
|
||||
Admins []string `json:"admins"`
|
||||
PSTN struct {
|
||||
Number string `json:"number"`
|
||||
Pin int `json:"pin"`
|
||||
Prefix string `json:"prefix"`
|
||||
} `json:"pstn"`
|
||||
}
|
||||
|
||||
type WSSConnectResponse struct {
|
||||
Host string `json:"host"`
|
||||
Path string `json:"path"`
|
||||
Schema string `json:"schema"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]string `json:"params"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
HTTPClient *http.Client
|
||||
Device DeviceProfile
|
||||
AccessToken string
|
||||
AccessTokenExp time.Time
|
||||
UserID string
|
||||
SessionID string
|
||||
cookiesPath string
|
||||
email string
|
||||
password string
|
||||
refreshMu sync.Mutex
|
||||
}
|
||||
|
||||
type AuthResult struct {
|
||||
Session *Session
|
||||
Event *EventInfo
|
||||
WSS *WSSConnectResponse
|
||||
SessionID string
|
||||
}
|
||||
|
||||
func NewSession(dialer N.Dialer) (*Session, error) {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cookiejar: %w", err)
|
||||
}
|
||||
httpClient := common.HttpClient(dialer)
|
||||
httpClient.Jar = jar
|
||||
return &Session{HTTPClient: httpClient, Device: RandomDeviceProfile()}, nil
|
||||
}
|
||||
|
||||
func (s *Session) RegisterGuest() (*GuestAuthResponse, error) {
|
||||
auth, err := s.callRefreshOnce()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.applyRefreshResult(auth)
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
func (s *Session) RegisterAnonymousGuest(eventID, displayName string) (*GuestAuthResponse, error) {
|
||||
if eventID == "" {
|
||||
return nil, fmt.Errorf("empty event_id")
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = "Guest"
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"event_id": eventID,
|
||||
"name": displayName,
|
||||
})
|
||||
req, err := http.NewRequest(http.MethodPost, APIBase+"/platform/v1/users/register/guest", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.setBaseHeaders(req, "")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := s.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("register/guest: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return nil, fmt.Errorf("register/guest: status %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
var auth GuestAuthResponse
|
||||
if err := json.Unmarshal(raw, &auth); err != nil {
|
||||
return nil, fmt.Errorf("register/guest decode: %w", err)
|
||||
}
|
||||
if auth.AccessToken == "" {
|
||||
return nil, fmt.Errorf("register/guest: empty access_token: %s", string(raw))
|
||||
}
|
||||
s.applyRefreshResult(&auth)
|
||||
return &auth, nil
|
||||
}
|
||||
|
||||
// SetCredentials stores an email/password pair used by refreshLocked to
|
||||
// re-authenticate when the refresh cookie is missing or rejected.
|
||||
func (s *Session) SetCredentials(email, password string) {
|
||||
s.email = strings.TrimSpace(email)
|
||||
s.password = password
|
||||
}
|
||||
|
||||
// LoginWithPassword exchanges credentials for a fresh token pair. The web
|
||||
// front-end posts to api-clients, and switches to the platform endpoint when
|
||||
// the DION_PLATFORM_COOKIE_AUTH_ENABLED toggle is on, so both are tried.
|
||||
func (s *Session) LoginWithPassword(email, password string) error {
|
||||
if email == "" || password == "" {
|
||||
return fmt.Errorf("login: email and password are required")
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"email": email, "password": password})
|
||||
login, err := s.postLogin(APIClientsBase+loginClientsPath, body)
|
||||
if errors.Is(err, errLoginEndpointMissing) {
|
||||
login, err = s.postLogin(APIBase+loginPlatformPath, body)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.email = email
|
||||
s.password = password
|
||||
s.applyLoginResult(login)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) postLogin(target string, body []byte) (*LoginResponse, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, target, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.setBaseHeaders(req, "")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("login: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed {
|
||||
return nil, errLoginEndpointMissing
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return nil, fmt.Errorf("login: status %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
var login LoginResponse
|
||||
if err := json.Unmarshal(raw, &login); err != nil {
|
||||
return nil, fmt.Errorf("login decode: %w", err)
|
||||
}
|
||||
if login.AccessToken == "" {
|
||||
return nil, fmt.Errorf("login: empty access_token: %s", string(raw))
|
||||
}
|
||||
return &login, nil
|
||||
}
|
||||
|
||||
func (s *Session) applyLoginResult(login *LoginResponse) {
|
||||
s.AccessToken = login.AccessToken
|
||||
s.UserID = login.User.ID
|
||||
if exp, err := parseJWTExpiry(login.AccessToken); err == nil {
|
||||
s.AccessTokenExp = exp
|
||||
}
|
||||
if login.RefreshToken != "" {
|
||||
s.SetCookieInJar(refreshCookieName, login.RefreshToken)
|
||||
}
|
||||
s.SetCookieInJar(accessCookieName, login.AccessToken)
|
||||
}
|
||||
|
||||
func (s *Session) Refresh() error {
|
||||
s.refreshMu.Lock()
|
||||
defer s.refreshMu.Unlock()
|
||||
return s.refreshLocked()
|
||||
}
|
||||
|
||||
func (s *Session) EnsureValidToken() error {
|
||||
s.refreshMu.Lock()
|
||||
defer s.refreshMu.Unlock()
|
||||
if s.AccessToken != "" && !s.AccessTokenExp.IsZero() &&
|
||||
time.Until(s.AccessTokenExp) > time.Duration(refreshSkewSeconds)*time.Second {
|
||||
return nil
|
||||
}
|
||||
return s.refreshLocked()
|
||||
}
|
||||
|
||||
func (s *Session) DoAuthenticated(buildRequest func() (*http.Request, error)) (*http.Response, error) {
|
||||
if err := s.EnsureValidToken(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := buildRequest()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.setBaseHeaders(req, s.AccessToken)
|
||||
resp, err := s.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
return resp, nil
|
||||
}
|
||||
staleToken := s.AccessToken
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
s.refreshMu.Lock()
|
||||
if s.AccessToken == staleToken {
|
||||
if err := s.refreshLocked(); err != nil {
|
||||
s.refreshMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
s.refreshMu.Unlock()
|
||||
retryReq, err := buildRequest()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.setBaseHeaders(retryReq, s.AccessToken)
|
||||
return s.HTTPClient.Do(retryReq)
|
||||
}
|
||||
|
||||
func (s *Session) WhoAmI() (json.RawMessage, error) {
|
||||
resp, err := s.DoAuthenticated(func() (*http.Request, error) {
|
||||
return http.NewRequest(http.MethodGet, APIBase+"/platform/v1/whoami", nil)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("whoami: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("whoami: status %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func (s *Session) GetEventBySlug(slug string) (*EventInfo, error) {
|
||||
if slug == "" {
|
||||
return nil, fmt.Errorf("empty room ID")
|
||||
}
|
||||
eventURL := fmt.Sprintf("%s/conference/v1/events/slug/%s", APIBase, slug)
|
||||
resp, err := s.DoAuthenticated(func() (*http.Request, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, eventURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get event: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("get event: status %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
var event EventInfo
|
||||
if err := json.Unmarshal(raw, &event); err != nil {
|
||||
return nil, fmt.Errorf("get event decode: %w", err)
|
||||
}
|
||||
if event.ID == "" {
|
||||
return nil, fmt.Errorf("get event: empty id: %s", string(raw))
|
||||
}
|
||||
return &event, nil
|
||||
}
|
||||
|
||||
func (s *Session) GenerateSlug() (string, error) {
|
||||
resp, err := s.DoAuthenticated(func() (*http.Request, error) {
|
||||
return http.NewRequest(http.MethodGet, APIClientsBase+"/v2/events/slug/generate", nil)
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate room ID: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("generate room ID: status %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
var out struct {
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", fmt.Errorf("generate room ID decode: %w", err)
|
||||
}
|
||||
if out.Slug == "" {
|
||||
return "", fmt.Errorf("generate room ID: empty: %s", string(raw))
|
||||
}
|
||||
return out.Slug, nil
|
||||
}
|
||||
|
||||
type CreateEventOptions struct {
|
||||
Slug string
|
||||
EventParams []string
|
||||
IsImpersonalSlug bool
|
||||
IsOnCloud bool
|
||||
}
|
||||
|
||||
func (s *Session) CreateEvent(opts CreateEventOptions) (*EventInfo, error) {
|
||||
if opts.Slug == "" {
|
||||
return nil, fmt.Errorf("empty room ID")
|
||||
}
|
||||
if opts.EventParams == nil {
|
||||
opts.EventParams = []string{"guest_access"}
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"event_params": opts.EventParams,
|
||||
"is_impersonal_slug": opts.IsImpersonalSlug,
|
||||
"is_on_cloud": opts.IsOnCloud,
|
||||
"slug": opts.Slug,
|
||||
})
|
||||
resp, err := s.DoAuthenticated(func() (*http.Request, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, APIBase+"/conference/v1/events", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create event: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return nil, fmt.Errorf("create event: status %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
var event EventInfo
|
||||
if err := json.Unmarshal(raw, &event); err != nil {
|
||||
return nil, fmt.Errorf("create event decode: %w", err)
|
||||
}
|
||||
if event.ID == "" {
|
||||
return nil, fmt.Errorf("create event: empty id: %s", string(raw))
|
||||
}
|
||||
return &event, nil
|
||||
}
|
||||
|
||||
func (s *Session) CreateRoom() (*EventInfo, error) {
|
||||
slug, err := s.GenerateSlug()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.CreateEvent(CreateEventOptions{
|
||||
Slug: slug,
|
||||
EventParams: []string{"guest_access"},
|
||||
IsImpersonalSlug: true,
|
||||
IsOnCloud: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Session) ConnectWSS(sessionID string) (*WSSConnectResponse, error) {
|
||||
if sessionID == "" {
|
||||
sessionID = uuid.New().String()
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"session_id": sessionID})
|
||||
resp, err := s.DoAuthenticated(func() (*http.Request, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, APIBase+"/conference/v1/connect/wss", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect/wss: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("connect/wss: status %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
var wss WSSConnectResponse
|
||||
if err := json.Unmarshal(raw, &wss); err != nil {
|
||||
return nil, fmt.Errorf("connect/wss decode: %w", err)
|
||||
}
|
||||
if wss.URL == "" {
|
||||
return nil, fmt.Errorf("connect/wss: empty url: %s", string(raw))
|
||||
}
|
||||
s.SessionID = sessionID
|
||||
return &wss, nil
|
||||
}
|
||||
|
||||
func (s *Session) LookupEventBySlugAnonymous(slug string) (*EventInfo, error) {
|
||||
if slug == "" {
|
||||
return nil, fmt.Errorf("empty room ID")
|
||||
}
|
||||
eventURL := fmt.Sprintf("%s/conference/v1/events/slug/%s", APIBase, slug)
|
||||
req, err := http.NewRequest(http.MethodGet, eventURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.setBaseHeaders(req, "")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := s.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get event anon: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("get event anon: status %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
var event EventInfo
|
||||
if err := json.Unmarshal(raw, &event); err != nil {
|
||||
return nil, fmt.Errorf("get event anon decode: %w", err)
|
||||
}
|
||||
if event.ID == "" {
|
||||
return nil, fmt.Errorf("get event anon: empty id: %s", string(raw))
|
||||
}
|
||||
return &event, nil
|
||||
}
|
||||
|
||||
func JoinAsGuest(dialer N.Dialer, slug, displayName string) (*Session, *EventInfo, error) {
|
||||
session, err := NewSession(dialer)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := session.PrimeCookies(slug); err != nil {
|
||||
return nil, nil, fmt.Errorf("prime cookies: %w", err)
|
||||
}
|
||||
event, err := session.LookupEventBySlugAnonymous(slug)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if _, err := session.RegisterAnonymousGuest(event.ID, displayName); err != nil {
|
||||
return nil, nil, fmt.Errorf("RegisterAnonymousGuest: %w", err)
|
||||
}
|
||||
return session, event, nil
|
||||
}
|
||||
|
||||
func AuthAndGetTicket(dialer N.Dialer, slug string) (*AuthResult, error) {
|
||||
session, err := NewSession(dialer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := session.PrimeCookies(slug); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := session.RegisterGuest(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := session.WhoAmI(); err != nil {
|
||||
return nil, fmt.Errorf("whoami after guest auth: %w", err)
|
||||
}
|
||||
event, err := session.GetEventBySlug(slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessionID := uuid.New().String()
|
||||
wss, err := session.ConnectWSS(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AuthResult{
|
||||
Session: session,
|
||||
Event: event,
|
||||
WSS: wss,
|
||||
SessionID: sessionID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ParseRoom(input string) string {
|
||||
trimmed := strings.TrimSpace(input)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
trimmed = strings.TrimPrefix(trimmed, "dion://")
|
||||
trimmed = strings.TrimPrefix(trimmed, "https://")
|
||||
trimmed = strings.TrimPrefix(trimmed, "http://")
|
||||
trimmed = strings.TrimPrefix(trimmed, "dion.vc/")
|
||||
trimmed = strings.TrimPrefix(trimmed, "event/")
|
||||
if idx := strings.Index(trimmed, "?"); idx >= 0 {
|
||||
trimmed = trimmed[:idx]
|
||||
}
|
||||
if idx := strings.Index(trimmed, "/"); idx >= 0 {
|
||||
trimmed = trimmed[:idx]
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func (s *Session) setBaseHeaders(req *http.Request, accessToken string) {
|
||||
req.Header.Set("User-Agent", s.Device.UserAgent)
|
||||
req.Header.Set("Origin", Origin)
|
||||
req.Header.Set("Referer", Origin+"/")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("Accept-Language", "en")
|
||||
req.Header.Set("X-Request-Id", uuid.New().String())
|
||||
for name, value := range s.Device.Headers() {
|
||||
req.Header.Set(name, value)
|
||||
}
|
||||
if accessToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) callRefreshOnce() (*GuestAuthResponse, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, APIBase+"/platform/v1/auth/refresh/web", bytes.NewReader(nil))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.setBaseHeaders(req, "")
|
||||
req.Header.Set("Content-Length", "0")
|
||||
resp, err := s.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth/refresh/web: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
|
||||
return nil, fmt.Errorf("%w: status %d: %s", ErrSessionExpired, resp.StatusCode, string(raw))
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("auth/refresh/web: status %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
var auth GuestAuthResponse
|
||||
if err := json.Unmarshal(raw, &auth); err != nil {
|
||||
return nil, fmt.Errorf("auth/refresh/web decode: %w", err)
|
||||
}
|
||||
if auth.AccessToken == "" {
|
||||
return nil, fmt.Errorf("auth/refresh/web: empty access_token: %s", string(raw))
|
||||
}
|
||||
return &auth, nil
|
||||
}
|
||||
|
||||
func (s *Session) applyRefreshResult(auth *GuestAuthResponse) {
|
||||
s.AccessToken = auth.AccessToken
|
||||
s.UserID = auth.User.ID
|
||||
if exp, err := parseJWTExpiry(auth.AccessToken); err == nil {
|
||||
s.AccessTokenExp = exp
|
||||
}
|
||||
s.SetCookieInJar(accessCookieName, auth.AccessToken)
|
||||
}
|
||||
|
||||
func (s *Session) refreshLocked() error {
|
||||
if !s.HasRefreshCookie() && s.HasCredentials() {
|
||||
return s.LoginWithPassword(s.email, s.password)
|
||||
}
|
||||
var lastErr error
|
||||
delay := refreshBaseDelay
|
||||
for attempt := 1; attempt <= refreshMaxAttempts; attempt++ {
|
||||
auth, err := s.callRefreshOnce()
|
||||
if err == nil {
|
||||
s.applyRefreshResult(auth)
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, ErrSessionExpired) {
|
||||
if s.HasCredentials() {
|
||||
return s.LoginWithPassword(s.email, s.password)
|
||||
}
|
||||
return err
|
||||
}
|
||||
lastErr = err
|
||||
if attempt < refreshMaxAttempts {
|
||||
time.Sleep(delay)
|
||||
delay = time.Duration(float64(delay) * refreshDelayMultiply)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("refresh failed after %d attempts: %w", refreshMaxAttempts, lastErr)
|
||||
}
|
||||
|
||||
func parseJWTExpiry(token string) (time.Time, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) < 2 {
|
||||
return time.Time{}, fmt.Errorf("invalid JWT")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("decode payload: %w", err)
|
||||
}
|
||||
var claims struct {
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return time.Time{}, fmt.Errorf("parse claims: %w", err)
|
||||
}
|
||||
if claims.Exp == 0 {
|
||||
return time.Time{}, fmt.Errorf("no exp claim")
|
||||
}
|
||||
return time.Unix(claims.Exp, 0), nil
|
||||
}
|
||||
732
transport/call/dion/call.go
Normal file
732
transport/call/dion/call.go
Normal file
@@ -0,0 +1,732 @@
|
||||
package dion
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pion/rtp/codecs"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
|
||||
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||
)
|
||||
|
||||
const (
|
||||
sendVideoMidIndex = 12
|
||||
sendScreenShareMidIndex = 13
|
||||
recvScreenShareMidStr = "14"
|
||||
defaultRecvVideoMid = "1"
|
||||
recvVideoMidCount = 9
|
||||
|
||||
creatorVP8FPS = 24
|
||||
creatorVP8Batch = 10
|
||||
joinerVP8FPS = 24
|
||||
joinerVP8Batch = 5
|
||||
)
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleCreator Role = "creator"
|
||||
RoleJoiner Role = "joiner"
|
||||
)
|
||||
|
||||
type CallConfig struct {
|
||||
Auth *Session
|
||||
Event *EventInfo
|
||||
Obfuscator *tunnel.TunnelObfuscator
|
||||
DisplayName string
|
||||
Logger logger.ContextLogger
|
||||
RecvMid string
|
||||
Role Role
|
||||
|
||||
SettingEngine *webrtc.SettingEngine
|
||||
Dialer N.Dialer
|
||||
DNSRouter adapter.DNSRouter
|
||||
}
|
||||
|
||||
type PeerEntry struct {
|
||||
SessionID string
|
||||
UserID string
|
||||
Name string
|
||||
CamState bool
|
||||
JoinedAt time.Time
|
||||
}
|
||||
|
||||
type Call struct {
|
||||
cfg CallConfig
|
||||
signaling *SignalingClient
|
||||
peer *PionPeer
|
||||
sendTrack *webrtc.TrackLocalStaticSample
|
||||
vp8tun *tunnel.VP8DataTunnel
|
||||
mySessionID string
|
||||
|
||||
peersMu sync.Mutex
|
||||
peersByID map[string]*PeerEntry
|
||||
subscribed map[string]bool
|
||||
peerToMid map[string]string
|
||||
freeMids []string
|
||||
pendingSubs []string
|
||||
|
||||
onConnectedFired atomic.Bool
|
||||
|
||||
OnConnected func(tunnel.DataTunnel)
|
||||
OnPeerRestart func()
|
||||
OnRemoteSDP func(sdp string)
|
||||
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func NewCall(cfg CallConfig) *Call {
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = logger.NOP()
|
||||
}
|
||||
if cfg.Role == "" {
|
||||
cfg.Role = RoleCreator
|
||||
}
|
||||
if cfg.RecvMid == "" {
|
||||
cfg.RecvMid = defaultRecvVideoMid
|
||||
}
|
||||
var freeMids []string
|
||||
if cfg.Role == RoleJoiner {
|
||||
freeMids = []string{recvScreenShareMidStr}
|
||||
} else {
|
||||
freeMids = make([]string, 0, recvVideoMidCount)
|
||||
for midIndex := 1; midIndex < recvVideoMidCount; midIndex++ {
|
||||
freeMids = append(freeMids, fmt.Sprintf("%d", midIndex))
|
||||
}
|
||||
freeMids = append(freeMids, "0")
|
||||
}
|
||||
return &Call{
|
||||
cfg: cfg,
|
||||
peersByID: make(map[string]*PeerEntry),
|
||||
subscribed: make(map[string]bool),
|
||||
peerToMid: make(map[string]string),
|
||||
freeMids: freeMids,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Call) Done() <-chan struct{} { return c.done }
|
||||
|
||||
func (c *Call) SessionID() string { return c.mySessionID }
|
||||
|
||||
func (c *Call) Start() error {
|
||||
sessionID := uuid.New().String()
|
||||
c.mySessionID = sessionID
|
||||
c.cfg.Logger.Debug(fmt.Sprintf("[call] my session_id=%s", sessionID))
|
||||
|
||||
wss, err := c.cfg.Auth.ConnectWSS(sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ConnectWSS: %w", err)
|
||||
}
|
||||
|
||||
signaling, err := DialSignaling(wss.URL, SignalingDialOptions{
|
||||
UserAgent: c.cfg.Auth.Device.UserAgent,
|
||||
Logger: c.cfg.Logger,
|
||||
Dialer: c.cfg.Dialer,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("DialSignaling: %w", err)
|
||||
}
|
||||
c.signaling = signaling
|
||||
if err := signaling.WaitConnected(15 * time.Second); err != nil {
|
||||
return fmt.Errorf("WaitConnected: %w", err)
|
||||
}
|
||||
|
||||
youJoinedChan := make(chan YouJoinedParams, 1)
|
||||
sdpAnswerChan := make(chan SDPAnswerParams, 4)
|
||||
var onceYouJoined sync.Once
|
||||
|
||||
signaling.OnYouJoined = func(params YouJoinedParams) {
|
||||
onceYouJoined.Do(func() { youJoinedChan <- params })
|
||||
}
|
||||
signaling.OnSDPAnswer = func(answerSDP string, transceivers []TransceiverDesc) {
|
||||
select {
|
||||
case sdpAnswerChan <- SDPAnswerParams{Answer: answerSDP, Transceivers: transceivers}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
signaling.OnSpeakerJoined = c.handleSpeakerJoined
|
||||
signaling.OnSpeakerDisconnected = c.handleSpeakerDisconnected
|
||||
signaling.OnSpeakerCamStateChanged = c.handleSpeakerCamStateChanged
|
||||
signaling.OnConfSpeakersState = c.handleConfSpeakersState
|
||||
signaling.OnGetVideoFromUserResponse = c.handleGetVideoFromUserResponse
|
||||
signaling.OnGetScreenSharingFromUserResponse = c.handleGetScreenSharingFromUserResponse
|
||||
|
||||
readLoopDone := make(chan error, 1)
|
||||
go func() { readLoopDone <- signaling.ReadLoop() }()
|
||||
|
||||
if err := signaling.Subscribe(c.cfg.Event.ID, sessionID); err != nil {
|
||||
return fmt.Errorf("Subscribe: %w", err)
|
||||
}
|
||||
|
||||
var youJoined YouJoinedParams
|
||||
select {
|
||||
case youJoined = <-youJoinedChan:
|
||||
case err := <-readLoopDone:
|
||||
return fmt.Errorf("read loop ended before you_joined: %v", err)
|
||||
case <-time.After(15 * time.Second):
|
||||
return fmt.Errorf("timeout waiting for you_joined")
|
||||
}
|
||||
c.cfg.Logger.Debug(fmt.Sprintf("[call] you_joined ice_servers=%d", len(youJoined.IceServers)))
|
||||
|
||||
pionAPI := NewPionAPI(c.cfg.SettingEngine)
|
||||
iceServers := ResolveICEServerHosts(youJoined.IceServers, c.cfg.DNSRouter, c.cfg.Dialer, c.cfg.Logger)
|
||||
peer, err := BuildPionPeer(pionAPI, iceServers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("BuildPionPeer: %w", err)
|
||||
}
|
||||
c.peer = peer
|
||||
|
||||
sendMidIndex := sendVideoMidIndex
|
||||
trackLabel := "dion-tunnel-" + sessionID
|
||||
if c.cfg.Role == RoleCreator {
|
||||
sendMidIndex = sendScreenShareMidIndex
|
||||
trackLabel = "dion-tunnel-screen-" + sessionID
|
||||
}
|
||||
track, err := webrtc.NewTrackLocalStaticSample(
|
||||
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90000},
|
||||
"video", trackLabel,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("NewTrackLocalStaticSample: %w", err)
|
||||
}
|
||||
c.sendTrack = track
|
||||
if len(peer.Transceivers) <= sendMidIndex {
|
||||
return fmt.Errorf("transceiver layout short, have %d", len(peer.Transceivers))
|
||||
}
|
||||
sender := peer.Transceivers[sendMidIndex].Sender()
|
||||
if sender == nil {
|
||||
return fmt.Errorf("mid=%d sender nil", sendMidIndex)
|
||||
}
|
||||
if err := sender.ReplaceTrack(track); err != nil {
|
||||
return fmt.Errorf("ReplaceTrack: %w", err)
|
||||
}
|
||||
c.cfg.Logger.Debug(fmt.Sprintf("[call] role=%s attached send track to mid=%d", c.cfg.Role, sendMidIndex))
|
||||
|
||||
peer.PC.OnTrack(func(remoteTrack *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
|
||||
c.cfg.Logger.Debug(fmt.Sprintf("[call] OnTrack id=%q kind=%s codec=%s ssrc=%d",
|
||||
remoteTrack.ID(), remoteTrack.Kind().String(), remoteTrack.Codec().MimeType, remoteTrack.SSRC()))
|
||||
if remoteTrack.Codec().MimeType != webrtc.MimeTypeVP8 {
|
||||
go drainTrack(remoteTrack)
|
||||
return
|
||||
}
|
||||
go c.readVP8Track(remoteTrack)
|
||||
})
|
||||
|
||||
var pendingMu sync.Mutex
|
||||
pendingCandidates := make([]webrtc.ICECandidateInit, 0, 32)
|
||||
remoteSet := false
|
||||
sendCandidate := func(cand webrtc.ICECandidateInit) {
|
||||
entry := ICECandidateJSON{Candidate: cand.Candidate}
|
||||
if cand.SDPMid != nil {
|
||||
m := *cand.SDPMid
|
||||
entry.SDPMid = &m
|
||||
}
|
||||
if cand.SDPMLineIndex != nil {
|
||||
i := *cand.SDPMLineIndex
|
||||
entry.SDPMLineIndex = &i
|
||||
}
|
||||
if cand.UsernameFragment != nil {
|
||||
entry.UsernameFragment = *cand.UsernameFragment
|
||||
}
|
||||
if err := signaling.SendICECandidates([]ICECandidateJSON{entry}); err != nil {
|
||||
c.cfg.Logger.Warn(fmt.Sprintf("[ice] SendICECandidates: %v", err))
|
||||
}
|
||||
}
|
||||
flushPending := func() {
|
||||
pendingMu.Lock()
|
||||
toFlush := pendingCandidates
|
||||
pendingCandidates = nil
|
||||
pendingMu.Unlock()
|
||||
for _, cand := range toFlush {
|
||||
sendCandidate(cand)
|
||||
}
|
||||
}
|
||||
peer.PC.OnICECandidate(func(cand *webrtc.ICECandidate) {
|
||||
if cand == nil {
|
||||
return
|
||||
}
|
||||
init := cand.ToJSON()
|
||||
pendingMu.Lock()
|
||||
alreadyRemote := remoteSet
|
||||
if !alreadyRemote {
|
||||
pendingCandidates = append(pendingCandidates, init)
|
||||
}
|
||||
pendingMu.Unlock()
|
||||
if alreadyRemote {
|
||||
sendCandidate(init)
|
||||
}
|
||||
})
|
||||
|
||||
iceConnected := make(chan struct{}, 1)
|
||||
iceDead := make(chan webrtc.ICEConnectionState, 1)
|
||||
peer.PC.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
|
||||
c.cfg.Logger.Debug(fmt.Sprintf("[ice] state=%s", state.String()))
|
||||
switch state {
|
||||
case webrtc.ICEConnectionStateConnected, webrtc.ICEConnectionStateCompleted:
|
||||
select {
|
||||
case iceConnected <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
case webrtc.ICEConnectionStateFailed, webrtc.ICEConnectionStateClosed:
|
||||
select {
|
||||
case iceDead <- state:
|
||||
default:
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
envelope, _, err := peer.CreateAndSetOffer()
|
||||
if err != nil {
|
||||
return fmt.Errorf("CreateAndSetOffer: %w", err)
|
||||
}
|
||||
offerParams := SDPOfferParams{
|
||||
MicState: false,
|
||||
CamState: false,
|
||||
NoiseSuppressionState: true,
|
||||
ScreenSharingQuality: "default",
|
||||
Datachannels: peer.DatachannelDescs,
|
||||
Transceivers: peer.TransceiverDescs,
|
||||
Offer: envelope,
|
||||
}
|
||||
if err := signaling.SendSDPOffer(offerParams); err != nil {
|
||||
return fmt.Errorf("SendSDPOffer: %w", err)
|
||||
}
|
||||
|
||||
var answer SDPAnswerParams
|
||||
select {
|
||||
case answer = <-sdpAnswerChan:
|
||||
case err := <-readLoopDone:
|
||||
return fmt.Errorf("read loop ended before sdp_answer: %v", err)
|
||||
case <-time.After(20 * time.Second):
|
||||
return fmt.Errorf("timeout waiting for sdp_answer")
|
||||
}
|
||||
if c.OnRemoteSDP != nil {
|
||||
c.OnRemoteSDP(answer.Answer)
|
||||
}
|
||||
if err := peer.PC.SetRemoteDescription(webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeAnswer,
|
||||
SDP: answer.Answer,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("SetRemoteDescription: %w", err)
|
||||
}
|
||||
pendingMu.Lock()
|
||||
remoteSet = true
|
||||
pendingMu.Unlock()
|
||||
flushPending()
|
||||
|
||||
select {
|
||||
case <-iceConnected:
|
||||
case state := <-iceDead:
|
||||
return fmt.Errorf("ICE died before connected: %s", state.String())
|
||||
case err := <-readLoopDone:
|
||||
return fmt.Errorf("read loop ended before ICE connected: %v", err)
|
||||
case <-time.After(30 * time.Second):
|
||||
return fmt.Errorf("timeout waiting for ICE connected; state=%s", peer.PC.ICEConnectionState().String())
|
||||
}
|
||||
c.cfg.Logger.Debug("[ice] connected")
|
||||
|
||||
fps, batch := joinerVP8FPS, joinerVP8Batch
|
||||
if c.cfg.Role == RoleCreator {
|
||||
fps, batch = creatorVP8FPS, creatorVP8Batch
|
||||
}
|
||||
c.vp8tun = tunnel.NewVP8DataTunnel(c.sendTrack, c.cfg.Obfuscator, c.cfg.Logger)
|
||||
c.vp8tun.Start(fps, batch)
|
||||
c.fireOnConnected(c.vp8tun)
|
||||
|
||||
if c.cfg.Role == RoleCreator {
|
||||
if err := signaling.SendScreenSharingSwitchOn(); err != nil {
|
||||
c.cfg.Logger.Warn(fmt.Sprintf("[call] SendScreenSharingSwitchOn: %v", err))
|
||||
} else {
|
||||
c.cfg.Logger.Debug("[call] sent screensharing_switch_on")
|
||||
}
|
||||
if err := signaling.SendScreensharingQualityChange("good"); err != nil {
|
||||
c.cfg.Logger.Warn(fmt.Sprintf("[call] SendScreensharingQualityChange: %v", err))
|
||||
} else {
|
||||
c.cfg.Logger.Debug("[call] sent screensharing_quality_change=good")
|
||||
}
|
||||
} else {
|
||||
if err := signaling.SendCamStateChange(true); err != nil {
|
||||
c.cfg.Logger.Warn(fmt.Sprintf("[call] SendCamStateChange: %v", err))
|
||||
} else {
|
||||
c.cfg.Logger.Debug("[call] sent cam_state_change=true")
|
||||
}
|
||||
}
|
||||
|
||||
go c.discoverPeersAndSubscribe()
|
||||
go c.runStatReporter()
|
||||
|
||||
go func() {
|
||||
defer close(c.done)
|
||||
select {
|
||||
case state := <-iceDead:
|
||||
c.cfg.Logger.Debug(fmt.Sprintf("[call] ICE went to %s", state.String()))
|
||||
case err := <-readLoopDone:
|
||||
c.cfg.Logger.Debug(fmt.Sprintf("[call] read loop ended: %v", err))
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Call) Close() {
|
||||
c.closeOnce.Do(func() {
|
||||
if c.vp8tun != nil {
|
||||
c.vp8tun.Stop()
|
||||
}
|
||||
if c.signaling != nil {
|
||||
c.signaling.Close()
|
||||
}
|
||||
if c.peer != nil {
|
||||
c.peer.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Call) fireOnConnected(tun tunnel.DataTunnel) {
|
||||
if !c.onConnectedFired.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
if c.OnConnected != nil {
|
||||
c.OnConnected(tun)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Call) handleSpeakerJoined(params SpeakerJoinedParams) {
|
||||
if params.SessionID == c.mySessionID {
|
||||
return
|
||||
}
|
||||
c.peersMu.Lock()
|
||||
_, wasKnown := c.peersByID[params.SessionID]
|
||||
c.peersByID[params.SessionID] = &PeerEntry{
|
||||
SessionID: params.SessionID,
|
||||
UserID: params.UserID,
|
||||
Name: params.Name,
|
||||
CamState: params.CamState,
|
||||
JoinedAt: time.Now(),
|
||||
}
|
||||
var toKick []string
|
||||
if !wasKnown {
|
||||
for id := range c.peersByID {
|
||||
if id != params.SessionID {
|
||||
toKick = append(toKick, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.peersMu.Unlock()
|
||||
c.cfg.Logger.Debug(fmt.Sprintf("[call] speaker_joined session_id=%s name=%q cam=%v", params.SessionID, params.Name, params.CamState))
|
||||
for _, staleID := range toKick {
|
||||
if err := c.signaling.SendKickOne(staleID); err != nil {
|
||||
c.cfg.Logger.Debug(fmt.Sprintf("[call] SendKickOne(%s): %v", staleID, err))
|
||||
continue
|
||||
}
|
||||
c.cfg.Logger.Debug(fmt.Sprintf("[call] kicked stale peer session_id=%s for newcomer=%s", staleID, params.SessionID))
|
||||
c.peersMu.Lock()
|
||||
delete(c.peersByID, staleID)
|
||||
delete(c.subscribed, staleID)
|
||||
c.releaseMidLocked(staleID)
|
||||
c.peersMu.Unlock()
|
||||
}
|
||||
if len(toKick) > 0 && c.OnPeerRestart != nil {
|
||||
c.cfg.Logger.Debug(fmt.Sprintf("[call] firing OnPeerRestart from kick path (kicked=%d newcomer=%s)", len(toKick), params.SessionID))
|
||||
c.OnPeerRestart()
|
||||
}
|
||||
if c.cfg.Role == RoleJoiner || params.CamState {
|
||||
c.subscribeIfNeeded(params.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Call) handleSpeakerDisconnected(params SpeakerDisconnectedParams) {
|
||||
c.peersMu.Lock()
|
||||
delete(c.peersByID, params.SessionID)
|
||||
delete(c.subscribed, params.SessionID)
|
||||
c.releaseMidLocked(params.SessionID)
|
||||
var freshestUnsubscribed string
|
||||
var freshestAt time.Time
|
||||
for sid, entry := range c.peersByID {
|
||||
if c.subscribed[sid] {
|
||||
continue
|
||||
}
|
||||
if entry.JoinedAt.After(freshestAt) {
|
||||
freshestAt = entry.JoinedAt
|
||||
freshestUnsubscribed = sid
|
||||
}
|
||||
}
|
||||
hasFreeMid := len(c.freeMids) > 0
|
||||
c.peersMu.Unlock()
|
||||
c.cfg.Logger.Debug(fmt.Sprintf("[call] speaker_disconnected session_id=%s", params.SessionID))
|
||||
if freshestUnsubscribed != "" && hasFreeMid {
|
||||
c.cfg.Logger.Debug(fmt.Sprintf("[call] claiming freed mid for unsubscribed peer %s", freshestUnsubscribed))
|
||||
c.subscribeIfNeeded(freshestUnsubscribed)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Call) handleSpeakerCamStateChanged(params SpeakerCamStateChangedParams) {
|
||||
if params.SessionID == c.mySessionID {
|
||||
return
|
||||
}
|
||||
c.peersMu.Lock()
|
||||
if entry, ok := c.peersByID[params.SessionID]; ok {
|
||||
entry.CamState = params.CamState
|
||||
} else {
|
||||
c.peersByID[params.SessionID] = &PeerEntry{SessionID: params.SessionID, CamState: params.CamState, JoinedAt: time.Now()}
|
||||
}
|
||||
c.peersMu.Unlock()
|
||||
c.cfg.Logger.Debug(fmt.Sprintf("[call] speaker_cam_state_changed session_id=%s cam=%v", params.SessionID, params.CamState))
|
||||
if params.CamState {
|
||||
c.subscribeIfNeeded(params.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Call) handleConfSpeakersState(response ConfSpeakersStateResponse) {
|
||||
for _, entry := range response.Speakers {
|
||||
if entry.SessionID == c.mySessionID || entry.SessionID == "" {
|
||||
continue
|
||||
}
|
||||
c.peersMu.Lock()
|
||||
c.peersByID[entry.SessionID] = &PeerEntry{
|
||||
SessionID: entry.SessionID,
|
||||
UserID: entry.UserID,
|
||||
Name: entry.Name,
|
||||
CamState: entry.CamState,
|
||||
JoinedAt: time.Now(),
|
||||
}
|
||||
c.peersMu.Unlock()
|
||||
if c.cfg.Role == RoleJoiner || entry.CamState {
|
||||
c.subscribeIfNeeded(entry.SessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Call) discoverPeersAndSubscribe() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if err := c.signaling.SendConfSpeakersState(DefaultConfSpeakersStateRequest()); err != nil {
|
||||
c.cfg.Logger.Debug(fmt.Sprintf("[call] SendConfSpeakersState: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Call) subscribeIfNeeded(peerSessionID string) {
|
||||
c.peersMu.Lock()
|
||||
if c.subscribed[peerSessionID] {
|
||||
c.peersMu.Unlock()
|
||||
return
|
||||
}
|
||||
entry := c.peersByID[peerSessionID]
|
||||
if entry == nil {
|
||||
c.peersMu.Unlock()
|
||||
return
|
||||
}
|
||||
if len(c.freeMids) == 0 {
|
||||
c.peersMu.Unlock()
|
||||
c.cfg.Logger.Debug(fmt.Sprintf("[call] no free recv mid for peer %s, ignoring", peerSessionID))
|
||||
return
|
||||
}
|
||||
mid := c.freeMids[0]
|
||||
c.freeMids = c.freeMids[1:]
|
||||
c.peerToMid[peerSessionID] = mid
|
||||
c.subscribed[peerSessionID] = true
|
||||
c.pendingSubs = append(c.pendingSubs, peerSessionID)
|
||||
c.peersMu.Unlock()
|
||||
var sendErr error
|
||||
if c.cfg.Role == RoleJoiner {
|
||||
sendErr = c.signaling.SendGetScreenSharingFromUser(GetScreenSharingFromUserRequest{
|
||||
SessionID: entry.SessionID,
|
||||
TransceiverID: mid,
|
||||
UserID: entry.UserID,
|
||||
})
|
||||
} else {
|
||||
sendErr = c.signaling.SendGetVideoFromUser(GetVideoFromUserRequest{
|
||||
SessionID: entry.SessionID,
|
||||
TransceiverID: mid,
|
||||
UserID: entry.UserID,
|
||||
Username: entry.Name,
|
||||
})
|
||||
}
|
||||
if sendErr != nil {
|
||||
c.cfg.Logger.Debug(fmt.Sprintf("[call] subscribe to peer %s failed: %v", peerSessionID, sendErr))
|
||||
c.peersMu.Lock()
|
||||
delete(c.subscribed, peerSessionID)
|
||||
delete(c.peerToMid, peerSessionID)
|
||||
c.freeMids = append(c.freeMids, mid)
|
||||
if len(c.pendingSubs) > 0 && c.pendingSubs[len(c.pendingSubs)-1] == peerSessionID {
|
||||
c.pendingSubs = c.pendingSubs[:len(c.pendingSubs)-1]
|
||||
}
|
||||
c.peersMu.Unlock()
|
||||
return
|
||||
}
|
||||
c.cfg.Logger.Debug(fmt.Sprintf("[call] subscribed to %s on mid=%s", peerSessionID, mid))
|
||||
if c.OnPeerRestart != nil {
|
||||
c.cfg.Logger.Debug(fmt.Sprintf("[call] firing OnPeerRestart from subscribe path (peer=%s)", peerSessionID))
|
||||
c.OnPeerRestart()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Call) handleGetVideoFromUserResponse(resp GetVideoFromUserResponse, errCode int, errMsg string) {
|
||||
c.handleSubscribeResponse("get_video_from_user", resp.SessionID, resp.TransceiverID, errCode, errMsg)
|
||||
}
|
||||
|
||||
func (c *Call) handleGetScreenSharingFromUserResponse(resp GetScreenSharingFromUserResponse, errCode int, errMsg string) {
|
||||
c.handleSubscribeResponse("get_screensharing_from_user", resp.SessionID, resp.TransceiverID, errCode, errMsg)
|
||||
}
|
||||
|
||||
func (c *Call) handleSubscribeResponse(rpc, sessionID, transceiverID string, errCode int, errMsg string) {
|
||||
c.peersMu.Lock()
|
||||
if sessionID == "" && len(c.pendingSubs) > 0 {
|
||||
sessionID = c.pendingSubs[0]
|
||||
c.pendingSubs = c.pendingSubs[1:]
|
||||
} else if len(c.pendingSubs) > 0 {
|
||||
for i, pending := range c.pendingSubs {
|
||||
if pending == sessionID {
|
||||
c.pendingSubs = append(c.pendingSubs[:i], c.pendingSubs[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if errCode != 0 {
|
||||
mid := c.peerToMid[sessionID]
|
||||
delete(c.subscribed, sessionID)
|
||||
delete(c.peerToMid, sessionID)
|
||||
if mid != "" {
|
||||
c.freeMids = append(c.freeMids, mid)
|
||||
}
|
||||
c.peersMu.Unlock()
|
||||
c.cfg.Logger.Debug(fmt.Sprintf("[call] %s FAILED session=%s mid=%s code=%d msg=%q", rpc, sessionID, mid, errCode, errMsg))
|
||||
return
|
||||
}
|
||||
c.peersMu.Unlock()
|
||||
c.cfg.Logger.Debug(fmt.Sprintf("[call] %s OK session=%s mid=%s", rpc, sessionID, transceiverID))
|
||||
}
|
||||
|
||||
func (c *Call) releaseMidLocked(peerSessionID string) {
|
||||
if mid, ok := c.peerToMid[peerSessionID]; ok {
|
||||
delete(c.peerToMid, peerSessionID)
|
||||
c.freeMids = append(c.freeMids, mid)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Call) readVP8Track(track *webrtc.TrackRemote) {
|
||||
var vp8Pkt codecs.VP8Packet
|
||||
var frameBuf []byte
|
||||
var lastSeq uint16
|
||||
var haveLastSeq bool
|
||||
frameValid := false
|
||||
for {
|
||||
pkt, _, err := track.ReadRTP()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if pkt == nil {
|
||||
continue
|
||||
}
|
||||
if haveLastSeq && pkt.SequenceNumber != lastSeq+1 {
|
||||
frameValid = false
|
||||
frameBuf = frameBuf[:0]
|
||||
}
|
||||
lastSeq = pkt.SequenceNumber
|
||||
haveLastSeq = true
|
||||
vp8Payload, err := vp8Pkt.Unmarshal(pkt.Payload)
|
||||
if err != nil {
|
||||
frameValid = false
|
||||
frameBuf = frameBuf[:0]
|
||||
continue
|
||||
}
|
||||
if vp8Pkt.S == 1 {
|
||||
frameBuf = frameBuf[:0]
|
||||
frameValid = true
|
||||
}
|
||||
if !frameValid {
|
||||
continue
|
||||
}
|
||||
frameBuf = append(frameBuf, vp8Payload...)
|
||||
if !pkt.Marker {
|
||||
continue
|
||||
}
|
||||
if c.vp8tun != nil {
|
||||
c.vp8tun.HandleFrame(frameBuf)
|
||||
}
|
||||
frameBuf = frameBuf[:0]
|
||||
frameValid = false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Call) runStatReporter() {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
case <-time.After(1500 * time.Millisecond):
|
||||
}
|
||||
if err := c.signaling.SendPCIceStat(); err != nil {
|
||||
c.cfg.Logger.Debug(fmt.Sprintf("[call] SendPCIceStat: %v", err))
|
||||
} else {
|
||||
c.cfg.Logger.Debug("[call] sent pc_ice_stat")
|
||||
}
|
||||
c.sendStatReport()
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.sendStatReport()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Call) sendStatReport() {
|
||||
report := ClientStatReport{
|
||||
ReportTimeUnixMS: time.Now().UnixMilli(),
|
||||
Connection: ClientStatConnection{},
|
||||
}
|
||||
report.Audio.In = ClientStatAudioIn{Codec: "opus", IsEnabled: true, Mid: 9}
|
||||
report.Video.In = c.buildVideoInStats()
|
||||
outStat := ClientStatVideoOut{
|
||||
Mid: sendVideoMidIndex,
|
||||
Codec: "VP8",
|
||||
IsEnabled: true,
|
||||
Resolution: ClientStatResolution{Width: 1280, Height: 720},
|
||||
Framerate: c.vp8tun.FPS(),
|
||||
ScalabilityMode: "L1T1",
|
||||
}
|
||||
report.Video.Out = outStat
|
||||
report.Video.OutV2 = []ClientStatVideoOut{outStat}
|
||||
if err := c.signaling.SendClientStatZip(report); err != nil {
|
||||
c.cfg.Logger.Debug(fmt.Sprintf("[call] SendClientStatZip: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Call) buildVideoInStats() []ClientStatVideoIn {
|
||||
c.peersMu.Lock()
|
||||
defer c.peersMu.Unlock()
|
||||
out := make([]ClientStatVideoIn, 0, len(c.peerToMid))
|
||||
for sessionID, midStr := range c.peerToMid {
|
||||
midInt := 0
|
||||
fmt.Sscanf(midStr, "%d", &midInt)
|
||||
out = append(out, ClientStatVideoIn{
|
||||
Codec: "VP8",
|
||||
IsEnabled: true,
|
||||
Mid: midInt,
|
||||
Resolution: ClientStatResolution{Width: 1280, Height: 720},
|
||||
Framerate: c.vp8tun.FPS(),
|
||||
SessionID: sessionID,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func drainTrack(track *webrtc.TrackRemote) {
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
if _, _, err := track.Read(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
139
transport/call/dion/connect.go
Normal file
139
transport/call/dion/connect.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package dion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/transport/call/common"
|
||||
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
func ConnectCreator(ctx context.Context, cookieStr, roomID, email, password string, readBuf int, dialer N.Dialer, logger logger.ContextLogger) (*tunnel.RelayBridge, string, error) {
|
||||
auth, err := NewSession(dialer)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("dion: new session: %w", err)
|
||||
}
|
||||
if err := auth.LoadCookieString(cookieStr); err != nil {
|
||||
return nil, "", fmt.Errorf("dion: load cookies: %w", err)
|
||||
}
|
||||
auth.SetCredentials(email, password)
|
||||
if err := auth.EnsureValidToken(); err != nil {
|
||||
return nil, "", fmt.Errorf("dion: ensure valid token: %w", err)
|
||||
}
|
||||
requestedRoom := ParseRoom(roomID)
|
||||
var event *EventInfo
|
||||
if requestedRoom != "" {
|
||||
event, err = auth.GetEventBySlug(requestedRoom)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("dion: get event by slug: %w", err)
|
||||
}
|
||||
} else {
|
||||
event, err = auth.CreateRoom()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("dion: create room: %w", err)
|
||||
}
|
||||
}
|
||||
joinLink := WebBase + "/event/" + event.Slug
|
||||
if readBuf <= 0 {
|
||||
readBuf = 32768
|
||||
}
|
||||
obf, err := tunnel.NewTunnelObfuscator(tunnel.DeriveSecretFromJoinLink(event.Slug))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("dion: obfuscator init: %w", err)
|
||||
}
|
||||
relayCh := make(chan *tunnel.RelayBridge, 1)
|
||||
var activeRelay *tunnel.RelayBridge
|
||||
call := NewCall(CallConfig{
|
||||
Auth: auth,
|
||||
Event: event,
|
||||
Obfuscator: obf,
|
||||
DisplayName: "Creator",
|
||||
Logger: logger,
|
||||
Dialer: dialer,
|
||||
Role: RoleCreator,
|
||||
})
|
||||
call.OnConnected = func(tun tunnel.DataTunnel) {
|
||||
if activeRelay != nil {
|
||||
activeRelay.Reset()
|
||||
}
|
||||
bridgeReadBuf := common.VP8BufSize
|
||||
if _, ok := tun.(*tunnel.DCTunnel); ok {
|
||||
bridgeReadBuf = readBuf
|
||||
}
|
||||
activeRelay = tunnel.NewRelayBridge(tun, "creator", bridgeReadBuf, dialer, logger)
|
||||
activeRelay.MarkReady()
|
||||
select {
|
||||
case relayCh <- activeRelay:
|
||||
default:
|
||||
}
|
||||
}
|
||||
call.OnPeerRestart = func() {
|
||||
if activeRelay != nil {
|
||||
activeRelay.Reset()
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
if err := call.Start(); err != nil {
|
||||
logger.Error(fmt.Sprintf("dion: call start failed: %v", err))
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case relay := <-relayCh:
|
||||
return relay, joinLink, nil
|
||||
case <-ctx.Done():
|
||||
call.Close()
|
||||
return nil, "", ctx.Err()
|
||||
case <-time.After(60 * time.Second):
|
||||
call.Close()
|
||||
return nil, "", fmt.Errorf("dion: creator tunnel timed out")
|
||||
}
|
||||
}
|
||||
|
||||
func ConnectJoiner(ctx context.Context, roomID, displayName string, readBuf int, dialer N.Dialer, logger logger.ContextLogger) (tunnel.DataTunnel, error) {
|
||||
if displayName == "" {
|
||||
displayName = "Joiner"
|
||||
}
|
||||
slug := ParseRoom(roomID)
|
||||
if slug == "" {
|
||||
return nil, fmt.Errorf("dion: missing room")
|
||||
}
|
||||
auth, event, err := JoinAsGuest(dialer, slug, displayName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dion: join as guest: %w", err)
|
||||
}
|
||||
obf, err := tunnel.NewTunnelObfuscator(tunnel.DeriveSecretFromJoinLink(event.Slug))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dion: obfuscator init: %w", err)
|
||||
}
|
||||
call := NewCall(CallConfig{
|
||||
Auth: auth,
|
||||
Event: event,
|
||||
Obfuscator: obf,
|
||||
DisplayName: displayName,
|
||||
Logger: logger,
|
||||
Dialer: dialer,
|
||||
Role: RoleJoiner,
|
||||
})
|
||||
tunCh := make(chan tunnel.DataTunnel, 1)
|
||||
call.OnConnected = func(tun tunnel.DataTunnel) {
|
||||
select {
|
||||
case tunCh <- tun:
|
||||
default:
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
if err := call.Start(); err != nil {
|
||||
logger.Error(fmt.Sprintf("dion: call start failed: %v", err))
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case tun := <-tunCh:
|
||||
return tun, nil
|
||||
case <-ctx.Done():
|
||||
call.Close()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
134
transport/call/dion/cookies.go
Normal file
134
transport/call/dion/cookies.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package dion
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type CookieEntry struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func (s *Session) LoadCookies(entries []CookieEntry) error {
|
||||
if s.HTTPClient.Jar == nil {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cookiejar: %w", err)
|
||||
}
|
||||
s.HTTPClient.Jar = jar
|
||||
}
|
||||
web, err := url.Parse(WebBase)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse target %s: %w", WebBase, err)
|
||||
}
|
||||
cookies := make([]*http.Cookie, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.Name == "" {
|
||||
continue
|
||||
}
|
||||
cookies = append(cookies, &http.Cookie{
|
||||
Name: entry.Name,
|
||||
Value: entry.Value,
|
||||
Path: "/",
|
||||
Domain: CookieDomain,
|
||||
})
|
||||
}
|
||||
s.HTTPClient.Jar.SetCookies(web, cookies)
|
||||
s.seedAccessTokenFromCookies(entries)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) LoadCookieString(cookieStr string) error {
|
||||
cookieStr = strings.TrimSpace(cookieStr)
|
||||
if cookieStr == "" {
|
||||
return fmt.Errorf("empty cookie string")
|
||||
}
|
||||
var entries []CookieEntry
|
||||
for _, piece := range strings.Split(cookieStr, ";") {
|
||||
piece = strings.TrimSpace(piece)
|
||||
if piece == "" {
|
||||
continue
|
||||
}
|
||||
eq := strings.IndexByte(piece, '=')
|
||||
if eq <= 0 {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, CookieEntry{Name: piece[:eq], Value: piece[eq+1:]})
|
||||
}
|
||||
return s.LoadCookies(entries)
|
||||
}
|
||||
|
||||
func (s *Session) SetCookieInJar(name, value string) {
|
||||
if s.HTTPClient == nil || s.HTTPClient.Jar == nil {
|
||||
return
|
||||
}
|
||||
web, err := url.Parse(WebBase)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.HTTPClient.Jar.SetCookies(web, []*http.Cookie{
|
||||
{Name: name, Value: value, Path: "/", Domain: CookieDomain},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Session) HasCredentials() bool {
|
||||
return s.email != "" && s.password != ""
|
||||
}
|
||||
|
||||
func (s *Session) HasRefreshCookie() bool {
|
||||
if s.HTTPClient == nil || s.HTTPClient.Jar == nil {
|
||||
return false
|
||||
}
|
||||
web, err := url.Parse(WebBase)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, c := range s.HTTPClient.Jar.Cookies(web) {
|
||||
if c.Name == refreshCookieName && c.Value != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Session) PrimeCookies(slug string) error {
|
||||
target := WebBase + "/"
|
||||
if slug != "" {
|
||||
target = fmt.Sprintf("%s/event/%s?showWeb=true", WebBase, slug)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.setBaseHeaders(req, "")
|
||||
resp, err := s.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prime cookies: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("prime cookies: status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) seedAccessTokenFromCookies(entries []CookieEntry) {
|
||||
for _, entry := range entries {
|
||||
if entry.Name != accessCookieName || entry.Value == "" {
|
||||
continue
|
||||
}
|
||||
exp, err := parseJWTExpiry(entry.Value)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.AccessToken = entry.Value
|
||||
s.AccessTokenExp = exp
|
||||
return
|
||||
}
|
||||
}
|
||||
185
transport/call/dion/device.go
Normal file
185
transport/call/dion/device.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package dion
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
)
|
||||
|
||||
type DeviceProfile struct {
|
||||
UserAgent string
|
||||
Platform string
|
||||
BrowserType string
|
||||
BrowserVersion string
|
||||
DeviceBrand string
|
||||
DeviceModel string
|
||||
DeviceType string
|
||||
OS string
|
||||
OSVersion string
|
||||
ScreenWidth int
|
||||
ScreenHeight int
|
||||
}
|
||||
|
||||
type deviceTemplate struct {
|
||||
os string
|
||||
osVersionPool []string
|
||||
deviceBrandPool []string
|
||||
deviceModelPool []string
|
||||
browsers []browserTemplate
|
||||
}
|
||||
|
||||
type browserTemplate struct {
|
||||
browserType string
|
||||
versionPool []string
|
||||
userAgentFn func(osVersion, browserVersion string) string
|
||||
}
|
||||
|
||||
var commonScreens = [][2]int{
|
||||
{1280, 720}, {1366, 768}, {1440, 900}, {1536, 864},
|
||||
{1600, 900}, {1680, 1050}, {1728, 1117}, {1920, 1080},
|
||||
{2048, 1152}, {2560, 1440}, {2880, 1800}, {3840, 2160},
|
||||
}
|
||||
|
||||
var deviceTemplates = []deviceTemplate{
|
||||
{
|
||||
os: "Mac OS",
|
||||
osVersionPool: []string{"10.15.7", "11.7.10", "12.7.6", "13.6.9", "14.6.1", "15.1.0"},
|
||||
deviceBrandPool: []string{"Apple"},
|
||||
deviceModelPool: []string{"Macintosh"},
|
||||
browsers: []browserTemplate{
|
||||
{
|
||||
browserType: "Chrome",
|
||||
versionPool: []string{"141.0.0.0", "143.0.0.0", "145.0.0.0", "147.0.0.0", "149.0.0.0"},
|
||||
userAgentFn: func(osVersion, browserVersion string) string {
|
||||
return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X %s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36",
|
||||
macOSVersionForUA(osVersion), browserVersion)
|
||||
},
|
||||
},
|
||||
{
|
||||
browserType: "Safari",
|
||||
versionPool: []string{"17.6", "18.0", "18.1", "18.2"},
|
||||
userAgentFn: func(osVersion, browserVersion string) string {
|
||||
return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X %s) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/%s Safari/605.1.15",
|
||||
macOSVersionForUA(osVersion), browserVersion)
|
||||
},
|
||||
},
|
||||
{
|
||||
browserType: "Firefox",
|
||||
versionPool: []string{"128.0", "131.0", "133.0", "135.0"},
|
||||
userAgentFn: func(osVersion, browserVersion string) string {
|
||||
return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X %s; rv:%s) Gecko/20100101 Firefox/%s",
|
||||
macOSVersionForUA(osVersion), browserVersion, browserVersion)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
os: "Windows",
|
||||
osVersionPool: []string{"10", "11"},
|
||||
deviceBrandPool: []string{"Dell", "Lenovo", "HP", "Asus", "Acer", "MSI"},
|
||||
deviceModelPool: []string{"PC"},
|
||||
browsers: []browserTemplate{
|
||||
{
|
||||
browserType: "Chrome",
|
||||
versionPool: []string{"141.0.0.0", "143.0.0.0", "145.0.0.0", "147.0.0.0", "149.0.0.0"},
|
||||
userAgentFn: func(osVersion, browserVersion string) string {
|
||||
return fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36",
|
||||
browserVersion)
|
||||
},
|
||||
},
|
||||
{
|
||||
browserType: "Edge",
|
||||
versionPool: []string{"141.0.0.0", "143.0.0.0", "145.0.0.0", "147.0.0.0"},
|
||||
userAgentFn: func(osVersion, browserVersion string) string {
|
||||
return fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36 Edg/%s",
|
||||
browserVersion, browserVersion)
|
||||
},
|
||||
},
|
||||
{
|
||||
browserType: "Firefox",
|
||||
versionPool: []string{"128.0", "131.0", "133.0", "135.0"},
|
||||
userAgentFn: func(osVersion, browserVersion string) string {
|
||||
return fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:%s) Gecko/20100101 Firefox/%s",
|
||||
browserVersion, browserVersion)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
os: "Linux",
|
||||
osVersionPool: []string{"x86_64", "x86_64 GNU"},
|
||||
deviceBrandPool: []string{"Dell", "Lenovo", "HP", "System76", "Framework"},
|
||||
deviceModelPool: []string{"PC"},
|
||||
browsers: []browserTemplate{
|
||||
{
|
||||
browserType: "Chrome",
|
||||
versionPool: []string{"141.0.0.0", "143.0.0.0", "145.0.0.0", "147.0.0.0", "149.0.0.0"},
|
||||
userAgentFn: func(osVersion, browserVersion string) string {
|
||||
return fmt.Sprintf("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36",
|
||||
browserVersion)
|
||||
},
|
||||
},
|
||||
{
|
||||
browserType: "Firefox",
|
||||
versionPool: []string{"128.0", "131.0", "133.0", "135.0"},
|
||||
userAgentFn: func(osVersion, browserVersion string) string {
|
||||
return fmt.Sprintf("Mozilla/5.0 (X11; Linux x86_64; rv:%s) Gecko/20100101 Firefox/%s",
|
||||
browserVersion, browserVersion)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func RandomDeviceProfile() DeviceProfile {
|
||||
tmpl := deviceTemplates[rand.IntN(len(deviceTemplates))]
|
||||
browser := tmpl.browsers[rand.IntN(len(tmpl.browsers))]
|
||||
osVersion := tmpl.osVersionPool[rand.IntN(len(tmpl.osVersionPool))]
|
||||
browserVersion := browser.versionPool[rand.IntN(len(browser.versionPool))]
|
||||
screen := commonScreens[rand.IntN(len(commonScreens))]
|
||||
return DeviceProfile{
|
||||
UserAgent: browser.userAgentFn(osVersion, browserVersion),
|
||||
Platform: "web",
|
||||
BrowserType: browser.browserType,
|
||||
BrowserVersion: browserVersion,
|
||||
DeviceBrand: tmpl.deviceBrandPool[rand.IntN(len(tmpl.deviceBrandPool))],
|
||||
DeviceModel: tmpl.deviceModelPool[rand.IntN(len(tmpl.deviceModelPool))],
|
||||
DeviceType: "pc",
|
||||
OS: tmpl.os,
|
||||
OSVersion: osVersion,
|
||||
ScreenWidth: screen[0],
|
||||
ScreenHeight: screen[1],
|
||||
}
|
||||
}
|
||||
|
||||
func (p DeviceProfile) Headers() map[string]string {
|
||||
return map[string]string{
|
||||
"d-platform": p.Platform,
|
||||
"d-browser-type": p.BrowserType,
|
||||
"d-browser-version": p.BrowserVersion,
|
||||
"d-device-brand": p.DeviceBrand,
|
||||
"d-device-model": p.DeviceModel,
|
||||
"d-device-type": p.DeviceType,
|
||||
"d-os": p.OS,
|
||||
"d-os-version": p.OSVersion,
|
||||
"d-screen-height": fmt.Sprintf("%d", p.ScreenHeight),
|
||||
"d-screen-width": fmt.Sprintf("%d", p.ScreenWidth),
|
||||
}
|
||||
}
|
||||
|
||||
func macOSVersionForUA(osVersion string) string {
|
||||
switch osVersion {
|
||||
case "10.15.7":
|
||||
return "10_15_7"
|
||||
case "11.7.10":
|
||||
return "10_15_7"
|
||||
case "12.7.6":
|
||||
return "10_15_7"
|
||||
case "13.6.9":
|
||||
return "10_15_7"
|
||||
case "14.6.1":
|
||||
return "10_15_7"
|
||||
case "15.1.0":
|
||||
return "10_15_7"
|
||||
}
|
||||
return "10_15_7"
|
||||
}
|
||||
262
transport/call/dion/pion_offer.go
Normal file
262
transport/call/dion/pion_offer.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package dion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/common/dialer"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
type TransceiverPlan struct {
|
||||
Mid int
|
||||
Direction webrtc.RTPTransceiverDirection
|
||||
Kind webrtc.RTPCodecType
|
||||
Ctype string
|
||||
}
|
||||
|
||||
type DataChannelPlan struct {
|
||||
ID uint16
|
||||
Label string
|
||||
}
|
||||
|
||||
var DionTransceiverLayout = []TransceiverPlan{
|
||||
{Mid: 0, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
|
||||
{Mid: 1, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
|
||||
{Mid: 2, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
|
||||
{Mid: 3, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
|
||||
{Mid: 4, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
|
||||
{Mid: 5, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
|
||||
{Mid: 6, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
|
||||
{Mid: 7, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
|
||||
{Mid: 8, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
|
||||
{Mid: 9, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeAudio, Ctype: "Audio"},
|
||||
{Mid: 10, Direction: webrtc.RTPTransceiverDirectionSendonly, Kind: webrtc.RTPCodecTypeAudio, Ctype: "Audio"},
|
||||
{Mid: 11, Direction: webrtc.RTPTransceiverDirectionSendonly, Kind: webrtc.RTPCodecTypeAudio, Ctype: "AudioScreenSharing"},
|
||||
{Mid: 12, Direction: webrtc.RTPTransceiverDirectionSendonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
|
||||
{Mid: 13, Direction: webrtc.RTPTransceiverDirectionSendonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "ScreenSharing"},
|
||||
{Mid: 14, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "ScreenSharing"},
|
||||
{Mid: 15, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Video"},
|
||||
{Mid: 16, Direction: webrtc.RTPTransceiverDirectionRecvonly, Kind: webrtc.RTPCodecTypeVideo, Ctype: "Padding"},
|
||||
}
|
||||
|
||||
var DionDataChannels = []DataChannelPlan{
|
||||
{ID: 0, Label: "vad"},
|
||||
{ID: 1, Label: "stats"},
|
||||
{ID: 2, Label: "speed"},
|
||||
{ID: 3, Label: "video_quality"},
|
||||
{ID: 4, Label: "media_messages"},
|
||||
}
|
||||
|
||||
type PionPeer struct {
|
||||
PC *webrtc.PeerConnection
|
||||
Transceivers []*webrtc.RTPTransceiver
|
||||
DataChannels map[string]*webrtc.DataChannel
|
||||
TransceiverDescs []TransceiverDesc
|
||||
DatachannelDescs []DataChannelDesc
|
||||
}
|
||||
|
||||
func NewPionAPI(customEngine ...*webrtc.SettingEngine) *webrtc.API {
|
||||
mediaEngine := &webrtc.MediaEngine{}
|
||||
if err := mediaEngine.RegisterDefaultCodecs(); err != nil {
|
||||
panic(fmt.Errorf("dion: register default codecs: %w", err))
|
||||
}
|
||||
engine := webrtc.SettingEngine{}
|
||||
if len(customEngine) > 0 && customEngine[0] != nil {
|
||||
engine = *customEngine[0]
|
||||
}
|
||||
return webrtc.NewAPI(
|
||||
webrtc.WithMediaEngine(mediaEngine),
|
||||
webrtc.WithSettingEngine(engine),
|
||||
)
|
||||
}
|
||||
|
||||
func ResolveICEServerHosts(entries []ICEServerEntry, dnsRouter adapter.DNSRouter, d N.Dialer, logger logger.ContextLogger) []ICEServerEntry {
|
||||
if dnsRouter == nil {
|
||||
return entries
|
||||
}
|
||||
resolved := make(map[string]string)
|
||||
out := make([]ICEServerEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
urls := make([]string, len(entry.URLs))
|
||||
copy(urls, entry.URLs)
|
||||
for k, raw := range urls {
|
||||
host := extractICEHost(raw)
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
ip, ok := resolved[host]
|
||||
if !ok {
|
||||
var addrs []netip.Addr
|
||||
var err error
|
||||
addrs, err = dnsRouter.Lookup(context.Background(), host, d.(dialer.ResolveDialer).QueryOptions())
|
||||
if err != nil {
|
||||
logger.Warn(fmt.Sprintf("[dion] resolve ICE host %s failed: %v", host, err))
|
||||
continue
|
||||
}
|
||||
ip = addrs[0].String()
|
||||
resolved[host] = ip
|
||||
logger.Debug(fmt.Sprintf("[dion] resolved ICE host %s -> %s", host, addrs[0]))
|
||||
}
|
||||
urls[k] = strings.Replace(raw, host, ip, 1)
|
||||
}
|
||||
out = append(out, ICEServerEntry{URLs: urls, Username: entry.Username, Credential: entry.Credential})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func IceServerEntriesToWebRTC(entries []ICEServerEntry) []webrtc.ICEServer {
|
||||
out := make([]webrtc.ICEServer, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
out = append(out, webrtc.ICEServer{
|
||||
URLs: entry.URLs,
|
||||
Username: entry.Username,
|
||||
Credential: entry.Credential,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func BuildPionPeer(api *webrtc.API, iceServers []ICEServerEntry) (*PionPeer, error) {
|
||||
pc, err := api.NewPeerConnection(webrtc.Configuration{
|
||||
ICEServers: IceServerEntriesToWebRTC(iceServers),
|
||||
BundlePolicy: webrtc.BundlePolicyMaxBundle,
|
||||
RTCPMuxPolicy: webrtc.RTCPMuxPolicyRequire,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new peer connection: %w", err)
|
||||
}
|
||||
transceivers := make([]*webrtc.RTPTransceiver, 0, len(DionTransceiverLayout))
|
||||
for _, plan := range DionTransceiverLayout {
|
||||
transceiver, err := pc.AddTransceiverFromKind(plan.Kind, webrtc.RTPTransceiverInit{
|
||||
Direction: plan.Direction,
|
||||
})
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
return nil, fmt.Errorf("add transceiver mid=%d kind=%s dir=%s: %w", plan.Mid, plan.Kind, plan.Direction, err)
|
||||
}
|
||||
transceivers = append(transceivers, transceiver)
|
||||
}
|
||||
dataChannels := make(map[string]*webrtc.DataChannel, len(DionDataChannels))
|
||||
for _, plan := range DionDataChannels {
|
||||
negotiated := true
|
||||
id := plan.ID
|
||||
dc, err := pc.CreateDataChannel(plan.Label, &webrtc.DataChannelInit{
|
||||
Negotiated: &negotiated,
|
||||
ID: &id,
|
||||
})
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
return nil, fmt.Errorf("create datachannel %s id=%d: %w", plan.Label, plan.ID, err)
|
||||
}
|
||||
dataChannels[plan.Label] = dc
|
||||
}
|
||||
dcDescs := make([]DataChannelDesc, 0, len(DionDataChannels))
|
||||
for _, plan := range DionDataChannels {
|
||||
dcDescs = append(dcDescs, DataChannelDesc{ID: int(plan.ID), Label: plan.Label})
|
||||
}
|
||||
return &PionPeer{
|
||||
PC: pc,
|
||||
Transceivers: transceivers,
|
||||
DataChannels: dataChannels,
|
||||
DatachannelDescs: dcDescs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *PionPeer) BuildOfferDescriptors() error {
|
||||
if len(p.Transceivers) != len(DionTransceiverLayout) {
|
||||
return fmt.Errorf("transceiver count drift: have %d want %d", len(p.Transceivers), len(DionTransceiverLayout))
|
||||
}
|
||||
descs := make([]TransceiverDesc, 0, len(p.Transceivers))
|
||||
for index, transceiver := range p.Transceivers {
|
||||
mid := transceiver.Mid()
|
||||
if mid == "" {
|
||||
return fmt.Errorf("transceiver index=%d has empty mid; call SetLocalDescription first", index)
|
||||
}
|
||||
plan := DionTransceiverLayout[index]
|
||||
descs = append(descs, TransceiverDesc{
|
||||
TransceiverID: mid,
|
||||
SessionID: "",
|
||||
Direction: directionToDion(plan.Direction),
|
||||
Ctype: plan.Ctype,
|
||||
})
|
||||
}
|
||||
p.TransceiverDescs = descs
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PionPeer) CreateAndSetOffer() (offerEnvelope string, sdpOffer string, err error) {
|
||||
offer, err := p.PC.CreateOffer(nil)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("create offer: %w", err)
|
||||
}
|
||||
if err := p.PC.SetLocalDescription(offer); err != nil {
|
||||
return "", "", fmt.Errorf("set local description: %w", err)
|
||||
}
|
||||
if err := p.BuildOfferDescriptors(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
envelope, err := BuildSDPOfferEnvelope(offer.SDP)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("build envelope: %w", err)
|
||||
}
|
||||
return envelope, offer.SDP, nil
|
||||
}
|
||||
|
||||
func (p *PionPeer) ApplyAnswerEnvelope(answerEnvelope string) error {
|
||||
sdp, err := DecodeSDPAnswerInner(answerEnvelope)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode answer envelope: %w", err)
|
||||
}
|
||||
return p.PC.SetRemoteDescription(webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeAnswer,
|
||||
SDP: sdp,
|
||||
})
|
||||
}
|
||||
|
||||
func (p *PionPeer) Close() error {
|
||||
if p.PC == nil {
|
||||
return nil
|
||||
}
|
||||
return p.PC.Close()
|
||||
}
|
||||
|
||||
func directionToDion(direction webrtc.RTPTransceiverDirection) string {
|
||||
switch direction {
|
||||
case webrtc.RTPTransceiverDirectionSendonly:
|
||||
return "SendOnly"
|
||||
case webrtc.RTPTransceiverDirectionRecvonly:
|
||||
return "RecvOnly"
|
||||
case webrtc.RTPTransceiverDirectionSendrecv:
|
||||
return "SendRecv"
|
||||
case webrtc.RTPTransceiverDirectionInactive:
|
||||
return "Inactive"
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
func extractICEHost(raw string) string {
|
||||
value := raw
|
||||
for _, prefix := range []string{"stun:", "turn:", "turns:"} {
|
||||
value = strings.TrimPrefix(value, prefix)
|
||||
}
|
||||
if idx := strings.Index(value, "?"); idx >= 0 {
|
||||
value = value[:idx]
|
||||
}
|
||||
if idx := strings.LastIndex(value, ":"); idx >= 0 {
|
||||
value = value[:idx]
|
||||
}
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if net.ParseIP(value) != nil {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
692
transport/call/dion/signaling.go
Normal file
692
transport/call/dion/signaling.go
Normal file
@@ -0,0 +1,692 @@
|
||||
package dion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/sagernet/sing-box/transport/call/common"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
const (
|
||||
MethodServerConnected = "server:notify:main:connected"
|
||||
MethodServerYouJoined = "server:you_joined"
|
||||
MethodServerSubscribeResponse = "server:main:response:subscribe:conference"
|
||||
MethodServerSDPAnswer = "server:sdp_answer"
|
||||
MethodServerSpeakerJoined = "server:speaker_joined"
|
||||
MethodServerSpeakerDisconnected = "server:speaker_disconnected"
|
||||
MethodServerHeartbeat = "server:notify:main:heartbeat"
|
||||
MethodServerSpeakersResponse = "server:response:speakers"
|
||||
MethodServerSpeakersResponseZip = "server:response:speakers_zip"
|
||||
|
||||
MethodClientSubscribeConference = "client:main:request:subscribe:conference"
|
||||
MethodClientSDPOffer = "client:request:media:sdp_offer"
|
||||
MethodClientSendICECandidates = "client:request:send_ice_candidates_zip"
|
||||
MethodClientPCICEStat = "client:request:pc_ice_stat"
|
||||
MethodClientTrace = "client:trace"
|
||||
MethodClientConfSpeakersState = "client:request:conf_speakers_state_zip"
|
||||
MethodServerConfSpeakersState = "server:response:conf_speakers_state_zip"
|
||||
MethodClientGetVideoFromUser = "client:request:get_video_from_user"
|
||||
MethodClientStopVideoFromUser = "client:request:stop_video_from_user"
|
||||
MethodServerGetVideoFromUser = "server:response:get_video_from_user"
|
||||
MethodServerStopVideoFromUser = "server:response:stop_video_from_user"
|
||||
MethodClientCamStateChange = "client:request:cam_state_change"
|
||||
MethodClientMicStateChange = "client:request:mic_state_change"
|
||||
MethodClientScreenSharingSwitchOn = "client:request:screensharing_switch_on"
|
||||
MethodClientScreenSharingSwitchOff = "client:request:screensharing_switch_off"
|
||||
MethodClientGetScreenSharingFromUser = "client:request:get_screensharing_from_user"
|
||||
MethodClientStopScreenSharingFromUser = "client:request:stop_screensharing_from_user"
|
||||
MethodClientScreensharingQualityChange = "client:request:screensharing_quality_change"
|
||||
MethodServerGetScreenSharingFromUser = "server:response:get_screensharing_from_user"
|
||||
MethodClientClientStatZip = "client:request:client_stat_zip"
|
||||
MethodClientKickOne = "client:request:kick_one"
|
||||
MethodServerKickOneResponse = "server:response:kick_one"
|
||||
MethodServerYouKicked = "server:you_kicked"
|
||||
MethodServerYourCamStateChanged = "server:response:your_cam_state_changed"
|
||||
MethodServerYourMicStateChanged = "server:response:your_mic_state_changed"
|
||||
MethodServerSpeakerCamStateChanged = "server:speaker_cam_state_changed"
|
||||
MethodServerSpeakerMicStateChanged = "server:speaker_mic_state_changed"
|
||||
|
||||
ProductVersion = "6.14.0"
|
||||
SubscriptionVersion = "2.0"
|
||||
)
|
||||
|
||||
type Frame struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *RPCError `json:"error,omitempty"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
type RPCError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type TransceiverDesc struct {
|
||||
TransceiverID string `json:"transceiver_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
Direction string `json:"direction"`
|
||||
Ctype string `json:"ctype"`
|
||||
}
|
||||
|
||||
type DataChannelDesc struct {
|
||||
ID int `json:"id"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type SDPEnvelope struct {
|
||||
Type string `json:"type"`
|
||||
SDP string `json:"sdp"`
|
||||
}
|
||||
|
||||
type SDPOfferParams struct {
|
||||
MicState bool `json:"mic_state"`
|
||||
CamState bool `json:"cam_state"`
|
||||
NoiseSuppressionState bool `json:"noise_suppression_state"`
|
||||
VideoQuality *string `json:"video_quality"`
|
||||
ScreenSharingQuality string `json:"screen_sharing_quality"`
|
||||
Datachannels []DataChannelDesc `json:"datachannels"`
|
||||
Transceivers []TransceiverDesc `json:"transceivers"`
|
||||
Offer string `json:"offer"`
|
||||
}
|
||||
|
||||
type SDPAnswerParams struct {
|
||||
Answer string `json:"answer"`
|
||||
Transceivers []TransceiverDesc `json:"transceivers"`
|
||||
}
|
||||
|
||||
type ICEServerEntry struct {
|
||||
URLs []string `json:"urls"`
|
||||
Username string `json:"username"`
|
||||
Credential string `json:"credential"`
|
||||
}
|
||||
|
||||
type YouJoinedParams struct {
|
||||
IcePolicy string `json:"ice_policy"`
|
||||
IceServers []ICEServerEntry `json:"ice_servers"`
|
||||
Event json.RawMessage `json:"event"`
|
||||
EventParams json.RawMessage `json:"event_params"`
|
||||
PreferredCodecs json.RawMessage `json:"preferred_codecs"`
|
||||
}
|
||||
|
||||
type SpeakerJoinedParams struct {
|
||||
SessionID string `json:"session_id"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
CamState bool `json:"cam_state"`
|
||||
MicState bool `json:"mic_state"`
|
||||
Extra json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
type SpeakerCamStateChangedParams struct {
|
||||
SessionID string `json:"session_id"`
|
||||
CamState bool `json:"cam_state"`
|
||||
}
|
||||
|
||||
type SpeakerMicStateChangedParams struct {
|
||||
SessionID string `json:"session_id"`
|
||||
MicState bool `json:"mic_state"`
|
||||
}
|
||||
|
||||
type SpeakerEntry struct {
|
||||
SessionID string `json:"session_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
MicState bool `json:"mic_state"`
|
||||
CamState bool `json:"cam_state"`
|
||||
Role string `json:"role"`
|
||||
WebinarRole string `json:"webinar_role"`
|
||||
IsGuest bool `json:"is_guest"`
|
||||
}
|
||||
|
||||
type ConfSpeakersStateResponse struct {
|
||||
SpeakersCount int `json:"speakers_count"`
|
||||
WebinarSpeakersCount int `json:"webinar_speakers_count"`
|
||||
Speakers []SpeakerEntry `json:"speakers"`
|
||||
}
|
||||
|
||||
type ConfSpeakersStateRequest struct {
|
||||
SessionIDs []string `json:"session_ids"`
|
||||
TileParams ConfSpeakersTileParams `json:"tile_params"`
|
||||
InputVideoQuality string `json:"input_video_quality"`
|
||||
ScreenParams ConfSpeakersScreenParams `json:"screen_params"`
|
||||
}
|
||||
|
||||
type ConfSpeakersTileParams struct {
|
||||
Mode string `json:"mode"`
|
||||
MosaicParams ConfSpeakersMosaicParams `json:"mosaic_params"`
|
||||
IsModeBlocked bool `json:"is_mode_blocked"`
|
||||
}
|
||||
|
||||
type ConfSpeakersMosaicParams struct {
|
||||
MaxTilesCount int `json:"max_tiles_count"`
|
||||
}
|
||||
|
||||
type ConfSpeakersScreenParams struct {
|
||||
Height int `json:"height"`
|
||||
Width int `json:"width"`
|
||||
}
|
||||
|
||||
type SpeakerDisconnectedParams struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
type ICECandidateJSON struct {
|
||||
Candidate string `json:"candidate"`
|
||||
SDPMid *string `json:"sdpMid"`
|
||||
SDPMLineIndex *uint16 `json:"sdpMLineIndex"`
|
||||
UsernameFragment string `json:"usernameFragment,omitempty"`
|
||||
}
|
||||
|
||||
type GetVideoFromUserRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
TransceiverID string `json:"transceiver_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type GetVideoFromUserResponse struct {
|
||||
SessionID string `json:"session_id"`
|
||||
TransceiverID string `json:"transceiver_id"`
|
||||
}
|
||||
|
||||
type GetScreenSharingFromUserRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
TransceiverID string `json:"transceiver_id"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
type GetScreenSharingFromUserResponse struct {
|
||||
SessionID string `json:"session_id"`
|
||||
TransceiverID string `json:"transceiver_id"`
|
||||
}
|
||||
|
||||
type ClientStatVideoIn struct {
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
Codec string `json:"codec"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
JitterBufferDelay float64 `json:"jitter_buffer_delay"`
|
||||
JitterBufferEmittedCount int `json:"jitter_buffer_emitted_count"`
|
||||
Jitter float64 `json:"jitter"`
|
||||
Mid int `json:"mid"`
|
||||
PacketsLost int `json:"packets_lost"`
|
||||
PacketsReceived int `json:"packets_received"`
|
||||
Framerate int `json:"framerate"`
|
||||
FreezeCount int `json:"freeze_count"`
|
||||
Resolution ClientStatResolution `json:"resolution"`
|
||||
Rid string `json:"rid"`
|
||||
TotalFreezesDuration int `json:"total_freezes_duration"`
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
type ClientStatVideoOut struct {
|
||||
Mid int `json:"mid"`
|
||||
BytesSent int64 `json:"bytes_sent"`
|
||||
Codec string `json:"codec"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
PacketsSent int `json:"packets_sent"`
|
||||
RemoteStats ClientStatRemoteStats `json:"remote_stats"`
|
||||
TargetBitrate int `json:"target_bitrate"`
|
||||
Framerate int `json:"framerate"`
|
||||
FreezeCount int `json:"freeze_count"`
|
||||
Resolution ClientStatResolution `json:"resolution"`
|
||||
Rid string `json:"rid"`
|
||||
TotalFreezesDuration int `json:"total_freezes_duration"`
|
||||
SessionID string `json:"session_id"`
|
||||
ScalabilityMode string `json:"scalability_mode"`
|
||||
}
|
||||
|
||||
type ClientStatResolution struct {
|
||||
Height int `json:"height"`
|
||||
Width int `json:"width"`
|
||||
}
|
||||
|
||||
type ClientStatRemoteStats struct {
|
||||
Jitter float64 `json:"jitter"`
|
||||
FractionPacketsLost float64 `json:"fraction_packets_lost"`
|
||||
PacketsLost int `json:"packets_lost"`
|
||||
RTT float64 `json:"rtt"`
|
||||
}
|
||||
|
||||
type ClientStatAudioIn struct {
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
Codec string `json:"codec"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
JitterBufferDelay float64 `json:"jitter_buffer_delay"`
|
||||
JitterBufferEmittedCount int `json:"jitter_buffer_emitted_count"`
|
||||
Jitter float64 `json:"jitter"`
|
||||
Mid int `json:"mid"`
|
||||
PacketsLost int `json:"packets_lost"`
|
||||
PacketsReceived int `json:"packets_received"`
|
||||
}
|
||||
|
||||
type ClientStatConnection struct {
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
BytesSent int64 `json:"bytes_sent"`
|
||||
CurrentRTT float64 `json:"current_rtt"`
|
||||
}
|
||||
|
||||
type ClientStatReport struct {
|
||||
ReportTimeUnixMS int64 `json:"report_time_unix_ms"`
|
||||
Connection ClientStatConnection `json:"connection"`
|
||||
Audio struct {
|
||||
In ClientStatAudioIn `json:"in"`
|
||||
} `json:"audio"`
|
||||
Video struct {
|
||||
In []ClientStatVideoIn `json:"in"`
|
||||
OutV2 []ClientStatVideoOut `json:"out_v2"`
|
||||
Out ClientStatVideoOut `json:"out"`
|
||||
} `json:"video"`
|
||||
Screensharing struct{} `json:"screensharing"`
|
||||
}
|
||||
|
||||
type SignalingDialOptions struct {
|
||||
UserAgent string
|
||||
Origin string
|
||||
Logger logger.ContextLogger
|
||||
Dialer N.Dialer
|
||||
}
|
||||
|
||||
type SignalingClient struct {
|
||||
conn *websocket.Conn
|
||||
writeMu sync.Mutex
|
||||
closed atomic.Bool
|
||||
logger logger.ContextLogger
|
||||
sessionID string
|
||||
eventID string
|
||||
|
||||
OnYouJoined func(YouJoinedParams)
|
||||
OnSubscribeResponse func()
|
||||
OnSDPAnswer func(answerSDP string, transceivers []TransceiverDesc)
|
||||
OnSpeakerJoined func(SpeakerJoinedParams)
|
||||
OnSpeakerDisconnected func(SpeakerDisconnectedParams)
|
||||
OnConfSpeakersState func(ConfSpeakersStateResponse)
|
||||
OnSpeakerCamStateChanged func(SpeakerCamStateChangedParams)
|
||||
OnSpeakerMicStateChanged func(SpeakerMicStateChangedParams)
|
||||
OnGetVideoFromUserResponse func(resp GetVideoFromUserResponse, errCode int, errMessage string)
|
||||
OnGetScreenSharingFromUserResponse func(resp GetScreenSharingFromUserResponse, errCode int, errMessage string)
|
||||
OnHeartbeat func()
|
||||
OnUnknown func(method string, params json.RawMessage)
|
||||
OnDataChannelMessage func(method string, params json.RawMessage)
|
||||
}
|
||||
|
||||
func DialSignaling(wssURL string, opts SignalingDialOptions) (*SignalingClient, error) {
|
||||
if !strings.Contains(wssURL, "socket_version=") {
|
||||
joiner := "&"
|
||||
if !strings.Contains(wssURL, "?") {
|
||||
joiner = "?"
|
||||
}
|
||||
wssURL = wssURL + joiner + "socket_version=2.0"
|
||||
}
|
||||
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
|
||||
if opts.Dialer != nil {
|
||||
dialer.NetDialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return opts.Dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
|
||||
}
|
||||
}
|
||||
headers := http.Header{}
|
||||
if opts.UserAgent != "" {
|
||||
headers.Set("User-Agent", opts.UserAgent)
|
||||
}
|
||||
if opts.Origin != "" {
|
||||
headers.Set("Origin", opts.Origin)
|
||||
} else {
|
||||
headers.Set("Origin", Origin)
|
||||
}
|
||||
log := opts.Logger
|
||||
if log == nil {
|
||||
log = logger.NOP()
|
||||
}
|
||||
conn, resp, err := dialer.Dial(wssURL, headers)
|
||||
if err != nil {
|
||||
status := 0
|
||||
if resp != nil {
|
||||
status = resp.StatusCode
|
||||
}
|
||||
return nil, fmt.Errorf("ws dial: %w status=%d url=%s", err, status, wssURL)
|
||||
}
|
||||
if resp != nil {
|
||||
log.Debug(fmt.Sprintf("dion: ws dial status=%d", resp.StatusCode))
|
||||
}
|
||||
return &SignalingClient{conn: conn, logger: log}, nil
|
||||
}
|
||||
|
||||
func (c *SignalingClient) Close() error {
|
||||
if !c.closed.CompareAndSwap(false, true) {
|
||||
return nil
|
||||
}
|
||||
common.CloseWS(c.conn)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *SignalingClient) WaitConnected(timeout time.Duration) error {
|
||||
c.conn.SetReadDeadline(time.Now().Add(timeout))
|
||||
_, raw, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read connected: %w", err)
|
||||
}
|
||||
var frame Frame
|
||||
if err := json.Unmarshal(raw, &frame); err != nil {
|
||||
return fmt.Errorf("decode connected: %w", err)
|
||||
}
|
||||
if frame.Method != MethodServerConnected {
|
||||
return fmt.Errorf("expected %s, got %s", MethodServerConnected, frame.Method)
|
||||
}
|
||||
c.logger.Debug("dion: signaling connected")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *SignalingClient) Subscribe(eventID, sessionID string) error {
|
||||
c.eventID = eventID
|
||||
c.sessionID = sessionID
|
||||
return c.sendFrame(MethodClientSubscribeConference, map[string]any{
|
||||
"event_id": eventID,
|
||||
"conf_user_session_id": sessionID,
|
||||
"main_user_session_id": nil,
|
||||
"product_version": ProductVersion,
|
||||
"subscription_version": SubscriptionVersion,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *SignalingClient) SendTrace(deviceInfo map[string]any) error {
|
||||
data, err := json.Marshal(deviceInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal trace: %w", err)
|
||||
}
|
||||
return c.sendFrame(MethodClientTrace, map[string]any{"data": string(data)})
|
||||
}
|
||||
|
||||
func (c *SignalingClient) SendSDPOffer(params SDPOfferParams) error {
|
||||
return c.sendFrame(MethodClientSDPOffer, params)
|
||||
}
|
||||
|
||||
func (c *SignalingClient) SendConfSpeakersState(request ConfSpeakersStateRequest) error {
|
||||
encoded, err := ZipEncode(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("zip conf_speakers_state: %w", err)
|
||||
}
|
||||
return c.sendFrame(MethodClientConfSpeakersState, encoded)
|
||||
}
|
||||
|
||||
func (c *SignalingClient) SendGetVideoFromUser(request GetVideoFromUserRequest) error {
|
||||
return c.sendFrame(MethodClientGetVideoFromUser, request)
|
||||
}
|
||||
|
||||
func (c *SignalingClient) SendStopVideoFromUser(request GetVideoFromUserRequest) error {
|
||||
return c.sendFrame(MethodClientStopVideoFromUser, request)
|
||||
}
|
||||
|
||||
func (c *SignalingClient) SendCamStateChange(state bool) error {
|
||||
return c.sendFrame(MethodClientCamStateChange, map[string]any{"state": state})
|
||||
}
|
||||
|
||||
func (c *SignalingClient) SendMicStateChange(state bool) error {
|
||||
return c.sendFrame(MethodClientMicStateChange, map[string]any{"state": state})
|
||||
}
|
||||
|
||||
func (c *SignalingClient) SendScreenSharingSwitchOn() error {
|
||||
return c.sendFrame(MethodClientScreenSharingSwitchOn, map[string]any{})
|
||||
}
|
||||
|
||||
func (c *SignalingClient) SendScreenSharingSwitchOff() error {
|
||||
return c.sendFrame(MethodClientScreenSharingSwitchOff, map[string]any{})
|
||||
}
|
||||
|
||||
func (c *SignalingClient) SendGetScreenSharingFromUser(request GetScreenSharingFromUserRequest) error {
|
||||
return c.sendFrame(MethodClientGetScreenSharingFromUser, request)
|
||||
}
|
||||
|
||||
func (c *SignalingClient) SendScreensharingQualityChange(quality string) error {
|
||||
return c.sendFrame(MethodClientScreensharingQualityChange, map[string]any{"quality": quality})
|
||||
}
|
||||
|
||||
func (c *SignalingClient) SendKickOne(sessionID string) error {
|
||||
return c.sendFrame(MethodClientKickOne, map[string]any{"session_id": sessionID})
|
||||
}
|
||||
|
||||
func (c *SignalingClient) SendPCIceStat() error {
|
||||
return c.sendFrame(MethodClientPCICEStat, map[string]any{"device": "web"})
|
||||
}
|
||||
|
||||
func (c *SignalingClient) SendClientStatZip(report ClientStatReport) error {
|
||||
encoded, err := ZipEncode(report)
|
||||
if err != nil {
|
||||
return fmt.Errorf("zip client_stat: %w", err)
|
||||
}
|
||||
return c.sendFrame(MethodClientClientStatZip, encoded)
|
||||
}
|
||||
|
||||
func (c *SignalingClient) SendICECandidates(candidates []ICECandidateJSON) error {
|
||||
encoded := make([]string, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
raw, err := EncodeICECandidate(candidate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode candidate: %w", err)
|
||||
}
|
||||
encoded = append(encoded, raw)
|
||||
}
|
||||
zipped, err := ZipEncode(map[string]any{"candidates": encoded})
|
||||
if err != nil {
|
||||
return fmt.Errorf("zip candidates: %w", err)
|
||||
}
|
||||
return c.sendFrame(MethodClientSendICECandidates, zipped)
|
||||
}
|
||||
|
||||
func (c *SignalingClient) ReadLoop() error {
|
||||
for {
|
||||
if c.closed.Load() {
|
||||
return nil
|
||||
}
|
||||
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
_, raw, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
if c.closed.Load() {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("ws read: %w", err)
|
||||
}
|
||||
var frame Frame
|
||||
if err := json.Unmarshal(raw, &frame); err != nil {
|
||||
c.logger.Debug(fmt.Sprintf("dion: drop non-json frame: %v", err))
|
||||
continue
|
||||
}
|
||||
if frame.Error != nil {
|
||||
c.logger.Debug(fmt.Sprintf("dion: <- %s ERROR code=%d message=%q", frame.Method, frame.Error.Code, frame.Error.Message))
|
||||
}
|
||||
c.dispatch(frame)
|
||||
}
|
||||
}
|
||||
|
||||
func EncodeICECandidate(candidate ICECandidateJSON) (string, error) {
|
||||
plain, err := json.Marshal(candidate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(plain), nil
|
||||
}
|
||||
|
||||
func DecodeICECandidate(encoded string) (ICECandidateJSON, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return ICECandidateJSON{}, fmt.Errorf("base64: %w", err)
|
||||
}
|
||||
var out ICECandidateJSON
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return ICECandidateJSON{}, fmt.Errorf("unmarshal: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func BuildSDPOfferEnvelope(offerSDP string) (string, error) {
|
||||
return ZipEncode(SDPEnvelope{Type: "offer", SDP: offerSDP})
|
||||
}
|
||||
|
||||
func DecodeSDPAnswerInner(answerZipped string) (string, error) {
|
||||
var inner SDPEnvelope
|
||||
if err := ZipDecode(answerZipped, &inner); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return inner.SDP, nil
|
||||
}
|
||||
|
||||
func DefaultConfSpeakersStateRequest() ConfSpeakersStateRequest {
|
||||
return ConfSpeakersStateRequest{
|
||||
SessionIDs: []string{},
|
||||
TileParams: ConfSpeakersTileParams{
|
||||
Mode: "mosaic",
|
||||
MosaicParams: ConfSpeakersMosaicParams{MaxTilesCount: 9},
|
||||
IsModeBlocked: false,
|
||||
},
|
||||
InputVideoQuality: "auto",
|
||||
ScreenParams: ConfSpeakersScreenParams{Height: 720, Width: 1280},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SignalingClient) sendFrame(method string, params any) error {
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
if c.closed.Load() {
|
||||
return fmt.Errorf("signaling closed")
|
||||
}
|
||||
payload := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params,
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal frame: %w", err)
|
||||
}
|
||||
return c.conn.WriteMessage(websocket.TextMessage, raw)
|
||||
}
|
||||
|
||||
func (c *SignalingClient) dispatch(frame Frame) {
|
||||
switch frame.Method {
|
||||
case MethodServerConnected:
|
||||
c.logger.Debug("dion: late server:notify:main:connected")
|
||||
case MethodServerYouJoined:
|
||||
var youJoined YouJoinedParams
|
||||
if err := json.Unmarshal(frame.Params, &youJoined); err != nil {
|
||||
c.logger.Debug(fmt.Sprintf("dion: decode you_joined: %v", err))
|
||||
return
|
||||
}
|
||||
if c.OnYouJoined != nil {
|
||||
c.OnYouJoined(youJoined)
|
||||
}
|
||||
case MethodServerSubscribeResponse:
|
||||
if c.OnSubscribeResponse != nil {
|
||||
c.OnSubscribeResponse()
|
||||
}
|
||||
case MethodServerSDPAnswer:
|
||||
var answer SDPAnswerParams
|
||||
if err := json.Unmarshal(frame.Params, &answer); err != nil {
|
||||
c.logger.Debug(fmt.Sprintf("dion: decode sdp_answer: %v", err))
|
||||
return
|
||||
}
|
||||
var inner SDPEnvelope
|
||||
if err := ZipDecode(answer.Answer, &inner); err != nil {
|
||||
c.logger.Debug(fmt.Sprintf("dion: decode sdp_answer envelope: %v", err))
|
||||
return
|
||||
}
|
||||
if c.OnSDPAnswer != nil {
|
||||
c.OnSDPAnswer(inner.SDP, answer.Transceivers)
|
||||
}
|
||||
case MethodServerSpeakerJoined:
|
||||
var joined SpeakerJoinedParams
|
||||
if err := json.Unmarshal(frame.Params, &joined); err != nil {
|
||||
c.logger.Debug(fmt.Sprintf("dion: decode speaker_joined: %v", err))
|
||||
return
|
||||
}
|
||||
joined.Extra = frame.Params
|
||||
if c.OnSpeakerJoined != nil {
|
||||
c.OnSpeakerJoined(joined)
|
||||
}
|
||||
case MethodServerSpeakerDisconnected:
|
||||
var left SpeakerDisconnectedParams
|
||||
if err := json.Unmarshal(frame.Params, &left); err != nil {
|
||||
c.logger.Debug(fmt.Sprintf("dion: decode speaker_disconnected: %v", err))
|
||||
return
|
||||
}
|
||||
if c.OnSpeakerDisconnected != nil {
|
||||
c.OnSpeakerDisconnected(left)
|
||||
}
|
||||
case MethodServerSpeakerCamStateChanged:
|
||||
var changed SpeakerCamStateChangedParams
|
||||
if err := json.Unmarshal(frame.Params, &changed); err != nil {
|
||||
c.logger.Debug(fmt.Sprintf("dion: decode speaker_cam_state_changed: %v", err))
|
||||
return
|
||||
}
|
||||
if c.OnSpeakerCamStateChanged != nil {
|
||||
c.OnSpeakerCamStateChanged(changed)
|
||||
}
|
||||
case MethodServerSpeakerMicStateChanged:
|
||||
var changed SpeakerMicStateChangedParams
|
||||
if err := json.Unmarshal(frame.Params, &changed); err != nil {
|
||||
c.logger.Debug(fmt.Sprintf("dion: decode speaker_mic_state_changed: %v", err))
|
||||
return
|
||||
}
|
||||
if c.OnSpeakerMicStateChanged != nil {
|
||||
c.OnSpeakerMicStateChanged(changed)
|
||||
}
|
||||
case MethodServerConfSpeakersState:
|
||||
var encoded string
|
||||
if err := json.Unmarshal(frame.Params, &encoded); err != nil {
|
||||
c.logger.Debug(fmt.Sprintf("dion: decode conf_speakers_state envelope: %v", err))
|
||||
return
|
||||
}
|
||||
var response ConfSpeakersStateResponse
|
||||
if err := ZipDecode(encoded, &response); err != nil {
|
||||
c.logger.Debug(fmt.Sprintf("dion: decode conf_speakers_state body: %v", err))
|
||||
return
|
||||
}
|
||||
if c.OnConfSpeakersState != nil {
|
||||
c.OnConfSpeakersState(response)
|
||||
}
|
||||
case MethodServerHeartbeat:
|
||||
if c.OnHeartbeat != nil {
|
||||
c.OnHeartbeat()
|
||||
}
|
||||
case MethodServerGetVideoFromUser:
|
||||
var resp GetVideoFromUserResponse
|
||||
_ = json.Unmarshal(frame.Params, &resp)
|
||||
errCode := 0
|
||||
errMsg := ""
|
||||
if frame.Error != nil {
|
||||
errCode = frame.Error.Code
|
||||
errMsg = frame.Error.Message
|
||||
}
|
||||
if c.OnGetVideoFromUserResponse != nil {
|
||||
c.OnGetVideoFromUserResponse(resp, errCode, errMsg)
|
||||
}
|
||||
case MethodServerGetScreenSharingFromUser:
|
||||
var resp GetScreenSharingFromUserResponse
|
||||
_ = json.Unmarshal(frame.Params, &resp)
|
||||
errCode := 0
|
||||
errMsg := ""
|
||||
if frame.Error != nil {
|
||||
errCode = frame.Error.Code
|
||||
errMsg = frame.Error.Message
|
||||
}
|
||||
if c.OnGetScreenSharingFromUserResponse != nil {
|
||||
c.OnGetScreenSharingFromUserResponse(resp, errCode, errMsg)
|
||||
}
|
||||
default:
|
||||
if c.OnUnknown != nil {
|
||||
c.OnUnknown(frame.Method, frame.Params)
|
||||
}
|
||||
}
|
||||
}
|
||||
49
transport/call/dion/zip.go
Normal file
49
transport/call/dion/zip.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package dion
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
func ZipEncode(value any) (string, error) {
|
||||
plain, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
var compressed bytes.Buffer
|
||||
gz := gzip.NewWriter(&compressed)
|
||||
if _, err := gz.Write(plain); err != nil {
|
||||
return "", fmt.Errorf("gzip write: %w", err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return "", fmt.Errorf("gzip close: %w", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(compressed.Bytes()), nil
|
||||
}
|
||||
|
||||
func ZipDecode(encoded string, out any) error {
|
||||
raw, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return fmt.Errorf("base64: %w", err)
|
||||
}
|
||||
reader, err := gzip.NewReader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("gzip reader: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
plain, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gzip read: %w", err)
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(plain, out); err != nil {
|
||||
return fmt.Errorf("unmarshal: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user