-
Notifications
You must be signed in to change notification settings - Fork 3
Conversion Pipeline
src/convert.ts is the orchestrator. Its single exported function, convert(opts), runs the entire Chrome-extension → Safari-Web-Extension conversion as a deterministic, ordered pipeline: every stage runs in a fixed sequence, and the output of one stage feeds the next. There is no plugin system and no conditional reordering: the order below is exactly the order of statements in convert.ts.
Two things gate the flow:
-
Blocking errors short-circuit. After the analysis stages,
countBlocking(src/analyze/report.ts) tallieserror-severity issues (warnings too under--strict). If any exist and--forcewas not passed, the run prints the issues and returns early, nothing is staged, no project is built. -
--forcebypasses that gate, converting a known-broken extension anyway.
Everything runs inside a try/finally with a scratch temp dir and SIGINT/SIGTERM handlers, so an interrupt still cleans up.
--analyzeis not a pipeline stage. It is handled beforeconvert()incli.ts(analyzeOnly): it reports issues and exits without ever entering this pipeline. See Analyzer and CLI Reference.
Pipeline orchestrator, CLI, reporting, and Safari-18 temp-load landed together in c212d81.
Every function named below is called in convert.ts. Each stage links to its deep-dive page.
-
Extract input:
extractExtension(input/extract.ts). Detects the input type by magic bytes and unpacks a.zip/.crx/.xpi/unpacked dir/URL into the scratch dir, returning the extension root. This is the "get files onto disk" step everything else reads from. See Input Handling. -
Load manifest:
loadManifest+resolveI18nString(manifest/manifest.ts). Parsesmanifest.json(JSONC-tolerant) and resolves the localized extension name for logging/reporting. See Manifest Transform. -
Derive the real Chrome extension id:
deriveChromeId(runtime/oauth-bridge.ts). Computes the extension's true Chrome id frommanifest.key(base64 CRX public key → SHA-256 → first 16 bytes mapped toathroughp). Ordering invariant: this MUST run before the manifest transform, becausetransformManifeststrips thekeyfield, and the OAuth bridge and native handler bakechrome-extension://<id>into their templates so pages that readchrome.runtime.idkeep matching after conversion (53b8e3c; id derivation generalized from Claude-specific to any CRX key across34d4724→208eaa6). See OAuth Bridge. -
Analyze manifest + scan code:
analyzeManifest(manifest/manifest.ts) +scanExtension(analyze/analyze.ts).analyzeManifestreturns issues, the permission list to strip, and whether a CDP shim is needed;scanExtensiongreps the JS/HTML for unsupported Chrome APIs. Their issues are merged intoresult.issues. It also computesresult.needsWebsiteAccessGrantbecause Safari gates every host-match content script behind a per-user "Ask" grant. Severity matters: a degrade-but-works case (blockingwebRequest) is awarning, not anerror, so it does not abort,aa89a20. See Analyzer. -
Blocking-error gate:
countBlocking/printIssues(analyze/report.ts). If blocking issues exist and--forceis absent, print and return.--forceskips this gate;--strictmakes warnings count as blocking. -
Guard against input/output overlap:
realpathSync-based checks inconvert.ts. Refuses to run if the input extension lives inside the output dir (staging would delete it) or vice-versa (can't copy a dir into itself). Not a transform, a safety gate before anything destructive.--cleanwipes the output dir first. -
Stage clean assets:
stageExtension(input/stage.ts). Copies only manifest-referenced files (collectReferencedPaths) into<output>/staged_extension/(a persistent dir, not scratch, so dev-mode symlinks survive cleanup). Returns any dropped assets, which are warned about. This staged dir is the working tree every later stage mutates. See Input Handling. -
Source rewrites the shim can't do at runtime: five
input/stage.tspasses over the staged JS (a sixth,idempotentContentScriptGlobals, runs later at stage 11a once the transformed manifest is known):-
inlineImmutableEnums, inlinechrome.scripting.ExecutionWorld.ISOLATEDto its literal (Safari'schrome.scriptingis a frozen host slot the shim can't extend). -
rewriteRuntimeIdUrlMatchers, stripruntime.id +from port-URL regexes (Safari'sruntime.idis the bundle id, not the URL host, so matchers would never fire). -
rewriteChromeSchemeLiterals, rewrite hardcodedchrome-extension:self-page checks (Safari pages aresafari-web-extension://). Runs before the shim/polyfill are written, whose templates carrychrome-extension://on purpose. -
rewriteSelfPageExtensionUrls, turn whole-URLchrome-extension://${id}/page.htmlnavigations intoruntime.getURL(...)(otherwise they open dead tabs in Safari). -
guardAncestorOriginsAccess, guardlocation.ancestorOrigins[0]reads (Safari's is an empty DOMStringList; the trailing string method throws at load → blank popup).
These edit the source in place because the runtime shim can't touch frozen/exotic Safari slots. See Input Handling.
-
-
Derive proxy hosts:
deriveProxyHosts(runtime/shim.ts). Computes the backend-host allowlist once, shared by both the shim (next) and the Swift native handler (later);transformManifestdeep-clones its input so the value stays valid at both points. See Runtime Shim. -
Write + inject the compatibility shim:
writePolyfill,writeShim,injectShimIntoHtmlPages(runtime/shim.ts). Bundleswebextension-polyfill(givesbrowser.*promises), writessafari-compat-shim.js(configured with the Chrome origin, proxy hosts, and CDP flag), and injects both into every extension HTML page. Ordering invariant: the shim is written here, before the manifest transform, becausetransformManifestprependsshimFileto every content script: so the file must exist and its path be known first.--no-shimskips this entire stage (generateShimis!--no-shim). See Runtime Shim. -
Transform the manifest:
transformManifest(manifest/manifest.ts). Strips unsupported permissions (from stage 4), rewrites keys for Safari (removeskey), prepends the shim/polyfill to content scripts, and applies MV3/Safari-version adjustments. Produces the in-memorytransformedmanifest that the remaining stages read and further mutate; it is not written to disk yet (see stage 20). Honors--keep-moduleand--min-safari. See Manifest Transform.
11a. Make content-script globals re-injection-safe: idempotentContentScriptGlobals (input/stage.ts).
Runs right after transformManifest because it needs the finalized content_scripts list. Demotes each top-level const/let to var in the files an isolated-world content-script entry references, so Safari's second evaluation of a document_end/all_frames group into a shared world (about:blank/srcdoc subframes) doesn't throw "Can't create duplicate variable" and kill the group. world:"MAIN" and non-content-script files are untouched. See Input Handling / Safari Quirks (E4).
-
Neutralize DNR header rules:
applyDnr(manifest/dnr.ts). Rewrites/neutralizesdeclarativeNetRequestrules Safari can't honor (e.g. unsupported header modifications), emitting warnings. See Manifest Transform. -
Wire the OAuth bridge:
applyOAuthBridge(runtime/oauth-bridge.ts). Injects the identity polyfill and page-bridge scripts and web-accessible-resource entries so a Chrome↔Safarichrome.identityOAuth handshake works, baking in the real Chrome id from stage 3.--no-oauth-bridgeskips it (guarded byopts.oauthBridge !== false). See OAuth Bridge. -
Wire the toolbar action:
wireActionHotkeythenwireActionClickBridge(runtime/shim.ts). Prefers a popover-free in-page hotkey; falls back to a synthetic popup that replaysaction.onClickedonly when no hotkey can be wired (Safari doesn't fireonClickedon the toolbar button). See Runtime Shim. -
Service worker → non-persistent background page:
convertServiceWorkerToBackgroundPage(runtime/shim.ts). Converts an MV3 service worker into a non-persistent background page, the code setsmanifest.background = { page, persistent: false }, with the comment quoting Safari's rule "A manifest_version >= 3 must be non-persistent." And, ordering-critical: it hoistsimportScripts()targets as classic<script>tags in the generated background HTML, which is CSP-safe whereimportScriptsin a Safari background page is not (fc49074). See Runtime Shim. -
CDP keep-alive:
wireCdpKeepalive(runtime/shim.ts). Only whenchrome.debuggerwas detected (needsCdpShim): injects a content script that keeps the Safari background loaded during debugger sessions. See Runtime Shim. -
Page-world →
world:"MAIN"content scripts:wirePageWorldMainInjection(runtime/shim.ts). Re-declares content scripts that inject<script src=getURL(X)>into the page asworld:"MAIN"content scripts (Safari 18.4+ runs those CSP-exempt, unlike page-injected WAR scripts). See Runtime Shim. -
Synthesize placeholder icons:
synthesizePlaceholderIcons(input/icons.ts). Generates solid-color PNG icons when the manifest declares none (Xcode/Safari expect icons). See Input Handling. -
Strip dangling sourcemaps:
stripDanglingSourcemaps(input/stage.ts). Removes//# sourceMappingURL=refs pointing at.mapfiles that weren't staged. See Input Handling. -
Write the manifest + size the popup:
writeManifesttheninjectPopupSizing(manifest/manifest.ts,runtime/shim.ts). Now that all in-memory manifest mutations (stages 11, 17) are done, the finaltransformedmanifest is written tostaged_extension/. Then the popup HTML is given a height floor, or full height when it's actually a side-panel page (side_panelfield or aside[_-]?panel.htmlname) that would otherwise collapse in a popover.result.stagedPathis set here. See Manifest Transform / Runtime Shim. -
Write the report:
writeReportFile(analyze/report.ts). Emits a Markdown conversion report (name, version, removed permissions, all issues) into the output dir. See Analyzer. -
Optional zip:
run("ditto", …). With--zip,ditto -c -karchives the staged dir's contents (so it unpacks straight tomanifest.json, the shape Safari's temp-load expects).
--- Terminal branch A: --temp-load (stage only, no Xcode) ---
23a. Temp-load stop: writeTempLoadInstructions (build/tempload.ts).
When --temp-load is set (tempLoadOnly), the pipeline stops here: it writes SAFARI_LOAD_INSTRUCTIONS.md next to the staged dir (for Safari 18's "Add Temporary Extension…"), marks success, and returns. No Xcode project, no build, no install. This is the fast iteration path. See Build and Install.
--- Terminal branch B: full build (default) ---
23b. Package into an Xcode project: runPackager (build/packager.ts).
Runs safari-web-extension-packager on the staged dir, producing the .xcodeproj. Returns early on failure. --ci sets copyResources: true (else dev-mode symlinks).
24b. Patch bundle ids + build version: patchProjectBundleIds, setBuildVersion (build/packager.ts).
Rewrites the app/appex bundle identifiers to the resolved id and stamps a unique version per build so Safari doesn't serve stale cached shim/background JS.
25b. Native handler / native-messaging broker: writeNativeHandler, and for nativeMessaging also writeAppBroker + unsandboxAppTarget (build/packager.ts).
Only when there are proxy hosts (stage 9) or the nativeMessaging permission: writes the Swift out-of-process handler (sets the Chrome origin on backend requests the shim can't) and, for native messaging, a loopback broker in the (unsandboxed) container app with a deterministic port/token derived from the bundle id.
26b. Open in Xcode / skip build: run("open", …); --no-build returns here after printing "open in Xcode to build". --open-xcode opens the project.
27b. Build & sign: buildXcodeProject (build/packager.ts).
Runs xcodebuild (ad-hoc, or team-signed with --team), producing the built .app and a throwaway DerivedData dir. Returns early on failure. See Build and Install.
28b. Verify bundle ids: verifyBuiltBundleId (build/packager.ts).
Confirms the compiled app/appex ids match intent before the bundle moves, aborts if not (Safari would register the wrong extension).
29b. Pluginkit / unsigned-extension checks: pluginkitStatus, unsignedExtensionsAllowed (build/packager.ts).
Reports pluginkit state; for ad-hoc (non---team) builds, warns if Safari's "Allow Unsigned Extensions" is off.
30b. Install (optional) or relocate: installToSafari / installBrokerAgent (build/installer.ts).
With --install, moves the product into ~/Applications and (for native messaging) registers the broker LaunchAgent; on failure sets installFailed so the CLI exits non-zero. Without --install, relocates the signed .app out of the throwaway DerivedData to a stable path in the output dir. See Build and Install.
convert.ts — deterministic, ordered
Extract ─▶ loadManifest ─▶ deriveChromeId ─▶ analyze + scanExtension ─▶ [BLOCKING GATE]
(input/) (manifest/) (oauth-bridge) (manifest/ + analyze/) countBlocking>0
│ ▲ before key && !--force
│ │ is stripped │
│ └───────────────────────────────────┐ abort ◀┘ (unless --force)
▼ │
overlap guard ─▶ stageExtension ─▶ 5 source rewrites ─▶ deriveProxyHosts
(--clean wipes) (input/stage) (enums / runtime.id / │
scheme / self-page / │
ancestorOrigins) │
▼
writeShim + polyfill + injectShimIntoHtmlPages ────────── (skip if --no-shim)
(runtime/shim) │
▼
transformManifest ─▶ idempotentContentScriptGlobals ─▶ applyDnr ─▶ applyOAuthBridge ─▶ wireAction(hotkey|clickBridge)
(prepends shim to CS) (top-level const/let → var (manifest) (skip if (runtime/shim)
strips `key` in isolated-world CS files) --no-oauth-bridge) │
▼
convertServiceWorkerToBackgroundPage ─▶ wireCdpKeepalive ─▶ wirePageWorldMainInjection
(→ non-persistent bg page; hoist importScripts (if CDP) (world:"MAIN")
as classic <script>, CSP-safe)
│
▼
synthesizePlaceholderIcons ─▶ stripDanglingSourcemaps ─▶ writeManifest ─▶ injectPopupSizing
(input/icons) (input/stage) (to staged dir) (side-panel aware)
│
▼
writeReportFile ─▶ [optional --zip]
│
┌─────────────┴──────────────────────────────┐
▼ --temp-load ▼ default (full build)
writeTempLoadInstructions runPackager ─▶ patchProjectBundleIds + setBuildVersion
(stage only, NO Xcode) (→ .xcodeproj) │
return success writeNativeHandler / writeAppBroker (nativeMessaging)
│ (--no-build → return here)
▼
buildXcodeProject (xcodebuild, ±--team)
▼
verifyBuiltBundleId ─▶ pluginkit/unsigned checks
▼
--install ? installToSafari (+broker agent)
: relocate built .app to output dir
| Branch | Trigger | What runs | What is skipped |
|---|---|---|---|
| Temp-load |
--temp-load (tempLoadOnly) |
Stages 1, 22, then writeTempLoadInstructions; returns success |
No runPackager, no xcodebuild, no install, stage dir only |
| No build | --no-build |
Stages 1, 22 + runPackager + bundle-id patch + native handler; returns |
No buildXcodeProject, no install |
| Full build | default | All build stages through buildXcodeProject + verifyBuiltBundleId; relocates the built app |
, |
| Install | --install |
Full build, then installToSafari (+ installBrokerAgent for native messaging) |
, |
Architecture · CLI Reference · Input Handling · Manifest Transform · Analyzer · Runtime Shim · OAuth Bridge · Build and Install
Viaduct CLI · @magicelk235/viaduct · PolyForm Shield 1.0.0 · Verified against src/ and grounded in git history.