Skip to content

Convert a Chrome extension to Safari: the command, and what breaks after

7 min readModerok team

Convert a Chrome extension to Safari with safari-web-extension-packager, set the background key your MV3 service worker needs, and see what Safari ignores.

To convert a Chrome extension to Safari, point Apple's packager at your unpacked extension folder:

xcrun safari-web-extension-packager --macos-only --copy-resources /path/to/extension

Apple's docs note the rename plainly: "This tool used to be named safari-web-extension-converter." That command writes an Xcode project containing a macOS app that installs your extension in Safari. It is a starting point, not a port. Below: the background key that decides whether your Manifest V3 service worker runs at all, and the keys and APIs Safari then ignores.

What the packager actually produces

Apple lists three things it does: create an Xcode project, configure that project with a macOS or iOS app that installs the extension in Safari, and configure a Safari web extension with your extension files inside it.

Two flags are worth setting on the first run. --macos-only keeps the project to one platform while you hunt breakage, and --copy-resources decides who owns your files. Apple flags the default as important: "By default, the packager creates a reference in the Xcode project to the resources in the path you provide. As a result, changes you make to the original extension update your packaged Safari web extension and vice versa." If your build output is a dist/ folder the bundler wipes each time, the referencing project points at files that keep disappearing. A third flag matters later: --rebuild-project, which Apple documents for adding iOS to an existing project.

Why your Manifest V3 service worker may not run in Safari

This is the failure that looks like a successful conversion in which nothing happens. Safari supports MV3 service workers, but may not choose yours.

Chrome's migration guide tells you to "Replace "background.scripts" with "background.service_worker" in the manifest.json". That leaves Chrome one option. Safari has two, and MDN's background reference states which it picks: Safari "supports background.scripts (or background.page) and background.service_worker", and "when both are specified, Safari uses background.scripts (or background.page) unless preferred_environment is set to service_worker".

So a manifest carrying both keys runs as a document in Safari and a service worker in Chrome. Code that assumes a ServiceWorkerGlobalScope, or branches on self.registration, takes a different path, with no error. To force the service worker, add preferred_environment, which MDN calls "An array of string listing the preferred environments in order of precedence":

{
  "manifest_version": 3,
  "background": {
    "service_worker": "background.js",
    "scripts": ["background.js"],
    "preferred_environment": ["service_worker"]
  },
  "permissions": ["storage"]
}

Four version floors decide whether that manifest is safe to ship. MDN's compatibility data records background.service_worker as added in Safari 15.4 and background.preferred_environment in Safari 18, so on Safari 15.4 through 17 a both-keys manifest runs the document path whatever you ask for. MDN's browser-support notes give the other two, the reason not to ship this as your only manifest: "before Chrome 121, Chrome refuses to load a Manifest V3 extension with background.scripts or background.page present. From Chrome 121, their presence in a Manifest V3 extension is ignored", and for Firefox, "Before Firefox 120, Firefox did not start the background page if service_worker was present (see Firefox bug 1860304). From Firefox 121, the background page starts as expected, regardless of the presence of service_worker."

Read together: it reaches a service worker on Chrome 121 and later, starts an event page on Firefox 121 and later, and reaches Safari's service worker only on Safari 18 and later. On any Chrome before 121 it fails to load at all. Below that floor, emit a per-browser manifest from your build, and declare the Safari requirement:

{
  "browser_specific_settings": {
    "safari": { "strict_min_version": "18.0" }
  }
}

If you keep a scripts background for iOS, write persistent out rather than dropping it. Apple's compatibility page says "In iOS, you need to set the persistent attribute to false", and its optimizing page is firmer: "In iOS, you must make your background page nonpersistent." MDN's data agrees for Safari on iOS, recording that only non-persistent pages are supported and that persistent: false or service_worker is required. The value to avoid is the opposite: "Setting to true in Manifest V3 results in an error."

Read the packager's manifest warnings

The packager prints a list of keys Safari does not understand. Apple's docs show its shape:

Warning: The following keys in your manifest.json are not supported by your
current version of Safari. If these are critical to your extension, you
should review your code to see if you need to make changes to support Safari:
downloads
offline_enabled

That list is one of two places to look, and Apple points at the other: "Review any reported exceptions, and see Assessing your Safari web extension's browser compatibility for details on how to resolve them." At runtime there is nothing further, because "Generally, Safari web extensions ignore any unsupported manifest keys, but you may need to develop workarounds or alternative approaches for any incompatibilities."

From that same manifest table, one entry to plan around: update_url is "Not supported. Handle Safari web extension updates with the App Store." A related rule sits in Apple's implementation guidance rather than the key table: "Safari ignores file URL schemes in manifest permissions."

What Apple's API table says to plan around

There is no packager warning for JavaScript APIs, only this: "Generally, Safari ignores any unsupported JavaScript APIs, but you may need to develop workarounds or alternative approaches for any incompatibilities." For iOS, Apple's advice is to test before you call: "iOS doesn't support some APIs. To prevent throwing exceptions, check for the existence of APIs in iOS before using them." The entries most likely to matter:

  • runtime: "setUninstallURL, onUpdateURL not supported." Any uninstall-attribution flow built on chrome.runtime.setUninstallURL is dead weight in Safari. See tracking uninstalls with setUninstallURL for what it does and does not catch on Chrome.
  • storage.sync: "Storage mechanism implemented, but syncing not supported." Reads and writes succeed, so nothing throws, but a value you wrote on one device will not appear on another.
  • identity: "Not supported. Initiate an OAuth flow in a new tab."
  • iOS gaps: webRequest is "Not supported in iOS", and contextMenus, menus likewise "Not supported in iOS".
  • storage.session: "Supported in Safari 16.4 or later."

No namespace shim needed, at least: Apple states that "Safari web extensions support both the chrome.* and browser.* namespaces" and both "the callback and Promise approaches for asynchronous APIs".

Testing it without building the Xcode project

For a first pass you can skip Xcode. Apple documents a temporary install, in Safari Settings under the Developer tab:

Click “Add Temporary Extension…”, then respond to Safari’s unsigned extension authentication prompt, if you haven’t already selected the “Allow unsigned extensions” option.

Two expiry rules catch everyone: Apple states that "Safari removes temporary extensions after 24 hours or when you quit Safari", and that the allow-unsigned-extensions setting "resets when you quit Safari". Once you move to the Xcode project and have run the containing app, which Apple requires in order to install the extension, its troubleshooting page adds a terminal check, pluginkit -mAvvv -p com.apple.Safari.web-extension: "If your extension is installed and visible to Safari, the command lists information about it."

For the background console, use the Develop menu rather than hunting for a DevTools link: "To debug background scripts in macOS, choose Develop > Web Extension Background Pages and select your background script. In Safari 17 and later in macOS, choose Develop > Web Extension Background Content, then select your background script and profile."

Firefox differences

Firefox needs no packager, and it needs the opposite background key. MDN records that in Firefox "background.service_worker is not supported (see Firefox bug 1573659)", and that Firefox "supports background.scripts (or background.page) if service_worker is not specified or the service worker feature is disabled". Setting preferred_environment does not change that, since there is no service worker for it to prefer. The event page Firefox runs instead differs from a service worker in ways covered in Firefox MV3 event pages versus service workers.

Instrumenting a converted build

Analytics is one place the silent-ignore rule bites, because an SDK assuming Chrome semantics reports fine and measures nothing. Moderok's SDK feature-tests both APIs Safari lists as unsupported, checking chrome.runtime?.setUninstallURL before calling it and chrome.storage?.sync before touching the identity mirror, which is the existence check Apple's iOS advice asks for. That cannot conjure data Safari does not expose: uninstall attribution is opt-in via trackUninstalls and on Safari yields nothing either way, since an absent function is skipped and a present-but-ignored one returns with no ping sent. One field to check against your own Safari build rather than assume: the user-agent parser matches only Firefox, Edge and Chromium tokens, so anything else records as an unknown browser. The uninstall tracking docs list which removals get reported and which never do.