-
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 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: lowercases host only for root/empty args (origin derivation), keeps real case for resource paths | Safari casing is inconsistent (UPPER in getURL/sender.url, lower in sender.origin) and its resource server is case-sensitive
|
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 normalize sender.url host case and strip ?tabId query |
Case mismatch and popup ?tabId break exact sender.url allow-listing |
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 | Present on macOS, absent on iOS |
webNavigation |
Backfills events + frame queries | Safari ships only a subset |
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; empty fallbacks; used by the auth proxy |
Present but without onChanged
|
permissions |
Reports all manifest-declared permissions/hosts as granted | Safari grants them up front |
webRequest |
Backfills events; filters listener URLs to watchable schemes | Observation-only on macOS; native addListener throws on exotic scheme filters |
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; reads return an empty tree | No bookmark API |
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/sender.url, lower insender.origin), so an origin equality is always false. - Popup
sender.urlcarries a?tabId=<n>query thatgetURL(path)never has, so exact-match allow-lists (Dark Reader) fail.
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. -
49c41cb— strip the?tabIdquery from popupsender.url. -
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.
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.
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} + white background) 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 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.
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. -
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.
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.
Two gated diagnostic mechanisms exist, both off by default so a shipped extension never logs to the user's console:
-
__C2S_DEBUG__(top of file,false) — flip totrueto trace proxy/fetch routing decisions to Web Inspector. - 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.
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.