├── .gitignore
├── README.md
├── data
├── audio-iframe.html
├── audio-iframe.js
├── channels.js
├── chat-rtc.js
├── chat.html
├── domutils.js
├── freeze.js
├── logging.js
├── login-iframe.html
├── login-iframe.js
├── master.js
├── md5.js
├── mirror.js
├── rtc.js
├── share-worker.js
├── sharing.html
├── sharing.js
├── startup-help.html
├── user.js
└── wsecho.js
├── examples
└── index.html
├── lib
├── channels.js
├── chrome-md5.js
├── main.js
├── rtc.js
├── sharer.js
├── sidebar.js
├── startup-panel.js
└── user.js
├── package.json
├── server.js
├── site
├── blank.html
├── bootstrap
│ ├── css
│ │ ├── bootstrap.css
│ │ └── bootstrap.min.css
│ ├── img
│ │ ├── glyphicons-halflings-white.png
│ │ └── glyphicons-halflings.png
│ └── js
│ │ ├── bootstrap.js
│ │ └── bootstrap.min.js
├── client.js
├── homepage.css
├── homepage.html
├── share-button-screenshot.png
├── share-html-inner.js
├── share-html.js
├── share-link-2-screenshot.png
├── share-link-screenshot.png
├── view-inner.html
└── view.html
├── test
├── test_diff.html
└── test_jsmirror.html
└── todo.txt
/.gitignore:
--------------------------------------------------------------------------------
1 | Profile
2 | data/browsermirror.xpi
3 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Browser Mirror
2 |
3 | This is an experiment in session sharing through the browser.
4 |
5 | The goal is primarily to explore the feasibility of a particular technique; it is well possible that this will not be a feasible manner of session sharing, but exactly *why* should be interesting.
6 |
7 | ## Implementation
8 |
9 | This is not cooperative sharing, as in the case of Etherpad or Google Wave, but rather a master/client relationship. The master browser is the active browser, and runs all the Javascript. The mirror receives all the HTML (i.e., DOM elements) but no Javascript. Anything that *happens* happens on the master browser and is relayed to the mirror.
10 |
11 | The mirror in turn will capture events and relay them back to the master. (This is where feasibility becomes questionable.) So if for instance the mirror (client) user clicks on something, the specific element and the click event goes back to the master, where it is presented as though the master user clicked on the element. If this in turn has visible side-effects, these are then transmitted back to the mirror.
12 |
13 | Form inputs however are transmitted differently, in part because form status is not directly visible in the DOM, but also because the two browsers do not share a mouse cursor, place of focus, or typing cursor. Instead these edits are allowed in the mirror and the resulting values transmitted back to the master. There are problems however with things like an input that itself has a dynamic event handler, consider for instance `` which on focus removes the `Search...` text. This only happens in response to a focus event, and the focus event does not happen on the master. (Though perhaps it could be triggered, without actually triggering the master's true focus?)
14 |
15 | Editing conflicts are most likely to occur within these input fields as well. It is possible that a form of [Operational Transformation](http://en.wikipedia.org/wiki/Operational_transformation) could be used to specifically manage concurrent changes to form fields ([mobwrite](http://code.google.com/p/google-mobwrite/) could be used for this). It is unclear to me how WYSIWYG editors will work; though since `contentEditable` is a fairly explicit feature we could special case this as well.
16 |
17 | Notable iframes require additional work, but only insofar as compound pages must be handled.
18 |
19 | Flash is a complete no-go.
20 |
21 | ## Communication
22 |
23 | In addition to just sharing the same page, direct communication will be appropriate. Simple chat is implemented, as is the concept of highlighting an element. Because the two browsers are not necessarily rendering the same way (e.g., zoom levels, browser screen size, etc) all these operations have to happen on a DOM level. I.e., you can't point at a *place*, you can only point at an *element* (though we find the element closest to your place, and possibly we could use an offset to get finer resolution).
24 |
25 | There is a rough implementation of screen position, but I haven't figured out how to present that. Also noting changes would probably be useful, so you can see that the other user is changing something (potentially off-screen for you)
26 | .
27 | ## Bookmarklet or Plugin
28 |
29 | Right now this is implemented as a bookmarklet. This is relatively easy to work with and largely works. It cannot feasibly handle iframes, nor can it handle the transition to another page (i.e., if you click a link you will lose your sharing).
30 |
31 | Ultimately the master would probably be best as a plugin. This could manage the transitions, and it's possible some things will be revealed which can be solved in a plugin but could not with in-page Javascript.
32 |
33 | The mirror should probably not require any special browser functionality. This would also make it easier to share a session with any user, regardless of what they are using.
34 |
35 | ## Initiating the sharing
36 |
37 | Right now you are given a URL which the mirror user should go to, starting the sharing session. Later it's possible F1 or an Open Web App service could facilitate the starting of a shared session, utilizing things like your social contacts. This work seems to be progressing reasonably elsewhere, so this project won't attempt anything fancy.
38 |
39 | ## Native sharing
40 |
41 | A web application *written to be shared* can in many ways be more elegant than what is implemented here. For instance, it would be superior to share a Google Doc using their built-in sharing facilities than to use this technique. The reliability of course will be much higher right now, but even if this project worked exactly as intended it would still be better to use the first-class editing concepts built into the web application, which understand intention far better than this project can.
42 |
43 | One could imagine a formal way for a web page to indicate that it is shareable (and how), and for this to be a fallback when the page doesn't have a native sense of session sharing.
44 |
45 | ## Permissions
46 |
47 | Right now the mirror client basically has permission to do anything (though certain things like file selection are not possible). But it would be easy to give more limited permissions, for instance only permission to view a session, or to require confirmation before some actions take place (like browsing to another URL). With a plugin it would also be possible to allow the remote client to select a file (possibly using for tech support).
48 |
--------------------------------------------------------------------------------
/data/audio-iframe.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/data/audio-iframe.js:
--------------------------------------------------------------------------------
1 | var expectedOrigin = null;
2 | var source;
3 |
4 | var pc;
5 |
6 | // console messages don't show up in this context:
7 | console = {
8 | log: function () {
9 | var s = "";
10 | for (var i=0; i this._pingPollMax ? this._pingPollMax : time;
271 | this._pingTimeout = setTimeout(this._setupConnection.bind(this), time);
272 | },
273 |
274 | _receiveMessage: function (event) {
275 | if (event.source !== this.window) {
276 | return;
277 | }
278 | if (this.expectedOrigin && event.origin != this.expectedOrigin) {
279 | console.info("Expected message from", this.expectedOrigin,
280 | "but got message from", event.origin);
281 | return;
282 | }
283 | if (! this.expectedOrigin) {
284 | this.expectedOrigin = event.origin;
285 | }
286 | if (event.data == "hello") {
287 | this._pingReceived = true;
288 | if (this._pingTimeout) {
289 | clearTimeout(this._pingTimeout);
290 | this._pingTimeout = null;
291 | }
292 | if (this.onopen) {
293 | this.onopen();
294 | }
295 | this._flush();
296 | return;
297 | }
298 | this._incoming(event.data);
299 | },
300 |
301 | close: function () {
302 | this.closed = true;
303 | this._pingReceived = false;
304 | if (this._pingTimeout) {
305 | clearTimeout(this._pingTimeout);
306 | }
307 | window.removeEventListener("message", this._receiveMessage, false);
308 | if (this.onclose) {
309 | this.onclose();
310 | }
311 | }
312 |
313 | });
314 |
315 |
316 | /* Handles message FROM an exterior window/parent */
317 | var PostMessageIncomingChannel = AbstractChannel.subclass({
318 |
319 | constructor: function (expectedOrigin) {
320 | this.source = null;
321 | this.expectedOrigin = expectedOrigin;
322 | this._receiveMessage = this._receiveMessage.bind(this);
323 | window.addEventListener("message", this._receiveMessage, false);
324 | },
325 |
326 | toString: function () {
327 | var s = '[PostMessageIncomingChannel';
328 | if (this.source) {
329 | s += ' bound to source ' + s;
330 | } else {
331 | s += ' awaiting source';
332 | }
333 | return s + ']';
334 | },
335 |
336 | _send: function (data) {
337 | this.source.postMessage(data, this.expectedOrigin);
338 | },
339 |
340 | _ready: function () {
341 | return !!this.source;
342 | },
343 |
344 | _setupConnection: function () {
345 | },
346 |
347 | _receiveMessage: function (event) {
348 | if (this.expectedOrigin && this.expectedOrigin != "*" &&
349 | event.origin != this.expectedOrigin) {
350 | // FIXME: Maybe not worth mentioning?
351 | console.info("Expected message from", this.expectedOrigin,
352 | "but got message from", event.origin);
353 | return;
354 | }
355 | if (! this.expectedOrigin) {
356 | this.expectedOrigin = event.origin;
357 | }
358 | if (! this.source) {
359 | this.source = event.source;
360 | }
361 | if (event.data == "hello") {
362 | // Just a ping
363 | this.source.postMessage("hello", this.expectedOrigin);
364 | return;
365 | }
366 | this._incoming(event.data);
367 | },
368 |
369 | close: function () {
370 | this.closed = true;
371 | window.removeEventListener("message", this._receiveMessage, false);
372 | if (this._pingTimeout) {
373 | clearTimeout(this._pingTimeout);
374 | }
375 | if (this.onclose) {
376 | this.onclose();
377 | }
378 | }
379 |
380 | });
381 |
382 |
383 | var CHROME_CHANNEL_EVENT = "BrowserMirrorChromeChannel";
384 |
385 | /* Sends TO a window or iframe, from a CHROME window. Windows and
386 | iframes can't talk back to a chrome window, so we use another
387 | approach */
388 | var ChromePostMessageChannel = AbstractChannel.subclass({
389 | _pingPollPeriod: 100, // milliseconds
390 |
391 | constructor: function (win, doc, expectedOrigin) {
392 | this.expectedOrigin = expectedOrigin;
393 | this._pingReceived = false;
394 | this.doc = doc;
395 | if (win) {
396 | this.bindWindow(win, true);
397 | }
398 | this._receiveMessage = this._receiveMessage.bind(this);
399 | this.doc.addEventListener(CHROME_CHANNEL_EVENT, this._receiveMessage, false, true);
400 | },
401 |
402 | toString: function () {
403 | var s = '[ChromePostMessageChannel';
404 | if (this.window) {
405 | s += ' to window ' + this.window;
406 | } else {
407 | s += ' not bound to a window';
408 | }
409 | if (this.window && ! this._pingReceived) {
410 | s += ' still establishing';
411 | }
412 | return s + ']';
413 | },
414 |
415 | bindWindow: function (win, noSetup) {
416 | if (this.window) {
417 | this.close();
418 | }
419 | if (win && win.contentWindow) {
420 | win = win.contentWindow;
421 | }
422 | this.window = win;
423 | if (! noSetup) {
424 | this._setupConnection();
425 | }
426 | },
427 |
428 | _send: function (data) {
429 | this.window.postMessage(data, this.expectedOrigin || "*");
430 | },
431 |
432 | _ready: function () {
433 | return this.window && this.pingReceived;
434 | },
435 |
436 | _setupConnection: function () {
437 | if (this.closed || this._pingReceived || (! this.window)) {
438 | return;
439 | }
440 | this._send("hello");
441 | this._pingTimeout = setTimeout(this._setupConnection.bind(this), this._pingPollPeriod);
442 | },
443 |
444 | _receiveMessage: function (event) {
445 | var el = event.target;
446 | var data = el.getAttribute('data-payload');
447 | el.parentNode.removeChild(el);
448 | el = null;
449 | if (data == "hello") {
450 | this._pingReceived = true;
451 | if (this._pingTimeout) {
452 | clearTimeout(this._pingTimeout);
453 | this._pingTimeout = null;
454 | }
455 | if (this.onopen) {
456 | this.onopen();
457 | }
458 | this._flush();
459 | return;
460 | }
461 | self._incoming(data);
462 | },
463 |
464 | close: function () {
465 | this.closed = true;
466 | this.doc.remoteEventListener(CHROME_CHANNEL_EVENT, this._receiveMessage, false, true);
467 | if (this._pingTimeout) {
468 | clearTimeout(this._pingTimeout);
469 | }
470 | if (this.onclose) {
471 | this.onclose();
472 | }
473 | }
474 |
475 | });
476 |
477 |
478 | /* Handles message FROM an exterior CHROME window/parent, with events
479 | for handling the inability of these windows to talk to their chrome
480 | parents */
481 | var ChromePostMessageIncomingChannel = AbstractChannel.subclass({
482 |
483 | constructor: function () {
484 | this._receiveMessage = this._receiveMessage.bind(this);
485 | window.addEventListener("message", this._receiveMessage, false);
486 | },
487 |
488 | toString: function () {
489 | var s = '[ChromePostMessageIncomingChannel]';
490 | return s;
491 | },
492 |
493 | _ready: function () {
494 | return !! document.head;
495 | },
496 |
497 | _send: function (data) {
498 | var event = document.createEvent("Events");
499 | event.initEvent(CHROME_CHANNEL_EVENT, true, false);
500 | var el = document.createElement("BrowserMirrorPayloadElement");
501 | el.setAttribute("data-payload", data);
502 | document.head.appendChild(el);
503 | el.dispatchEvent(event);
504 | },
505 |
506 | _setupConnection: function () {
507 | },
508 |
509 | _receiveMessage: function (event) {
510 | if (event.source || event.origin) {
511 | // If either of these are set, it's not a postMessage from a
512 | // Chrome window
513 | // FIXME: warn? Or an option to warn in this case?
514 | return;
515 | }
516 | if (event.data == "hello") {
517 | if (this._ready()) {
518 | this._send("hello");
519 | } else {
520 | // Wait a moment to respond
521 | setTimeout((function () {
522 | this._receiveMessage(event);
523 | }).bind(this), 100);
524 | }
525 | return;
526 | }
527 | this._incoming(event.data);
528 | },
529 |
530 | close: function () {
531 | this.closed = true;
532 | window.removeEventListener("message", this._receiveMessage, false);
533 | if (this.onclose) {
534 | this.onclose();
535 | }
536 | }
537 |
538 | });
539 |
540 | /* Sends messages over a port. The "port" is the port object, such as self.port
541 | or worker.port; it must implement emit, on, and removeListener.
542 |
543 | Closed and Opened events are sent across the port to indicate when
544 | the remote channel is setup or closed.
545 | */
546 | var PortChannel = AbstractChannel.subclass({
547 |
548 | constructor: function (port, prefix) {
549 | this.prefix = prefix || '';
550 | this.port = port;
551 | this._incoming = this._incoming.bind(this);
552 | this._remoteOpened = this._remoteOpened.bind(this);
553 | this._remoteClosed = this._remoteClosed.bind(this);
554 | this._gotHello = false;
555 | },
556 |
557 | toString: function () {
558 | var s = '[PortChannel';
559 | s += ' port: ' + this.port.toString();
560 | if (this.prefix) {
561 | s += ' prefix: "' + this.prefix + '"';
562 | }
563 | s += ']';
564 | return s;
565 | },
566 |
567 | _setupConnection: function () {
568 | this.port.on(this.prefix + 'Send', this._incoming);
569 | this.port.on(this.prefix + 'Opened', this._remoteOpened);
570 | this.port.on(this.prefix + 'Closed', this._remoteClosed);
571 | this.port.emit("Opened");
572 | },
573 |
574 | destroy: function () {
575 | this.port.removeListener(this.prefix + 'Send', this._incoming);
576 | this.port.removeListener(this.prefix + 'Opened', this._remoteOpened);
577 | this.port.removeListener(this.prefix + 'Closed', this._remoteClosed);
578 | },
579 |
580 | _ready: function () {
581 | // FIXME: is any kind of ping necessary?
582 | return this._gotHello;
583 | },
584 |
585 | _remoteOpened: function (helloBack) {
586 | this._gotHello = true;
587 | if (! helloBack) {
588 | // We'll say hello back, in case our original hello was lost
589 | this.port.emit("Opened", true);
590 | }
591 | if (this.onopen) {
592 | this.onopen();
593 | }
594 | this._flush();
595 | },
596 |
597 | _remoteClosed: function () {
598 | this._gotHello = false;
599 | if (this.onclose) {
600 | this.onclose();
601 | }
602 | this.destroy();
603 | },
604 |
605 | close: function () {
606 | this.port.emit("Closed");
607 | this._remoteClosed();
608 | },
609 |
610 | _send: function (s) {
611 | this.port.emit("Send", s);
612 | }
613 |
614 | });
615 |
616 |
617 | /* This proxies to another channel located in another process, via port.emit/port.on */
618 | var PortProxyChannel = AbstractChannel.subclass({
619 |
620 | constructor: function (prefix, self_) {
621 | this.prefix = prefix || '';
622 | this.self = self_ || self;
623 | this._incoming = this._incoming.bind(this);
624 | this._remoteOpened = this._remoteOpened.bind(this);
625 | },
626 |
627 | toString: function () {
628 | var s = '[PortProxyChannel';
629 | if (typeof self == "undefined" || this.self !== self) {
630 | s += ' bound to self ' + this.self;
631 | }
632 | if (this.prefix) {
633 | s += ' with prefix "' + this.prefix + '"';
634 | }
635 | return s + ']';
636 | },
637 |
638 | _setupConnection: function () {
639 | this.self.port.on(this.prefix + "IncomingData", this._incoming);
640 | this.self.port.on(this.prefix + "Opened", this._remoteOpened);
641 | },
642 |
643 | _ready: function () {
644 | // FIXME: is any kind of ping necessary?
645 | return true;
646 | },
647 |
648 | _send: function (data) {
649 | this.self.port.emit(this.prefix + "SendData", data);
650 | },
651 |
652 | close: function () {
653 | try {
654 | this.self.port.emit(this.prefix + "Close");
655 | } catch (e) {
656 | console.log('Error on close', e, e.name);
657 | }
658 | this.self.port.removeListener(this.prefix + "IncomingData", this._incoming);
659 | this.self.port.removeListener(this.prefix + "Opened", this._remoteOpened);
660 | this.closed = true;
661 | if (this.onclose) {
662 | this.onclose();
663 | }
664 | },
665 |
666 | _remoteOpened: function () {
667 | // Note this isn't the same as _ready, because we rely on the
668 | // remote connection to do caching/buffering
669 | if (this.onopen) {
670 | this.onopen();
671 | }
672 | }
673 |
674 | });
675 |
676 | /* Will handle incoming requests for the given channel over a port.
677 | Returns a function that tears down this connection. The teardown
678 | happens automatically on close. */
679 | function PortIncomingChannel(channel, prefix, self_) {
680 | prefix = prefix || '';
681 | self_ = self_ || self;
682 | function remoteSendData(data) {
683 | channel.send(data);
684 | }
685 | function remoteClose() {
686 | self_.port.removeListener(prefix + "SendData", remoteSendData);
687 | self_.port.removeListener(prefix + "Close", remoteClose);
688 | channel.close();
689 | }
690 | self_.port.on(prefix + "SendData", remoteSendData);
691 | self_.port.on(prefix + "Close", remoteClose);
692 | channel.rawdata = true;
693 | channel.onmessage = function (data) {
694 | self_.port.emit(prefix + "IncomingData", data);
695 | };
696 | channel.onopen = function () {
697 | self_.port.emit(prefix + "Opened");
698 | };
699 | channel.onclose = function () {
700 | // FIXME: call remoteClose?
701 | self_.port.emit(prefix + "Closed");
702 | };
703 | return {
704 | close: remoteClose
705 | };
706 | }
707 |
708 | /* Echos all the connection proxying from from_ (the worker that wants
709 | a connection) to to_ (the worker that actually implements the
710 | connection). Returns a function that will tear down the connection. */
711 | function EchoProxy(from_, to_, prefix) {
712 | prefix = prefix || '';
713 | var bindings = [];
714 | function echo(name, source, dest) {
715 | function echoer() {
716 | var args = [name];
717 | for (var i=0; i
2 |
3 |
20 |
21 |
22 |
23 |
38 |
39 |