Porting a Chrome Extension to Safari with Xcode
Convert an MV3 Chrome extension into a Safari web extension: safari-web-extension-converter, the containing app, signing and the App Store, per-site permissions, missing APIs, and an iterative build workflow.
Table of Contents
Safari users are a large share of desktop and nearly all of mobile on Apple devices, and they cannot install your extension from the Chrome Web Store or AMO. Safari web extensions use the same WebExtensions API and accept MV3 manifests, but they are distributed very differently: every Safari extension ships inside a native macOS or iOS app, built with Xcode, signed with an Apple developer account, and usually published through the App Store. The JavaScript port is often smaller than the packaging work. This guide covers both. It belongs to cross-browser API compatibility.
What a Safari web extension is
A Safari web extension is an app extension — a bundle inside a containing app — that includes your web extension’s files as resources. The user installs the containing app from the App Store (or as a notarised download on macOS), then enables the extension in Safari’s settings. Safari loads your manifest.json, background, content scripts and pages from the bundle. Native messaging, if used, goes to a Swift handler in the same app extension. Permissions are granted per website through Safari’s own prompts — “Allow for one day”, “Always allow on this website” — regardless of what the manifest declares. Apple provides a converter that generates the Xcode project from an existing extension folder.
Step-by-step: port and ship
1. Prepare a Safari build of the web extension
1// build/manifest.safari.mjs
2export function toSafari(chromeManifest) {
3 const m = structuredClone(chromeManifest);
4 delete m.key;
5 delete m.minimum_chrome_version;
6 delete m.side_panel;
7 m.permissions = m.permissions.filter((p) => !["sidePanel", "offscreen", "readingList", "downloads", "bookmarks", "history"].includes(p));
8 m.browser_specific_settings = { safari: { strict_min_version: "16.4" } };
9 return m;
10}
Execution context: the build pipeline producing dist/safari. Removing permissions Safari does not support avoids warnings in Safari’s extension console and keeps the permission prompts honest. service_worker backgrounds are supported in current Safari; keep the same background file.
2. Run the converter once
1xcrun safari-web-extension-converter dist/safari \
2 --project-location safari-app \
3 --app-name "Readable" \
4 --bundle-identifier com.acme.readable \
5 --macos-only --swift --no-open
Execution context: macOS with Xcode installed. The converter creates an Xcode project with a containing app and an extension target that references the files in dist/safari, and prints warnings for unsupported manifest keys and APIs. Run it once; afterwards, keep the project in version control and update its resource folder from your build rather than re-converting. Drop --macos-only to also generate an iOS target.
3. Allow unsigned extensions while developing
1Safari → Settings → Advanced → "Show features for web developers"
2Develop → "Allow Unsigned Extensions" (resets when Safari quits)
3Safari → Settings → Extensions → enable "Readable"
Execution context: Safari on the development Mac. Building and running the containing app from Xcode registers the extension; Safari then lists it in Settings → Extensions. Unsigned extensions must be re-allowed after every Safari restart. Inspect the background through Develop → Web Extension Background Content, and extension pages with Web Inspector as usual.
4. Work through API gaps
1// Feature checks that matter most on Safari
2const has = {
3 sidePanel: typeof chrome.sidePanel?.open === "function",
4 downloads: typeof chrome.downloads?.download === "function",
5 bookmarks: typeof chrome.bookmarks?.getTree === "function",
6 omnibox: typeof chrome.omnibox?.onInputEntered?.addListener === "function",
7 offscreen: typeof chrome.offscreen?.createDocument === "function",
8};
Execution context: the Safari background. Features built on side panels, downloads, bookmarks, history, the omnibox and offscreen documents need alternatives or must be hidden. Side panels become a tab or the popup; exports become an <a download> in an extension page; DOM work moves into extension pages. The full list is in Chrome vs Firefox vs Safari API gap reference, and conversion-specific gaps are in handling Safari web extension conversion gaps.
5. Design for per-site permissions
1// popup.js — Safari may not have granted access to this site yet
2const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
3const origin = new URL(tab.url).origin;
4if (!(await chrome.permissions.contains({ origins: [`${origin}/*`] }))) {
5 showHint("Safari needs your permission for this site. Click “Allow” in the toolbar prompt, or choose Always Allow on This Website.");
6}
Execution context: the popup. Safari shows its own prompt the first time the extension tries to act on a site, and users often pick “Allow for one day”. Your UI should explain what to do when access is missing rather than appearing broken. The same check-before-act pattern used for Chrome’s site-access controls applies, as in handling user-restricted site access.
6. Keep the Xcode project in sync with your build
1# scripts/sync-safari.sh
2npm run build:safari
3rsync -a --delete dist/safari/ "safari-app/Readable Extension/Resources/"
4xcodebuild -project safari-app/Readable.xcodeproj -scheme "Readable (macOS)" -configuration Debug build
Execution context: a terminal or CI on macOS. Depending on how the converter wired the project, the extension target references either the original folder or a copy; standardise on one and script it. Bump the app’s marketing version and build number with each release — App Store Connect rejects duplicates — and keep them aligned with manifest.json’s version for support sanity.
7. Sign, archive and submit
1xcodebuild -project safari-app/Readable.xcodeproj -scheme "Readable (macOS)" \
2 -configuration Release -archivePath build/Readable.xcarchive archive
3xcodebuild -exportArchive -archivePath build/Readable.xcarchive \
4 -exportOptionsPlist ExportOptions.plist -exportPath build/export
Execution context: macOS with an Apple Developer account and signing certificates. ExportOptions.plist selects App Store or Developer ID distribution. App Store submissions need privacy labels that match any data the extension collects, screenshots of the containing app, and review time. Developer ID builds must be notarised before distribution outside the store. Automation of this step is covered in automating Safari builds with xcodebuild.
Common mistakes
- Re-running the converter on every build. It regenerates the project and loses manual changes; convert once.
- Expecting the manifest to grant access. Safari asks per site; design for missing access.
- Shipping Chrome-only permissions. They produce warnings and confusing prompts.
- Forgetting the containing app. It needs a real UI — at minimum, instructions for enabling the extension — or App Review rejects it.
- Testing only unsigned builds. Signed and sandboxed builds behave differently around native messaging and file access.
Cross-browser variation
- Chrome / Edge: zip upload to a web store; no native app required.
- Firefox:
.xpisigned by AMO; no native app required. - Safari: an app containing the extension, built with Xcode and distributed through the App Store or as a notarised download; iOS and iPadOS use the same model with additional UI constraints.
Verification
- Build and run the containing app from Xcode; the extension appears in Safari → Settings → Extensions.
- Enable it, visit a target site, accept Safari’s permission prompt and confirm the core features work.
- Open Develop → Web Extension Background Content and confirm no unsupported-API errors.
- Archive and export a signed build, install it on a clean Mac, and repeat the checks.
FAQ
Do I need a Mac?
Yes. Xcode, signing and Safari testing require macOS.
Can one Xcode project ship macOS and iOS?
Yes. The converter can create both targets sharing the same web resources; test touch interactions and narrower popups on iOS.
Is App Store review different from Chrome Web Store review?
It reviews the whole app, including the containing app’s UI and privacy labels, and typically takes longer. Plan releases accordingly.
Related
- Handling Safari web extension conversion gaps — converter warnings in detail.
- Native messaging in Firefox and Safari — the Swift handler.
- Publishing to Firefox Add-ons and Safari — store submission.
- Cross-browser API compatibility — the parent topic.