diff --git a/README.md b/README.md index c2a7db9..6780840 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,25 @@ 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. + +At the moment, sending a file would transmit it only to the last connection made 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: + +
    +
      + +
    + + + + + + + + + + + + + + + + + + + + + + + +