Skip to content

Repository files navigation

  ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ 
 ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ 
 ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ 
   ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓   
   ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓  
   ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒  
  ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒  
  ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒  
  ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓        ▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒  
  ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓              ▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒  
  ▒▒▒▒▒▒▒▒▒▒▓▒▒▒▓▓▓▓▓                  ▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒  
  ▒▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓                      ▓▓▓▓▓▒▒▓▓▓▓▒▒▓▒▓▒  
  ▓▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓                        ▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓▓  
  ▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓                        ▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓  
  ▓▓▓▓▓▓▓▓▒▒▓▓▓▓▓                          ▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓  
  ▓▓▓▓▓▓▓▓▒▒▓▓▓▓▓                          ▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓▓ 
 ▓▓▓▓▓▓▓▓▓▒▒▓▓▓▓▓                          ▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓▓ 
 ▓▓▓▓▓▓▓▓▓▒▒▓▓▓▓▓                          ▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓ 
 ▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓                          ▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓ 
 ▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓                          ▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓ 
 ▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓                          ▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓ 
 ▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓                          ▓▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓ 
 ▓▓▓▓▓▓▓▓▒▒▓▓▓▓▓▓                          ▓▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓ 
 ▓▓▓▓▓▓▓▓▒▓▓▓▓▓▓▓                          ▓▓▓▓▓▓▒▓▓▓▓▓▓▓▓▓ 
 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓                          ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ 
 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓                          ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ 
 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓                          ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓                          ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓                          ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓                          ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓                          ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓                          ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓                            ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ 

Viaduct

A command-line tool that turns a Google Chrome extension into a Safari Web Extension. It unpacks the extension, checks what Safari will refuse, rewrites the manifest, injects a runtime shim for the chrome.* APIs Safari does not implement, then runs Apple's safari-web-extension-packager and xcodebuild to produce a signed app. The result can be loaded straight into Safari for development or built through Xcode for TestFlight.

What it does

Input

Give it a .zip, a .crx, an .xpi, an unpacked extension directory, or a URL. A Chrome Web Store link or a direct .crx/.zip download link is fetched for you. The archive type comes from the file's magic bytes rather than its extension, so a CRX someone renamed to .zip still works. Pass several inputs to convert them in one run.

Analysis

viaduct detects MV2 versus MV3 and reports what will break before it converts anything. You get a readable CONVERSION_REPORT.md, and --analyze --json prints the same findings as a machine-readable payload: issue counts, autoFixed/blocking totals, a convertible verdict that uses the same gate as a real conversion, the per-issue list, the permissions that were removed, and the bundle id and name.

Manifest rewrite

  • Removes Chrome-only keys (update_url, key, minimum_chrome_version).
  • Strips permissions Safari does not implement, such as tabGroups, offscreen, sidePanel, and debugger.
  • Converts an MV3 service worker into a non-persistent background page, and forces persistent: false on MV2 backgrounds too, because Safari rejects a persistent MV3 background outright ("a manifest_version >= 3 must be non-persistent"). It also strips background.type: "module", a known cause of popups that fail silently.
  • Injects browser_specific_settings.safari with a minimum version (15.4 by default, changed with --min-safari) and no maximum cap. An 18.* cap hides the extension on Safari 18 and later, including Safari 26.
  • Flags icons Safari cannot render (anything that is not PNG), content_scripts that use world: "MAIN" (Safari 18.4 and up only), and a missing App Store description.
  • Flags hardcoded chrome-extension://<id>/ URLs in JS, CSS, and HTML. Safari gives every install its own origin, so these need chrome.runtime.getURL().
  • Checks commands shortcuts. A chord with no primary modifier is dropped by Safari without a word, and ChromeOS-only modifiers like Search have no Safari equivalent.
  • Checks _locales and __MSG_*__ placeholders, so an unresolvable name/description reference does not ship as a literal placeholder string.
  • Flags URL match patterns left behind in permissions under MV3, a common migration slip. Safari ignores them there; they belong in host_permissions.
  • Validates the version string. A missing, non-numeric, or out-of-range version is rejected by Apple's CFBundleShortVersionString and fails the build.
  • Clears use_dynamic_url on web_accessible_resources. Chrome rotates those URLs per session and Safari cannot serve such an entry at all, so chrome.runtime.getURL() hands back a URL that 404s. Anything loaded that way (a content script's injected CSS, an <img>, a <link>, an iframe src) fails without an error, which is a frequent reason an in-page panel toggles but never appears.

Toolbar buttons Safari never clicks

Safari 26 does not dispatch action.onClicked to a converted background, and it does not fire commands.onCommand either. A popup-less toolbar button that toggles in-page UI, a sidebar or an overlay, is therefore dead on arrival. viaduct has two ways to revive it and tries them in this order.

The first is an in-page hotkey, and it is preferred because it never involves a popover. The toggle really happens inside the content script, in its runtime.onMessage listener. The shim loads ahead of the bundle's content script and captures those listeners; viaduct then reads, statically, the message the onClicked handler sends to the tab (something like {type:"TOGGLE_SHELL"}) and generates a content script that replays that message to the captured listeners when you press a keyboard shortcut. The toggle happens with no toolbar popup and no popover at all. The shortcut reuses a declared commands key when there is one, since Safari cannot fire onCommand and that key is otherwise dead weight (the inert command is removed), and falls back to Ctrl+Shift+Y. When this path is wired the toolbar button is deliberately left inert, because giving it a popup would summon Safari's un-closable popover. It needs two things: a statically determinable message, and an extension that has content scripts.

The second path is a synthetic popup, used only when the hotkey cannot be wired. viaduct adds a tiny transparent default_popup that wakes the background on click through runtime.getBackgroundPage(), which is the only call that wakes a suspended Safari background (runtime.sendMessage does not), then replays the captured onClicked listeners inside the background realm so their tabs.sendMessage actually reaches the content script, deduped down to a single toggle. This path carries a Safari limitation you will see: a toolbar popup always draws a popover that script cannot close, since window.close, blur, and refocus are all ignored, so a popover flashes up and goes away on your next interaction.

Runtime shim

The shim is injected into content scripts and into every extension HTML page: popup, options, side panel. It:

  • Routes storage.sync to storage.local, since Safari has no iCloud sync.
  • Stubs sidePanel, identity, notifications, tabGroups, debugger, and offscreen so evaluating a module does not throw and leave you a blank page. The sidePanel fallback opens whichever panel page the extension actually configured, from the manifest's side_panel.default_path or a setOptions({path}) call, instead of guessing at a filename.
  • Completes chrome.i18n by backfilling detectLanguage, getUILanguage, and getAcceptLanguages without clobbering Safari's native getMessage, so code calling the missing detectLanguage degrades to und rather than throwing.
  • Makes keyboard-shortcut management work without chrome://extensions/shortcuts, which Safari does not have. chrome.commands.getAll() is rebuilt from the manifest so an extension's own shortcut UI has something to show, and a navigation to chrome://extensions/shortcuts or chrome://settings is swallowed instead of opening a dead tab. Shortcuts themselves are edited in Safari, under Settings, Extensions. The analyzer warns when the source hardcodes one of those links.

Staging, packaging, install

  • Side-panel pages wired up as the action popup get sized automatically, so the popup is not a tiny collapsed window.
  • Staging copies a clean tree and drops dev cruft (*.map, *.ts, README, lockfiles, store metadata) while keeping any file the manifest declares as a runtime asset. A web-accessible LICENSE.txt or a deliberately served .map will not go missing and 404 in Safari.
  • The extension is packaged into an Xcode project, bundle identifiers are patched, and the app is optionally built ad-hoc or team-signed.
  • The bundle identifier is verified on the compiled .appex rather than on the project files, so the wrong extension never gets registered with Safari.
  • With --install the built host app is moved into ~/Applications, with no copy left behind, and registered with Safari. When it is team-signed it survives Safari restarts.

Requirements

  • macOS with a full Xcode install, not just the Command Line Tools, for the packaging and build steps. Both xcrun safari-web-extension-packager and xcodebuild come with Xcode.
  • Node.js 18 or newer.
  • No runtime dependencies. TypeScript is the only dev dependency.

You can check the toolchain at any time:

viaduct --doctor

Install

npm install -g @magicelk235/viaduct
viaduct <input> [options]

The command is viaduct. It is macOS only, because it needs Xcode. See Requirements above.

Build from source

npm install
npm run build

That compiles src/ into dist/. The CLI entry point is dist/cli.js.

Run it directly with Node:

node dist/cli.js <input> [options]

Or link it as a global command:

npm link
viaduct <input> [options]

Usage

Convert straight from a Chrome Web Store link and let viaduct download the CRX:

viaduct "https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm"

A direct .crx or .zip URL works the same way:

viaduct "https://example.com/my-extension.crx"

Analyze an extension and report the issues without converting it:

viaduct ./my-extension.zip --analyze

Issues are tagged so you can tell what needs your attention from what the converter already dealt with. [auto-fixed] means the manifest rewrite resolves it and there is nothing for you to do. [shimmed] means Safari rejects the API or permission but the injected shim emulates it, so the feature still works, and you only need a real migration if the shim's documented limitation matters to you. The summary line and the --analyze --json payload both carry autoFixed and shimmed counts, which are disjoint, so CI can see how much the converter absorbed.

Stage for Safari 18's "Add Temporary Extension", which skips Xcode entirely and is the fastest way to iterate:

viaduct ./my-extension.zip --temp-load

Then, in Safari: under Settings, Advanced, turn on "Show features for web developers"; under Settings, Developer, turn on "Allow Unsigned Extensions"; then use the Develop menu, "Add Temporary Extension", and pick the staged folder. Temporary extensions have to be re-added every time Safari restarts.

Generate an Xcode project without building it:

viaduct ./my-extension.zip --no-build

Full conversion with an ad-hoc build, using a clean copy that is safe for CI and TestFlight:

viaduct ./my-extension.zip --ci

Without --ci, resources are symlinked instead, so your edits show up live during development. Use --ci to clean-copy them into the project.

Convert several extensions in one go:

viaduct ./one.zip ./two.crx ./unpacked-dir

Batch runs give each extension its own default ./<App>_Safari output, so --output, --report, --app-name, --bundle-id, and --json are single-extension flags and are rejected here.

Options

-o, --output <dir>       Output directory (default: ./<AppName>_Safari)
    --bundle-id <id>     Reverse-DNS bundle id (default: com.viaduct.<app>)
    --app-name <name>    Host app name (default: extension name)
    --min-safari <ver>   Safari strict_min_version (default: 15.4; use 18.4 for world:MAIN)
    --platforms <p>      all | macos | ios            (default: macos)
    --ci                 Clean-copy resources (CI/TestFlight-safe)
    --temp-load          Stage only, for Safari 18 "Add Temporary Extension"
    --zip                Also emit a distributable .zip of the staged extension
    --clean              Wipe the output directory before staging
    --no-build           Generate the Xcode project but do not run xcodebuild
    --open-xcode         Open the generated .xcodeproj in Xcode when done
    --install            Install the built app to ~/Applications + register w/ Safari
    --verify             After --install, check Safari registered/enabled it
    --install-dir <dir>  Install target directory (default: ~/Applications)
    --uninstall <name>   Remove the installed <name>.app + unregister it
    --no-safari-restart  With --install, don't quit/relaunch Safari or set the toggle
    --background-launch  With --install, launch the host app hidden: the extension
                         still registers, but no window opens over your work
    --team [<id>]        Sign with an Apple Team ID. --team auto (or plain
                         --install) auto-detects it from Xcode, a provisioning
                         profile, or your signing certificate. Omit for ad-hoc.
    --no-shim            Do not generate/inject the compatibility shim
    --no-oauth-bridge    Do not wire the Safari OAuth/externally_connectable bridge
    --keep-module        Keep background.type:"module" (default strips it)
    --debug              Emit the shim with debug tracing enabled. Traces persist
                         to a bounded ring buffer (last 2000 entries) in
                         storage.local under __viaduct_debug_log__; read it with
                         --logs. Dev builds only — never ship a --debug build.
    --force              Convert despite blocking errors
    --strict             Treat warnings as blocking too (CI gate). With --analyze,
                         exit 1 if any warning or error is present.
    --analyze            Analyze and report only (also previews the manifest rewrites)
    --json               With --analyze, print a machine-readable JSON report
    --report <file>      With --analyze, also write the report to <file>
                         (.json if --json, else Markdown)
    --config <file>      Load defaults from <file> (default: ./viaduct.config.json
                         if present). JSON keyed by long-flag name; CLI flags win.
    --doctor             Verify xcrun/packager/xcodebuild availability
    --list               List Safari Web Extensions registered with pluginkit
    --logs <name>        Dump the persisted debug log of an installed --debug
                         build. <name> matches the app name or bundle id; reads
                         Safari's on-disk storage, so Safari can stay open.
-q, --quiet              Suppress progress messages (warnings/errors still print)
-v, --verbose            Verbose output
-h, --help               Show this help
    --version            Print the viaduct version and exit

Installing a built app

Easiest is to let the tool do it. It moves the built app into ~/Applications, leaving no duplicate behind, registers it with LaunchServices, and launches it once so Safari picks up the extension:

viaduct ./my-extension.zip --install

Then enable the extension in Safari, under Settings, Extensions.

To remove an app you installed earlier, which unregisters it from LaunchServices and deletes it from the install directory:

viaduct --uninstall <AppName>                       # ~/Applications
viaduct --uninstall <AppName> --install-dir <dir>   # custom directory

Persisting across Safari restarts (team signing)

How long the extension sticks around depends on how it was signed.

Ad-hoc, with no --team, means Safari only loads it while "Allow Unsigned Extensions" is on in the Develop menu, and that setting resets every time Safari restarts. With --install the tool flips the toggle and bounces Safari for you; pass --no-safari-restart if you would rather it did not.

Team-signed, with --team, uses a real Apple Developer certificate. Safari loads the extension without the unsigned toggle and it survives quitting Safari.

--team auto, which is also what plain --install does, finds your Team ID in Xcode so you never have to know or type it:

viaduct ./my-extension.zip --install            # auto-detects the team
viaduct ./my-extension.zip --install --team auto # same, explicit
viaduct ./my-extension.zip --install --team V8K8L3ZSD5  # exact id

Auto-detection reads the team Xcode cached (IDEProvisioningTeamByIdentifier or IDEProvisioningTeams, in both the com.apple.dt.Xcode and com.apple.dt.xcodebuild domains) plus the codesigning identities in your keychain, then uses the provisioning profiles on disk to choose between them, newest first, so when there are several the team you provisioned for most recently wins. Each source covers a few layouts, because which preference key gets written, where profiles live, and what your certificate is called all vary with the Xcode version. A team id that appears only in a profile is ignored: profiles outlive the account that installed them, and handing xcodebuild a team you have no account for fails every build. The team id is all the build needs, since xcodebuild runs with -allowProvisioningUpdates and Xcode mints the development certificate itself. If nothing usable turns up, the run tells you and falls back to ad-hoc signing.

Signing is not taken on trust. When a team does reach xcodebuild, the signature is read back off the built app and checked, so a build that quietly came out ad-hoc fails instead of handing you an extension that vanishes the next time Safari quits. An ad-hoc fallback that was announced up front is a warning rather than a failure, since you asked for "a team if there is one" and there wasn't one. If an auto-detected team turns out not to be able to sign, an expired certificate for instance, the build is retried ad-hoc so the conversion still finishes. A team you named yourself with --team <id> fails instead, because that was a deliberate request.

A free personal Apple team works, but its provisioning profile expires roughly every 7 days, so re-run the command to re-sign. A paid Developer Program account lasts about a year.

If you would rather install by hand, copy the app path the run prints:

cp -R "<AppName>_Safari/<AppName>.app" ~/Applications/
open "~/Applications/<AppName>.app"

Tested extensions

Dark Reader, Claude in Chrome, and TWP (Translate Web Pages) are verified working on the current build. Cloaked gets as far as a real sign-in window, which is where testing stopped for want of an account. Grammarly, LastPass, and several others were verified in an earlier round and are due a re-check. The wiki keeps the full list, including what was actually exercised in Safari and what is still untested: Tested Extensions.

Limitations

  • Ad and content blockers built on blocking webRequest, full uBlock Origin among them, cannot block network requests in Safari. WebKit decides each request before extension JavaScript runs and ignores the blocking return value. No shim can change that, because the decision happens below JS. The extension still installs and its cosmetic and element-hiding features still run, and viaduct reports this class of extension as an error. For real ad and tracker blocking on Safari, convert the extension's declarativeNetRequest build instead, for example uBlock Origin Lite (uBOL). Safari honors DNR rulesets, so uBOL blocks for real once converted.
  • APIs with no Safari equivalent are stubbed so the extension loads, but the feature behind them does not work. The analyzer reports each one with a suggested remediation.
  • Extensions that authenticate with chrome.identity or a hardcoded chrome-extension:// OAuth redirect cannot complete login. The OAuth client is registered on the provider's server against the original Chrome extension identity and scheme, which Safari cannot reproduce. Fixing it needs the provider to register a Safari redirect or a hosted HTTPS callback flow, and it is not something conversion alone can do.
  • storage.sync is mapped to storage.local. Data persists, but it does not sync across devices.
  • Native messaging (connectNative and sendNativeMessage) has no Chrome-style host manifest or host binary in Safari; messages route to the containing macOS app instead. The analyzer flags it, and you implement the response in the app's SafariWebExtensionHandler (beginRequest).
  • declarativeNetRequest modifyHeaders rules are dropped, from static rulesets and from dynamic updateSessionRules/updateDynamicRules calls alike. Safari accepts such a rule and then never applies it (verified against a live server, with a block rule in the same call blocking correctly), and one header name off WebKit's allowlist makes the whole update throw and takes the working rules with it. Spoofing user-agent/referer, stripping Cookie, a CORS bypass or a response-header rewrite all need a native-messaging proxy instead. The tool also warns when static rulesets use regexFilter, since Safari supports only a limited regex subset and silently drops rules it cannot compile, and when the number of enabled rules exceeds what Safari honors, since the overflow is ignored.

License

Licensed under the PolyForm Shield License 1.0.0. Copyright (c) 2026 Yehonatan Cohen (magicelk235). You may freely use, modify, and share it, but you may not use the Viaduct CLI to build a product that competes with it.

Releases

Packages

Used by

Contributors

Languages