Chunliang Lyu

I am a developer and researcher from China. Love to make fun and useful stuff. Co-founded Hyperlink.

From Chrome Extension to Safari Extension

Recently we have converted our Chrome extension to Safari extension. Although Chrome and Safari share the same WebKit engine, their extensions have slightly different structure. This post walks you through major differences between Chrome and Safari extensions.

NOTE: Before you begin, you may want to register as a Safari Developer and get a certificate. Otherwise, there is no way to debug you extension with Safari. I assume you are already familiar with the Chrome extension development. If not, you can check my previous post on Developing Chrome Extension with AngularJS.

Terms

Apple provides a short description about converting Chrome extension to Safari here. It is more like a terms mapping:

Chrome Safari
Background page Global page
Popup popover
browser/page action toolbar item

Meta data

Chrome uses manifest.json to hold package data, while Safari uses Info.plist. Another plist file is Settings.plist, if you use their provided settings module.

Content scripts/styles

In Chrome extension, you can inject different scripts/styles for different domain sets. Safari only allows you to specify one set of scripts/styles for all matched domains.

Message Passing

In Safari, each message is an event, holding name and message properties. Unlike Chrome extension, there is no callback function after sending message in Safari. You need to handle the callback manually by sending another message.

In the global page, you can listen and process messages like:

safari.application.addEventListener('message', function (event) {
  switch (messageEvent.name) {
    case 'search':
      var query = messageEvent.message;
      handleSearchRequest(query, function (html) {
        // send back the result
        safari.application.activeBrowserWindow.activeTab.page.dispatchMessage('search:back', html);
          });
      break;
}

In the content scripts, you send message and process the callback like:

safari.self.tab.dispatchMessage("search", query);
safari.self.addEventListener("message", function (messageEvent) {
  if (messageEvent.name === "search:back") {
    var html = messageEvent.message;
  }
}, false);

Assets

According to the doc, if you reference images or other external resources in your style sheets, you can use relative URLs to indicate sources within your extension package, relative to the style sheet. This is more convenient compared to Chrome, which need manually specify the absolute path for extension assets.

Cross-Origin Request

Like Chrome extension, you can communicate with server either from content page, or from global page

from global page

Both Chrome and Safari allows you to send Cross-Origin Request from background/global page. In Chrome, you allow CORS by adding your domain to permission in manifest.json; in Safari, you add your domain in Extension Website Access.

However, Safari and Chrome handles cookie differently. Chrome will use all the available cookie in extension ajax call as in the regular website. Safari does not send HTTP Only Cookies. Session id is usually stored in HTTP Only cookie, and this means the login status is not preserved in Safari by default. If this bothers you, you should send requests from content scripts.

from content page

If you send AJAX requests from content scripts. Safari will use all cookies (including HTTP Only cookie). However, this needs server-side support.

You can open any other website (not on your own domain), say https://www.google.com, open console, and test with the following snippets:

var req = new XMLHttpRequest();

req.open('GET', 'https://hyperlinkapp.com/api/user/', true);
req.setRequestHeader('Content-Type', 'application/json');
req.withCredentials = true;
req.onreadystatechange =  function() {
  console.log('done');
};
req.send();

Standard CORS requests do not send or set any cookies by default. By setting the XMLHttpRequest’s .withCredentials property to true, we tell the browser to include cookies as part of the request. The browser will first send a OPTIONS request and check whether the current domain is allowed to send CORS request. If your server does not have CORS support (like ours), you will see that the request is refused by the browser. To allow CORS from a specific domain, (https://www.google.com in this example), you need to add proper headers in server response. For example, here is a valid config in nginx.

location /api/ {
    if ($request_method = OPTIONS ) {
        add_header Access-Control-Allow-Origin https://www.google.com;
        add_header Access-Control-Allow-Credentials true;
        add_header Access-Control-Allow-Headers origin;
        add_header Access-Control-Allow-Headers content-type;
        return 200;
    }

    proxy_pass http://127.0.0.1:3032;

    add_header Access-Control-Allow-Origin https://www.google.com;
    add_header Access-Control-Allow-Credentials true;
    add_header Access-Control-Allow-Headers origin;
    add_header Access-Control-Allow-Headers content-type;
}

Misc

Safari need to be restarted quite frequently

Developing extension with Safari is not that pleasant compared to Chrome. If things does not work as predicted, try restart Safari. For example, if you add a new injected scripts/styles, reloading extension/webpages is not enough and you need to restart Safari.

Packaging

You can package you extension using Safari's Extension Builder. A better way is to automate the process by scripts. You can check this article for details.