Page MenuHomePhabricator

Investigate whether WhoWroteThat browser extension can have access to the `window` global on MediaWiki sites
Closed, ResolvedPublic

Description

Since the WhoWroteThat extension will be operating strictly on MediaWiki sites, and since we want to provide an experience that takes into account as many of the already-existing tools that MediaWiki offers (like right-to-left support, interface messaging, and access to tools that users are familiar with, like GuidedTour) we should check whether we can access, from within the browser extension code, the window global variable.

If we can do that, we can access things like mw.loader and mw.msg which would make development easier, maintainance easier, and help us provide the solutions we need and make them act and look like any other MediaWiki experience.

It appears browser extensions may allow for this inherently. Some resources:

We should investigate if this is doable and feasible in a way that is compatible with both Chrome and Firefox's requirements for security in their extension store.

Results of investigation

OK, it looks like there's agreement that the strategy is sound and acceptable. In that case, I am going to summarize it with an action plan for the team. Please see if this makes sense or if I missed anything:

Action plan

  • The WhoWroteThat script can be written as if it is running from within MW infrastructure (just like a gadget)
  • Our build step (currently using grunt, in the extension repo, but may change to use another tool) needs to do this:
    • Concatenate all WhoWroteThat business-logic files into a single script, with two parts:
      • a loader script: This attaches whatever trigger button we need on the page to activate the WhoWroteThat mode. This depends on jquery and mediawiki.base
      • Business logic: The entire logic of presenting the results from Whocolor, popups, etc. This depends on OOUI.
    • The "business logic" script can be wrapped with a mw.loader.using( 'ooui' ).then( ... ) statement, and then be included inside the loader script.
    • The loader (that includes, within it, a lazy-loaded business-logic script) will be wrapped by a loading method.
    • At the bottom of the concatenated file that has the loading function, we push the loading function into RLQ.
      • We only need the jquery and mediawiki.base dependencies through the RLQ load.
      • When the script sent to the RLQ runs, we have mw.loader.using available, so we can load the heavier dependencies only when we need them (when the toggle button is activated)
  • The entire file that was produced is our "web_accessible_resources" and is the file that the extension the injects into the DOM on Wikipedia sites that work with WhoColor

If this looks good and I didn't miss anything, I'll add some code snippets and put this up in the task description, so we can reference to it from our implementation tasks.

Here's what it should look like, generally:

function loadWhoWroteThat() {
	var $button = $( <a> ); // etc

	// CODE: Attach a toggle button somewhere in the DOM. jQuery is available.

	$button.click( function () {
		mw.loader.using( [ 'oojs-ui' ] ).then( function () {


			// CODE: This is handling the actual business logic of WhoWroteThat? service
			// pulling the API from WhoColor, replacing/toggling the new DOM, building
			// OOUI popups for viewing data about revisions, etc.

		} );
	
	} );
}

var q = window.RLQ || ( window.RLQ = [] );
q.push( [ [ 'jquery', 'mediawiki.base' ], loadWhoWroteThat ] );

Since this is the basic structure, our build step can allow us to work with individual files in development (meaning also allowing for proper unit tests, etc) and then build the files into the above structure.

Event Timeline

Some preliminary investigation on my end shows one direction we can go with. Chrome extensions are sandboxed; that is, the extension code itself cannot communicate with code inside the tab, and whatever is inside the DOM cannot communicate with the Chrome browser API. This is done for security reasons, and is understandable.

Since WhoWroteThat is, in its core, a sort of gadget, we could potentially run the entire script as if it's running from within the DOM. The upside is that if we ever want to use this as a script, we can quite simply copy/paste that in (with the caveat that we'd need to create some proxy for the WhoColor API through Cloud or something like that).

Extension files

manifest.json

{
  "manifest_version": 2,
  "content_scripts": [ {
    "matches": ["*://*.wikipedia.org/*"],
    "all_frames": true,
    "js": [ "js/lib/jquery.js", "js/contentScript.js" ],
	"run_at": "document_end"
  } ],
  "web_accessible_resources": ["js/pageScript.js"],
  "permissions": [
    "activeTab"
  ]
}

js/contentScript.js (Injecting the actual script into the DOM):

$( document ).ready( function () {
	// Inject page script into the DOM
	injectScript(chrome.extension.getURL('js/pageScript.js'), 'body');

	/**
	 * injectScript - Inject internal script to available access to the `window`
	 *
	 * @param  {type} file_path Local path of the internal script.
	 * @param  {type} tag The tag as string, where the script will be append (default: 'body').
	 * @see    {@link http://stackoverflow.com/questions/20499994/access-window-variable-from-content-script}
	 */
	function injectScript(file_path, tag) {
	    var node = document.getElementsByTagName(tag)[0];
	    var script = document.createElement('script');
	    script.setAttribute('type', 'text/javascript');
	    script.setAttribute('src', file_path);
	    node.appendChild(script);
		return script;
	}
} );

js/pageScript.js - the actual script that runs and can use the mw global

( function ( $ ) {
	$( document ).ready( function () {
		var $heading = $( 'h1#firstHeading' ),
			$content = $( '<span>' ),
			ajax_url = 'https://www.wikiwho.net/en/whocolor/v1.0.0-beta/' +
				encodeURIComponent( $heading.text().trim() ) + '/';

		$.ajax( {
			url: ajax_url,
			method: 'GET',
			dataType: 'json'
		} ).then(
			function ( result ) {
				OO.ui.alert( 'Content ready.' );
				$content.text(
					'Biggest conflict score: ' + result.biggest_conflict_score
				);
			},
			function () {
				$content.text( 'Could not load information from WhoColor API' );
			}
		);
		function load() {
			if ( !mw.loader.using ) {
				// HACK: This is for the sake of quick investigation ONLY!!!
				// A VERYVERY hacky way to wait until mw.loader.using is ready
				setTimeout( load, 50 );
				return;
			}

			mw.loader.using( [ 'oojs-ui' ] ).then( function () {
				popup = new OO.ui.PopupWidget( {
					$floatableContainer: $heading,
					$content: $content.text( 'Hello world!' ),
					padded: true,
					autoClose: true
				} );

				popup.$element.appendTo( $( 'body' ) );

				$( 'h1#firstHeading' ).click( function () {
					popup.toggle();
				} );
			} );
		}
		load();
	} );
}( jQuery ) );

Conclusion

This approach works, but it does mean we have to cheat quite a bit in order to get this to work with mw.loader.using since that does not immediately exist after the document is ready.

What we gain
  • We can use mw.loader.using to load OOUI and GuidedTour to give the user unified UX that suits our standards and that they already are familiar with
  • Less maintainance burden in the future
  • Ability to convert this to either a gadget or an extension, trivially
Downsides
  • This is not very clean approach, and the fact we need to use the "setTimeout" (or probably some setInterval in the non-investigation script) means we're pretty heavily abusing the system.
  • We're injecting a script into Wikipedias. We will need to make sure the code is always up to par with security concerns. That said, there are two aspects that mitigate this:
    • Since this is a browser extension, we control 100% of the pipeline, from reviewing/merging code and up to releasing a new extension version.
    • The extension has no user input at all. It collects data from an API that is familiar, and does some DOM manipulations, so XSS attacks are not much of a concern, unless the remote API is hacked (in which case we'll have much bigger problems)
Recommendations

First, I think we need to get some input here from more front-end considerations. I'm pinging @Krinkle and @Catrope since they're both the wizards of resource loader. Please take into consideration that this is a browser extension that we control. Your input is most welcome.

Second, I think we should highly favor this method as opposed to writing the extension externally to the loaders. If we do that, we will have to either include OOUI into the extension load (which would increase the payload by quite a lot to the user) or implement popup mechanisms ourselves, taking into account they must work with multiple languages and directionalities in content/interface. Having access to "GuidedTour" is a bonus as well, since part of the product requires having that intro to new users of the extension.

I'm pretty meh on doing this with a browser extension rather than a gadget, it feels like overkill for dodging CSP/privacy policy restrictions on making AJAX requests to a 3rd-party domain. Ideally we'd have infrastructure that lets users opt into 3rd party AJAX requests on a per-domain basis (with an acknowledgment of the privacy implications), but building that is a project in itself.

As for your approach, I think it makes sense to do as much as possible in the page's JS context so that you can use MW's JS libraries. Getting the timing to work in a way that's less hacky might be hard.

...or wait! I just had an idea while writing this. Your injected script could register an RL module using mw.loader.register(), then immediately implement it using mw.loader.implement(), and when you need it, load it with mw.loader.load() or mw.loader.using(). That way you could make it depend on OOUI and have ResourceLoader take care of the load timing for you.

mw.loader.register( 'whowrotewhat', '', [ 'oojs-ui' ] );
mw.loader.implement( 'whowrotewhat', function () {
    // Body of the module here
} );

// When you need it (OOUI won't be loaded until you run this)
mw.loader.load( 'whowrotewhat' );
// or
mw.loader.using( 'whowrotewhat' ).done( function () { ... } );

Ooh! This is interesting. My only question, though, is that wouldn't we be facing the same problem of running mw.loader.using( 'whowrotethat' ) too early? The main issue we're running into is that the pageScript that's injected is in itself running to early, so even if we use the available infrastructure to create a module, wouldn't we still have to wait for mw.loader.using to be available in order to instigate the actual loading of the script?

Yes, you're right, you can't use mw.loader.using(). You can use mw.loader.load() though.

...so if you wanted to lazy-load OOUI, you could make the whowrotewhat module not depend on OOUI, but instead use mw.loader.using() to load it when appropriate.

Wait, so, if we go with mw.loader.load on a module we implemented *and* defined as having an OOUI dependency, then, technically, mw.loader.load() is sufficient. We won't need to wait for dependencies and THEN run the code -- the definition itself does it for us.

... am I right?

Yes, you're right, you can't use mw.loader.using(). You can use mw.loader.load() though.

Both are defined asynchronously and both may be absent initially. mw.loader.load is part of the startup module and while that is loaded from an async script tag, it is guaranteed to be executed at domComplete/window-onload because it is a sub resource (just like images, stylesheets and synchronous scripts). However, domInteractive/dom-ready does not guarantee it.

The mw.loader.using method is ensured to be available for any ResourceLoader module is executed, but actually ships as part of an implicit default dependency for all modules (called "mediawiki.base"). And downloads in parallel to regular modules. This is an implementation detail, but it means that if you're observing from the outside, "window-onload" will not guarantee presence of mw.loader.using.

But... none of this matters. This use case is exactly why RLQ exists. Use the following:

whoWroteThat.init = function () {
 /* This can use mw.loader.using(), and jQuery etc. */
};

var q = window.RLQ || ( window.RLQ = [] );
q.push( [ 'jquery', 'mediawiki.base' ], whoWroteThat.init );

Then everything will work as expected. No timeouts, dom-readies, custom jquery, or other things required.

I have an alternative idea building off of yours that will allow us to lazy-load our dependencies when we need them, rather than immediately.

What if we split this to two operations in the browser extension:

  • We use the extension to inject the mw.loader.register and mw.loader.implement but we do not call any of them. They just exist in the background for ResourceLoader to know about.
  • At that stage (in the extension load, really) we inject our "activate WhoWroteThat" button into the DOM. We could use the dependencies here (we might want to inject to one of the menus, so having mw.util is helpful, but we can work around that just for this specific case)
  • We put the mw.loader.load( 'whowrotethat' ) in the button click handler.

This means that the initial operation of the extension doesn't care that resource loader isn't ready yet, we won't automatically load all assets for everyone until they ask for it.

The only problem here is that if someone is super fast in clicking that button, it may still be triggered before resource loader is ready, but I think that mw.loader.load is ready before the using so I think invoking it early might be okay; resource loader will load it when it's ready. Is that right?

Yes, you're right, you can't use mw.loader.using(). You can use mw.loader.load() though.

Both are defined asynchronously and both may be absent initially. mw.loader.load is part of the startup module and while that is loaded from an async script tag, it is guaranteed to be executed at domComplete/window-onload because it is a sub resource (just like images, stylesheets and synchronous scripts). However, domInteractive/dom-ready does not guarantee it.

The mw.loader.using method is ensured to be available for any ResourceLoader module is executed, but actually ships as part of an implicit default dependency for all modules (called "mediawiki.base"). And downloads in parallel to regular modules. This is an implementation detail, but it means that if you're observing from the outside, "window-onload" will not guarantee presence of mw.loader.using.

But... none of this matters. This use case is exactly why RLQ exists. Use the following:

myStuff.load = function () {
 /* This can use mw.loader.using(), and jQuery etc. */
};

var q = window.RLQ || ( window.RLQ = [] );
q.push( [ 'jquery', 'mediawiki.base' ], myStuff.load );

Then everything will work as expected. No timeouts, dom-readies, custom jquery, or other things required.

I don't 100% remember the details here, so @Catrope might, but when I tested this out, @Catrope took a look at the RLQ and we seemed to have concluded it won't work. Do you remember what the problem was?

In any case, I have a version of this running in Chrome. I'll give it a try and see if I can report back if there are any tangible problems. This looks like a sound approach, but once again, we'll probably need to split apart our process so we don't automatically load all the dependencies for anyone with the extension (so that we can have lazy-loading only when the "WhoWroteThat" button trigger is actually clicked)

I might be missing something here, but this didn't work for me. The browser extension itself requires jQuery, so I can immediately use $( document ).ready() in the contentScript just for that. So, I tested this:

  • The $.ajax runs first because in this test I don't care yet about the OOUI to fill in the content. The "load" function uses mw.loader.using to get OOUI and start a OO.ui.PopupWidget.
  • I push [ 'jquery', 'mediawiki.base' ] and load into RLQ.

Result:

  • The "content is ready" alert pops up
  • The load function never runs.

Am I missing something?

Here's the new code I tested:

( function ( $ ) {
	$( document ).ready( function () {
		var $heading = $( 'h1#firstHeading' ),
			$content = $( '<span>' ),
			ajax_url = 'https://www.wikiwho.net/en/whocolor/v1.0.0-beta/' +
				encodeURIComponent( $heading.text().trim() ) + '/';

		$.ajax( {
			url: ajax_url,
			method: 'GET',
			dataType: 'json'
		} ).then(
			function ( result ) {
				alert( 'content from the API is ready' ); // Testing only, of course
				$content.text(
					'Biggest conflict score: ' + result.biggest_conflict_score
				);
			},
			function () {
				$content.text( 'Could not load information from WhoColor API' );
			}
		);
		function load() {
			mw.loader.using( [ 'oojs-ui' ] ).then( function () {
				popup = new OO.ui.PopupWidget( {
					$floatableContainer: $heading,
					$content: $content.text( 'Hello world!' ),
					padded: true,
					autoClose: true
				} );

				popup.$element.appendTo( $( 'body' ) );

				$( 'h1#firstHeading' ).click( function () {
					popup.toggle();
				} );
			} );
		}

		var q = window.RLQ || ( window.RLQ = [] );
		q.push( [ 'jquery', 'mediawiki.base' ], load );
	} );
}( jQuery ) );

I don't know much about how browser extensions work behind the scenes, but I believe the following are mutually exclusive:

  • JavaScript code is actually executed in the context of a browser tab.
  • JavaScript code has access to a library automatically loaded by the browser (in your case, a copy of jquery.js).

This because the latter loads in a sandbox (it has to, otherwise it would conflict with the one on the page itself).

A library bundled with the browser extension could be loaded into the page context (just like you are loading your main script into that context), but that would have to be done manually.

Can you confirm that this script really does run in the page context, and that you have not manually loaded jQuery into that same context? And then explain how jQuery can be available there initially?

Per T227160#5305590 , the extension contains a script that runs in the browser tab context that then injects a <script>tag into the DOM with a second script, which in turn runs in the page context.

Re RLQ, we tried putting RLQ.push( function () { console.log( 'using', mw.loader.using ); } ); in the page script and I'm pretty sure we got using, undefined back. Now that I'm reading where RLQ is executed, that surprises me, because using should be available there. I forgot about the two-parameter usage of RLQ, you're right that we should use that.

You're also right that jQuery won't be available where she's trying to use it, all of that code would have to be wrapped in RLQ as well. @Mooeypoo: if you follow the pattern that @Krinkle proposed where everything, including every usage of jQuery, is wrapped in a function that's called by RLQ, does it work?

I don't know much about how browser extensions work behind the scenes, but I believe the following are mutually exclusive:

  • JavaScript code is actually executed in the context of a browser tab.
  • JavaScript code has access to a library automatically loaded by the browser (in your case, a copy of jquery.js).

Yeah, I forgot about that in my enthusiasm to test this out. This actually leads to a different timing issue, whereby I need to only execute the jQuery script when jQuery is actually available.
I'm currently getting errors of "jQuery is not available", but I didn't get those before. I'm going to revert to the original implementation (up above in the comment) and see if I can get this to run again.

I discovered why we were so confused: the code in @Krinkle's example was wrong. RLQ,push() takes one argument which is a two-element array, not two arguments. The signature is not q.push( [ dependency1, dependency2 ], func ), it's q.push( [ [ dependency1, dependency2], func ] )

Alright, now it's working:

function getAjax() {
	$( document ).ready( function () {
		var $heading = $( 'h1#firstHeading' ),
		ajax_url = 'https://www.wikiwho.net/en/whocolor/v1.0.0-beta/' +
			encodeURIComponent( $heading.text().trim() ) + '/';

		$.ajax( {
			url: ajax_url,
			method: 'GET',
			dataType: 'json'
		} ).then(
			function ( result ) {
				alert( 'content from the API is ready' ); // Testing only, of course
				$content.text(
					'Biggest conflict score: ' + result.biggest_conflict_score
				);
			},
			function () {
				$content.text( 'Could not load information from WhoColor API' );
			}
		);
	} );
}

function load() {
	mw.loader.using( [ 'oojs-ui' ] ).then( function () {
		var $heading = $( 'h1#firstHeading' ),
			$content = $( '<span>' ),
			popup = new OO.ui.PopupWidget( {
				$floatableContainer: $heading,
				$content: $content.text( 'Hello world!' ),
				padded: true,
				autoClose: true,
				classes: [ 'whowrotethat-popup' ]
			} );

		popup.$element.appendTo( $( 'body' ) );

		$heading.click( function () {
			popup.toggle();
		} );
	} );
}

var q = window.RLQ || ( window.RLQ = [] );
q.push( [ [ 'jquery' ], getAjax ] );
q.push( [ [ 'jquery', 'mediawiki.base' ], load ] );

I am running into a different issue now where I no longer have the reference of the popup $content, so I can't have the AJAX replace it -- but that's super minor and is a result of my very quick implementation test. In "real life" implementation, we'll be able to time those modules better and write them in a way where they reach into what they need when all is loaded.

This works!

OK, it looks like there's agreement that the strategy is sound and acceptable. In that case, I am going to summarize it with an action plan for the team. Please see if this makes sense or if I missed anything:

Action plan

  • The WhoWroteThat script can be written as if it is running from within MW infrastructure (just like a gadget)
  • Our build step (currently using grunt, in the extension repo, but may change to use another tool) needs to do this:
    • Concatenate all WhoWroteThat business-logic files into a single script, with two parts:
      • a loader script: This attaches whatever trigger button we need on the page to activate the WhoWroteThat mode. This depends on jquery and mediawiki.base
      • Business logic: The entire logic of presenting the results from Whocolor, popups, etc. This depends on OOUI.
    • The "business logic" script can be wrapped with a mw.loader.using( 'ooui' ).then( ... ) statement, and then be included inside the loader script.
    • The loader (that includes, within it, a lazy-loaded business-logic script) will be wrapped by a loading method.
    • At the bottom of the concatenated file that has the loading function, we push the loading function into RLQ.
      • We only need the jquery and mediawiki.base dependencies through the RLQ load.
      • When the script sent to the RLQ runs, we have mw.loader.using available, so we can load the heavier dependencies only when we need them (when the toggle button is activated)
  • The entire file that was produced is our "web_accessible_resources" and is the file that the extension the injects into the DOM on Wikipedia sites that work with WhoColor

If this looks good and I didn't miss anything, I'll add some code snippets and put this up in the task description, so we can reference to it from our implementation tasks.

Here's what it should look like, generally:

function loadWhoWroteThat() {
	var $button = $( <a> ); // etc

	// CODE: Attach a toggle button somewhere in the DOM. jQuery is available.

	$button.click( function () {
		mw.loader.using( [ 'oojs-ui' ] ).then( function () {


			// CODE: This is handling the actual business logic of WhoWroteThat? service
			// pulling the API from WhoColor, replacing/toggling the new DOM, building
			// OOUI popups for viewing data about revisions, etc.

		} );
	
	} );
}

var q = window.RLQ || ( window.RLQ = [] );
q.push( [ [ 'jquery', 'mediawiki.base' ], loadWhoWroteThat ] );

Since this is the basic structure, our build step can allow us to work with individual files in development (meaning also allowing for proper unit tests, etc) and then build the files into the above structure.