Skip to content

Conversion Pipeline

magicelk235 edited this page Jul 23, 2026 · 3 revisions

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) tallies error-severity issues (warnings too under --strict). If any exist and --force was not passed, the run prints the issues and returns early, nothing is staged, no project is built.
  • --force bypasses 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.

--analyze is not a pipeline stage. It is handled before convert() in cli.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.


Stages, in execution order

Every function named below is called in convert.ts. Each stage links to its deep-dive page.

  1. 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.

  2. Load manifest: loadManifest + resolveI18nString (manifest/manifest.ts). Parses manifest.json (JSONC-tolerant) and resolves the localized extension name for logging/reporting. See Manifest Transform.

  3. Derive the real Chrome extension id: deriveChromeId (runtime/oauth-bridge.ts). Computes the extension's true Chrome id from manifest.key (base64 CRX public key → SHA-256 → first 16 bytes mapped to a through p). Ordering invariant: this MUST run before the manifest transform, because transformManifest strips the key field, and the OAuth bridge and native handler bake chrome-extension://<id> into their templates so pages that read chrome.runtime.id keep matching after conversion (53b8e3c; id derivation generalized from Claude-specific to any CRX key across 34d4724208eaa6). See OAuth Bridge.

  4. Analyze manifest + scan code: analyzeManifest (manifest/manifest.ts) + scanExtension (analyze/analyze.ts). analyzeManifest returns issues, the permission list to strip, and whether a CDP shim is needed; scanExtension greps the JS/HTML for unsupported Chrome APIs. Their issues are merged into result.issues. It also computes result.needsWebsiteAccessGrant because Safari gates every host-match content script behind a per-user "Ask" grant. Severity matters: a degrade-but-works case (blocking webRequest) is a warning, not an error, so it does not abort, aa89a20. See Analyzer.

  5. Blocking-error gate: countBlocking / printIssues (analyze/report.ts). If blocking issues exist and --force is absent, print and return. --force skips this gate; --strict makes warnings count as blocking.

  6. Guard against input/output overlap: realpathSync-based checks in convert.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. --clean wipes the output dir first.

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

  8. Source rewrites the shim can't do at runtime: five input/stage.ts passes over the staged JS (a sixth, idempotentContentScriptGlobals, runs later at stage 11a once the transformed manifest is known):

    • inlineImmutableEnums, inline chrome.scripting.ExecutionWorld.ISOLATED to its literal (Safari's chrome.scripting is a frozen host slot the shim can't extend).
    • rewriteRuntimeIdUrlMatchers, strip runtime.id + from port-URL regexes (Safari's runtime.id is the bundle id, not the URL host, so matchers would never fire).
    • rewriteChromeSchemeLiterals, rewrite hardcoded chrome-extension: self-page checks (Safari pages are safari-web-extension://). Runs before the shim/polyfill are written, whose templates carry chrome-extension:// on purpose.
    • rewriteSelfPageExtensionUrls, turn whole-URL chrome-extension://${id}/page.html navigations into runtime.getURL(...) (otherwise they open dead tabs in Safari).
    • guardAncestorOriginsAccess, guard location.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.

  9. 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); transformManifest deep-clones its input so the value stays valid at both points. See Runtime Shim.

  10. Write + inject the compatibility shim: writePolyfill, writeShim, injectShimIntoHtmlPages (runtime/shim.ts). Bundles webextension-polyfill (gives browser.* promises), writes safari-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, because transformManifest prepends shimFile to every content script: so the file must exist and its path be known first. --no-shim skips this entire stage (generateShim is !--no-shim). See Runtime Shim.

  11. Transform the manifest: transformManifest (manifest/manifest.ts). Strips unsupported permissions (from stage 4), rewrites keys for Safari (removes key), prepends the shim/polyfill to content scripts, and applies MV3/Safari-version adjustments. Produces the in-memory transformed manifest that the remaining stages read and further mutate; it is not written to disk yet (see stage 20). Honors --keep-module and --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).

  1. Neutralize DNR header rules: applyDnr (manifest/dnr.ts). Rewrites/neutralizes declarativeNetRequest rules Safari can't honor (e.g. unsupported header modifications), emitting warnings. See Manifest Transform.

  2. 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↔Safari chrome.identity OAuth handshake works, baking in the real Chrome id from stage 3. --no-oauth-bridge skips it (guarded by opts.oauthBridge !== false). See OAuth Bridge.

  3. Wire the toolbar action: wireActionHotkey then wireActionClickBridge (runtime/shim.ts). Prefers a popover-free in-page hotkey; falls back to a synthetic popup that replays action.onClicked only when no hotkey can be wired (Safari doesn't fire onClicked on the toolbar button). See Runtime Shim.

  4. Service worker → non-persistent background page: convertServiceWorkerToBackgroundPage (runtime/shim.ts). Converts an MV3 service worker into a non-persistent background page, the code sets manifest.background = { page, persistent: false }, with the comment quoting Safari's rule "A manifest_version >= 3 must be non-persistent." And, ordering-critical: it hoists importScripts() targets as classic <script> tags in the generated background HTML, which is CSP-safe where importScripts in a Safari background page is not (fc49074). See Runtime Shim.

  5. CDP keep-alive: wireCdpKeepalive (runtime/shim.ts). Only when chrome.debugger was detected (needsCdpShim): injects a content script that keeps the Safari background loaded during debugger sessions. See Runtime Shim.

  6. Page-world → world:"MAIN" content scripts: wirePageWorldMainInjection (runtime/shim.ts). Re-declares content scripts that inject <script src=getURL(X)> into the page as world:"MAIN" content scripts (Safari 18.4+ runs those CSP-exempt, unlike page-injected WAR scripts). See Runtime Shim.

  7. Synthesize placeholder icons: synthesizePlaceholderIcons (input/icons.ts). Generates solid-color PNG icons when the manifest declares none (Xcode/Safari expect icons). See Input Handling.

  8. Strip dangling sourcemaps: stripDanglingSourcemaps (input/stage.ts). Removes //# sourceMappingURL= refs pointing at .map files that weren't staged. See Input Handling.

  9. Write the manifest + size the popup: writeManifest then injectPopupSizing (manifest/manifest.ts, runtime/shim.ts). Now that all in-memory manifest mutations (stages 11, 17) are done, the final transformed manifest is written to staged_extension/. Then the popup HTML is given a height floor, or full height when it's actually a side-panel page (side_panel field or a side[_-]?panel.html name) that would otherwise collapse in a popover. result.stagedPath is set here. See Manifest Transform / Runtime Shim.

  10. Write the report: writeReportFile (analyze/report.ts). Emits a Markdown conversion report (name, version, removed permissions, all issues) into the output dir. See Analyzer.

  11. Optional zip: run("ditto", …). With --zip, ditto -c -k archives the staged dir's contents (so it unpacks straight to manifest.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.


Visual flow

                          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

Terminal branches summary

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) ,

See also

Architecture · CLI Reference · Input Handling · Manifest Transform · Analyzer · Runtime Shim · OAuth Bridge · Build and Install

Clone this wiki locally