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.

Published October 2, 2026 Updated October 2, 2026 7 min read
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.

Anatomy of a Safari web extensionThe containing app distributed through the App Store, the Safari web extension target inside it, the web extension resources from your build, and the optional Swift handler for native messaging.Containing appApp Store / notarisedwhat users installSafari Web Extension targetapp extension bundlesigned with your teamResourcesmanifest, JS, HTML, iconsfrom your web buildSafariWebExtensionHandlerSwift, optionalnative messaging
Your JavaScript is one layer inside an Apple app bundle.

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.

From web build to App StoreBuild the Safari variant of the web extension, convert it once to an Xcode project, sync resources on each build, run in Safari for development, archive and sign, then submit to App Store Connect.npm run build:safaridist/safariconverter (once)Xcode projectCopy resourceseach builddevelop, then releaseRun in SafariDevelop menuArchive + signDeveloper ID / App StoreApp Store Connectreview
Convert once; after that, the web build feeds the Xcode project on every change.

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.

Safari-specific behaviours to design forPer-site permission prompts, alarm timing, background suspension, missing browser-data APIs and App Store review, with the design response for each.AreaBehaviourDesign responsePermissionsPer-site prompts, can expireCheck on every useAlarmsDelayed when idleReconcile on UI openBackgroundSuspended aggressivelyPersist all stateBrowser data APIsMostly absentHide featuresDistributionApp Store reviewPlan lead time
Most Safari issues are about permissions and lifecycle, not syntax.

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: .xpi signed 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

  1. Build and run the containing app from Xcode; the extension appears in Safari → Settings → Extensions.
  2. Enable it, visit a target site, accept Safari’s permission prompt and confirm the core features work.
  3. Open Develop → Web Extension Background Content and confirm no unsupported-API errors.
  4. 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.

Other Core APIs & Cross-Browser Data Management Resources