Porting a Chrome extension to Safari 14

December 08, 2020

With the release of Safari 14, Apple has made it possible to port Chrome extensions (or any extension made using the WebExtensions API) to Safari using a new conversion tool shipped with Xcode 12. I was quite happy to hear this as I have an extension that I built for Chrome and was easily able to port to Firefox and Microsoft Edge, but was confused about how to make a version compatible with Safari.

While I found it very easy to get my extension working in Safari with the new tool, it still required a small amount of tinkering to get it ready for distribution at the App Store. This was made more challenging by the fact that I had never deployed anything to the App Store before. In this blog post I'll lay out the steps I used to go from conversion to distribution. Hopefully much of this will become unnecessary in the future as Apple refines this conversion process.

Requirements

  • Xcode 12
  • Safari 14
  • macOS 10.14.6 or higher
  • jq (optional though recommended for build automation)

Creating an Xcode project

The conversion tool takes a directory with your extension's files (the directory with the manifest.json) and creates an Xcode project from it. For a hypothetical extension called "myext", this would look like:

xcrun safari-web-extension-converter myext-directory --project-location xcode-myext --app-name myext --bundle-identifier dev.jamesknight.myext

This will take my project directory (called "myext-directory") and convert it into an Xcode project in the current directory as "xcode-myext". The bundle identifier is a unique ID for distributing via the App Store and Apple recommends using reverse domain name notation. You do not need to own the domain but it makes sense to using something associated with you or your organization.

With this command you should have a working extension that can be tested in Safari (more on that below). However, it does not configure everything for distributing your extension at the App Store. In my case, it missed the following things:

  1. It only created a single icon, whereas extensions need seven for distribution.
  2. It did not set the version number from the manifest.json.
  3. The app category was not set, which has to be set in Xcode.

These can be done manually but I wanted to automate this and wrote the following bash script called "safari-build.sh":

# set build variables
buildNumber='1'
versionNumber=$(cat myext-directory/manifest.json | jq '.version')
infoPlist='xcode-myext/myext/myext/Info.plist'
infoPlistExtension='xcode-myext/myext/myext Extension/Info.plist'

# convert extension
xcrun safari-web-extension-converter myext-directory --project-location xcode-myext --app-name myext --bundle-identifier dev.jamesknight.myext --copy-resources --force --swift --no-open

# copy app icons
rm -rf xcode-myext/myext/myext/Assets.xcassets/AppIcon.appiconset
cp -r design/AppIcon.appiconset xcode-myext/myext/myext/Assets.xcassets

# update version and build numbers
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $buildNumber" "$infoPlist"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $buildNumber" "$infoPlistExtension"
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $versionNumber" "$infoPlist"
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $versionNumber" "$infoPlistExtension"

# set app category
/usr/libexec/PlistBuddy -c "Add :LSApplicationCategoryType string public.app-category.productivity" "$infoPlist"

First the script sets some variables. The build number is just a numerical ID required for submitting the extension that is not visible to users. It can be any number, however if you submit a new build and the version number does not change, you must create a new build number greater than the previous one. This was relevant for me as I was trying to figure out the build and distribution steps and Apple reported certain errors that needed to be fixed. Apple won't let you resubmit with the same version and build number, so instead of incrementing the version, which I didn't want to do because nothing had changed with the extension itself, I just incremented the build number.

The version number is extracted from the manifest using the "jq" tool for bash, and two files that need to be modified are defined.

With those variables set, I convert the extension using xcrun safari-web-extension-converter. I added a few additional arguments for my case. First --copy-resources will copy the project directory into the Xcode project itself. This isn't necessary and Xcode can reference the files from your project directory directly, but for me this directory can change depending on the browser I'm building for in production. By copying this directory over, my Safari build is always contained within my Xcode project as a distinct folder. --force will overwrite the destination Xcode project directory if it already exists. --swift tells it to use Swift for the native language. It can also be set to Objective-C, but Apple says to use Swift if you are unsure so that's what I did. Finally --no-open tells the convertor not to open Xcode when conversion finishes. Since I need to modify some files with my script, I chose to open Xcode manually after everything is complete.

After conversion my extension's icons are copied into the Xcode project. In this case the icons are in the directory "design/AppIcon.appiconset". A simple way to create the icons you need is to create one that is 1024 x 1024 pixels and then use a tool like App Icon Generator. If you use this tool, you only need to check the option for Mac icons.

Next, the version and build numbers are set in two places using Apple's PlistBuddy tool for modifying the files. And lastly I set the extension's app category, which in my case was "productivity" (see App Store categories for more options).

Finally, I created a script in my package.json to handle the building of my source code, which I do with Webpack, and the running of the Xcode conversion script.

{
  "scripts": {
    "build:safari": "NODE_ENV=production webpack --config webpack.prod.js && ./safari-build.sh"
  }
}

With that the extension is converted and ready for testing in Safari and for distribution.

Testing in Safari

At this point the terms "app" and "extension" become somewhat (and confusingly) equivalent or interchangeable. From Xcode's perspective it is an app that you will create and upload to App Store Connect, while from the App Store you can find a subsection for extensions and within Safari the term "extension" is used. For me this caused some confusion because I assumed I would need to explicitly specify that I was uploading and distributing an extension, but this is all implicit within the conversion process itself (xcrun) and the App Store will know how to handle your app (as an extension).

To test the new extension in Safari you first need to create an app from it in Xcode and then configure Safari to allow the use of unsigned extensions.

  1. Open Xcode and from the File menu bar open your project. The relevant file to open in this example would be "xcode-myext/myext/myext.xcodeproj".
  2. Once the project has loaded, click the run button to build the app. A new window will open prompting you to configure Safari.
  3. Open Safari and from the menu bar go to Safari > Preferences.
  4. On the Preferences window, select the Advanced tab, then select the “Show Develop menu in menu bar” checkbox.
  5. Leave the Preferences window open, but go back to the Safari menu bar and choose Develop > Allow Unsigned Extensions. Unfortunately this option is reset whenever Safari is closed, so you'll have to redo it whenever you reopen Safari for testing your extensions.
  6. Go back to the Preferences window and select the Extensions tab. Find the extension in the list on the left, and enable it by selecting the checkbox.
  7. Close the Preferences window and your extension is ready for testing. You may however need to click on its icon in the toolbar to grant whatever access it requires.

If your extension is working, you are ready for distribution. Otherwise, make any changes you need and rebuild and/or convert again as needed.

App Store configuration

To distribute the app you will need to do some configuration at the App Store and then upload your app from Xcode. First, however, you will need an Apple Developer account ($100 USD/$119 CAD per year). I'm going to specifically show how to distribute through the App Store although as long as the app is signed with a developer certificate it can be distributed independently.

Create a bundleID

Once you have a developer account, you'll need to create a bundle ID. This is the same ID you used in the conversion step, which in my case was "dev.jamesknight.myext". This can be done from the Certificates, Identifiers & Profiles page.

  1. Click on the blue "plus" icon next to where it says "Identifiers".
  2. Then select "App ID" and click Continue.
  3. Select the "App" icon and click Continue again.
  4. Then describe your identifier, for example "myext extension", and enter your bundle ID.
  5. Finally click Continue and then Register.

Setup your app at App Store Connect

The bundleID is now available for use and you can configure your app at App Store Connect.

  1. Sign in to App Store Connect.
  2. Click on "My Apps" and then click the blue "plus" icon in the upper left. Select "New App" from the dropdown.
  3. From the modal you'll select "mac OS", give the app a name and choose the language. If you have registered your bundle ID, you'll be able to select it from the dropdown, otherwise you won't be able to complete this step. Under "SKU" I also used my bundle ID, although once the app is accepted to the store it will be assigned a numerical SKU.
  4. Hit the create button.

You'll go to your app's page and you can enter all of the meta data for it. One thing I would recommend is adding "for Safari" to your app's name to clearly distinguish it within the App Store. There does not seem to be any indicator on extension pages in the App Store to signal they are extensions for Safari, other than what the authors define themselves in the app's name or description. You can imagine some poor user installing one thinking it a regular app and then not finding it in their Applications folder.

Distribution

Once your App Store configuration is complete, you can upload your app from Xcode:

  1. Archive the app in Xcode from the menu bar at Product > Archive.
  2. On the archive window that opens, select your new archive and click "Validate App". This is technically optional, but it will highlight any issues you may have with your build. Use all of the default options when prompted.
  3. When ready click "Distribute App". You'll be prompted for the same information as during the validation step.

If the build uploads successfully you will find it and its associated status under the "Activity" tab on your App Store Connect page. When the build is ready, go back to the "App Store" tab, associate the build with the project and submit for review.

Next steps

After being accepted your app will be assigned an url that will look like https://apps.apple.com/us/app/[mxext]/[sku], where "myext" is the name you gave the app at App Store Connect and "sku" is the numerical identifier that gets assigned to it (it will look like id1234567890).

Within ~24 hours of its acceptance to the App Store, users should be able to search for it there as well.

© James Knight, 2020.