Skip to content

Manifest Transform

magicelk235 edited this page Aug 19, 2026 · 6 revisions

Manifest Transform

How viaduct rewrites a Chrome manifest.json into one Safari will load, plus the declarativeNetRequest sanitizer and the Safari compat-data tables that drive it.

Scope. This page covers the manifest rewrite: the manifest.json keys viaduct deletes, injects, and reshapes, and the DNR rule files it edits on disk. The analysis that decides what to strip (JS scanning, issue reporting, severity) lives on Analyzer; the runtime browser/chrome emulation those strips lean on lives on Runtime Shim. Where a transform only makes sense together with a Safari behavior, the deep "why" is on Safari Quirks.

All code references are to src/manifest/manifest.ts, src/manifest/compat-data.ts, src/manifest/dnr.ts, and, for the pipeline steps that mutate the manifest object outside the pure transform, src/convert.ts, src/runtime/shim.ts, and src/input/stage.ts.


Where the rewrite happens

The manifest rewrite is not one function. It is a pipeline, orchestrated in src/convert.ts:

  1. analyzeManifest(m): read-only. Returns { issues, permissionsToRemove, needsCdpShim }. It decides what is unsupported (consulting the compat tables) but changes nothing. See Analyzer.
  2. transformManifest(m, permissionsToRemove, extPath, opts): the pure core rewrite. Deep-clones its input (JSON.parse(JSON.stringify(m))), never touches disk, returns the Safari manifest object.
  3. Pipeline mutators that run after transformManifest and further mutate the same object as a side effect of writing files, applyDnr, convertServiceWorkerToBackgroundPage, wireActionHotkey / wireActionClickBridge, wirePageWorldMainInjection, plus the on-disk source rewrites rewriteChromeSchemeLiterals / rewriteSelfPageExtensionUrls / idempotentContentScriptGlobals in stage.ts. (idempotentContentScriptGlobals reads the transformed content_scripts but only rewrites source, it doesn't mutate the manifest.)
  4. writeManifest(targetDir, manifest): the final write. Drops an empty content_scripts: [] (Safari rejects it) then serializes.

So "the transform" below means the whole rewrite, and each row says which function actually performs it.


A) The rewrite, key by key

Chrome key → Safari action → why

Chrome key / field Safari action Why Where
update_url Deleted Safari updates via the App Store; the field is dead. transformManifest
key Deleted CRX packing identity; Safari derives identity from the bundle id. transformManifest
minimum_chrome_version Deleted Meaningless on Safari. transformManifest
version_name Kept A real runtime field (getManifest().version_name); some extensions render it and crash on undefined if it is dropped. (5b1bd03)
version Clamped Xcode needs dot-separated integers, ≤ 3 components, each ≤ 65535; keep leading numeric parts, stop at the first non-numeric, else fall back to 1.0.0. transformManifest
permissions / optional_permissions Filtered: every token in permissionsToRemove removed, plus any host-pattern entry with an unparseable scheme (chrome://favicon/, ws://…) dropped Safari rejects or ignores the unsupported permission tokens (see compat tables). A chrome://-scheme URL left in permissions is worse than ignored: Safari treats the whole manifest as invalid and won't load the extension, so it's stripped like host_permissions patterns, see Safari Quirks A6 (31d13b9). transformManifest
declarativeNetRequestWithHostAccess Stripped, then plain declarativeNetRequest re-added to whichever list held the variant Safari doesn't recognize the variant token, but both grant the same API, losing it silently would kill DNR. transformManifest
host_permissions / optional_host_permissions Bad patterns dropped (matchPatternError) A pattern Safari's parser rejects (e.g. ws://*/*) grants no host access silently; dropping it matches what Safari would do anyway and keeps the valid rest. transformManifest
MV2 background.persistent Forced false Safari does not honor a persistent MV2 background page. transformManifest
MV3 background.type: "module" Deleted (unless --keep-module) A module background produces silent popup / listener failures on Safari. transformManifest
commands Capped at 4: suggested_key stripped from the 5th onward (declaration order) Safari rejects the whole manifest with a 5th shortcut ("only 4 shortcuts are allowed"); extras stay bindable manually. (d3fcb9a)
page_action Folded into action (MV3) or browser_action (MV2) Safari has no page_action; empty {} stubs are treated as absent first. transformManifest
MV3 string content_security_policy Wrapped to { extension_pages: <string> } Safari silently ignores a bare-string CSP under MV3, dropping the author's policy. transformManifest
content_security_policy connect-src 'self' injected (addSelfToConnectSrc) Safari enforces connect-src strictly and refuses same-origin fetch of the extension's own bundled assets when 'self' is absent. transformManifest
MV3 web_accessible_resources (bare string[]) Wrapped to [{ resources, matches: ["<all_urls>"] }] (mixed arrays handled per-entry) A bare MV2-style list makes Safari reject the manifest at load. transformManifest
web_accessible_resources[].use_dynamic_url Cleared to false on every object entry Chrome-only per-session URL rotation; left true, runtime.getURL() returns an unservable URL that 404s and the asset silently fails. transformManifest
browser_specific_settings.safari Injected with strict_min_version (default 15.4, override via --min-safari; an existing value is preserved) and no max cap. Raised to 18.4 afterwards when the conversion injected a world:"MAIN" content script that cannot run below it 15.4 is the MV3 floor; a strict_max_version (e.g. 18.*) would hide the extension on newer Safari. Safari ignores a world:"MAIN" entry below 18.4 in silence, so declaring 15.4 while depending on one ships a manifest that quietly does less than it claims. (45f4d29), raiseMinVersionForMainWorld
action / browser_action Normalized to the key valid for this MV; a mismatched key is migrated, a duplicate wrong key deleted; a default_popup is wired only when safe Safari rejects action in an MV2 manifest and ignores browser_action under MV3; wiring a popup onto a code-driven button hijacks the click. transformManifest
content_scripts[].js Shim + polyfill prepended (order: [polyfill, shim, ...original]); world:"MAIN" scripts skipped The page context has no chrome/browser; the bundled polyfill throws there. transformManifest
MV2 background.scripts Left untouched (no shim/polyfill prepended) The compat shim wraps the runtime in a relay Proxy, and Safari only delivers a content script's native runtime.sendMessage to a background registered on the real, unwrapped runtime. Wrapping the background breaks that delivery, so an extension whose content script messages its background goes dead (TWP: translateHTML never reaches the background). See Safari Quirks E6. transformManifest

Permission stripping in detail

analyzeManifest walks permissions + optional_permissions, and for any token present in UNSUPPORTED_PERMISSIONS pushes it onto permissionsToRemove. transformManifest then filters both lists against that set. Examples of what gets stripped: tabGroups, offscreen, sidePanel, debugger, identity, proxy, gcm, and the ChromeOS-only family, see the compat tables.

Two tokens are Safari-unrecognized rather than merely unimplemented, and were added specifically so they stop passing through verbatim as dead tokens: webRequestAuthProvider and declarativeNetRequestWithHostAccess (774d0a8). The latter is special-cased so the DNR capability survives (row above).

A stripped permission whose API the shim still emulates (in SHIMMED_PERMISSIONS) is reported as "removed but the shim keeps it working" rather than a bare removal, but it is stripped from the manifest all the same. That distinction is an Analyzer concern; the transform just removes the token.

MV3 service worker → non-persistent background page

(bd3bbc7) This is not done in transformManifest. convertServiceWorkerToBackgroundPage (src/runtime/shim.ts, called from convert.ts) builds a background.html (SW-lifecycle shim, polyfill, hoisted importScripts targets, then the SW itself as a <script>), writes it, and rewrites the manifest to:

manifest.background = { page: BACKGROUND_PAGE_FILENAME, persistent: false };

with the comment // MV3 (Safari) rejects persistent background: "A manifest_version >= 3 must be non-persistent.". So a converted MV3 background is always non-persistent: Safari would refuse a persistent one outright. (transformManifest's persistent:false write handles only the MV2 case.)

Toolbar-button behavior: popover-free toggle & synthetic popup

(e9ba1e3) Safari never dispatches action.onClicked to a converted background, so a popup-less, code-driven toolbar button is inert. The pipeline (convert.ts) prefers, in order:

  1. wireActionHotkey: a popover-free in-page hotkey that toggles the extension; the toolbar button stays inert, avoiding Safari's un-closable popover.
  2. wireActionClickBridge: fallback synthetic popup (__viaduct-action.html) that, on open, replays the shim-captured onClicked listeners for the active tab and closes itself.

transformManifest deliberately does not guess a default_popup when the background registers action.onClicked, it leaves the slot empty so the click bridge can replay the real handler instead of hijacking it into an orphan popover (5b1bd03). This is a high-level summary; the full popover mechanics are on Safari Quirks.

Page-world injections → world:"MAIN"

(d35e3ad) A content script that injects a page-world <script src=getURL(X)> is CSP-blocked in Safari (Chrome exempts web-accessible-resource scripts from the page CSP; Safari doesn't). wirePageWorldMainInjection (src/runtime/shim.ts, called from convert.ts) re-declares each such X as a world:"MAIN" content script, which Safari runs CSP-exempt (Safari 18.4+). Note transformManifest treats existing MAIN-world scripts as read-only (skips prepending the polyfill to them), because they run where chrome/browser do not exist.

chrome-extension: scheme references

Compiled bundles hardcode chrome-extension: when classifying their own pages; Safari pages are safari-web-extension://, so those checks fail. The staged sources (not the manifest) are rewritten in stage.ts:

  • rewriteChromeSchemeLiterals (5a12186), rewrites bare chrome-extension: scheme literals, via CHROME_SCHEME_RE = /(?<![-\w])chrome-extension:(?!\/\/[\w$])/g. It deliberately skips concrete-host URLs chrome-extension://<id>/… (literal or ${…}) (1b7b6f5), those can be real OAuth redirect_uris and rewriting one breaks a working flow.
  • rewriteSelfPageExtensionUrls: for the narrow case where a chrome-extension://${id}/page.html literal is the whole URL of a self-page navigation, converts it to runtime.getURL(...) so it doesn't open a dead chrome-extension://<bundle-id>/… tab.

B) DNR sanitizer, applyDnr

src/manifest/dnr.ts, called from convert.ts. applyDnr(stageDir, manifest) sanitizes declarativeNetRequest static rule files on disk and returns human-readable notes (surfaced as warnings). It iterates manifest.declarative_net_request.rule_resources.

Strips modifyHeaders action rules (55d8b0e). A static ruleset containing a modifyHeaders action crashes the whole browser: reading the rule back from its SQLite store null-derefs in WebKit's loadDeclarativeNetRequestRulesgetRulesWithRuleIDs (EXC_BAD_ACCESS). The runtime shim already strips these from dynamic updateSessionRules / updateDynamicRules calls, but static rule_resources files load straight from disk and never pass through the shim, so applyDnr filters them out (r?.action?.type !== "modifyHeaders"), rewrites the file, and notes how many were dropped. block / redirect / allow / upgradeScheme rules pass through untouched.

Warns (does not strip) on:

  • regexFilter rules: Safari supports only a limited regex subset and silently drops rules it can't compile; the note advises preferring urlFilter.
  • Rule-count over the honored cap: enabled static rules beyond SAFARI_STATIC_RULE_GUIDELINE = 30000 load in Chrome but are silently ignored in Safari; warn so the author splits/trims rather than ships a half-applied ruleset. (Disabled rulesets, res.enabled === false, don't count.)

Robustness / safety. Paths are resolved with resolveInside (a path.relative-based containment check), res.path comes from the untrusted manifest, and the writeFileSync back to it would otherwise be an arbitrary-overwrite primitive from a malicious extension. Rule files are parsed with the same lenient parseJsonc the manifest uses (Chrome tolerates BOM/comments/trailing commas); a missing path, escaping path, missing file, non-JSON, or non-array ruleset each yields a note and is skipped rather than crashing.

Anthropic CORS note (cd9b73c, 5d1d542). When the extension talks to api.anthropic.com (needsAnthropicCorsBypass), applyDnr emits a note only, no CORS-bypass ruleset is shipped. The org CORS gate keys on sec-fetch-site, a forbidden header Safari refuses to let DNR modify, and modifyHeaders would crash Safari anyway; the viable path is the native-messaging retry through SafariWebExtensionHandler, so the note flags whether nativeMessaging is declared. (The earlier "pin Origin" ruleset that commit 5d1d542 introduced was superseded by this native-host retry.)


C) Compat data, the source of truth

src/manifest/compat-data.ts (3f919d0) is pure data, no logic: extracted out of manifest.ts to keep that file focused on parse + transform. It is imported by manifest.ts and re-exported so existing importers (analyze.ts, report.ts) keep their path. This is the file a contributor edits to add a newly-discovered unsupported permission or API: add a token here and the analyzer strips + reports it automatically.

Three tables:

  • UNSUPPORTED_PERMISSIONS: Record<string, string>: permission → remediation note. Membership here is what makes analyzeManifest add the token to permissionsToRemove.
  • SHIMMED_PERMISSIONS: Set<string>: the subset whose API the shim functionally emulates, so the analyzer tags them "removed but still working" instead of a bare removal. Only real emulations belong here; APIs that merely reject gracefully do not.
  • UNSUPPORTED_APIS: Record<string, {severity, message, fix, shimmed?}>: chrome.* call patterns matched during JS scans (an Analyzer concern; some keys are regex fragments like chrome.tts\b).

Representative sample

Permission Verdict Note (abridged)
identity removed No chrome.identity; use a hosted web OAuth2 flow + postMessage.
debugger shimmed CDP subset (Page/Target/Input/DOM/Runtime/Accessibility) over Safari tabs + scripting; Network/Fetch and trusted input unavailable.
sidePanel shimmed Emulated via the action popover (Safari 17.4+) / tab fallback.
offscreen shimmed Emulated via an extension-origin iframe; still no DOM in a true SW context.
tabGroups shimmed Emulated in memory (no tab-bar coloring).
tts shimmed Routed to Web Speech API (speechSynthesis).
power shimmed Backed by Screen Wake Lock (navigator.wakeLock).
userScripts, idle, sessions, topSites, search shimmed Management surface / degraded fallbacks emulated.
webRequestBlocking removed Use declarativeNetRequest.
webRequestAuthProvider removed Safari can't provide onAuthRequired credentials via webRequest. (774d0a8)
declarativeNetRequestWithHostAccess removed → re-added as declarativeNetRequest Safari doesn't recognize the variant token. (774d0a8)
gcm, instanceID removed Chrome-only push; use APNs natively or poll.
proxy, privacy, contentSettings, browsingData, management removed No Safari equivalent; drop or move to the native host.
tabCapture, desktopCapture, pageCapture removed Use getDisplayMedia() or a native bridge.
system.cpu / .memory / .storage / .display removed Native bridge or drop.
omnibox, declarativeContent removed No Safari surface; reshape to popup / content scripts.
fileBrowserHandler, fileSystemProvider, documentScan, printing, certificateProvider, vpnProvider, wallpaper, … removed ChromeOS/enterprise-only; always drop on Safari.

(The SHIMMED_PERMISSIONS entries above are the ones tagged "shimmed"; everything else is a bare removal. The full tables carry ~40 permissions and ~40 API patterns.)


Related pages

  • Analyzer: analyzeManifest, JS scanning, issue severities, and the analysis-vs-transform split.
  • Runtime Shim: the browser/chrome emulation the stripped permissions rely on (sidePanel, offscreen, tts, debugger/CDP, …).
  • Safari Quirks: the deep "why" behind the popover-free toggle, MAIN-world CSP exemption, and connect-src 'self'.
  • Conversion Pipeline: where transformManifest, applyDnr, and the shim wiring sit in the end-to-end convert flow.
  • CLI Reference: --min-safari, --keep-module, --no-shim, and the other flags this rewrite reads.

Clone this wiki locally