Skip to main content
Package your existing web extension into a native Safari app on macOS, with no separate Xcode project to maintain by hand.
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.
Use --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.
If Xcode is missing, 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.
The app name and bundle identifier are derived from your manifest name (for example, React Sidebar Example → bundle id dev.extensionjs.React-Sidebar-Example). The project targets macOS by default.
The generated dev.extensionjs.* bundle id is a development placeholder. If you plan to distribute your app, set a bundle id you own from the first build. Changing it later makes Safari treat the extension as a brand-new identity (users lose their enable state and data).

App identity and packaging options

Both dev 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):
Changing the bundle id, app name, or manifest regenerates the Xcode project on the next run (see the regeneration warning below).

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:
  1. Safari ▸ Settings ▸ Advanced ▸ check “Show features for web developers”.
  2. Safari ▸ Develop ▸ Allow Unsigned Extensions (this resets every time Safari restarts).
  3. 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 xcodebuild resync (typically a couple of seconds) that updates the app’s resources from the freshly rebuilt dist/safari.
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. Safari has no live-reload channel like Chromium or Firefox, so after a rebuild refresh the page (or toggle the extension) in Safari to pick up changes.

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 polls pluginkit 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.
If a build fails, the CLI prints the tail of the failing 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. (build on other platforms still produces dist/safari; dev requires 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: Two things from the Extension.js side make that path smooth. Do them early:
  1. 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.
  2. Set your DEVELOPMENT_TEAM in Xcode once: it survives project regeneration automatically, along with CODE_SIGN_STYLE and PROVISIONING_PROFILE_SPECIFIER.

Best practices

  • Build other targets normally: Safari is additive, so keep iterating in chromium/firefox and run --browser=safari when you want to validate Safari.
  • Use browser-specific fields for true behavioral differences. Safari resolves the chromium-family prefixes (chromium:, chrome:, edge:), and safari:/webkit: prefixed keys win over them on Safari targets, for both --browser=safari and --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