Friday, October 8, 2010

Javascript Injection using UIWebView

UIWebView is awesome

The UIWebView class and its companion UIWebViewDelegate protocol have just a handful of methods -- far fewer than most UIKit classes -- but don't let their basic appearance fool you.

The most potent of UIWebView's instance methods is without a doubt stringByEvaluatingJavaScriptFromString. You pass it some JavaScript code in an NSString and the UIWebView executes it. The injected JavaScript has full access to the DOM tree of any web page that you load in the web view and can wreak all kinds of havoc, like rearranging all the DOM elements, rewriting the text on the page, injecting webkit animations, and so on.

Malicious apps masquerading as a souped-up web browser can extract passwords and other sensitive information off the web page you are on and send this data to an unauthorized third party. (That's why I generally use only the Safari web browser that comes with my iPhone/iPad to surf the web. You should too.)

On the other hand, non-malicious apps (like my Stupid Browser app) can take advantage of this awesome feature to thoroughly customize your web browsing experience. Let's see how all this works.

Get your JavaScript in an NSString

The first step is to get the JavaScript code you want to inject as an NSString object. You will typically put the code in a .js file in your app bundle and write a few lines of code to read it as a text file.
NSString *filePath = [[NSBundle mainBundle] pathForResource:@"lolcat" ofType:@"js"];
NSData *fileData = [NSData dataWithContentsOfFile:filePath];
NSString *jsString = [[NSString alloc] initWithData:fileData encoding:NSUTF8StringEncoding];
The tricky part is remembering that when you drag a .js file into Xcode, it may be added to the Compile Sources section of your app target, and you will get a mysterious compilation error when you try to compile. If this happens, simply drag your .js file out of Compile Sources into Copy Bundle Resources where it belongs.

Inject JavaScript into any web page

Once you have the JavaScript code represented as an NSString object, executing it in your UIWebView is trivial using UIWebView's stringByEvaluatingJavaScriptFromString method.

In Stupid Browser, I used something like the following:
NSString *script = [NSString stringWithFormat:@"%@ window.location.href = \"command://done\";", jsString];

[webView stringByEvaluatingJavaScriptFromString:script];
Attempting to set window.location.href to a nonsensical but easily parsed URI (e.g. command://done) is a great way to set up a callback system for your JavaScript code to communicate with the surrounding UIWebView (see the next section). Stupid Browser uses this technique to hide the "Processing..." status message after the injected JavaScript is done processing the web page.

Call out to Objective C from JavaScript

The UIWebView calls its delegate (an object that implements the UIWebViewDelegate protocol) several times over the course of loading content from the web. The delegate method we care most about is webView:shouldStartLoadWithRequest:navigationType:.

Remember that one line of JavaScript code from the last section that tries to set window.location.href to command://done? When this line is executed, the UIWebView object will call its delegate's webView:shouldStartLoadWithRequest:navigationType: method with a request to load content from this non-existent URI. This method is expected to return YES if the web view should proceed to load the content, or NO if the load request should be ignored.

Since command://done is just a figment of your app's imagination, your delegate should return NO. But before returning from the delegate method, your delegate can do whatever it wants to react to the command. Effectively you have used JavaScript to call an Objective C method. Pretty neat, huh.

The code snippet:

A few gotchas

So how do you detect that the page has finished loading so that you can inject your JavaScript? Turns out it's not as straightforward as calling stringByEvaluatingJavaScriptFromString in the webViewDidFinishLoad: delegate method. I found that with the more complex web pages, webViewDidFinishLoad: may be called quite a few times before the page is truly done loading. (I haven't figured out exactly what triggers this delegate method. If you know the answer, please leave a comment!) This can cause your injected JavaScript to be executed over and over again, which not only slows your app down but can also cause unintended side effects if your JavaScript code isn't idempotent, i.e. calling it multiple times does not give you the same outcome as calling it exactly once.

I used two techniques in Stupid Browser to get around this issue:

1. Make the JavaScript code idempotent.

The way you accomplish this is going to depend on what your JavaScript code actually does. If you are going through a bunch of DOM nodes and modifying them, you can mark the ones you've modified by appending a class name to it. Before modifying a particular node, simply check that it doesn't already contain the marker class.

Here's two JavaScript functions addClassName and hasClassName that will come in handy for this:


2. Wait before injecting the JavaScript code

I've seen cases where the webViewDidFinishLoad: method is called several times in rapid succession. You can "consolidate" these calls by delaying JavaScript injection using performSelection:withObject:afterDelay:. Here's what I used in the webViewDidFinishLoad: method in Stupid Browser:
[NSObject cancelPreviousPerformRequestsWithTarget:self];
[self performSelector:@selector(injectJavascript) withObject:nil afterDelay:1.0];
Wrap up

So there you have it -- a way to load any web page in a UIWebView and completely transform it using JavaScript that you inject into the page. The injected JavaScript can even invoke Objective C methods in the app to update the UI around the web view.

To see all this in action, check out my Stupid Browser app on the app store.

1 comment:

  1. Hello,

    I was looking for something like that exactly.

    I want to tweak one site for mobile viewing and since iPhone can't do GreaseMonkey and I can't do Obj-C (much), this is absolutely perfect for me.

    Will let you know how that turns out :)

    ReplyDelete