Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

magicelk235/Viaduct-CLI

Open more actions menu

Repository files navigation

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

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/.zip download 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 --json feed (counts, autoFixed/blocking totals, a convertible verdict 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: false on MV2 backgrounds too (Safari rejects a persistent MV3 background: "a manifest_version >= 3 must be non-persistent"); strips background.type: "module" (a known cause of silent popup failures).
    • Injects browser_specific_settings.safari with a minimum version (default 15.4, override with --min-safari) and no maximum cap (an 18.* cap hides the extension on Safari 18+ and Safari 26).
    • Flags icons Safari cannot render (non-PNG), content_scripts using world: "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); suggests chrome.runtime.getURL() instead.
    • Validates commands keyboard shortcuts: flags chords with no primary modifier (Safari silently drops them) and ChromeOS-only modifiers like Search that have no Safari equivalent.
    • Validates _locales and __MSG_*__ placeholders: flags an unresolvable name/description reference that would show as a literal placeholder.
    • Flags URL match patterns left in permissions under MV3 (a common migration mistake): Safari ignores them there, so they belong in host_permissions.
    • Validates the version string: flags a missing, non-numeric, or out-of-range version that Apple's CFBundleShortVersionString rejects (the build fails).
    • Clears use_dynamic_url on web_accessible_resources (Chrome-only per-session URL rotation): Safari can't serve such an entry, so chrome.runtime.getURL() hands back a URL that 404s and any asset loaded that way (a content script's injected CSS, an <img>/<link>/iframe src) silently fails — a common cause of an in-page panel/sidebar that toggles but never appears.
    • Makes a popup-less toolbar button that toggles in-page UI (a sidebar/overlay) from chrome.action.onClicked work in Safari. Safari (26) never dispatches action.onClicked — nor commands.onCommand — to a converted background, so such a button is otherwise dead. Two paths, in priority order:
      • In-page hotkey (popover-free, preferred). The toggle actually happens inside the content script (its runtime.onMessage listener flips the UI). The shim loads before the bundle's content script and captures those listeners; viaduct statically reads the message the onClicked handler sends to the tab (e.g. {type:"TOGGLE_SHELL"}) and generates a content script that, on a keyboard shortcut, replays that message to the captured listeners — reproducing the toggle with no toolbar popup and no popover. The shortcut reuses a declared commands key when present (Safari can't fire onCommand, so that key is otherwise dead, and the inert command is removed), else Ctrl+Shift+Y. When this is wired the toolbar button is intentionally left inert — wiring a popup would force Safari's un-closable popover. Requires the message to be statically determinable and the extension to have content scripts.
      • Synthetic popup (fallback). Only when the hotkey can't be wired, viaduct adds a tiny transparent default_popup that, on click, wakes the background via runtime.getBackgroundPage() (the only thing that wakes a suspended Safari background — runtime.sendMessage does not) and replays the captured onClicked listeners in the background realm (so their tabs.sendMessage reaches the content script), deduped to one toggle. Safari limitation: a toolbar popup always draws a popover that script cannot close (window.close/blur/refocus are ignored), so this path shows a brief popover that dismisses on the next interaction.
  • Generates and injects a compatibility shim into content scripts and every extension HTML page (popup, options, side panel). The shim:
    • Routes storage.sync to storage.local (Safari has no iCloud sync).
    • Stubs sidePanel, identity, notifications, tabGroups, debugger, and offscreen so module evaluation does not throw and blank the page. The sidePanel fallback opens the panel page the extension actually configured (manifest side_panel.default_path or a setOptions({path}) call), not a hardcoded guess.
    • Completes chrome.i18n (backfills detectLanguage/getUILanguage/ getAcceptLanguages without clobbering Safari's native getMessage), so code that calls the Safari-missing detectLanguage degrades to und instead 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 to chrome://extensions/shortcuts (or chrome://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.
  • 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-accessible LICENSE.txt or served .map is 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-packager and xcodebuild ship 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 --doctor

Install

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 build

This 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 --analyze

Each 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-load

Then 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-build

Full conversion and ad-hoc build (CI/TestFlight-safe clean copy):

viaduct ./my-extension.zip --ci

The 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 exit

Installing 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 --install

Then 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 directory

Persisting 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 --install the tool sets the toggle and bounces Safari for you; pass --no-safari-restart to 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 id

Auto-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

  • Ad/content blockers built on blocking webRequest (e.g. full uBlock Origin) cannot block network requests in Safari. WebKit decides each request before extension JavaScript runs and ignores the blocking return value — no shim can change this, because the decision happens below JS. The extension still installs and its cosmetic/element-hiding features still run; viaduct reports this class of extension with an error. For real ad/tracker blocking on Safari, convert the extension's declarativeNetRequest build instead — e.g. 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 underlying feature 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. This requires the provider to register a Safari redirect or a hosted HTTPS callback flow; it cannot be fixed by conversion alone.
  • storage.sync is mapped to storage.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's SafariWebExtensionHandler (beginRequest).
  • declarativeNetRequest rules with a modifyHeaders action crash Safari's WebKit rule loader, so the tool strips them (both static rulesets and dynamic updateSessionRules/updateDynamicRules calls). 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 use regexFilter (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).

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.

Morty Proxy This is a proxified and sanitized view of the page, visit original site.