▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓ ▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▓▒▒▒▓▓▓▓▓ ▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▓▓▓▓▒▒▓▒▓▒
▓▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓ ▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓ ▓▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▓▓▓▓▓▓ ▓▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▓▓▓▓▓▓▓ ▓▓▓▓▓▓▒▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
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.
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.
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.
- Removes Chrome-only keys (
update_url,key,minimum_chrome_version). - Strips permissions Safari does not implement, such as
tabGroups,offscreen,sidePanel, anddebugger. - Converts an MV3 service worker into a non-persistent background page, and
forces
persistent: falseon MV2 backgrounds too, because Safari rejects a persistent MV3 background outright ("a manifest_version >= 3 must be non-persistent"). It also stripsbackground.type: "module", a known cause of popups that fail silently. - Injects
browser_specific_settings.safariwith a minimum version (15.4by default, changed with--min-safari) and no maximum cap. An18.*cap hides the extension on Safari 18 and later, including Safari 26. - Flags icons Safari cannot render (anything that is not PNG),
content_scriptsthat useworld: "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 needchrome.runtime.getURL(). - Checks
commandsshortcuts. A chord with no primary modifier is dropped by Safari without a word, and ChromeOS-only modifiers likeSearchhave no Safari equivalent. - Checks
_localesand__MSG_*__placeholders, so an unresolvablename/descriptionreference does not ship as a literal placeholder string. - Flags URL match patterns left behind in
permissionsunder MV3, a common migration slip. Safari ignores them there; they belong inhost_permissions. - Validates the
versionstring. A missing, non-numeric, or out-of-range version is rejected by Apple'sCFBundleShortVersionStringand fails the build. - Clears
use_dynamic_urlonweb_accessible_resources. Chrome rotates those URLs per session and Safari cannot serve such an entry at all, sochrome.runtime.getURL()hands back a URL that 404s. Anything loaded that way (a content script's injected CSS, an<img>, a<link>, an iframesrc) fails without an error, which is a frequent reason an in-page panel toggles but never appears.
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.
The shim is injected into content scripts and into every extension HTML page: popup, options, side panel. It:
- Routes
storage.synctostorage.local, since Safari has no iCloud sync. - Stubs
sidePanel,identity,notifications,tabGroups,debugger, andoffscreenso evaluating a module does not throw and leave you a blank page. ThesidePanelfallback opens whichever panel page the extension actually configured, from the manifest'sside_panel.default_pathor asetOptions({path})call, instead of guessing at a filename. - Completes
chrome.i18nby backfillingdetectLanguage,getUILanguage, andgetAcceptLanguageswithout clobbering Safari's nativegetMessage, so code calling the missingdetectLanguagedegrades toundrather 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 tochrome://extensions/shortcutsorchrome://settingsis 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.
- 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-accessibleLICENSE.txtor a deliberately served.mapwill 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
.appexrather than on the project files, so the wrong extension never gets registered with Safari. - With
--installthe 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.
- macOS with a full Xcode install, not just the Command Line Tools, for the
packaging and build steps. Both
xcrun safari-web-extension-packagerandxcodebuildcome 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
npm install -g @magicelk235/viaduct
viaduct <input> [options]
The command is viaduct. It is macOS only, because it needs Xcode. See
Requirements above.
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]
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.
-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
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
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"
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.
- 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 anerror. For real ad and tracker blocking on Safari, convert the extension'sdeclarativeNetRequestbuild 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.identityor a hardcodedchrome-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.syncis mapped tostorage.local. Data persists, but it does not sync across devices.- Native messaging (
connectNativeandsendNativeMessage) 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'sSafariWebExtensionHandler(beginRequest). declarativeNetRequestmodifyHeadersrules are dropped, from static rulesets and from dynamicupdateSessionRules/updateDynamicRulescalls alike. Safari accepts such a rule and then never applies it (verified against a live server, with ablockrule 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. Spoofinguser-agent/referer, strippingCookie, a CORS bypass or a response-header rewrite all need a native-messaging proxy instead. The tool also warns when static rulesets useregexFilter, 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.
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.