diff --git a/static/protocol.js b/static/protocol.js new file mode 100644 index 0000000..962b187 --- /dev/null +++ b/static/protocol.js @@ -0,0 +1,918 @@ +// Copyright (c) 2020 by Juliusz Chroboczek. + +// This is not open source software. Copy it, and I'll break into your +// house and tell your three year-old that Santa doesn't exist. + +'use strict'; + +/** + * toHex formats an array as a hexadecimal string. + * @returns {string} + */ +function toHex(array) { + let a = new Uint8Array(array); + function hex(x) { + let h = x.toString(16); + if(h.length < 2) + h = '0' + h; + return h; + } + return a.reduce((x, y) => x + hex(y), ''); +} + +/** randomid returns a random string of 32 hex digits (16 bytes). + * @returns {string} + */ +function randomid() { + let a = new Uint8Array(16); + crypto.getRandomValues(a); + return toHex(a); +} + +/** + * ServerConnection encapsulates a websocket connection to the server and + * all the associated streams. + * @constructor + */ +function ServerConnection() { + /** + * The id of this connection. + * @type {string} + */ + this.id = randomid(); + /** + * The group that we have joined, or nil if we haven't joined yet. + * @type {string} + */ + this.group = null; + /** + * The underlying websocket. + * @type {WebSocket} + */ + this.socket = null; + /** + * The set of all up streams, indexed by their id. + * @type {Object.} + */ + this.up = {}; + /** + * The set of all down streams, indexed by their id. + * @type {Object.} + */ + this.down = {}; + /** + * The ICE configuration used by all associated streams. + * @type {Array.} + */ + this.iceServers = []; + /** + * The permissions granted to this connection. + * @type {Object.} + */ + this.permissions = {}; + + /* Callbacks */ + + /** + * onconnected is called when the connection has been established + * @type{function(): any} + */ + this.onconnected = null; + /** + * onclose is called when the connection is closed + * @type{function(number, string): any} + */ + this.onclose = null; + /** + * onuser is called whenever a user is added or removed from the group + * @type{function(string, string, string): any} + */ + this.onuser = null; + /** + * onpermissions is called whenever the current user's permissions change + * @type{function(Object.): any} + */ + this.onpermissions = null; + /** + * ondownstream is called whenever a new down stream is added. It + * should set up the stream's callbacks; actually setting up the UI + * should be done in the stream's ondowntrack callback. + * @type{function(Stream): any} + */ + this.ondownstream = null; + /** + * onchat is called whenever a new chat message is received. + * @type {function(string, string, string, string): any} + */ + this.onchat = null; + /** + * onclearchat is called whenever the server requests that the chat + * be cleared. + * @type{function(): any} + */ + this.onclearchat = null; + /** + * onusermessage is called when the server sends an error or warning + * message that should be displayed to the user. + * @type{function(string, string): any} + */ + this.onusermessage = null; +} + +/** + * @typedef {Object} message + * @property {string} type + * @property {string} [kind] + * @property {string} [id] + * @property {string} [username] + * @property {string} [password] + * @property {Object.} [permissions] + * @property {string} [group] + * @property {string} [value] + * @property {RTCSessionDescriptionInit} [offer] + * @property {RTCSessionDescriptionInit} [answer] + * @property {RTCIceCandidate} [candidate] + * @property {Object.} [labels] + * @property {Object.} [request] + */ + +/** + * close forcibly closes a server connection. The onclose callback will + * be called when the connection is effectively closed. + */ +ServerConnection.prototype.close = function() { + this.socket && this.socket.close(1000, 'Close requested by client'); + this.socket = null; +} + +/** + * send sends a message to the server. + * @param {message} m - the message to send. + */ +ServerConnection.prototype.send = function(m) { + if(this.socket.readyState !== this.socket.OPEN) { + // send on a closed connection doesn't throw + throw(new Error('Connection is not open')); + } + return this.socket.send(JSON.stringify(m)); +} + +/** getIceServers fetches an ICE configuration from the server and + * populates the iceServers field of a ServerConnection. It is called + * lazily by connect. + * + * @returns {Promise>} + */ +ServerConnection.prototype.getIceServers = async function() { + let r = await fetch('/ice-servers.json'); + if(!r.ok) + throw new Error("Couldn't fetch ICE servers: " + + r.status + ' ' + r.statusText); + let servers = await r.json(); + if(!(servers instanceof Array)) + throw new Error("couldn't parse ICE servers"); + this.iceServers = servers; + return servers; +} + +/** + * Connect connects to the server. + * + * @param {string} url - The URL to connect to. + * @returns {Promise} + */ +ServerConnection.prototype.connect = function(url) { + let sc = this; + if(sc.socket) { + sc.socket.close(1000, 'Reconnecting'); + sc.socket = null; + } + + if(!sc.iceServers) { + try { + sc.getIceServers(); + } catch(e) { + console.error(e); + } + } + + try { + sc.socket = new WebSocket( + `ws${location.protocol === 'https:' ? 's' : ''}://${location.host}/ws`, + ); + } catch(e) { + return Promise.reject(e); + } + + return new Promise((resolve, reject) => { + this.socket.onerror = function(e) { + reject(e); + }; + this.socket.onopen = function(e) { + if(sc.onconnected) + sc.onconnected.call(sc); + resolve(sc); + }; + this.socket.onclose = function(e) { + sc.permissions = {}; + if(sc.onpermissions) + sc.onpermissions.call(sc, {}); + for(let id in sc.down) { + let c = sc.down[id]; + delete(sc.down[id]); + c.close(false); + if(c.onclose) + c.onclose.call(c); + } + if(sc.onclose) + sc.onclose.call(sc, e.code, e.reason); + reject(new Error('websocket close ' + e.code + ' ' + e.reason)); + }; + this.socket.onmessage = function(e) { + let m = JSON.parse(e.data); + switch(m.type) { + case 'offer': + sc.gotOffer(m.id, m.labels, m.offer, m.kind === 'renegotiate'); + break; + case 'answer': + sc.gotAnswer(m.id, m.answer); + break; + case 'renegotiate': + sc.gotRenegotiate(m.id) + break; + case 'close': + sc.gotClose(m.id); + break; + case 'abort': + sc.gotAbort(m.id); + break; + case 'ice': + sc.gotICE(m.id, m.candidate); + break; + case 'label': + sc.gotLabel(m.id, m.value); + break; + case 'permissions': + sc.permissions = m.permissions; + if(sc.onpermissions) + sc.onpermissions.call(sc, m.permissions); + break; + case 'user': + if(sc.onuser) + sc.onuser.call(sc, m.id, m.kind, m.username); + break; + case 'chat': + if(sc.onchat) + sc.onchat.call(sc, m.id, m.username, m.kind, m.value); + break; + case 'clearchat': + if(sc.onclearchat) + sc.onclearchat.call(sc); + break; + case 'ping': + sc.send({ + type: 'pong', + }); + break; + case 'pong': + /* nothing */ + break; + case 'usermessage': + if(sc.onusermessage) + sc.onusermessage.call(sc, m.kind, m.value) + break; + default: + console.warn('Unexpected server message', m.type); + return; + } + }; + }); +} + +/** + * login authenticates with the server. + * + * @param {string} username + * @param {string} password + */ +ServerConnection.prototype.login = function(username, password) { + this.send({ + type: 'login', + id: this.id, + username: username, + password: password, + }) +} + +/** + * join joins a group. + * + * @param {string} group - The name of the group to join. + */ +ServerConnection.prototype.join = function(group) { + this.send({ + type: 'join', + group: group, + }) +} + +/** + * request sets the list of requested media types. + * + * @param {string} what - One of "audio", "screenshare" or "everything". + */ +ServerConnection.prototype.request = function(what) { + /** @type {Object.} */ + let request = {}; + switch(what) { + case 'audio': + request = {audio: true}; + break; + case 'screenshare': + request = {audio: true, screenshare: true}; + break; + case 'everything': + request = {audio: true, screenshare: true, video: true}; + break; + default: + console.error(`Uknown value ${what} in sendRequest`); + break; + } + + this.send({ + type: 'request', + request: request, + }); +} + +/** + * newUpStream requests the creation of a new up stream. + * + * @param {string} id - The id of the stream to create (optional). + * @returns {Stream} + */ +ServerConnection.prototype.newUpStream = function(id) { + let sc = this; + if(!id) { + id = randomid(); + if(sc.up[id]) + throw new Error('Eek!'); + } + let pc = new RTCPeerConnection({ + iceServers: sc.iceServers, + }); + if(!pc) + throw new Error("Couldn't create peer connection"); + if(sc.up[id]) { + sc.up[id].close(false); + } + let c = new Stream(this, id, pc); + sc.up[id] = c; + + pc.onnegotiationneeded = async e => { + await c.negotiate(); + } + + pc.onicecandidate = e => { + if(!e.candidate) + return; + sc.send({type: 'ice', + id: id, + candidate: e.candidate, + }); + }; + + pc.oniceconnectionstatechange = e => { + if(c.onstatus) + c.onstatus.call(c, pc.iceConnectionState); + if(pc.iceConnectionState === 'failed') { + try { + /** @ts-ignore */ + pc.restartIce(); + } catch(e) { + console.warn(e); + } + } + } + + pc.ontrack = console.error; + + return c; +} + +/** + * chat sends a chat message to the server. The server will normally echo + * the message back to the client. + * + * @param {string} username - The username of the sending user. + * @param {string} kind - The kind of message, either "" or "me". + * @param {string} message - The text of the message. + */ +ServerConnection.prototype.chat = function(username, kind, message) { + this.send({ + type: 'chat', + id: this.id, + username: username, + kind: kind, + value: message, + }); +} + +/** + * groupAction sends a request to act on the current group. + * + * @param {string} kind - One of "clearchat", "lock", "unlock", "record or + * "unrecord". + */ +ServerConnection.prototype.groupAction = function(kind) { + this.send({ + type: 'groupaction', + kind: kind, + }); +} + +/** + * userAction sends a request to act on a user. + * + * @param {string} kind - One of "op", "unop", "kick", "present", "unpresent". + */ +ServerConnection.prototype.userAction = function(kind, id) { + this.send({ + type: 'useraction', + kind: kind, + id: id, + }); +} + +/** + * Called when we receive an offer from the server. Don't call this. + * + * @param {string} id + * @param labels + * @param {RTCSessionDescriptionInit} offer + * @param {boolean} renegotiate + */ +ServerConnection.prototype.gotOffer = async function(id, labels, offer, renegotiate) { + let sc = this; + let c = sc.down[id]; + if(c && !renegotiate) { + // SDP is rather inflexible as to what can be renegotiated. + // Unless the server indicates that this is a renegotiation with + // all parameters unchanged, tear down the existing connection. + delete(sc.down[id]) + c.close(false); + c = null; + } + + if(!c) { + let pc = new RTCPeerConnection({ + iceServers: this.iceServers, + }); + c = new Stream(this, id, pc); + sc.down[id] = c; + + c.pc.onicecandidate = function(e) { + if(!e.candidate) + return; + sc.send({type: 'ice', + id: id, + candidate: e.candidate, + }); + }; + + pc.oniceconnectionstatechange = e => { + if(c.onstatus) + c.onstatus.call(c, pc.iceConnectionState); + if(pc.iceConnectionState === 'failed') { + sc.send({type: 'renegotiate', + id: id, + }); + } + } + + c.pc.ontrack = function(e) { + let label = e.transceiver && c.labelsByMid[e.transceiver.mid]; + if(label) { + c.labels[e.track.id] = label; + } else { + console.warn("Couldn't find label for track"); + } + if(c.stream !== e.streams[0]) { + c.stream = e.streams[0]; + let label = + e.transceiver && c.labelsByMid[e.transceiver.mid]; + c.labels[e.track.id] = label; + if(c.ondowntrack) { + c.ondowntrack.call( + c, e.track, e.transceiver, label, e.streams[0], + ); + } + if(c.onlabel) { + c.onlabel.call(c, label); + } + } + }; + } + + c.labelsByMid = labels; + + if(sc.ondownstream) + sc.ondownstream.call(sc, c); + + await c.pc.setRemoteDescription(offer); + await c.flushIceCandidates(); + let answer = await c.pc.createAnswer(); + if(!answer) + throw new Error("Didn't create answer"); + await c.pc.setLocalDescription(answer); + this.send({ + type: 'answer', + id: id, + answer: answer, + }); +} + +/** + * Called when we receive a stream label from the server. Don't call this. + * + * @param {string} id + * @param {string} label + */ +ServerConnection.prototype.gotLabel = function(id, label) { + let c = this.down[id]; + if(!c) + throw new Error('Got label for unknown id'); + + c.label = label; + if(c.onlabel) + c.onlabel.call(c, label); +} + +/** + * Called when we receive an answer from the server. Don't call this. + * + * @param {string} id + * @param {RTCSessionDescriptionInit} answer + */ +ServerConnection.prototype.gotAnswer = async function(id, answer) { + let c = this.up[id]; + if(!c) + throw new Error('unknown up stream'); + try { + await c.pc.setRemoteDescription(answer); + } catch(e) { + if(c.onerror) + c.onerror.call(c, e); + return; + } + await c.flushIceCandidates(); +} + +/** + * Called when we receive a renegotiation request from the server. Don't + * call this. + * + * @param {string} id + */ +ServerConnection.prototype.gotRenegotiate = async function(id) { + let c = this.up[id]; + if(!c) + throw new Error('unknown up stream'); + try { + /** @ts-ignore */ + c.pc.restartIce(); + } catch(e) { + console.warn(e); + } +} + +/** + * Called when we receive a close request from the server. Don't call this. + * + * @param {string} id + */ +ServerConnection.prototype.gotClose = function(id) { + let c = this.down[id]; + if(!c) + throw new Error('unknown down stream'); + delete(this.down[id]); + c.close(false); + if(c.onclose) + c.onclose.call(c); +} + +/** + * Called when we receive an abort message from the server. Don't call this. + * + * @param {string} id + */ +ServerConnection.prototype.gotAbort = function(id) { + let c = this.down[id]; + if(!c) + throw new Error('unknown up stream'); + if(c.onabort) + c.onabort.call(c); +} + +/** + * Called when we receive an ICE candidate from the server. Don't call this. + * + * @param {string} id + * @param {RTCIceCandidate} candidate + */ +ServerConnection.prototype.gotICE = async function(id, candidate) { + let c = this.up[id]; + if(!c) + c = this.down[id]; + if(!c) + throw new Error('unknown stream'); + if(c.pc.remoteDescription) + await c.pc.addIceCandidate(candidate).catch(console.warn); + else + c.iceCandidates.push(candidate); +} + +/** + * Stream encapsulates a MediaStream, a set of tracks. + * + * A stream is said to go "up" if it is from the client to the server, and + * "down" otherwise. + * + * @param {ServerConnection} sc + * @param {string} id + * @param {RTCPeerConnection} pc + * + * @constructor + */ +function Stream(sc, id, pc) { + /** + * The associated ServerConnection. + * + * @type {ServerConnection} + */ + this.sc = sc; + /** + * The id of this stream. + * + * @type {string} + */ + this.id = id; + /** + * For up streams, one of "local" or "screenshare". + * + * @type {string} + */ + this.kind = null; + /** + * For down streams, a user-readable label. + * + * @type {string} + */ + this.label = null; + /** + * The associated RTCPeerConnectoin. This is null before the stream + * is connected, and may change over time. + * + * @type {RTCPeerConnection} + */ + this.pc = pc; + /** + * The associated MediaStream. This is null before the stream is + * connected, and may change over time. + * + * @type {MediaStream} + */ + this.stream = null; + /** + * Track labels, indexed by track id. + * + * @type {Object.} + */ + this.labels = {}; + /** + * Track labels, indexed by mid. + * + * @type {Object.} + */ + this.labelsByMid = {}; + /** + * Buffered ICE candidates. This will be flushed by flushIceCandidates + * when the PC becomes stable. + * + * @type {Array.} + */ + this.iceCandidates = []; + /** + * The statistics last computed by the stats handler. This is + * a dictionary indexed by track id, with each value a disctionary of + * statistics. + * + * @type {Object.} + */ + this.stats = {}; + /** + * The id of the periodic handler that computes statistics, as + * returned by setInterval. + * + * @type {number} + */ + this.statsHandler = null; + + /* Callbacks */ + + /** + * onclose is called when the stream is closed. + * + * @type{function(): any} + */ + this.onclose = null; + /** + * onerror is called whenever an error occurs. If the error is + * fatal, then onclose will be called afterwards. + * + * @type{function(any): any} + */ + this.onerror = null; + /** + * ondowntrack is called whenever a new track is added to a stream. + * If the stream parameter differs from its previous value, then it + * indicates that the old stream has been discarded. + * + * @type{function(MediaStreamTrack, RTCRtpTransceiver, string, MediaStream): any} + */ + this.ondowntrack = null; + /** + * onlabel is called whenever the server sets a new label for the stream. + * + * @type{function(string): any} + */ + this.onlabel = null; + /** + * onstatus is called whenever the status of the stream changes. + * + * @type{function(string): any} + */ + this.onstatus = null; + /** + * onabort is called when the server requested that an up stream be + * closed. It is the resposibility of the client to close the stream. + * + * @type{function(): any} + */ + this.onabort = null; + /** + * onstats is called when we have new statistics about the connection + * + * @type{function(Object.): any} + */ + this.onstats = null; +} + +/** + * close closes an up stream. It should not be called for down streams. + * @param {boolean} sendclose - whether to send a close message to the server + */ +Stream.prototype.close = function(sendclose) { + let c = this; + if(c.statsHandler) { + clearInterval(c.statsHandler); + c.statsHandler = null; + } + + if(c.stream) { + c.stream.getTracks().forEach(t => { + try { + t.stop(); + } catch(e) { + } + }); + } + c.pc.close(); + + if(sendclose) { + try { + c.sc.send({ + type: 'close', + id: c.id, + }); + } catch(e) { + } + } + c.sc = null; +}; + +/** + * flushIceCandidates flushes any buffered ICE candidates. It is called + * automatically when the connection reaches a stable state. + */ +Stream.prototype.flushIceCandidates = async function () { + let promises = []; + this.iceCandidates.forEach(c => { + promises.push(this.pc.addIceCandidate(c).catch(console.warn)); + }); + this.iceCandidates = []; + return await Promise.all(promises); +} + +/** + * negotiate negotiates or renegotiates an up stream. It is called + * automatically when required. If the client requires renegotiation, it + * is probably more effective to call restartIce on the underlying PC + * rather than invoking this function directly. + */ +Stream.prototype.negotiate = async function () { + let c = this; + + let offer = await c.pc.createOffer(); + if(!offer) + throw(new Error("Didn't create offer")); + await c.pc.setLocalDescription(offer); + + // mids are not known until this point + c.pc.getTransceivers().forEach(t => { + if(t.sender && t.sender.track) { + let label = c.labels[t.sender.track.id]; + if(label) + c.labelsByMid[t.mid] = label; + else + console.warn("Couldn't find label for track"); + } + }); + + c.sc.send({ + type: 'offer', + kind: 'renegotiate', + id: c.id, + labels: c.labelsByMid, + offer: offer, + }); +} + +/** + * updateStats is called periodically, if requested by setStatsInterval, + * in order to recompute stream statistics and invoke the onstats handler. + * + * @returns {Promise} + */ +Stream.prototype.updateStats = async function() { + let c = this; + let old = c.stats; + let stats = {}; + + let transceivers = c.pc.getTransceivers(); + for(let i = 0; i < transceivers.length; i++) { + let t = transceivers[i]; + let tid = t.sender.track && t.sender.track.id; + if(!tid) + continue; + + let report; + try { + report = await t.sender.getStats(); + } catch(e) { + continue; + } + + stats[tid] = {}; + + for(let r of report.values()) { + if(r.type !== 'outbound-rtp') + continue; + + stats[tid].timestamp = r.timestamp; + stats[tid].bytesSent = r.bytesSent; + if(old[tid] && old[tid].timestamp) { + stats[tid].rate = + ((r.bytesSent - old[tid].bytesSent) * 1000 / + (r.timestamp - old[tid].timestamp)) * 8; + } + } + } + + c.stats = stats; + + if(c.onstats) + c.onstats.call(c, c.stats); +} + +/** + * setStatsInterval sets the interval in milliseconds at which the onstats + * handler will be called. This is only useful for up streams. + * + * @param {number} ms + */ +Stream.prototype.setStatsInterval = function(ms) { + let c = this; + if(c.statsHandler) { + clearInterval(c.statsHandler); + c.statsHandler = null; + } + + if(ms <= 0) + return; + + c.statsHandler = setInterval(() => { + c.updateStats(); + }, ms); +} + diff --git a/static/sfu.html b/static/sfu.html index 5c79623..e63cca1 100644 --- a/static/sfu.html +++ b/static/sfu.html @@ -76,6 +76,7 @@
- + + diff --git a/static/sfu.js b/static/sfu.js index 7403026..abfae82 100644 --- a/static/sfu.js +++ b/static/sfu.js @@ -5,76 +5,8 @@ 'use strict'; -let myid; - let group; - -let socket; - -let up = {}, down = {}; - -let iceServers = []; - -let permissions = {}; - -function toHex(array) { - let a = new Uint8Array(array); - function hex(x) { - let h = x.toString(16); - if(h.length < 2) - h = '0' + h; - return h; - } - return a.reduce((x, y) => x + hex(y), ''); -} - -function randomid() { - let a = new Uint8Array(16); - crypto.getRandomValues(a); - return toHex(a); -} - -function Stream(id, pc) { - this.id = id; - this.kind = null; - this.label = null; - this.pc = pc; - this.stream = null; - this.labels = {}; - this.labelsByMid = {}; - this.iceCandidates = []; - this.timers = []; - this.stats = {}; -} - -Stream.prototype.setInterval = function(f, t) { - this.timers.push(setInterval(f, t)); -}; - -Stream.prototype.close = function(sendit) { - while(this.timers.length > 0) - clearInterval(this.timers.pop()); - - if(this.stream) { - this.stream.getTracks().forEach(t => { - try { - t.stop(); - } catch(e) { - } - }); - } - this.pc.close(); - - if(sendit) { - try { - send({ - type: 'close', - id: this.id, - }); - } catch(e) { - } - } -}; +let serverConnection; function setUserPass(username, password) { window.sessionStorage.setItem( @@ -103,6 +35,8 @@ function setConnected(connected) { let disconnectbutton = document.getElementById('disconnectbutton'); if(connected) { clearError(); + resetUsers(); + clearChat(); statspan.textContent = 'Connected'; statspan.classList.remove('disconnected'); statspan.classList.add('connected'); @@ -122,8 +56,40 @@ function setConnected(connected) { userform.classList.add('userform'); userform.classList.remove('invisible'); disconnectbutton.classList.add('invisible'); - permissions={}; - clearUsername(false); + clearUsername(); + } +} + +function gotConnected() { + setConnected(true); + let up = getUserPass(); + this.login(up.username, up.password); + this.join(group); + this.request(document.getElementById('requestselect').value); +} + +function gotClose(code, reason) { + setConnected(false); + if(code != 1000) + console.warn('Socket close', code, reason); +} + +function gotDownStream(c) { + c.onclose = function() { + delMedia(c.id); + }; + c.onerror = function(e) { + console.error(e); + displayError(e); + } + c.ondowntrack = function(track, transceiver, label, stream) { + setMedia(c, false); + } + c.onlabel = function(label) { + setLabel(c); + } + c.onstatus = function(status) { + setMediaStatus(c); } } @@ -153,6 +119,7 @@ function setVisibility(id, visible) { } function setButtonsVisibility() { + let permissions = serverConnection.permissions; let local = !!findUpMedia('local'); let share = !!findUpMedia('screenshare') // don't allow multiple presentations @@ -209,59 +176,17 @@ document.getElementById('unsharebutton').onclick = function(e) { document.getElementById('requestselect').onchange = function(e) { e.preventDefault(); - sendRequest(this.value); + serverConnection.request(this.value); }; -async function updateStats(conn, sender) { - let tid = sender.track && sender.track.id; - if(!tid) - return; - - let stats = conn.stats[tid]; - if(!stats) { - conn.stats[tid] = {}; - stats = conn.stats[tid]; - } - - let report; - try { - report = await sender.getStats(); - } catch(e) { - delete(stats[id].rate); - delete(stats.timestamp); - delete(stats.bytesSent); - return; - } - - for(let r of report.values()) { - if(r.type !== 'outbound-rtp') - continue; - - if(stats.timestamp) { - stats.rate = - ((r.bytesSent - stats.bytesSent) * 1000 / - (r.timestamp - stats.timestamp)) * 8; - } else { - delete(stats.rate); - } - stats.timestamp = r.timestamp; - stats.bytesSent = r.bytesSent; - return; - } -} - -function displayStats(id) { - let conn = up[id]; - if(!conn) { - setLabel(id); - return; - } +function displayStats(stats) { + let c = this; let text = ''; - conn.pc.getSenders().forEach(s => { + c.pc.getSenders().forEach(s => { let tid = s.track && s.track.id; - let stats = tid && conn.stats[tid]; + let stats = tid && c.stats[tid]; if(stats && stats.rate > 0) { if(text) text = text + ' + '; @@ -269,7 +194,7 @@ function displayStats(id) { } }); - setLabel(id, text); + setLabel(c, text); } function mapMediaOption(value) { @@ -337,6 +262,22 @@ async function setMediaChoices() { mediaChoicesDone = true; } +function newUpStream(id) { + let c = serverConnection.newUpStream(); + c.onstatus = function(status) { + setMediaStatus(c); + } + c.onerror = function(e) { + console.error(e); + displayError(e); + delUpMedia(c.id); + } + c.onabort = function() { + delUpMedia(c.id); + } + return c; +} + async function addLocalMedia(id) { if(!getUserPass()) return; @@ -366,8 +307,7 @@ async function addLocalMedia(id) { setMediaChoices(); - id = await newUpStream(id); - let c = up[id]; + let c = newUpStream(id); c.kind = 'local'; c.stream = stream; @@ -376,14 +316,10 @@ async function addLocalMedia(id) { if(t.kind == 'audio' && localMute) t.enabled = false; let sender = c.pc.addTrack(t, stream); - c.setInterval(() => { - updateStats(c, sender); - }, 2000); }); - c.setInterval(() => { - displayStats(id); - }, 2500); - await setMedia(id); + c.onstats = displayStats; + c.setStatsInterval(2000); + await setMedia(c, true); setButtonsVisibility() } @@ -399,33 +335,23 @@ async function addShareMedia(setup) { return; } - let id = await newUpStream(); - let c = up[id]; + let c = await serverConnection.newUpStream(); c.kind = 'screenshare'; c.stream = stream; stream.getTracks().forEach(t => { let sender = c.pc.addTrack(t, stream); t.onended = e => { - delUpMedia(id); + delUpMedia(c.id); }; c.labels[t.id] = 'screenshare'; - c.setInterval(() => { - updateStats(c, sender); - }, 2000); }); - c.setInterval(() => { - displayStats(id); - }, 2500); - await setMedia(id); + c.onstats = displayStats; + c.setStatsInterval(2000); + await setMedia(c, true); setButtonsVisibility() } -function stopUpMedia(id) { - let c = up[id]; - if(!c) { - console.error('Stopping unknown up media'); - return; - } +function stopUpMedia(c) { if(!c.stream) return; c.stream.getTracks().forEach(t => { @@ -436,43 +362,40 @@ function stopUpMedia(id) { }); } -function delUpMedia(id) { - let c = up[id]; - if(!c) { - console.error('Deleting unknown up media'); - return; - } - stopUpMedia(id); - delMedia(id); +function delUpMedia(c) { + stopUpMedia(c); + delMedia(c); c.close(true); - delete(up[id]); + delete(serverConnection.up[c.id]); setButtonsVisibility() } function delUpMediaKind(kind) { - for(let id in up) { - let c = up[id]; + for(let id in serverConnection.up) { + let c = serverConnection.up[id]; if(c.kind != kind) continue c.close(true); delMedia(id); - delete(up[id]); + delete(serverConnection.up[id]); } setButtonsVisibility() } function findUpMedia(kind) { - for(let id in up) { - if(up[id].kind === kind) + for(let id in serverConnection.up) { + if(serverConnection.up[id].kind === kind) return id; } return null; } function muteLocalTracks(mute) { - for(let id in up) { - let c = up[id]; + if(!serverConnection) + return; + for(let id in serverConnection.up) { + let c = serverConnection.up[id]; if(c.kind === 'local') { let stream = c.stream; stream.getTracks().forEach(t => { @@ -484,50 +407,45 @@ function muteLocalTracks(mute) { } } -function setMedia(id) { - let mine = true; - let c = up[id]; - if(!c) { - c = down[id]; - mine = false; - } - if(!c) - throw new Error('Unknown stream'); - +/** + * @param {Stream} c + * @param {boolean} isUp + */ +function setMedia(c, isUp) { let peersdiv = document.getElementById('peers'); - let div = document.getElementById('peer-' + id); + let div = document.getElementById('peer-' + c.id); if(!div) { div = document.createElement('div'); - div.id = 'peer-' + id; + div.id = 'peer-' + c.id; div.classList.add('peer'); peersdiv.appendChild(div); } - let media = document.getElementById('media-' + id); + let media = document.getElementById('media-' + c.id); if(!media) { media = document.createElement('video'); - media.id = 'media-' + id; + media.id = 'media-' + c.id; media.classList.add('media'); media.autoplay = true; media.playsinline = true; media.controls = true; - if(mine) + if(isUp) media.muted = true; div.appendChild(media); } - let label = document.getElementById('label-' + id); + let label = document.getElementById('label-' + c.id); if(!label) { label = document.createElement('div'); - label.id = 'label-' + id; + label.id = 'label-' + c.id; label.classList.add('label'); div.appendChild(label); } media.srcObject = c.stream; - setLabel(id); - setMediaStatus(id); + setLabel(c); + setMediaStatus(c); resizePeers(); } @@ -543,12 +461,14 @@ function delMedia(id) { resizePeers(); } -function setMediaStatus(id) { - let c = up[id] || down[id]; +/** + * @param {Stream} c + */ +function setMediaStatus(c) { let state = c && c.pc && c.pc.iceConnectionState; let good = state === 'connected' || state === 'completed'; - let media = document.getElementById('media-' + id); + let media = document.getElementById('media-' + c.id); if(!media) { console.warn('Setting status of unknown media.'); return; @@ -560,11 +480,15 @@ function setMediaStatus(id) { } -function setLabel(id, fallback) { - let label = document.getElementById('label-' + id); +/** + * @param {Stream} c + * @param {string} [fallback] + */ +function setLabel(c, fallback) { + let label = document.getElementById('label-' + c.id); if(!label) return; - let l = down[id] ? down[id].label : null; + let l = c.label; if(l) { label.textContent = l; label.classList.remove('label-fallback'); @@ -578,294 +502,14 @@ function setLabel(id, fallback) { } function resizePeers() { - let count = Object.keys(up).length + Object.keys(down).length; + let count = + Object.keys(serverConnection.up).length + + Object.keys(serverConnection.down).length; let columns = Math.ceil(Math.sqrt(count)); document.getElementById('peers').style['grid-template-columns'] = `repeat(${columns}, 1fr)`; } -function serverConnect() { - if(socket) { - socket.close(1000, 'Reconnecting'); - socket = null; - setConnected(false); - } - - try { - socket = new WebSocket( - `ws${location.protocol === 'https:' ? 's' : ''}://${location.host}/ws`, - ); - } catch(e) { - console.error(e); - setConnected(false); - return Promise.reject(e); - } - - return new Promise((resolve, reject) => { - socket.onerror = function(e) { - reject(e.error ? e.error : e); - }; - socket.onopen = function(e) { - resetUsers(); - resetChat(); - setConnected(true); - let up = getUserPass(); - try { - send({ - type: 'login', - id: myid, - username: up.username, - password: up.password, - }) - send({ - type: 'join', - group: group, - }) - sendRequest(document.getElementById('requestselect').value); - } catch(e) { - console.error(e); - displayError(e); - reject(e); - return; - } - resolve(); - }; - socket.onclose = function(e) { - setConnected(false); - delUpMediaKind('local'); - delUpMediaKind('screenshare'); - for(let id in down) { - let c = down[id]; - delete(down[id]); - c.close(false); - delMedia(id); - } - reject(new Error('websocket close ' + e.code + ' ' + e.reason)); - }; - socket.onmessage = function(e) { - let m = JSON.parse(e.data); - switch(m.type) { - case 'offer': - gotOffer(m.id, m.labels, m.offer, m.kind === 'renegotiate'); - break; - case 'answer': - gotAnswer(m.id, m.answer); - break; - case 'renegotiate': - let c = up[m.id]; - if(c) { - try { - c.pc.restartIce() - } catch(e) { - console.error(e); - displayError(e); - } - } - break; - case 'close': - gotClose(m.id); - break; - case 'abort': - gotAbort(m.id); - break; - case 'ice': - gotICE(m.id, m.candidate); - break; - case 'label': - gotLabel(m.id, m.value); - break; - case 'permissions': - gotPermissions(m.permissions); - break; - case 'user': - gotUser(m.id, m.kind, m.username); - break; - case 'chat': - addToChatbox(m.id, m.username, m.kind, m.value); - break; - case 'clearchat': - resetChat(); - break; - case 'ping': - send({ - type: 'pong', - }); - break; - case 'pong': - /* nothing */ - break; - case 'usermessage': - switch(m.kind) { - case 'error': - displayError('The server said: ' + m.value); - break; - default: - displayWarning('The server said: ' + m.value) - break; - } - break; - default: - console.warn('Unexpected server message', m.type); - return; - } - }; - }); -} - -function sendRequest(value) { - let request = []; - switch(value) { - case 'audio': - request = {audio: true}; - break; - case 'screenshare': - request = {audio: true, screenshare: true}; - break; - case 'everything': - request = {audio: true, screenshare: true, video: true}; - break; - default: - console.error(`Uknown value ${value} in sendRequest`); - break; - } - - send({ - type: 'request', - request: request, - }); -} - -async function gotOffer(id, labels, offer, renegotiate) { - let c = down[id]; - if(c && !renegotiate) { - // SDP is rather inflexible as to what can be renegotiated. - // Unless the server indicates that this is a renegotiation with - // all parameters unchanged, tear down the existing connection. - delete(down[id]) - c.close(false); - c = null; - } - - if(!c) { - let pc = new RTCPeerConnection({ - iceServers: iceServers, - }); - c = new Stream(id, pc); - down[id] = c; - - c.pc.onicecandidate = function(e) { - if(!e.candidate) - return; - send({type: 'ice', - id: id, - candidate: e.candidate, - }); - }; - - pc.oniceconnectionstatechange = e => { - setMediaStatus(id); - if(pc.iceConnectionState === 'failed') { - send({type: 'renegotiate', - id: id, - }); - } - } - - c.pc.ontrack = function(e) { - let label = e.transceiver && c.labelsByMid[e.transceiver.mid]; - if(label) { - c.labels[e.track.id] = label; - } else { - console.warn("Couldn't find label for track"); - } - c.stream = e.streams[0]; - setMedia(id); - }; - } - - c.labelsByMid = labels; - - await c.pc.setRemoteDescription(offer); - await addIceCandidates(c); - let answer = await c.pc.createAnswer(); - if(!answer) - throw new Error("Didn't create answer"); - await c.pc.setLocalDescription(answer); - send({ - type: 'answer', - id: id, - answer: answer, - }); -} - -function gotLabel(id, label) { - let c = down[id]; - if(!c) - throw new Error('Got label for unknown id'); - - c.label = label; - setLabel(id); -} - -async function gotAnswer(id, answer) { - let c = up[id]; - if(!c) - throw new Error('unknown up stream'); - try { - await c.pc.setRemoteDescription(answer); - } catch(e) { - console.error(e); - displayError(e); - delUpMedia(id); - return; - } - await addIceCandidates(c); -} - -function gotClose(id) { - let c = down[id]; - if(!c) - throw new Error('unknown down stream'); - delete(down[id]); - c.close(false); - delMedia(id); -} - -function gotAbort(id) { - delUpMedia(id); -} - -async function gotICE(id, candidate) { - let conn = up[id]; - if(!conn) - conn = down[id]; - if(!conn) - throw new Error('unknown stream'); - if(conn.pc.remoteDescription) - await conn.pc.addIceCandidate(candidate).catch(console.warn); - else - conn.iceCandidates.push(candidate); -} - -async function addIceCandidates(conn) { - let promises = []; - conn.iceCandidates.forEach(c => { - promises.push(conn.pc.addIceCandidate(c).catch(console.warn)); - }); - conn.iceCandidates = []; - return await Promise.all(promises); -} - -function send(m) { - if(!m) - throw(new Error('Sending null message')); - if(socket.readyState !== socket.OPEN) { - // send on a closed connection doesn't throw - throw(new Error('Stream is not open')); - } - return socket.send(JSON.stringify(m)); -} - let users = {}; function addUser(id, name) { @@ -919,11 +563,11 @@ function displayUsername() { let text = ''; if(userpass && userpass.username) text = 'as ' + userpass.username; - if(permissions.op && permissions.present) + if(serverConnection.permissions.op && serverConnection.permissions.present) text = text + ' (op, presenter)'; - else if(permissions.op) + else if(serverConnection.permissions.op) text = text + ' (op)'; - else if(permissions.present) + else if(serverConnection.permissions.present) text = text + ' (presenter)'; document.getElementById('userspan').textContent = text; } @@ -932,8 +576,10 @@ function clearUsername() { document.getElementById('userspan').textContent = ''; } -function gotPermissions(perm) { - permissions = perm; +/** + * @param {Object.} perms + */ +function gotPermissions(perms) { displayUsername(); setButtonsVisibility(); } @@ -1020,13 +666,12 @@ function addToChatbox(peerId, nick, kind, message){ return message; } -function resetChat() { +function clearChat() { lastMessage = {}; document.getElementById('box').textContent = ''; } function handleInput() { - let username = getUsername(); let input = document.getElementById('input'); let data = input.value; input.value = ''; @@ -1057,51 +702,42 @@ function handleInput() { me = true; break; case '/leave': - socket.close(); + serverConnection.close(); return; case '/clear': - if(!permissions.op) { + if(!serverConnection.permissions.op) { displayError("You're not an operator"); return; } - send({ - type: 'groupaction', - kind: 'clearchat', - }); + serverConnection.groupAction('clearchat'); return; case '/lock': case '/unlock': - if(!permissions.op) { + if(!serverConnection.permissions.op) { displayError("You're not an operator"); return; } - send({ - type: 'groupaction', - kind: cmd.slice(1), - }); + serverConnection.groupAction(cmd.slice(1)); return; case '/record': case '/unrecord': - if(!permissions.record) { + if(!serverConnection.permissions.record) { displayError("You're not allowed to record"); return; } - send({ - type: 'groupaction', - kind: cmd.slice(1), - }); + serverConnection.groupAction(cmd.slice(1)); return; case '/op': case '/unop': case '/kick': case '/present': case '/unpresent': { - if(!permissions.op) { + if(!serverConnection.permissions.op) { displayError("You're not an operator"); return; } let id; - if(id in users) { + if(rest in users) { id = rest; } else { for(let i in users) { @@ -1115,11 +751,7 @@ function handleInput() { displayError('Unknown user ' + rest); return; } - send({ - type: 'useraction', - kind: cmd.slice(1), - id: id, - }); + serverConnection.userAction(cmd.slice(1), id) return; } default: @@ -1132,19 +764,14 @@ function handleInput() { me = false; } + let username = getUsername(); if(!username) { displayError("Sorry, you're anonymous, you cannot chat"); return; } try { - let a = send({ - type: 'chat', - id: myid, - username: username, - kind: me ? 'me' : '', - value: message, - }); + serverConnection.chat(username, me ? 'me' : '', message); } catch(e) { console.error(e); displayError(e); @@ -1198,91 +825,6 @@ function chatResizer(e) { document.getElementById('resizer').addEventListener('mousedown', chatResizer, false); -async function newUpStream(id) { - if(!id) { - id = randomid(); - if(up[id]) - throw new Error('Eek!'); - } - let pc = new RTCPeerConnection({ - iceServers: iceServers, - }); - if(!pc) - throw new Error("Couldn't create peer connection"); - if(up[id]) { - up[id].close(false); - } - up[id] = new Stream(id, pc); - - pc.onnegotiationneeded = async e => { - try { - await negotiate(id, false); - } catch(e) { - console.error(e); - displayError(e); - delUpMedia(id); - } - } - - pc.onicecandidate = e => { - if(!e.candidate) - return; - send({type: 'ice', - id: id, - candidate: e.candidate, - }); - }; - - pc.oniceconnectionstatechange = e => { - setMediaStatus(id); - if(pc.iceConnectionState === 'failed') { - try { - pc.restartIce(); - } catch(e) { - console.error(e); - displayError(e); - } - } - } - - pc.ontrack = console.error; - - return id; -} - -async function negotiate(id, restartIce) { - let c = up[id]; - if(!c) - throw new Error('unknown stream'); - - if(typeof(c.pc.getTransceivers) !== 'function') - throw new Error('Browser too old, please upgrade'); - - let offer = await c.pc.createOffer({iceRestart: restartIce}); - if(!offer) - throw(new Error("Didn't create offer")); - await c.pc.setLocalDescription(offer); - - // mids are not known until this point - c.pc.getTransceivers().forEach(t => { - if(t.sender && t.sender.track) { - let label = c.labels[t.sender.track.id]; - if(label) - c.labelsByMid[t.mid] = label; - else - console.warn("Couldn't find label for track"); - } - }); - - send({ - type: 'offer', - kind: 'renegotiate', - id: id, - labels: c.labelsByMid, - offer: offer, - }); -} - let errorTimeout = null; function setErrorTimeout(ms) { @@ -1317,29 +859,36 @@ function clearError() { setErrorTimeout(null); } -async function getIceServers() { - let r = await fetch('/ice-servers.json'); - if(!r.ok) - throw new Error("Couldn't fetch ICE servers: " + - r.status + ' ' + r.statusText); - let servers = await r.json(); - if(!(servers instanceof Array)) - throw new Error("couldn't parse ICE servers"); - iceServers = servers; -} - -document.getElementById('userform').onsubmit = async function(e) { +document.getElementById('userform').onsubmit = function(e) { e.preventDefault(); let username = document.getElementById('username').value.trim(); let password = document.getElementById('password').value; setUserPass(username, password); - await serverConnect(); + serverConnect(); }; document.getElementById('disconnectbutton').onclick = function(e) { - socket.close(); + serverConnection.close(); }; +function serverConnect() { + serverConnection = new ServerConnection(); + serverConnection.onconnected = gotConnected; + serverConnection.onclose = gotClose; + serverConnection.ondownstream = gotDownStream; + serverConnection.onuser = gotUser; + serverConnection.onpermissions = gotPermissions; + serverConnection.onchat = addToChatbox; + serverConnection.onclearchat = clearChat; + serverConnection.onusermessage = function(kind, message) { + if(kind === 'error') + displayError(`The server said: ${message}`); + else + displayWarning(`The server said: ${message}`); + } + return serverConnection.connect(`ws${location.protocol === 'https:' ? 's' : ''}://${location.host}/ws`); +} + function start() { group = decodeURIComponent(location.pathname.replace(/^\/[a-z]*\//, '')); let title = group.charAt(0).toUpperCase() + group.slice(1); @@ -1350,15 +899,11 @@ function start() { setLocalMute(localMute); - myid = randomid(); + document.getElementById('connectbutton').disabled = false; - getIceServers().catch(console.error).then(c => { - document.getElementById('connectbutton').disabled = false; - }).then(c => { - let userpass = getUserPass(); - if(userpass) - return serverConnect(); - }); + let userpass = getUserPass(); + if(userpass) + serverConnect(); } start(); diff --git a/static/tsconfig.json b/static/tsconfig.json new file mode 100644 index 0000000..ca34d5d --- /dev/null +++ b/static/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES6", + "allowJs": true, + "checkJs": true, + "declaration": true, + "emitDeclarationOnly": true, + "strictBindCallApply": true + }, + "files": [ + "protocol.js" + ] +}