#36/#37/#40/#41/#47: WebRTC kütüphanesi, sub-network ve Studio UI

sdk/webrtc/ — kapsamlı RTCEngine kütüphanesi:
  - PeerConnection.js: RTCPeerConnection wrapper; connectionstatechange +
    iceconnectionstatechange her ikisi de izleniyor (eski 'bağlantı kopsa
    fark etmez' hatası bu şekilde önleniyor)
  - Negotiator.js: perfect negotiation (RFC 8829); polite/impolite roller,
    teklif çakışması rollback, ICE adayları sıraya alınıyor
  - StreamManager.js: addStream/replaceTrack/removeStream/setEncodings —
    runtime'da track ekle/çıkar/değiştir, yeniden müzakere gerekmiyor
  - DataChannel.js: birincil veri kanalı, bağlantı canlıyken kapanırsa
    otomatik yeniden oluşturulur, açılana kadar mesaj kuyruğu
  - MediaSources.js: camera/microphone/screen/cameraAndMic fabrika metodları,
    AudioBuffer→MediaStream, canvas.captureStream, AudioContext mix bus
  - FileSender.js: en fazla 5 paralel DataChannel üzerinden dosya transferi,
    progress olayı, 16 KB chunk + bufferedAmountLow akış kontrolü
  - index.js (RTCEngine): tüm alt sistemleri koordine eder; ICE restart
    exponential backoff (1s→2s→4s); Peer.js ile geriye dönük uyumlu

sdk/WebRTC.js → ./webrtc/index.js'e ince re-export (Peer.js değişmedi)

sdk/studio/ — masaüstü-first Miller-kolon UI (#47):
  - Column.js: başlık + arama filtresi + öğe listesi
  - ColumnView.js: yatay kayan kolon yöneticisi, popTo() ile derine git
  - index.js (Studio): Server→Groups→Peers→Devices→Streams→Quality hiyerarşisi;
    WebRTC bağlantısını otomatik başlatır (polite = küçük socketId)
  - style.css: koyu masaüstü teması, aktif öğe vurgusu, ilerleme çubuğu

internal/services/ippressure.go — rastgele atama + sub-network (#40/#41):
  - lockIP/lockNumber/lockCode: önce 256 rastgele deneme, sonra sıralı yedek;
    yüksek bağlantı sayısında O(1) ortalama atama
  - SubNet: /24 sanal alt ağ (10.A.B.0/24); Alloc/Release/Whois metodları
  - alloc|release|whois APSubNet + APSubNetIP HTTP handler'ları eklendi
  - Bağlantı kesildiğinde subnet IP'leri ve prefix otomatik serbest bırakılıyor

sdk/IPPressure.js: allocSubNet/releaseSubNet/allocSubNetIP/releaseSubNetIP/
  querySubNetIP metodları eklendi

go test -race ./... — tüm testler yeşil

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
abdussamedulutas 2026-06-17 12:59:16 +03:00
parent 06ca31eecb
commit 75d5999b4a
15 changed files with 2239 additions and 95 deletions

View File

@ -2,20 +2,22 @@ package services
import ( import (
"fmt" "fmt"
"math/rand/v2"
"sync" "sync"
"git.saqut.com/saqut/mwse/internal/protocol" "git.saqut.com/saqut/mwse/internal/protocol"
"git.saqut.com/saqut/mwse/internal/ws" "git.saqut.com/saqut/mwse/internal/ws"
) )
// shortCodeAlphabet is the 22-letter set the original used (note: J, Q, U, W are // shortCodeAlphabet is the 22-letter set the original used (J, Q, U, W absent).
// intentionally absent). Three letters give 22^3 = 10,648 codes. // Three letters give 22³ = 10,648 unique codes.
const shortCodeAlphabet = "ABCDEFGHIKLMNOPRSTVXYZ" const shortCodeAlphabet = "ABCDEFGHIKLMNOPRSTVXYZ"
// Announcer receives address allocation events. In the multi-process Node // randomProbes is how many random candidates we try before falling back to a
// deployment these were forwarded to a parent via process.send for the live // sequential scan. At low occupancy the first probe almost always succeeds.
// traffic panel. For the single-node 0.1.0 core the default is a no-op; a cluster const randomProbes = 256
// integration can supply a real implementation later.
// Announcer receives address allocation events for external monitoring.
type Announcer interface { type Announcer interface {
Announce(kind, action, clientID string, value any) Announce(kind, action, clientID string, value any)
} }
@ -24,16 +26,89 @@ type noopAnnouncer struct{}
func (noopAnnouncer) Announce(string, string, string, any) {} func (noopAnnouncer) Announce(string, string, string, any) {}
// IPPressure allocates three kinds of unique virtual address to clients. A single // SubNet is a /24 virtual sub-network (10.A.B.0/24).
// mutex guards all three tables; allocation is infrequent relative to messaging, // Clients in a group or room can draw IPs from within a dedicated prefix
// so finer-grained locking would add complexity for no real gain. // so they appear to share the same network segment.
//
// Thread-safety: protected by its own mutex; IPPressure's outer mutex guards
// the subnet registry but not the per-subnet host tables.
type SubNet struct {
Prefix string // "10.A.B" — the /24 prefix
Owner string // room / group id that claimed this subnet
mu sync.Mutex
hosts map[byte]string // host byte [1..254] → clientID
}
// Alloc assigns a random host address within this /24 to clientID.
func (sn *SubNet) Alloc(clientID string) (string, bool) {
sn.mu.Lock()
defer sn.mu.Unlock()
// Random probe first.
for range randomProbes {
h := byte(rand.IntN(254)) + 1 // [1..254]
if _, busy := sn.hosts[h]; !busy {
sn.hosts[h] = clientID
return fmt.Sprintf("%s.%d", sn.Prefix, h), true
}
}
// Sequential fallback (subnet is more than 98% full).
for h := byte(1); h < 255; h++ {
if _, busy := sn.hosts[h]; !busy {
sn.hosts[h] = clientID
return fmt.Sprintf("%s.%d", sn.Prefix, h), true
}
}
return "", false // /24 exhausted (~254 hosts)
}
// Release frees the host address previously allocated to clientID.
func (sn *SubNet) Release(clientID string) {
sn.mu.Lock()
defer sn.mu.Unlock()
for h, id := range sn.hosts {
if id == clientID {
delete(sn.hosts, h)
return
}
}
}
// Whois returns the clientID that holds the given IP within this subnet.
func (sn *SubNet) Whois(ip string) (string, bool) {
sn.mu.Lock()
defer sn.mu.Unlock()
var host byte
_, err := fmt.Sscanf(ip, sn.Prefix+".%d", &host)
if err != nil {
return "", false
}
id, ok := sn.hosts[host]
return id, ok
}
// IPPressure allocates virtual addresses to clients.
//
// Allocation strategy (#41): random probe with sequential fallback.
// At typical occupancy (< 1 % of the address space) the first random
// candidate is free, so allocation is O(1) in practice.
//
// Sub-networks (#40): callers can reserve a /24 prefix and hand out IPs
// within it. The global flat space and the subnet space are disjoint; a
// client that holds a subnet IP is also tracked in busyIP so whois/APIPAddress
// queries still work.
type IPPressure struct { type IPPressure struct {
ann Announcer ann Announcer
mu sync.Mutex mu sync.Mutex
busyNumber map[int]string // number -> clientID busyNumber map[int]string // number → clientID
busyCode map[string]string // shortcode -> clientID busyCode map[string]string // shortcode → clientID
busyIP map[string]string // ip -> clientID busyIP map[string]string // ip → clientID (both flat and subnet)
// Sub-network registry.
subnets map[string]*SubNet // prefix ("10.A.B") → SubNet
clientSN map[string]string // clientID → subnet prefix (for disconnect cleanup)
} }
// NewIPPressure builds an allocator. A nil announcer becomes a no-op. // NewIPPressure builds an allocator. A nil announcer becomes a no-op.
@ -46,15 +121,86 @@ func NewIPPressure(ann Announcer) *IPPressure {
busyNumber: make(map[int]string), busyNumber: make(map[int]string),
busyCode: make(map[string]string), busyCode: make(map[string]string),
busyIP: make(map[string]string), busyIP: make(map[string]string),
subnets: make(map[string]*SubNet),
clientSN: make(map[string]string),
} }
} }
// ---- number (starts at 24, counts up) ----------------------------------- // ---- Flat IP address (10.x.x.x, random) --------------------------------
func (p *IPPressure) lockIP(clientID string) string {
p.mu.Lock()
defer p.mu.Unlock()
// Random probe across the full 10.0.0.0/8.
for range randomProbes {
a := byte(rand.IntN(255)) + 1
b := byte(rand.IntN(256))
c := byte(rand.IntN(255)) + 1
ip := fmt.Sprintf("10.%d.%d.%d", a, b, c)
if _, busy := p.busyIP[ip]; !busy {
p.busyIP[ip] = clientID
p.ann.Announce("AP_IPADDRESS", "LOCK", clientID, ip)
return ip
}
}
// Sequential fallback (extremely unlikely with random probes).
for a := 1; a <= 255; a++ {
for b := 0; b <= 255; b++ {
for c := 1; c <= 255; c++ {
ip := fmt.Sprintf("10.%d.%d.%d", a, b, c)
if _, busy := p.busyIP[ip]; !busy {
p.busyIP[ip] = clientID
p.ann.Announce("AP_IPADDRESS", "LOCK", clientID, ip)
return ip
}
}
}
}
return "" // address space exhausted (> ~16 million clients)
}
func (p *IPPressure) releaseIP(ip string) {
p.mu.Lock()
defer p.mu.Unlock()
if clientID, ok := p.busyIP[ip]; ok {
p.ann.Announce("AP_IPADDRESS", "RELEASE", clientID, ip)
delete(p.busyIP, ip)
}
}
func (p *IPPressure) whoisIP(ip string) (string, bool) {
p.mu.Lock()
defer p.mu.Unlock()
id, ok := p.busyIP[ip]
return id, ok
}
// ---- Number (random in [24, 99999]) -------------------------------------
func (p *IPPressure) lockNumber(clientID string) int { func (p *IPPressure) lockNumber(clientID string) int {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
for n := 24; ; n++ {
const lo, hi = 24, 99999
for range randomProbes {
n := rand.IntN(hi-lo+1) + lo
if _, busy := p.busyNumber[n]; !busy {
p.busyNumber[n] = clientID
p.ann.Announce("AP_NUMBER", "LOCK", clientID, n)
return n
}
}
// Sequential fallback.
for n := lo; n <= hi; n++ {
if _, busy := p.busyNumber[n]; !busy {
p.busyNumber[n] = clientID
p.ann.Announce("AP_NUMBER", "LOCK", clientID, n)
return n
}
}
// Beyond hi: keep counting up without bound.
for n := hi + 1; ; n++ {
if _, busy := p.busyNumber[n]; !busy { if _, busy := p.busyNumber[n]; !busy {
p.busyNumber[n] = clientID p.busyNumber[n] = clientID
p.ann.Announce("AP_NUMBER", "LOCK", clientID, n) p.ann.Announce("AP_NUMBER", "LOCK", clientID, n)
@ -79,11 +225,26 @@ func (p *IPPressure) whoisNumber(n int) (string, bool) {
return id, ok return id, ok
} }
// ---- short code (three letters from the restricted alphabet) ------------ // ---- Short code (random 3-letter from restricted alphabet) --------------
func (p *IPPressure) lockCode(clientID string) string { func (p *IPPressure) lockCode(clientID string) string {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
n := len(shortCodeAlphabet)
for range randomProbes {
code := string([]byte{
shortCodeAlphabet[rand.IntN(n)],
shortCodeAlphabet[rand.IntN(n)],
shortCodeAlphabet[rand.IntN(n)],
})
if _, busy := p.busyCode[code]; !busy {
p.busyCode[code] = clientID
p.ann.Announce("AP_SHORTCODE", "LOCK", clientID, code)
return code
}
}
// Sequential fallback.
for _, a := range shortCodeAlphabet { for _, a := range shortCodeAlphabet {
for _, b := range shortCodeAlphabet { for _, b := range shortCodeAlphabet {
for _, d := range shortCodeAlphabet { for _, d := range shortCodeAlphabet {
@ -96,7 +257,7 @@ func (p *IPPressure) lockCode(clientID string) string {
} }
} }
} }
return "" // address space exhausted return "" // all 10,648 codes in use
} }
func (p *IPPressure) releaseCode(code string) { func (p *IPPressure) releaseCode(code string) {
@ -115,56 +276,84 @@ func (p *IPPressure) whoisCode(code string) (string, bool) {
return id, ok return id, ok
} }
// ---- ip address (10.0.0.1 upward) --------------------------------------- // ---- Sub-network (#40) -------------------------------------------------
func (p *IPPressure) lockIP(clientID string) string { // allocSubNet reserves a random /24 prefix (10.A.B.0/24) for the caller.
// The prefix is held until releaseSubNet is called or the client disconnects.
func (p *IPPressure) allocSubNet(ownerID string) (*SubNet, bool) {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
a, b, cc, d := 10, 0, 0, 1
for { for range randomProbes {
ip := fmt.Sprintf("%d.%d.%d.%d", a, b, cc, d) a := byte(rand.IntN(255)) + 1
if _, busy := p.busyIP[ip]; !busy { b := byte(rand.IntN(256))
p.busyIP[ip] = clientID prefix := fmt.Sprintf("10.%d.%d", a, b)
p.ann.Announce("AP_IPADDRESS", "LOCK", clientID, ip) if _, busy := p.subnets[prefix]; !busy {
return ip sn := &SubNet{Prefix: prefix, Owner: ownerID, hosts: make(map[byte]string)}
} p.subnets[prefix] = sn
switch { p.ann.Announce("AP_SUBNET", "ALLOC", ownerID, prefix)
case d != 255: return sn, true
d++
case cc != 255:
d, cc = 0, cc+1
case b != 255:
d, cc, b = 0, 0, b+1
case a != 255:
d, cc, b, a = 0, 0, 0, a+1
default:
return "" // address space exhausted
} }
} }
// Sequential fallback.
for a := 1; a <= 255; a++ {
for b := 0; b <= 255; b++ {
prefix := fmt.Sprintf("10.%d.%d", a, b)
if _, busy := p.subnets[prefix]; !busy {
sn := &SubNet{Prefix: prefix, Owner: ownerID, hosts: make(map[byte]string)}
p.subnets[prefix] = sn
p.ann.Announce("AP_SUBNET", "ALLOC", ownerID, prefix)
return sn, true
}
}
}
return nil, false
} }
func (p *IPPressure) releaseIP(ip string) { // getSubNet looks up the SubNet owned by ownerID.
func (p *IPPressure) getSubNet(ownerID string) (*SubNet, bool) {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
if clientID, ok := p.busyIP[ip]; ok { for _, sn := range p.subnets {
p.ann.Announce("AP_IPADDRESS", "RELEASE", clientID, ip) if sn.Owner == ownerID {
return sn, true
}
}
return nil, false
}
// releaseSubNet frees a /24 prefix and removes all host addresses it contained
// from the global busyIP table.
func (p *IPPressure) releaseSubNet(prefix string) {
p.mu.Lock()
sn, ok := p.subnets[prefix]
if !ok {
p.mu.Unlock()
return
}
delete(p.subnets, prefix)
p.mu.Unlock()
sn.mu.Lock()
defer sn.mu.Unlock()
for h, cid := range sn.hosts {
ip := fmt.Sprintf("%s.%d", prefix, h)
p.mu.Lock()
delete(p.busyIP, ip) delete(p.busyIP, ip)
p.mu.Unlock()
p.ann.Announce("AP_SUBNET_IP", "RELEASE", cid, ip)
} }
p.ann.Announce("AP_SUBNET", "RELEASE", sn.Owner, prefix)
} }
func (p *IPPressure) whoisIP(ip string) (string, bool) { // ---- Service registration ----------------------------------------------
p.mu.Lock()
defer p.mu.Unlock()
id, ok := p.busyIP[ip]
return id, ok
}
// registerIPPressure wires the alloc/realloc/release/whois handlers and the // registerIPPressure wires the alloc/realloc/release/whois handlers and the
// disconnect cleanup. The allocator instance is returned for tests. // disconnect cleanup. The allocator instance is returned for tests.
func registerIPPressure(hub *ws.Hub, ann Announcer) *IPPressure { func registerIPPressure(hub *ws.Hub, ann Announcer) *IPPressure {
p := NewIPPressure(ann) p := NewIPPressure(ann)
// --- IP address --- // --- Flat IP address ---
hub.Register("alloc/APIPAddress", func(c *ws.Client, m protocol.Message) any { hub.Register("alloc/APIPAddress", func(c *ws.Client, m protocol.Message) any {
if ip := c.APIP(); ip != "" { if ip := c.APIP(); ip != "" {
return map[string]any{"status": "success", "ip": ip} return map[string]any{"status": "success", "ip": ip}
@ -181,11 +370,9 @@ func registerIPPressure(hub *ws.Hub, ann Announcer) *IPPressure {
if old == "" { if old == "" {
return map[string]any{"status": "fail"} return map[string]any{"status": "fail"}
} }
// Allocate the new address before freeing the old one, so realloc actually
// yields a different address (the old stays reserved during the search).
ip := p.lockIP(c.ID) ip := p.lockIP(c.ID)
if ip == "" { if ip == "" {
return map[string]any{"status": "fail"} // exhausted; keep the old one return map[string]any{"status": "fail"}
} }
p.releaseIP(old) p.releaseIP(old)
c.SetAPIP(ip) c.SetAPIP(ip)
@ -203,7 +390,7 @@ func registerIPPressure(hub *ws.Hub, ann Announcer) *IPPressure {
return map[string]any{"status": "fail"} return map[string]any{"status": "fail"}
}) })
// --- number --- // --- Number ---
hub.Register("alloc/APNumber", func(c *ws.Client, m protocol.Message) any { hub.Register("alloc/APNumber", func(c *ws.Client, m protocol.Message) any {
if n := c.APNumber(); n != 0 { if n := c.APNumber(); n != 0 {
return map[string]any{"status": "success", "number": n} return map[string]any{"status": "success", "number": n}
@ -217,7 +404,7 @@ func registerIPPressure(hub *ws.Hub, ann Announcer) *IPPressure {
if old == 0 { if old == 0 {
return map[string]any{"status": "fail"} return map[string]any{"status": "fail"}
} }
n := p.lockNumber(c.ID) // old stays reserved, so n != old n := p.lockNumber(c.ID)
p.releaseNumber(old) p.releaseNumber(old)
c.SetAPNumber(n) c.SetAPNumber(n)
return map[string]any{"status": "success", "number": n} return map[string]any{"status": "success", "number": n}
@ -234,7 +421,7 @@ func registerIPPressure(hub *ws.Hub, ann Announcer) *IPPressure {
return map[string]any{"status": "fail"} return map[string]any{"status": "fail"}
}) })
// --- short code --- // --- Short code ---
hub.Register("alloc/APShortCode", func(c *ws.Client, m protocol.Message) any { hub.Register("alloc/APShortCode", func(c *ws.Client, m protocol.Message) any {
if code := c.APShortCode(); code != "" { if code := c.APShortCode(); code != "" {
return map[string]any{"status": "success", "code": code} return map[string]any{"status": "success", "code": code}
@ -253,7 +440,7 @@ func registerIPPressure(hub *ws.Hub, ann Announcer) *IPPressure {
} }
code := p.lockCode(c.ID) code := p.lockCode(c.ID)
if code == "" { if code == "" {
return map[string]any{"status": "fail"} // exhausted; keep the old one return map[string]any{"status": "fail"}
} }
p.releaseCode(old) p.releaseCode(old)
c.SetAPShortCode(code) c.SetAPShortCode(code)
@ -271,16 +458,130 @@ func registerIPPressure(hub *ws.Hub, ann Announcer) *IPPressure {
return map[string]any{"status": "fail"} return map[string]any{"status": "fail"}
}) })
// Release every address a client held when it disconnects. // --- Sub-network (#40) ---
// alloc/APSubNet: claim a /24 prefix for a group/room. The caller becomes
// the subnet owner; its members can then call alloc/APSubNetIP.
hub.Register("alloc/APSubNet", func(c *ws.Client, m protocol.Message) any {
// Idempotent: return existing prefix if the caller already owns one.
if sn, ok := p.getSubNet(c.ID); ok {
return map[string]any{"status": "success", "prefix": sn.Prefix}
}
sn, ok := p.allocSubNet(c.ID)
if !ok {
return map[string]any{"status": "fail"}
}
// Track on the client so disconnect cleanup works.
p.mu.Lock()
p.clientSN[c.ID] = sn.Prefix
p.mu.Unlock()
return map[string]any{"status": "success", "prefix": sn.Prefix}
})
// release/APSubNet: free the prefix. All host IPs are released.
hub.Register("release/APSubNet", func(c *ws.Client, m protocol.Message) any {
p.mu.Lock()
prefix, had := p.clientSN[c.ID]
if had {
delete(p.clientSN, c.ID)
}
p.mu.Unlock()
if had {
p.releaseSubNet(prefix)
}
return success()
})
// alloc/APSubNetIP: allocate a host IP within the subnet identified by
// the "prefix" field. The caller need not be the owner.
hub.Register("alloc/APSubNetIP", func(c *ws.Client, m protocol.Message) any {
prefix := m.Str("prefix")
p.mu.Lock()
sn, ok := p.subnets[prefix]
p.mu.Unlock()
if !ok {
return map[string]any{"status": "fail", "message": "subnet not found"}
}
ip, ok := sn.Alloc(c.ID)
if !ok {
return map[string]any{"status": "fail", "message": "subnet exhausted"}
}
// Register in the global table so flat whois works.
p.mu.Lock()
p.busyIP[ip] = c.ID
p.mu.Unlock()
// If the client later disconnects, release via the subnet too.
p.mu.Lock()
p.clientSN[c.ID] = prefix
p.mu.Unlock()
c.SetAPIP(ip)
return map[string]any{"status": "success", "ip": ip}
})
// release/APSubNetIP: free the current subnet IP of the caller.
hub.Register("release/APSubNetIP", func(c *ws.Client, m protocol.Message) any {
ip := c.APIP()
if ip == "" {
return success()
}
p.mu.Lock()
prefix := p.clientSN[c.ID]
sn, hasSN := p.subnets[prefix]
delete(p.clientSN, c.ID)
delete(p.busyIP, ip)
p.mu.Unlock()
if hasSN {
sn.Release(c.ID)
}
c.SetAPIP("")
return success()
})
// whois/APSubNetIP: look up a host IP within a given subnet.
hub.Register("whois/APSubNetIP", func(c *ws.Client, m protocol.Message) any {
prefix := m.Str("prefix")
ip := m.Str("whois")
p.mu.Lock()
sn, ok := p.subnets[prefix]
p.mu.Unlock()
if !ok {
return map[string]any{"status": "fail"}
}
if id, ok := sn.Whois(ip); ok {
return map[string]any{"status": "success", "socket": id}
}
return map[string]any{"status": "fail"}
})
// Release every address when a client disconnects.
hub.OnDisconnect(func(c *ws.Client) { hub.OnDisconnect(func(c *ws.Client) {
if c.APIP() != "" { if ip := c.APIP(); ip != "" {
p.releaseIP(c.APIP()) p.releaseIP(ip)
// Also release from subnet if applicable.
p.mu.Lock()
if prefix, ok := p.clientSN[c.ID]; ok {
if sn, snOK := p.subnets[prefix]; snOK {
sn.Release(c.ID)
}
delete(p.clientSN, c.ID)
}
p.mu.Unlock()
} }
if c.APNumber() != 0 { if c.APNumber() != 0 {
p.releaseNumber(c.APNumber()) p.releaseNumber(c.APNumber())
} }
if c.APShortCode() != "" { if code := c.APShortCode(); code != "" {
p.releaseCode(c.APShortCode()) p.releaseCode(code)
}
// Release owned subnet prefix on disconnect.
p.mu.Lock()
prefix, hadSN := p.clientSN[c.ID]
if hadSN {
delete(p.clientSN, c.ID)
}
p.mu.Unlock()
if hadSN {
p.releaseSubNet(prefix)
} }
}) })

View File

@ -0,0 +1,141 @@
package services
import (
"strings"
"sync"
"testing"
)
// TestRandomAllocationIsUnique verifies that 1000 concurrent IP allocations
// produce no duplicates (the random probing strategy must be collision-safe).
func TestRandomAllocationIsUnique(t *testing.T) {
p := NewIPPressure(nil)
const n = 1000
results := make([]string, n)
var wg sync.WaitGroup
for i := range n {
wg.Add(1)
go func(i int) {
defer wg.Done()
results[i] = p.lockIP("client-" + string(rune('A'+i%26)) + string(rune('0'+i/26)))
}(i)
}
wg.Wait()
seen := make(map[string]int, n)
for i, ip := range results {
if ip == "" {
t.Fatalf("allocation %d returned empty string", i)
}
if prev, dup := seen[ip]; dup {
t.Fatalf("duplicate IP %s at slots %d and %d", ip, prev, i)
}
seen[ip] = i
}
}
// TestRandomNumberAllocation checks uniqueness for number allocations.
func TestRandomNumberAllocation(t *testing.T) {
p := NewIPPressure(nil)
const n = 500
nums := make([]int, n)
var wg sync.WaitGroup
for i := range n {
wg.Add(1)
go func(i int) {
defer wg.Done()
nums[i] = p.lockNumber("c" + string(rune('A'+i%26)))
}(i)
}
wg.Wait()
seen := make(map[int]bool, n)
for _, n := range nums {
if seen[n] {
t.Fatalf("duplicate number %d", n)
}
seen[n] = true
}
}
// TestSubNetAllocRelease covers the /24 subnet lifecycle.
func TestSubNetAllocRelease(t *testing.T) {
hub := newHub()
a, _ := connect(hub, "a")
b, _ := connect(hub, "b")
// Allocate a subnet.
resp := asMap(t, hub.Handle(a, msg("alloc/APSubNet")))
if resp["status"] != "success" {
t.Fatalf("alloc/APSubNet = %v", resp)
}
prefix := resp["prefix"].(string)
if !strings.HasPrefix(prefix, "10.") {
t.Fatalf("prefix %q should start with '10.'", prefix)
}
// Idempotent: second alloc returns the same prefix.
resp2 := asMap(t, hub.Handle(a, msg("alloc/APSubNet")))
if resp2["prefix"] != prefix {
t.Fatalf("second alloc/APSubNet should return same prefix, got %v vs %v", resp2["prefix"], prefix)
}
// b requests a host IP within a's subnet.
ipResp := asMap(t, hub.Handle(b, msg("alloc/APSubNetIP", "prefix", prefix)))
if ipResp["status"] != "success" {
t.Fatalf("alloc/APSubNetIP = %v", ipResp)
}
ip := ipResp["ip"].(string)
if !strings.HasPrefix(ip, prefix+".") {
t.Fatalf("allocated IP %q should be within prefix %q", ip, prefix)
}
// whois query finds b.
who := asMap(t, hub.Handle(a, msg("whois/APSubNetIP", "prefix", prefix, "whois", ip)))
if who["socket"] != "b" {
t.Fatalf("whois/APSubNetIP = %v, want b", who)
}
// Release b's host IP.
if r := asMap(t, hub.Handle(b, msg("release/APSubNetIP"))); r["status"] != "success" {
t.Fatalf("release/APSubNetIP = %v", r)
}
// Release a's subnet.
if r := asMap(t, hub.Handle(a, msg("release/APSubNet"))); r["status"] != "success" {
t.Fatalf("release/APSubNet = %v", r)
}
}
// TestSubNetIPsAreUnique verifies that multiple clients get distinct IPs
// within the same subnet.
func TestSubNetIPsAreUnique(t *testing.T) {
p := NewIPPressure(nil)
sn, ok := p.allocSubNet("owner")
if !ok {
t.Fatal("allocSubNet failed")
}
const n = 50
ips := make([]string, n)
for i := range n {
ip, ok := sn.Alloc("client-" + string(rune('A'+i)))
if !ok {
t.Fatalf("Alloc failed at %d", i)
}
ips[i] = ip
}
seen := make(map[string]bool, n)
for _, ip := range ips {
if seen[ip] {
t.Fatalf("duplicate subnet IP %s", ip)
}
seen[ip] = true
if !strings.HasPrefix(ip, sn.Prefix+".") {
t.Fatalf("IP %q not within prefix %q", ip, sn.Prefix)
}
}
}

View File

@ -83,4 +83,44 @@ export class IPPressure {
}); });
return status === 'success' ? socket : null; return status === 'success' ? socket : null;
} }
// ---- Sub-network (#40) -----------------------------------------------
// Claim a /24 virtual sub-network (10.A.B.0/24).
// The caller becomes the subnet owner. Members can then call allocSubNetIP().
async allocSubNet() {
const { status, prefix } = await this.mwse.EventPooling.request({ type: 'alloc/APSubNet' });
if (status === 'success') { this.APSubNet = prefix; return prefix; }
throw new Error('Error allocating sub-network');
}
async releaseSubNet() {
const { status } = await this.mwse.EventPooling.request({ type: 'release/APSubNet' });
if (status === 'success') { this.APSubNet = undefined; this.APSubNetIP = undefined; return; }
throw new Error('Error releasing sub-network');
}
// Allocate a host IP within an existing subnet.
// prefix: the "10.A.B" string returned by allocSubNet().
async allocSubNetIP(prefix) {
const { status, ip } = await this.mwse.EventPooling.request({
type: 'alloc/APSubNetIP', prefix
});
if (status === 'success') { this.APSubNetIP = ip; return ip; }
throw new Error('Error allocating sub-network IP');
}
async releaseSubNetIP() {
const { status } = await this.mwse.EventPooling.request({ type: 'release/APSubNetIP' });
if (status === 'success') { this.APSubNetIP = undefined; return; }
throw new Error('Error releasing sub-network IP');
}
// Look up which socket holds a given IP within a subnet.
async querySubNetIP(prefix, ip) {
const { status, socket } = await this.mwse.EventPooling.request({
type: 'whois/APSubNetIP', prefix, whois: ip
});
return status === 'success' ? socket : null;
}
} }

View File

@ -1,36 +1,3 @@
// WebRTC.js — placeholder. Full WebRTC implementation will be rewritten separately. // Re-exports the full RTCEngine from sdk/webrtc/.
// Provides the interface Peer.js depends on so the rest of the SDK loads cleanly. // Peer.js imports this file; the actual implementation lives in sdk/webrtc/index.js.
import MWSEEventTarget from './EventTarget.js'; export { default } from './webrtc/index.js';
export default class WebRTC extends MWSEEventTarget {
constructor(_rtcConfig, _rtcServers) {
super();
this.active = false;
this.peer = null;
this.connectionStatus = 'new';
this.iceStatus = 'new';
this.gatheringStatus = 'new';
this.signalingStatus = '';
this.channel = undefined;
}
connect() {
console.warn('WebRTC: not yet implemented — replacement coming separately');
}
sendStream(_stream, _name, _info) {
console.warn('WebRTC.sendStream: not yet implemented');
}
sendMessage(_data) {
console.warn('WebRTC.sendMessage: not yet implemented');
}
destroy() {
this.active = false;
this.emit('disconnected');
}
// send is called internally by the signaling output path in the full implementation.
send(_data) {}
}

137
sdk/studio/Column.js Normal file
View File

@ -0,0 +1,137 @@
// A single column in the Miller-column navigator.
//
// Each column holds a list of Item objects. Selecting an item calls
// item.onSelect() and marks the item as active (highlighted). The column
// also supports optional search filtering.
export default class Column {
constructor({ title, items = [], searchable = true }) {
this.title = title;
this._items = items;
this._active = null; // currently selected item element
this._root = null;
this._list = null;
this._searchable = searchable;
this._filter = '';
this._actionsEl = null;
}
// Build and return the column DOM element.
mount() {
const col = document.createElement('div');
col.className = 'mwse-col';
this._root = col;
const header = document.createElement('div');
header.className = 'mwse-col__header';
header.textContent = this.title;
col.appendChild(header);
if (this._searchable && this._items.length > 6) {
const search = document.createElement('input');
search.type = 'text';
search.className = 'mwse-col__search';
search.placeholder = 'Filter…';
search.addEventListener('input', () => {
this._filter = search.value.toLowerCase();
this._renderItems();
});
col.appendChild(search);
}
const list = document.createElement('div');
list.className = 'mwse-col__list';
this._list = list;
col.appendChild(list);
this._renderItems();
return col;
}
// Replace the item list (re-renders the list area).
setItems(items) {
this._items = items;
this._active = null;
this._filter = '';
this._renderItems();
}
// Add a persistent action button below the list.
addAction(label, className, onClick) {
if (!this._root) return;
if (!this._actionsEl) {
this._actionsEl = document.createElement('div');
this._actionsEl.className = 'mwse-col__actions';
this._root.appendChild(this._actionsEl);
}
const btn = document.createElement('button');
btn.className = `mwse-btn ${className ?? ''}`;
btn.textContent = label;
btn.addEventListener('click', onClick);
this._actionsEl.appendChild(btn);
}
// Force a re-render (e.g. after external state changes meta text).
refresh() {
this._renderItems();
}
// ---- Private --------------------------------------------------------
_renderItems() {
if (!this._list) return;
this._list.innerHTML = '';
const visible = this._filter
? this._items.filter(i => i.label.toLowerCase().includes(this._filter))
: this._items;
for (const item of visible) {
this._list.appendChild(this._buildItem(item));
}
}
_buildItem(item) {
const row = document.createElement('div');
row.className = 'mwse-item';
const icon = document.createElement('span');
icon.className = 'mwse-item__icon';
icon.textContent = item.icon ?? '○';
row.appendChild(icon);
const body = document.createElement('div');
body.className = 'mwse-item__body';
const label = document.createElement('div');
label.className = 'mwse-item__label';
label.textContent = item.label;
body.appendChild(label);
if (item.meta !== undefined) {
const meta = document.createElement('div');
meta.className = 'mwse-item__meta';
meta.textContent = typeof item.meta === 'function' ? item.meta() : item.meta;
body.appendChild(meta);
item._metaEl = meta; // for refresh()
}
row.appendChild(body);
if (item.hasChildren !== false) {
const arrow = document.createElement('span');
arrow.className = 'mwse-item__arrow';
arrow.textContent = '';
row.appendChild(arrow);
}
row.addEventListener('click', () => {
// Deactivate previous selection.
if (this._active) this._active.classList.remove('mwse-item--active');
row.classList.add('mwse-item--active');
this._active = row;
item.onSelect?.(item);
});
return row;
}
}

93
sdk/studio/ColumnView.js Normal file
View File

@ -0,0 +1,93 @@
// Miller-column view: manages a horizontal row of up to 5 columns.
// Selecting an item in column N removes columns N+1…end and adds the
// new column opened by that item's onSelect callback.
import Column from './Column.js';
export default class ColumnView {
constructor(container) {
this._container = container;
this._columns = []; // Column instances in order
this._els = []; // corresponding DOM elements
this._root = null;
}
mount() {
const root = document.createElement('div');
root.className = 'mwse-studio__columns';
this._root = root;
this._container.appendChild(root);
return this;
}
// Push a new column to the right. Returns the Column.
pushColumn(title, items, opts = {}) {
const col = new Column({ title, items, ...opts });
const el = col.mount();
this._columns.push(col);
this._els.push(el);
this._root.appendChild(el);
// Mark the new column as active (rightmost).
this._els.forEach((e, i) => {
e.classList.toggle('mwse-col--active', i === this._els.length - 1);
});
// Scroll so the new column is fully visible.
requestAnimationFrame(() => {
this._root.scrollLeft = this._root.scrollWidth;
});
return col;
}
// Remove all columns from index `depth` onwards, then push `title` + `items`.
replaceFrom(depth, title, items, opts) {
this._truncateTo(depth);
return this.pushColumn(title, items, opts);
}
// Remove columns deeper than `depth` (0-based).
// Useful when the user navigates back by clicking an earlier column item.
truncateTo(depth) {
this._truncateTo(depth + 1);
}
// Pop the rightmost column.
popColumn() {
if (this._columns.length === 0) return;
const el = this._els.pop();
this._columns.pop();
el.remove();
if (this._els.length) {
this._els[this._els.length - 1].classList.add('mwse-col--active');
}
}
// Remove all columns from index `toDepth` (exclusive) onwards.
popTo(toDepth) {
while (this._columns.length > toDepth) this.popColumn();
}
// Force all columns to re-render their meta text.
refresh() {
for (const col of this._columns) col.refresh();
}
get depth() { return this._columns.length; }
col(i) { return this._columns[i] ?? null; }
// ---- Internal -------------------------------------------------------
_truncateTo(count) {
while (this._columns.length > count) {
const el = this._els.pop();
this._columns.pop();
el.remove();
}
if (this._els.length) {
this._els[this._els.length - 1].classList.add('mwse-col--active');
}
}
}

308
sdk/studio/index.js Normal file
View File

@ -0,0 +1,308 @@
// MWSE Studio — desktop-first Miller-column UI.
//
// Hierarchy: Network → Groups → Peers → Devices → Streams → Quality
//
// Usage:
// import Studio from '/sdk/studio/index.js';
// const studio = new Studio(mwse, '#app');
// studio.mount();
//
// The Studio is purely additive: it renders into the given container and
// never modifies the MWSE SDK state except through the public SDK API.
import ColumnView from './ColumnView.js';
import { MediaSources } from '../webrtc/index.js';
export default class Studio {
constructor(mwse, container) {
this.mwse = mwse;
this._container = typeof container === 'string'
? document.querySelector(container)
: container;
this._view = new ColumnView(this._container);
this._styleInjected = false;
}
// Mount the Studio UI inside the container element.
mount() {
this._injectStyle();
this._container.classList.add('mwse-studio');
// Toolbar
const toolbar = document.createElement('div');
toolbar.className = 'mwse-studio__toolbar';
const title = document.createElement('span');
title.className = 'mwse-studio__title';
title.textContent = 'MWSE Studio';
toolbar.appendChild(title);
this._statusEl = document.createElement('span');
this._statusEl.className = 'mwse-studio__status';
toolbar.appendChild(this._statusEl);
this._container.appendChild(toolbar);
this.mwse.on('scope', () => this._setStatus('online', 'Connected'));
this.mwse.on('close', () => this._setStatus('', 'Disconnected'));
this.mwse.on('error', e => this._setStatus('error', e.message));
this._view.mount();
this._pushNetworkColumn();
return this;
}
// ---- Column builders ------------------------------------------------
_pushNetworkColumn() {
const items = [{
icon: '◉',
label: 'Network',
meta: () => `${this.mwse.peers.size} peers online`,
onSelect: () => {
this._view.popTo(1);
this._pushGroupsColumn();
}
}];
this._view.pushColumn('Studio', items, { searchable: false });
}
_pushGroupsColumn() {
const items = [];
for (const [id, room] of this.mwse.rooms) {
items.push({
icon: '#',
label: id,
meta: () => `${room.peers.size} peers`,
onSelect: () => {
this._view.popTo(2);
this._pushPeersColumn(room);
}
});
}
if (items.length === 0) {
items.push({ icon: '—', label: 'No groups', meta: 'Join a room first', hasChildren: false });
}
this._view.pushColumn('Groups', items);
}
_pushPeersColumn(room) {
const items = [];
for (const [, peer] of room.peers) {
items.push({
icon: peer.selfSocket ? '★' : '●',
label: peer.socketId,
meta: () => peer.peerConnection ? 'p2p' : 'ws',
onSelect: () => {
this._view.popTo(3);
this._pushDevicesColumn(peer);
}
});
}
if (items.length === 0) {
items.push({ icon: '—', label: 'No peers in room', meta: '', hasChildren: false });
}
this._view.pushColumn('Peers', items);
}
_pushDevicesColumn(peer) {
const items = [
{
icon: '◎',
label: 'Camera',
meta: 'Capture video',
onSelect: () => this._openCamera(peer)
},
{
icon: '●',
label: 'Camera + Mic',
meta: 'Video + audio',
onSelect: () => this._openCameraAndMic(peer)
},
{
icon: '♪',
label: 'Microphone',
meta: 'Audio only',
onSelect: () => this._openMicrophone(peer)
},
{
icon: '⬜',
label: 'Screen',
meta: 'Share display',
onSelect: () => this._openScreen(peer)
},
{
icon: '↗',
label: 'Send file',
meta: 'DataChannel transfer',
hasChildren: false,
onSelect: () => this._openFilePicker(peer)
}
];
const col = this._view.pushColumn('Devices', items);
// Show already-active streams at the bottom of devices.
if (peer.rtc?._streams) {
const active = peer.rtc._streams.list();
if (active.length) {
col.addAction('View streams', '', () => {
this._view.popTo(4);
this._pushActiveStreamsColumn(peer);
});
}
}
}
_pushActiveStreamsColumn(peer) {
const srcs = peer.rtc?._streams?.list() ?? [];
const items = srcs.map(src => ({
icon: src.tracks[0]?.kind === 'video' ? '▶' : '♪',
label: src.label,
meta: src.tracks.map(t => t.kind).join(' + '),
onSelect: () => {
this._view.popTo(5);
this._pushQualityColumn(peer, src.label, src);
}
}));
this._view.pushColumn('Streams', items);
}
_pushQualityColumn(peer, label, src) {
const presets = [
{ icon: '↑', label: 'High', meta: '1080p · 4 Mbps', params: { maxBitrate: 4_000_000, scaleResolutionDownBy: 1 } },
{ icon: '—', label: 'Medium', meta: '720p · 1.5 Mbps', params: { maxBitrate: 1_500_000, scaleResolutionDownBy: 1.5 } },
{ icon: '↓', label: 'Low', meta: '480p · 500 Kbps', params: { maxBitrate: 500_000, scaleResolutionDownBy: 2 } },
];
const items = presets.map(p => ({
icon: p.icon,
label: p.label,
meta: p.meta,
hasChildren: false,
onSelect: () => peer.rtc?.setEncodings(label, 'video', p.params)
}));
// Mute / stop controls.
for (const track of (src.tracks ?? [])) {
items.push({
icon: track.enabled ? '⊙' : '○',
label: `${track.enabled ? 'Mute' : 'Unmute'} ${track.kind}`,
meta: '',
hasChildren: false,
onSelect: () => {
peer.rtc?.setEnabled(label, track.kind, !track.enabled);
this._view.refresh();
}
});
}
items.push({
icon: '✕',
label: 'Stop stream',
meta: '',
hasChildren: false,
onSelect: () => {
peer.rtc?.removeStream(label);
this._view.popTo(4);
}
});
this._view.pushColumn('Quality', items);
}
// ---- Device helpers -------------------------------------------------
async _openCamera(peer) {
const stream = await MediaSources.camera().catch(e => { this._setStatus('error', e.message); return null; });
if (!stream) return;
this._ensureRTC(peer);
peer.rtc.addStream('camera', stream);
this._view.popTo(4);
this._pushActiveStreamsColumn(peer);
}
async _openCameraAndMic(peer) {
const stream = await MediaSources.cameraAndMic().catch(e => { this._setStatus('error', e.message); return null; });
if (!stream) return;
this._ensureRTC(peer);
peer.rtc.addStream('cam+mic', stream);
this._view.popTo(4);
this._pushActiveStreamsColumn(peer);
}
async _openMicrophone(peer) {
const stream = await MediaSources.microphone().catch(e => { this._setStatus('error', e.message); return null; });
if (!stream) return;
this._ensureRTC(peer);
peer.rtc.addStream('mic', stream);
this._view.popTo(4);
this._pushActiveStreamsColumn(peer);
}
async _openScreen(peer) {
const stream = await MediaSources.screen().catch(e => { this._setStatus('error', e.message); return null; });
if (!stream) return;
this._ensureRTC(peer);
peer.rtc.addStream('screen', stream);
this._view.popTo(4);
this._pushActiveStreamsColumn(peer);
}
_openFilePicker(peer) {
const input = document.createElement('input');
input.type = 'file';
input.addEventListener('change', async () => {
const file = input.files?.[0];
if (!file) return;
this._ensureRTC(peer);
this._setStatus('', `Sending ${file.name}`);
peer.rtc.files.on('progress', ({ sent, total }) => {
const pct = Math.round(sent / total * 100);
this._setStatus('', `${file.name}${pct}%`);
});
await peer.rtc.sendFile(file).catch(e => this._setStatus('error', e.message));
this._setStatus('online', `${file.name} sent`);
});
input.click();
}
// ---- RTCEngine management -------------------------------------------
// Ensure the peer has an RTCEngine connected (creates one if absent).
_ensureRTC(peer) {
if (peer.rtc.active) return;
// Determine politeness by lexicographic socket ID comparison.
const myId = this.mwse.me.socketId;
const polite = myId < peer.socketId;
peer.rtc.connect({ polite });
}
// ---- Helpers --------------------------------------------------------
_setStatus(cls, text) {
if (!this._statusEl) return;
this._statusEl.className = `mwse-studio__status${cls ? ` mwse-studio__status--${cls}` : ''}`;
this._statusEl.textContent = text;
}
_injectStyle() {
if (this._styleInjected) return;
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = new URL('./style.css', import.meta.url).href;
document.head.appendChild(link);
this._styleInjected = true;
}
}

255
sdk/studio/style.css Normal file
View File

@ -0,0 +1,255 @@
/* MWSE Studio — desktop-first Miller-column UI */
.mwse-studio {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: #1a1a1a;
color: #d4d4d4;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
font-size: 13px;
overflow: hidden;
user-select: none;
}
/* Top toolbar */
.mwse-studio__toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: #111;
border-bottom: 1px solid #333;
flex-shrink: 0;
}
.mwse-studio__title {
font-weight: 600;
color: #fff;
font-size: 12px;
letter-spacing: .04em;
text-transform: uppercase;
}
.mwse-studio__status {
margin-left: auto;
font-size: 11px;
color: #888;
}
.mwse-studio__status--online { color: #4caf50; }
.mwse-studio__status--error { color: #f44336; }
/* Column scroll area */
.mwse-studio__columns {
display: flex;
flex-direction: row;
flex: 1;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
scrollbar-color: #444 #1a1a1a;
}
.mwse-studio__columns::-webkit-scrollbar { height: 4px; }
.mwse-studio__columns::-webkit-scrollbar-track { background: #1a1a1a; }
.mwse-studio__columns::-webkit-scrollbar-thumb { background: #444; border-radius: 2px; }
/* Individual column */
.mwse-col {
min-width: 220px;
max-width: 260px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-right: 1px solid #2e2e2e;
background: #1e1e1e;
}
.mwse-col--active { background: #222; }
.mwse-col__header {
padding: 8px 12px 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: .06em;
text-transform: uppercase;
color: #777;
border-bottom: 1px solid #2a2a2a;
flex-shrink: 0;
}
/* Search box */
.mwse-col__search {
margin: 6px 8px;
padding: 4px 8px;
background: #2a2a2a;
border: 1px solid #383838;
border-radius: 4px;
color: #d4d4d4;
font-size: 12px;
outline: none;
flex-shrink: 0;
}
.mwse-col__search:focus { border-color: #0078d4; }
/* Item list */
.mwse-col__list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
scrollbar-width: thin;
scrollbar-color: #333 transparent;
}
.mwse-col__list::-webkit-scrollbar { width: 4px; }
.mwse-col__list::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
/* Single item */
.mwse-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
cursor: pointer;
border-radius: 0;
border-left: 2px solid transparent;
transition: background 80ms;
}
.mwse-item:hover { background: #2a2a2a; }
.mwse-item--active {
background: #0d3a5a !important;
border-left-color: #0078d4;
}
.mwse-item__icon {
font-size: 12px;
color: #555;
flex-shrink: 0;
width: 14px;
text-align: center;
}
.mwse-item--active .mwse-item__icon { color: #60cdff; }
.mwse-item__body { flex: 1; min-width: 0; }
.mwse-item__label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #d0d0d0;
font-size: 13px;
}
.mwse-item--active .mwse-item__label { color: #fff; }
.mwse-item__meta {
font-size: 11px;
color: #666;
margin-top: 1px;
}
.mwse-item--active .mwse-item__meta { color: #4d9fce; }
.mwse-item__arrow {
font-size: 10px;
color: #444;
flex-shrink: 0;
}
.mwse-item--active .mwse-item__arrow { color: #60cdff; }
/* Status badges */
.mwse-badge {
display: inline-block;
padding: 1px 5px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
letter-spacing: .03em;
}
.mwse-badge--live { background: #d32f2f; color: #fff; }
.mwse-badge--ok { background: #2e7d32; color: #fff; }
.mwse-badge--ws { background: #333; color: #aaa; }
.mwse-badge--p2p { background: #1565c0; color: #fff; }
/* Progress bar (file transfer) */
.mwse-progress {
margin: 8px 12px;
height: 4px;
background: #333;
border-radius: 2px;
}
.mwse-progress__bar {
height: 100%;
background: #0078d4;
border-radius: 2px;
transition: width 200ms;
}
/* Action buttons inside columns */
.mwse-col__actions {
padding: 8px 12px;
border-top: 1px solid #2a2a2a;
display: flex;
gap: 6px;
flex-shrink: 0;
}
.mwse-btn {
flex: 1;
padding: 5px 8px;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
color: #ccc;
font-size: 11px;
cursor: pointer;
text-align: center;
}
.mwse-btn:hover { background: #333; color: #fff; }
.mwse-btn--primary { background: #0d47a1; border-color: #1565c0; color: #fff; }
.mwse-btn--primary:hover { background: #1565c0; }
.mwse-btn--danger { background: #4a1a1a; border-color: #7b2020; color: #f88; }
.mwse-btn--danger:hover { background: #7b2020; }
/* Slider for quality controls */
.mwse-slider-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
}
.mwse-slider-row label {
font-size: 11px;
color: #888;
flex-shrink: 0;
width: 64px;
}
.mwse-slider-row input[type=range] {
flex: 1;
accent-color: #0078d4;
}
.mwse-slider-row span {
font-size: 11px;
color: #bbb;
width: 40px;
text-align: right;
}
/* Video preview thumbnail */
.mwse-preview {
width: 100%;
aspect-ratio: 16/9;
background: #111;
border-top: 1px solid #2a2a2a;
object-fit: contain;
}

91
sdk/webrtc/DataChannel.js Normal file
View File

@ -0,0 +1,91 @@
// Primary RTCDataChannel manager.
//
// - Messages queued while the channel is not yet open.
// - If the channel closes while the RTCPeerConnection is still 'connected',
// it is recreated automatically (brief-disconnect recovery).
// - Both sides can safely call initiate() — the one that creates the channel
// triggers onnegotiationneeded; the other side receives ondatachannel.
// Use { negotiated: true, id: 0 } if you want to skip the SDP dance.
import MWSEEventTarget from '../EventTarget.js';
export default class DataChannel extends MWSEEventTarget {
constructor(pc, label = 'mwse') {
super();
this._pc = pc;
this._label = label;
this._ch = null;
this._queue = [];
this._open = false;
// Accept channels offered by the remote side.
pc.on('datachannel', ch => {
if (ch.label === this._label) this._attach(ch);
});
}
// Create the channel on this side (makes us the "offering" side for the
// channel SDP line; triggers onnegotiationneeded).
initiate() {
if (this._ch?.readyState === 'open' || this._ch?.readyState === 'connecting') return;
const ch = this._pc.createDataChannel(this._label, { ordered: true });
this._attach(ch);
return ch;
}
// Send any JSON-serialisable value or an ArrayBuffer.
send(data) {
const wire = (typeof data === 'string' || data instanceof ArrayBuffer)
? data
: JSON.stringify(data);
if (this._open) {
this._ch.send(wire);
} else {
this._queue.push(wire);
}
}
get open() { return this._open; }
get state() { return this._ch?.readyState ?? 'closed'; }
close() {
this._ch?.close();
this._ch = null;
this._open = false;
}
// ---- Internal -------------------------------------------------------
_attach(ch) {
this._ch = ch;
ch.binaryType = 'arraybuffer';
ch.onopen = () => {
this._open = true;
this.emit('open');
for (const msg of this._queue) ch.send(msg);
this._queue = [];
};
ch.onmessage = ({ data }) => {
let parsed = data;
if (typeof data === 'string') {
try { parsed = JSON.parse(data); } catch (_) {}
}
this.emit('message', parsed);
};
ch.onclose = () => {
this._open = false;
this.emit('close');
// Auto-recreate the channel if the connection is still alive.
// (Channels can close without the full connection closing, e.g. on
// some mobile browsers when the page is backgrounded briefly.)
if (this._pc.connectionState === 'connected') {
setTimeout(() => this.initiate(), 800);
}
};
ch.onerror = err => this.emit('error', err);
}
}

139
sdk/webrtc/FileSender.js Normal file
View File

@ -0,0 +1,139 @@
// P2P file transfer over dedicated RTCDataChannels.
//
// The file is split into PART_SIZE (10 MB) partitions; up to 5 parallel
// channels carry one partition each so large files don't stall.
// Protocol per channel:
// sender → JSON { size, idx } — announce partition size + index
// receiver → 'READY' — ready to receive chunks
// sender → ArrayBuffer chunks — CHUNK_SIZE bytes each
// receiver → 'ACK' — all bytes received, send next part
// sender → close() — no more partitions for this channel
//
// Emits: 'progress'({ sent, total }), 'complete', 'error'(err)
import MWSEEventTarget from '../EventTarget.js';
const PART_SIZE = 10 * 1024 * 1024; // 10 MB per partition
const CHUNK_SIZE = 16 * 1024; // 16 KB chunk size
export default class FileSender extends MWSEEventTarget {
constructor(pc) {
super();
this._pc = pc; // PeerConnection instance
}
// Set up a receiver. Must be called before the sender opens channels.
// onFile(blob) is called when all partitions are reassembled.
receive(partCount, onFile) {
const parts = new Array(partCount);
let remaining = partCount;
this._pc.on('datachannel', ch => {
if (!ch.label.startsWith('mwse/file/')) return;
_receivePartition(ch, parts, () => {
if (--remaining === 0) onFile(new Blob(parts));
});
});
}
// Send a File or Blob. Returns a Promise that resolves when done.
async send(file) {
const buffer = await file.arrayBuffer();
const partCount = Math.ceil(buffer.byteLength / PART_SIZE);
const chanCount = Math.min(5, partCount);
let sent = 0;
let pending = chanCount;
let partIdx = 0;
const nextPart = () => {
if (partIdx >= partCount) return null;
const start = partIdx * PART_SIZE;
const slice = buffer.slice(start, Math.min(start + PART_SIZE, buffer.byteLength));
return { data: slice, idx: partIdx++ };
};
return new Promise((resolve, reject) => {
for (let i = 0; i < chanCount; i++) {
const ch = this._pc.createDataChannel(`mwse/file/${i}`);
ch.binaryType = 'arraybuffer';
ch.onopen = () => {
const part = nextPart();
if (!part) { ch.close(); if (--pending === 0) { this.emit('complete'); resolve(); } return; }
_sendPartition(ch, part, nextPart,
bytes => { sent += bytes; this.emit('progress', { sent, total: buffer.byteLength }); },
() => { ch.close(); if (--pending === 0) { this.emit('complete'); resolve(); } },
err => reject(err)
);
};
ch.onerror = reject;
}
});
}
}
// ---- Private helpers --------------------------------------------------------
function _receivePartition(ch, parts, onPartDone) {
let metaSize = 0;
let metaIdx = 0;
let chunks = [];
let received = 0;
ch.onmessage = ({ data }) => {
if (typeof data === 'string') {
// First message per partition: metadata
({ size: metaSize, idx: metaIdx } = JSON.parse(data));
ch.send('READY');
} else {
chunks.push(data);
received += data.byteLength;
if (received >= metaSize) {
parts[metaIdx] = new Blob(chunks);
chunks = [];
received = 0;
ch.send('ACK');
onPartDone();
}
}
};
}
function _sendPartition(ch, part, nextPart, onProgress, onDone, onError) {
let offset = 0;
// Announce the partition to the receiver.
ch.send(JSON.stringify({ size: part.data.byteLength, idx: part.idx }));
const pump = () => {
while (offset < part.data.byteLength) {
// Back off when the buffer is getting full.
if (ch.bufferedAmount > CHUNK_SIZE * 8) return;
const end = Math.min(offset + CHUNK_SIZE, part.data.byteLength);
const chunk = part.data.slice(offset, end);
ch.send(chunk);
onProgress(chunk.byteLength);
offset += chunk.byteLength;
}
};
ch.bufferedAmountLowThreshold = CHUNK_SIZE;
ch.onbufferedamountlow = pump;
ch.onmessage = ({ data }) => {
if (data === 'READY') {
pump();
} else if (data === 'ACK') {
const next = nextPart();
if (next) {
part = next;
offset = 0;
ch.send(JSON.stringify({ size: part.data.byteLength, idx: part.idx }));
} else {
onDone();
}
}
};
ch.onerror = onError;
}

View File

@ -0,0 +1,98 @@
// Static factory methods for common media sources.
//
// All methods return a MediaStream so they can be passed directly to
// StreamManager.addStream(label, stream).
export const MediaSources = {
// Single camera (video only by default).
async camera(constraints = {}) {
return navigator.mediaDevices.getUserMedia({
video: constraints.video ?? true,
audio: constraints.audio ?? false
});
},
// Microphone (audio only).
async microphone(constraints = {}) {
return navigator.mediaDevices.getUserMedia({
audio: constraints.audio ?? true,
video: false
});
},
// Camera + microphone together.
async cameraAndMic(constraints = {}) {
return navigator.mediaDevices.getUserMedia({
video: constraints.video ?? true,
audio: constraints.audio ?? true
});
},
// Screen / window / tab sharing via getDisplayMedia.
async screen(constraints = {}) {
return navigator.mediaDevices.getDisplayMedia({
video: constraints.video ?? { cursor: 'always' },
audio: constraints.audio ?? false
});
},
// Enumerate available media devices.
async devices() {
const all = await navigator.mediaDevices.enumerateDevices();
return {
cameras: all.filter(d => d.kind === 'videoinput'),
microphones: all.filter(d => d.kind === 'audioinput'),
speakers: all.filter(d => d.kind === 'audiooutput')
};
},
// Play a pre-decoded AudioBuffer as a continuous MediaStream.
// Useful for replacing the live microphone with an audio file.
// Returns the MediaStream; the AudioContext is attached as ._ctx for cleanup.
fromAudioBuffer(audioBuffer, { loop = false } = {}) {
const ctx = new AudioContext();
const src = ctx.createBufferSource();
src.buffer = audioBuffer;
src.loop = loop;
const dest = ctx.createMediaStreamDestination();
src.connect(dest);
src.start();
const stream = dest.stream;
stream._ctx = ctx; // caller can close() to free resources
stream._src = src;
return stream;
},
// Capture a <canvas> or OffscreenCanvas as a video MediaStream.
fromCanvas(canvas, fps = 30) {
if (typeof canvas.captureStream === 'function') {
return canvas.captureStream(fps);
}
if (typeof canvas.transferControlToOffscreen === 'function') {
throw new Error(
'fromCanvas: pass the OffscreenCanvas, not the original canvas, ' +
'or use fromOffscreenCanvas()'
);
}
throw new Error('fromCanvas: captureStream() not supported on this element');
},
// Create an AudioContext-based mixing bus and capture its output as a
// MediaStream. Useful for compositing multiple audio sources before sending.
// Returns { ctx, dest, stream } — add nodes to ctx and connect to dest.
createAudioMix() {
const ctx = new AudioContext();
const dest = ctx.createMediaStreamDestination();
return { ctx, dest, stream: dest.stream };
},
// Fetch and decode an audio file URL into an AudioBuffer.
async loadAudioFile(url) {
const ctx = new AudioContext();
const resp = await fetch(url);
const ab = await resp.arrayBuffer();
const buf = await ctx.decodeAudioData(ab);
await ctx.close();
return buf; // pass to fromAudioBuffer()
}
};

117
sdk/webrtc/Negotiator.js Normal file
View File

@ -0,0 +1,117 @@
// Perfect-negotiation (RFC 8829 §5.2) for WebRTC offer/answer exchange.
//
// Each peer is either "polite" or "impolite". When both peers generate an
// offer simultaneously (collision), the polite peer rolls back and accepts
// the impolite peer's offer. Politeness is decided by the caller — typically
// by comparing the two socket IDs so exactly one side is polite per pair.
//
// ICE candidates are queued until the remote description is in place so they
// are never applied to an uninitialised peer connection.
export default class Negotiator {
constructor(pc, { polite, onSend }) {
this._pc = pc; // PeerConnection wrapper
this.polite = polite;
this._send = onSend; // fn(signalingObject) → relays to remote peer
this._pendingCandidates = [];
this._settingRemote = false;
this._makingOffer = false;
this._ignoreOffer = false;
}
// Attach to PeerConnection events. Returns `this` for chaining.
attach() {
this._pc.on('negotiation-needed', () => this._offer());
this._pc.on('ice-candidate', cand => {
// null candidate = end-of-candidates; always forward.
this._send({ type: 'ice', candidate: cand });
});
return this;
}
// Handle an incoming signaling message from the remote peer.
// msg.type: 'offer' | 'answer' | 'ice'
async receive(msg) {
try {
if (msg.type === 'offer') await this._onOffer(msg.sdp);
else if (msg.type === 'answer') await this._onAnswer(msg.sdp);
else if (msg.type === 'ice') await this._onICE(msg.candidate);
} catch (err) {
// Surface on caller — the engine will re-emit as 'error'.
throw err;
}
}
// ---- Internal -------------------------------------------------------
async _offer() {
if (this._makingOffer) return;
this._makingOffer = true;
try {
// Modern browsers support the implicit description form.
await this._pc.setLocalDescription();
this._send({ type: 'offer', sdp: this._pc.localDescription.sdp });
} catch (err) {
// setLocalDescription can fail if the signaling state rolled back
// under us; safe to ignore since the other side's offer will win.
if (this.polite) return;
throw err;
} finally {
this._makingOffer = false;
}
}
async _onOffer(sdp) {
const collision = this._makingOffer || this._pc.signalingState !== 'stable';
this._ignoreOffer = !this.polite && collision;
if (this._ignoreOffer) return;
this._settingRemote = true;
try {
// Polite peer: rollback own offer, accept incoming.
if (this._pc.signalingState !== 'stable') {
await Promise.all([
this._pc.setLocalDescription({ type: 'rollback' }),
this._pc.setRemoteDescription({ type: 'offer', sdp })
]);
} else {
await this._pc.setRemoteDescription({ type: 'offer', sdp });
}
} finally {
this._settingRemote = false;
}
await this._pc.setLocalDescription(); // implicit answer
this._send({ type: 'answer', sdp: this._pc.localDescription.sdp });
await this._flushCandidates();
}
async _onAnswer(sdp) {
if (this._pc.signalingState === 'stable') return; // already settled
await this._pc.setRemoteDescription({ type: 'answer', sdp });
await this._flushCandidates();
}
async _onICE(candidate) {
if (!candidate) return; // end-of-candidates
const ready = this._pc.remoteDescription && !this._settingRemote;
if (!ready) {
this._pendingCandidates.push(candidate);
return;
}
try {
await this._pc.addIceCandidate(candidate);
} catch (err) {
// Silently drop if we're ignoring an offer collision on the
// impolite side — those candidates belong to the rolled-back offer.
if (!this._ignoreOffer) throw err;
}
}
async _flushCandidates() {
for (const c of this._pendingCandidates) {
await this._pc.addIceCandidate(c).catch(() => {});
}
this._pendingCandidates = [];
}
}

View File

@ -0,0 +1,122 @@
// RTCPeerConnection wrapper with comprehensive state monitoring.
// Fixes the original SDK's missed-disconnect bug by subscribing to BOTH
// connectionstatechange (the modern event) and iceconnectionstatechange
// (the legacy fallback) and never letting either go unobserved.
//
// Emits: 'connected', 'disconnected', 'failed',
// 'state-change'(state), 'ice-state-change'(state),
// 'gathering-change'(state), 'signaling-change'(state),
// 'ice-candidate'(candidate|null), 'track'(track, streams),
// 'datachannel'(channel), 'negotiation-needed'
import MWSEEventTarget from '../EventTarget.js';
export default class PeerConnection extends MWSEEventTarget {
constructor(config) {
super();
this._pc = new RTCPeerConnection(config);
this._wire();
}
// ---- RTCPeerConnection passthrough ----------------------------------
get pc() { return this._pc; }
get connectionState() { return this._pc.connectionState; }
get iceConnectionState() { return this._pc.iceConnectionState; }
get signalingState() { return this._pc.signalingState; }
get iceGatheringState() { return this._pc.iceGatheringState; }
get localDescription() { return this._pc.localDescription; }
get remoteDescription() { return this._pc.remoteDescription; }
addTrack(track, ...streams) { return this._pc.addTrack(track, ...streams); }
removeTrack(sender) { this._pc.removeTrack(sender); }
async createOffer(opts) { return this._pc.createOffer(opts); }
async createAnswer() { return this._pc.createAnswer(); }
async setLocalDescription(d) { await this._pc.setLocalDescription(d); }
async setRemoteDescription(d){ await this._pc.setRemoteDescription(d); }
async addIceCandidate(c) {
if (!c) return;
await this._pc.addIceCandidate(c);
}
createDataChannel(label, opts) {
return this._pc.createDataChannel(label, opts);
}
async restartIce() {
if (typeof this._pc.restartIce === 'function') {
this._pc.restartIce();
} else {
const offer = await this._pc.createOffer({ iceRestart: true });
await this._pc.setLocalDescription(offer);
}
}
close() {
const pc = this._pc;
pc.onconnectionstatechange = null;
pc.oniceconnectionstatechange = null;
pc.onicegatheringstatechange = null;
pc.onsignalingstatechange = null;
pc.onicecandidate = null;
pc.ontrack = null;
pc.ondatachannel = null;
pc.onnegotiationneeded = null;
pc.close();
}
// ---- Internal event wiring ------------------------------------------
_wire() {
const pc = this._pc;
// connectionstatechange is the primary signal on modern browsers.
// It reliably reports 'failed' and 'closed' — states that
// iceconnectionstatechange can miss under certain NAT conditions.
pc.onconnectionstatechange = () => {
const s = pc.connectionState;
this.emit('state-change', s);
if (s === 'connected') this.emit('connected');
if (s === 'disconnected' || s === 'closed') this.emit('disconnected');
if (s === 'failed') this.emit('failed');
};
// Keep iceconnectionstatechange as a fallback for environments where
// connectionstatechange never fires (some mobile browsers, old Chrome).
pc.oniceconnectionstatechange = () => {
const s = pc.iceConnectionState;
this.emit('ice-state-change', s);
// Only act if connectionState hasn't given us a verdict yet.
if (pc.connectionState === 'new' || pc.connectionState === 'connecting') {
if (s === 'connected' || s === 'completed') this.emit('connected');
if (s === 'disconnected') this.emit('disconnected');
if (s === 'failed') this.emit('failed');
}
};
pc.onicegatheringstatechange = () => {
this.emit('gathering-change', pc.iceGatheringState);
};
pc.onsignalingstatechange = () => {
this.emit('signaling-change', pc.signalingState);
};
pc.onicecandidate = ({ candidate }) => {
this.emit('ice-candidate', candidate);
};
pc.ontrack = ({ track, streams }) => {
this.emit('track', track, streams);
};
pc.ondatachannel = ({ channel }) => {
this.emit('datachannel', channel);
};
pc.onnegotiationneeded = () => {
this.emit('negotiation-needed');
};
}
}

112
sdk/webrtc/StreamManager.js Normal file
View File

@ -0,0 +1,112 @@
// Manages named media sources on an RTCPeerConnection.
//
// Each "source" has a label (e.g. 'camera', 'screen', 'mic') and maps to
// one MediaStream plus the RTCRtpSenders the peer connection created for it.
//
// Usage:
// const sm = new StreamManager(peerConnection);
// sm.addStream('camera', await navigator.mediaDevices.getUserMedia({ video: true }));
// await sm.replaceTrack('camera', newVideoTrack); // hot-swap, no renegotiation
// await sm.setEncodings('camera', 'video', { maxBitrate: 2_000_000 });
// sm.removeStream('camera'); // stops tracks + renegotiates
export default class StreamManager {
constructor(pc) {
this._pc = pc; // PeerConnection instance
this._sources = new Map(); // label → { stream, senders }
}
// Add all tracks from a MediaStream. Triggers renegotiation.
addStream(label, stream) {
if (this._sources.has(label)) {
throw new Error(`StreamManager: "${label}" already exists — call removeStream first`);
}
const senders = [];
for (const track of stream.getTracks()) {
const sender = this._pc.addTrack(track, stream);
senders.push(sender);
}
this._sources.set(label, { stream, senders });
return senders;
}
// Swap one track inside an existing source by kind ('video' | 'audio').
// Uses RTCRtpSender.replaceTrack — no renegotiation needed.
async replaceTrack(label, newTrack) {
const src = this._sources.get(label);
if (!src) throw new Error(`StreamManager: source "${label}" not found`);
for (const sender of src.senders) {
if (sender.track?.kind === newTrack.kind) {
await sender.replaceTrack(newTrack);
return sender;
}
}
throw new Error(`StreamManager: no ${newTrack.kind} sender in "${label}"`);
}
// Remove a source: stops all its tracks and calls removeTrack (renegotiates).
removeStream(label) {
const src = this._sources.get(label);
if (!src) return;
for (const sender of src.senders) {
try { this._pc.removeTrack(sender); } catch (_) {}
}
for (const track of src.stream.getTracks()) {
track.stop();
}
this._sources.delete(label);
}
// Adjust encoding parameters (bitrate, framerate, resolution scale) for a
// specific source+kind. RTCRtpSender.setParameters is used — no renegotiation.
//
// params: { maxBitrate, maxFramerate, scaleResolutionDownBy, ... }
async setEncodings(label, kind, params) {
const src = this._sources.get(label);
if (!src) return;
for (const sender of src.senders) {
if (sender.track?.kind !== kind) continue;
const p = sender.getParameters();
if (!p.encodings?.length) p.encodings = [{}];
p.encodings = p.encodings.map(enc => ({ ...enc, ...params }));
await sender.setParameters(p);
return;
}
}
// Mute/unmute a track by label+kind (local-only, no signaling).
setEnabled(label, kind, enabled) {
const src = this._sources.get(label);
if (!src) return;
for (const track of src.stream.getTracks()) {
if (track.kind === kind) track.enabled = enabled;
}
}
// Return a snapshot of all managed sources.
list() {
const out = [];
for (const [label, { stream, senders }] of this._sources) {
out.push({
label,
tracks: stream.getTracks().map(t => ({
id: t.id,
kind: t.kind,
enabled: t.enabled,
label: t.label
})),
senders: senders.length
});
}
return out;
}
has(label) { return this._sources.has(label); }
// Get the MediaStream for a label.
stream(label) { return this._sources.get(label)?.stream ?? null; }
// Stop all sources and clear the map.
destroy() {
for (const [label] of this._sources) this.removeStream(label);
}
}

223
sdk/webrtc/index.js Normal file
View File

@ -0,0 +1,223 @@
// RTCEngine — the main WebRTC entry point for the MWSE SDK.
//
// Drop-in replacement for the old WebRTC.js stub. Peer.js still imports
// from WebRTC.js which re-exports this class, so no change is needed there.
//
// Architecture:
// RTCEngine — coordinates all sub-systems, surfaces public API
// ├── PeerConnection — RTCPeerConnection wrapper with full event coverage
// ├── Negotiator — perfect-negotiation (offer/answer/ICE, no collisions)
// ├── StreamManager — named media sources (addStream/replaceTrack/setEncodings)
// ├── DataChannel — primary data channel with auto-reconnect + message queue
// └── FileSender — multi-channel parallel file transfer
//
// Signaling relay:
// Incoming → Peer.js emits 'input' on the engine → receive(msg) → Negotiator
// Outgoing → Negotiator emits 'output' on the engine → Peer.js relays to server
//
// ICE restart: automatic exponential backoff on 'failed' (1 s → 2 s → 4 s).
import MWSEEventTarget from '../EventTarget.js';
import PeerConnection from './PeerConnection.js';
import Negotiator from './Negotiator.js';
import StreamManager from './StreamManager.js';
import DataChannel from './DataChannel.js';
import FileSender from './FileSender.js';
export { MediaSources } from './MediaSources.js';
export { default as StreamManager } from './StreamManager.js';
export { default as FileSender } from './FileSender.js';
const DEFAULT_ICE = [{ urls: 'stun:stun.l.google.com:19302' }];
const RESTART_DELAYS = [1000, 2000, 4000]; // ms, exponential backoff
export default class RTCEngine extends MWSEEventTarget {
constructor(config) {
super();
this._config = config;
// Public state — Peer.js reads these.
this.active = false;
this.peer = null; // set by Peer.js
this.connectionStatus = 'new';
this.iceStatus = 'new';
this.gatheringStatus = 'new';
this.signalingStatus = '';
// Sub-system references (null until connect() is called).
this._pc = null;
this._neg = null;
this._streams = null;
this._data = null;
this._files = null;
this._restartN = 0;
// Peer.js legacy: `this.rtc.channel` for direct DataChannel access.
this.channel = null;
// Bridge the 'input' event (from Peer.js) → receive().
this.on('input', msg => this.receive(msg));
}
// ---- Connection lifecycle -------------------------------------------
// Connect to the remote peer. Call after the pair has been established.
//
// polite: true → this side yields on offer collision (accept incoming offer)
// polite: false → this side wins on collision (the impolite peer)
//
// Typically set polite = (mySocketId < peerSocketId) so exactly one side
// is polite per pair without any extra signaling.
connect({ polite = false, iceServers } = {}) {
if (this._pc) return this; // already connected
const cfg = this._config ?? { iceServers: iceServers ?? DEFAULT_ICE };
this._pc = new PeerConnection(cfg);
this._neg = new Negotiator(this._pc, {
polite,
onSend: msg => this.emit('output', msg)
}).attach();
this._streams = new StreamManager(this._pc);
this._data = new DataChannel(this._pc, 'mwse');
this.channel = this._data;
this._files = new FileSender(this._pc);
// The side with polite=false initiates the primary data channel
// (creates the SDP line), which triggers onnegotiationneeded and
// kicks off the offer/answer exchange.
if (!polite) {
this._data.initiate();
}
// State events.
this._pc.on('state-change', s => {
this.connectionStatus = s;
this.emit('state-change', s);
});
this._pc.on('connected', () => {
this.active = true;
this.iceStatus = 'connected';
this._restartN = 0;
this.emit('connected');
});
this._pc.on('disconnected', () => {
this.active = false;
this.emit('disconnected');
});
this._pc.on('failed', () => {
this.active = false;
this.emit('failed');
this._scheduleRestart();
});
this._pc.on('ice-state-change', s => { this.iceStatus = s; });
this._pc.on('gathering-change', s => { this.gatheringStatus = s; });
this._pc.on('signaling-change', s => { this.signalingStatus = s; });
this._pc.on('track', (track, streams) => this.emit('track', track, streams));
this._data.on('open', () => this.emit('channel-open'));
this._data.on('message', msg => this.emit('message', msg));
this._data.on('close', () => this.emit('channel-close'));
return this;
}
// ---- Signaling ------------------------------------------------------
// Handle an incoming signaling payload (relayed as ':rtcpack:' by Peer.js).
async receive(msg) {
if (!this._neg) return;
try {
await this._neg.receive(msg);
} catch (err) {
this.emit('error', err);
}
}
// Compatibility shim: Peer.js may call rtc.send() for outgoing signaling.
send(data) { this.emit('output', data); }
// ---- Media (StreamManager proxy) ------------------------------------
get streams() { return this._streams; }
// Add a named MediaStream. Triggers renegotiation.
addStream(label, stream) {
if (!this._streams) throw new Error('RTCEngine: call connect() first');
return this._streams.addStream(label, stream);
}
// Swap one track inside a named source by kind ('video'|'audio').
// No renegotiation — instant hot-swap.
async replaceTrack(label, newTrack) {
return this._streams?.replaceTrack(label, newTrack);
}
// Remove a named source (stops tracks and renegotiates).
removeStream(label) {
this._streams?.removeStream(label);
}
// Adjust bitrate / framerate / scale for an encoding on a named source.
async setEncodings(label, kind, params) {
return this._streams?.setEncodings(label, kind, params);
}
// Mute/unmute a track by label and kind.
setEnabled(label, kind, enabled) {
this._streams?.setEnabled(label, kind, enabled);
}
// ---- DataChannel ----------------------------------------------------
sendMessage(data) { this._data?.send(data); }
// ---- File transfer (FileSender proxy) -------------------------------
get files() { return this._files; }
// Send a File/Blob to the remote peer over parallel DataChannels.
async sendFile(file) {
if (!this._files) throw new Error('RTCEngine: call connect() first');
return this._files.send(file);
}
// Prepare to receive a file split into partCount partitions.
// onFile(blob) is called when fully assembled.
receiveFile(partCount, onFile) {
this._files?.receive(partCount, onFile);
}
// ---- Lifecycle ------------------------------------------------------
destroy() {
this._streams?.destroy();
this._data?.close();
this._pc?.close();
this._pc = null;
this._neg = null;
this._streams = null;
this._data = null;
this._files = null;
this.channel = null;
this.active = false;
this.connectionStatus = 'closed';
this.emit('disconnected');
}
// ---- ICE restart ----------------------------------------------------
_scheduleRestart() {
const delay = RESTART_DELAYS[Math.min(this._restartN, RESTART_DELAYS.length - 1)];
this._restartN++;
setTimeout(async () => {
if (!this._pc || this.connectionStatus === 'closed') return;
try {
await this._pc.restartIce();
} catch (err) {
this.emit('error', err);
}
}, delay);
}
}