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

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. Pass --development-team with your Apple Developer team id and the app is signed with your own certificate:
Safari then lists the extension like any other, and there is one step:
  1. Safari ▸ Settings ▸ Extensions ▸ turn on your extension.
The toggle survives restarts, so this is a one-time step per machine. Turning the extension on is not the same as giving it access to pages. Safari asks for website access separately, and until you grant it a content script does not run at all: the extension is listed, enabled, and does nothing. This holds even when your manifest declares <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.
If your extension loads but nothing happens on the page, this is almost always why. Check the permission before you go looking at your code. To find your team id, run 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:
  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.
  4. 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 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. 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 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, 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. (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. 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-team signs 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: 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