-
Notifications
You must be signed in to change notification settings - Fork 3
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.jsonkeys 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 runtimebrowser/chromeemulation 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.
The manifest rewrite is not one function. It is a pipeline, orchestrated in src/convert.ts:
-
analyzeManifest(m): read-only. Returns{ issues, permissionsToRemove, needsCdpShim }. It decides what is unsupported (consulting the compat tables) but changes nothing. See Analyzer. -
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. -
Pipeline mutators that run after
transformManifestand further mutate the same object as a side effect of writing files,applyDnr,convertServiceWorkerToBackgroundPage,wireActionHotkey/wireActionClickBridge,wirePageWorldMainInjection, plus the on-disk source rewritesrewriteChromeSchemeLiterals/rewriteSelfPageExtensionUrls/idempotentContentScriptGlobalsinstage.ts. (idempotentContentScriptGlobalsreads the transformedcontent_scriptsbut only rewrites source, it doesn't mutate the manifest.) -
writeManifest(targetDir, manifest): the final write. Drops an emptycontent_scripts: [](Safari rejects it) then serializes.
So "the transform" below means the whole rewrite, and each row says which function actually performs it.
| 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 |
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.
(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.)
(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:
-
wireActionHotkey: a popover-free in-page hotkey that toggles the extension; the toolbar button stays inert, avoiding Safari's un-closable popover. -
wireActionClickBridge: fallback synthetic popup (__viaduct-action.html) that, on open, replays the shim-capturedonClickedlisteners 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.
(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.
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 barechrome-extension:scheme literals, viaCHROME_SCHEME_RE = /(?<![-\w])chrome-extension:(?!\/\/[\w$])/g. It deliberately skips concrete-host URLschrome-extension://<id>/…(literal or${…})(1b7b6f5), those can be real OAuthredirect_uris and rewriting one breaks a working flow. -
rewriteSelfPageExtensionUrls: for the narrow case where achrome-extension://${id}/page.htmlliteral is the whole URL of a self-page navigation, converts it toruntime.getURL(...)so it doesn't open a deadchrome-extension://<bundle-id>/…tab.
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 loadDeclarativeNetRequestRules → getRulesWithRuleIDs (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:
-
regexFilterrules: Safari supports only a limited regex subset and silently drops rules it can't compile; the note advises preferringurlFilter. -
Rule-count over the honored cap: enabled static rules beyond
SAFARI_STATIC_RULE_GUIDELINE = 30000load 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.)
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 makesanalyzeManifestadd the token topermissionsToRemove. -
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 likechrome.tts\b).
| 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.)
-
Analyzer:
analyzeManifest, JS scanning, issue severities, and the analysis-vs-transform split. -
Runtime Shim: the
browser/chromeemulation 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-endconvertflow. -
CLI Reference:
--min-safari,--keep-module,--no-shim, and the other flags this rewrite reads.
Viaduct CLI · @magicelk235/viaduct · PolyForm Shield 1.0.0 · Verified against src/ and grounded in git history.