diff --git a/e2e/tests/admin_console.spec.ts-snapshots/calls-system-console-general-settings-section-chromium-linux.png b/e2e/tests/admin_console.spec.ts-snapshots/calls-system-console-general-settings-section-chromium-linux.png index 1dbfbae3..3858cee3 100644 Binary files a/e2e/tests/admin_console.spec.ts-snapshots/calls-system-console-general-settings-section-chromium-linux.png and b/e2e/tests/admin_console.spec.ts-snapshots/calls-system-console-general-settings-section-chromium-linux.png differ diff --git a/e2e/tests/admin_console.spec.ts-snapshots/calls-system-console-general-settings-section-webkit-linux.png b/e2e/tests/admin_console.spec.ts-snapshots/calls-system-console-general-settings-section-webkit-linux.png index 1dbfbae3..3858cee3 100644 Binary files a/e2e/tests/admin_console.spec.ts-snapshots/calls-system-console-general-settings-section-webkit-linux.png and b/e2e/tests/admin_console.spec.ts-snapshots/calls-system-console-general-settings-section-webkit-linux.png differ diff --git a/go.mod b/go.mod index 50bd9fb3..fd8a1fcb 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/Masterminds/semver v1.5.0 github.com/go-sql-driver/mysql v1.7.1 github.com/gorilla/mux v1.8.1 - github.com/grafana/pyroscope-go/godeltaprof v0.1.7 + github.com/grafana/pyroscope-go/godeltaprof v0.1.8 github.com/jmoiron/sqlx v1.3.5 github.com/lib/pq v1.10.9 github.com/mattermost/calls-offloader v0.8.1-0.20240925183132-f81d68d229b8 @@ -24,7 +24,7 @@ require ( github.com/mattermost/mattermost-plugin-calls/server/public v0.0.3 github.com/mattermost/mattermost/server/public v0.1.5-0.20240613070149-4b0ae20ef7b4 github.com/mattermost/morph v1.1.0 - github.com/mattermost/rtcd v0.17.2-0.20240925183117-466f6a8ed02f + github.com/mattermost/rtcd v0.17.2-0.20241009214839-a44d499cefcc github.com/mattermost/squirrel v0.2.0 github.com/pkg/errors v0.9.1 github.com/rudderlabs/analytics-go v3.3.3+incompatible @@ -76,7 +76,7 @@ require ( github.com/hashicorp/go-plugin v1.6.0 // indirect github.com/hashicorp/yamux v0.1.1 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect - github.com/klauspost/compress v1.17.9 // indirect + github.com/klauspost/compress v1.17.10 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae // indirect diff --git a/go.sum b/go.sum index 0d3d0c23..87c5cf9d 100644 --- a/go.sum +++ b/go.sum @@ -265,8 +265,8 @@ github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWS github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= -github.com/grafana/pyroscope-go/godeltaprof v0.1.7 h1:C11j63y7gymiW8VugJ9ZW0pWfxTZugdSJyC48olk5KY= -github.com/grafana/pyroscope-go/godeltaprof v0.1.7/go.mod h1:Tk376Nbldo4Cha9RgiU7ik8WKFkNpfds98aUzS8omLE= +github.com/grafana/pyroscope-go/godeltaprof v0.1.8 h1:iwOtYXeeVSAeYefJNaxDytgjKtUuKQbJqgAIjlnicKg= +github.com/grafana/pyroscope-go/godeltaprof v0.1.8/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= @@ -335,9 +335,8 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:C github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.17.3/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/compress v1.17.10 h1:oXAz+Vh0PMUvJczoi+flxpnBEPxoER1IaAnU/NMPtT0= +github.com/klauspost/compress v1.17.10/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= @@ -380,8 +379,8 @@ github.com/mattermost/mattermost/server/public v0.1.5-0.20240613070149-4b0ae20ef github.com/mattermost/mattermost/server/public v0.1.5-0.20240613070149-4b0ae20ef7b4/go.mod h1:PDPb/iqzJJ5ZvK/m70oDF55AXN/cOvVFj96Yu4e6j+Q= github.com/mattermost/morph v1.1.0 h1:Q9vrJbeM3s2jfweGheq12EFIzdNp9a/6IovcbvOQ6Cw= github.com/mattermost/morph v1.1.0/go.mod h1:gD+EaqX2UMyyuzmF4PFh4r33XneQ8Nzi+0E8nXjMa3A= -github.com/mattermost/rtcd v0.17.2-0.20240925183117-466f6a8ed02f h1:bJiMgr9Lub1HR0S8yQ/QeXRlHUd0WmmYek72hW3sTk8= -github.com/mattermost/rtcd v0.17.2-0.20240925183117-466f6a8ed02f/go.mod h1:twellAs4hvfjDI/8PIq5viG5ej2zoBzvTGzFscbjKZc= +github.com/mattermost/rtcd v0.17.2-0.20241009214839-a44d499cefcc h1:KTdzXsjcrnawYH2uNwLW8WVe0vyYmuC8ckpR83Cj7Sg= +github.com/mattermost/rtcd v0.17.2-0.20241009214839-a44d499cefcc/go.mod h1:FVyFLa+7dWImCZ+0g9xoc/1fMRKMuXs6yAZP1mDKBIg= github.com/mattermost/squirrel v0.2.0 h1:8ZWeyf+MWQ2cL7hu9REZgLtz2IJi51qqZEovI3T3TT8= github.com/mattermost/squirrel v0.2.0/go.mod h1:NPPtk+CdpWre4GxMGoOpzEVFVc0ZoEFyJBZGCtn9nSU= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= diff --git a/lt/client/client.go b/lt/client/client.go index b3956c88..0dacac2e 100644 --- a/lt/client/client.go +++ b/lt/client/client.go @@ -468,6 +468,10 @@ func (u *User) Speak(text string) chan struct{} { } func (u *User) onConnect() { + // Waiting a second before transmitting to avoid + // the known race between incoming and outgoing track. + // This is needed as pion doesn't support perfect negotiation yet. + time.Sleep(time.Second) if u.cfg.Unmuted { go u.transmitAudio() } else if u.cfg.Speak { @@ -509,6 +513,33 @@ func (u *User) getCallsConfig() (map[string]any, error) { return config, nil } +func (u *User) handleTrack(ctx any) error { + m, ok := ctx.(map[string]any) + if !ok || m == nil { + return fmt.Errorf("failed to convert map") + } + + track, ok := m["track"].(*webrtc.TrackRemote) + if !ok || track == nil { + return fmt.Errorf("failed to convert track") + } + + // We don't currently do anything with the packets but we should still read + // them to properly calculate client stats. + buf := make([]byte, receiveMTU) + for { + _, _, readErr := track.Read(buf) + if readErr != nil { + if !errors.Is(readErr, io.EOF) { + u.log.Error("failed to read RTP packet for track", + slog.String("err", readErr.Error()), + slog.String("trackID", track.ID())) + } + return nil + } + } +} + func (u *User) Connect(stopCh chan struct{}) error { u.log.Debug("connecting user") @@ -597,16 +628,22 @@ func (u *User) Connect(stopCh chan struct{}) error { u.callsConfig = callsConfig enableAV1, _ := u.callsConfig["EnableAV1"].(bool) + enableDCSignaling, _ := u.callsConfig["EnableDCSignaling"].(bool) callsClient, err := client.New(client.Config{ - SiteURL: u.cfg.SiteURL, - AuthToken: apiClient.AuthToken, - ChannelID: u.cfg.ChannelID, - EnableAV1: enableAV1, + SiteURL: u.cfg.SiteURL, + AuthToken: apiClient.AuthToken, + ChannelID: u.cfg.ChannelID, + EnableAV1: enableAV1, + EnableDCSignaling: enableDCSignaling, + EnableRTCMonitor: true, }, client.WithLogger(u.log)) if err != nil { return fmt.Errorf("failed to create calls client: %w", err) } + if err := callsClient.On(client.RTCTrackEvent, u.handleTrack); err != nil { + return fmt.Errorf("failed to add track handler: %w", err) + } u.callsClient = callsClient diff --git a/lt/go.mod b/lt/go.mod index 38e6cb26..1ef7cf59 100644 --- a/lt/go.mod +++ b/lt/go.mod @@ -6,7 +6,7 @@ require ( github.com/aws/aws-sdk-go v1.50.3 github.com/hajimehoshi/go-mp3 v0.3.4 github.com/mattermost/mattermost/server/public v0.0.12 - github.com/mattermost/rtcd v0.17.2-0.20240925183117-466f6a8ed02f + github.com/mattermost/rtcd v0.17.2-0.20241009214839-a44d499cefcc github.com/pion/rtp v1.8.6 github.com/pion/webrtc/v3 v3.2.41 gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 diff --git a/lt/go.sum b/lt/go.sum index 3ee0a4b9..1631f43c 100644 --- a/lt/go.sum +++ b/lt/go.sum @@ -85,8 +85,8 @@ github.com/mattermost/logr/v2 v2.0.21 h1:CMHsP+nrbRlEC4g7BwOk1GAnMtHkniFhlSQPXy5 github.com/mattermost/logr/v2 v2.0.21/go.mod h1:kZkB/zqKL9e+RY5gB3vGpsyenC+TpuiOenjMkvJJbzc= github.com/mattermost/mattermost/server/public v0.0.12 h1:iunc9q4/XkArOrndEUn73uFw6v9TOEXEtp6Nm6Iv218= github.com/mattermost/mattermost/server/public v0.0.12/go.mod h1:Bk+atJcELCIk9Yeq5FoqTr+gra9704+X4amrlwlTgSc= -github.com/mattermost/rtcd v0.17.2-0.20240925183117-466f6a8ed02f h1:bJiMgr9Lub1HR0S8yQ/QeXRlHUd0WmmYek72hW3sTk8= -github.com/mattermost/rtcd v0.17.2-0.20240925183117-466f6a8ed02f/go.mod h1:twellAs4hvfjDI/8PIq5viG5ej2zoBzvTGzFscbjKZc= +github.com/mattermost/rtcd v0.17.2-0.20241009214839-a44d499cefcc h1:KTdzXsjcrnawYH2uNwLW8WVe0vyYmuC8ckpR83Cj7Sg= +github.com/mattermost/rtcd v0.17.2-0.20241009214839-a44d499cefcc/go.mod h1:FVyFLa+7dWImCZ+0g9xoc/1fMRKMuXs6yAZP1mDKBIg= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/plugin.json b/plugin.json index 28705ea6..a935fca4 100644 --- a/plugin.json +++ b/plugin.json @@ -67,6 +67,13 @@ "type": "bool", "default": false, "help_text": "When set to true it enables using the AV1 codec to encode screen sharing tracks. This can result in improved screen sharing quality for clients that support it.\nNote: this setting won't apply when EnableSimulcast is true." + }, + { + "key": "EnableDCSignaling", + "display_name": "Use data channels for signaling (Experimental)", + "type": "bool", + "default": false, + "help_text": "When set to true, clients will use WebRTC data channels for signaling of new media tracks. This can result in a more efficient and less race-prone process, especially in case of frequent WebSocket disconnections." } ] }, @@ -676,6 +683,13 @@ "type": "bool", "default": false, "help_text": "When set to true it enables using the AV1 codec to encode screen sharing tracks. This can result in improved screen sharing quality for clients that support it.\nNote: this setting won't apply when EnableSimulcast is true." + }, + { + "key": "EnableDCSignaling", + "display_name": "Use data channels for signaling (Experimental)", + "type": "bool", + "default": false, + "help_text": "When set to true, clients will use WebRTC data channels for signaling of new media tracks. This can result in a more efficient and less race-prone process, especially in case of frequent WebSocket disconnections." } ] }, diff --git a/server/configuration.go b/server/configuration.go index 9e959cb7..195d0214 100644 --- a/server/configuration.go +++ b/server/configuration.go @@ -137,6 +137,8 @@ type clientConfig struct { EnableAV1 *bool // Let the server determine whether or not group calls are allowed (through license checks or otherwise) GroupCallsAllowed bool + // When set to true it enables experimental support for using the data channel for signaling. + EnableDCSignaling *bool } type adminClientConfig struct { @@ -265,6 +267,9 @@ func (c *configuration) SetDefaults() { if c.EnableAV1 == nil { c.EnableAV1 = model.NewBool(false) } + if c.EnableDCSignaling == nil { + c.EnableDCSignaling = model.NewBool(false) + } } func (c *configuration) IsValid() error { @@ -453,6 +458,10 @@ func (c *configuration) Clone() *configuration { cfg.EnableAV1 = model.NewBool(*c.EnableAV1) } + if c.EnableDCSignaling != nil { + cfg.EnableDCSignaling = model.NewBool(*c.EnableDCSignaling) + } + return &cfg } @@ -517,6 +526,7 @@ func (p *Plugin) getClientConfig(c *configuration) clientConfig { HostControlsAllowed: p.licenseChecker.HostControlsAllowed(), EnableAV1: c.EnableAV1, GroupCallsAllowed: p.licenseChecker.GroupCallsAllowed(), + EnableDCSignaling: c.EnableDCSignaling, } } diff --git a/server/mocks/github.com/mattermost/rtcd/service/rtc/mock_Metrics.go b/server/mocks/github.com/mattermost/rtcd/service/rtc/mock_Metrics.go index c5881300..7ebe554e 100644 --- a/server/mocks/github.com/mattermost/rtcd/service/rtc/mock_Metrics.go +++ b/server/mocks/github.com/mattermost/rtcd/service/rtc/mock_Metrics.go @@ -220,6 +220,108 @@ func (_c *MockMetrics_IncRTPTracks_Call) RunAndReturn(run func(string, string, s return _c } +// ObserveRTCClientJitter provides a mock function with given fields: groupID, val +func (_m *MockMetrics) ObserveRTCClientJitter(groupID string, val float64) { + _m.Called(groupID, val) +} + +// MockMetrics_ObserveRTCClientJitter_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ObserveRTCClientJitter' +type MockMetrics_ObserveRTCClientJitter_Call struct { + *mock.Call +} + +// ObserveRTCClientJitter is a helper method to define mock.On call +// - groupID string +// - val float64 +func (_e *MockMetrics_Expecter) ObserveRTCClientJitter(groupID interface{}, val interface{}) *MockMetrics_ObserveRTCClientJitter_Call { + return &MockMetrics_ObserveRTCClientJitter_Call{Call: _e.mock.On("ObserveRTCClientJitter", groupID, val)} +} + +func (_c *MockMetrics_ObserveRTCClientJitter_Call) Run(run func(groupID string, val float64)) *MockMetrics_ObserveRTCClientJitter_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(float64)) + }) + return _c +} + +func (_c *MockMetrics_ObserveRTCClientJitter_Call) Return() *MockMetrics_ObserveRTCClientJitter_Call { + _c.Call.Return() + return _c +} + +func (_c *MockMetrics_ObserveRTCClientJitter_Call) RunAndReturn(run func(string, float64)) *MockMetrics_ObserveRTCClientJitter_Call { + _c.Call.Return(run) + return _c +} + +// ObserveRTCClientLossRate provides a mock function with given fields: groupID, val +func (_m *MockMetrics) ObserveRTCClientLossRate(groupID string, val float64) { + _m.Called(groupID, val) +} + +// MockMetrics_ObserveRTCClientLossRate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ObserveRTCClientLossRate' +type MockMetrics_ObserveRTCClientLossRate_Call struct { + *mock.Call +} + +// ObserveRTCClientLossRate is a helper method to define mock.On call +// - groupID string +// - val float64 +func (_e *MockMetrics_Expecter) ObserveRTCClientLossRate(groupID interface{}, val interface{}) *MockMetrics_ObserveRTCClientLossRate_Call { + return &MockMetrics_ObserveRTCClientLossRate_Call{Call: _e.mock.On("ObserveRTCClientLossRate", groupID, val)} +} + +func (_c *MockMetrics_ObserveRTCClientLossRate_Call) Run(run func(groupID string, val float64)) *MockMetrics_ObserveRTCClientLossRate_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(float64)) + }) + return _c +} + +func (_c *MockMetrics_ObserveRTCClientLossRate_Call) Return() *MockMetrics_ObserveRTCClientLossRate_Call { + _c.Call.Return() + return _c +} + +func (_c *MockMetrics_ObserveRTCClientLossRate_Call) RunAndReturn(run func(string, float64)) *MockMetrics_ObserveRTCClientLossRate_Call { + _c.Call.Return(run) + return _c +} + +// ObserveRTCClientRTT provides a mock function with given fields: groupID, val +func (_m *MockMetrics) ObserveRTCClientRTT(groupID string, val float64) { + _m.Called(groupID, val) +} + +// MockMetrics_ObserveRTCClientRTT_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ObserveRTCClientRTT' +type MockMetrics_ObserveRTCClientRTT_Call struct { + *mock.Call +} + +// ObserveRTCClientRTT is a helper method to define mock.On call +// - groupID string +// - val float64 +func (_e *MockMetrics_Expecter) ObserveRTCClientRTT(groupID interface{}, val interface{}) *MockMetrics_ObserveRTCClientRTT_Call { + return &MockMetrics_ObserveRTCClientRTT_Call{Call: _e.mock.On("ObserveRTCClientRTT", groupID, val)} +} + +func (_c *MockMetrics_ObserveRTCClientRTT_Call) Run(run func(groupID string, val float64)) *MockMetrics_ObserveRTCClientRTT_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(float64)) + }) + return _c +} + +func (_c *MockMetrics_ObserveRTCClientRTT_Call) Return() *MockMetrics_ObserveRTCClientRTT_Call { + _c.Call.Return() + return _c +} + +func (_c *MockMetrics_ObserveRTCClientRTT_Call) RunAndReturn(run func(string, float64)) *MockMetrics_ObserveRTCClientRTT_Call { + _c.Call.Return(run) + return _c +} + // ObserveRTPTracksWrite provides a mock function with given fields: groupID, trackType, dur func (_m *MockMetrics) ObserveRTPTracksWrite(groupID string, trackType string, dur float64) { _m.Called(groupID, trackType, dur) diff --git a/server/websocket.go b/server/websocket.go index 9161b585..e5474b1a 100644 --- a/server/websocket.go +++ b/server/websocket.go @@ -87,7 +87,8 @@ type CallsClientJoinData struct { Title string ThreadID string - AV1Support bool + AV1Support bool + DCSignaling bool // JobID is the id of the job tight to the bot connection to // a call (e.g. recording, transcription). It's a parameter reserved to the @@ -776,11 +777,12 @@ func (p *Plugin) handleJoin(userID, connID, authSessionID string, joinData calls msg := rtcd.ClientMessage{ Type: rtcd.ClientMessageJoin, Data: map[string]any{ - "callID": us.callID, - "userID": userID, - "sessionID": connID, - "channelID": channelID, - "av1Support": joinData.AV1Support, + "callID": us.callID, + "userID": userID, + "sessionID": connID, + "channelID": channelID, + "av1Support": joinData.AV1Support, + "dcSignaling": joinData.DCSignaling, }, } if err := p.rtcdManager.Send(msg, state.Call.Props.RTCDHost); err != nil { @@ -809,8 +811,9 @@ func (p *Plugin) handleJoin(userID, connID, authSessionID string, joinData calls UserID: userID, SessionID: connID, Props: rtc.SessionProps{ - "channelID": channelID, - "av1Support": joinData.AV1Support, + "channelID": channelID, + "av1Support": joinData.AV1Support, + "dcSignaling": joinData.DCSignaling, }, } p.LogDebug("initializing RTC session", "userID", userID, "connID", connID, "channelID", channelID, "callID", us.callID) @@ -837,8 +840,9 @@ func (p *Plugin) handleJoin(userID, connID, authSessionID string, joinData calls CallID: us.callID, SenderID: p.nodeID, SessionProps: rtc.SessionProps{ - "channelID": channelID, - "av1Support": joinData.AV1Support, + "channelID": channelID, + "av1Support": joinData.AV1Support, + "dcSignaling": joinData.DCSignaling, }, }, clusterMessageTypeConnect, handlerID); err != nil { p.LogError("failed to send connect message", "err", err.Error()) @@ -1169,17 +1173,19 @@ func (p *Plugin) WebSocketMessageHasBeenPosted(connID, userID string, req *model jobID, _ := req.Data["jobID"].(string) av1Support, _ := req.Data["av1Support"].(bool) + dcSignaling, _ := req.Data["dcSignaling"].(bool) remoteAddr, _ := req.Data[model.WebSocketRemoteAddr].(string) xff, _ := req.Data[model.WebSocketXForwardedFor].(string) joinData := callsJoinData{ CallsClientJoinData{ - ChannelID: channelID, - Title: title, - ThreadID: threadID, - AV1Support: av1Support, - JobID: jobID, + ChannelID: channelID, + Title: title, + ThreadID: threadID, + AV1Support: av1Support, + DCSignaling: dcSignaling, + JobID: jobID, }, remoteAddr, xff, diff --git a/standalone/package-lock.json b/standalone/package-lock.json index 3fbbeb63..1b576262 100644 --- a/standalone/package-lock.json +++ b/standalone/package-lock.json @@ -5,13 +5,12 @@ "packages": { "": { "dependencies": { - "@mattermost/calls-common": "github:mattermost/calls-common#1ce6defb1ee0c1e0f106ddff8f46c37d10d60b76", + "@mattermost/calls-common": "github:mattermost/calls-common#8d2b13bd2f10847a4be461dd4225fef2ade06ab9", "@mattermost/compass-icons": "0.1.31", "@msgpack/msgpack": "2.7.1", "bootstrap": "3.4.1", "core-js": "3.26.1", "luxon": "3.3.0", - "pako": "2.0.4", "react": "17.0.2", "react-bootstrap": "github:mattermost/react-bootstrap#c6957962364e0818a51bbfd13e89919903b422d6", "react-dom": "17.0.2", @@ -3089,8 +3088,20 @@ }, "node_modules/@mattermost/calls-common": { "version": "0.27.2", - "resolved": "git+ssh://git@github.com/mattermost/calls-common.git#1ce6defb1ee0c1e0f106ddff8f46c37d10d60b76", - "integrity": "sha512-VeX0GT1g8bl7AqG5TJEnbkTTVbc+CqZttpPBKYDkMJd86CGjtURc4o83OUu3TNsEI3opSpJfbetScUTG9TWNLw==" + "resolved": "git+ssh://git@github.com/mattermost/calls-common.git#8d2b13bd2f10847a4be461dd4225fef2ade06ab9", + "integrity": "sha512-jze9YO7n9c8AgaE2FOMzj8g2ieqczNfCzah7W6TeQtC0Ufhe3jcGG99DNtrB3tHeiBsp/ZlrvBXCVpA/oQR6YA==", + "dependencies": { + "@msgpack/msgpack": "^3.0.0-beta2", + "fflate": "^0.8.2" + } + }, + "node_modules/@mattermost/calls-common/node_modules/@msgpack/msgpack": { + "version": "3.0.0-beta2", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.0.0-beta2.tgz", + "integrity": "sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==", + "engines": { + "node": ">= 14" + } }, "node_modules/@mattermost/client": { "resolved": "../webapp/mattermost-webapp/webapp/platform/client", @@ -6861,6 +6872,11 @@ "bser": "2.1.1" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "dev": true, @@ -10502,10 +10518,6 @@ "node": ">=6" } }, - "node_modules/pako": { - "version": "2.0.4", - "license": "(MIT AND Zlib)" - }, "node_modules/param-case": { "version": "3.0.4", "dev": true, diff --git a/standalone/package.json b/standalone/package.json index f7db4bd6..4756640c 100644 --- a/standalone/package.json +++ b/standalone/package.json @@ -18,13 +18,12 @@ "extract": "formatjs extract 'src/**/*.{ts,tsx}' --ignore 'src/**/*.d.ts' --out-file i18n/temp.json --id-interpolation-pattern '[sha512:contenthash:base64:6]' && formatjs compile 'i18n/temp.json' --out-file i18n/en.json && rm i18n/temp.json" }, "dependencies": { - "@mattermost/calls-common": "github:mattermost/calls-common#1ce6defb1ee0c1e0f106ddff8f46c37d10d60b76", + "@mattermost/calls-common": "github:mattermost/calls-common#8d2b13bd2f10847a4be461dd4225fef2ade06ab9", "@mattermost/compass-icons": "0.1.31", "@msgpack/msgpack": "2.7.1", "bootstrap": "3.4.1", "core-js": "3.26.1", "luxon": "3.3.0", - "pako": "2.0.4", "react": "17.0.2", "react-bootstrap": "github:mattermost/react-bootstrap#c6957962364e0818a51bbfd13e89919903b422d6", "react-dom": "17.0.2", diff --git a/standalone/src/init.ts b/standalone/src/init.ts index 3b950571..322389f0 100644 --- a/standalone/src/init.ts +++ b/standalone/src/init.ts @@ -233,6 +233,7 @@ export default async function init(cfg: InitConfig) { authToken: getToken(), simulcast: callsConfig(store.getState()).EnableSimulcast, enableAV1: callsConfig(store.getState()).EnableAV1, + dcSignaling: callsConfig(store.getState()).EnableDCSignaling, }; connectCall(joinData, clientConfig, (ev) => { diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 85668ddb..0df5353a 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -16,6 +16,7 @@ "0fj4bj": "Allow screen sharing", "10k1Mv": "The URL pointing to a running calls-offloader job service instance.", "1QvHUW": "Your recording will end in {count, plural, =1 {# minute} other {# minutes}}.", + "1s4g9H": "When set to true, clients will use WebRTC data channels for signaling of new media tracks. This can result in a more efficient and less race-prone process, especially in case of frequent WebSocket disconnections.", "1vzfaf": "End call for everyone", "22Lra1": "Here's the call recording", "23eRB2": "Call connection failed", @@ -245,6 +246,7 @@ "kr3shS": "Unable to join call", "ks1Gvx": "Or hold space bar", "l/BSzX": "When set to true, simulcast for screen sharing is enabled. This can help to improve screen sharing quality.", + "lE59Z5": "Use data channels for signaling (Experimental)", "lKv8ex": "Default", "lRSSL4": "You're already in a call in {channel}.", "lWsBmL": "Chat unavailable: different team selected. Click here to switch back to {channelName} in {teamName}.", diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 6fb99cce..db1fc3ad 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -7,15 +7,15 @@ "hasInstallScript": true, "dependencies": { "@floating-ui/react": "0.26.12", - "@mattermost/calls-common": "github:mattermost/calls-common#1ce6defb1ee0c1e0f106ddff8f46c37d10d60b76", + "@mattermost/calls-common": "github:mattermost/calls-common#8d2b13bd2f10847a4be461dd4225fef2ade06ab9", "@msgpack/msgpack": "2.7.1", "@redux-devtools/extension": "3.2.3", "core-js": "3.26.1", "css-vars-ponyfill": "2.4.8", "emoji-picker-react": "4.4.7", + "fflate": "0.8.2", "highlight.js": "11.6.0", "media-chrome": "0.16.0", - "pako": "2.0.4", "react": "17.0.2", "react-dom": "17.0.2", "react-redux": "7.2.4", @@ -8239,8 +8239,20 @@ }, "node_modules/@mattermost/calls-common": { "version": "0.27.2", - "resolved": "git+ssh://git@github.com/mattermost/calls-common.git#1ce6defb1ee0c1e0f106ddff8f46c37d10d60b76", - "integrity": "sha512-VeX0GT1g8bl7AqG5TJEnbkTTVbc+CqZttpPBKYDkMJd86CGjtURc4o83OUu3TNsEI3opSpJfbetScUTG9TWNLw==" + "resolved": "git+ssh://git@github.com/mattermost/calls-common.git#8d2b13bd2f10847a4be461dd4225fef2ade06ab9", + "integrity": "sha512-jze9YO7n9c8AgaE2FOMzj8g2ieqczNfCzah7W6TeQtC0Ufhe3jcGG99DNtrB3tHeiBsp/ZlrvBXCVpA/oQR6YA==", + "dependencies": { + "@msgpack/msgpack": "^3.0.0-beta2", + "fflate": "^0.8.2" + } + }, + "node_modules/@mattermost/calls-common/node_modules/@msgpack/msgpack": { + "version": "3.0.0-beta2", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.0.0-beta2.tgz", + "integrity": "sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==", + "engines": { + "node": ">= 14" + } }, "node_modules/@mattermost/client": { "resolved": "mattermost-webapp/webapp/platform/client", @@ -17227,6 +17239,11 @@ "pend": "~1.2.0" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -23658,11 +23675,6 @@ "node": ">=6" } }, - "node_modules/pako": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/pako/-/pako-2.0.4.tgz", - "integrity": "sha512-v8tweI900AUkZN6heMU/4Uy4cXRc2AYNRggVmTR+dEncawDJgCdLMximOVA2p4qO57WMynangsfGRb5WD6L1Bg==" - }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", diff --git a/webapp/package.json b/webapp/package.json index 03021570..9da04573 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -20,15 +20,15 @@ }, "dependencies": { "@floating-ui/react": "0.26.12", - "@mattermost/calls-common": "github:mattermost/calls-common#1ce6defb1ee0c1e0f106ddff8f46c37d10d60b76", + "@mattermost/calls-common": "github:mattermost/calls-common#8d2b13bd2f10847a4be461dd4225fef2ade06ab9", "@msgpack/msgpack": "2.7.1", "@redux-devtools/extension": "3.2.3", "core-js": "3.26.1", "css-vars-ponyfill": "2.4.8", "emoji-picker-react": "4.4.7", + "fflate": "0.8.2", "highlight.js": "11.6.0", "media-chrome": "0.16.0", - "pako": "2.0.4", "react": "17.0.2", "react-dom": "17.0.2", "react-redux": "7.2.4", @@ -140,7 +140,7 @@ "jest-canvas-mock" ], "setupFilesAfterEnv": [ - "/tests/setup.js" + "/src/setup_jest.ts" ], "testURL": "http://localhost:8065", "testEnvironment": "jsdom" diff --git a/webapp/src/client.ts b/webapp/src/client.ts index c0ea01a4..ffdce3f3 100644 --- a/webapp/src/client.ts +++ b/webapp/src/client.ts @@ -5,8 +5,7 @@ import type {EmojiData, CallsClientJoinData} from '@mattermost/calls-common/lib/ import {EventEmitter} from 'events'; -// @ts-ignore -import {deflate} from 'pako/lib/deflate'; +import {zlibSync, strToU8} from 'fflate'; import {AudioDevices, CallsClientConfig, CallsClientStats, TrackInfo} from 'src/types/types'; import {logDebug, logErr, logInfo, logWarn, persistClientLogs} from './log'; @@ -53,6 +52,7 @@ export default class CallsClient extends EventEmitter { private av1Codec: RTCRtpCodecCapability | null = null; constructor(config: CallsClientConfig) { + logDebug('creating new calls client', JSON.stringify(config)); super(); this.ws = null; this.peer = null; @@ -233,6 +233,11 @@ export default class CallsClient extends EventEmitter { logWarn('both simulcast and av1 support are enabled'); } + if (this.config.dcSignaling) { + logDebug('enabling DC signaling on client'); + joinData.dcSignaling = true; + } + if (!window.isSecureContext) { throw insecureContextErr; } @@ -299,6 +304,7 @@ export default class CallsClient extends EventEmitter { logInfo, }, simulcast: this.config.simulcast, + dcSignaling: this.config.dcSignaling, }); this.peer = peer; @@ -317,23 +323,20 @@ export default class CallsClient extends EventEmitter { }); this.rtcMonitor.on('mos', (mos: number) => this.emit('mos', mos)); - peer.on('offer', (sdp) => { - logDebug(`local signal: ${JSON.stringify(sdp)}`); - ws.send('sdp', { - data: deflate(JSON.stringify(sdp)), - }, true); - }); + const sdpHandler = (sdp: RTCSessionDescription) => { + const payload = JSON.stringify(sdp); - peer.on('answer', (sdp) => { - logDebug(`local signal: ${JSON.stringify(sdp)}`); + // SDP data is compressed using zlib since it's text based + // and can grow substantially, potentially hitting the maximum + // message size (4KB). ws.send('sdp', { - data: deflate(JSON.stringify(sdp)), + data: zlibSync(strToU8(payload)), }, true); - }); + }; + peer.on('offer', sdpHandler); + peer.on('answer', sdpHandler); peer.on('candidate', (candidate) => { - logDebug(`local candidate: ${JSON.stringify(candidate)}`); - ws.send('ice', { data: JSON.stringify(candidate), }); @@ -385,9 +388,6 @@ export default class CallsClient extends EventEmitter { if (!msg) { return; } - if (msg.type !== 'ping') { - logDebug('remote signal', data); - } if (msg.type === 'answer' || msg.type === 'offer' || msg.type === 'candidate') { if (this.peer) { await this.peer.signal(data); diff --git a/webapp/src/components/admin_console_settings/enable_dc_signaling.tsx b/webapp/src/components/admin_console_settings/enable_dc_signaling.tsx new file mode 100644 index 00000000..74792ae4 --- /dev/null +++ b/webapp/src/components/admin_console_settings/enable_dc_signaling.tsx @@ -0,0 +1,60 @@ +import React, {ChangeEvent} from 'react'; +import {useIntl} from 'react-intl'; +import {leftCol, RadioInput, RadioInputLabel, rightCol} from 'src/components/admin_console_settings/common'; +import {CustomComponentProps} from 'src/types/mattermost-webapp'; + +export default function EnableDCSignaling(props: CustomComponentProps) { + const {formatMessage} = useIntl(); + + const handleChange = (e: ChangeEvent) => { + props.onChange(props.id, e.target.value === 'true'); + }; + + // @ts-ignore val is a boolean, but the signature says 'string'. (being defensive here, just in case) + const checked = props.value === 'true' || props.value === true; + + return ( +
+ +
+ + + {formatMessage({defaultMessage: 'True'})} + + + + {formatMessage({defaultMessage: 'False'})} + +
+ {formatMessage({defaultMessage: 'When set to true, clients will use WebRTC data channels for signaling of new media tracks. This can result in a more efficient and less race-prone process, especially in case of frequent WebSocket disconnections.'})} +
+
+
+ ); +} diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index 3827693d..8b82a676 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -35,6 +35,7 @@ import { import {navigateToURL} from 'src/browser_routing'; import AllowScreenSharing from 'src/components/admin_console_settings/allow_screen_sharing'; import EnableAV1 from 'src/components/admin_console_settings/enable_av1'; +import EnableDCSignaling from 'src/components/admin_console_settings/enable_dc_signaling'; import EnableIPv6 from 'src/components/admin_console_settings/enable_ipv6'; import EnableRinging from 'src/components/admin_console_settings/enable_ringing'; import EnableSimulcast from 'src/components/admin_console_settings/enable_simulcast'; @@ -487,6 +488,7 @@ export default class Plugin { registry.registerAdminConsoleCustomSetting('EnableSimulcast', EnableSimulcast); registry.registerAdminConsoleCustomSetting('EnableAV1', EnableAV1); registry.registerAdminConsoleCustomSetting('EnableRinging', EnableRinging); + registry.registerAdminConsoleCustomSetting('EnableDCSignaling', EnableDCSignaling); // RTCD Service if (registry.registerAdminConsoleCustomSection) { @@ -660,6 +662,7 @@ export default class Plugin { iceServers: iceConfigs, simulcast: callsConfig(state).EnableSimulcast, enableAV1: callsConfig(state).EnableAV1, + dcSignaling: callsConfig(state).EnableDCSignaling, }); window.currentCallData = CurrentCallDataDefault; diff --git a/webapp/src/setup_jest.ts b/webapp/src/setup_jest.ts new file mode 100644 index 00000000..8c41296d --- /dev/null +++ b/webapp/src/setup_jest.ts @@ -0,0 +1,6 @@ +import {TextDecoder, TextEncoder} from 'util'; + +global.TextEncoder = TextEncoder; + +// @ts-ignore +global.TextDecoder = TextDecoder; diff --git a/webapp/src/types/types.ts b/webapp/src/types/types.ts index 9ff9063d..9055a42e 100644 --- a/webapp/src/types/types.ts +++ b/webapp/src/types/types.ts @@ -22,6 +22,7 @@ export const CallsConfigDefault: CallsConfig = { EnableAV1: false, TranscribeAPI: TranscribeAPI.WhisperCPP, GroupCallsAllowed: false, + EnableDCSignaling: false, }; export type ChannelState = { @@ -35,6 +36,7 @@ export type CallsClientConfig = { iceServers: RTCIceServer[]; simulcast?: boolean; enableAV1: boolean; + dcSignaling: boolean; } export type AudioDevices = {