@magicelk235/viaduct
v1.3.1
Published
Convert a Chrome extension into a Safari Web Extension (macOS/iOS) ready for local dev or CI/CD.
Maintainers
Readme
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░
░░░░░░░░░░▓▓▓▒▒▒ ░░░░░░░░░░░▒▓▒▒▒▒▒ ░░░░░░░░░░░▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒░░░░░░░░
░░░░░░░░▓▓▒▒▒▒ ░░░░░░░░▓▒▒▒▒▒ ░░░░░░░░▒▒▒▒▒▒ ▒▒▒░░░░░░
░░░░░░░▓▒▒▒▒▒ ░░░░░░▒▒▒▒▒▒ ░░░░░░▒▒▒▒▒▒ ░░░░░░░
░░░░░░▓▒▒▒▒▒ ░░░░░░▒▒▒▒▒ ░░░░░░▒▒▒▒▒▒ ░░░░░░
░░░░░░▓▒▒▒▒▒ ░░░░░░▒▒▒▒▒ ░░░░░░▒▒▒▒▒▒ ░░░░░░
░░░░░░▒▒▒▒▒▒ ░░░░░░▒▒▒▒▒ ░░░░░░▒▒▒▒▒░ ░░░░░░
░░░░░░▒▒▒▒▒▒ ░░░░░░▒▒▒▒▒ ░░░░░░▒▒▒░░░ ░░░░░░
░░░░░░▒▒▒▒▒▒ ░░░░░░▒▒▒▒▒ ░░░░▒░▒░░░░░ ░░░▒▒░
▒▒▒▒▒▒▒▒▒▒▒▒ ▒▒▒▒▒░▒▒▒▒░ ▒▒▒▒▒▒░░░░░░ ▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒░░░ ▒▒▒▒▒▒▒▒░░░ ▒▒▒▒▒▒░░░░░░ ▒▒▒▒▒▒
▒▒▒▒▒▒▒▒░░░░ ▒▒▒▒▒▒░░░░░ ▒▒▒▒▒▒░░░░░░ ▒▒▒▒▒▒ Viaduct
A command-line tool that converts a Google Chrome extension into a Safari Web
Extension ready for local development, ad-hoc testing, or an Xcode/TestFlight
build. It extracts the extension, analyzes and rewrites the manifest for Safari,
injects a runtime compatibility shim for unsupported chrome.* APIs, and drives
Apple's safari-web-extension-packager and xcodebuild to produce a signed app.
What it does
- Accepts a
.zip,.crx,.xpi, an unpacked extension directory, or a URL — a Chrome Web Store link or a direct.crx/.zipdownload link (fetched automatically). Archive type is detected by magic bytes, so a mislabeled file (e.g. a CRX renamed to.zip) is still handled correctly. - Detects MV2 vs MV3 and reports incompatibilities before converting, with a
human report (
CONVERSION_REPORT.md) and a machine-readable--analyze --jsonfeed (counts,autoFixed/blockingtotals, aconvertibleverdict matching the real conversion gate, per-issue list, removed permissions, bundle id/name). - Rewrites the manifest for Safari:
- Removes Chrome-only keys (
update_url,key,minimum_chrome_version). - Strips permissions Safari does not implement (for example
tabGroups,offscreen,sidePanel,debugger). - Converts an MV3 service worker to a non-persistent background page and forces
persistent: falseon MV2 backgrounds too (Safari rejects a persistent MV3 background: "a manifest_version >= 3 must be non-persistent"); stripsbackground.type: "module"(a known cause of silent popup failures). - Injects
browser_specific_settings.safariwith a minimum version (default15.4, override with--min-safari) and no maximum cap (an18.*cap hides the extension on Safari 18+ and Safari 26). - Flags icons Safari cannot render (non-PNG),
content_scriptsusingworld: "MAIN"(Safari 18.4+ only), and a missing App Store description. - Flags hardcoded
chrome-extension://<id>/URLs in JS/CSS/HTML (Safari uses a different per-install origin); suggestschrome.runtime.getURL()instead. - Validates
commandskeyboard shortcuts: flags chords with no primary modifier (Safari silently drops them) and ChromeOS-only modifiers likeSearchthat have no Safari equivalent. - Validates
_localesand__MSG_*__placeholders: flags an unresolvablename/descriptionreference that would show as a literal placeholder. - Flags URL match patterns left in
permissionsunder MV3 (a common migration mistake): Safari ignores them there, so they belong inhost_permissions. - Validates the
versionstring: flags a missing, non-numeric, or out-of-range version that Apple'sCFBundleShortVersionStringrejects (the build fails). - Auto-wires a
default_popupwhen the action has none.
- Removes Chrome-only keys (
- Generates and injects a compatibility shim into content scripts and every
extension HTML page (popup, options, side panel). The shim:
- Routes
storage.synctostorage.local(Safari has no iCloud sync). - Stubs
sidePanel,identity,notifications,tabGroups,debugger, andoffscreenso module evaluation does not throw and blank the page. ThesidePanelfallback opens the panel page the extension actually configured (manifestside_panel.default_pathor asetOptions({path})call), not a hardcoded guess. - Completes
chrome.i18n(backfillsdetectLanguage/getUILanguage/getAcceptLanguageswithout clobbering Safari's nativegetMessage), so code that calls the Safari-missingdetectLanguagedegrades toundinstead of throwing. - Makes keyboard-shortcut management work without
chrome://extensions/ shortcuts(which Safari lacks):chrome.commands.getAll()is rebuilt from the manifest so an extension's own shortcut UI is populated, and a navigation tochrome://extensions/shortcuts(orchrome://settings) is swallowed instead of opening a broken tab. Shortcuts are edited in Safari → Settings → Extensions; the analyzer warns when source hardcodes such a link.
- Routes
- Auto-sizes side-panel pages wired as the action popup so the popup is not a collapsed, tiny window.
- Stages a clean copy that drops dev cruft (
*.map,*.ts,README, lockfiles, store metadata) while preserving any file the manifest declares as a runtime asset — so a web-accessibleLICENSE.txtor served.mapis never dropped and 404'd in Safari. - Packages the extension into an Xcode project, patches bundle identifiers, and optionally builds an ad-hoc or team-signed app.
- Verifies the bundle identifier of the COMPILED
.appex, not just the project files, so the wrong extension is never registered with Safari. - Optionally moves the built host app into
~/Applications(no intermediate copy) and registers it with Safari (--install), so the extension persists across Safari restarts when team-signed.
Requirements
- macOS with a full Xcode install (not just the Command Line Tools) for the
packaging and build steps.
xcrun safari-web-extension-packagerandxcodebuildship with Xcode. - Node.js 18 or newer.
- No runtime dependencies; TypeScript is the only dev dependency.
Run the built-in toolchain check at any time:
viaduct --doctorInstall
npm install -g @magicelk235/viaduct
viaduct <input> [options]The command is viaduct. macOS only (needs Xcode — see Requirements).
Build from source
npm install
npm run buildThis compiles src/ to 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 (the CRX is downloaded for you):
viaduct "https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm"A direct .crx or .zip download URL works too:
viaduct "https://example.com/my-extension.crx"Analyze an extension and report issues without converting:
viaduct ./my-extension.zip --analyzeEach issue is tagged so you can tell what needs your attention from what the converter already handles:
[auto-fixed]— the manifest rewrite resolves it; nothing to do.[shimmed]— Safari rejects the API/permission, but the injected runtime shim emulates it, so the feature keeps working. Migrate for real only if the shim's documented limitation matters to you.
The summary line and the --analyze --json payload both carry autoFixed and
shimmed counts (disjoint), so CI can see at a glance how much the converter
absorbed.
Stage for Safari 18+ "Add Temporary Extension" (no Xcode, fastest iteration):
viaduct ./my-extension.zip --temp-loadThen in Safari: Settings, Advanced, enable "Show features for web developers"; Settings, Developer, enable "Allow Unsigned Extensions"; Develop menu, "Add Temporary Extension", and select the staged folder. Temporary extensions must be re-added after each Safari restart.
Generate an Xcode project without building:
viaduct ./my-extension.zip --no-buildFull conversion and ad-hoc build (CI/TestFlight-safe clean copy):
viaduct ./my-extension.zip --ciThe default (without --ci) symlinks resources for live development edits; use
--ci to clean-copy resources into the project.
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
--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
--team [<id>] Sign with an Apple Team ID; --team auto (or plain --install)
auto-detects it from Xcode. Omit for ad-hoc signing.
--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)
--force Convert despite blocking errors
--strict Treat warnings as blocking too (CI gate)
--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)
--doctor Verify xcrun/packager/xcodebuild availability
-q, --quiet Suppress progress messages (warnings/errors still print)
-v, --verbose Verbose output
-h, --help Show this help
--version Print the viaduct version and exitInstalling a built app
Let the tool install for you. It moves the built app into ~/Applications (no
duplicate copy left behind), registers it with LaunchServices, and launches it
once so Safari registers the extension:
viaduct ./my-extension.zip --installThen enable the extension in Safari, Settings, Extensions.
To remove a previously installed app, unregister it from LaunchServices and delete it from the install directory:
viaduct --uninstall <AppName> # ~/Applications
viaduct --uninstall <AppName> --install-dir <dir> # custom directoryPersisting across Safari restarts (team signing)
How the extension persists depends on how it was signed:
- Ad-hoc (no
--team): Safari only loads it while "Allow Unsigned Extensions" (Develop menu) is on, and that setting resets every time Safari restarts. With--installthe tool sets the toggle and bounces Safari for you; pass--no-safari-restartto skip that. - Team-signed (
--team): signed with a real Apple Developer certificate, so Safari loads it without the unsigned toggle and it survives quitting Safari.
--team auto (or plain --install) auto-detects your Team ID from Xcode, so you
do not need 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 idAuto-detection reads the team cached by Xcode (IDEProvisioningTeamByIdentifier
in com.apple.dt.Xcode); it requires an Apple account signed into Xcode. If no
team is found, the build falls back to ad-hoc signing.
A free personal Apple team works, but its provisioning profile expires about every 7 days — re-run the command to re-sign. A paid Developer Program account lasts about a year.
If you prefer to install manually, copy the printed app path yourself:
cp -R "<AppName>_Safari/<AppName>.app" ~/Applications/
open "~/Applications/<AppName>.app"Limitations
- APIs with no Safari equivalent are stubbed so the extension loads, but the underlying feature 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. This requires the provider to register a Safari redirect or a hosted HTTPS callback flow; it cannot be fixed by conversion alone. storage.syncis mapped tostorage.local; data persists but does not sync across devices.- Native messaging (
connectNative/sendNativeMessage) has no Chrome-style host manifest or host binary in Safari — messages route to the containing macOS app. The analyzer flags it; you implement the response in the app'sSafariWebExtensionHandler(beginRequest). declarativeNetRequestrules with amodifyHeadersaction crash Safari's WebKit rule loader, so the tool strips them (both static rulesets and dynamicupdateSessionRules/updateDynamicRulescalls). Header-rewriting use cases — for example a CORS bypass — are not converted; use a native-messaging proxy instead. The tool also warns when static rulesets useregexFilter(Safari supports a limited regex subset and silently drops rules it cannot compile) or when enabled rules exceed the count Safari honors (the overflow is ignored).
