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
Local builds are ad-hoc signed (no Apple Developer account required), so Safari needs you to opt in once. When the app opens (dev, or build --open), Extension.js prints these steps and confirms when macOS has registered the extension:
- 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.
âAllow Unsigned Extensionsâ resets each time you launch Safari. Re-enable it
after restarting Safari during development. A signed build (Apple Developer
ID) avoids this step and is part of the distribution workflow.
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. Local
builds pass ad-hoc signing 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, preview fails with E_COMMAND_UNSUPPORTED_FOR_TARGET (Safari is a supported browser, this command has no Safari path) and start fails with E_UNSUPPORTED_BROWSER.
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. Allowing unsigned extensions and toggling the extension on are Safari security controls and cannot be automated.
- Local builds are ad-hoc signed. 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 an ad-hocâsigned local app. 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.

