I recently attempted to convert a Chrome extension to Safari—something I had postponed due to Xcode's poor development experience. This article documents the conversion process for Redirector, which is already available on Chrome/Firefox/Edge, into my first Safari extension on the App Store.
WXT's documentation mentions how to publish Safari versions using the xcrun
command line tool for converting Chrome extensions to Safari format.
Since I used Manifest V3, the command is:
pnpm wxt build -b safari
xcrun safari-web-extension-converter --bundle-identifier com.rxliuli.redirector --force .output/safari-mv3
This generates an Xcode project and automatically opens it.
After building in Xcode, Safari won't show the extension by default because Safari doesn't allow unsigned extensions.
To enable testing:
- Go to Safari > Settings > Advanced > Show features for web developers
- Then Safari > Settings > Developer > Allow unsigned extensions
If you've previously installed and uninstalled this extension, you'll need to specify a different project location:
pnpm wxt build -b safari
xcrun safari-web-extension-converter --bundle-identifier com.rxliuli.redirector --force --project-location 'Redirector 2025-03-13-17-20' .output/safari-mv3
You can check recognized extensions with:
pluginkit -mAvvv -p com.apple.Safari.web-extension
If everything works, you'll see the extension in Safari's Extensions panel.
Enable it to see the extension icon in Safari's toolbar.
If you find development in the Mac ecosystem painful, with things often not working without any error messages, this is unfortunately "normal."
Safari has several incompatible APIs with Chrome extensions. These won't produce errors but simply won't work. You can check the official compatibility documentation for incompatible APIs.
For example, webRequest APIs: Safari doesn't support webRequest API functionality in Manifest V3 at all, and it only works in Manifest V2 persistent background pages (which iOS doesn't support).
For Redirector, I had to replace:
browser.webRequest.onBeforeRequest.addListener
with browser.webNavigation.onCommitted.addListener
You can refer to the official documentation to debug background scripts. It's inconvenient, but it's the only method available.
Now the extension works correctly:
<iframe width="560" height="315" src="https://www.youtube.com/embed/GjVY7fELWts?si=uHWOuA50JA2RJzLH" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>To prepare for App Store submission:
The version is also in <project name>/<project name>.xcodeproj/project.pbxproj
file, search and replace MARKETING_VERSION = 1.0;
First click Validate App to check for configuration errors. I encountered an error that my extension name already exists.
After modifying the name in the manifest and repeating the conversion and build process, validation succeeded.
Then click Distribute App and select App Store Connect for App Store distribution or Direct Distribute for notarization.
I found this video very helpful https://youtu.be/s0HtHvgf1EQ?si=rbzc88E1Y_6nZY6k
Finally, go to App Store Connect to complete publication details including screenshots, privacy policy, and pricing. I didn't realize Apple manages app publishing through a website rather than through one of their apps, which led to a two-week delay.
Note that emoji characters are not allowed in App descriptions.
Mac/iOS development is a very closed platform with development tools and experiences quite different from the Web. However, considering Safari is the default browser on Mac and the only option on iOS, it's worth supporting despite being even worse than Firefox.
The extension has been published to the App Store: https://apps.apple.com/app/id6743197230