Handling Safari Web Extension Conversion Gaps

Run safari-web-extension-converter and fix what it reports: missing APIs, the native container app, stricter permission prompts, and behaviour probes for stubbed methods.

Published August 1, 2026 Updated August 1, 2026 8 min read
Table of Contents

The converter runs, Xcode opens, the extension builds, and the popup is blank. Nothing threw — Safari implements the APIs your popup called, it just returned empty results because the user has not granted the extension access to the site yet, and Safari’s permission model asks separately from the manifest. Conversion is rarely blocked by a missing API; it is blocked by APIs that exist and behave differently. This page is part of Cross-Browser API Compatibility and covers the gaps in the order you meet them.

Root cause: Safari runs your extension inside a native app

Chrome and Firefox load an extension directly. Safari wraps it in a macOS or iOS application: the converter generates an Xcode project containing your web extension as a bundled resource plus a small native container that the user installs from the App Store. That wrapper is where most of the differences come from — permissions are mediated by the system, updates arrive with the app, and the extension’s lifetime is tied to Safari’s own process management.

What sits between your code and the user on SafariThe web extension runs inside a native app container, which is subject to App Store review, system permission prompts and app-driven updates.Your web extensionmanifest.json, sw.js, popup.htmlunchanged from the Chrome buildSafari extension bridgebrowser.* mapped onto WebKitwhere stubs and gaps liveNative container appSwift shell, entitlementsgenerated by the converterApp Store and systemreview, per-site permission promptsupdates arrive here, not in Safari
Every layer below your code is one that does not exist on Chrome or Firefox.

Step 1. Run the converter and read every warning

1# macOS only, ships with Xcode command line tools
2xcrun safari-web-extension-converter dist/safari \
3  --project-location build/safari \
4  --app-name "Tab Curator" \
5  --bundle-identifier com.example.tabcurator \
6  --macos-only \
7  --no-open

Execution context: A macOS build machine with Xcode installed. The converter reads your manifest and reports every key and permission it cannot map, then generates the project regardless — the warnings are advisory, so nobody reads them and everybody is surprised later. --macos-only skips the iOS target, which halves the build time while you are iterating; drop it when you are ready to ship both. Run this from the per-engine artefact produced in shipping one manifest for Chrome and Firefox rather than from your Chrome build.

Step 2. Triage the gaps into three buckets

Triaging a converter warningEach unsupported key or API is either genuinely absent, present but stubbed, or unnecessary on Safari because the platform solves it differently.Does the API exist on Safari at all?AbsentPresence probe + fallbacksidePanel, getAuthTokenCaught at development timetypeof check is enoughStubbed or partialBehaviour probedeclarativeNetRequest, alarmsCall it once and inspect the resultcache in storage.sessionHandled by the platformRemove the code pathupdate checks, badge pollingThe container app owns itdo not reimplement
The middle branch is the dangerous one — the code runs and quietly does nothing.

The first bucket is easy — the capability probes from feature detection instead of browser sniffing already handle it. The third is a deletion. The second is where the debugging time goes, because the code path runs, returns a plausible value, and does nothing useful.

Step 3. Probe the behaviour of partial implementations

Alarms are the canonical example: chrome.alarms exists on Safari, accepts a one-minute period, and then fires later than requested because the system coalesces timers to save power.

 1// safari-probes.js
 2export async function probeAlarmFidelity() {
 3  const { probes = {} } = await chrome.storage.session.get("probes");
 4  if (typeof probes.alarmDriftMs === "number") return probes.alarmDriftMs;
 5
 6  const requestedAt = Date.now();
 7  await chrome.alarms.create("__probe__", { delayInMinutes: 1 });
 8
 9  return new Promise((resolve) => {
10    const onFire = async (alarm) => {
11      if (alarm.name !== "__probe__") return;
12      chrome.alarms.onAlarm.removeListener(onFire);
13      const alarmDriftMs = Date.now() - requestedAt - 60_000;
14      await chrome.storage.session.set({ probes: { ...probes, alarmDriftMs } });
15      resolve(alarmDriftMs);
16    };
17    chrome.alarms.onAlarm.addListener(onFire);
18  });
19}

Execution context: Service worker. This is a diagnostic you run once during development, not something to ship in the hot path — the point is to measure the drift so you can decide whether your feature tolerates it. On Chrome the drift is close to zero; on Safari it can be tens of seconds and grows when the machine is on battery. The design consequences are covered in minimum alarm period and throttling: anything that must happen at a precise moment needs a different mechanism.

Step 4. Handle the permission model, which is the real blocker

Safari does not grant host permissions at install. The user grants them per site, from the Safari toolbar, and until they do your content scripts never run and tabs.query returns objects with no url.

The Safari permission handshake your code has to noticeA trivial injection probes whether the user has granted this site; a rejection routes the click to a guidance page instead of a broken feature.UserWorkerSafariPageclicks the toolbar buttonexecuteScript({ func: () => true })throws — site not grantedshow needs-access.htmlexplain the toolbar menugrants access for this websiteinjection now succeeds
permissions.contains reports the manifest's request, not the user's grant — only an injection tells the truth.
 1// sw.js — detect the ungranted state and guide the user rather than failing silently
 2async function hasAccessTo(tab) {
 3  if (!tab?.id) return false;
 4  try {
 5    const [probe] = await chrome.scripting.executeScript({
 6      target: { tabId: tab.id },
 7      func: () => true,
 8    });
 9    return probe?.result === true;
10  } catch {
11    return false;                       // no host access on this origin
12  }
13}
14
15chrome.action.onClicked.addListener(async (tab) => {
16  if (await hasAccessTo(tab)) return openPanel(tab.id);
17  await chrome.action.setPopup({ tabId: tab.id, popup: "needs-access.html" });
18  await chrome.action.openPopup?.();
19});

Execution context: Service worker. Attempting a trivial injection is the reliable way to ask “do I have access to this page right now” on Safari, where permissions.contains reports the manifest’s request rather than the user’s grant. The fallback page should explain how to grant access from Safari’s toolbar menu — a screenshot in your onboarding is worth more than any amount of retry logic. Chrome and Firefox grant host permissions at install, so this branch simply never fires there.

Step 5. Adjust for the packaging and update model

Two things the container app owns, which your code must stop trying to do.

 1// updates.js — Safari has no browser-managed update channel
 2import { can } from "./capabilities.js";
 3
 4export function wireUpdateChecks() {
 5  if (!can.updateCheck) return;         // Safari and Firefox: nothing to wire
 6
 7  chrome.runtime.onUpdateAvailable.addListener(async ({ version }) => {
 8    await chrome.storage.session.set({ pendingUpdate: version });
 9  });
10}

Execution context: Service worker. chrome.runtime.requestUpdateCheck and onUpdateAvailable have no meaningful Safari implementation because updates ship through the App Store with the container app and apply at launch. The deferral machinery in controlling when an update is applied is therefore inert on Safari — which is fine, provided your migrations also run at script evaluation rather than only from onInstalled. The second platform-owned concern is the extension’s visibility: Safari users must enable the extension in Safari’s settings after installing the app, so the container app’s first-run screen is part of your onboarding, not an afterthought.

Cross-browser variation

  • Chrome/Edge: loads the extension directly, grants declared host permissions at install, manages updates itself. None of this page applies.
  • Firefox: also direct, with a per-installation UUID and AMO signing. Shares Safari’s lack of requestUpdateCheck but nothing else.
  • Safari: native container, per-site permission grants, App Store updates, coalesced timers, and a handful of stubbed APIs. world: "MAIN" injection needs Safari 17; storage.session needs 16.4; sidePanel does not exist at all.
  • All engines: the same bundle can serve all three if every engine-specific path is behind a capability probe rather than a build flag.

Verification

  1. Run the converter and capture its output to a file. Every warning should map to a bucket in step 2 with a decision recorded.
  2. Install the container app, then check Safari’s settings — the extension should appear and be enableable.
  3. Visit a matched site without granting access and confirm the extension shows the guidance page rather than appearing broken.
  4. Grant access, reload, and confirm content scripts run and tabs.query returns a populated url.
  5. Run the alarm probe on battery power and record the drift. If your feature cannot tolerate it, change the mechanism before shipping.

FAQ

Do I need a Mac to build the Safari version?

Yes. The converter and Xcode are macOS-only, so a Safari target means a macOS runner in CI or a build machine.

Can I ship the same code bundle to all three engines?

Yes, and you should. The differences belong in capability probes and in the generated manifest, not in the source.

Why does permissions.contains say I have access when content scripts do not run?

Because it reports what the manifest requested, not what the user granted. Probe with a trivial injection instead.

Does the container app need its own features?

It needs a first-run screen that explains how to enable the extension in Safari. Beyond that it can be the converter’s default shell.

Other Core APIs & Cross-Browser Data Management Resources