Safari is supported on macOS only and requires the full Xcode app. The
build → convert →
xcodebuild → open pipeline covers build and dev;
preview and start are not available, and there is no live reload yet.--browser=safari to turn the same extension you ship to Chrome and Firefox into a Safari App Extension. Extension.js bundles your code, runs Apple’s safari-web-extension-converter, compiles the generated app with xcodebuild, and walks you through enabling it.
Requirements
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.
extension build/dev --browser=safari 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
How you enable the extension depends on how the app was signed. 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.
Signed builds (recommended)
Pass--development-team with your Apple Developer team id and the app is
signed with your own certificate:
- 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 a development extension you are iterating on. It persists, so you grant it once.
- Edit Websites… to allow only the hosts you are testing against.
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.
Ad-hoc builds (no Apple Developer account)
Without--development-team the build is ad-hoc signed, which Safari treats as
unsigned. It still runs, with three steps:
- Safari ▸ Settings ▸ Advanced ▸ check “Show features for web developers”.
- Safari ▸ Develop ▸ Allow Unsigned Extensions (this resets every time Safari restarts).
- Safari ▸ Settings ▸ Extensions ▸ turn on your extension.
- Grant website access, exactly as described above. Enabling alone does not let a content script run.
“Allow Unsigned Extensions” resets each time you launch Safari, and it cannot
be scripted or saved in preferences, so every restart costs you the same three
steps. If you have an Apple Developer account,
--development-team is worth
it for day-to-day development, not just for shipping.Developing with dev
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: incremental
xcodebuildresync (typically a couple of seconds) that updates the app’s resources from the freshly rebuiltdist/safari.
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 v2
fingerprint stores your normalized manifest.json content plus the identity
inputs: app name, bundle id, and the macOS-only setting. 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.
Debugging in Safari
Safari doesn’t support the--logs centralized logger (there is no automation
channel), but Web Inspector covers every extension context:
- Background/service worker: 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. The converter’s compatibility warnings (manifest keys Safari
doesn’t support) are surfaced as warnings during packaging.
Engine target
safari has an engine alias, webkit-based, that parallels chromium-based and gecko-based:
Command support
preview and start exist to launch your extension in a running browser. Safari requires the manual, security-gated enable step above, so those commands refuse Safari targets and point you to build instead. Under --output json, both fail with E_COMMAND_UNSUPPORTED_FOR_TARGET (Safari is a supported browser, these commands have no Safari path).
Limitations
- Packaging is macOS-only. The Xcode step needs macOS with the full Xcode app. (
buildon other platforms still producesdist/safari;devrequires macOS.) - No live reload. Rebuilds are fast, but you refresh in Safari to apply them.
- Manual one-time enable. Toggling the extension on and granting it website access are Safari security controls and cannot be automated. On ad-hoc builds, allowing unsigned extensions is a third control with the same rule.
- 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.
Best practices
- Build other targets normally: Safari is additive, so keep iterating in
chromium/firefoxand run--browser=safariwhen you want to validate Safari. - Use browser-specific fields for true behavioral differences. Safari resolves the chromium-family prefixes (
chromium:,chrome:,edge:), andsafari:/webkit:prefixed keys win over them on Safari targets, for both--browser=safariand--browser=webkit-based. - 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.
- Use browser-specific manifest fields.
- Review multi-platform builds.

