Skip to content

Runtime Shim

magicelk235 edited this page Aug 24, 2026 · 14 revisions

Runtime Shim

The runtime shim is the single largest and most load-bearing subsystem in viaduct-cli. It is a ~5,900-line JavaScript file (src/runtime/safari-compat-shim.js) that is prepended to every content script and injected into every extension HTML page of a converted extension. Its job is to make an unmodified Chrome extension bundle run on Safari's Web Extension runtime, where dozens of chrome.* APIs are missing, partial, frozen, or behave subtly differently, without ever throwing at the top level.

Everything else in the shim is downstream of one rule.


The cardinal rule: the shim must never throw at top level

The shim is prepended to code it does not own. When viaduct stages a content script, the shim's source is placed above the bundle's own script in the same execution unit; in HTML pages it is a <script> that runs before the bundle's scripts. In both cases a top-level throw aborts the entire script chain that follows it. A single uncaught TypeError means:

  • a content script that never executes (the page's DOM logic is dead),
  • a popup/options/side-panel page that renders blank (its module never evaluates),
  • a background page whose onConnect/onMessage listeners never register, so every popup→background RPC queues forever and the extension hangs at "starting…".

This constraint dominates every design decision in the file. Concretely it produces three layers of defense:

  1. A guarded config assignment above the IIFE. The very first executable lines sit outside the main function's try, because a build-time substitution miss (a bare __C2S_PROXY_CONFIG_JSON__ token left in) would be a ReferenceError that no inner try could catch. So the assignment itself is wrapped: a leftover token degrades to an empty config instead of killing the host script.

    var __C2S_PROXY_CONFIG__;
    try { __C2S_PROXY_CONFIG__ = __C2S_PROXY_CONFIG_JSON__; } catch (e) { __C2S_PROXY_CONFIG__ = { origin: "", hosts: [] }; }
  2. An outer try wrapping the whole IIFE body (the "backstop"). Whatever partially applied stays applied; the host script keeps running. The comment on it reads "Never remove it."

  3. Per-block try/catch around every individual patch, plus feature-detection before touching any chrome.*. No patch assumes an API exists, is writable, or is even reachable. A throw in the notifications backfill must not skip the debugger backfill, the namespace catch-all, or the declarativeNetRequest crash-strip that follows it.

The reason this is hard rather than merely careful is Safari's frozen namespaces: see the next section. Commits that established and repeatedly re-hardened this invariant: ca523e8 (shim survives no-chrome context), 1880790 (shim survives no-chrome global), 73c1926 (make converted extensions actually run on Safari's frozen/exotic namespaces), f344d7a (frozen-root throw guards).

De-freezing the roots (__thaw / __thawRoot / __publishGlobal)

Safari exposes the whole namespace tree, browser, the chrome alias, and one level down (browser.runtime, browser.storage, browser.scripting, …), as frozen / non-extensible native objects. This is the master root cause behind "every fix is ineffective when run live": nearly every backfill ultimately does parent[key] = …, and on a frozen parent that silently no-ops or throws. So getURL stays unwrapped, storage.sync never installs, connect is never proxied.

The shim cannot mutate the frozen natives, but the global bindings (globalThis.browser, self.chrome, …) are reassignable even when the object they point at is frozen. So it:

  • __thaw(ns), builds an extensible shallow clone of a frozen namespace (own + inherited members; native functions .bind()-ed to the original so this survives the copy);
  • __thawRoot(root, name), thaws the root and one level of sub-namespaces;
  • __publishGlobal(name, value), republishes the clone on every global the bundle might read (globalThis / self / window).

After this, browser.runtime.getURL resolves to the extensible clone whose getURL the shim can then wrap. A companion helper mutableNamespace(obj, key) is used throughout the file to get a writable clone of any specific frozen sub-namespace before attaching to it, and installOverride / setIfMissing / fill verify the write actually took before marking a slot as patched (bare assignment can silently no-op on Safari's exotic slots).

The alias, and hasChrome

Safari exposes browser in extension pages/content scripts/background, but chrome is frequently absent. The overwhelming majority of converted bundles call chrome.* directly at top level, so a missing chrome throws "chrome is not defined" and kills the whole script. Before anything else, the shim aliases a global chrome to browser, but only when chrome is truly absent, so it never clobbers a real chrome and browser === chrome stays true on Safari. Most backfills are gated on the resulting hasChrome flag.


Where it runs, and load order

viaduct wires the shim into every context an extension has:

Context How the shim gets there Wiring code
Content scripts Prepended ahead of the bundle's own content-script files staging (walkScripts)
Popup / options / side panel (any .html) <script src="/safari-compat-shim.js"> injected into <head> injectShimIntoHtmlPages
Background The converted background page loads it as a <script> convertServiceWorkerToBackgroundPage

injectShimIntoHtmlPages walks every .html file (skipping node_modules, .git, __MACOSX), finds a real <head> open tag (ignoring commented-out ones, via headInsertIndex), and inserts the polyfill tag, the shim tag, and (conditionally) a color-scheme style, each only if not already present.

Load order is deliberate and critical. The shim loads before the bundle's own content script. That ordering is what lets the shim capture the bundle's runtime.onMessage/onConnect listeners as they register (stored in msgListeners and surfaced via helpers like self.__viaductMsgListeners), the key to the "popover-free toggle" and the synthetic action-click bridge. The browser-polyfill loads before the shim so the shim's browser-namespace patches apply to the polyfilled object.

The __viaductMsgListeners capture runs in every content-script context unconditionally. It can't be gated on reading content_scripts back from chrome.runtime.getManifest() to decide whether a hotkey was wired: Safari omits content_scripts from getManifest() inside a content script, so that read returns nothing and the capture silently never installs, leaving the hotkey with an empty listener list (live: SuperDev Pro's Cmd+Shift+S, 190133d). See Safari Quirks A5.


Why it's plain .js, not .ts

The shim is a real, hand-edited .js file, not compiled TypeScript. The generator side (src/runtime/shim.ts) does exactly one thing to it: shimSource(config) reads the raw file and performs a single placeholder substitution:

export function shimSource(config: ShimConfig = {}): string {
  const proxyCfg = JSON.stringify({ origin: config.chromeOrigin || "", hosts: config.proxyHosts || [], cdp: config.cdp !== false })
    .replace(/[]/g, (c) => c === "
" ? "\\u2028" : "\\u2029");
  const runtime = readFileSync(join(RUNTIME_DIR, SHIM_FILENAME), "utf-8");
  return runtime.split("__C2S_PROXY_CONFIG_JSON__").join(proxyCfg);
}

Keeping it as plain JS means backslashes and regex literals are literal: there is no template-string escaping layer to fight (the file is dense with regexes for host matching, scheme rewriting, and lexing). Contributors edit the runtime behavior directly in the .js. writeShim writes the substituted result into the staged extension. Commit 2927cdf (extract shim runtime JS to src/runtime/safari-compat-shim.js) established this split.


The // chrome.<api> catalog

The file is organized as a long series of // chrome.<api> — … sections. Each patches, completes, or stubs one namespace. The table below enumerates them; every row was verified against the actual section in the .js.

API What the shim does Safari reason
chrome alias Publishes chrome = browser when absent, before anything else chrome often undefined on Safari → "chrome is not defined"
runtime.getURL Wraps it so a falsy or relative arg still yields an absolute base; keeps Safari's real host case for every arg getURL("") returns ""/undefined on Safari, crashing getURL("").slice(…) (uBlock). The case is left alone because the resource server is case-sensitive and because location.href and sender.url carry the same case; sender.origin, the one API that disagrees, is aligned on the sender instead (B4)
runtime.id Overridden to the UUID host derived from getURL("") Safari reports the App-Extension bundle id, not the URL-host UUID; breaks new RegExp(runtime.id + "…").test(sender.url) port routing
runtime.connect Queues/retries until onConnect is live, then flushes; wraps thrown "no onConnect listeners" into a proxy port Safari throws synchronously when the non-persistent background is suspended at connect() time
runtime.onConnect / onMessage Wrapped to hand listeners a normalized sender: query and fragment stripped from sender.url, sender.origin aligned to getURL("")'s case Exact sender.url allow-listing breaks on a query (Dark Reader), and sender.origin === getURL("").slice(0,-1) breaks on the case (uBlock)
tabs.onActivated Emulated in the background by polling the active tab while something is listening; stands down permanently if a native event ever fires Safari delivers no tab or window events to a converted background page, so an extension caching "the selected tab" from them never learns one (Honey)
runtime.sendNativeMessage / connectNative Re-implemented on Safari's native-messaging handler (see native bridge) Safari has no host launcher but routes sendNativeMessage to the containing app
runtime stragglers requestUpdateCheck, setUninstallURL, getPlatformInfo (reports mac/arm64), getContexts, getBackgroundPage Missing members throw at module-eval
runtime.onMessageExternal / onConnectExternal Inert event stubs if absent Read at module-eval by e.g. Requestly
storage.sync Routed to storage.local with dual callback/Promise bridging, Chrome quota constants, and an area-scoped onChanged relay Safari has no iCloud storage.sync
storage.local.onChanged / sync.onChanged Per-area event synthesized as a filtered relay of the global storage.onChanged Safari exposes only the global event; per-area is absent → TypeError (DarkReader)
storage.session Backfilled entirely when absent (in-memory, structured-clone semantics); setAccessLevel backfilled + lastError cleared when the native session lacks it; managed stubbed Safari <16.4 lacks session; ≥16.4 lacks setAccessLevel → Grammarly bg init hangs forever
sidePanel Emulated; open() toggles the panel cooperatively (popover) or falls back to a tab; honors the manifest / setOptions path No sidePanel API on Safari
identity Stubbed to reject rather than crash (real OAuth handled by the OAuth Bridge) Unsupported
notifications Completes the partial Safari object (onClicked etc. as inert events) Safari exposes only create/clear; onClicked.addListener throws and aborts SW registration
tabGroups In-memory group registry (create/query/update/move) Absent on Safari
tabs.group / ungroup / zoom surface Backfilled inert dual stubs Create/remove side of tab groups + zoom are absent
debugger (CDP) Full DevTools-Protocol emulation over injected page-context executors, gated by __C2S_CDP__ Safari has no DevTools Protocol
offscreen Emulated with a hidden extension-origin <iframe src=getURL(url)>; waits for load; installs an inert window.SNOW host stub No offscreen API; the SW→background conversion gives a real DOM to host the iframe
i18n Backfills detectLanguage/getUILanguage/getAcceptLanguages; wraps native getMessage to never throw Safari lacks detectLanguage (→ "und"), and its native getMessage("") throws
commands getAll() rebuilt from the manifest; openShortcutSettings() no-throw affordance Safari has no chrome://extensions/shortcuts page
contextMenus Completes missing members + enums; create wrapper strips/rewrites patterns and maps MV2 context aliases to action (Safari Quirks F4) Present on macOS, absent on iOS; native create throws on ftp:// and host-carrying file:// patterns and on page_action/browser_action contexts
webNavigation Backfills events + frame queries; onHistoryStateUpdated / onReferenceFragmentUpdated are emulated from content-script URL reports (Safari Quirks C5) Safari ships only a subset, and never fires the SPA-navigation pair
windows Backfills omitted events (e.g. onBoundsChanged) Some events missing → module-eval throw
devtools Inert panels/inspectedWindow/network + ExtensionSidebarPane stub devtools_page extensions throw on load
app isInstalled/getDetails legacy probe Hosted-app probe reads throw
cookies onChanged guarded against Safari null events; get re-resolved through getAll so a name shared across nested domains returns the cookie the request will carry; empty fallbacks; used by the auth proxy Present but without onChanged, and get picks a different cookie than the network stack sends (Safari Quirks E8)
permissions Reports all manifest-declared permissions/hosts as granted Safari grants them up front
webRequest Backfills events; filters listener URLs to watchable schemes by patching addListener on the event instance or its prototype — never by republishing the global roots (Safari Quirks E15) Observation-only on macOS; native addListener throws on exotic scheme filters, the root's webRequest slot silently refuses overrides, and dispatch dies for any context whose global chrome/browser is not the native object
declarativeContent Matcher constructors stubbed Constructed at module-eval
declarativeNetRequest Backfills enums + onRuleMatchedDebug; strips modifyHeaders rules from updateSession/DynamicRules A modifyHeaders rule crashes the whole Safari browser (WebKit SQLite null-deref)
alarms Hardened for temp-loaded/background-page contexts Present but flaky in some contexts
idle Derives real idle from user activity in a document context ,
downloads Real download via a synthesized <a download> / blob re-typing; in-memory item registry No downloads API
bookmarks Whole API emulated over a per-extension node map in storage.local: real CRUD, search, tree assembly, onCreated/onRemoved/onChanged/onMoved. Seeded with empty "Bookmarks Bar" / "Other Bookmarks" roots, not the user's Safari bookmarks No bookmark API, and the real Safari bookmark store is unreachable from an extension
history / sessions / topSites Completed / empty reads Gated or absent
readingList Emulated No JS API for the native Reading List
management Self-introspection only; rest stubbed ,
omnibox / tts / proxy / power / system.* / search / gcm / instanceID / fontSettings / accessibilityFeatures / ttsEngine / dom Namespace backfills (Web Speech / Wake Lock where a real API exists, else inert) No / partial Safari surface
userScripts Coherent in-memory registry (register/get/update/unregister round-trip; does not actually inject) No persistent user-script registry in WebKit
scripting Missing ExecutionWorld/RegistrationWorld enums backfilled; dynamic content-script registration emulated Present but omits enums
extension (MV2) Legacy surface completed ,
action Badge/title/icon setters wrapped so they don't reject Setters reject with "Tab not found" on Safari
enterprise / fileSystemProvider / printing / vpnProvider / platformKeys / … (inertNamespaces) Inert objects (event() / rejectUnsupported / empty dual) ChromeOS/enterprise surfaces; feature probes must not crash
catch-all (audio, dns, input, systemLog, + any future) Recursive inert Proxy Any un-shimmed documented namespace would otherwise throw

Big themes

Messaging & port routing on Safari

This is the densest and most-iterated part of the file, because Safari's port-routing invariants differ from Chrome's in three independent ways, and bundles rely on all three being Chrome-shaped.

Problem. On Chrome, chrome.runtime.id equals the extension-URL host, and bundles route their privileged ports with matchers like new RegExp(chrome.runtime.id + "/src/popup.html").test(sender.url) (Grammarly), or gate privileged messages with sender.origin === getURL('').slice(0,-1). On Safari:

  • runtime.id is the App-Extension bundle id (e.g. "com.viaduct.Foo.Extension (TEAMID)", even contains regex metachars), while the URL host is a per-install UUID. The regex can never match → the popup port is judged "unknown", never stored, the background posts no reply → the popup hangs.
  • The UUID's case differs across APIs (UPPER in getURL, location.href and sender.url, lower in sender.origin), so an origin equality is always false. See Safari Quirks B4 for why the fix belongs on the sender rather than on getURL.
  • A sender.url can carry a query that getURL(path) never has, so exact-match allow-lists (Dark Reader) fail. The ?tabId=<n> behind that one is viaduct's own: the shim writes it into a side panel's URL because Safari opens the panel as a popover without it. It used to write it into plain action popups too, which is what put it on Dark Reader's popup in the first place.

Fix chain (each a separate commit, because each was found live against a different real extension):

  • 362feb9, rewrite runtime.id to the UUID host taken from getURL("") (where the frozen slot allows it via the mutable clone).
  • cc611a3, because runtime.id is often a frozen exotic slot the shim genuinely cannot replace, this is also fixed at conversion time: rewriteRuntimeIdUrlMatchers in stage.ts strips the runtime.id + prefix from port matchers so they become host-agnostic. (The shim's own comment documents that it can't win against the frozen slot and defers to this.)
  • e90a45f, wrapOnConnect/wrapOnMessage lowercase sender.url's host so both sides of the routing regex agree. Reverted with the port-clone machinery in 43047af; the matcher rewrite above had made it redundant.
  • 49c41cb, strip the ?tabId query from popup sender.url, and 11a00b4 for the storage relay, which builds its sender out of location.href and so needed the same rule on a second path.
  • 064812b, stop injecting ?tabId into an action popup's URL at all. Only a real side panel gets it now, which removes the cause rather than the symptom. Honey reads its own href to decide whether it is the popover and to pick a message service, and the injected query made both answers wrong.
  • bf014e0, hand back Safari's real host case from getURL for every argument and align sender.origin to it on the clone, instead of lowercasing the root args.
  • 7c39c87, forward cloned-port methods bound to the real port so Safari's internal brand-check on the port object still passes.
  • 26605b7, route content-script messages and XHR through the correct paths.

The shim also handles runtime.connect() waking a suspended background: Safari throws synchronously "No runtime.onConnect listeners found" when the non-persistent background is asleep at connect time (Chrome would queue the port). The shim returns a proxy port (makeProxyPort) that buffers postMessage calls and transparently swaps to a real port once onConnect goes live and the connection succeeds, matching only the "no onConnect / receiving end / message port closed" error family, so genuine failures still surface.

The storage relay, and why it must never touch a content script

Safari drops native runtime.sendMessage between an extension page (popover, panel, options) and a non-persistent background that is asleep: the send resolves but no listener fires. chrome.storage.local is delivered cross-context, so for those pages the shim relays messages through it. The sender writes a request record and polls for a response record; every extension context mirrors incoming records into its own onMessage listeners and writes back whatever sendResponse yields. To make that work the shim republishes chrome and browser as a Proxy (wrap) whose sendMessage and onMessage route through the relay instead of the native methods.

That relay is only correct on extension pages. A content script must stay on Safari's native transport, for two reasons Safari enforces:

  • Safari does deliver a content script's native runtime.sendMessage to the background, and lets the background's sendResponse travel back, but only when both sides are registered on the real, unwrapped runtime. Once chrome is the relay Proxy, that native delivery is silently dropped and the reply never returns.
  • A content script's storage.local is a different partition from the background's, so a relayed request written on the page side is never visible to the background at all. The relay cannot cross that boundary.

So the relay block bails out at the top when location.protocol is not an extension scheme, leaving a content script's chrome/browser untouched. For the same reason the compat shim is not prepended to an MV2 background.scripts list (Manifest-Transform): wrapping the background's own runtime breaks the native content-to-background delivery it is supposed to receive. The pre-relay builds never wrapped either context and worked; the relay is scoped back to the extension pages that actually need it. Live case: TWP - Translate Web Pages, whose page translator sends {action:"translateHTML"} from a content script and awaits the reply, went dead until both carve-outs were in place. Source: src/runtime/safari-compat-shim.js (relay IIFE location.protocol guard), src/manifest/manifest.ts (no MV2 background injection); tests test/sendmessage-relay-scope.test.js, test/mv2-background-shim.test.js.

navigator.serviceWorker in an extension page, and the port tunnel behind it

On Chrome the MV3 background is a service worker registered against the extension origin, so navigator.serviceWorker inside an extension page is a live handle on the background. Sites use it as the documented web-page ↔ background channel: the page hands an extension-origin iframe a MessagePort with window.postMessage, and the iframe forwards it on with (await navigator.serviceWorker.ready).active.postMessage(msg, [port]). viaduct converts the worker into a background page, so nothing is registered for the origin: ready never settles, controller is null, getRegistrations() is empty, and the awaiting bundle stops there with no error anywhere. See Safari Quirks C7 for the measurements and the live case (Kondo).

The shim emulates the container in extension pages only — content scripts keep the web page's real registration, and a page that genuinely has a controller is left alone — and tunnels the traffic to the background over a runtime.connect port named __c2sSwBridge:

  • ready resolves immediately with a registration stub whose active/controller is a worker stub. Resolving early is safe because a postMessage buffers behind the wrapped connect() while a suspended background wakes.
  • Transferred MessagePorts cannot cross a runtime port, so each one is bridged by id: the page side forwards its onmessage traffic as {t:"port", pid, data}, and the background side re-materializes a real MessagePort from a MessageChannel it owns, so event.ports[0] behaves like the port Chrome would have delivered.
  • The background gets a real message event carrying those ports, plus two own properties a service-worker handler expects and MessageEvent doesn't have: source (whose postMessage answers back down the tunnel and is dispatched on the page's navigator.serviceWorker) and a no-op waitUntil.
  • The background side ignores a replayed seq, because the connect proxy above can re-flush a queued send onto more than one native port.

Unlike the offscreen client in Safari Quirks C2, this cannot hand the port object straight to the background realm: an extension page embedded in a web page runs in the web content process, where extension.getBackgroundPage() returns null. The cost of the tunnel is fidelity — payloads cross as runtime-message JSON rather than a structured clone, so Blobs, ArrayBuffers and Dates do not survive.

Storage

storage.sync is routed to storage.local (Safari has no iCloud sync). The bridge is careful about the dual callback/Promise contract: when api is the webextension-polyfill browser object, local.get/set are Promise-only and ignore a trailing callback, so chrome.storage.sync.get(keys, cb) would never fire cb. syncFwd strips a trailing function arg and routes the Promise result to it, setting runtime.lastError for the callback's duration on failure (a bare cb() reads as "success, no data" and can overwrite good data with defaults). Chrome's documented sync quota constants are provided because settings-sync libraries read them to chunk values before set().

A documented limitation is marked in-code with the upgrade path: sync and local share one flat backing store, so a .sync.onChanged listener also sees unrelated local writes, and an area === "sync" branch never fires (Safari reports the backing write as "local"). Separating them needs key-namespacing the sync blob, which would orphan already-written data, deliberately not done for a Safari-only partial gap where data still persists.

storage.session gets two distinct fixes (e0a70ea, f3eb880, e5d343b): backfilled wholesale when absent, and, the subtle one, its setAccessLevel is backfilled on Safari 16.4+ which ships session without that Chrome-MV3-only method. Grammarly's bootstrap awaits session.setAccessLevel(...) through a promise that only settles in its callback; with the method undefined the optional-chain call short-circuits, the callback never fires, and background init hangs forever with no timeout. The backfill honors the contract and clears lastError first, because the caller's resolver reads lastError to choose resolve-vs-reject.

Beyond those backfills, storage.session needs its Chrome semantics rebuilt outright, because Safari gives it neither of the two properties an extension relies on. It is per-context, so a value the background wrote is missing when a panel reads it, and it is per-page load, so the background's own store is empty again the moment Safari suspends and revives it. The shim answers both: the background page is the single owner and every other extension page forwards get/set/remove/clear to it over the storage relay, and the owner mirrors its store into storage.local under __c2sSessMirror, restoring it on the next background page and holding reads until that restore lands. The mirror is stamped and expires after 12 hours, so values an extension deliberately kept off disk are on it for a bounded window and no longer; past the deadline the extension sees the empty store Chrome would hand it at the start of a browser session. Safari never fires runtime.onStartup for a converted background, so a deadline is the only available substitute for a browser-start signal. This is what keeps an OAuth bundle signed in across a Safari quit — see Safari Quirks E14 and E14a.

i18n completion (1f3af6f)

Safari supports getMessage/getUILanguage but not detectLanguage, and some contexts omit getAcceptLanguages. The shim backfills the missing members without clobbering Safari's native getMessage (fill() only writes when absent). Missing detectLanguage degrades to a well-formed { isReliable:false, languages:[{language:"und", percentage:100}] } result instead of throwing. Separately, it wraps the native getMessage because Safari's throws "the 'name' value is invalid… cannot be empty" on an empty key (uBlock calls getMessage("") in its popup); the wrapper returns "" on a falsy key and never throws.

commands.getAll rebuild (503e14f)

Safari wires manifest shortcuts itself but has no chrome://extensions/shortcuts page. Two things break: an extension's own "your shortcuts" UI goes blank when getAll() returns [], and code that navigates to the shortcuts page hits a non-existent Safari URL. The shim reconstructs the {name, description, shortcut} list from chrome.runtime.getManifest().commands (preferring the mac/default suggested_key), and provides an honest openShortcutSettings() that resolves false, there is no API to deep-link Safari's shortcut pane, so callers fall back to an in-UI hint. Navigation attempts to chrome://extensions/shortcuts / chrome://settings are swallowed at conversion time.

In-memory emulation of complex APIs

Where a namespace has no Safari equivalent but callers do more than probe it, the shim keeps a coherent in-memory model so the management surface round-trips: tabGroups, bookmarks (whole tree), userScripts (register/get/update/unregister with duplicate-id and unknown-id errors), downloads (item registry), plus windows/devtools/app. These don't do the real thing (WebKit gives no API to inject dynamic user-script worlds, for instance), they make callers stop rejecting. Each carries an explicit upgrade path in its comment. Commits: 75d6cd2 (emulate complex Chrome APIs + catch-all safety net), 8255ebc (shim chrome.windows/devtools/app), 94ad310 (wire 4 inert shim stubs to real Safari/web implementations, e.g. power → Screen Wake Lock, tts → Web Speech).

The native-host CORS / auth proxy

Safari sets the request Origin to safari-web-extension://<uuid>, which an extension's backend may reject (org CORS allowlist keyed on the Chrome origin). Origin/sec-fetch-* are forbidden headers JS cannot set, and a DNR modifyHeaders rule crashes Safari, so the only way to send the Chrome origin is out-of-process. The shim wraps fetch and XMLHttpRequest: when a request to a manifest-derived backend host is blocked in-browser (network error / 401 / 403), it retries through the Swift containing-app host via runtime.sendNativeMessage, which fetches server-side with the spoofed origin and no CORS.

The host list and origin are computed at build time by deriveProxyHosts(manifest) (from host_permissions, externally_connectable.matches, and CSP connect-src) and baked into the __C2S_PROXY_CONFIG__ placeholder, nothing in the runtime is extension-specific. A background context proxies directly; a page/side-panel relays through the SW via a {__c2sProxyRelay} handler. Two subtleties:

  • httpOnly cookies (6071347): Safari's third-party ITP strips the backend's cookies from an in-browser credentials:"include" fetch, and a session cookie like Grammarly's grauth is httpOnly and invisible to document.cookie, so the proxy retry stayed 401. The proxy sources the Cookie header from chrome.cookies.getAll({url}) (which reads Safari's real jar including httpOnly), falling back to document.cookie only for same-host requests.
  • The proxy strips forbidden headers (host, cookie, origin, referer, …) before replaying, since the browser would have set them itself and the cookie is carried authoritatively via the message's cookie field.

Commit 8255ebc introduced the CORS proxy; a 178-line test/xhr-proxy.test.js covers it. Safari blob downloads (7acf2ca, bfa7903, 6c2396d): an extension page can't trigger a normal download, so forceBlobDownload re-types the blob to a MIME matching the intended filename's extension (WebKit derives the download suffix from MIME) and drives the share sheet / one-click path with filename preservation.

offscreen reply routing & scheme rewrite (5a12186)

chrome.offscreen.createDocument is emulated as a hidden extension-origin iframe (above). Because Safari's native sendMessage broadcasts to all extension-origin documents and the offscreen page registers its real onMessage listeners, the SW→offscreen messaging the extension already wrote works unchanged, replies are delivered by polling a reply key so a streamed response isn't split or reordered. The same commit adds a conversion-time chrome-extension:safari-web-extension: scheme rewrite (CHROME_SCHEME_RE in stage.ts): idioms like sender.url.startsWith("chrome-extension://") (Tampermonkey's INTERNAL_PAGE_PROTOCOLS) can never match on Safari. The regex deliberately preserves concrete-host URLs (chrome-extension://<id>/oauth_callback.html) and origin-spoofing literals, rewriting only scheme-only and wildcard-host occurrences. See test/scheme-rewrite.test.js, test/offscreen-response.test.js, test/self-page-url-rewrite.test.js.

Dark-mode page injection

injectShimIntoHtmlPages also injects a c2s-color-scheme style (:root{color-scheme:light} + a white body) into any page that doesn't already declare a color scheme, pageHandlesDarkMode checks the inline HTML, a <meta name="color-scheme">, and each linked local stylesheet (root-absolute hrefs resolve from the extension root, not the page's directory) before deciding. Extensions that never accounted for dark mode would otherwise render dark-on-dark or with a transparent background in Safari's dark appearance. Commit 7acf2ca; test test/color-scheme-inject.test.js.

The floor paints body, never html. Plenty of extensions choose their theme at runtime instead of in CSS — TWP's popup and options page append html *{background-color:#181a1b!important} when matchMedia("(prefers-color-scheme: dark)") reports dark, Bitwarden and Tampermonkey ship theme CSS inside the app bundle — and none of that is visible in the markup, so those pages are classified light. A floor on html is unbeatable there (html * matches the body, never the root): the canvas stayed white behind the app's own dark UI and the page rendered in two themes at once, e.g. TWP's centered 1200px options column dark with white margins either side. On body the same rule still whitens the canvas for a genuinely light page — a transparent html propagates the body background to the canvas — and the app's own rule wins the moment it lands. See Safari Quirks F2.

The catch-all safety net (75d6cd2)

After every explicit namespace is handled, a generic guard covers everything else. For the truly-uncovered documented namespaces (audio, dns, input, systemLog) it installs a recursive inert Proxy: a member that looks like an event (on* / *Listener) returns an event(); anything else returns a function that resolves a Promise / calls back undefined; sub-namespace access recurses. It carefully returns undefined for Symbol access and "then" so awaiting or string-coercing chrome.x doesn't hang. This means an unknown chrome.someFutureApi.onThing.addListener at module-eval degrades to a no-op instead of crashing the page, and future Chrome APIs are covered for free.


Service-worker → background-page conversion (convertServiceWorkerToBackgroundPage)

Safari's Web Extension model does not run an MV3 service worker the way Chrome does, so viaduct rewrites the SW into a non-persistent background page (background.html, { page, persistent: false }). The generator (in shim.ts) assembles the page with, in order: an SW-lifecycle shim, the polyfill, the compat shim, an optional identity polyfill, the hoisted importScripts targets, the SW itself (as a classic <script> or type="module" depending on swNeedsModule), and finally any webpack chunk pre-registrations.

  • hoistImportScripts / neutralizeImportScripts (fc49074). A background page can't call importScripts() under CSP, so every importScripts("a.js", "b.js") call is located (via a proper JS lexer, walkCode/matchBalancedParen skip strings, template literals, comments, and regexes so a coincidental match inside a string is never rewritten), its static targets are emitted as classic <script> tags in dependency order, and the call itself is replaced with void 0 /* importScripts hoisted */. b33698a fixed the resolve path for a SW living in a subdirectory. The static-vs-dynamic test strips comments as well as string/template literals before checking for a leftover residue, so importScripts("a.js" /*x*/, "b.js") is still recognized as fully static — a stray comment used to read as a dynamic argument and needlessly force webpack-chunk collection + type="module" background loading (92fa2ee). A target's leading / is root-absoluteimportScripts resolves URLs against the worker's location, so /src/lib/actions.js means the extension root, not the worker's directory. Joining it onto the worker's directory produced src/src/lib/…, which resolved to nothing, and since a call is neutralized whether or not its targets resolve, the background page came up with none of the libraries the worker imports. See Safari Quirks G1a for what that looks like from the outside; test/importscripts-root-absolute.test.js covers both forms.
  • Webpack chunk pre-registration (collectWebpackChunks). An MV3 SW split by webpack registers async chunks by pushing into a webpackChunk* global. The converter finds every file whose first real statement is a 2-element push into that same global and pre-loads them with <script defer>, so require()-ing a chunk later finds it. It rejects 3-element pushes (the 3rd element is webpack's startup callback, which would boot a foreign entry point inside the background page) and any file with real code before the push (that's an entry script, not a registration), using CHUNK_PREFIX_RE/CHUNK_SUFFIX_RE to allow only license banners and sourcemap pragmas around it. Covered by test/webpack-chunk-preregister.test.js and test/mv2-background-shim.test.js.

Popup sizing injection (injectPopupSizing)

A page's own layout is authoritative; viaduct only ensures it isn't smaller than it should be. injectPopupSizing inserts a c2s-popup-size style whose size rule is a floor, never an override (fb05734, popup sizing is a floor only, never overrides the app's own size; 0601904, survives app CSS; fit content instead of a fixed box). Two modes, driven by whether the page is a side-panel page wired as the action popup (isSidePanel in convert.ts):

  • Default popup: body gets width: fit-content; min-width: 180px so a small popup isn't forced wide.
  • Side-panel-as-popup (fullHeight, bf77318, explicit height so 100% layout fills): a Chrome side panel fills its host, so a height:100% layout collapses to nothing inside Safari's auto-height popover; the shim gives it an explicit 600×400 box so the layout fills. A :root:root:root{justify-content:flex-start} anchor stops centered flex layouts from floating in the oversized popover.

Sizing is only half of it. Safari's popover cannot scroll its own main frame, so a popup taller than the popover is cut off with no scrollbar and no way to reach the rest. That one can't be fixed by injected CSS at conversion time, because the decision depends on runtime measurements the converter doesn't have: the shim watches the popover document and, once the frame has proven it can't scroll a document that overflows it, makes <body> the scroller with a pixel cap read off the settled frame. See Safari Quirks F5 for the measurements, the 100vh feedback loop it has to avoid, and why it is gated rather than applied to every popup.

b33698a also carried full-page popup sizing for subdir SWs. Related action-popover machinery (wireActionClickBridge, wireActionHotkey) synthesizes a popover for extensions whose toolbar button is handled entirely in the background via action.onClicked, using the captured __viaductMsgListeners to fire the same message the click handler would have sent.


Diagnostics: __C2S_DEBUG__ and the ring buffer

Three gated diagnostic mechanisms exist, all off by default so a shipped extension never logs to the user's console or writes trace state:

  • __C2S_DEBUG__ (top of file, false) gates the trace lines (proxy/fetch routing decisions, the early-abort warning) to Web Inspector. viaduct --debug flips it to true at staging time — shimSource({ debug: true }) rewrites the declaration by the same exact-string split/join the proxy-config token uses.
  • The same --debug emit splices src/runtime/debug-ring.js over a marker line under the gate: a persistent ring buffer that mirrors every gated trace (including cdpLog lines from the CDP emulation) into storage.local under __viaduct_debug_log__{ t, ctx, msg } entries, background/content/page context labels, writes batched ~1 s, capped at the last 2000, every failure swallowed. The tee points check typeof __C2S_DEBUG_WRITE__ === "function", which only a debug emit defines, so a default emit contains no ring-buffer write path at all (the marker stays an inert comment). Read it back with viaduct --logs <name> or from any extension console; see Testing and Debugging.
  • The __c2sDiagMsgs / __c2sDiagConn recorders, append the last ~30 onMessage/onConnect events to chrome.storage.local, readable from the background console.

These belong to viaduct's debug protocol; see Testing and Debugging for how to enable and read them.


browser-polyfill (67e2039)

writePolyfill(targetDir) copies the bundled webextension-polyfill.min.js into the staged extension for promise-based browser.* parity: the polyfill no-ops when a native browser already exists (Safari/Firefox) and otherwise wraps chrome.* callbacks as promises. It must load before the compat shim so the shim's browser-namespace patches apply to the polyfilled object; injectShimIntoHtmlPages and convertServiceWorkerToBackgroundPage both emit its tag ahead of the shim tag.

If the extension already ships its own browser-polyfill.min.js (uBlock Origin does), writePolyfill writes viaduct's copy under viaduct-browser-polyfill.min.js and returns that name instead of overwriting the extension's file. The old unconditional copy replaced the exact build the extension's content scripts were compiled against, breaking them (622001c), see Safari Quirks E5. Since webextension-polyfill is idempotent, loading both copies in sequence is harmless.


Opting out

viaduct convert --no-shim skips shim generation and injection entirely (generateShim: false). The generator guards for this, wireCdpKeepalive and the HTML injectors don't reference a shim file that was never written. Use it only for extensions that already target Safari natively. See CLI Reference.


Related pages

  • OAuth Bridge, a separate but adjacent runtime piece; the shim stubs chrome.identity inert, and the OAuth bridge supplies the real interactive flow.
  • Manifest Transform, how background.service_worker becomes background.page, and where deriveProxyHosts reads from.
  • Safari Quirks, the catalog of underlying Safari/WebKit behaviors (frozen namespaces, casing, the modifyHeaders crash) the shim works around.
  • Conversion Pipeline, where shim writing, HTML injection, SW conversion, and popup sizing sit in the overall staging flow.
  • Testing and Debugging, the test suite (xhr-proxy, webpack-chunk-preregister, color-scheme-inject, offscreen-response, scheme-rewrite, …) and the __C2S_DEBUG__ diagnostic protocol.
  • CLI Reference, --no-shim and related flags.

Clone this wiki locally