-
Notifications
You must be signed in to change notification settings - Fork 3
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 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/onMessagelisteners 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:
-
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 aReferenceErrorthat 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: [] }; }
-
An outer
trywrapping the whole IIFE body (the "backstop"). Whatever partially applied stays applied; the host script keeps running. The comment on it reads "Never remove it." -
Per-block
try/catcharound every individual patch, plus feature-detection before touching anychrome.*. No patch assumes an API exists, is writable, or is even reachable. A throw in thenotificationsbackfill must not skip thedebuggerbackfill, the namespace catch-all, or thedeclarativeNetRequestcrash-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).
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 sothissurvives 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).
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.
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.
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 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 |
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.idis 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.hrefandsender.url, lower insender.origin), so an origin equality is always false. See Safari Quirks B4 for why the fix belongs on the sender rather than ongetURL. - A
sender.urlcan carry a query thatgetURL(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, rewriteruntime.idto the UUID host taken fromgetURL("")(where the frozen slot allows it via the mutable clone). -
cc611a3, becauseruntime.idis often a frozen exotic slot the shim genuinely cannot replace, this is also fixed at conversion time:rewriteRuntimeIdUrlMatchersinstage.tsstrips theruntime.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/wrapOnMessagelowercasesender.url's host so both sides of the routing regex agree. Reverted with the port-clone machinery in43047af; the matcher rewrite above had made it redundant. -
49c41cb, strip the?tabIdquery from popupsender.url, and11a00b4for the storage relay, which builds its sender out oflocation.hrefand so needed the same rule on a second path. -
064812b, stop injecting?tabIdinto 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 fromgetURLfor every argument and alignsender.originto 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.
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.sendMessageto the background, and lets the background'ssendResponsetravel back, but only when both sides are registered on the real, unwrapped runtime. Oncechromeis the relayProxy, that native delivery is silently dropped and the reply never returns. - A content script's
storage.localis 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.
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:
-
readyresolves immediately with a registration stub whoseactive/controlleris a worker stub. Resolving early is safe because apostMessagebuffers behind the wrappedconnect()while a suspended background wakes. - Transferred
MessagePorts cannot cross a runtime port, so each one is bridged by id: the page side forwards itsonmessagetraffic as{t:"port", pid, data}, and the background side re-materializes a realMessagePortfrom aMessageChannelit owns, soevent.ports[0]behaves like the port Chrome would have delivered. - The background gets a real
messageevent carrying those ports, plus two own properties a service-worker handler expects andMessageEventdoesn't have:source(whosepostMessageanswers back down the tunnel and is dispatched on the page'snavigator.serviceWorker) and a no-opwaitUntil. - 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.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.
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.
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.
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).
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-browsercredentials:"include"fetch, and a session cookie like Grammarly'sgrauthis httpOnly and invisible todocument.cookie, so the proxy retry stayed 401. The proxy sources theCookieheader fromchrome.cookies.getAll({url})(which reads Safari's real jar including httpOnly), falling back todocument.cookieonly 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'scookiefield.
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.
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.
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.
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.
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 callimportScripts()under CSP, so everyimportScripts("a.js", "b.js")call is located (via a proper JS lexer,walkCode/matchBalancedParenskip 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 withvoid 0 /* importScripts hoisted */.b33698afixed 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, soimportScripts("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-absolute —importScriptsresolves URLs against the worker's location, so/src/lib/actions.jsmeans the extension root, not the worker's directory. Joining it onto the worker's directory producedsrc/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.jscovers both forms. -
Webpack chunk pre-registration (
collectWebpackChunks). An MV3 SW split by webpack registers async chunks by pushing into awebpackChunk*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>, sorequire()-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), usingCHUNK_PREFIX_RE/CHUNK_SUFFIX_REto allow only license banners and sourcemap pragmas around it. Covered bytest/webpack-chunk-preregister.test.jsandtest/mv2-background-shim.test.js.
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:
bodygetswidth: fit-content; min-width: 180pxso 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 aheight:100%layout collapses to nothing inside Safari's auto-height popover; the shim gives it an explicit600×400box 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.
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 --debugflips it totrueat staging time —shimSource({ debug: true })rewrites the declaration by the same exact-string split/join the proxy-config token uses. - The same
--debugemit splicessrc/runtime/debug-ring.jsover a marker line under the gate: a persistent ring buffer that mirrors every gated trace (includingcdpLoglines from the CDP emulation) intostorage.localunder__viaduct_debug_log__—{ t, ctx, msg }entries,background/content/pagecontext labels, writes batched ~1 s, capped at the last 2000, every failure swallowed. The tee points checktypeof __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 withviaduct --logs <name>or from any extension console; see Testing and Debugging. - The
__c2sDiagMsgs/__c2sDiagConnrecorders, append the last ~30onMessage/onConnectevents tochrome.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.
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.
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.
-
OAuth Bridge, a separate but adjacent runtime piece; the shim stubs
chrome.identityinert, and the OAuth bridge supplies the real interactive flow. -
Manifest Transform, how
background.service_workerbecomesbackground.page, and wherederiveProxyHostsreads from. -
Safari Quirks, the catalog of underlying Safari/WebKit behaviors (frozen namespaces, casing, the
modifyHeaderscrash) 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-shimand related flags.
Viaduct CLI · @magicelk235/viaduct · PolyForm Shield 1.0.0 · Verified against src/ and grounded in git history.