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.
Table of Contents
- Root cause: Safari runs your extension inside a native app
- Step 1. Run the converter and read every warning
- Step 2. Triage the gaps into three buckets
- Step 3. Probe the behaviour of partial implementations
- Step 4. Handle the permission model, which is the real blocker
- Step 5. Adjust for the packaging and update model
- Cross-browser variation
- Verification
- FAQ
- Related
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.
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
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.
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
requestUpdateCheckbut 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.sessionneeds 16.4;sidePaneldoes 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
- Run the converter and capture its output to a file. Every warning should map to a bucket in step 2 with a decision recorded.
- Install the container app, then check Safari’s settings — the extension should appear and be enableable.
- Visit a matched site without granting access and confirm the extension shows the guidance page rather than appearing broken.
- Grant access, reload, and confirm content scripts run and
tabs.queryreturns a populatedurl. - 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.
Related
- Feature detection instead of browser sniffing — the probes that keep the Safari paths out of your logic.
- Shipping one manifest for Chrome and Firefox — producing the artefact the converter consumes.
- Publishing to Firefox add-ons and Safari — getting the container app through review.
- Cross-Browser API Compatibility — the guide this page belongs to.