Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save morhekil/d1ff977e40b63cdd6379a8dac81e506c to your computer and use it in GitHub Desktop.
Save morhekil/d1ff977e40b63cdd6379a8dac81e506c to your computer and use it in GitHub Desktop.
This article is to show how to inject JavaScript into iframs under iOS web view and so we can communicate with it.

[iOS - Swift] How to communicate with iFrames inside WebView

One of the core technologies at the heart of Onefill is its ability to auto-fill even the most complex form on a website with 100% precision, with a single tap. And the techology that built does allow us to do just that, but with one exception - iframes.

And while you might think that iframes are a thing of the past (who would use them these days, right?) - the reality is that they're often powering some of the most critical piece of functionality there is on online store - its payment form.

Many payment processors (e.g. Stripe, or Braintree) offer their customer a way to accept payment in the simplest possible way - by just dropping a piece of javascript onto their checkout page. That renders an iframe with a form provided by the payment processor, that actually takes your card details - so that merchant never sees them at all, and everyone involved in the process stays safer.

The problem for us is that this, together with modern browser security mechanisms like CORS and same-origin policy, means that we can't automate the process of entering credit card data if we only inject Onefill's Javascript helpers into the main page, and the payment form is rendered inside an iframe.

But, after some experimentation we have found an approach to support iframe - at least with iOS web view - and to communicate with it.

To run the code in this blog post you'll need:

  • Xcode: Version 8.2.1 (8C1002)
  • Swift: Apple Swift version 3.0.2 (swiftlang-800.0.63 clang-800.0.42.1)

Conclusion first

  1. Use WKWebView instead UIWebView or WebView for 2 reasons:
  2. Use built-in WKUserScript API to inject JS, set forMainFrameOnly as false to inject JS into every frames (including iframe)
  3. To communicate between iframe and app, use JS webkit API

Code

As it turned out, the problem can be solved if we split it into two parts: first, how to inject our Javascript helpers into an iframe. And second - how to establish interactions between our code in an iframe, and the one in the main frame.

Let's look at those one by one.

Injecting Javascript helpers into an iframe

Apparently it is really easy these days - all you need to do is to use WKUserScript API to inject JavaScript into all frames, and specifically - forMainFrameOnly parameter when creating an injectable user script object.

Example: Inject a script to make every h1 tag red.

import UIKit
import WebKit

class ViewController: UIViewController, WKUIDelegate, WKNavigationDelegate {
    var webView: WKWebView!

    override func loadView() {
        let webConfiguration = WKWebViewConfiguration()
        let contentController = WKUserContentController()
        let js: String = "var h1s = document.querySelectorAll('h1'); for (var i = 0; i < h1s.length; i++) { h1s[i].style.color = 'red' };"
        let userScript = WKUserScript(source: js, injectionTime: WKUserScriptInjectionTime.atDocumentEnd, forMainFrameOnly: false)
        contentController.addUserScript(userScript)
        webConfiguration.userContentController = contentController

        webView = WKWebView(frame: .zero, configuration: webConfiguration)
        webView.uiDelegate = self
        webView.navigationDelegate = self
        view = webView
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        let myURL = URL(string: "http://localhost:3000")
        let myRequest = URLRequest(url: myURL!)
        webView.load(myRequest)
    }
}

As you can see, all we have to do is just to create WKUserScript, and configure it with forMainFrameOnly: false - which means that this script will be executed in all frames of WKWebView, and not just the main one.

After that - add it to an instance of WKUserContentController, create a WKWebView with that controller, and load your page with iframe - you should see your code being executed in all of them.

Communicate with iFrame

Use JavaScript webkit API Almost same as “inject JS into iframe”, however, we need to implement WKScriptMessageHandler protocol to receive message sent by JavaScript.

Once we've got our code running inside all iframes, we need to communicate with it to execute specific tasks. This can be done by implementing WKScriptMessageHandler protocol, and adding that resulting class to WKUserContentController used with the web view.

Once it is done, the handler becomes available to Javascript code running inside the frame as window.webkit.messageHandlers.<handlerName> object, exposing postMessage method that accepts strings data and sends it back to the main iOS application.

The application then becomes the communication medium, passing and orchestrating messages back and forth between various frames on the page, and allowing them to work together and execute cross-iframe tasks.

Here's an example of the full configuration, with message handler being attached to the web view:

import UIKit
import WebKit

class ViewController: UIViewController, WKUIDelegate, WKNavigationDelegate, WKScriptMessageHandler {
    var webView: WKWebView!

    override func loadView() {
        let webConfiguration = WKWebViewConfiguration()
        let contentController = WKUserContentController()
        // Inject JavaScript which sending message to App
        let js: String = "window.webkit.messageHandlers.callbackHandler.postMessage('Hello from JavaScript');"
        let userScript = WKUserScript(source: js, injectionTime: WKUserScriptInjectionTime.atDocumentEnd, forMainFrameOnly: false)
        contentController.removeAllUserScripts()
        contentController.addUserScript(userScript)
        // Add ScriptMessageHandler
        contentController.add(
            self,
            name: "callbackHandler"
        )

        webConfiguration.userContentController = contentController

        webView = WKWebView(frame: .zero, configuration: webConfiguration)
        webView.uiDelegate = self
        webView.navigationDelegate = self
        view = webView
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        let myURL = URL(string: "http://localhost:3000")
        let myRequest = URLRequest(url: myURL!)
        webView.load(myRequest)
    }

    // Implement `WKScriptMessageHandler`,handle message which been sent by JavaScript
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if(message.name == "callbackHandler") {
            print("JavaScript is sending a message \(message.body)")
        }
    }
}

And this is all. Of course, WKWebView is available only on iOS 8 - but thankfully old OS support is that not big an issue on iOS platform.

Now off to implement the orchestration code, and to find a similar solution for Android!

PS: want to work with us on problems like this one? We're hiring - native mobile development, Javascript, and other roles - check out our jobs page and let's have a chat! http://onefill.com/meettheteam/

References

  1. wkwebview-and-javascript-in-ios-8-using-swift
  2. WKWebit
  3. 2014 WWDC about WKWebView
  4. Apple API Docs:
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment