Commit 93632029 authored by Sean Roberts's avatar Sean Roberts Committed by GitHub

Merge pull request #430 from evidentpoint/initial-multi-frame-support

Multiple frame detection and injection
parents 0306b229 3db43465
...@@ -327,6 +327,7 @@ gulp.task('serve-live-reload', ['serve-package'], function () { ...@@ -327,6 +327,7 @@ gulp.task('serve-live-reload', ['serve-package'], function () {
var LiveReloadServer = require('./scripts/gulp/live-reload-server'); var LiveReloadServer = require('./scripts/gulp/live-reload-server');
liveReloadServer = new LiveReloadServer(3000, { liveReloadServer = new LiveReloadServer(3000, {
clientUrl: `http://${packageServerHostname()}:3001/hypothesis`, clientUrl: `http://${packageServerHostname()}:3001/hypothesis`,
enableMultiFrameSupport: !!process.env.MULTI_FRAME_SUPPORT,
}); });
}); });
......
...@@ -4,11 +4,20 @@ var fs = require('fs'); ...@@ -4,11 +4,20 @@ var fs = require('fs');
var gulpUtil = require('gulp-util'); var gulpUtil = require('gulp-util');
var http = require('http'); var http = require('http');
var WebSocketServer = require('websocket').server; var WebSocketServer = require('websocket').server;
var urlParser = require('url');
function changelogText() { function readmeText() {
return fs.readFileSync('./README.md', 'utf-8'); return fs.readFileSync('./README.md', 'utf-8');
} }
function licenseText() {
return fs.readFileSync('./LICENSE', 'utf-8');
}
function changelogText() {
return fs.readFileSync('./CHANGELOG.md', 'utf-8');
}
/** /**
* @typedef Config * @typedef Config
* @property {string} clientUrl - The URL of the client's boot script * @property {string} clientUrl - The URL of the client's boot script
...@@ -33,43 +42,103 @@ function LiveReloadServer(port, config) { ...@@ -33,43 +42,103 @@ function LiveReloadServer(port, config) {
function listen() { function listen() {
var log = gulpUtil.log; var log = gulpUtil.log;
var server = http.createServer(function (req, response) { var server = http.createServer(function (req, response) {
var content = ` var url = urlParser.parse(req.url);
<html> var content;
<head>
<meta charset="UTF-8"> if (url.pathname === '/document/license') {
<title>Hypothesis Client Test</title> content = `
</head> <html>
<body> <head>
<div data-hypothesis-trigger style="margin: 75px 0 0 75px;"> <meta charset="UTF-8">
Number of annotations: <title>Hypothesis in-line frame document - License</title>
<span data-hypothesis-annotation-count>...</span> </head>
</div> <body>
<pre style="margin: 20px 75px 75px 75px;">${changelogText()}</pre> <pre style="margin: 20px;">${licenseText()}</pre>
<script> </body>
var appHost = document.location.hostname; </html>
`;
window.hypothesisConfig = function () { } else if (url.pathname === '/document/changelog') {
return { content = `
liveReloadServer: 'ws://' + appHost + ':${port}', <html>
<head>
// Open the sidebar when the page loads <meta charset="UTF-8">
openSidebar: true, <title>Hypothesis in-line frame document - Changelog</title>
}; </head>
}; <body>
<pre style="margin: 20px;">${changelogText()}</pre>
window.addEventListener('message', function (event) { </body>
if (event.data.type && event.data.type === 'reloadrequest') { </html>
window.location.reload(); `;
} } else {
}); var multiFrameContent = config.enableMultiFrameSupport ? `
<div style="margin: 10px 0 0 75px;">
var embedScript = document.createElement('script'); <button id="add-test" style="padding: 0.6em; font-size: 0.75em">Toggle 2nd Frame</button>
embedScript.src = '${config.clientUrl}'; </div>
document.body.appendChild(embedScript); <div style="margin: 10px 0 0 75px;">
</script> <iframe id="iframe1" src="/document/license" style="width: 50%;height: 300px;"></iframe>
</body> </div>
</html> <div id="iframe2-container" style="margin: 10px 0 0 75px;">
`; </div>` : '';
content = `
<html>
<head>
<meta charset="UTF-8">
<title>Hypothesis Client Test</title>
</head>
<body>
<div data-hypothesis-trigger style="margin: 75px 0 0 75px;">
Number of annotations:
<span data-hypothesis-annotation-count>...</span>
</div>
${multiFrameContent}
<pre style="margin: 20px 75px 75px 75px;">${readmeText()}</pre>
<script>
var appHost = document.location.hostname;
window.hypothesisConfig = function () {
return {
liveReloadServer: 'ws://' + appHost + ':${port}',
// Open the sidebar when the page loads
openSidebar: true,
// Needed for multi frame support
enableMultiFrameSupport: ${config.enableMultiFrameSupport},
embedScriptUrl: '${config.clientUrl}'
};
};
window.addEventListener('message', function (event) {
if (event.data.type && event.data.type === 'reloadrequest') {
window.location.reload();
}
});
var embedScript = document.createElement('script');
embedScript.src = '${config.clientUrl}';
document.body.appendChild(embedScript);
var iframeIsAdded = false;
document.querySelector('#add-test').addEventListener('click', function() {
if (!iframeIsAdded) {
var iframe1 = document.querySelector('#iframe1');
var iframeNew = iframe1.cloneNode();
iframeNew.src = "/document/changelog";
iframeNew.id = "iframe2";
iframeIsAdded = true;
document.querySelector('#iframe2-container').appendChild(iframeNew);
} else {
var iframe2 = document.querySelector('#iframe2');
iframe2.parentNode.removeChild(iframe2);
iframeIsAdded = false;
}
});
</script>
</body>
</html>
`;
}
response.end(content); response.end(content);
}); });
......
...@@ -81,6 +81,8 @@ module.exports = class Guest extends Delegator ...@@ -81,6 +81,8 @@ module.exports = class Guest extends Delegator
this.anchors = [] this.anchors = []
cfOptions = cfOptions =
enableMultiFrameSupport: config.enableMultiFrameSupport
embedScriptUrl: config.embedScriptUrl
on: (event, handler) => on: (event, handler) =>
this.subscribe(event, handler) this.subscribe(event, handler)
emit: (event, args...) => emit: (event, args...) =>
......
...@@ -20,6 +20,7 @@ if (window.wgxpath) { ...@@ -20,6 +20,7 @@ if (window.wgxpath) {
var $ = require('jquery'); var $ = require('jquery');
// Applications // Applications
var Guest = require('./guest');
var Sidebar = require('./sidebar'); var Sidebar = require('./sidebar');
var PdfSidebar = require('./pdf-sidebar'); var PdfSidebar = require('./pdf-sidebar');
...@@ -44,11 +45,21 @@ $.noConflict(true)(function() { ...@@ -44,11 +45,21 @@ $.noConflict(true)(function() {
var Klass = window.PDFViewerApplication ? var Klass = window.PDFViewerApplication ?
PdfSidebar : PdfSidebar :
Sidebar; Sidebar;
if (config.hasOwnProperty('constructor')) { if (config.hasOwnProperty('constructor')) {
Klass = config.constructor; Klass = config.constructor;
delete config.constructor; delete config.constructor;
} }
if (config.enableMultiFrameSupport && config.subFrameInstance) {
Klass = Guest;
// Other modules use this to detect if this
// frame context belongs to hypothesis.
// Needs to be a global property that's set.
window.__hypothesis_frame = true;
}
config.pluginClasses = pluginClasses; config.pluginClasses = pluginClasses;
window.annotator = new Klass(document.body, config); window.annotator = new Klass(document.body, config);
......
...@@ -3,7 +3,9 @@ Plugin = require('../plugin') ...@@ -3,7 +3,9 @@ Plugin = require('../plugin')
AnnotationSync = require('../annotation-sync') AnnotationSync = require('../annotation-sync')
Bridge = require('../../shared/bridge') Bridge = require('../../shared/bridge')
Discovery = require('../../shared/discovery') Discovery = require('../../shared/discovery')
FrameUtil = require('../util/frame-util')
debounce = require('lodash.debounce')
# Extracts individual keys from an object and returns a new one. # Extracts individual keys from an object and returns a new one.
extract = extract = (obj, keys...) -> extract = extract = (obj, keys...) ->
...@@ -11,11 +13,17 @@ extract = extract = (obj, keys...) -> ...@@ -11,11 +13,17 @@ extract = extract = (obj, keys...) ->
ret[key] = obj[key] for key in keys when obj.hasOwnProperty(key) ret[key] = obj[key] for key in keys when obj.hasOwnProperty(key)
ret ret
# Find difference of two arrays
difference = (arrayA, arrayB) ->
arrayA.filter (x) -> !arrayB.includes(x)
# Class for establishing a messaging connection to the parent sidebar as well # Class for establishing a messaging connection to the parent sidebar as well
# as keeping the annotation state in sync with the sidebar application, this # as keeping the annotation state in sync with the sidebar application, this
# frame acts as the bridge client, the sidebar is the server. This plugin # frame acts as the bridge client, the sidebar is the server. This plugin
# can also be used to send messages through to the sidebar using the # can also be used to send messages through to the sidebar using the
# call method. # call method. This plugin also enables the discovery and management of
# not yet known frames in a multiple frame scenario.
module.exports = class CrossFrame extends Plugin module.exports = class CrossFrame extends Plugin
constructor: (elem, options) -> constructor: (elem, options) ->
super super
...@@ -28,11 +36,16 @@ module.exports = class CrossFrame extends Plugin ...@@ -28,11 +36,16 @@ module.exports = class CrossFrame extends Plugin
opts = extract(options, 'on', 'emit') opts = extract(options, 'on', 'emit')
annotationSync = new AnnotationSync(bridge, opts) annotationSync = new AnnotationSync(bridge, opts)
handledFrames = []
this.pluginInit = -> this.pluginInit = ->
onDiscoveryCallback = (source, origin, token) -> onDiscoveryCallback = (source, origin, token) ->
bridge.createChannel(source, origin, token) bridge.createChannel(source, origin, token)
discovery.startDiscovery(onDiscoveryCallback) discovery.startDiscovery(onDiscoveryCallback)
if options.enableMultiFrameSupport
_setupFrameDetection()
this.destroy = -> this.destroy = ->
# super doesnt work here :( # super doesnt work here :(
Plugin::destroy.apply(this, arguments) Plugin::destroy.apply(this, arguments)
...@@ -50,3 +63,36 @@ module.exports = class CrossFrame extends Plugin ...@@ -50,3 +63,36 @@ module.exports = class CrossFrame extends Plugin
this.onConnect = (fn) -> this.onConnect = (fn) ->
bridge.onConnect(fn) bridge.onConnect(fn)
_setupFrameDetection = ->
_discoverOwnFrames()
# Listen for DOM mutations, to know when frames are added / removed
observer = new MutationObserver(debounce(_discoverOwnFrames, 300, leading: true))
observer.observe(elem, {childList: true, subtree: true});
_discoverOwnFrames = ->
frames = FrameUtil.findFrames(elem)
for frame in frames
if frame not in handledFrames
_handleFrame(frame)
handledFrames.push(frame)
for frame, i in difference(handledFrames, frames)
_iframeUnloaded(frame)
delete handledFrames[i]
_injectToFrame = (frame) ->
if !FrameUtil.hasHypothesis(frame)
FrameUtil.injectHypothesis(frame, options.embedScriptUrl)
frame.contentWindow.addEventListener 'unload', ->
_iframeUnloaded(frame)
_handleFrame = (frame) ->
if !FrameUtil.isAccessible(frame) then return
FrameUtil.isLoaded frame, () ->
_injectToFrame(frame)
_iframeUnloaded = (frame) ->
# TODO: Bridge call here not yet implemented, placeholder for now
bridge.call('destroyFrame', frame.src);
'use strict';
var proxyquire = require('proxyquire');
var isLoaded = require('../../util/frame-util.js').isLoaded;
describe('CrossFrame multi-frame scenario', function () {
var fakeAnnotationSync;
var fakeBridge;
var proxyAnnotationSync;
var proxyBridge;
var container;
var crossFrame;
var options;
var sandbox = sinon.sandbox.create();
beforeEach(function () {
fakeBridge = {
createChannel: sandbox.stub(),
call: sandbox.stub(),
destroy: sandbox.stub(),
};
fakeAnnotationSync = {};
proxyAnnotationSync = sandbox.stub().returns(fakeAnnotationSync);
proxyBridge = sandbox.stub().returns(fakeBridge);
var CrossFrame = proxyquire('../../plugin/cross-frame', {
'../annotation-sync': proxyAnnotationSync,
'../../shared/bridge': proxyBridge,
});
container = document.createElement('div');
document.body.appendChild(container);
options = {
enableMultiFrameSupport: true,
embedScriptUrl: 'data:,', // empty data uri
on: sandbox.stub(),
emit: sandbox.stub(),
};
crossFrame = new CrossFrame(container, options);
});
afterEach(function () {
sandbox.restore();
crossFrame.destroy();
container.parentNode.removeChild(container);
});
it('detects frames on page', function () {
// Create a frame before initializing
var validFrame = document.createElement('iframe');
container.appendChild(validFrame);
// Create another that mimics the sidebar frame
// This one should should not be detected
var invalidFrame = document.createElement('iframe');
invalidFrame.className = 'h-sidebar-iframe';
container.appendChild(invalidFrame);
// Now initialize
crossFrame.pluginInit();
var validFramePromise = new Promise(function (resolve) {
isLoaded(validFrame, function () {
assert(validFrame.contentDocument.body.hasChildNodes(),
'expected valid frame to be modified');
resolve();
});
});
var invalidFramePromise = new Promise(function (resolve) {
isLoaded(invalidFrame, function () {
assert(!invalidFrame.contentDocument.body.hasChildNodes(),
'expected invalid frame to not be modified');
resolve();
});
});
return Promise.all([validFramePromise, invalidFramePromise]);
});
it('detects removed frames', function () {
// Create a frame before initializing
var frame = document.createElement('iframe');
container.appendChild(frame);
// Now initialize
crossFrame.pluginInit();
// Remove the frame
frame.remove();
assert.calledWith(fakeBridge.call, 'destroyFrame');
});
it('injects embed script in frame', function () {
var frame = document.createElement('iframe');
container.appendChild(frame);
crossFrame.pluginInit();
return new Promise(function (resolve) {
isLoaded(frame, function () {
var scriptElement = frame.contentDocument.querySelector('script[src]');
assert(scriptElement, 'expected embed script to be injected');
assert.equal(scriptElement.src, options.embedScriptUrl,
'unexpected embed script source');
resolve();
});
});
});
it('excludes injection from already injected frames', function () {
var frame = document.createElement('iframe');
frame.srcdoc = '<script>window.__hypothesis_frame = true;</script>';
container.appendChild(frame);
crossFrame.pluginInit();
return new Promise(function (resolve) {
isLoaded(frame, function () {
var scriptElement = frame.contentDocument.querySelector('script[src]');
assert(!scriptElement, 'expected embed script to not be injected');
resolve();
});
});
});
it('detects dynamically added frames', function () {
// Initialize with no initial frame, unlike before
crossFrame.pluginInit();
// Add a frame to the DOM
var frame = document.createElement('iframe');
container.appendChild(frame);
return new Promise(function (resolve) {
// Yield to let the DOM and CrossFrame catch up
setTimeout(function () {
isLoaded(frame, function () {
assert(frame.contentDocument.body.hasChildNodes(),
'expected dynamically added frame to be modified');
resolve();
});
}, 0);
});
});
it('detects dynamically removed frames', function () {
// Create a frame before initializing
var frame = document.createElement('iframe');
container.appendChild(frame);
// Now initialize
crossFrame.pluginInit();
return new Promise(function (resolve) {
// Yield to let the DOM and CrossFrame catch up
setTimeout(function () {
frame.remove();
// Yield again
setTimeout(function () {
assert.calledWith(fakeBridge.call, 'destroyFrame');
resolve();
}, 0);
}, 0);
});
});
});
\ No newline at end of file
'use strict';
// Find all iframes within this iframe only
function findFrames (container) {
var frames = Array.from(container.getElementsByTagName('iframe'));
return frames.filter(isValid);
}
// Check if the iframe has already been injected
function hasHypothesis (iframe) {
return iframe.contentWindow.__hypothesis_frame === true;
}
// Inject embed.js into the iframe
function injectHypothesis (iframe, scriptUrl) {
var config = document.createElement('script');
config.className = 'js-hypothesis-config';
config.type = 'application/json';
config.innerText = '{"enableMultiFrameSupport": true, "subFrameInstance": true}';
var src = scriptUrl;
var embed = document.createElement('script');
embed.className = 'js-hypothesis-embed';
embed.async = true;
embed.src = src;
iframe.contentDocument.body.appendChild(config);
iframe.contentDocument.body.appendChild(embed);
}
// Check if we can access this iframe's document
function isAccessible (iframe) {
try {
return !!iframe.contentDocument;
} catch (e) {
return false;
}
}
// Check if this is an iframe that we want to inject embed.js into
function isValid (iframe) {
// Currently only checks if it's not the h-sidebar
return iframe.className !== 'h-sidebar-iframe';
}
function isLoaded (iframe, callback) {
if (iframe.contentDocument.readyState !== 'complete') {
iframe.addEventListener('load', function () {
callback();
});
} else {
callback();
}
}
module.exports = {
findFrames: findFrames,
hasHypothesis: hasHypothesis,
injectHypothesis: injectHypothesis,
isAccessible: isAccessible,
isValid: isValid,
isLoaded: isLoaded,
};
...@@ -52,6 +52,7 @@ function RPC (src, dst, origin, methods) { ...@@ -52,6 +52,7 @@ function RPC (src, dst, origin, methods) {
this._onmessage = function (ev) { this._onmessage = function (ev) {
if (self._destroyed) return; if (self._destroyed) return;
if (self.dst !== ev.source) return;
if (self.origin !== '*' && ev.origin !== self.origin) return; if (self.origin !== '*' && ev.origin !== self.origin) return;
if (!ev.data || typeof ev.data !== 'object') return; if (!ev.data || typeof ev.data !== 'object') return;
if (ev.data.protocol !== 'frame-rpc') return; if (ev.data.protocol !== 'frame-rpc') return;
......
...@@ -6,6 +6,7 @@ require('core-js/es6/set'); ...@@ -6,6 +6,7 @@ require('core-js/es6/set');
require('core-js/fn/array/find'); require('core-js/fn/array/find');
require('core-js/fn/array/find-index'); require('core-js/fn/array/find-index');
require('core-js/fn/array/from'); require('core-js/fn/array/from');
require('core-js/fn/array/includes');
require('core-js/fn/object/assign'); require('core-js/fn/object/assign');
require('core-js/fn/string/ends-with'); require('core-js/fn/string/ends-with');
require('core-js/fn/string/starts-with'); require('core-js/fn/string/starts-with');
......
...@@ -160,6 +160,7 @@ describe 'Bridge', -> ...@@ -160,6 +160,7 @@ describe 'Bridge', ->
} }
event = { event = {
source: fakeWindow
origin: 'http://example.com' origin: 'http://example.com'
data: data data: data
} }
...@@ -182,6 +183,7 @@ describe 'Bridge', -> ...@@ -182,6 +183,7 @@ describe 'Bridge', ->
} }
event = { event = {
source: fakeWindow
origin: 'http://example.com' origin: 'http://example.com'
data: data data: data
} }
......
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