Skip to content

Safari Quirks

magicelk235 edited this page Aug 26, 2026 · 28 revisions

Safari Quirks

This page is the cross-cutting reference for the specific Safari/WebKit behaviors viaduct works around. It's organized by quirk, not by source file, the subsystem pages (Manifest Transform, Runtime Shim, Analyzer, OAuth Bridge, Build and Install) link into here for the "why is Safari like this" explanation.

Each entry follows the same shape:

Quirk: what Safari does or doesn't do. Symptom: how it breaks a converted extension. Handling: what viaduct does about it. Source: file + commit.

Every entry cites the commit that introduced or fixed it. Safari version notes reflect Safari 18.x / 26 unless stated otherwise.


A. Manifest & background

See Manifest Transform for the transform pass and Runtime Shim for the click-bridge/hotkey wiring.

A1. Safari rejects a persistent MV3 background

Quirk. Safari refuses to load any MV3 manifest whose background is persistent: "A manifest_version >= 3 must be non-persistent." It also has no concept of a service worker background, the background must be a page.

Symptom. The extension fails to load outright, or (for an MV3 service-worker background) has no reachable background context at all, so runtime.connect() from the popup gets "No onConnect listeners found."

Handling. transformManifest forces persistent: false on MV2 backgrounds. convertServiceWorkerToBackgroundPage rewrites an MV3 service_worker into a generated background.html page and sets background = { page, persistent: false }.

Source. src/manifest/manifest.ts (transformManifest), src/runtime/shim.ts (convertServiceWorkerToBackgroundPage), bd3bbc7.

A2. background.type: "module" silently breaks the popup

Quirk. A module background page is deferred; it hasn't finished evaluating when the popup opens and calls into it. Safari surfaces no error, the background just isn't ready.

Symptom. Silent popup failures: the popup opens but its init RPCs into the background never resolve, and the click-bridge (A5) finds no registered listeners because the module hasn't run.

Handling. transformManifest strips background.type: "module" unless --keep-module is passed. convertServiceWorkerToBackgroundPage additionally loads the converted SW as a classic <script> (not type="module") when it has no ES-module syntax of its own, so listener registration completes during the page's synchronous parse. Module is kept only for bundles that truly need it (own import/export, import.meta, or deferred webpack-chunk ordering).

Source. src/manifest/manifest.ts, src/runtime/shim.ts (convertServiceWorkerToBackgroundPage, swNeedsModule), bd3bbc7, README "silent popup failures".

A3. An 18.* max-version cap hides the extension

Quirk. A browser_specific_settings.safari.strict_max_version of 18.* (a common Chrome-era template value) causes Safari 18+ and Safari 26 to treat the extension as incompatible and hide it entirely.

Symptom. The extension builds and installs but never appears in Safari's extension list on current Safari.

Handling. viaduct injects browser_specific_settings.safari with a strict_min_version (default 15.4, override --min-safari) and no maximum cap.

Source. src/manifest/manifest.ts (transformManifest, the browser_specific_settings block), README "no maximum cap".

A4. commands limited to 4 keyboard shortcuts

Quirk. Safari rejects the whole manifest when more than 4 commands carry a suggested_key Safari can read: "Too many shortcuts specified for commands, only 4 shortcuts are allowed." Chrome has no such cap (bundles like TWP declare 8+).

Symptom. Manifest load fails; the extension won't install.

Handling. transformManifest keeps the first 4 Safari-readable chords (declaration order) and strips suggested_key from the rest, those commands still exist and stay bindable in Safari → Settings → Extensions, they just lose their default chord. Only Safari-platform chords count toward the limit; windows/linux-only chords are already dead keys.

Source. src/manifest/manifest.ts (transformManifest, hasSafariSuggestedKey), d3fcb9a.

A5. Safari never dispatches action.onClicked (or commands.onCommand), the flagship quirk

Quirk. Safari (26) never dispatches chrome.action.onClicked, nor chrome.commands.onCommand, to a converted background. A common extension pattern is a popup-less toolbar button whose onClicked handler toggles in-page UI (a sidebar/overlay), typically by tabs.sendMessage(tab, {type:"…"}) to a content script that flips the UI.

Symptom. The toolbar button is completely dead in Safari, clicking it does nothing, because the event that drives it is never delivered.

Handling. viaduct wires one of two paths, in strict priority order. Both start from the same heuristic (backgroundRegistersActionOnClicked: the background source references onClicked alongside action/browserAction) and both must run before the SW→page conversion so the background scripts are still on the manifest for static scanning.

setPopup opt-out. The heuristic returns false when the same background source calls (browser|page)Action.setPopup({popup:"…"}) with a non-empty popup. Such a button is popup-driven, not onClicked-driven, its onClicked handler is usually a no-op or a conditional fallback, and the real UI is a popup wired at runtime, which Safari honors. Treating it as onClicked-driven made both paths hijack the button: the bridge injected a transparent stub default_popup that overrode the runtime setPopup, so the extension's real popup never opened. This is what killed TWP (Translate Web Pages): its toolbar button sets popup/popup.html via setPopup at startup, and its browserAction.onClicked handler only acts when translateClickingOnce is on (off by default), so clicking the button opened an empty gray stub and there was no way to trigger a translation. An empty setPopup({popup:""}) clears the popup and does not opt out, that button is genuinely onClicked-driven. The scan is collective across all background files, not a per-file short-circuit: viaduct prepends its own shim to background.scripts, and the shim references onClicked + action but never calls setPopup, so a per-file "onClicked here, no setPopup here → wire it" would fire on the shim before ever reaching the bundle's real setPopup in background.js. A non-empty setPopup anywhere in the background wins (6f63db2).

Path 1, In-page hotkey (popover-free, preferred)

The actual toggle lives in the content script's runtime.onMessage listener, so viaduct reproduces the toggle entirely in the page, with no toolbar popup and no popover (Safari's popover, see A6, is un-closable, avoiding it is the whole point).

  • The compat shim loads before the bundle's content script and captures the content script's runtime.onMessage listeners into self.__viaductMsgListeners. This capture runs in every content-script context unconditionally — it must not be gated on a "was a hotkey wired?" check derived from chrome.runtime.getManifest(), because Safari strips content_scripts from getManifest() inside a content script, so that check reads false and the capture silently never installs. When it was gated this way, the hotkey replayed to an empty listener list and did nothing (live: SuperDev Pro's Cmd+Shift+S sidebar toggle, 190133d). Capturing always costs only a few listener refs and the array is simply never read when no hotkey script exists.
  • extractActionMessage statically reads the message literal the background's onClicked handler sends to the tab, it searches a 600-char window after the onClicked registration for sendMessage(tab, {…}) and lifts the {…} object (e.g. {type:"TOGGLE_SHELL"}). Only single-level brace literals qualify; a dynamic/computed message fails and falls through to Path 2.
  • wireActionHotkey generates a content script that, on a keyboard shortcut, replays that captured message directly to the captured listeners, reproducing the onClicked toggle.
  • Shortcut selection: it reuses a declared commands key when the binding is unambiguous, _execute_action, or a lone declared command. This is safe precisely because Safari never fires commands.onCommand, so that key was otherwise dead; viaduct then deletes that now-inert command so Safari doesn't reserve the combo. With several ambiguous commands it doesn't guess and falls back to Ctrl+Shift+Y.
  • Key mapping: the generated content script matches on KeyboardEvent.key, so parseCombo maps a named WebExtensions key token to its DOM form — UpArrowUp, Space" ", Comma/Period/PageUp/Home/End/… — rather than the bare token. A token it can't map (e.g. a media key) makes parseCombo return null, and the caller falls back to the Ctrl+Shift+Y default rather than wiring a shortcut that can never fire. A command like Ctrl+Shift+Up previously wired key:"up", which no e.key ever equals, so the hotkey was silently dead (92fa2ee).
  • Requirements: the message must be statically determinable and the extension must have content scripts. When wired, the toolbar button is intentionally left inert.

Source. src/runtime/shim.ts (wireActionHotkey, extractActionMessage, backgroundRegistersActionOnClicked, setsNonEmptyPopup, parseCombo), shim capture in src/runtime/safari-compat-shim.js, e9ba1e3, 190133d (ungate the content-script capture), 6f63db2 (setPopup opt-out).

Path 2, Synthetic transparent popup (fallback)

Used only when the hotkey can't be wired (dynamic message, or no content scripts).

  • viaduct adds a tiny transparent default_popup (ACTION_BRIDGE_HTML), transparent so the popover shows no fill, though Safari still draws its own gray chrome at a minimum size.
  • On open, the popup script calls chrome.runtime.getBackgroundPage(). This is the crucial detail: getBackgroundPage() is the only thing that wakes a suspended Safari background, runtime.sendMessage does NOT wake it, so it can't be used here. getBackgroundPage() both wakes the background and returns the one canonical background page.
  • The popup then calls __viaductFireClick(id) on that page, which replays the real onClicked listeners in the background realm (so their tabs.sendMessage correctly reaches the content script). The call is deduped on the id so retries (which cover wake latency, up to 80 tries × 100 ms) fire exactly one toggle.
  • Safari limitation: a toolbar popup always draws a popover that script cannot close, window.close() / blur() / refocus are all ignored. So this path shows a brief popover that dismisses on the next interaction. This is exactly why Path 1 is preferred.

Source. src/runtime/shim.ts (wireActionClickBridge, actionSlot, setsNonEmptyPopup), background replay in src/runtime/safari-compat-shim.js (__viaductFireClick / __viaductOnClicked), e9ba1e3, 6f63db2 (setPopup opt-out).

Wiring order (src/convert.ts): wireActionHotkey is attempted first; only on null does wireActionClickBridge run; then convertServiceWorkerToBackgroundPage.

A6. A chrome:// scheme URL in permissions makes the whole manifest invalid

Quirk. A permissions entry that is a host match pattern with a scheme Safari's validator can't parse (chrome://favicon/, ws://…) isn't merely ignored the way a stray https://site/* in permissions is. Safari treats the entire manifest as invalid and refuses to load the extension.

Symptom. The converted app installs, signs cleanly, and registers with pluginkit (viaduct --list shows it), but Safari never adds it to its extension state and it doesn't appear in Settings → Extensions. A restart doesn't help. The extension is dead, not degraded. Tampermonkey shipped chrome://favicon/ in permissions and hit exactly this (#11).

Handling. transformManifest drops any permissions / optional_permissions entry that looks like a host pattern (includes("://")) and fails matchPatternError, the same filter already applied to host_permissions. A plain permission name (tabs) has no scheme and is untouched; a legal https:// pattern passes matchPatternError, so the deliberate warn-don't-move behavior for a misplaced-but-grantable host pattern (see the MV3 branch in analyzeManifest) is preserved. The analyzer reports the unparseable-scheme case as auto-fixed rather than telling the author to move a value Safari can't grant in host_permissions either.

Source. src/manifest/manifest.ts (transformManifest permission filter, analyzeManifest MV3 host-pattern branch, matchPatternError), 31d13b9.


A7. Safari drops the last message in _locales/<locale>/messages.json

Quirk. Safari does not load the final entry of a locale catalog. i18n.getMessage returns "" for that key in every form: with no substitution, with a string substitution, with an array substitution.

Symptom. One string in the UI comes back empty and the extension falls back to rendering its raw message key. Live on Tampermonkey, whose last message is v0version0 ("v$version$"): its dashboard header read a literal v0version0 where the version belongs, while top_level_await, the entry immediately before it, resolved normally. It is neither a size cutoff nor a gap in placeholder resolution, both of which were the obvious suspects: entries past the same byte offset resolve, and other placeholder messages resolve. The entry is dropped for being last.

Handling. Staging appends a sacrificial viaduct_locale_tail_guard message to every catalog so the extension's own last string is no longer last. The insert is textual, ahead of the closing brace, so the shipped formatting and escapes survive byte for byte, and a catalog that isn't a non-empty JSON object is skipped.

Source. src/input/stage.ts (guardLocaleTailMessage), 937da18.


A8. Host access defaults to Ask, and every reinstall resets it

Quirk. Safari does not grant broad host access on install. <all_urls> and every host pattern start at Ask, and the grant is per install: --install replaces the app and the extension comes back with no access, however many times it was granted before.

Symptom. Everything that touches a page goes quiet at once. Content scripts do not inject, and tabs.onUpdated and webNavigation.onCommitted are not delivered for the ungranted site either, so a background listener looks correctly wired and never fires. Extension pages are unaffected, which is what makes it convincing: the popup and options page work perfectly while nothing reaches any tab.

Why it matters for debugging. A negative result from a build whose access was not re-granted proves nothing, and it is easy to mistake for a platform limitation. During the Tampermonkey work this produced a confident and wrong conclusion that Safari delivers no navigation events to a converted background page, which reached a commit message and a doc comment before it was caught.

Handling. Nothing to fix in the bundle; this is the user's choice to make. The installer prints the grant step, and Testing and Debugging makes confirming it a precondition for believing any negative live result. Grant it from the toolbar icon, "Always Allow on Every Website", after every reinstall.

Source. Safari per-site permission model; src/build/installer.ts prints the step.


A9. The service-worker-to-page conversion breaks "am I the background?" checks

Quirk. Not Safari's doing, ours. MV3 bundles decide they are the background by the absence of window, because in Chrome the background is a service worker. convertServiceWorkerToBackgroundPage (A1) gives them a page, which has one, so the check answers no and the bundle concludes it is a content script.

Symptom. Anything keyed off that identity misroutes in silence. Live: Cloaked - Privacy & Password Manager, built on crx-kit, whose dispatcher opens with if (msg.to !== this.myEndpoint) return false. Its background page reported FOREGROUND, so clicking Log in delivered {to:"BACKGROUND", name:"openAuthUrl"} to a listener that dropped it on the floor. No error anywhere, and the popup spun forever.

Handling. rewriteBackgroundContextChecks rewrites the check to true in the staged sources, scoped to the files the manifest's own background loads, and only where the check directly produces a background-ish value ("background", "background_script", "service_worker", and similar). That leaves a bundled library using the same idiom to pick a Node path alone, and leaves the identical detector inside the popup bundle answering "foreground", which it must.

Source. src/input/stage.ts (rewriteBackgroundContextChecks), src/convert.ts; test test/background-context-check.test.js.


B. Origins, URLs & scheme

Safari serves an extension from a different per-install origin than Chrome: the scheme is safari-web-extension:// and the host is a per-install UUID, not the Chrome extension ID. Anything that hardcodes the Chrome origin, scheme, or ID breaks. See Analyzer for the flags and Runtime Shim/src/convert.ts for the rewrites.

B1. Hardcoded chrome-extension://<id>/ URLs

Quirk. Safari's per-install origin means a literal chrome-extension://<id>/page.html points nowhere in Safari.

Symptom. Assets, self-page navigations, and internal links that use the hardcoded URL 404 or open dead tabs.

Handling. Layered:

  • The analyzer flags hardcoded chrome-extension://<id>/ URLs and suggests chrome.runtime.getURL().
  • rewriteChromeSchemeLiterals rewrites the bare chrome-extension: scheme literal used in self-page classification (sender-URL prefix checks, internal-protocol tables) to the Safari scheme, but skips chrome-extension://${id}/… forms, which could be an OAuth redirect_uri (5a12186).
  • rewriteSelfPageExtensionUrls rewrites a whole-URL self-page navigation (tabs.create/window.open of chrome-extension://<id>/page.html) to runtime.getURL(), so it doesn't open a dead tab (02c4007).
  • Concrete-host chrome-extension:// URLs (pointing at a real other host, not self) are kept intact: they're not the extension's own pages (1b7b6f5).

Source. src/convert.ts (rewriteChromeSchemeLiterals, rewriteSelfPageExtensionUrls), src/input/stage.ts, analyzer in src/manifest/manifest.ts, 1b7b6f5, 02c4007, 5a12186.

B2. chrome.runtime.id differs → port routing breaks

Quirk. In Safari chrome.runtime.id is the bundle id, not the URL-host UUID, and runtime.id is a frozen exotic slot the shim cannot rewrite. Bundles routinely route extension-page ports by testing sender.url against a matcher built from runtime.id, e.g. new RegExp(runtime.id + "/src/popup.html").test(sender.url).

Symptom. The matcher never matches → popup/side-panel ports go unrouted → their init RPCs hang, leaving a blank/uninitialized popup.

Handling. Several coordinated fixes:

  • rewriteRuntimeIdUrlMatchers strips the runtime.id + prefix from port-routing matchers in staged source, making the matcher host-agnostic and query-tolerant (cc611a3, 362feb9).
  • The shim rewrites/normalizes chrome.runtime.id toward the UUID host where needed (362feb9).
  • senderWithFixedUrl strips the query and fragment from a sender.url on our own origin, so an allow-list built from getURL(path) still matches (49c41cb, 11a00b4 for the storage relay's own sender).

A fourth fix used to live here: lowerHost lowercased the scheme://authority of sender.url so a comparison against runtime.id matched (e90a45f). The port-clone machinery that carried it went away in 43047af once the conversion-time matcher rewrite covered the same ground, and the leftover half of it caused B4. Nothing lowercases sender.url now.

Source. src/convert.ts (rewriteRuntimeIdUrlMatchers), src/runtime/safari-compat-shim.js (senderWithFixedUrl), 362feb9, e90a45f, 43047af, cc611a3, 49c41cb, 26605b7, 11a00b4.

B4. The extension UUID's case is not the same in every API

Quirk. Safari reports the per-install UUID uppercase in getURL(), location.href and sender.url, and lowercase in sender.origin. Bundles compare getURL's output against both of those, so no single case satisfies everyone:

sender.origin === chrome.runtime.getURL("").slice(0, -1)      // uBlock
window.location.href.includes(getExtensionURL("/"))           // Honey

Resource loading pins one end of it down: Safari's resource server is case-sensitive on the host, so a lowercased fetch(getURL("manifest.json")) 404s with "TypeError: Load failed" and takes the background's init with it (live on Grammarly).

Symptom. Whichever side loses the comparison fails silently. An early build lowercased the host for the empty and root args (getURL(""), getURL("/")), which satisfied uBlock's origin check and broke Honey's page check. Honey's popup uses that check to decide whether it is the popover; with it false the popup treated its own href as the current page URL, sent every message without a data.tabId, and its background rejects those on the first line. Blank popup, no error on either side.

Handling. getURL lowercases the host for the exactly-empty argument only — the getURL("") form exists solely to build an origin string, and Safari reports every real origin lowercase, so the lowercase form is the one both sides of an origin equality can meet at. Every other argument, including "/", keeps Safari's real case: Honey compares location.href against getURL("/"), and Safari's resource server is case-sensitive on the host, so a lowercased resource path 404s. The remaining mismatch is fixed on the receiving end: senderWithFixedUrl aligns sender.origin to the lowercase canonical origin on the clone it hands message listeners. In-place mutation of the sender does not stick, because Safari's sender is an exotic getter returning a fresh object per read, but the clone's does.

Ports needed their own path: a port's sender is the same fresh-object-per-read getter, but unlike a message listener there is no argument slot to hand the clone through — onConnect delivers the Port itself, and bundles read port.sender off it at connect time to decide privilege (uBlock: origin !== undefined ? origin === getURL('').slice(0,-1) : url.startsWith(…), where the url fallback can never pass against the uppercase sender.url). The onConnect wrapper now installs the corrected sender on the native port where the slot allows it (plain assignment, then defineProperty with writable: true, because bundles assign port.sender = undefined from strict-mode modules and a read-only slot would throw inside their handler), and where Safari refuses both it substitutes a memoized delegating Port that differs in sender alone — same object for every listener, and the port argument in its onMessage/onDisconnect callbacks is swapped for the wrapper so identity-keyed bookkeeping (Map, indexOf) keeps matching. A sender not on the extension's own origin passes through on the untouched native port, so a web page can never be handed a privileged origin. Test: test/port-sender-origin.test.js.

Source. src/runtime/safari-compat-shim.js (patchGetURL, senderWithFixedUrl), bf014e0; test test/geturl-host-case.test.js.

B3. use_dynamic_url on web-accessible resources

Quirk. Safari doesn't implement web_accessible_resources.use_dynamic_url (Chrome-only per-session URL rotation). An entry left with use_dynamic_url: true is unservable in Safari.

Symptom. chrome.runtime.getURL() for that resource hands back a URL that 404s: a content script's injected CSS, an <img>/<link>/iframe src silently fails. A common cause of an in-page panel/sidebar that toggles but never appears.

Handling. transformManifest clears the flag (use_dynamic_url: false) on every object entry so the resource resolves at its stable static extension URL. Chrome treats absent/false as the default, so it's a no-op there.

Source. src/manifest/manifest.ts (transformManifest, the use_dynamic_url loop), README "Clears use_dynamic_url".


C. APIs with no / partial Safari support (stub-so-it-loads)

Quirk (shared). Safari lacks or only partially implements many chrome.* namespaces. In an MV3 module world, a single undefined.method() at module-eval throws and blanks the page.

Symptom (shared). Without a shim, the popup/options/background page dies at load. With one, the page loads but the underlying feature may still be dead, the analyzer reports each with a remediation. See Runtime Shim and Analyzer; the full per-API table lives in src/manifest/compat-data.ts.

Handling (by API):

Namespace What viaduct does
storage.sync Routed to storage.local, Safari has no iCloud sync, so data persists but does not sync across devices. Per-area sync.onChanged is synthesized as a filtered relay of the global storage.onChanged to match Chrome's area-scoped, single-arg event.
sidePanel Stubbed/emulated, opens via the action popover (Safari 17.4+), tab fallback on older; opens the page the extension actually configured (side_panel.default_path or a setOptions({path}) call), not a hardcoded guess.
identity Stubbed so calls reject instead of throwing (real OAuth still can't complete, see OAuth Bridge).
notifications Backed by the Web Notification API (Safari keeps the permission).
tabGroups Emulated in memory (no tab-bar coloring).
debugger Emulated by the CDP shim (a Page/Target/Input/DOM/Runtime/Accessibility subset over Safari tabs + scripting; Network/Fetch + trusted input unavailable).
offscreen Emulated via an extension-origin iframe.
i18n.detectLanguage Missing engine in Safari → shim returns 'und' instead of throwing (backfilled without clobbering Safari's native getMessage).
commands.getAll Rebuilt from the manifest so an extension's own shortcut UI is populated (Safari's native surface differs).

Source. src/manifest/compat-data.ts (UNSUPPORTED_PERMISSIONS, SHIMMED_PERMISSIONS, UNSUPPORTED_APIS), src/runtime/safari-compat-shim.js, 530a7ed, 1232fff, 1f3af6f, a69e926.

C1. chrome://extensions/shortcuts doesn't exist in Safari

Quirk. Safari has no chrome://extensions/shortcuts page (shortcuts are edited in Safari → Settings → Extensions).

Symptom. An extension's own "edit shortcut" UI opens a broken/dead tab.

Handling. The shim swallows a navigation to chrome://extensions/shortcuts (or chrome://settings) instead of opening a broken tab, and rebuilds commands.getAll() from the manifest so the extension's shortcut UI still populates. The analyzer warns when source hardcodes such a link.

Source. src/runtime/safari-compat-shim.js, src/analyze/analyze.ts, 503e14f, e7d1653.


C2. A fabricated clients list still has to deliver a SW to offscreen postMessage

Quirk. Chrome's documented way to hand binary data to an offscreen document is client.postMessage(msg, [port2]) over self.clients.matchAll(), with the reply arriving on the port's twin and the offscreen document listening on navigator.serviceWorker.onmessage. A converted background is a page, so it has no clients at all and the shim fabricates one over the emulated offscreen iframe.

Symptom. The fabricated client answered the existence probe but discarded the message, so the caller's await on the port never settled. Nothing threw and the background console stayed clean, which made it read as a permanent hang rather than a failure. Live on Tampermonkey: it wraps a userscript's source in an object URL created offscreen, so every editor save spun on "Please wait..." and then reported a bogus "Unable to parse this!" once the message channel gave up.

Handling. The fabricated client dispatches a real message event at the offscreen iframe's navigator.serviceWorker with the transferred ports attached, and falls back to a window message for offscreen documents that listen there instead.

Source. src/runtime/safari-compat-shim.js, 3dd05f2.


C3. chrome.userScripts has no Safari equivalent, and a registry alone runs nothing

Quirk. Safari has no dynamic user-script API. The shim keeps a coherent chrome.userScripts registry so register/update/getScripts/unregister round-trip, but a registry that nothing reads injects nothing, and an extension whose entire injection strategy is this API ships no content_scripts to fall back on. Tampermonkey is exactly that: its manifest declares no content scripts at all, so its userscripts saved, listed as enabled in the popup, and never ran on a single page.

Symptom. Scripts are managed correctly and have no effect.

Handling. The background publishes its registry to storage.local on every register, update and unregister, and viaduct injects a content script (only into extensions that asked for the userScripts permission) that reads it at document_start, matches the page URL itself, and evaluates what applies. Storage rather than messaging: runtime.sendMessage broadcasts to every listener and the first sendResponse wins, and Tampermonkey's own background handler consumed the request and answered with nothing on all twelve retries. Storage also outlives the background being torn down, so an injector that runs before the background has woken still finds the scripts.

What this still does not fix. Both Tampermonkey builds still run no userscripts, but the earlier explanation here — that its content.js installs a pagejs setter on a sandbox global of its own making that the isolated world can never satisfy — was wrong. That code path is Tampermonkey's Firefox branch, gated on chrome.userScripts.onBeforeScript, which neither Chrome nor Safari has; it never runs here. The Chrome-path handshake is a plain shared-global exchange (page.js does this.pagejs = fn, content.js reads the same global or installs a setter on it), and the injector already satisfies it: both scripts evaluate via indirect eval into one isolated-world global in registration order, which test/userscripts-injection.test.js asserts with Tampermonkey's real registration payloads. What actually remains open, in likelihood order: the injector runs only after an async storage.local read plus polling, so document_start scripts arrive late and Tampermonkey classifies its own injection as "late"; configureWorld({csp}) is a no-op, so the page's CSP still applies where Chrome's USER_SCRIPT world is exempt; and Tampermonkey's raw sandbox keeps using a detached <iframe>'s realm, which WebKit may discard where Blink keeps it alive. Its Settings → Sandbox mode set to DOM avoids the detached realm entirely and is the first thing to test with a --debug build.

Source. src/runtime/safari-compat-shim.js, src/runtime/shim.ts (wireUserScriptsContentScript), 62ec130.


C4. windows.create resolves a Window with no tabs

Quirk. Chrome guarantees the Window resolved by windows.create carries its tabs array, unlike windows.get where it depends on the populate option. Safari leaves it undefined.

Symptom. An extension that reads the new tab straight off the result treats it as a hard failure. Live: Cloaked's auth flow throws "Created window has no tabs available", and because its popup has already called window.close(), the user sees a spinner vanish and no login window.

Handling. fill() cannot cover this, the method exists on macOS and merely under-reports. The shim wraps it and backfills tabs from a tabs.query on the new window id, synthesizing a minimal tab when that query comes back empty, since callers index tabs[0] unconditionally and an empty array is as fatal as a missing one. It also retries without type when Safari refuses a window type it does not render.

Source. src/runtime/safari-compat-shim.js; test test/windows-create-tabs.test.js.


C5. webNavigation.onHistoryStateUpdated never fires, and the background sees no navigation events at all

Quirk. Safari has no onHistoryStateUpdated or onReferenceFragmentUpdated. Worse, a converted background page appears to receive no navigation or tab events: with <all_urls> confirmed granted via permissions.contains and the E7 fix in place, tabs.onUpdated and webNavigation.onCommitted both stayed silent through a full page load and an in-page click, on the chrome and browser namespaces alike.

Symptom. An inert backfilled event is indistinguishable from a real one that has not fired yet, so a bundle keying real work off SPA navigation stalls with nothing logged. Live: Cloaked installs its page-to-extension bridge from onHistoryStateUpdated on my.cloaked.com, a Vue app, so after login the dashboard's postMessage had no listener and the extension stayed logged out behind a page that said the login had worked.

A content script cannot simply watch for it either. It runs in an isolated world and holds a different history than the page, so hooking pushState never sees the page's own routing (measured on GitHub, whose Turbo router pushes in the page world). location.href does reflect the change in both worlds, but sampling it needs a reason to run. Two things were measured on Safari 26 with a purpose-built probe extension, a pushState performed by the page 45 s after the last click, and host access granted:

  • The content script is not re-injected. The earlier note here generalized from Turbo clicks, which do re-run the file; a plain page-world pushState does not.
  • Sampling in short bursts after user input therefore sees nothing at all, and no event is emitted. This is precisely Cloaked's login: its dashboard pushes the extension-auth status route once the token exchange returns, seconds after the last click.

Handling, and its limits. The shim replaces the inert stubs with live events fed from two sources: content scripts announce their URL, and tabs.onUpdated stays wired for hosts that do deliver it. The background holds the per-tab baseline and decides what changed, because the sender cannot: each injection gets a fresh isolated world, so neither a closure nor a window property survives to be compared against. Announcements come from the injection itself, from bursts after input, and — where the extension actually reads the event — from a standing 350 ms watch on location.href. That watch is armed by the background's answer to a report, which says whether anything is listening: no extra message, no storage key, and no timer at all in a page belonging to an extension that never registered a listener. Remaining limits: Safari freezes a hidden tab's timers outright (measured), so a change made while hidden is only reported when the tab is shown again; the standing watch runs in the top frame only; and a site the user has not granted reports nothing, per A8.

Confirmed working. With the standing watch in place the same probe reports the push within ~130 ms, the background emits onHistoryStateUpdated with the right tabId and frameId: 0, and a tabs.sendMessage back to that tabId is received and answered — the full round trip Cloaked's dashboardNavigated depends on. Before the fix, the identical run produced no report and no event.

Read A8 first. Every negative result here is only as good as the host-access grant behind it, and the grant resets on every --install.

Source. src/runtime/safari-compat-shim.js; tests test/history-state-updated.test.js, test/history-nav-content-script.test.js.

C6. tabs.onActivated never fires in a converted background page

Quirk. A background page can register tabs.onActivated and windows.onFocusChanged and receive neither. Probed live on Honey with logging wrapped around the event registrations themselves: both listeners are in place 28 ms into boot, the user clicks between three tabs, and the log records the registrations and then nothing, while the same background instance is demonstrably alive and answering popup messages 8.7 s later. This is C5's silence, in the one place it costs the most.

Symptom. An extension that keeps "the selected tab" in a variable fed only by those events never learns one. Honey's background is exactly that shape:

chrome.tabs.onActivated.addListener(e => { selectedTabId = e.tabId;  });
function getSelectedTab(){ return tabs.get(selectedTabId) }

Its popup asks for the selected tab as it opens, the background calls tabs.get(undefined), and Safari answers "Invalid call to tabs.get(). The 'tabID' value is invalid, because a number is expected." The popup gets no tab id, every message it sends afterwards is rejected by the background's own tabId guard, and it renders empty with nothing in either console but the downstream error.

Restarts make it worse rather than better. Safari rebuilds the background constantly, and a freshly woken one has missed every activation there ever was, so waiting or switching tabs does not recover it.

Handling. The shim polls the active tab in the background while something is listening and dispatches onActivated when it changes. The first poll always dispatches, which is the part that fixes Honey: a woken background needs to be told where it is, not just what changed since. Polling starts only when a listener registers, and one real onActivated firing stops it permanently, so a Safari that starts delivering the event does not produce two of everything.

Source. src/runtime/safari-compat-shim.js (emulateTabActivation), 7b1b69a; test test/tab-activation-emulation.test.js.


C7. An extension page's navigator.serviceWorker has no registration, so ready never settles

Quirk. Chrome registers an MV3 background service worker against the extension origin, which makes navigator.serviceWorker inside an extension PAGE a live handle on the background. That is the basis of 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 the extension origin has no service-worker registration at all. Measured in a converted extension page on Safari 26: getRegistrations()[], controllernull, and ready stays pending indefinitely (still unsettled after 6 s).

Symptom. The await never returns. Nothing throws, no console anywhere shows an error, and the extension looks installed and enabled while doing absolutely nothing. Live on Kondo, whose web app (app.trykondo.com) talks to the extension only through that port: the site loaded, sign-in worked, and the extension never answered a single request, so the app sat empty. The toolbar popup looked equally broken for an unrelated reason — Kondo's popup is a redirect (window.open(app) ; window.close()), and Safari's popover cannot be closed by script (A5), so it lingers as an empty grey panel even though the tab does open.

Handling. The shim emulates the container in extension pages — ready resolves with a registration whose active/controller is a worker stub — and tunnels postMessage to the background over a runtime.connect port. The tunnel is the only transport available here: an extension page embedded in a web page runs in the web content process, where extension.getBackgroundPage() returns null (measured: still null after 11 s with a port held open), so the port object cannot be handed across realms the way C2's offscreen client does it. A MessagePort cannot cross a runtime port either, so each transferred port is bridged: its traffic travels as ordinary port messages and the background side re-materializes a real MessagePort from a MessageChannel it owns, then dispatches a message event carrying it, plus a source that answers back down the tunnel and a no-op waitUntil. Consequences worth knowing: payloads cross as runtime-message JSON, not a structured clone (no Blob/ArrayBuffer/Date fidelity), and a page that really is controlled by a worker is left alone.

Source. src/runtime/safari-compat-shim.js (the __c2sSwBridge block); test test/sw-message-bridge.test.js.


D. DNR & networking

See OAuth Bridge for the native-host proxy and Analyzer/Limitations and FAQ for the reporting.

D1. modifyHeaders rules are accepted and never applied

Quirk. Safari takes a modifyHeaders rule, stores it, hands it back — and never acts on it. Measured on Safari 26.6.2 / macOS 26.6.2, from a converted extension holding all-website access, with the headers read off a server rather than inferred:

step result
updateSessionRules with a header rule resolves
updateDynamicRules with the same rule resolves
getSessionRules / getDynamicRules list the rule back verbatim
main-frame navigation to the matched host Safari's own User-Agent, no Referer override
page-context fetch() to the matched host Safari's own User-Agent
subresources (script / image / XHR) on that page Safari's own User-Agent
block rule registered in the same call blocks

That last row is the control: the rule list is compiled and live, so this is not a host-access or timing artifact. The header action specifically does nothing. WebKit has the plumbing (_WKWebExtensionDeclarativeNetRequestRule.mm:780 maps the action to the content-extension modify-headers type; ModifyHeadersAction::applyToRequest walks requestHeaders), it just never reaches a real request in a WebExtension content rule list.

Three further limits, from the same source files, in case that ever changes:

  1. Header names go through a fixed 93-name allowlist, WebKit's isHeaderNameValid. user-agent, referer, cookie, authorization, content-security-policy are on it; x-forwarded-for and every other custom x-* are not (WebKit bug 290922).
  2. Rejection is a synchronous throw, not a rejected promise, so one unlisted header name aborts the extension's own registration code and costs it the whole batch.
  3. responseHeaders are not implemented at all — parsed, stored, compiled, never applied; no applyToResponse exists (WebKit bug 263818).

Symptom. Anything whose bypass is a header rewrite silently does nothing after conversion. Live: Bypass Paywalls Clean spoofs User-Agent to Googlebot, pins Referer to google.com and strips Cookie for 146 of its 802 session rules; on Safari every one of those sites behaves as if the extension were not installed. Its block rules, which are most of the ruleset, do work.

Handling. Drop the rules, on both paths: stripModifyHeaders in applyDnr for static rule_resources on disk (they never pass through the shim), and the same filter inside the shim's update{Session,Dynamic}Rules wrapper. They cannot work, they occupy a rule store that has a cap, and one bad header name can take the whole batch's working rules down with it. The shim additionally catches a synchronous throw out of that call and falls into the rule-by-rule salvage it already uses for regexFilter rejections, so a rule Safari dislikes for any other reason cannot abort the caller either. Re-enabling is a two-line change (this filter and its shim counterpart) if WebKit starts applying the action.

History. Until 1.12.x the same rules were stripped for the wrong reason: the belief that a modifyHeaders rule crashed the whole browser via a null-deref in WebExtensionContext::loadDeclarativeNetRequestRules → getRulesWithRuleIDs. That crash is real but rule-content-independentgetRulesWithRuleIDs hands back a null RefPtr<JSON::Array> when the DNR SQLite store fails to open and the caller dereferenced it unguarded. Fixed on WebKit trunk by 305661@main (ac2067c1, 2026-01-15, if (!rules || !rules->length())), and that guard is not on safari-7623-branch / safari-7624-branch, the generation Safari 26.x ships from. So: right behavior, wrong explanation, and the explanation mattered — it is why nobody re-tested the platform for a year.

Source. src/manifest/dnr.ts (stripModifyHeaders, applyDnr), src/runtime/safari-compat-shim.js, test/dnr-modify-headers.test.js.

D2. regexFilter, a narrower regex subset than Chrome's

Quirk. Safari compiles only a subset of the regex syntax Chrome accepts for a DNR regexFilter, and it reacts to a pattern it can't compile in two opposite ways. In a static ruleset it silently drops the offending rule. In a dynamic update{Session,Dynamic}Rules call it rejects the entire call, so one bad rule costs the extension every other rule in the batch.

Symptom. Static: some blocking/redirect rules just don't apply, with no error. Dynamic: nothing in the batch applies and the rejection lands in the console. Live on Tampermonkey, which registers all of its *.user.js interception rules in one call and got back "Rule with id 2 is invalid. regexFilter is not a supported regular expression", leaving userscript-URL detection dead outright.

How narrow, measured. Bypass Paywalls Clean registers 802 session rules in one call, of which 253 carry a regexFilter; the rest it has already converted to urlFilter itself. Safari refused 249 of those 253, every one with "regexFilter is not a supported regular expression", and accepted all 530 urlFilter rules. The refused patterns use nothing exotic — alternation ((a|b), 205 rules), \w/\d classes (48), […] classes (34), {n,m} (20); no lookahead or backreferences anywhere. Treat regexFilter as effectively unusable on Safari and urlFilter as the only reliable form.

Handling. applyDnr counts regexFilter rules in static rulesets and warns (doesn't strip), advising urlFilter where possible and per-rule testing. For the dynamic path the shim retries a rejected call rule by rule and keeps whatever Safari accepts; if nothing lands at all the original error still surfaces, so a wholly rejected update can't masquerade as success. A --debug build additionally tallies the refusals by reason ([c2s] DNR refused x249: …), which is the only way to tell "Safari took 534 of 783" apart from "the extension asked for the wrong thing".

Source. src/manifest/dnr.ts (applyDnr), src/runtime/safari-compat-shim.js, 55d8b0e, 78976ab, 479d518.

D3. Enabled rule count over Safari's limit

Quirk. WebKit caps enabled static DNR rules at ~30,000; rules past the cap are silently dropped at load.

Symptom. A ruleset that's fully applied in Chrome is half-applied in Safari, silently.

Handling. applyDnr sums enabled static rules and warns past the guideline so the author can split/trim rather than ship a half-applied ruleset. The overflow itself is left to Safari to ignore.

Source. src/manifest/dnr.ts (SAFARI_STATIC_RULE_GUIDELINE, applyDnr), 55d8b0e.

D4. Blocking webRequest can't block in Safari

Quirk. WebKit decides each network request before extension JavaScript runs and ignores the blocking return value. The decision happens below JS, so no shim can change it.

Symptom. Ad/content blockers built on blocking webRequest (e.g. full uBlock Origin) cannot block subresource requests in Safari: {cancel: true} for a script, image or frame is ignored and the request goes out. The extension still installs and its cosmetic/element-hiding features still run.

One class of blocking survives, because it never depended on the return value: a blocker that enforces a main-frame decision imperatively through the tabs API keeps working. uBlock's strict blocking is that shape — onBeforeRootFrameRequest calls vAPI.tabs.replace(tabId, 'document-blocked.html?…') and returns {cancel: true} only in addition (js/traffic.js), so on Safari the observation-only webRequest event still fires, the filter still matches, and the tab is replaced with uBlock's interstitial. Verified live: a w3schools ad's cookie-sync navigation to sync.richaudience.com was strict-blocked by ||richaudience.com^ from Peter Lowe's list. It is a soft block — the request leaves before the tab is replaced — and it covers whole-page navigations only.

Handling. The analyzer detects this class of extension and reports it as an error (not silently). The remediation: convert the extension's declarativeNetRequest build instead, e.g. uBlock Origin Lite (uBOL), because Safari honors DNR rulesets, so uBOL blocks for real once converted.

Source. src/analyze/analyze.ts (blocking-webRequest flag), README "Limitations", dbe3442, 644e97e.

D4a. webRequest labels iframe document loads main_frame

Quirk. Safari delivers every frame's document load to webRequest with type: "main_frame""sub_frame" is never reported. Measured live (macOS 26, probe extension): an iframe load arrives as {type: "main_frame", frameId: 30064771073, parentFrameId: 4294967298, tabId: <real>}, where frameId is WebKit's raw 64-bit FrameIdentifier and parentFrameId matches no frameId the API ever reports; a true top-level load arrives as {type: "main_frame", frameId: 0, parentFrameId: -1}. webNavigation.onCommitted for the same frame reports parentFrameId: 0 correctly, so the mislabel is specific to webRequest. Safari also omits Chrome's initiator, frameType, and originUrl fields.

Symptom. Any bundle that gates top-level-navigation logic on type === "main_frame" fires it for every ad iframe on the page. uBlock Origin's strict blocker is exactly that shape (onBeforeRequest routes main_frame to onBeforeRootFrameRequest, which replaces the tab with document-blocked.html through the tabs API — see D4), so visiting w3schools.com got the whole tab replaced by the interstitial over an ad iframe's cookie-sync navigation to sync.richaudience.com. In Chrome that sync stays invisible inside the iframe.

Handling. The webRequest sanitizer's listener wrapper normalizes the Chrome-impossible shape: type === "main_frame" with a nonzero frameId (Chrome's main frame is always frameId 0) is delivered to the listener as "sub_frame" on a shallow clone; everything else passes through with object identity preserved, and the native details object is never mutated. parentFrameId is left as Safari gave it — the real parent cannot be resolved at onBeforeRequest time, and webNavigation (which bundles use for frame hierarchy) already reports it correctly.

The first two versions of this fix looked correct and still lost, and the reason is a WebKit behavior worth knowing on its own. WebKit materializes each webRequest event as a lazy wrapper object it holds only weakly: patch addListener as an own property on the instance and the patch lives exactly as long as that wrapper. Measured live inside a converted uBlock background: all nine events audited sanitized at init and native again five seconds later, with chrome.webRequest itself unchanged the whole time. Pinning the patched wrappers in a shim-lifetime array was not enough either — the pin keeps our wrapper alive, but WebKit's weak wrapper cache can still hand a later property access a fresh event object with no expando, which is exactly what uBlock's post-boot registrations got: it registers onBeforeRequest at boot (patched wrapper, normalized — which made the bug look fixed) and onResponseStarted seconds later once filter lists load (fresh wrapper, native addListener, raw main_frame), and that second listener's own strict-block path (onResponseStartedonBeforeRootFrameRequest, traffic.js) replaced the tab. Blocked 5 of 5 loads on the pinned build. The durable fix is to patch addListener (and removeListener/hasListener) on the shared event prototype, which fresh wrappers inherit from; the RequestFilter shape (args[1].urls an array) keeps the prototype wrapper transparent for non-webRequest events, and filterless registrations are still covered by the per-instance patch while it lives. Verified: 6 of 6 w3schools loads clean with 40 sync dispatches observed normalized during them. Any shim patch that installs an own property on a lazily-materialized WebKit API object has this failure mode — anchor on the prototype.

Source. src/runtime/safari-compat-shim.js (the webRequest sanitizer's normalizeListener); test test/webrequest-frame-type.test.js.

D5. Third-party extension origin → auth/CORS failures

Quirk. Safari treats the safari-web-extension://<uuid> origin as third-party to the backend. An in-browser credentials:"include" fetch is stripped of the site's cookies (ITP) → the backend answers 401. Worse, an httpOnly session cookie (e.g. Grammarly's grauth) is invisible to document.cookie, so even forwarding document.cookie doesn't authenticate. And api.anthropic.com's CORS gate keys on sec-fetch-site, a browser-controlled forbidden header JS can't set and Safari won't let DNR modify.

Symptom. Backend calls return 401; a CORS-bypass ruleset can't be shipped (it would need to set sec-fetch-site, and Safari's DNR never applies header rules at all per D1).

Handling. viaduct routes blocked backend requests through an out-of-process native-host proxy (SafariWebExtensionHandler), which sets the Chrome Origin server-side. The shim's gatherCookieHeader sources the Cookie header from chrome.cookies.getAll({url}): which reads Safari's real cookie jar including httpOnly cookies (works from popup/bg, both of which hold the cookies permission), and carries it through the proxy message, falling back to document.cookie only when the cookies API is unavailable. Requires the nativeMessaging permission; the analyzer notes when it's absent.

Source. src/runtime/safari-compat-shim.js (gatherCookieHeader, proxyFetch, headersToObj), src/manifest/dnr.ts (needsAnthropicCorsBypass), src/runtime/oauth-bridge.ts, 6071347.

D6. Proxied XHR must fire timeout, not error, on a timeout

Quirk (self-inflicted). When a request is replayed through the native-host proxy (D5), the shim synthesizes the XHR's terminal events rather than getting them from the platform. A timeout and a network error are distinct XHR terminal states: a timeout fires timeout then loadend (never error), while a fetch/network failure fires error then loadend. The proxy's timeout path originally routed through the same fail() that fires error, so a proxied request that timed out looked like a network error.

Symptom. Callers that branch on timeout-vs-failure (retry logic, error classification) mis-handle a proxied timeout, they see a network error where there was a timeout, and addEventListener("timeout") handlers never fire (only xhr.ontimeout did).

Handling. fail() takes an isTimeout flag: the timer path fires timeout + loadend; the fetch/network-failure path keeps firing error + loadend. The timeout event is routed through the shim's fire() so addEventListener("timeout") handlers receive it too, not only xhr.ontimeout. Covered by test/xhr-proxy.test.js.

Source. src/runtime/safari-compat-shim.js (XHR proxy fail() / fire()), 2501a8b.


E. Content scripts & world

See Manifest Transform (wirePageWorldMainInjection) and Runtime Shim.

E1. world: "MAIN" and page-world script injection

Quirk. content_scripts with world: "MAIN" are only supported on Safari 18.4+. Separately, Chrome exempts web-accessible-resource scripts from the page's CSP, but Safari does not: so a content script that stages a page-world script via <el>.src = runtime.getURL("x.js") has that <script src="safari-web-extension://…"> refused by a strict page CSP (e.g. YouTube's script-src), and the page-world code never runs.

Symptom. The extension works in Chrome but its MAIN-world logic silently dies in Safari (live: Jump Cutter's MediaSource-clone bridge → no audio analysis → playback stuck at silence speed).

Handling. The analyzer flags world: "MAIN" usage (Safari 18.4+ only). wirePageWorldMainInjection scans all bundled scripts (not just declared content scripts, the injector is often registered dynamically from background code) for the .src = <ns>.runtime.getURL("…js") pattern and re-declares each target as a world:"MAIN" content script, which Safari runs with extension privilege, CSP-exempt, reproducing Chrome's behavior. It mirrors the extension's own content-script match patterns/frame options so the MAIN-world twin runs exactly where the isolated injector would, never broader. The extension's own now-redundant <script> injection is left in place (it fails the CSP harmlessly).

Because those entries are ours and cannot run below 18.4, raiseMinVersionForMainWorld lifts strict_min_version to 18.4 when any of them is present, rather than shipping a manifest that claims 15.4 and quietly does less. Entries the extension declared itself are left alone: that floor is the author's claim to make, and the analyzer's advice for them is to feature-detect and degrade. The OAuth page bridge is also left out, since page-bridge-cs.js re-injects it as a web-accessible <script> when the MAIN-world entry does not run, so it works on older Safari without narrowing who can install the extension. See OAuth Bridge.

Source. src/runtime/shim.ts (wirePageWorldMainInjection, PAGE_WORLD_INJECT_RE), src/manifest/manifest.ts (raiseMinVersionForMainWorld, analyzer), d35e3ad; tests test/main-world-min-version.test.js, test/page-bridge-main-world-fallback.test.js.

E2. cookies.onChanged fires with a null event

Quirk. Safari's native chrome.cookies.onChanged fires with a NULL changeInfo.

Symptom. A listener that destructures const { cookie } = changeInfo throws, and an unhandled throw in the background page kills the whole background (Grammarly: bg.unhandledException → the popup's calls to the bg all time out → "All initialization attempts failed"). The inert stub can't help because Safari does expose the (broken) event, so the stub is skipped.

Handling. The shim wraps cookies.onChanged.addListener (via installOverride, since addListener is non-writable on Safari and a plain assignment would silently fail) to swallow null/undefined events before they reach the listener.

Source. src/runtime/safari-compat-shim.js (the cookies.onChanged guard), 8952cd5.

E3. location.ancestorOrigins reads

Quirk. Safari extension pages are top-level, so location.ancestorOrigins is an empty but present DOMStringList, ancestorOrigins?.[0] is undefined.

Symptom. A trailing string method on that undefined throws at module load, leaving a blank popup (Salesforce Inspector Reloaded).

Handling. guardAncestorOriginsAccess guards the [0] read in staged source so the call sees "". The receiver walk-back class is restricted to a dotted-identifier Location chain (location, window.location, self.location, …) — it excludes ) and ], so the match can't start mid-expression at the ) of a foo().ancestorOrigins… and emit unbalanced parentheses in the wrapped output. That's latent (real bundles never call through to ancestorOrigins), but a token rewrite must never be able to produce invalid JS (92fa2ee).

Source. src/convert.ts (guardAncestorOriginsAccess), src/input/stage.ts (ANCESTOR_ORIGINS_RE), 02c4007, 92fa2ee. Test: test/ancestor-origins-guard.test.js.

E3b. The browser_specific_settings viaduct adds has no gecko block

Quirk. Not Safari's, ours — a hazard the conversion itself creates. strict_min_version has exactly one home, browser_specific_settings.safari, so viaduct adds that object to a Chrome manifest that had no browser_specific_settings at all. A cross-browser bundle then finds the container present and reads the Firefox block out of it, and the idiom in the wild guards only the container:

var self_hosted = !!(manifest.update_url ||
  (manifest.browser_specific_settings && manifest.browser_specific_settings.gecko.update_url));

On Chrome the && short-circuits on a missing container and .gecko is never touched. After conversion the container exists, gecko does not, and the read throws.

Symptom. Depends entirely on where it sits. At the top level of a background script it kills every statement after it: Bypass Paywalls Clean (background.js:260) registered none of its 802 DNR session rules, injected no content scripts, and looked like a converter that had done nothing — no console error visible unless you open the background inspector. Inside a promise it is a silent unhandled rejection that drops just that feature (the same bundle's update check, background.js:1843).

Handling. Two independent changes, because the crash needed both halves:

  1. update_url is no longer deleted from the manifest. Safari never fetches it, but deleting it is what made the expression above fall through to the Firefox operand in the first place, and a self-hosted bundle reads the field back at runtime. (Same reasoning as version_name, which viaduct has always kept.)
  2. guardGeckoSettingsAccess optional-chains the block in staged source: bss.gecko.xbss.gecko?.x. undefined is the honest answer on Safari and keeps a bss.gecko presence test false. Injecting an empty gecko: {} into the manifest would have been the smaller change and is the wrong one: it flips that presence test to true and sends the bundle down its Firefox path.

Source. src/manifest/manifest.ts (transformManifest, the keep-update_url comment), src/input/stage.ts (GECKO_SETTINGS_RE, guardGeckoSettingsAccess), src/convert.ts. Tests: test/manifest-update-url-kept.test.js, test/gecko-settings-guard.test.js.

E4. A content-script group is evaluated twice in one isolated world

Quirk. Safari re-evaluates a document_end / document_idle, all_frames content-script group a second time into a world that already ran it. An about:blank / about:srcdoc subframe (and some same-origin navigations) shares its parent frame's isolated world, and Safari fires the injection for that frame again, so every file in the group is evaluated twice in one world. Chrome gives each such frame its own world and never does this.

Symptom. The second evaluation is fatal only where a file has a top-level const / let: re-declaring a lexical binding throws "Can't create duplicate variable" and aborts the rest of the group, so the extension's content side dies (live: TWP, const twpI18n in lib/i18n.js then const startMark in contentScript/pageTranslator.js, the two errors reported). viaduct's own shim/polyfill survive the same double-eval only because they declare with var, whose re-declaration is a silent no-op. The crash surfaced only after the background stopped dying at load (A1 / onUpdateAvailable), which had masked it.

Handling. idempotentContentScriptGlobals demotes each column-0 (top-level) const/let to var in the files an isolated-world content_scripts entry references. They are module-scoped singletons assigned once; var keeps them global, which they must stay (TWP's files share twpI18n / startMark across the group), and makes the redeclaration harmless, with no change on the normal single-eval path. Scoped to content-script files only, background/popup/library files and world:"MAIN" scripts are untouched; minified bundles (one line, already IIFE-wrapped) are naturally skipped since only column-0 declarations match. Top-level class is left as-is (no real content-script bundle declares one bare at top level; that would need AST work). Paths are normalized like collectReferencedPaths (^\.?/, \/) so /lib/x.js, ./lib/x.js, and backslash forms all resolve.

Source. src/input/stage.ts (idempotentContentScriptGlobals, TOPLEVEL_LEXICAL_RE), src/convert.ts, 1f22a10, 2dc30ca.

E5. viaduct's polyfill overwrote an extension that ships its own browser-polyfill.min.js

Quirk. Not a Safari behavior but a viaduct-side collision with the same fatal shape as E4: an extension that bundles its own browser-polyfill.min.js had that file replaced by viaduct's webextension-polyfill build. writePolyfill copied viaduct's copy to the fixed name browser-polyfill.min.js in the stage dir unconditionally, clobbering the extension's own.

Symptom. uBlock Origin (1.71.0, MV2) installed, enabled, was granted *://*/*, and rendered its toolbar badge from the background, but no content script ran on any page: no cosmetic-filter <style> was injected and nothing was hidden (#10). uBlock's vapi.js is the first of its own scripts in the content-script list and is built against uBlock's polyfill; loaded against viaduct's different build it threw, and because a throw aborts the rest of a content-script group, contentscript.js (the cosmetic/element-hiding engine, listed after it) never ran. The background badged because its failure path was separate.

Handling. writePolyfill no longer overwrites an existing browser-polyfill.min.js. When the stage already contains one (the extension ships its own), viaduct writes its copy under viaduct-browser-polyfill.min.js and returns that name, so the manifest prepend, HTML injection, and background page all reference viaduct's copy while the extension's file stays byte-for-byte intact. webextension-polyfill is idempotent (it no-ops once browser exists), so loading both in sequence is harmless. The alt name is added to wirePageWorldMainInjection's own-files set so it isn't mistaken for a page-world rewrite candidate.

Source. src/runtime/shim.ts (writePolyfill, POLYFILL_ALT_FILENAME, wirePageWorldMainInjection), 622001c.


E6. Native content-to-background messaging only survives on the unwrapped runtime

Quirk. Safari delivers a content script's native runtime.sendMessage to the background, and lets the background's sendResponse travel back, but only while both sides are registered on the real, unwrapped chrome/browser runtime. The shim's storage relay (for extension-page-to-background messaging, which Safari genuinely drops) republishes chrome/browser as a Proxy. Wrapping a content script's runtime in that Proxy makes Safari quietly stop delivering its messages, and the relay can't stand in because a content script's storage.local is a separate partition from the background's, so a relayed request written on the page side is never seen by the background.

Symptom. A converted extension whose content script sends a message to its background and waits for the reply hangs with no error (live: TWP - Translate Web Pages, whose page translator sends {action:"translateHTML"} and awaits the response, so no page ever translated). The extension pages still work, only the content-to-background path is dead.

Handling. The relay block bails out at the top when location.protocol is not an extension scheme, so a content script keeps its native, unwrapped runtime and uses Safari's native transport. For the same reason the compat shim is no longer prepended to an MV2 background.scripts list, wrapping the background's own runtime broke the native content-to-background delivery it needs to receive.

Scoping the relay to extension pages was not enough, because the background is an extension page. Wrapping it there broke the same inbound path from the other end, and did so for every MV3 conversion: see E7.

Source. src/runtime/safari-compat-shim.js (relay IIFE location.protocol guard), src/manifest/manifest.ts (no MV2 background.scripts injection); tests test/sendmessage-relay-scope.test.js, test/mv2-background-shim.test.js.


E7. Swapping an extension page's global api object stops all message delivery to it

Quirk. Replacing an extension page's global chrome/browser with a Proxy makes Safari stop delivering content-script messages to that page entirely. It is not a registration problem: a listener attached to the pristine native runtime.onMessage before the swap stops receiving too, so Safari resolves deliverability against the page's current global at dispatch time. Replacing runtime.onMessage in place fails identically, so the event object's identity matters just as much as the global's. Wrapping that event's addListener is fine.

Symptom. Every converted MV3 extension whose content scripts message the background, silently. Nothing throws, nothing logs, and extension-page messaging keeps working, so the popup behaves perfectly while the content-script path is dead. Live: Cloaked - Privacy & Password Manager, whose login tokens are read from the page by a content script and sent to the background, so signing in could never complete.

Handling. The relay no longer swaps the global on extension pages. It wraps runtime.onMessage.addListener in place to record listeners, and overrides runtime.sendMessage in place, leaving both the global and the event object untouched. Where a host refuses the in-place patch it falls back to the old swap, since a page with no relay is worse than one with degraded native delivery. Content scripts keep the swap: it is harmless there and carries the runtime.id spoof (B2), which an in-place patch cannot apply to a frozen slot.

Cost. On extension pages where the in-place patch succeeds, runtime.id is no longer spoofed. That affects native-host install matching only.

How it was found. Six converted builds of a three-file probe extension, differing one variable at a time: no shim, polyfill only, shim in the content script only, shim in the background with the swap disabled, and the full shim. Only the builds that swapped the background's global were silent. A unit test now pins both identities, test/extension-page-api-identity.test.js, because nothing about this failure is observable from inside the extension.

Source. src/runtime/safari-compat-shim.js (applyRelay, captureListeners).


E8. cookies.get answers with a different cookie than the request will send

Quirk. When two cookies share a name on nested domains, Safari's cookies.get({name, url}) can hand back the parent-domain one while the network stack sends the host one first. Chrome breaks that tie by longest path, then earliest creation time, which returns the site's own cookie; Safari exposes no creation time and picks differently.

Symptom. An extension reads a session value and then makes a request that carries a different value for the same cookie, so the server rejects its own token. Live on Kondo: LinkedIn's real JSESSIONID lives on .www.linkedin.com, and Kondo mints a placeholder one on .linkedin.com whenever it can't find a session (which is what happens the first time it runs before LinkedIn has been opened in Safari). From then on cookies.get({name:"JSESSIONID", url:"https://www.linkedin.com/"}) returned the placeholder, every voyager call went out with a Csrf-Token contradicting its own Cookie header, and LinkedIn answered 403 CSRF check failed — permanently, because the junk cookie outlives the session that caused it. Verified from Kondo's own error payload: two JSESSIONID cookies in the jar, .www.linkedin.com = "ajax:-5864878726027283640" and .linkedin.com = "ajax:69910599375289214683", and the token sent was the second.

Handling. The shim wraps cookies.get to re-resolve through getAll({url, name}) and, when more than one candidate comes back, return the one the server will see first (RFC 6265 order: longest path, then the most specific domain). A single candidate, an incomplete query, or a host whose getAll isn't promise-based falls straight through to the native get, so nothing changes where there was nothing to disambiguate.

Source. src/runtime/safari-compat-shim.js (the cookies.get order wrap); test test/cookies-get-precedence.test.js.

E9. runtime.sendMessage into a suspended background never settles

Quirk. A converted MV3 background is a non-persistent page, and Safari suspends it. A sendMessage aimed at a suspended background is not reliably delivered, and it is not reliably rejected either: the returned promise can stay pending for good. This is the same corner the runtime.connect wrapper works around, where Safari either throws "No runtime.onConnect listeners found" or hands back a port that disconnects a tick later.

Symptom. Any one-shot handshake into the background is lost with no error to show anyone. The OAuth page bridge is the clearest case: page-bridge-cs.js relayed the page's message once, the promise never settled, and the page waited out its own timeout while the extension ran normally the whole time. The only trace was a [bridge-cs] line on the page console, which points away from the real cause.

Handling. Do not treat one send as delivery. The relay probes with { __bridgePing: true } that the SW-side polyfill answers itself, abandoning each attempt after 800ms rather than awaiting it, three times over sendMessage and then three times over the shim's chrome.storage.local mailbox. The payload crosses once, over whichever transport answered. See OAuth Bridge for the protocol and the reasoning behind sending the payload only once.

Source. src/templates/page-bridge-cs.js, src/templates/identity-polyfill.js; tests test/bridge-background-wake.test.js.

E10. runtime.sendMessage rejects with "Tab not found." while a page unloads

Quirk. Safari rejects runtime.sendMessage with "Invalid call to runtime.sendMessage(). Tab not found." when it cannot resolve the sender's tab. That happens routinely rather than exceptionally: the page is unloading, the tab is being discarded, or the extension has just navigated the tab away itself.

Symptom. Teardown gets reported as breakage. A relay that forwards the rejection makes the page-side bridge reject a promise nobody is left to catch, so the console ends a successful OAuth flow with "Unhandled Promise Rejection: Invalid call to runtime.sendMessage(). Tab not found." claude.ai keeps that exact string in its own error ignore list, which says how common it is. The same failure also invites the wrong diagnosis, since a closing tab looks identical to an unreachable background.

Handling. Match the message (/tab not found|no tab with id|frame not found|invalidated/i), treat it as teardown, and set a sticky flag. The relay then stops, posts nothing back into the departing page, and suppresses every later diagnostic, because all of them would be describing a page that no longer exists.

Source. src/templates/page-bridge-cs.js (TAB_GONE_RE, tabGone); test test/bridge-background-wake.test.js.

E11. A site's frame-ancestors list cannot name a Safari extension

Quirk. A site decides who may embed it, and the list is written for Chrome. claude.ai serves the URL its own extension embeds in the side panel with frame-ancestors 'self' chrome-extension://<their id> chrome-extension://<their second id>. A converted extension page is safari-web-extension://<per-install UUID>, which is not on that list and cannot be added to it: the UUID differs per install and the header comes from the site's server. Safari computes the ancestor chain itself, so nothing on this side reaches the check, and every ancestor is tested rather than just the immediate parent.

Symptom. The panel is blank. The only trace is one WebKit line, Refused to load https://… because it does not appear in the frame-ancestors directive of the Content Security Policy, in a console most people never open. Measured across claude.ai's routes, /cic/* is the only path that admits any extension at all and it names Chrome ids; every other app route sends frame-ancestors 'self', which is stricter still.

Handling. None available, and worth being plain about why. Safari's declarativeNetRequest accepts a header rule and then never applies it, so no rule can strip the site's header (see D1 and Limitations and FAQ 5). Fetching the document and re-serving it from the extension origin defeats itself: session cookies on a site like this are SameSite=Lax, so the copy renders logged out. The native-host retry covers blocked API requests, not a frame navigation the browser refuses before any request leaves.

What the shim does instead is stop the failure from being invisible. A refused frame stays on its inherited about:blank, which is same-origin with the extension page and therefore writable, so the shim writes a short explanation into the frame and logs the site, the directive it read back, and the fact that only the site can change it. A frame that really loaded cross-origin reports contentDocument as null, which is what makes it impossible for this to touch a working frame; a frame that rendered anything of its own is also left alone.

Real remediation belongs to the site: a scheme source, frame-ancestors safari-web-extension:, admits any install. Until then, look for a non-embedded mode. The embed is usually a newer opt-in surface with the older native UI still shipping beside it, and the native one authenticates with a token rather than cookies, so it works after conversion. Claude for Chrome is exactly that shape.

Source. src/runtime/safari-compat-shim.js (the blocked-frame explainer); test test/blocked-iframe-explainer.test.js.

E12. A bundle asking whether it IS the service worker gets no for an answer

Quirk. The conversion turns the MV3 service worker into a background PAGE, so ServiceWorkerGlobalScope does not exist there and self instanceof ServiceWorkerGlobalScope is false. This is a different idiom from the typeof window === "undefined" check rewriteBackgroundContextChecks handles (cd58700), and it is used for routing rather than for naming a context.

Symptom. The background takes the branch written for panels and popups, which is to ask the background over messaging. It messages itself, no context receives its own runtime message, and the work never happens. Claude for Chrome routes its OAuth token refresh exactly this way:

if ("ServiceWorkerGlobalScope" in globalThis) return readAndRefreshLocally();
const r = await chrome.runtime.sendMessage({ type: "check_and_refresh_oauth" });

Its startup re-auth is gated on the same question and returns early, so the one path that recovers a signed-in state never runs. Its tokens live in storage.session, which does not survive Safari tearing the background page down, so the user is asked to log in again after a while even though the login itself worked.

Handling. A build-time rewrite is wrong here: the module that asks is usually shared with the panel, where the answer must stay no, or two contexts race each other over a single-use refresh token. viaduct-sw-lifecycle.js answers at runtime instead, defining a ServiceWorkerGlobalScope constructor whose Symbol.hasInstance recognizes the global. That file is loaded by the generated background.html and nowhere else, so every other extension page still says no and still asks the background, which keeps one refresh in one place. A real worker scope is left untouched.

Source. src/templates/viaduct-sw-lifecycle.js; test test/sw-global-scope-identity.test.js.

E13. A silent launchWebAuthFlow has no silent surface to run in

Quirk. Chrome runs launchWebAuthFlow({interactive:false}) with no visible UI and abandons it as soon as the provider would need the user. Safari has no equivalent surface, so the polyfill drives a real tab.

Symptom. Driven the obvious way, with a focused tab on the interactive 120s ceiling, every silent refresh steals focus and leaves a stray tab parked on a login screen long after the caller has given up and shown its own login prompt. Callers race these on short timers: Claude allows 5s through timeoutMsForNonInteractive, and 15s of its own around it.

Handling. Honor the contract. A non-interactive call gets a background tab (active:false) rather than a focused one, and abortOnLoadForNonInteractive, which is Chrome's default, ends the attempt the moment the auth page finishes loading somewhere other than the redirect target, since that is the provider saying it needs the user. The tab is closed on every exit. An interactive call is unchanged, because it has to be visible.

The caller's deadline is spent on the provider alone. In Chrome that is all it ever pays for; here it would also be paying for a tab being created and Safari loading the authorize page cold through whatever edge sits in front of it, and Claude's 5s budget is routinely gone before the provider is even asked. The redirect then lands after the attempt was abandoned, the caller falls back to an interactive login, and the user sees a tab flash past and a login screen for no reason. Setup gets its own 8s allowance, the caller's window starts when the auth page first navigates, and a 20s ceiling bounds the whole attempt because callers race timers of their own.

Source. src/templates/identity-polyfill.js (launchWebAuthFlow); test test/silent-reauth-flow.test.js.

E13a. The auth tab's navigations are never reported

Quirk. Safari delivers no webNavigation event to a listener the background page added after it finished evaluating. launchWebAuthFlow used to open its tab and then attach its own onBeforeNavigate / onCommitted / onCompleted / onErrorOccurred listeners, which is the natural shape and works in Chrome. In Safari those listeners hear nothing, ever.

Symptom. Every silent re-auth ended in "tab never navigated" with an empty navigation list, 8s after the tab was opened, while Safari's own history showed the authorize page had loaded fine. Measured with the background page provably alive: a 2s heartbeat written to storage.local kept ticking through the whole attempt, so this is not a page being torn down. The extension therefore never recovered a session it was perfectly able to recover, and Claude for Chrome — whose tokens live in storage.session and whose only recovery is this flow — asked for a login on every Safari launch, opening and closing a stray tab each time it tried.

Handling. Register the observers once, at polyfill load, and route each event to whichever in-flight flow owns the tab. Three sources feed that router, because Safari's coverage varies: webNavigation, tabs.onUpdated, and a 250ms poll of tabs.get inside the flow. The poll is the one that actually reports the redirect on Safari 18 — tabs.get answers with the URL a navigation left behind even when no event was delivered — and the events stay wired as the fast path where a browser does send them. The redirect-target match is unchanged (exact prefix plus a boundary character), so a look-alike host still cannot complete a flow, and a poll cannot resolve one either.

Outcomes are also recorded now. Every flow appends one redacted entry to __c2sAuthLog in storage.local — silent or not, whether it redirected, why it did not, how long it took, and the scheme/host/path of each navigation it saw, never a query or fragment. Timeouts route through the same exit, which they previously bypassed: an attempt that quietly ran out of time left no trace of itself anywhere, and that was the one failure mode most in need of a record. See Testing and Debugging.

Source. src/templates/identity-polyfill.js (routeNav, the per-flow poll); tests in test/silent-reauth-flow.test.js.

E13b. Neither windows.create({state:"minimized"}) nor off-screen geometry hides a window

Quirk. A silent flow needs a surface the user does not see. Safari ignores state: "minimized" on windows.create — the window comes up ordinary and unminimized — and it clamps an off-screen popup back onto the display. Both were measured through the accessibility API: the window was reported unminimized at 0,33 for the ~2.5s the flow lasted.

Handling. There is no invisible surface, so the flow keeps using a background tab, which at least stays inside the window the user already has. What actually removes the churn is not needing the flow: see E14a, the session mirror. runtime.onStartup never fires for a converted background either (also measured), so there is no browser-start hook to hang anything on.

Source. src/templates/identity-polyfill.js (the comment above tabs.create records both measurements).

E14. storage.session is per-context, so state the background owns is invisible

Quirk. Chrome keeps storage.session in the browser: every extension context reads and writes one map, and it outlives a service-worker restart. Safari gives each context its own space (Apple's own developer-forum answer, and measured behavior), so a value the background wrote is simply not there when a panel looks for it.

Symptom. The ordinary MV3 pattern of state the background owns and a panel reads returns nothing, with no error to explain it. Claude for Chrome does exactly this after every token check:

const r = await chrome.runtime.sendMessage({ type: "check_and_refresh_oauth" });
if (r?.isValid) return chrome.storage.session.get("accessToken");   // in the panel

The background answers "valid", the panel reads its own empty space, finds no token, and shows a login screen. Claude moves its OAuth tokens into session storage deliberately so they never reach disk, which is what puts them on the broken side of this.

Handling. Give the extension one owner. The background page keeps using the native store, and every other extension page forwards get/set/remove/clear to it over runtime.sendMessage, which on an extension page is the shim's own storage relay and therefore works. When the owner cannot be reached the call falls back to the local space, so it is never worse than the behavior it replaces.

Two guards worth knowing. The owner registers its listener after the storage relay installs, because the relay mirrors mailbox records only into listeners it recorded itself. And the proxy is skipped when storage.session is the same object as storage.local or storage.sync: patching an aliased object would make the first read travel over the mailbox into itself until the stack ran out.

Source. src/runtime/safari-compat-shim.js (the session-store owner); test test/session-store-sharing.test.js.

E14a. The owner's storage.session does not survive its own restart

Quirk. The other half of E14, and the more damaging one. Chrome keeps storage.session for the life of the browser session, so a service worker that restarts reads back everything it left there — that durability is the whole reason an extension is willing to keep live state in it. Safari hands every page load its own session store, so a converted background comes back empty every time Safari suspends it, which is within seconds of going idle.

Symptom. Measured: one background page stored a valid OAuth token, and the next page — 10 to 30 seconds later — read accessToken absent, refreshToken absent, along with the backoff record the extension had written to rate-limit itself. So every wake concluded the session was gone, answered the panel with a login screen, and opened another silent re-auth tab. Users see it as "it forgets my login" plus tabs opening and closing on their own; a panel opened during the ~2.5s recovery still draws its login screen, and only shows the account after it is closed and reopened.

Handling. Mirror the owner's session store into storage.local under __c2sSessMirror, and restore it when the background comes back. set/remove/clear on the background keep the mirror in step; get holds until the restore has landed, since a read answered from the empty store before the restore is exactly the bug. The mirror is stamped and expires after 12 hours, which bounds how long values the extension kept off disk sit there; past the deadline the extension gets the empty store Chrome would give it at the start of a browser session and takes its own recovery path. A browser-start signal would be better than a deadline, but Safari never fires runtime.onStartup for a converted background (measured), so there is none to use.

This is a deliberate trade, and worth stating plainly: an extension that put a secret in storage.session did so to keep it out of a file, and within the mirror's window it is in one — the same file storage.local already writes to, inside the app's container. Without it, storage.session has no Chrome-like semantics in Safari at all, and for an OAuth bundle the alternative was a login screen and a stray tab on every wake.

Source. src/runtime/safari-compat-shim.js (the session mirror, in the owner branch); test test/session-store-restart.test.js.

E15. Message dispatch resolves through the page's global chrome/browser, so the globals must stay native

Quirk. WebKit does not deliver a runtime message to the listeners a context registered. It delivers to the namespace object it finds on the context's global bindings at dispatch time: enumerateFramesAndNamespaceObjects reads the frame's global browser property (then chrome), casts it with toWebExtensionAPINamespace, and that cast only succeeds on WebKit's own native namespace wrapper. Put anything else there — a Proxy, an Object.create(native) root, a polyfill object — and the frame is skipped without a trace. Listener registration still works through the prototype chain, so Safari's wake bookkeeping (BackgroundContentEventListeners in the extension's State.plist) keeps saying the background listens, and wakeUpBackgroundContentIfNecessaryToFireEvents keeps waking it for messages it will never receive. The UI-process side (WebExtensionContext::runtimeSendMessage) then answers the sender with its default: undefined, no lastError, in under a millisecond.

Symptom. A content script's runtime.sendMessage to the background resolves instantly with undefined and no error, and the background never fires. Live: TWP - Translate Web Pages 10.2.5 (MV3). Its webRequest permission routed it into the shim's url-sanitizer, whose fallback republished global chrome and browser as Object.create(native) roots when Safari's exotic root refused an override of the webRequest slot. Every translateHTML request died as an instant undefined; TWP crashed reading results[i] and no page ever translated — popup open or closed, background fresh or minutes old. A probe listener registered on the pristine native event at shim load received nothing either, which is the tell that separates this from a listener-registration problem.

This is the mechanism behind the 8acef29 bisect ("Safari resolves delivery through the page's current global at dispatch time"), confirmed in WebKit source: Source/WebKit/WebProcess/Extensions/WebExtensionContextProxy.cpp (enumerateFramesAndNamespaceObjects), Source/WebKit/UIProcess/Extensions/Cocoa/API/WebExtensionContextAPIRuntimeCocoa.mm (runtimeSendMessage). Two corollaries worth knowing: the same source shows return true from an onMessage listener is honored (anyListenerHandledMessage), and runtime.connect fails loudly ("no runtime.onConnect listeners found") where sendMessage fails silently, which makes connect a useful oracle when debugging this class of bug.

Handling. The rule is absolute: in any extension context, the shim never reassigns the global chrome/browser bindings to a non-native object. The webRequest sanitizer now patches addListener in place on each native event instance (the move the storage relay already proved on runtime.onMessage), falls back to the shared event prototype when the instance is frozen (freezing an instance never freezes its prototype), and if both refuse it leaves the event native — an unsanitized addListener can throw on one bad pattern, which costs one call; a republished root costs every message the context would ever receive. The relay's own extension-page path already respected this (8acef29), and content scripts never enter the relay at all (E6), so no swap arises there. Two swappers remain in the shim for cases this session could not exercise — the frozen-root thaw at the shim head and the native-messaging root proxy — and both carry the same risk in an extension context; anyone touching them should read this entry first.

Source. src/runtime/safari-compat-shim.js (the webRequest sanitizer), 895e7a3, 8acef29; test test/webrequest-global-identity.test.js.


F. UI & rendering

See Runtime Shim for the injected sizing/theme/download logic.

F1. Blob/data downloads

Quirk. WebKit ignores the <a download> attribute for blob:/data: URLs in an extension page, every anchor/iframe/tabs.create method just navigates or no-ops. The only thing that saves the bytes is a top-level navigation to the URL, which hands off to WebKit's downloader; and WebKit derives the file's extension from the blob's MIME type (the base name can't be set from JS for a blob in an extension page, only the multi-step share sheet can).

Symptom. Chrome's one-click "download this file" produces nothing, or a file with no/wrong extension, in Safari.

Handling. forceBlobDownload reproduces one-click download: with no usable filename extension it opens the original URL synchronously; with a known extension it refetches and re-types the blob to the MIME that matches the intended filename's extension (via c2sMimeForName) so WebKit appends the right suffix, then opens that. The chrome.downloads wrapper defers the popover's close ~350 ms so the async open lands in time. On any fetch error it falls back to the plain open so a download still happens. Never both paths (a double open = double download).

Source. src/runtime/safari-compat-shim.js (forceBlobDownload, c2sMimeForName, the chrome.downloads wrapper), 7acf2ca, bfa7903, 6c2396d.

F2. Dark-mode / color-scheme page injection

Quirk. Chrome renders extension pages light regardless of the OS theme; Safari honors the OS dark mode. A page that relied on Chrome's white default (never setting its own body background/color) gets a dark default text color over Safari's dark window.

Symptom. White-on-white or invisible content, light-only pages go unreadable in Safari dark mode (live: "Chrome extension source viewer" file list invisible; crxviewer source pane). color-scheme:light alone isn't enough: Safari sets the scheme but leaves the canvas transparent and the default text light.

Handling. injectShimIntoHtmlPages detects theme-aware pages (pageHandlesDarkMode: any prefers-color-scheme / color-scheme: in the HTML or a linked local stylesheet, or a <meta name="color-scheme">) and leaves them untouched. A root-absolute href (/css/app.css) resolves from the extension root, not the page's directory, joining it onto the page directory pointed at a file that does not exist, so those stylesheets used to go unread. Every other (light) page gets a floor style, :root{color-scheme:light} plus body{background:#fff;color:#000} without !important: replicating Chrome's UA default so any explicit color the page's own CSS sets still wins.

Source. src/runtime/shim.ts (injectShimIntoHtmlPages, pageHandlesDarkMode, COLOR_SCHEME_MARKER), 7acf2ca.

F2a. One page, two themes: the light floor beat the extension's own dark mode

Quirk. Not every extension declares its theme in CSS. TWP defaults to darkMode: "auto" and, when matchMedia("(prefers-color-scheme: dark)") reports dark, appends html *, nav, #header{color:rgb(231,230,228)!important;background-color:#181a1b!important} from JavaScript; Bitwarden and Tampermonkey ship their theme CSS inside the app bundle. Nothing in the markup or the linked stylesheets mentions a color scheme, so F2 classified those pages as light and applied the floor. (Safari still reports the real OS preference to prefers-color-scheme even with color-scheme:light on the root — verified on Safari 26 — so the extension's dark branch runs regardless.)

Symptom. The page renders in two themes at once. html * matches the body but never the root, so the floor's html background stayed white behind the app's own dark UI: TWP's options page (a centered max-width:1200px body) came up as a dark column with white margins on both sides, and its popup as a dark strip on white.

Handling. The floor paints body, not html. A transparent html propagates the body background to the canvas, so a genuinely light page still gets Chrome's white page, while any rule the extension applies to body — at load or later from JavaScript — wins on source order. Verified on the real TWP options page in Safari 26 with the OS in dark mode: white margins before, uniformly #181a1b after; "Chrome extension source viewer" still renders white with black text.

Source. src/runtime/shim.ts (COLOR_SCHEME_MARKER style); test test/color-scheme-inject.test.js.

F3. Side-panel-as-popup collapses to a tiny window

Quirk. A side-panel/full-height page wired as a Safari action popup carries no intrinsic size, so Safari opens it as a collapsed, tiny window. A height:100% layout also can't fill because the popup has no explicit height to be 100% of.

Symptom. The popup opens as a cramped little box instead of the intended panel.

Handling. injectPopupSizing injects a sizing style so the popup opens at usable dimensions. Critically it's a floor only (auto-sizes to fit content, never overrides the app's own explicit size, fb05734), and side-panel popups additionally get an explicit height so a 100% layout fills (bf77318). Earlier iterations moved from a fixed box to fit-content that survives app CSS (0601904).

A side panel opened this way also arrives without the ?tabId=<n> query a panel page expects, so the shim resolves the active tab and writes the param in with replaceState. That runs for side panels only (064812b). It used to run for plain action popups as well, where Chrome puts no query either, and the invented one broke every bundle that compares its own location.href against getURL(): Honey picks its message service that way and its popup went blank, and it is also where Dark Reader's popup got the ?tabId that B2 strips back off.

Source. src/runtime/shim.ts (injectPopupSizing), src/runtime/safari-compat-shim.js (the panel-doc block), 0601904, fb05734, bf77318, 064812b (and b33698a for full-page sizing); test test/popup-tabid-query.test.js.

F4. contextMenus patterns and contexts Safari's parser rejects

Quirk. Safari's menus.create throws on inputs Chrome accepts, and each throw aborts the caller, silently dropping the menu item. Three rejections are proven live: ftp:// URL patterns ("'ftp:///' is not a valid pattern"), a host component on file patterns ("'file:///' is not a valid pattern" — Chrome treats file://*/* and file:///* as the same "any local file" match), and the MV2-era context aliases ("'page_action' is not a valid context"). TWP hits all three: its updateContextMenu re-creates its items on every tab event, every create threw, and the right-click entries simply never existed.

Symptom. A contextMenus.create with any of these fails and the item is missing; an extension that rebuilds its menu on tab activity floods the unified log with the same exception.

Handling. The shim's create wrapper strips ftp:// patterns, rewrites file://*/… to the hostless file:///… form Safari parses, and maps page_action/browser_action contexts to action — the same aliasing upstream WebKit has since adopted (WebExtensionAPIMenusCocoa.mm). launcher (Chrome OS only) is dropped; an item whose contexts sanitize to nothing is skipped rather than thrown on. (chrome.contextMenus itself works natively on macOS Safari but is absent on iOS, the analyzer notes that separately.)

Source. src/runtime/safari-compat-shim.js (the menus create wrapper), src/manifest/compat-data.ts, 4a5cae9, cf96d63; test test/menus-ftp-pattern.test.js.

F5. The popover cannot scroll its own main frame

Quirk. Safari sizes the action popover from the document and caps it (600px tall on a standard display), and past that cap the frame does not scroll. Measured on Replace AI Translator API in Safari 26: the popover viewport was 360x600, the document 989 tall, and both window.scrollTo(0, 400) and documentElement.scrollTop = 400 left the offset at 0. Chrome scrolls the same popup normally, so nothing in the extension's CSS accounts for this.

Two timing details matter. The frame reports a 1px viewport until Safari has sized the popover, some time after DOMContentLoaded, and the popover height is derived from the document height — so a 100vh cap on the content feeds straight back into the number it was measured against. Applied at 1px, that loop latches and the whole popup collapses to a sliver that never recovers (observed while building the fix).

Symptom. The popup renders correctly and is simply cut off at 600px. There is no scrollbar, the wheel and the trackpad do nothing, no error appears anywhere, and everything below the cut is unreachable. On the extension above that was the whole options group in the Translate tab and most of the provider list in API settings.

Handling. A nested scroller still scrolls, so the shim hands the overflow to <body>: <html> gets overflow:hidden (without it, CSS propagates the body's overflow to the viewport instead of applying it to the body box, css-overflow-3 §3.5, and nothing scrolls after all), and <body> gets overflow-y:auto plus a max-height in pixels, read off the settled frame. The declarations go on element.style rather than into an injected <style>: CSSOM writes are exempt from CSP, and inline !important beats the app's own sheet, which these popups need since they set body{overflow:hidden} for Chrome's scrolling main frame.

It is gated twice, because making <body> a scroll container has a cost — an absolutely-positioned menu that used to grow the popover now scrolls inside it. It runs only on the popover document, and only once the frame has proven it cannot scroll a document that overflows it: a viewport under 80px is treated as not-yet-sized rather than as a verdict, and a frame that moves when probed is latched as scrollable and left alone forever. A popup that fits, or one that already manages its own inner scroller (so the root never overflows), is never touched.

Source. src/runtime/safari-compat-shim.js (the popover scroll-rescue block); test test/popover-scroll-rescue.test.js.


G. Service worker & bundlers

The MV3→background-page conversion (A1) has to reproduce the service-worker environment on a classic HTML page. See Manifest Transform and Runtime Shim.

G1. importScripts must be hoisted as classic scripts (CSP-safe)

Quirk. importScripts() is undefined in a module background page, so the first call throws and aborts SW evaluation before its onConnect/onMessage listeners register. The default extension CSP is script-src 'self' (no eval), so a runtime fetch+eval polyfill can't substitute.

Symptom. The SW dies mid-boot; the popup's runtime.connect() gets "No onConnect listeners found."

Handling. hoistImportScripts/neutralizeImportScripts resolve each importScripts target and emit it as a classic <script> in background.html before the SW module (the imported files are self-contained IIFEs that just need global scope first), then neutralize the calls in the SW to void 0 so the now-undefined global is never invoked. Done depth-first (a hoisted file may itself call importScripts), balanced-paren aware (so importScripts(o.p+o.u(t)) is replaced whole, not truncated at the first )), and receiver-aware (self.importScripts(...), aliased globals). CSP-safe and generic.

Source. src/runtime/shim.ts (hoistImportScripts, neutralizeImportScripts, convertServiceWorkerToBackgroundPage), fc49074, e8830e0.

G1a. A root-absolute importScripts target resolved to nowhere

Quirk. Not Safari's, viaduct's: a bug in the G1 hoist, found on Replace AI Translator API 1.0.21. Its worker sits in src/ and imports its libraries by root-absolute path:

if (typeof importScripts === "function") {
  importScripts("/src/lib/actions.js", "/src/lib/lang.js", "/src/lib/model-pricing.js",
                "/src/lib/providers.js", "/src/lib/stream.js");
}

importScripts resolves each URL against the worker's own location, so a leading / means the extension root. The hoist instead joined every literal onto the worker's directory, and join("src", "/src/lib/actions.js") is src/src/lib/actions.js, which exists nowhere. Unresolved targets are skipped — while the call is neutralized regardless, because an unresolved target must not be left to throw on the undefined global. So the libraries were dropped and the importScripts line that would have loaded them was gone too. Only bites a worker in a subdirectory: for a root-level one, join(".", "/a.js") normalizes to a.js and the bug cancels itself out.

Symptom. background.html lists the shim, the polyfill and the worker, and none of the libraries. The background still boots and still answers, so nothing looks dead: every handler just throws on the first missing global and answers with the extension's own error envelope. Live, the popup asked for its settings and got {ok:false, error:"exception"} back in 11 ms, left state.settings at null, and every later click read a property off it and threw. Picking an API provider did nothing at all, with no error visible in the popup and a background that looked healthy.

Handling. neutralizeImportScripts resolves a leading / from the extension root and everything else from the worker's directory. Worth knowing for the next bug in this area: hoisting and neutralizing are separate decisions, so a resolution failure never announces itself — it silently strips code the extension needs.

Source. src/runtime/shim.ts (neutralizeImportScripts); test test/importscripts-root-absolute.test.js.

G2. Webpack chunk pre-registration & subdir SW polyfill path

Quirk. A dynamic-arg importScripts (webpack worker builds: importScripts(r.p+r.u(id))) can't be hoisted, after neutralization the async chunks would never load and the bundle dies (MetaMask). Separately, a SW living in a subdirectory needs its injected polyfill/shim resolved from the right path.

Symptom. The bundle boots but breaks the moment it needs a lazily-loaded chunk; or the polyfill/shim 404s for a subdir SW.

Handling. When hoisting sees a dynamic importScripts, collectWebpackChunks finds every pure chunk-push file for the SW's own webpackChunk* global and pre-registers them as <script defer> tags after the SW module. Deferred classic + module scripts share one in-order queue, so each chunk pushes through the runtime's wrapped webpackChunk.push and marks itself loaded before the SW's install handler asks for it, the neutralized importScripts is never reached. Only 2-element pushes ([chunkIds, modules]) qualify (a 3rd element is a webpack startup callback that would boot a foreign entry point). The polyfill import path is fixed for subdir SWs.

Source. src/runtime/shim.ts (collectWebpackChunks, CHUNK_PREFIX_RE/CHUNK_SUFFIX_RE, convertServiceWorkerToBackgroundPage), b33698a.


See also

  • Manifest Transform, the manifest rewrite pass (A, B3, D1, D3, F4)
  • Runtime Shim, the injected chrome.* compatibility layer (A5, C, E2, F, G)
  • Analyzer, pre-conversion detection and reporting (B1, C, D4, E1)
  • OAuth Bridge, identity polyfill + native-host proxy (C identity, D5)
  • Build and Install, Xcode packaging, bundle-id verification, Safari registration
  • Limitations and FAQ, what conversion can't fix (D4, D5, identity OAuth, storage.sync, native messaging)

Clone this wiki locally