Commit 3e0bb089 authored by Juliusz Chroboczek's avatar Juliusz Chroboczek

Split out the javascript protocol interface.

parent ec742eac
// 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.<string,Stream>}
*/
this.up = {};
/**
* The set of all down streams, indexed by their id.
* @type {Object.<string,Stream>}
*/
this.down = {};
/**
* The ICE configuration used by all associated streams.
* @type {Array.<Object>}
*/
this.iceServers = [];
/**
* The permissions granted to this connection.
* @type {Object.<string,boolean>}
*/
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.<string,boolean>): 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.<string,boolean>} [permissions]
* @property {string} [group]
* @property {string} [value]
* @property {RTCSessionDescriptionInit} [offer]
* @property {RTCSessionDescriptionInit} [answer]
* @property {RTCIceCandidate} [candidate]
* @property {Object.<string,string>} [labels]
* @property {Object.<string,(boolean|number)>} [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<Array.<Object>>}
*/
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>}
*/
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.<string,boolean>} */
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.<string,string>}
*/
this.labels = {};
/**
* Track labels, indexed by mid.
*
* @type {Object.<string,string>}
*/
this.labelsByMid = {};
/**
* Buffered ICE candidates. This will be flushed by flushIceCandidates
* when the PC becomes stable.
*
* @type {Array.<RTCIceCandidate>}
*/
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.<string,any>}
*/
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.<string,any>): 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<void>}
*/
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);
}
...@@ -76,6 +76,7 @@ ...@@ -76,6 +76,7 @@
<div id="peers"></div> <div id="peers"></div>
</div> </div>
<script src="/sfu.js" defer></script> <script src="/protocol.js" defer></script>
<script src="/sfu.js" defer></script>
</body> </body>
</html> </html>
...@@ -5,76 +5,8 @@ ...@@ -5,76 +5,8 @@
'use strict'; 'use strict';
let myid;
let group; let group;
let serverConnection;
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) {
}
}
};
function setUserPass(username, password) { function setUserPass(username, password) {
window.sessionStorage.setItem( window.sessionStorage.setItem(
...@@ -103,6 +35,8 @@ function setConnected(connected) { ...@@ -103,6 +35,8 @@ function setConnected(connected) {
let disconnectbutton = document.getElementById('disconnectbutton'); let disconnectbutton = document.getElementById('disconnectbutton');
if(connected) { if(connected) {
clearError(); clearError();
resetUsers();
clearChat();
statspan.textContent = 'Connected'; statspan.textContent = 'Connected';
statspan.classList.remove('disconnected'); statspan.classList.remove('disconnected');
statspan.classList.add('connected'); statspan.classList.add('connected');
...@@ -122,8 +56,40 @@ function setConnected(connected) { ...@@ -122,8 +56,40 @@ function setConnected(connected) {
userform.classList.add('userform'); userform.classList.add('userform');
userform.classList.remove('invisible'); userform.classList.remove('invisible');
disconnectbutton.classList.add('invisible'); disconnectbutton.classList.add('invisible');
permissions={}; clearUsername();
clearUsername(false); }
}
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) { ...@@ -153,6 +119,7 @@ function setVisibility(id, visible) {
} }
function setButtonsVisibility() { function setButtonsVisibility() {
let permissions = serverConnection.permissions;
let local = !!findUpMedia('local'); let local = !!findUpMedia('local');
let share = !!findUpMedia('screenshare') let share = !!findUpMedia('screenshare')
// don't allow multiple presentations // don't allow multiple presentations
...@@ -209,59 +176,17 @@ document.getElementById('unsharebutton').onclick = function(e) { ...@@ -209,59 +176,17 @@ document.getElementById('unsharebutton').onclick = function(e) {
document.getElementById('requestselect').onchange = function(e) { document.getElementById('requestselect').onchange = function(e) {
e.preventDefault(); e.preventDefault();
sendRequest(this.value); serverConnection.request(this.value);
}; };
async function updateStats(conn, sender) { function displayStats(stats) {
let tid = sender.track && sender.track.id; let c = this;
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;
}
let text = ''; let text = '';
conn.pc.getSenders().forEach(s => { c.pc.getSenders().forEach(s => {
let tid = s.track && s.track.id; 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(stats && stats.rate > 0) {
if(text) if(text)
text = text + ' + '; text = text + ' + ';
...@@ -269,7 +194,7 @@ function displayStats(id) { ...@@ -269,7 +194,7 @@ function displayStats(id) {
} }
}); });
setLabel(id, text); setLabel(c, text);
} }
function mapMediaOption(value) { function mapMediaOption(value) {
...@@ -337,6 +262,22 @@ async function setMediaChoices() { ...@@ -337,6 +262,22 @@ async function setMediaChoices() {
mediaChoicesDone = true; 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) { async function addLocalMedia(id) {
if(!getUserPass()) if(!getUserPass())
return; return;
...@@ -366,8 +307,7 @@ async function addLocalMedia(id) { ...@@ -366,8 +307,7 @@ async function addLocalMedia(id) {
setMediaChoices(); setMediaChoices();
id = await newUpStream(id); let c = newUpStream(id);
let c = up[id];
c.kind = 'local'; c.kind = 'local';
c.stream = stream; c.stream = stream;
...@@ -376,14 +316,10 @@ async function addLocalMedia(id) { ...@@ -376,14 +316,10 @@ async function addLocalMedia(id) {
if(t.kind == 'audio' && localMute) if(t.kind == 'audio' && localMute)
t.enabled = false; t.enabled = false;
let sender = c.pc.addTrack(t, stream); let sender = c.pc.addTrack(t, stream);
c.setInterval(() => {
updateStats(c, sender);
}, 2000);
}); });
c.setInterval(() => { c.onstats = displayStats;
displayStats(id); c.setStatsInterval(2000);
}, 2500); await setMedia(c, true);
await setMedia(id);
setButtonsVisibility() setButtonsVisibility()
} }
...@@ -399,33 +335,23 @@ async function addShareMedia(setup) { ...@@ -399,33 +335,23 @@ async function addShareMedia(setup) {
return; return;
} }
let id = await newUpStream(); let c = await serverConnection.newUpStream();
let c = up[id];
c.kind = 'screenshare'; c.kind = 'screenshare';
c.stream = stream; c.stream = stream;
stream.getTracks().forEach(t => { stream.getTracks().forEach(t => {
let sender = c.pc.addTrack(t, stream); let sender = c.pc.addTrack(t, stream);
t.onended = e => { t.onended = e => {
delUpMedia(id); delUpMedia(c.id);
}; };
c.labels[t.id] = 'screenshare'; c.labels[t.id] = 'screenshare';
c.setInterval(() => {
updateStats(c, sender);
}, 2000);
}); });
c.setInterval(() => { c.onstats = displayStats;
displayStats(id); c.setStatsInterval(2000);
}, 2500); await setMedia(c, true);
await setMedia(id);
setButtonsVisibility() setButtonsVisibility()
} }
function stopUpMedia(id) { function stopUpMedia(c) {
let c = up[id];
if(!c) {
console.error('Stopping unknown up media');
return;
}
if(!c.stream) if(!c.stream)
return; return;
c.stream.getTracks().forEach(t => { c.stream.getTracks().forEach(t => {
...@@ -436,43 +362,40 @@ function stopUpMedia(id) { ...@@ -436,43 +362,40 @@ function stopUpMedia(id) {
}); });
} }
function delUpMedia(id) { function delUpMedia(c) {
let c = up[id]; stopUpMedia(c);
if(!c) { delMedia(c);
console.error('Deleting unknown up media');
return;
}
stopUpMedia(id);
delMedia(id);
c.close(true); c.close(true);
delete(up[id]); delete(serverConnection.up[c.id]);
setButtonsVisibility() setButtonsVisibility()
} }
function delUpMediaKind(kind) { function delUpMediaKind(kind) {
for(let id in up) { for(let id in serverConnection.up) {
let c = up[id]; let c = serverConnection.up[id];
if(c.kind != kind) if(c.kind != kind)
continue continue
c.close(true); c.close(true);
delMedia(id); delMedia(id);
delete(up[id]); delete(serverConnection.up[id]);
} }
setButtonsVisibility() setButtonsVisibility()
} }
function findUpMedia(kind) { function findUpMedia(kind) {
for(let id in up) { for(let id in serverConnection.up) {
if(up[id].kind === kind) if(serverConnection.up[id].kind === kind)
return id; return id;
} }
return null; return null;
} }
function muteLocalTracks(mute) { function muteLocalTracks(mute) {
for(let id in up) { if(!serverConnection)
let c = up[id]; return;
for(let id in serverConnection.up) {
let c = serverConnection.up[id];
if(c.kind === 'local') { if(c.kind === 'local') {
let stream = c.stream; let stream = c.stream;
stream.getTracks().forEach(t => { stream.getTracks().forEach(t => {
...@@ -484,50 +407,45 @@ function muteLocalTracks(mute) { ...@@ -484,50 +407,45 @@ function muteLocalTracks(mute) {
} }
} }
function setMedia(id) { /**
let mine = true; * @param {Stream} c
let c = up[id]; * @param {boolean} isUp
if(!c) { */
c = down[id]; function setMedia(c, isUp) {
mine = false;
}
if(!c)
throw new Error('Unknown stream');
let peersdiv = document.getElementById('peers'); let peersdiv = document.getElementById('peers');
let div = document.getElementById('peer-' + id); let div = document.getElementById('peer-' + c.id);
if(!div) { if(!div) {
div = document.createElement('div'); div = document.createElement('div');
div.id = 'peer-' + id; div.id = 'peer-' + c.id;
div.classList.add('peer'); div.classList.add('peer');
peersdiv.appendChild(div); peersdiv.appendChild(div);
} }
let media = document.getElementById('media-' + id); let media = document.getElementById('media-' + c.id);
if(!media) { if(!media) {
media = document.createElement('video'); media = document.createElement('video');
media.id = 'media-' + id; media.id = 'media-' + c.id;
media.classList.add('media'); media.classList.add('media');
media.autoplay = true; media.autoplay = true;
media.playsinline = true; media.playsinline = true;
media.controls = true; media.controls = true;
if(mine) if(isUp)
media.muted = true; media.muted = true;
div.appendChild(media); div.appendChild(media);
} }
let label = document.getElementById('label-' + id); let label = document.getElementById('label-' + c.id);
if(!label) { if(!label) {
label = document.createElement('div'); label = document.createElement('div');
label.id = 'label-' + id; label.id = 'label-' + c.id;
label.classList.add('label'); label.classList.add('label');
div.appendChild(label); div.appendChild(label);
} }
media.srcObject = c.stream; media.srcObject = c.stream;
setLabel(id); setLabel(c);
setMediaStatus(id); setMediaStatus(c);
resizePeers(); resizePeers();
} }
...@@ -543,12 +461,14 @@ function delMedia(id) { ...@@ -543,12 +461,14 @@ function delMedia(id) {
resizePeers(); 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 state = c && c.pc && c.pc.iceConnectionState;
let good = state === 'connected' || state === 'completed'; let good = state === 'connected' || state === 'completed';
let media = document.getElementById('media-' + id); let media = document.getElementById('media-' + c.id);
if(!media) { if(!media) {
console.warn('Setting status of unknown media.'); console.warn('Setting status of unknown media.');
return; return;
...@@ -560,11 +480,15 @@ function setMediaStatus(id) { ...@@ -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) if(!label)
return; return;
let l = down[id] ? down[id].label : null; let l = c.label;
if(l) { if(l) {
label.textContent = l; label.textContent = l;
label.classList.remove('label-fallback'); label.classList.remove('label-fallback');
...@@ -578,294 +502,14 @@ function setLabel(id, fallback) { ...@@ -578,294 +502,14 @@ function setLabel(id, fallback) {
} }
function resizePeers() { 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)); let columns = Math.ceil(Math.sqrt(count));
document.getElementById('peers').style['grid-template-columns'] = document.getElementById('peers').style['grid-template-columns'] =
`repeat(${columns}, 1fr)`; `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 = {}; let users = {};
function addUser(id, name) { function addUser(id, name) {
...@@ -919,11 +563,11 @@ function displayUsername() { ...@@ -919,11 +563,11 @@ function displayUsername() {
let text = ''; let text = '';
if(userpass && userpass.username) if(userpass && userpass.username)
text = 'as ' + userpass.username; text = 'as ' + userpass.username;
if(permissions.op && permissions.present) if(serverConnection.permissions.op && serverConnection.permissions.present)
text = text + ' (op, presenter)'; text = text + ' (op, presenter)';
else if(permissions.op) else if(serverConnection.permissions.op)
text = text + ' (op)'; text = text + ' (op)';
else if(permissions.present) else if(serverConnection.permissions.present)
text = text + ' (presenter)'; text = text + ' (presenter)';
document.getElementById('userspan').textContent = text; document.getElementById('userspan').textContent = text;
} }
...@@ -932,8 +576,10 @@ function clearUsername() { ...@@ -932,8 +576,10 @@ function clearUsername() {
document.getElementById('userspan').textContent = ''; document.getElementById('userspan').textContent = '';
} }
function gotPermissions(perm) { /**
permissions = perm; * @param {Object.<string,boolean>} perms
*/
function gotPermissions(perms) {
displayUsername(); displayUsername();
setButtonsVisibility(); setButtonsVisibility();
} }
...@@ -1020,13 +666,12 @@ function addToChatbox(peerId, nick, kind, message){ ...@@ -1020,13 +666,12 @@ function addToChatbox(peerId, nick, kind, message){
return message; return message;
} }
function resetChat() { function clearChat() {
lastMessage = {}; lastMessage = {};
document.getElementById('box').textContent = ''; document.getElementById('box').textContent = '';
} }
function handleInput() { function handleInput() {
let username = getUsername();
let input = document.getElementById('input'); let input = document.getElementById('input');
let data = input.value; let data = input.value;
input.value = ''; input.value = '';
...@@ -1057,51 +702,42 @@ function handleInput() { ...@@ -1057,51 +702,42 @@ function handleInput() {
me = true; me = true;
break; break;
case '/leave': case '/leave':
socket.close(); serverConnection.close();
return; return;
case '/clear': case '/clear':
if(!permissions.op) { if(!serverConnection.permissions.op) {
displayError("You're not an operator"); displayError("You're not an operator");
return; return;
} }
send({ serverConnection.groupAction('clearchat');
type: 'groupaction',
kind: 'clearchat',
});
return; return;
case '/lock': case '/lock':
case '/unlock': case '/unlock':
if(!permissions.op) { if(!serverConnection.permissions.op) {
displayError("You're not an operator"); displayError("You're not an operator");
return; return;
} }
send({ serverConnection.groupAction(cmd.slice(1));
type: 'groupaction',
kind: cmd.slice(1),
});
return; return;
case '/record': case '/record':
case '/unrecord': case '/unrecord':
if(!permissions.record) { if(!serverConnection.permissions.record) {
displayError("You're not allowed to record"); displayError("You're not allowed to record");
return; return;
} }
send({ serverConnection.groupAction(cmd.slice(1));
type: 'groupaction',
kind: cmd.slice(1),
});
return; return;
case '/op': case '/op':
case '/unop': case '/unop':
case '/kick': case '/kick':
case '/present': case '/present':
case '/unpresent': { case '/unpresent': {
if(!permissions.op) { if(!serverConnection.permissions.op) {
displayError("You're not an operator"); displayError("You're not an operator");
return; return;
} }
let id; let id;
if(id in users) { if(rest in users) {
id = rest; id = rest;
} else { } else {
for(let i in users) { for(let i in users) {
...@@ -1115,11 +751,7 @@ function handleInput() { ...@@ -1115,11 +751,7 @@ function handleInput() {
displayError('Unknown user ' + rest); displayError('Unknown user ' + rest);
return; return;
} }
send({ serverConnection.userAction(cmd.slice(1), id)
type: 'useraction',
kind: cmd.slice(1),
id: id,
});
return; return;
} }
default: default:
...@@ -1132,19 +764,14 @@ function handleInput() { ...@@ -1132,19 +764,14 @@ function handleInput() {
me = false; me = false;
} }
let username = getUsername();
if(!username) { if(!username) {
displayError("Sorry, you're anonymous, you cannot chat"); displayError("Sorry, you're anonymous, you cannot chat");
return; return;
} }
try { try {
let a = send({ serverConnection.chat(username, me ? 'me' : '', message);
type: 'chat',
id: myid,
username: username,
kind: me ? 'me' : '',
value: message,
});
} catch(e) { } catch(e) {
console.error(e); console.error(e);
displayError(e); displayError(e);
...@@ -1198,91 +825,6 @@ function chatResizer(e) { ...@@ -1198,91 +825,6 @@ function chatResizer(e) {
document.getElementById('resizer').addEventListener('mousedown', chatResizer, false); 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; let errorTimeout = null;
function setErrorTimeout(ms) { function setErrorTimeout(ms) {
...@@ -1317,29 +859,36 @@ function clearError() { ...@@ -1317,29 +859,36 @@ function clearError() {
setErrorTimeout(null); setErrorTimeout(null);
} }
async function getIceServers() { document.getElementById('userform').onsubmit = function(e) {
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) {
e.preventDefault(); e.preventDefault();
let username = document.getElementById('username').value.trim(); let username = document.getElementById('username').value.trim();
let password = document.getElementById('password').value; let password = document.getElementById('password').value;
setUserPass(username, password); setUserPass(username, password);
await serverConnect(); serverConnect();
}; };
document.getElementById('disconnectbutton').onclick = function(e) { 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() { function start() {
group = decodeURIComponent(location.pathname.replace(/^\/[a-z]*\//, '')); group = decodeURIComponent(location.pathname.replace(/^\/[a-z]*\//, ''));
let title = group.charAt(0).toUpperCase() + group.slice(1); let title = group.charAt(0).toUpperCase() + group.slice(1);
...@@ -1350,15 +899,11 @@ function start() { ...@@ -1350,15 +899,11 @@ function start() {
setLocalMute(localMute); setLocalMute(localMute);
myid = randomid(); document.getElementById('connectbutton').disabled = false;
getIceServers().catch(console.error).then(c => { let userpass = getUserPass();
document.getElementById('connectbutton').disabled = false; if(userpass)
}).then(c => { serverConnect();
let userpass = getUserPass();
if(userpass)
return serverConnect();
});
} }
start(); start();
{
"compilerOptions": {
"target": "ES6",
"allowJs": true,
"checkJs": true,
"declaration": true,
"emitDeclarationOnly": true,
"strictBindCallApply": true
},
"files": [
"protocol.js"
]
}
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment