A command-line tool that converts a Google Chrome extension into a Safari Web Extension
68
stars
272
commits
JavaScript
primary language
Aug 26, 2026
updated
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓ ▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▓▒▒▒▓▓▓▓▓ ▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▓▓▓▓▒▒▓▒▓▒
▓▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓ ▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓ ▓▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▓▓▓▓▓▓ ▓▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▓▓▓▓▓▓▓ ▓▓▓▓▓▓▒▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
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.
update_url, key, minimum_chrome_version).tabGroups,
offscreen, sidePanel, and debugger.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.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.content_scripts
that use world: "MAIN" (Safari 18.4 and up only), and a missing App Store
description.chrome-extension://<id>/ URLs in JS, CSS, and HTML. Safari
gives every install its own origin, so these need chrome.runtime.getURL().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._locales and __MSG_*__ placeholders, so an unresolvable
name/description reference does not ship as a literal placeholder string.permissions under MV3, a common
migration slip. Safari ignores them there; they belong in host_permissions.version string. A missing, non-numeric, or out-of-range version
is rejected by Apple's CFBundleShortVersionString and fails the build.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.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:
storage.sync to storage.local, since Safari has no iCloud sync.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.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.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.*.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..appex rather than on the
project files, so the wrong extension never gets registered with Safari.--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.xcrun safari-web-extension-packager and
xcodebuild come with Xcode.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.
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.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.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.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.
270 commits
2 commits
JavaScript
68.2%
TypeScript
31.8%
A command-line tool that converts a Google Chrome extension into a Safari Web Extension
68
stars
272
commits
JavaScript
primary language
Aug 26, 2026
updated
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓ ▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▓▒▒▒▓▓▓▓▓ ▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▓▓▓▓▒▒▓▒▓▒
▓▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓ ▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓ ▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓ ▓▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▒▓▓▓▓▓▓ ▓▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▒▓▓▓▓▓▓▓ ▓▓▓▓▓▓▒▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
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.
update_url, key, minimum_chrome_version).tabGroups,
offscreen, sidePanel, and debugger.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.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.content_scripts
that use world: "MAIN" (Safari 18.4 and up only), and a missing App Store
description.chrome-extension://<id>/ URLs in JS, CSS, and HTML. Safari
gives every install its own origin, so these need chrome.runtime.getURL().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._locales and __MSG_*__ placeholders, so an unresolvable
name/description reference does not ship as a literal placeholder string.permissions under MV3, a common
migration slip. Safari ignores them there; they belong in host_permissions.version string. A missing, non-numeric, or out-of-range version
is rejected by Apple's CFBundleShortVersionString and fails the build.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.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:
storage.sync to storage.local, since Safari has no iCloud sync.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.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.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.*.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..appex rather than on the
project files, so the wrong extension never gets registered with Safari.--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.xcrun safari-web-extension-packager and
xcodebuild come with Xcode.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.
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.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.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.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.
270 commits
2 commits
JavaScript
68.2%
TypeScript
31.8%