From caa833c1644f07823d388293210879837eceed6f Mon Sep 17 00:00:00 2001
From: Shadi Akiki
Date: Wed, 28 Jan 2015 12:38:37 +0200
Subject: [PATCH 1/2] Added multinode capability based on serverless-webrtc.js
Added export/import of answer/offers (via FileSaver js library) Added
nicknames (needed for distinguishing different recipients) Added network
display of recipients + individual send/latency/close buttons Documented in
README
---
README.md | 23 ++-
js/FileSaver.min.js | 7 +
js/SWGuestConnection.js | 84 +++++++++
js/SWHostConnection.js | 68 ++++++++
js/serverless-webrtc-multinode.js | 275 ++++++++++++++++++++++++++++++
serverless-webrtc-multinode.html | 143 ++++++++++++++++
6 files changed, 599 insertions(+), 1 deletion(-)
create mode 100644 js/FileSaver.min.js
create mode 100644 js/SWGuestConnection.js
create mode 100644 js/SWHostConnection.js
create mode 100644 js/serverless-webrtc-multinode.js
create mode 100644 serverless-webrtc-multinode.html
diff --git a/README.md b/README.md
index c2a7db9..d698655 100644
--- a/README.md
+++ b/README.md
@@ -6,10 +6,11 @@ WebRTC offer/answer exchange is performed manually by the users, for example
via IM. This means that the app can run out of `file:///` directly, without
involving a web server. You can send text messages and files between peers.
-This repository contains two different clients that can talk to each other:
+This repository contains three different clients that can talk to each other:
1. `serverless-webrtc.js` runs under node.js
2. `serverless-webrtc.html` runs in Chrome or Firefox
+3. `serverless-webrtc-multinode.html` runs in Chrome or Firefox
Chat is fully interoperable between all of the above (Node, Chrome, Firefox)
in any combination (tested with Chrome 35 and Firefox 29).
@@ -53,3 +54,23 @@ http://blog.printf.net/articles/2014/07/01/serverless-webrtc-continued
http://cjb.github.io/serverless-webrtc/serverless-webrtc.html
-- Chris Ball (http://printf.net/)
+
+#### Multi-node capability
+
+serverless-webrtc-multinode.html adds on top of serverless-webrtc.html the functionality of many-to-many connections.
+
+It just does so by making a new WebRTC peer connection for each new pair of users.
+
+Peer connections are either ''Guest'' connections (Bob the recipient), or ''Host'' connections (Alice the sender)
+
+An unordered list (labeled ''Network'') at the bottom of the page shows all the connections made between you and other users.
+
+You could send text to all connected users at once via the ''Send to all'' button, or to a particular user via the ''Send''
+
+button near his/her nickname in the ''Network'' list.
+
+Individual connections can also be closed, or queried for their Round Trip Duration (labeled RTD) via the ''Latency'' button.
+
+RTD is queried by sending an ''echo request'' package, which automatically triggers an ''echo response'' from the other user.
+
+One could optionally use different nicknames for different connections.
diff --git a/js/FileSaver.min.js b/js/FileSaver.min.js
new file mode 100644
index 0000000..e93092f
--- /dev/null
+++ b/js/FileSaver.min.js
@@ -0,0 +1,7 @@
+/*! @source http://purl.eligrey.com/github/FileSaver.js/blob/master/FileSaver.js */
+var saveAs=saveAs||"undefined"!==typeof navigator&&navigator.msSaveOrOpenBlob&&navigator.msSaveOrOpenBlob.bind(navigator)||function(a){"use strict";if("undefined"===typeof navigator||!/MSIE [1-9]\./.test(navigator.userAgent)){var k=a.document,n=k.createElementNS("http://www.w3.org/1999/xhtml","a"),w="download"in n,x=function(c){var e=k.createEvent("MouseEvents");e.initMouseEvent("click",!0,!1,a,0,0,0,0,0,!1,!1,!1,!1,0,null);c.dispatchEvent(e)},q=a.webkitRequestFileSystem,u=a.requestFileSystem||q||a.mozRequestFileSystem,
+y=function(c){(a.setImmediate||a.setTimeout)(function(){throw c;},0)},r=0,s=function(c){var e=function(){"string"===typeof c?(a.URL||a.webkitURL||a).revokeObjectURL(c):c.remove()};a.chrome?e():setTimeout(e,10)},t=function(c,a,d){a=[].concat(a);for(var b=a.length;b--;){var l=c["on"+a[b]];if("function"===typeof l)try{l.call(c,d||c)}catch(f){y(f)}}},m=function(c,e){var d=this,b=c.type,l=!1,f,p,k=function(){t(d,["writestart","progress","write","writeend"])},g=function(){if(l||!f)f=(a.URL||a.webkitURL||
+a).createObjectURL(c);p?p.location.href=f:void 0==a.open(f,"_blank")&&"undefined"!==typeof safari&&(a.location.href=f);d.readyState=d.DONE;k();s(f)},h=function(a){return function(){if(d.readyState!==d.DONE)return a.apply(this,arguments)}},m={create:!0,exclusive:!1},v;d.readyState=d.INIT;e||(e="download");if(w)f=(a.URL||a.webkitURL||a).createObjectURL(c),n.href=f,n.download=e,x(n),d.readyState=d.DONE,k(),s(f);else{a.chrome&&b&&"application/octet-stream"!==b&&(v=c.slice||c.webkitSlice,c=v.call(c,0,
+c.size,"application/octet-stream"),l=!0);q&&"download"!==e&&(e+=".download");if("application/octet-stream"===b||q)p=a;u?(r+=c.size,u(a.TEMPORARY,r,h(function(a){a.root.getDirectory("saved",m,h(function(a){var b=function(){a.getFile(e,m,h(function(a){a.createWriter(h(function(b){b.onwriteend=function(b){p.location.href=a.toURL();d.readyState=d.DONE;t(d,"writeend",b);s(a)};b.onerror=function(){var a=b.error;a.code!==a.ABORT_ERR&&g()};["writestart","progress","write","abort"].forEach(function(a){b["on"+
+a]=d["on"+a]});b.write(c);d.abort=function(){b.abort();d.readyState=d.DONE};d.readyState=d.WRITING}),g)}),g)};a.getFile(e,{create:!1},h(function(a){a.remove();b()}),h(function(a){a.code===a.NOT_FOUND_ERR?b():g()}))}),g)}),g)):g()}},b=m.prototype;b.abort=function(){this.readyState=this.DONE;t(this,"abort")};b.readyState=b.INIT=0;b.WRITING=1;b.DONE=2;b.error=b.onwritestart=b.onprogress=b.onwrite=b.onabort=b.onerror=b.onwriteend=null;return function(a,b){return new m(a,b)}}}("undefined"!==typeof self&&
+self||"undefined"!==typeof window&&window||this.content);"undefined"!==typeof module&&null!==module?module.exports=saveAs:"undefined"!==typeof define&&null!==define&&null!=define.amd&&define([],function(){return saveAs});
\ No newline at end of file
diff --git a/js/SWGuestConnection.js b/js/SWGuestConnection.js
new file mode 100644
index 0000000..dd16909
--- /dev/null
+++ b/js/SWGuestConnection.js
@@ -0,0 +1,84 @@
+// This is a moderatorless version of
+// http://www.rtcmulticonnection.org/docs/
+
+/* THIS IS BOB, THE ANSWERER/RECEIVER */
+
+function SWGuestConnection() {
+return {
+ username: null,
+ counterparty: null,
+ lastTimestamp: null,
+ latency: null,
+ pc2 : new RTCPeerConnection(cfg, con),
+ dc2 : null,
+ guestConnections: null,
+ p1: null,
+ init: function(un,pc2a2,p11) {
+ var self=this;
+ self.username=un;
+ self.guestConnections=pc2a2;
+ self.p1=p11;
+
+ self.pc2.ondatachannel = function (e) {
+ var fileReceiver2 = new FileReceiver();
+ var datachannel = e.channel || e; // Chrome sends event, FF sends raw channel
+ console.log("Received datachannel (pc2)", arguments);
+ self.dc2 = datachannel;
+ activedc = self.dc2;
+ self.dc2.onopen = function (e) {
+ console.log('data channel connect');
+ $('#waitForConnection').remove();
+ $('#showLocalAnswer').modal('hide');
+ setTimeout(function() { self.send('Initiated'); }, 500);
+ setTimeout(function() { self.send('','echo request'); }, 1500);
+ };
+ self.dc2.onmessage = function (e) { handleOnMessage(e,self); };
+ };
+
+ self.pc2.onsignalingstatechange = onsignalingstatechange;
+ self.pc2.oniceconnectionstatechange = function(e) { oniceconnectionstatechange(e,self) };
+ self.pc2.onicegatheringstatechange = onicegatheringstatechange;
+
+ self.pc2.onaddstream = function (e) {
+ console.log("Got remote stream", e);
+ var el = new Audio();
+ el.autoplay = true;
+ attachMediaStream(el, e.stream);
+ };
+
+ self.pc2.onconnection = handleOnconnection;
+
+ self.pc2.onicecandidate = function (e) {
+ console.log("ICE candidate (pc2)", e);
+ if (e.candidate == null)
+ $('#localAnswer').html(JSON.stringify(self.pc2.localDescription));
+ };
+
+ return self;
+ },
+ send: function(msg,type) {
+ var self=this;
+ self.dc2.send(JSON.stringify({
+ username: self.username,
+ message: msg,
+ timestamp: getTimestamp(),
+ type: type
+ }));
+ },
+ handleOfferFromPC1:function(offerDesc) {
+ var self=this;
+ self.pc2.setRemoteDescription(offerDesc);
+ self.pc2.createAnswer(function (answerDesc) {
+ self.pc2.setLocalDescription(answerDesc);
+ }, function () { console.warn("No create answer"); });
+ },
+ close: function() {
+ this.pc2.close();
+ if(this.echoId!=null) {
+ clearTimeout(this.echoId);
+ this.echoId=null;
+ }
+ },
+ echoId:null,
+};
+}
diff --git a/js/SWHostConnection.js b/js/SWHostConnection.js
new file mode 100644
index 0000000..cd47f1a
--- /dev/null
+++ b/js/SWHostConnection.js
@@ -0,0 +1,68 @@
+/* THIS IS ALICE, THE CALLER/SENDER */
+
+function SWHostConnection() {
+return {
+ username: null,
+ counterparty: null,
+ lastTimestamp: null,
+ latency: null,
+ pc1 : new RTCPeerConnection(cfg, con),
+ dc1 : null,
+ setupDC1: function() {
+ try {
+ var self=this;
+ self.dc1 = self.pc1.createDataChannel('test', {reliable:true});
+ activedc = self.dc1;
+ console.log("Created datachannel (pc1)");
+ self.dc1.onopen = function (e) {
+ console.log('data channel connect');
+ $('#waitForConnection').modal('hide');
+ $('#waitForConnection').remove();
+ self.send('Initiated');
+ setTimeout(function() { self.send('','echo request'); }, 1000);
+ };
+ self.dc1.onmessage = function (e) { handleOnMessage(e,self); };
+ } catch (e) { console.warn("No data channel (pc1)", e); }
+ },
+ createLocalOffer: function() {
+ var self=this;
+ self.setupDC1();
+ self.pc1.createOffer(function (desc) {
+ self.pc1.setLocalDescription(desc, function () {});
+ console.log("created local offer", desc);
+ }, function () {console.warn("Couldn't create offer");});
+ },
+ init: function(un) {
+ var self=this;
+ self.username=un;
+ self.pc1.onconnection = handleOnconnection;
+ self.pc1.onsignalingstatechange = onsignalingstatechange;
+ self.pc1.oniceconnectionstatechange = function(e) { oniceconnectionstatechange(e,self) };
+ self.pc1.onicegatheringstatechange = onicegatheringstatechange;
+ self.pc1.onicecandidate = function (e) {
+ console.log("ICE candidate (pc1)", e);
+ if (e.candidate == null) {
+ $('#localOffer').html(JSON.stringify(self.pc1.localDescription));
+ }
+ };
+ return self;
+ },
+ send: function(msg,type) {
+ var self=this;
+ self.dc1.send(JSON.stringify({
+ username: self.username,
+ message: msg,
+ timestamp: getTimestamp(),
+ type: type
+ }));
+ },
+ close: function() {
+ this.pc1.close();
+ if(this.echoId!=null) {
+ clearTimeout(this.echoId);
+ this.echoId=null;
+ }
+ },
+ echoId:null,
+};
+}
diff --git a/js/serverless-webrtc-multinode.js b/js/serverless-webrtc-multinode.js
new file mode 100644
index 0000000..ba19ecb
--- /dev/null
+++ b/js/serverless-webrtc-multinode.js
@@ -0,0 +1,275 @@
+/* See also:
+ http://www.html5rocks.com/en/tutorials/webrtc/basics/
+ https://code.google.com/p/webrtc-samples/source/browse/trunk/apprtc/index.html
+
+ https://webrtc-demos.appspot.com/html/pc1.html
+*/
+
+$('#sendMessageBtn').click(function() { return sendMessage(); });
+
+var cfg = {"iceServers":[{"url":"stun:23.21.150.121"}]},
+ con = { 'optional': [{'DtlsSrtpKeyAgreement': true}] };
+
+// Since the same JS file contains code for both sides of the connection,
+// activedc tracks which of the two possible datachannel variables we're using.
+var activedc;
+
+$('#showLocalOffer').modal('hide');
+$('#getRemoteAnswer').modal('hide');
+$('#waitForConnection').modal('hide');
+$('#createOrJoin').modal('hide');
+
+$('#addPeer').click(function() {
+ $('#createOrJoin').modal('show');
+});
+
+var hostConnections=[];
+$('#createBtn').click(function() {
+ hostConnections.push(SWHostConnection());
+ hostConnections[hostConnections.length-1].init($('#usernameInput').val());
+
+ $('#showLocalOffer').modal('show');
+ hostConnections[hostConnections.length-1].createLocalOffer();
+});
+
+var guestConnections=[];
+$('#joinBtn').click(function() {
+ guestConnections.push(SWGuestConnection());
+ guestConnections[guestConnections.length-1].init($('#usernameInput').val());
+ $('#getRemoteOffer').modal('show');
+});
+
+$('#offerSentBtn').click(function() {
+ $('#localOffer').html('');
+ $('#getRemoteAnswer').modal('show');
+});
+
+function handleOfferFromPC1(offerDesc,pc2i) {
+ pc2i.handleOfferFromPC1(offerDesc);
+ writeToChatLog("Created local answer", "text-success");
+ console.log("Created local answer: ", offerDesc);
+}
+
+$('#offerRecdBtn').click(function() {
+ var offer = $('#remoteOffer').val();
+ var offerDesc = new RTCSessionDescription(JSON.parse(offer));
+ console.log("Received remote offer", offerDesc);
+ writeToChatLog("Received remote offer", "text-success");
+ handleOfferFromPC1(offerDesc,guestConnections[guestConnections.length-1]);
+ $('#remoteOffer').val('');
+ $('#showLocalAnswer').modal('show');
+});
+
+$('#answerSentBtn').click(function() {
+ $('#localAnswer').html('');
+ $('#waitForConnection').modal('show');
+});
+
+function handleAnswerFromPC2(answerDesc,pci) {
+ console.log("Received remote answer: ", answerDesc);
+ writeToChatLog("Received remote answer", "text-success");
+ pci.setRemoteDescription(answerDesc);
+}
+
+$('#answerRecdBtn').click(function() {
+ var answer = $('#remoteAnswer').val();
+ var answerDesc = new RTCSessionDescription(JSON.parse(answer));
+ handleAnswerFromPC2(answerDesc,hostConnections[hostConnections.length-1].pc1);
+ $('#remoteAnswer').val('');
+ $('#waitForConnection').modal('show');
+});
+
+$('#fileBtn').change(function() {
+ var file = this.files[0];
+ console.log(file);
+
+ sendFile(file);
+});
+
+function fileSent(file) {
+ console.log(file + " sent");
+}
+
+function fileProgress(file) {
+ console.log(file + " progress");
+}
+
+function sendFile(data) {
+ if (data.size) {
+ FileSender.send({
+ file: data,
+ onFileSent: fileSent,
+ onFileProgress: fileProgress,
+ });
+ }
+}
+
+function sendMessage() {
+ if ($('#messageTextBox').val()) {
+ /*var channel = new RTCMultiSession();
+ channel.send({message: $('#messageTextBox').val()});*/
+ for(var i=0;i' + "[" + getTimestamp() + "] " + message + '
';
+ // Scroll chat text area to the bottom on new input.
+ $('#chatlog').scrollTop($('#chatlog')[0].scrollHeight);
+}
+
+function summarizeRTC1(x) { return {
+ username: x.username,
+ counterparty: x.counterparty,
+ local: x.pc1.localDescription,
+ remote: x.pc1.remoteDescription
+}; }
+
+function summarizeRTC2(x) { return {
+ username: x.username,
+ counterparty: x.counterparty,
+ local: x.pc2.localDescription,
+ remote: x.pc2.remoteDescription
+}; }
+
+$('#exportOffer').click(function() {
+var blob = new Blob([$('#localOffer').val()], {type: "application/json"});
+saveAs(blob, "offer.json");
+});
+
+$('#exportAnswer').click(function() {
+var blob = new Blob([$('#localAnswer').val()], {type: "application/json"});
+saveAs(blob, "answer.json");
+});
+
+function importOfferAnswer(fr,elem) {
+ var file = fr.files[0];
+
+ var reader = new window.FileReader();
+ reader.onload = function(event) {
+ if (event) {
+ $(elem).val(reader.result);
+ }
+ }
+ reader.readAsText(file);
+}
+$('#importOffer').change(function() { importOfferAnswer(this,'#remoteOffer'); });
+$('#importAnswer').change(function() { importOfferAnswer(this,'#remoteAnswer'); });
+
+
+function handleOnMessage(e,self) {
+ console.log("Got message", e.data);
+ var fileReceiver1 = new FileReceiver();
+ if (e.data.size) {
+ fileReceiver1.receive(e.data, {});
+ }
+ else {
+ if (e.data.charCodeAt(0) == 2) {
+ // The first message we get from Firefox (but not Chrome)
+ // is literal ASCII 2 and I don't understand why -- if we
+ // leave it in, JSON.parse() will barf.
+ return;
+ }
+ console.log(e);
+ var data = JSON.parse(e.data);
+ switch(data.type) {
+ case 'echo request':
+ self.send(data.timestamp,'echo response');
+ break;
+ case 'echo response':
+ self.latency=getTimestamp()+' - '+data.timestamp+' - '+data.message;
+ writeToChatLog("RTD: "+self.username+' x '+self.counterparty+' , '+self.latency);
+ if(self.echoId!=null) {
+ clearTimeout(self.echoId);
+ self.echoId=null;
+ }
+ break;
+ default:
+ // set the counterparty name
+ if(self.counterparty==null) {
+ self.counterparty=data.username;
+ $('#onlineUsers').append(
+ $('')
+ .append(self.username+' x '+self.counterparty+' ')
+ .append( $('