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( $(' + +
    + Network: + +
    +
      + +
    + + + + + + + + + + + + + + + + + + + + + + + + From c652c3f270b7d1fc4b26296f54899f61a93acd24 Mon Sep 17 00:00:00 2001 From: Shadi Akiki Date: Wed, 28 Jan 2015 12:51:01 +0200 Subject: [PATCH 2/2] Noted that sending a file would only send it to the most recent connection made --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index d698655..6780840 100644 --- a/README.md +++ b/README.md @@ -74,3 +74,5 @@ Individual connections can also be closed, or queried for their Round Trip Durat 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. + +At the moment, sending a file would transmit it only to the last connection made