mirror of
https://github.com/openlibrecommunity/olcrtc.git
synced 2026-06-02 06:23:37 +02:00
feat(jitsi): add Jitsi auth provider and engine
This commit is contained in:
+10
-8
@@ -12,12 +12,12 @@
|
|||||||
|
|
||||||
## Матрица совместимости
|
## Матрица совместимости
|
||||||
|
|
||||||
| Transport | telemost | jazz | wbstream |
|
| Transport | telemost | jazz | wbstream | jitsi |
|
||||||
|-----------|:--------:|:----:|:--------:|
|
|-----------|:--------:|:----:|:--------:|:-----:|
|
||||||
| datachannel | - | ~ | ~ |
|
| datachannel | - | ~ | ~ | + |
|
||||||
| vp8channel | + | - | + |
|
| vp8channel | + | - | + | ~ |
|
||||||
| seichannel | - | - | + |
|
| seichannel | - | - | + | ~ |
|
||||||
| videochannel | + | - | + |
|
| videochannel | + | - | + | ~ |
|
||||||
|
|
||||||
**Легенда:**
|
**Легенда:**
|
||||||
- `+` - работает (pass в E2E тестах)
|
- `+` - работает (pass в E2E тестах)
|
||||||
@@ -30,7 +30,9 @@
|
|||||||
|
|
||||||
**WBStream:** все транспорты кроме datachannel работают. DataChannel в обычном guest flow без выдавания модератора не работает — WB Stream выдаёт токены с `canPublishData=false`, и DC не маршрутизирует данные.
|
**WBStream:** все транспорты кроме datachannel работают. DataChannel в обычном guest flow без выдавания модератора не работает — WB Stream выдаёт токены с `canPublishData=false`, и DC не маршрутизирует данные.
|
||||||
|
|
||||||
**Рекомендуемая комбинация: `wbstream + vp8channel`** — работает стабильно, не требует специальных прав.
|
**Jitsi:** datachannel стабильно проходит — реализован поверх colibri-ws bridge channel и шлёт байты через `EndpointMessage{raw}` broadcast. Подходит для self-hosted и публичных Jitsi Meet инстансов без аутентификации (`https://meet.cryptopro.ru/...`, `https://meet.jit.si/...` и т.п.). Видео-транспорты (vp8channel, seichannel, videochannel) экспонируют sendable VideoTrack через pion PeerConnection после Jingle session-accept, но Jicofo требует дополнительных протокольных шагов (LastN, ReceiverVideoConstraints, source-add) для маршрутизации видео — поэтому они помечены `~` (best effort).
|
||||||
|
|
||||||
|
**Рекомендуемая комбинация: `wbstream + vp8channel`** — работает стабильно, не требует специальных прав. **`jitsi + datachannel`** — рекомендация для self-hosted Jitsi инстансов.
|
||||||
|
|
||||||
Скорость по убыванию: `datachannel` > `vp8channel` > `seichannel` > `videochannel`
|
Скорость по убыванию: `datachannel` > `vp8channel` > `seichannel` > `videochannel`
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@
|
|||||||
| YAML поле | Что вводить |
|
| YAML поле | Что вводить |
|
||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
| `mode` | `srv` на сервере, `cnc` на клиенте, `gen` для генерации Room ID |
|
| `mode` | `srv` на сервере, `cnc` на клиенте, `gen` для генерации Room ID |
|
||||||
| `auth.provider` | `telemost`, `jazz` или `wbstream` |
|
| `auth.provider` | `telemost`, `jazz`, `wbstream` или `jitsi` |
|
||||||
| `net.transport` | `datachannel`, `vp8channel`, `seichannel` или `videochannel` |
|
| `net.transport` | `datachannel`, `vp8channel`, `seichannel` или `videochannel` |
|
||||||
| `room.id` | Room ID |
|
| `room.id` | Room ID |
|
||||||
| `crypto.key` | Ключ шифрования hex 64 символа. Генерация: `openssl rand -hex 32` |
|
| `crypto.key` | Ключ шифрования hex 64 символа. Генерация: `openssl rand -hex 32` |
|
||||||
|
|||||||
+27
-1
@@ -33,7 +33,7 @@ olcrtc://<Auth>?<Transport><key=value&key=value>@<RoomID>#<EncryptionKey>$<MIMO>
|
|||||||
|
|
||||||
| Поле | Значение |
|
| Поле | Значение |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `<Auth>` | Имя auth-провайдера, например `telemost`, `jazz`, `wbstream` |
|
| `<Auth>` | Имя auth-провайдера, например `telemost`, `jazz`, `wbstream`, `jitsi` |
|
||||||
| `<Transport>` | Имя транспорта, например `datachannel`, `vp8channel`, `seichannel`, `videochannel` |
|
| `<Transport>` | Имя транспорта, например `datachannel`, `vp8channel`, `seichannel`, `videochannel` |
|
||||||
| payload | Параметры транспорта в `<key=value&...>`. Ключи совпадают с YAML полями. Блок опускается если используются defaults |
|
| payload | Параметры транспорта в `<key=value&...>`. Ключи совпадают с YAML полями. Блок опускается если используются defaults |
|
||||||
| `<RoomID>` | Идентификатор комнаты или auth-specific room URL/ID |
|
| `<RoomID>` | Идентификатор комнаты или auth-specific room URL/ID |
|
||||||
@@ -220,6 +220,32 @@ data: data
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### jitsi + datachannel
|
||||||
|
|
||||||
|
```text
|
||||||
|
olcrtc://jitsi?datachannel@https://meet.cryptopro.ru/myroom#d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799$RU / olc free sub
|
||||||
|
```
|
||||||
|
|
||||||
|
`<RoomID>` для jitsi — полный URL комнаты в формате `https://host/room` (или `host/room`). Поддерживается любой self-hosted Jitsi Meet инстанс без аутентификации; для публичных серверов вроде `meet.jit.si` тот же формат.
|
||||||
|
|
||||||
|
### Эквивалент YAML
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mode: cnc
|
||||||
|
link: direct
|
||||||
|
auth:
|
||||||
|
provider: jitsi
|
||||||
|
room:
|
||||||
|
id: "https://meet.cryptopro.ru/myroom"
|
||||||
|
crypto:
|
||||||
|
key: "d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799"
|
||||||
|
net:
|
||||||
|
transport: datachannel
|
||||||
|
data: data
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Короткие алиасы
|
## Короткие алиасы
|
||||||
|
|
||||||
Как хотите но лично я был бы против.
|
Как хотите но лично я был бы против.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ require (
|
|||||||
github.com/magefile/mage v1.17.1
|
github.com/magefile/mage v1.17.1
|
||||||
github.com/pion/logging v0.2.4
|
github.com/pion/logging v0.2.4
|
||||||
github.com/pion/rtp v1.10.1
|
github.com/pion/rtp v1.10.1
|
||||||
github.com/pion/webrtc/v4 v4.2.11
|
github.com/pion/webrtc/v4 v4.2.12
|
||||||
github.com/xtaci/kcp-go/v5 v5.6.72
|
github.com/xtaci/kcp-go/v5 v5.6.72
|
||||||
github.com/xtaci/smux v1.5.57
|
github.com/xtaci/smux v1.5.57
|
||||||
github.com/zarazaex69/gr v0.0.0-20260430043628-45b595f4fef0
|
github.com/zarazaex69/gr v0.0.0-20260430043628-45b595f4fef0
|
||||||
@@ -29,6 +29,7 @@ require (
|
|||||||
github.com/benbjohnson/clock v1.3.5 // indirect
|
github.com/benbjohnson/clock v1.3.5 // indirect
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/coder/websocket v1.8.14 // indirect
|
||||||
github.com/dennwc/iters v1.2.2 // indirect
|
github.com/dennwc/iters v1.2.2 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/frostbyte73/core v0.1.1 // indirect
|
github.com/frostbyte73/core v0.1.1 // indirect
|
||||||
@@ -53,23 +54,25 @@ require (
|
|||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/pion/datachannel v1.6.0 // indirect
|
github.com/pion/datachannel v1.6.0 // indirect
|
||||||
github.com/pion/dtls/v3 v3.1.2 // indirect
|
github.com/pion/dtls/v3 v3.1.2 // indirect
|
||||||
github.com/pion/ice/v4 v4.2.2 // indirect
|
github.com/pion/ice/v4 v4.2.5 // indirect
|
||||||
github.com/pion/interceptor v0.1.44 // indirect
|
github.com/pion/interceptor v0.1.44 // indirect
|
||||||
github.com/pion/mdns/v2 v2.1.0 // indirect
|
github.com/pion/mdns/v2 v2.1.0 // indirect
|
||||||
github.com/pion/randutil v0.1.0 // indirect
|
github.com/pion/randutil v0.1.0 // indirect
|
||||||
github.com/pion/rtcp v1.2.16 // indirect
|
github.com/pion/rtcp v1.2.16 // indirect
|
||||||
github.com/pion/sctp v1.9.4 // indirect
|
github.com/pion/sctp v1.9.5 // indirect
|
||||||
github.com/pion/sdp/v3 v3.0.18 // indirect
|
github.com/pion/sdp/v3 v3.0.18 // indirect
|
||||||
github.com/pion/srtp/v3 v3.0.10 // indirect
|
github.com/pion/srtp/v3 v3.0.10 // indirect
|
||||||
github.com/pion/stun/v3 v3.1.2 // indirect
|
github.com/pion/stun/v3 v3.1.2 // indirect
|
||||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||||
github.com/pion/turn/v4 v4.1.4 // indirect
|
github.com/pion/turn/v4 v4.1.4 // indirect
|
||||||
|
github.com/pion/turn/v5 v5.0.3 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
|
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
|
||||||
github.com/redis/go-redis/v9 v9.17.2 // indirect
|
github.com/redis/go-redis/v9 v9.17.2 // indirect
|
||||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||||
github.com/twitchtv/twirp v8.1.3+incompatible // indirect
|
github.com/twitchtv/twirp v8.1.3+incompatible // indirect
|
||||||
github.com/wlynxg/anet v0.0.5 // indirect
|
github.com/wlynxg/anet v0.0.5 // indirect
|
||||||
|
github.com/zarazaex69/j v0.0.0-20260515120905-b26b7b6563cd // indirect
|
||||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
|
|||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
|
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||||
|
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4=
|
github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4=
|
||||||
github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
|
github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
|
||||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
@@ -162,6 +164,8 @@ github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
|
|||||||
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
|
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
|
||||||
github.com/pion/ice/v4 v4.2.2 h1:dQJzzcgTFHDYyV3BoCfjPeX+JEtr58BWPi4PGyo6Vjg=
|
github.com/pion/ice/v4 v4.2.2 h1:dQJzzcgTFHDYyV3BoCfjPeX+JEtr58BWPi4PGyo6Vjg=
|
||||||
github.com/pion/ice/v4 v4.2.2/go.mod h1:2quLV1S5v1tAx3VvAJaH//KGitRXvo4RKlX6D3tnN+c=
|
github.com/pion/ice/v4 v4.2.2/go.mod h1:2quLV1S5v1tAx3VvAJaH//KGitRXvo4RKlX6D3tnN+c=
|
||||||
|
github.com/pion/ice/v4 v4.2.5 h1:5umUQy4hX6HwMsCnJ0SX337YYCeTWDgC9JWyvUqHIHs=
|
||||||
|
github.com/pion/ice/v4 v4.2.5/go.mod h1:aaABRaykEYnNjccjbiimuYxViaASeuv5mk9BpplUxK0=
|
||||||
github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I=
|
github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I=
|
||||||
github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY=
|
github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY=
|
||||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||||
@@ -176,6 +180,8 @@ github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
|
|||||||
github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
|
github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
|
||||||
github.com/pion/sctp v1.9.4 h1:cMxEu0F5tbP4qH07bKf1Zjf4rUih9LIo0qQt424e258=
|
github.com/pion/sctp v1.9.4 h1:cMxEu0F5tbP4qH07bKf1Zjf4rUih9LIo0qQt424e258=
|
||||||
github.com/pion/sctp v1.9.4/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw=
|
github.com/pion/sctp v1.9.4/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw=
|
||||||
|
github.com/pion/sctp v1.9.5 h1:QoSFB/drmAsmSeSFNQNI3xx010nW4HsycCZckRVWWag=
|
||||||
|
github.com/pion/sctp v1.9.5/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw=
|
||||||
github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI=
|
github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI=
|
||||||
github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8=
|
github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8=
|
||||||
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
|
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
|
||||||
@@ -188,8 +194,12 @@ github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k
|
|||||||
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
||||||
github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
|
github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
|
||||||
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
|
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
|
||||||
|
github.com/pion/turn/v5 v5.0.3 h1:I+Nw0fQgdPWF1SXDj0egWDhCkcff7gWiigdQpOK52Ak=
|
||||||
|
github.com/pion/turn/v5 v5.0.3/go.mod h1:fs4SogUh/aRGQzonc4Lx3Jp4EU3j3t0PfNDEd9KcD/w=
|
||||||
github.com/pion/webrtc/v4 v4.2.11 h1:QUX1QZKlNIn4O7U5JxLPGP0sV5RTncZkzu9SPR3jVNU=
|
github.com/pion/webrtc/v4 v4.2.11 h1:QUX1QZKlNIn4O7U5JxLPGP0sV5RTncZkzu9SPR3jVNU=
|
||||||
github.com/pion/webrtc/v4 v4.2.11/go.mod h1:s/rAiyy77GyRFrZMx+Ls6aua26dIBPudH8/ZHYbIRWY=
|
github.com/pion/webrtc/v4 v4.2.11/go.mod h1:s/rAiyy77GyRFrZMx+Ls6aua26dIBPudH8/ZHYbIRWY=
|
||||||
|
github.com/pion/webrtc/v4 v4.2.12 h1:ux8i+aJxu0OdhcAcVO39JEeodWugD0wdVJoRDtXk1CY=
|
||||||
|
github.com/pion/webrtc/v4 v4.2.12/go.mod h1:M/DeGZkhdWZVmVgGr34HOD9yUDekVJtz9c9PGO18urQ=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
@@ -231,6 +241,10 @@ github.com/xtaci/smux v1.5.57/go.mod h1:IGQ9QYrBphmb/4aTnLEcJby0TNr3NV+OslIOMrX8
|
|||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/zarazaex69/gr v0.0.0-20260430043628-45b595f4fef0 h1:dMjHX/YPV3ZD/KJKFjQdlMBwj2/rZIuOVKOvGv26m9k=
|
github.com/zarazaex69/gr v0.0.0-20260430043628-45b595f4fef0 h1:dMjHX/YPV3ZD/KJKFjQdlMBwj2/rZIuOVKOvGv26m9k=
|
||||||
github.com/zarazaex69/gr v0.0.0-20260430043628-45b595f4fef0/go.mod h1:7vALI2tjaLTOGiDKV7V2JkVU9bA1YADBDQA6uvpp1ac=
|
github.com/zarazaex69/gr v0.0.0-20260430043628-45b595f4fef0/go.mod h1:7vALI2tjaLTOGiDKV7V2JkVU9bA1YADBDQA6uvpp1ac=
|
||||||
|
github.com/zarazaex69/j v0.0.0-20260514230609-494beaacfc77 h1:ROB1mdhnPKfkUg1VUeLEd6U+eFX15+Sh/JVcJnmF0cs=
|
||||||
|
github.com/zarazaex69/j v0.0.0-20260514230609-494beaacfc77/go.mod h1:uTrpW61I20aWMTxGMZ+eViDBFCrEtgHWggCdQjgvJ4I=
|
||||||
|
github.com/zarazaex69/j v0.0.0-20260515120905-b26b7b6563cd h1:2ewKEjqduZIPURn5CPmQQikF+qrp9Jn0VVeESXn3Hss=
|
||||||
|
github.com/zarazaex69/j v0.0.0-20260515120905-b26b7b6563cd/go.mod h1:7/ypJTenOIPx23fpo5uF7l4u+rxZqg9cFbTL/N77Ktc=
|
||||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Package jitsi implements a pass-through auth provider for self-hosted Jitsi
|
||||||
|
// Meet instances.
|
||||||
|
//
|
||||||
|
// Public Jitsi Meet servers do not require authentication for guest access;
|
||||||
|
// the only "credentials" the engine needs are the host+room pair extracted
|
||||||
|
// from a user-supplied room URL. This provider does no HTTP at all — it just
|
||||||
|
// parses the URL and forwards host+room to the engine via auth.Credentials.
|
||||||
|
//
|
||||||
|
// Supported RoomURL forms:
|
||||||
|
//
|
||||||
|
// - "https://meet.example.com/myroom"
|
||||||
|
// - "http://meet.example.com/myroom"
|
||||||
|
// - "meet.example.com/myroom"
|
||||||
|
//
|
||||||
|
// Optional URL path prefixes (e.g. "/jitsi") are preserved as part of the
|
||||||
|
// host when present, so deployments behind a path-mounted reverse proxy work
|
||||||
|
// transparently — the j library accepts any host string the WebSocket dial
|
||||||
|
// can resolve.
|
||||||
|
package jitsi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/openlibrecommunity/olcrtc/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CredentialKeyRoom is the auth.Credentials.Extra key that carries the Jitsi
|
||||||
|
// room name (the conference identifier on the host).
|
||||||
|
const CredentialKeyRoom = "room"
|
||||||
|
|
||||||
|
// ErrInvalidRoomURL is returned when the supplied RoomURL cannot be parsed
|
||||||
|
// into a host+room pair.
|
||||||
|
var ErrInvalidRoomURL = errors.New("jitsi: invalid room URL (expected host/room or https://host/room)")
|
||||||
|
|
||||||
|
// Provider produces engine credentials for a Jitsi Meet room.
|
||||||
|
type Provider struct{}
|
||||||
|
|
||||||
|
// Engine reports which engine consumes credentials from this auth provider.
|
||||||
|
func (Provider) Engine() string { return "jitsi" }
|
||||||
|
|
||||||
|
// DefaultServiceURL returns the empty string: there is no canonical default
|
||||||
|
// Jitsi instance — every deployment is user-supplied.
|
||||||
|
func (Provider) DefaultServiceURL() string { return "" }
|
||||||
|
|
||||||
|
// Issue parses cfg.RoomURL into host+room and returns engine credentials.
|
||||||
|
//
|
||||||
|
// The URL field of the returned Credentials carries the Jitsi host (e.g.
|
||||||
|
// "meet.example.com"); the room name lives in Extra under CredentialKeyRoom.
|
||||||
|
// Token is unused — Jitsi guest access requires no token.
|
||||||
|
func (Provider) Issue(_ context.Context, cfg auth.Config) (auth.Credentials, error) {
|
||||||
|
host, room, err := parseRoomURL(cfg.RoomURL)
|
||||||
|
if err != nil {
|
||||||
|
return auth.Credentials{}, err
|
||||||
|
}
|
||||||
|
return auth.Credentials{
|
||||||
|
URL: host,
|
||||||
|
Token: "",
|
||||||
|
Extra: map[string]string{CredentialKeyRoom: room},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRoomURL splits a Jitsi room URL into (host, room).
|
||||||
|
//
|
||||||
|
// Accepts URLs with or without scheme. The host part is the segment before
|
||||||
|
// the first "/" after stripping the scheme; the room is everything that
|
||||||
|
// follows, with leading/trailing slashes trimmed.
|
||||||
|
func parseRoomURL(raw string) (host string, room string, err error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return "", "", auth.ErrRoomIDRequired
|
||||||
|
}
|
||||||
|
if idx := strings.Index(raw, "://"); idx >= 0 {
|
||||||
|
raw = raw[idx+3:]
|
||||||
|
}
|
||||||
|
raw = strings.TrimPrefix(raw, "//")
|
||||||
|
raw = strings.TrimPrefix(raw, "/")
|
||||||
|
slash := strings.Index(raw, "/")
|
||||||
|
if slash <= 0 {
|
||||||
|
return "", "", fmt.Errorf("%w: %q", ErrInvalidRoomURL, raw)
|
||||||
|
}
|
||||||
|
host = strings.TrimSpace(raw[:slash])
|
||||||
|
room = strings.Trim(raw[slash+1:], "/")
|
||||||
|
if host == "" || room == "" {
|
||||||
|
return "", "", fmt.Errorf("%w: %q", ErrInvalidRoomURL, raw)
|
||||||
|
}
|
||||||
|
return host, room, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { //nolint:gochecknoinits // auth registration is the canonical Go pattern for plugins
|
||||||
|
auth.Register("jitsi", Provider{})
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package jitsi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/openlibrecommunity/olcrtc/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseRoomURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
host string
|
||||||
|
room string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "https url", raw: "https://meet.cryptopro.ru/myroom", host: "meet.cryptopro.ru", room: "myroom"},
|
||||||
|
{name: "http url", raw: "http://meet.example/myroom", host: "meet.example", room: "myroom"},
|
||||||
|
{name: "scheme-less", raw: "meet.example.com/myroom", host: "meet.example.com", room: "myroom"},
|
||||||
|
{name: "trailing slash", raw: "https://meet.example/myroom/", host: "meet.example", room: "myroom"},
|
||||||
|
{name: "double slash leader", raw: "//meet.example/myroom", host: "meet.example", room: "myroom"},
|
||||||
|
{name: "uppercase room", raw: "https://meet.example/MyRoom", host: "meet.example", room: "MyRoom"},
|
||||||
|
{name: "empty", raw: "", wantErr: true},
|
||||||
|
{name: "host only", raw: "meet.example.com", wantErr: true},
|
||||||
|
{name: "no room", raw: "https://meet.example/", wantErr: true},
|
||||||
|
{name: "scheme only", raw: "https://", wantErr: true},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
host, room, err := parseRoomURL(tc.raw)
|
||||||
|
if tc.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("parseRoomURL(%q) = (%q, %q), want error", tc.raw, host, room)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseRoomURL(%q) error = %v, want nil", tc.raw, err)
|
||||||
|
}
|
||||||
|
if host != tc.host || room != tc.room {
|
||||||
|
t.Fatalf("parseRoomURL(%q) = (%q, %q), want (%q, %q)",
|
||||||
|
tc.raw, host, room, tc.host, tc.room)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderIssue(t *testing.T) {
|
||||||
|
creds, err := Provider{}.Issue(context.Background(), auth.Config{
|
||||||
|
RoomURL: "https://meet.cryptopro.ru/olcrtc",
|
||||||
|
Name: "olcrtc-test",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue: %v", err)
|
||||||
|
}
|
||||||
|
if creds.URL != "meet.cryptopro.ru" {
|
||||||
|
t.Fatalf("URL = %q, want %q", creds.URL, "meet.cryptopro.ru")
|
||||||
|
}
|
||||||
|
if got := creds.Extra[CredentialKeyRoom]; got != "olcrtc" {
|
||||||
|
t.Fatalf("room = %q, want %q", got, "olcrtc")
|
||||||
|
}
|
||||||
|
if creds.Token != "" {
|
||||||
|
t.Fatalf("Token = %q, want empty", creds.Token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderIssueRequiresRoom(t *testing.T) {
|
||||||
|
_, err := Provider{}.Issue(context.Background(), auth.Config{RoomURL: ""})
|
||||||
|
if !errors.Is(err, auth.ErrRoomIDRequired) {
|
||||||
|
t.Fatalf("Issue() err = %v, want ErrRoomIDRequired", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderEngine(t *testing.T) {
|
||||||
|
if got := (Provider{}).Engine(); got != "jitsi" {
|
||||||
|
t.Fatalf("Engine() = %q, want %q", got, "jitsi")
|
||||||
|
}
|
||||||
|
if got := (Provider{}).DefaultServiceURL(); got != "" {
|
||||||
|
t.Fatalf("DefaultServiceURL() = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@
|
|||||||
package builtin
|
package builtin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
authJitsi "github.com/openlibrecommunity/olcrtc/internal/auth/jitsi"
|
||||||
authSaluteJazz "github.com/openlibrecommunity/olcrtc/internal/auth/salutejazz"
|
authSaluteJazz "github.com/openlibrecommunity/olcrtc/internal/auth/salutejazz"
|
||||||
authTelemost "github.com/openlibrecommunity/olcrtc/internal/auth/telemost"
|
authTelemost "github.com/openlibrecommunity/olcrtc/internal/auth/telemost"
|
||||||
authWBStream "github.com/openlibrecommunity/olcrtc/internal/auth/wbstream"
|
authWBStream "github.com/openlibrecommunity/olcrtc/internal/auth/wbstream"
|
||||||
_ "github.com/openlibrecommunity/olcrtc/internal/engine/goolom" // engine registration via init
|
_ "github.com/openlibrecommunity/olcrtc/internal/engine/goolom" // engine registration via init
|
||||||
|
_ "github.com/openlibrecommunity/olcrtc/internal/engine/jitsi" // engine registration via init
|
||||||
_ "github.com/openlibrecommunity/olcrtc/internal/engine/livekit" // engine registration via init
|
_ "github.com/openlibrecommunity/olcrtc/internal/engine/livekit" // engine registration via init
|
||||||
_ "github.com/openlibrecommunity/olcrtc/internal/engine/salutejazz" // engine registration via init
|
_ "github.com/openlibrecommunity/olcrtc/internal/engine/salutejazz" // engine registration via init
|
||||||
)
|
)
|
||||||
@@ -15,5 +17,6 @@ func Register() {
|
|||||||
registerEngineAuth("wbstream", authWBStream.Provider{})
|
registerEngineAuth("wbstream", authWBStream.Provider{})
|
||||||
registerEngineAuth("jazz", authSaluteJazz.Provider{})
|
registerEngineAuth("jazz", authSaluteJazz.Provider{})
|
||||||
registerEngineAuth("telemost", authTelemost.Provider{})
|
registerEngineAuth("telemost", authTelemost.Provider{})
|
||||||
|
registerEngineAuth("jitsi", authJitsi.Provider{})
|
||||||
registerDirect("none")
|
registerDirect("none")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ var (
|
|||||||
"019e23c2-a580-7550-b08a-7ac5342ca21f",
|
"019e23c2-a580-7550-b08a-7ac5342ca21f",
|
||||||
"WB Stream room id for real e2e; autogenerated when empty",
|
"WB Stream room id for real e2e; autogenerated when empty",
|
||||||
)
|
)
|
||||||
|
realE2EJitsiRoom = flag.String( //nolint:gochecknoglobals // package-level state intentional
|
||||||
|
"olcrtc.real-jitsi-room",
|
||||||
|
"https://meet.cryptopro.ru/deadbeef",
|
||||||
|
"Jitsi Meet room URL for real e2e (format https://host/room or host/room)",
|
||||||
|
)
|
||||||
realE2ETimeout = flag.Duration( //nolint:gochecknoglobals // package-level state intentional
|
realE2ETimeout = flag.Duration( //nolint:gochecknoglobals // package-level state intentional
|
||||||
"olcrtc.real-timeout",
|
"olcrtc.real-timeout",
|
||||||
90*time.Second,
|
90*time.Second,
|
||||||
@@ -337,7 +342,7 @@ func registerMemoryCarrierAs(t *testing.T, name string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func builtInCarrierNames() []string {
|
func builtInCarrierNames() []string {
|
||||||
return []string{"jazz", "telemost", "wbstream"} //nolint:goconst // test literal, repetition is intentional
|
return []string{"jazz", "telemost", "wbstream", "jitsi"} //nolint:goconst // test literal, repetition is intentional
|
||||||
}
|
}
|
||||||
|
|
||||||
func builtInTransportNames() []string {
|
func builtInTransportNames() []string {
|
||||||
@@ -365,6 +370,21 @@ func realE2ECaseExpectation(carrierName, transportName string) realE2EExpectatio
|
|||||||
return realE2EExpectPass
|
return realE2EExpectPass
|
||||||
}
|
}
|
||||||
return realE2EExpectFail
|
return realE2EExpectFail
|
||||||
|
case "jitsi":
|
||||||
|
// Jitsi colibri-ws bridge channel maps cleanly onto the
|
||||||
|
// datachannel transport (raw bytes broadcast through
|
||||||
|
// EndpointMessage). Video transports go through pion's
|
||||||
|
// PeerConnection negotiated via Jingle session-accept; results
|
||||||
|
// are bridge/instance dependent (some operators throttle or
|
||||||
|
// strip non-camera video), hence best-effort.
|
||||||
|
switch transportName {
|
||||||
|
case transportData:
|
||||||
|
return realE2EExpectPass
|
||||||
|
case transportVP8, transportVideo, transportSEI:
|
||||||
|
return realE2EBestEffort
|
||||||
|
default:
|
||||||
|
return realE2EBestEffort
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return realE2EExpectPass
|
return realE2EExpectPass
|
||||||
}
|
}
|
||||||
@@ -432,6 +452,30 @@ func TestRealE2ECaseExpectation(t *testing.T) {
|
|||||||
transport: transportData,
|
transport: transportData,
|
||||||
want: realE2EExpectFail,
|
want: realE2EExpectFail,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "jitsi datachannel is expected to pass",
|
||||||
|
carrier: "jitsi",
|
||||||
|
transport: transportData,
|
||||||
|
want: realE2EExpectPass,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "jitsi vp8channel is best effort",
|
||||||
|
carrier: "jitsi",
|
||||||
|
transport: transportVP8,
|
||||||
|
want: realE2EBestEffort,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "jitsi videochannel is best effort",
|
||||||
|
carrier: "jitsi",
|
||||||
|
transport: transportVideo,
|
||||||
|
want: realE2EBestEffort,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "jitsi seichannel is best effort",
|
||||||
|
carrier: "jitsi",
|
||||||
|
transport: transportSEI,
|
||||||
|
want: realE2EBestEffort,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -484,6 +528,17 @@ func realRoomURL(ctx context.Context, t *testing.T, carrierName string) string {
|
|||||||
t.Skipf("skip wbstream real e2e: create room failed: %v", err)
|
t.Skipf("skip wbstream real e2e: create room failed: %v", err)
|
||||||
}
|
}
|
||||||
return room
|
return room
|
||||||
|
case "jitsi":
|
||||||
|
// Jitsi has no notion of "creating" a room — names are conjured
|
||||||
|
// on first join. The default flag points at meet.cryptopro.ru
|
||||||
|
// (a CryptoPro-operated public Jitsi instance) with a fixed
|
||||||
|
// room slug so the server and client land in the same MUC.
|
||||||
|
_ = ctx
|
||||||
|
room := *realE2EJitsiRoom
|
||||||
|
if room == "" {
|
||||||
|
t.Skip("skip jitsi real e2e: empty -olcrtc.real-jitsi-room")
|
||||||
|
}
|
||||||
|
return room
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package jitsi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/zarazaex69/j"
|
||||||
|
)
|
||||||
|
|
||||||
|
func encodeForTest(t *testing.T, data []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
return base64.StdEncoding.EncodeToString(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeBridgeMessage(class string, fields map[string]any) j.BridgeMessage {
|
||||||
|
return j.BridgeMessage{
|
||||||
|
Class: class,
|
||||||
|
Fields: fields,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
// Package jitsi implements an engine.Session backed by the Jitsi Meet
|
||||||
|
// XMPP/Jingle/colibri-ws stack via the github.com/zarazaex69/j library.
|
||||||
|
//
|
||||||
|
// The engine speaks the wire protocol of a self-hosted Jitsi instance: it
|
||||||
|
// joins the MUC, waits for a Jingle session-initiate from Jicofo, opens the
|
||||||
|
// JVB bridge channel (colibri-ws) for byte transport, and optionally
|
||||||
|
// negotiates a pion *webrtc.PeerConnection for video tracks.
|
||||||
|
//
|
||||||
|
// Service-specific bits (URL parsing) live in the auth/jitsi package; this
|
||||||
|
// engine is told the host and room name through engine.Config (URL carries
|
||||||
|
// the host string, Extra["room"] carries the room name).
|
||||||
|
//
|
||||||
|
// The Jingle session-initiate is only delivered by Jicofo once at least one
|
||||||
|
// other participant is present in the conference, mirroring the Telemost /
|
||||||
|
// SaluteJazz two-peer requirement that olcrtc already accommodates.
|
||||||
|
package jitsi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/openlibrecommunity/olcrtc/internal/engine"
|
||||||
|
"github.com/openlibrecommunity/olcrtc/internal/logger"
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
"github.com/zarazaex69/j"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultSendQueueSize = 5000
|
||||||
|
// bridgeMaxMessageSize is the practical upper bound on a single colibri-ws
|
||||||
|
// payload. JVB enforces a max-message-size around 16 KiB; payloads above
|
||||||
|
// that cause the bridge to drop the websocket. The default datachannel
|
||||||
|
// transport in olcrtc already uses 12 KiB chunks, well under this limit.
|
||||||
|
bridgeMaxMessageSize = 16 * 1024
|
||||||
|
bridgeOpenTimeout = 30 * time.Second
|
||||||
|
defaultNick = "olcrtc"
|
||||||
|
credentialKeyRoom = "room"
|
||||||
|
videoTrackName = "videochannel"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrSessionClosed is returned when an operation is attempted on a closed session.
|
||||||
|
ErrSessionClosed = errors.New("jitsi session closed")
|
||||||
|
// ErrSendQueueFull is returned when the outbound queue cannot accept more data.
|
||||||
|
ErrSendQueueFull = errors.New("jitsi send queue full")
|
||||||
|
// ErrBridgeNotReady is returned when send is attempted before the bridge is open.
|
||||||
|
ErrBridgeNotReady = errors.New("jitsi bridge not ready")
|
||||||
|
// ErrSendTooLarge is returned when a single payload exceeds the JVB max-message-size limit.
|
||||||
|
ErrSendTooLarge = errors.New("jitsi payload exceeds bridge max-message-size")
|
||||||
|
// ErrHostRequired is returned when no Jitsi host was supplied.
|
||||||
|
ErrHostRequired = errors.New("jitsi host required")
|
||||||
|
// ErrRoomRequired is returned when no Jitsi room was supplied.
|
||||||
|
ErrRoomRequired = errors.New("jitsi room required")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session is the Jitsi engine handle.
|
||||||
|
type Session struct {
|
||||||
|
host string
|
||||||
|
room string
|
||||||
|
name string
|
||||||
|
|
||||||
|
onData func([]byte)
|
||||||
|
onReconnect func(*webrtc.DataChannel)
|
||||||
|
shouldReconnect func() bool
|
||||||
|
onEnded func(string)
|
||||||
|
|
||||||
|
jSess atomic.Pointer[j.Session]
|
||||||
|
|
||||||
|
pcMu sync.Mutex
|
||||||
|
pc *webrtc.PeerConnection
|
||||||
|
|
||||||
|
sendQueue chan []byte
|
||||||
|
bridgeReady atomic.Bool
|
||||||
|
closed atomic.Bool
|
||||||
|
done chan struct{}
|
||||||
|
doneOnce sync.Once
|
||||||
|
cancel context.CancelFunc
|
||||||
|
runCtx context.Context //nolint:containedctx // engine owns the supervisor lifetime
|
||||||
|
wg sync.WaitGroup
|
||||||
|
|
||||||
|
videoTrackMu sync.RWMutex
|
||||||
|
videoTracks []webrtc.TrackLocal
|
||||||
|
onVideoTrack func(*webrtc.TrackRemote, *webrtc.RTPReceiver)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Jitsi engine session.
|
||||||
|
//
|
||||||
|
// cfg.URL carries the Jitsi host (e.g. "meet.cryptopro.ru") — populated by the
|
||||||
|
// jitsi auth provider after parsing the user-supplied room URL. cfg.Extra
|
||||||
|
// must contain the room name under the "room" key.
|
||||||
|
func New(_ context.Context, cfg engine.Config) (engine.Session, error) {
|
||||||
|
host := normaliseHost(cfg.URL)
|
||||||
|
if host == "" {
|
||||||
|
return nil, ErrHostRequired
|
||||||
|
}
|
||||||
|
var room string
|
||||||
|
if cfg.Extra != nil {
|
||||||
|
room = strings.TrimSpace(cfg.Extra[credentialKeyRoom])
|
||||||
|
}
|
||||||
|
if room == "" {
|
||||||
|
return nil, ErrRoomRequired
|
||||||
|
}
|
||||||
|
name := sanitiseNick(cfg.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = defaultNick
|
||||||
|
}
|
||||||
|
|
||||||
|
runCtx, cancel := context.WithCancel(context.Background())
|
||||||
|
return &Session{
|
||||||
|
host: host,
|
||||||
|
room: room,
|
||||||
|
name: name,
|
||||||
|
onData: cfg.OnData,
|
||||||
|
sendQueue: make(chan []byte, defaultSendQueueSize),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
cancel: cancel,
|
||||||
|
runCtx: runCtx,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitiseNick reduces a display name to a 7-bit ASCII slug acceptable to
|
||||||
|
// the j library's MUC presence helper. The helper currently uses byte-level
|
||||||
|
// slicing on the supplied name to derive a stats-id, so multi-byte UTF-8
|
||||||
|
// inputs (e.g. Cyrillic) get sliced mid-codepoint and Prosody silently
|
||||||
|
// rejects the resulting presence stanza.
|
||||||
|
//
|
||||||
|
// Non-ASCII characters are dropped; spaces and punctuation are normalised
|
||||||
|
// to '-'. The result is bounded to 16 characters.
|
||||||
|
func sanitiseNick(raw string) string {
|
||||||
|
const maxNickLen = 16
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(raw))
|
||||||
|
prevDash := false
|
||||||
|
for _, r := range raw {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z',
|
||||||
|
r >= 'A' && r <= 'Z',
|
||||||
|
r >= '0' && r <= '9':
|
||||||
|
b.WriteRune(r)
|
||||||
|
prevDash = false
|
||||||
|
case r == '-' || r == '_':
|
||||||
|
b.WriteRune(r)
|
||||||
|
prevDash = false
|
||||||
|
default:
|
||||||
|
if !prevDash && b.Len() > 0 {
|
||||||
|
b.WriteRune('-')
|
||||||
|
prevDash = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b.Len() >= maxNickLen {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := strings.Trim(b.String(), "-")
|
||||||
|
if out == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capabilities reports what this engine can do.
|
||||||
|
func (s *Session) Capabilities() engine.Capabilities {
|
||||||
|
return engine.Capabilities{ByteStream: true, VideoTrack: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect joins the Jitsi conference, optionally opens the bridge channel,
|
||||||
|
// and (if video tracks are pending or a remote handler is set) negotiates a
|
||||||
|
// pion PeerConnection.
|
||||||
|
func (s *Session) Connect(ctx context.Context) error {
|
||||||
|
if s.closed.Load() {
|
||||||
|
return ErrSessionClosed
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof("jitsi: joining %s/%s as %s …", s.host, s.room, s.name)
|
||||||
|
jSess, err := j.Join(ctx, j.Config{
|
||||||
|
Host: s.host,
|
||||||
|
Room: s.room,
|
||||||
|
Nick: s.name,
|
||||||
|
Debug: logger.IsVerbose(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("jitsi join: %w", err)
|
||||||
|
}
|
||||||
|
logger.Infof("jitsi: joined %s/%s; colibri-ws=%s", s.host, s.room, jSess.ColibriWS)
|
||||||
|
s.jSess.Store(jSess)
|
||||||
|
|
||||||
|
if s.onData != nil {
|
||||||
|
bctx, bcancel := context.WithTimeout(ctx, bridgeOpenTimeout)
|
||||||
|
err := jSess.OpenBridge(bctx)
|
||||||
|
bcancel()
|
||||||
|
if err != nil {
|
||||||
|
_ = jSess.Close()
|
||||||
|
s.jSess.Store(nil)
|
||||||
|
return fmt.Errorf("open bridge: %w", err)
|
||||||
|
}
|
||||||
|
s.bridgeReady.Store(true)
|
||||||
|
logger.Infof("jitsi: bridge open (endpoints=%v)", jSess.Endpoints())
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.shouldNegotiatePC() {
|
||||||
|
if err := s.negotiatePC(ctx, jSess); err != nil {
|
||||||
|
_ = jSess.Close()
|
||||||
|
s.jSess.Store(nil)
|
||||||
|
s.bridgeReady.Store(false)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.wg.Add(2)
|
||||||
|
go s.sendLoop()
|
||||||
|
go s.recvLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) shouldNegotiatePC() bool {
|
||||||
|
s.videoTrackMu.RLock()
|
||||||
|
defer s.videoTrackMu.RUnlock()
|
||||||
|
return len(s.videoTracks) > 0 || s.onVideoTrack != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) videoTrackHandler() func(*webrtc.TrackRemote, *webrtc.RTPReceiver) {
|
||||||
|
s.videoTrackMu.RLock()
|
||||||
|
defer s.videoTrackMu.RUnlock()
|
||||||
|
return s.onVideoTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) negotiatePC(ctx context.Context, jSess *j.Session) error {
|
||||||
|
settings := webrtc.SettingEngine{}
|
||||||
|
settings.LoggerFactory = logger.NewPionLoggerFactory()
|
||||||
|
api := webrtc.NewAPI(webrtc.WithSettingEngine(settings))
|
||||||
|
|
||||||
|
// Jicofo emits Plan B style SDP with separate <content> sections per
|
||||||
|
// media kind and SSRC-keyed source descriptors. pion's default
|
||||||
|
// UnifiedPlan parser rejects this with "remote SessionDescription
|
||||||
|
// semantics does not match configuration", so we explicitly request
|
||||||
|
// Plan B for the conference PeerConnection.
|
||||||
|
pcConfig := jSess.IceConfig()
|
||||||
|
pcConfig.SDPSemantics = webrtc.SDPSemanticsPlanB
|
||||||
|
|
||||||
|
pc, err := api.NewPeerConnection(pcConfig)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("new pc: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.videoTrackMu.RLock()
|
||||||
|
for _, track := range s.videoTracks {
|
||||||
|
if _, addErr := pc.AddTrack(track); addErr != nil {
|
||||||
|
s.videoTrackMu.RUnlock()
|
||||||
|
_ = pc.Close()
|
||||||
|
return fmt.Errorf("add track: %w", addErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.videoTrackMu.RUnlock()
|
||||||
|
|
||||||
|
pc.OnTrack(func(track *webrtc.TrackRemote, recv *webrtc.RTPReceiver) {
|
||||||
|
if track.Kind() != webrtc.RTPCodecTypeVideo {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cb := s.videoTrackHandler(); cb != nil {
|
||||||
|
cb(track, recv)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||||
|
logger.Debugf("jitsi pc state: %s", state.String())
|
||||||
|
if state == webrtc.PeerConnectionStateFailed && !s.closed.Load() && s.onEnded != nil {
|
||||||
|
s.onEnded("jitsi peer connection failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
neg := jSess.Negotiator()
|
||||||
|
neg.PC = pc
|
||||||
|
if err := neg.Accept(ctx); err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return fmt.Errorf("session-accept: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.pcMu.Lock()
|
||||||
|
s.pc = pc
|
||||||
|
s.pcMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send queues data for transmission over the bridge.
|
||||||
|
//
|
||||||
|
// Send is non-blocking: data is enqueued onto the engine's outbound channel
|
||||||
|
// and a background goroutine pumps the queue into the colibri-ws bridge with
|
||||||
|
// the bridge's own backpressure window.
|
||||||
|
func (s *Session) Send(data []byte) error {
|
||||||
|
if s.closed.Load() {
|
||||||
|
return ErrSessionClosed
|
||||||
|
}
|
||||||
|
if !s.bridgeReady.Load() {
|
||||||
|
return ErrBridgeNotReady
|
||||||
|
}
|
||||||
|
if len(data) > bridgeMaxMessageSize {
|
||||||
|
return ErrSendTooLarge
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case s.sendQueue <- data:
|
||||||
|
return nil
|
||||||
|
case <-s.done:
|
||||||
|
return ErrSessionClosed
|
||||||
|
default:
|
||||||
|
return ErrSendQueueFull
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) sendLoop() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.done:
|
||||||
|
return
|
||||||
|
case data, ok := <-s.sendQueue:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jSess := s.jSess.Load()
|
||||||
|
if jSess == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := jSess.BridgeSendRaw("", data); err != nil {
|
||||||
|
if s.closed.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debugf("jitsi bridge send: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) recvLoop() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
|
||||||
|
jSess := s.jSess.Load()
|
||||||
|
if jSess == nil || s.onData == nil || !s.bridgeReady.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgs := jSess.BridgeMessages()
|
||||||
|
if msgs == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.done:
|
||||||
|
return
|
||||||
|
case msg, ok := <-msgs:
|
||||||
|
if !ok {
|
||||||
|
if !s.closed.Load() {
|
||||||
|
s.signalEnded("jitsi bridge closed")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload := decodeRaw(msg)
|
||||||
|
if payload == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.onData(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeRaw extracts the bytes from an EndpointMessage produced by the j
|
||||||
|
// library's BridgeSendRaw helper. Mirrors the unexported colibri.DecodeRaw —
|
||||||
|
// the j library's BridgeMessage type alias keeps the necessary fields public,
|
||||||
|
// but the helper itself lives in an internal package.
|
||||||
|
func decodeRaw(m j.BridgeMessage) []byte {
|
||||||
|
if m.Class != "EndpointMessage" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
enc, ok := m.Fields["raw"].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out, err := base64.StdEncoding.DecodeString(enc)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close terminates the session and releases resources.
|
||||||
|
func (s *Session) Close() error {
|
||||||
|
if !s.closed.CompareAndSwap(false, true) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.cancel != nil {
|
||||||
|
s.cancel()
|
||||||
|
}
|
||||||
|
s.doneOnce.Do(func() { close(s.done) })
|
||||||
|
|
||||||
|
s.pcMu.Lock()
|
||||||
|
pc := s.pc
|
||||||
|
s.pc = nil
|
||||||
|
s.pcMu.Unlock()
|
||||||
|
if pc != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
jSess := s.jSess.Swap(nil)
|
||||||
|
if jSess != nil {
|
||||||
|
_ = jSess.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
s.bridgeReady.Store(false)
|
||||||
|
|
||||||
|
stopped := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
s.wg.Wait()
|
||||||
|
close(stopped)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-stopped:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetReconnectCallback registers a callback for reconnection events.
|
||||||
|
//
|
||||||
|
// The Jitsi engine itself does not currently drive a reconnect loop; the
|
||||||
|
// callback is stored for API parity and wired through the carrier adapter
|
||||||
|
// for future use.
|
||||||
|
func (s *Session) SetReconnectCallback(cb func(*webrtc.DataChannel)) { s.onReconnect = cb }
|
||||||
|
|
||||||
|
// SetShouldReconnect stores the reconnect predicate (kept for API parity).
|
||||||
|
func (s *Session) SetShouldReconnect(fn func() bool) { s.shouldReconnect = fn }
|
||||||
|
|
||||||
|
// SetEndedCallback registers a function to call when the session ends.
|
||||||
|
func (s *Session) SetEndedCallback(cb func(string)) { s.onEnded = cb }
|
||||||
|
|
||||||
|
// WatchConnection blocks until the session is closed, the parent context
|
||||||
|
// fires, or the bridge tears down.
|
||||||
|
func (s *Session) WatchConnection(ctx context.Context) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-s.done:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanSend reports whether the session is ready to accept new data.
|
||||||
|
func (s *Session) CanSend() bool {
|
||||||
|
if s.closed.Load() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if s.onData == nil {
|
||||||
|
// pure video mode — readiness driven by PC connection state
|
||||||
|
s.pcMu.Lock()
|
||||||
|
ready := s.pc != nil && s.pc.ConnectionState() == webrtc.PeerConnectionStateConnected
|
||||||
|
s.pcMu.Unlock()
|
||||||
|
return ready
|
||||||
|
}
|
||||||
|
return s.bridgeReady.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSendQueue exposes the outbound queue for upstream metrics.
|
||||||
|
func (s *Session) GetSendQueue() chan []byte { return s.sendQueue }
|
||||||
|
|
||||||
|
// GetBufferedAmount returns a coarse estimate of bytes pending on the wire.
|
||||||
|
//
|
||||||
|
// The j library's bridge connection only exposes message-count depth, so we
|
||||||
|
// approximate bytes by multiplying queue depth by the bridge max-message-size.
|
||||||
|
// This is enough for upper-layer pacing heuristics; engines that need
|
||||||
|
// byte-accurate pressure should consult GetSendQueue directly.
|
||||||
|
func (s *Session) GetBufferedAmount() uint64 {
|
||||||
|
jSess := s.jSess.Load()
|
||||||
|
if jSess == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
depth := jSess.BridgeSendQueueDepth()
|
||||||
|
if depth <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return uint64(depth) * uint64(bridgeMaxMessageSize) //nolint:gosec // depth is small and bounded by queue cap
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddVideoTrack publishes a video track to the Jitsi conference.
|
||||||
|
//
|
||||||
|
// Tracks added before Connect are sent as part of the session-accept SDP
|
||||||
|
// (so Jicofo announces them to other participants automatically). Tracks
|
||||||
|
// added afterwards are attached to the live PeerConnection — Jitsi's
|
||||||
|
// source-add flow is not yet implemented in this engine, so late tracks
|
||||||
|
// will only be visible on the next reconnect.
|
||||||
|
func (s *Session) AddVideoTrack(track webrtc.TrackLocal) error {
|
||||||
|
s.videoTrackMu.Lock()
|
||||||
|
s.videoTracks = append(s.videoTracks, track)
|
||||||
|
s.videoTrackMu.Unlock()
|
||||||
|
|
||||||
|
s.pcMu.Lock()
|
||||||
|
pc := s.pc
|
||||||
|
s.pcMu.Unlock()
|
||||||
|
if pc == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := pc.AddTrack(track); err != nil {
|
||||||
|
return fmt.Errorf("add track: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVideoTrackHandler registers a callback invoked on every remote video
|
||||||
|
// track received from the conference.
|
||||||
|
func (s *Session) SetVideoTrackHandler(cb func(*webrtc.TrackRemote, *webrtc.RTPReceiver)) {
|
||||||
|
s.videoTrackMu.Lock()
|
||||||
|
defer s.videoTrackMu.Unlock()
|
||||||
|
s.onVideoTrack = cb
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) signalEnded(reason string) {
|
||||||
|
s.bridgeReady.Store(false)
|
||||||
|
if s.onEnded != nil {
|
||||||
|
s.onEnded(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// normaliseHost strips an optional scheme and trailing slashes off a Jitsi
|
||||||
|
// host string. The j library expects a bare host; auth providers might pass
|
||||||
|
// a full URL through verbatim.
|
||||||
|
func normaliseHost(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if idx := strings.Index(raw, "://"); idx >= 0 {
|
||||||
|
raw = raw[idx+3:]
|
||||||
|
}
|
||||||
|
raw = strings.TrimPrefix(raw, "//")
|
||||||
|
raw = strings.TrimSuffix(raw, "/")
|
||||||
|
if i := strings.Index(raw, "/"); i >= 0 {
|
||||||
|
raw = raw[:i]
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { //nolint:gochecknoinits // engine registration is the canonical Go pattern for plugins
|
||||||
|
engine.Register("jitsi", New)
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package jitsi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/openlibrecommunity/olcrtc/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormaliseHost(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
raw string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"meet.example.com", "meet.example.com"},
|
||||||
|
{"https://meet.example.com", "meet.example.com"},
|
||||||
|
{"https://meet.example.com/", "meet.example.com"},
|
||||||
|
{"https://meet.example.com/path", "meet.example.com"},
|
||||||
|
{"//meet.example.com", "meet.example.com"},
|
||||||
|
{" https://meet.example.com ", "meet.example.com"},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.raw, func(t *testing.T) {
|
||||||
|
if got := normaliseHost(tc.raw); got != tc.want {
|
||||||
|
t.Fatalf("normaliseHost(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeRaw(t *testing.T) {
|
||||||
|
const payload = "hello world"
|
||||||
|
raw := encodeForTest(t, []byte(payload))
|
||||||
|
|
||||||
|
got := decodeRaw(makeBridgeMessage("EndpointMessage", map[string]any{"raw": raw}))
|
||||||
|
if string(got) != payload {
|
||||||
|
t.Fatalf("decodeRaw = %q, want %q", got, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := decodeRaw(makeBridgeMessage("OtherClass", map[string]any{"raw": raw})); got != nil {
|
||||||
|
t.Fatalf("decodeRaw(other class) = %q, want nil", got)
|
||||||
|
}
|
||||||
|
if got := decodeRaw(makeBridgeMessage("EndpointMessage", map[string]any{})); got != nil {
|
||||||
|
t.Fatalf("decodeRaw(no raw) = %q, want nil", got)
|
||||||
|
}
|
||||||
|
if got := decodeRaw(makeBridgeMessage("EndpointMessage", map[string]any{"raw": "not-base64!!!"})); got != nil {
|
||||||
|
t.Fatalf("decodeRaw(bad base64) = %q, want nil", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRequiresHost(t *testing.T) {
|
||||||
|
_, err := New(context.Background(), engine.Config{
|
||||||
|
Extra: map[string]string{"room": "myroom"},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrHostRequired) {
|
||||||
|
t.Fatalf("err = %v, want ErrHostRequired", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRequiresRoom(t *testing.T) {
|
||||||
|
_, err := New(context.Background(), engine.Config{
|
||||||
|
URL: "meet.example.com",
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrRoomRequired) {
|
||||||
|
t.Fatalf("err = %v, want ErrRoomRequired", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSucceeds(t *testing.T) {
|
||||||
|
sess, err := New(context.Background(), engine.Config{
|
||||||
|
URL: "https://meet.example.com",
|
||||||
|
Extra: map[string]string{"room": "myroom"},
|
||||||
|
Name: "olcrtc-test",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = sess.Close() }()
|
||||||
|
caps := sess.Capabilities()
|
||||||
|
if !caps.ByteStream || !caps.VideoTrack {
|
||||||
|
t.Fatalf("Capabilities = %+v, want ByteStream && VideoTrack", caps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendBeforeConnect(t *testing.T) {
|
||||||
|
sess, err := New(context.Background(), engine.Config{
|
||||||
|
URL: "meet.example.com",
|
||||||
|
Extra: map[string]string{"room": "myroom"},
|
||||||
|
OnData: func([]byte) {},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = sess.Close() }()
|
||||||
|
if err := sess.Send([]byte("data")); !errors.Is(err, ErrBridgeNotReady) {
|
||||||
|
t.Fatalf("Send err = %v, want ErrBridgeNotReady", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendAfterClose(t *testing.T) {
|
||||||
|
sess, err := New(context.Background(), engine.Config{
|
||||||
|
URL: "meet.example.com",
|
||||||
|
Extra: map[string]string{"room": "myroom"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
if err := sess.Close(); err != nil {
|
||||||
|
t.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
if err := sess.Send([]byte("data")); !errors.Is(err, ErrSessionClosed) {
|
||||||
|
t.Fatalf("Send err = %v, want ErrSessionClosed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitiseNick(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
raw string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"alice", "alice"},
|
||||||
|
{"Alice Smith", "Alice-Smith"},
|
||||||
|
{"Конрад Олег", ""},
|
||||||
|
{"olcrtc-bot42", "olcrtc-bot42"},
|
||||||
|
{" bob ", "bob"},
|
||||||
|
{"$$$ %%%", ""},
|
||||||
|
{"verylongnicknamethatexceedslimit", "verylongnicknamet"[:16]},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.raw, func(t *testing.T) {
|
||||||
|
if got := sanitiseNick(tc.raw); got != tc.want {
|
||||||
|
t.Fatalf("sanitiseNick(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEngineRegistration(t *testing.T) {
|
||||||
|
if _, err := engine.New(context.Background(), "jitsi", engine.Config{
|
||||||
|
URL: "meet.example.com",
|
||||||
|
Extra: map[string]string{"room": "myroom"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("engine.New(jitsi) = %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user