Safari is supported on macOS only and requires the full Xcode app.
dev
and build both work. preview and start do not, for the reason described
in Command support.extension dev --browser safari bundles your code, converts it with Apple’s safari-web-extension-converter, builds and signs an app with xcodebuild, and opens it. You enable the extension once in Safari Settings. After that, extension logs streams background and content rows, the control bridge attaches, and every save reloads the extension in Safari. That dev loop ships from 4.1.20.
The behavior on this page was measured on macOS 26.5.2, Safari 26.5.2, and
Xcode 26.6. Safari 27 exists and was not re-measured.
What you need
Safari is macOS only and needs the full Xcode app, not just the Command Line Tools. The converter (safari-web-extension-converter) and xcodebuild ship inside Xcode.app.
--development-team the app is signed ad-hoc. Safari accepts an ad-hoc signed app and lists the extension like any other, with the Develop menu setting Allow unsigned extensions turned off. That setting belongs to a different path, described in Temporary extensions.
If Xcode is missing, extension dev and extension build for Safari targets fail fast, before bundling, with guidance instead of a late, confusing error.
What it produces
extension build --browser=safari creates, next to your project:
The pipeline runs end to end: bundle → convert →
xcodebuild, plus open
the app → guided enable in dev (or with build --open). A plain build
stops after packaging and prints the open command instead.
name (for example, React Sidebar Example → bundle id dev.extensionjs.React-Sidebar-Example). The project targets macOS by default.
App identity and packaging options
Bothdev and build accept identity overrides (Safari targets only):
Without
--bundle-id, Extension.js derives dev.extensionjs.<name>, where <name> is your sanitized app name (non-alphanumeric runs become hyphens). A user-provided bundle id must be reverse-DNS shaped: at least two dot-separated segments of letters, digits, and hyphens, each starting with a letter. Invalid values are rejected before packaging.
The same options can live in extension.config.js (CLI flags win):
Building the web-extension bundle on other platforms
extension build --browser=safari works on Linux and Windows too: it produces
the complete dist/safari web-extension bundle and skips the Xcode packaging
step with a warning. This lets CI build the payload anywhere and a Mac (or a
macOS runner) do the convert + xcodebuild part later. dev --browser=safari
still requires macOS with Xcode, because a Safari dev loop without packaging has
nothing to run.
Enabling the extension in Safari
Extension.js prints the steps that match your build when the app opens (dev,
or build --open), and confirms when macOS has registered the extension.
The one-time gesture is the same for signed and ad-hoc builds:
- Safari ▸ Settings ▸ Extensions ▸ turn on your extension.
<all_urls> in both
content_scripts.matches and host_permissions, which is the part that
surprises people coming from Chrome, where installing grants it.
In the same panel, under Permissions, use:
- Always Allow on Every Website… for an extension that you iterate on. It persists, so you grant it once.
- Edit Websites… to allow only the hosts that you are testing against.
Signed builds with an Apple Developer team
Pass--development-team with your Apple Developer team id and the app is
signed with your own certificate:
xcrun security find-identity -v -p codesigning. The
ten-character code in the certificate name is your team id, and it is also on
the Membership page of your Apple Developer account.
Team signing is what you need for distribution. For day-to-day development it
changes nothing about how Safari lists or runs the extension.
Ad-hoc builds (no Apple Developer account)
Without--development-team the app is ad-hoc signed. Safari lists it and runs
it, and the enable gesture above is the whole setup. This was measured with
Safari ▸ Develop ▸ Allow unsigned extensions turned off.
Temporary extensions (no Xcode)
Safari has a second path that Extension.js does not drive: Safari ▸ Settings ▸ Developer ▸ Add Temporary Extension…. It loads an unpacked folder such asdist/safari, with no Xcode step at all. It needs Allow unsigned extensions,
and the extension disappears when Safari quits, or after 24 hours. Use it for a
quick look at a bundle. Use extension dev for a dev loop that survives a
restart.
The dev loop
extension dev --browser=safari runs a watch loop:
- First compile: full package: convert, build, open the app, and print the enable steps.
- On every save: an incremental
xcodebuildresync updates app resources from the rebuiltdist/safari. Safari reloads the extension.
extension dev --browser safari opens the container app that the converter built, not Safari itself. Safari is where you enable and use the extension, so open it yourself, or pass --safari-binary and the session opens Safari beside the app. Once the extension is enabled and the session runs with --allow-control, extension navigate <url> points Safari’s active tab at a page through the extension, a static tabs call rather than an eval, so a content script’s page can be opened without touching the browser by hand.
Each save prints what it did. A resync ends with Rebuilt <App>., and a content script change follows it with Reloading content_script (content.ts)…. When the extension is not connected at that moment, the line reads Queued content_script (content.ts) for the extension to apply when it reconnects. instead. A background or manifest edit prints only Rebuilt, because Safari restarts the extension itself after the resync, and there is nothing left to dispatch.
After a conversion, the converter’s own warning list is printed, one line per manifest key that it does not know. A key that the build keeps on purpose is annotated under Extension.js kept one of these keys on purpose:. Today that is world, with the reason Safari has honored the MAIN world since Safari 18, so the key stays.
In dev, the bridge parks early errors. An error thrown before the extension’s socket to the dev server opens is stored in the extension’s chrome.storage.local under __extjsPendingErrors and replayed once the socket connects, so extension logs still shows it.
What one save costs
Resyncs run in the background, so the bundler loop is never blocked. A burst of saves collapses to a single follow-up resync against the newest output, so five quick saves cost one rebuild, not five. If the first full package fails, the next compile retries the full flow instead of resyncing a project that was never built.When the Xcode project regenerates
The Xcode project is generated once and reused for resyncs. Staleness is decided by a fingerprint file,dist/safari-xcode/.manifest-fingerprint. The v4
fingerprint, written from 4.1.20, stores the identity inputs (app name, bundle id, and the macOS-only
setting), the top-level entries of dist/safari, and the manifest icons set.
Of the manifest itself it stores only the surface the converter judges. That
surface is the top-level key names, the permissions and optional_permissions
values, and the key names inside each content_scripts entry and options_ui. The other
manifest bytes stay out, so the content-hashed script names that change on every
save do not re-run the converter. The converter runs again when the stored
fingerprint no longer matches, or when you pass --force-regenerate. Cosmetic
manifest edits (key order, whitespace) do not trigger it.
Regeneration replaces the project: customizations made in
Xcode (entitlements, capabilities, added files or targets) are discarded.
Only these signing settings are preserved automatically: DEVELOPMENT_TEAM,
CODE_SIGN_STYLE, and PROVISIONING_PROFILE_SPECIFIER. Extension.js warns
before every regeneration of an existing project. If you customized the project
in Xcode, back it up first. Delete dist/safari-xcode for a clean slate.
How the bundle id is enforced
Apple’s converter derives the parent app’s id from the app name instead of taking--bundle-identifier verbatim. After every conversion, Extension.js
rewrites both PRODUCT_BUNDLE_IDENTIFIER entries in the generated
project.pbxproj: the app target gets your bundle id, and the extension target
gets <bundle-id>.Extension. This keeps the identity you configured, not the
one the converter guessed.
What xcodebuild runs
The compile step uses the Release configuration with derived data written to
dist/safari-xcode/.derived, a folder worth adding to .gitignore. Signing
settings depend on --development-team. With a team id the build passes
DEVELOPMENT_TEAM=<id>, CODE_SIGN_STYLE=Automatic, and
-allowProvisioningUpdates, so Xcode mints the provisioning profile it needs
without being opened. Without one it passes ad-hoc settings
(CODE_SIGN_IDENTITY=-, CODE_SIGNING_REQUIRED=NO,
CODE_SIGNING_ALLOWED=YES) so the embedded .appex still validates without an
Apple Developer account. The scheme name is
your app name for a macOS-only project, and <App> (macOS) for a universal
project.
Registration confirmation
After opening the app, Extension.js pollspluginkit for the extension’s
registration, about 6 tries spread over 5 seconds, and prints a confirmation or
a not-yet-registered note. Under --no-open (and plain build without
--open) the app never launches, so registration cannot happen yet. The poll is
skipped and the CLI prints the open command instead.
Reading logs
From 4.1.20, a Safari dev session is a normal session forlogs:
--context, --level, and --output filters.
A silent stream after an edit is the symptom described in the next section. It means the background context is gone, not that logging is unsupported.
The one call that kills a Safari background
One unguarded Chromium-only call at the top level of a background script throws on Safari. Safari then discards the background context, and you get no logs, no reload, and no error anywhere. The extension looks dead.EXTENSION_PUBLIC_BROWSER value on each target.
What Safari builds change in your manifest
Background page instead of a service worker
From 4.1.20, Safari builds emit a non-persistent background page (background: {scripts: [...]}), not a service worker. Extension.js translates a background.service_worker entry into that shape, the same translation it runs for Firefox.
The reason is measured, not stylistic. On Safari 26.5.2, a Manifest V3 service
worker never started in testing, including for extensions built by Apple’s own
converter. A background page ran immediately. WebKit deliberately prefers
the page form (WebKit bug 270750).
Author one background.service_worker and it loads on Chromium, Firefox, and
Safari. See Background scripts for the
translation in both directions.
Keys and permissions Safari cannot use
Manifest keys and permissions that Safari does not implement are dropped from the Safari build automatically. From 4.1.20, the build prints one line per dropped key, naming the key and why it went. Nothing is dropped silently, and your sourcemanifest.json is untouched.
content_scripts[].world is deliberately kept, because Safari has supported it since Safari 18.
For Safari-only overrides, use the safari: and webkit: manifest prefixes. They win over the chromium: family keys that Safari otherwise inherits. See Browser-specific manifest fields.
APIs Safari does not have
From 4.1.20, the build warns about extension APIs that Safari lacks, naming each namespace or member it finds in your code. Twelve namespaces are covered:sidePanel, offscreen, tabGroups, management, identity,
notifications, bookmarks, history, downloads, idle, omnibox, and
userScripts. The warnings run in production builds and in dev.
The build also warns on 21 members of namespaces that Safari does ship, where
the namespace resolves and the throw waits one level deeper:
action.getUserSettings, action.getBadgeTextColor,
action.setBadgeTextColor, action.onUserSettingsChanged, storage.managed,
runtime.getContexts, runtime.onSuspend, runtime.onSuspendCanceled,
runtime.onUpdateAvailable, declarativeNetRequest.getAvailableStaticRuleCount,
declarativeNetRequest.getDisabledRuleIds,
declarativeNetRequest.updateStaticRules,
declarativeNetRequest.testMatchOutcome,
declarativeNetRequest.onRuleMatchedDebug, tabs.group, tabs.ungroup,
webNavigation.onCreatedNavigationTarget,
webNavigation.onHistoryStateUpdated,
webNavigation.onReferenceFragmentUpdated, webNavigation.onTabReplaced, and
windows.onBoundsChanged.
The scan is textual. A mention of one of these names in a comment or in a
string literal triggers it too. Optional chaining silences it:
chrome.sidePanel?.setPanelBehavior() for a namespace, and
chrome.tabs.group?.() for a member.
A warning is not a build failure. Code that reaches one of these APIs
still ships. It is your call whether to guard it, branch on the target, or
accept that the feature is absent on Safari.
Debugging in Safari
Web Inspector covers every extension context, and it is the right tool when you want a debugger rather than a log stream:- Background: Safari ▸ Develop ▸ Web Extension Background Content ▸ your extension.
- Popup/options/sidebar pages: open the surface, then right-click ▸ Inspect Element (or Develop ▸ your Mac ▸ the page).
- Content scripts: inspect the host page, where extension script contexts appear in the Sources tab under Extension Scripts.
xcrun/xcodebuild
output, bounded to the last 50 lines and 8 KB so diagnostics stay readable.
Pass --debug (or set EXTENSION_DEBUG=true) to stream the full tool output
live instead.
Engine target
safari has an engine alias, webkit-based, that parallels chromium-based and gecko-based:
Command support
preview and start exist to launch an already-built extension in a running browser. The Safari route for that is WebDriver, and it was measured. safaridriver does load an unpacked folder, and the background even runs, but Safari grants the extension zero host origins. Content scripts never inject, and no API call can grant the access afterwards. A session that looks healthy would test nothing, so both commands refuse Safari targets instead and point at extension dev --browser safari or extension build --browser safari --open.
Limitations
- Packaging is macOS only. The Xcode step needs the full Xcode app.
buildstill producesdist/safarielsewhere. previewandstarthave no Safari path. See Command support for what was measured.- Enabling and granting access are manual. They are Safari security controls and cannot be automated. Both survive restarts.
- Signing stops at development.
--development-teamsigns the local app with your development certificate. Distribution signing, notarization, and App Store submission are a separate step beyond this workflow (see below). - macOS target only. iOS app generation is not produced by this workflow today.
After dev: shipping to the App Store
The Safari workflow above ends with a locally signed app, ad-hoc or development. Distributing it (to the Mac App Store, or as a notarized direct download) is a separate pipeline that Extension.js plans to offer through the extension.dev platform. Until that lands, follow Apple’s own guides:- Distributing your Safari web extension (Apple Developer Program, signing, App Store Connect).
- Notarizing macOS software for non-App Store distribution.
- Set your own
--bundle-id(reverse-DNS, a domain you own) from the first build. Bundle id is the extension’s identity on Apple platforms. - Set your
DEVELOPMENT_TEAMin Xcode once: it survives project regeneration automatically, along withCODE_SIGN_STYLEandPROVISIONING_PROFILE_SPECIFIER.
What a session leaves behind
The ready contract atdist/extension-js/safari/ready.json carries Safari-specific values:
extension logs, extension eval, extension reload, and extension doctor --browser safari attach to the session through that contract, the same way they do on Chromium.
Ctrl+C stops the dev server only. The container app stays open, Safari stays open, the extension stays registered with macOS and enabled in Safari Settings, and dist/safari-xcode/ stays on disk. The next dev reuses that project. Delete dist/safari-xcode/ for a clean slate, which makes the next run convert and build from scratch.
Best practices
- Read the build warnings first. They name the APIs and manifest keys that Safari does not have, which is the cheapest way to find a background that will not start.
- Guard Chromium-only calls at the top level of background and content scripts, with optional chaining or a branch on
EXTENSION_PUBLIC_BROWSER. - Use browser-specific fields for true behavioral differences. Safari resolves the
chromium:family prefix, andsafari:/webkit:prefixed keys win over it on Safari targets, for both--browser=safariand--browser=webkit-based.chrome:andedge:keys do not reach Safari. - Keep the generated project unless you need a clean slate, because regenerating discards Xcode-side customizations beyond the preserved signing settings.
Next steps
- See all supported browsers.
- Stream a Safari session with
logs. - Use browser-specific manifest fields.
- Review multi-platform builds.

