---
title: "Extension compatibility"
description: "Which Chrome extension APIs Lilo supports, which ones behave differently from Chrome, and what that means for the extensions you rely on."
sidebar:
  order: 5
---

Lilo runs Chrome extensions on WebKit and fills in many of the Chrome APIs WebKit lacks. This page lists only what is missing or differs from Chrome, and the store extensions that were tested: an API not listed here behaves as in Chrome. To install and manage extensions, see [Extensions](/guides/extensions).

The text below comes from the project's compatibility tracker, [COMPATIBILITY.md](https://github.com/Alexays/Lilo/blob/main/COMPATIBILITY.md), and is written for extension developers as much as for people using Lilo. To see whether an extension you use works, go to [Tested store extensions](#tested-store-extensions): its table gives each extension's status. The paragraphs above that table describe how the project tests extensions: names such as `make ext-smoke`, `top-extensions.json` or `RealExtensionSmokeTests` are the project's own test commands and files, which you do not need to use Lilo. If an extension you rely on misbehaves and its problem is not here, [report it](https://github.com/Alexays/Lilo/issues).

## Chrome extensions [#chrome-extensions]

### API differences [#api-differences]

MV3 only, on WebKit's `WKWebExtension` runtime plus Lilo's Gap APIs ([docs/ARCHITECTURE.md](https://github.com/Alexays/Lilo/blob/main/docs/ARCHITECTURE.md#extension-architecture)). This section lists only what is missing or differs from Chrome; anything not listed behaves as in Chrome.

- **Supported as in Chrome:** `action` (badge colors included), `alarms`, `bookmarks`, `contextMenus`, `cookies`, `history`, `idle`, `management`, `offscreen`, `omnibox`, `permissions`, `power`, `runtime` messaging, `scripting`, `search`, `sessions`, `storage` (`local`, `session`, `sync`), `system.cpu`, `system.display`, `system.memory`, `system.network`, `system.storage`, `topSites`, `chrome_url_overrides.newtab`.
- **Missing:** `webRequest` blocking (observe-only, so `webRequest.onActionIgnored` never fires and `handlerBehaviorChanged` has nothing to flush), MV2 extensions, `chrome_settings_overrides` (a search engine extension changes neither the search engine nor the home and startup pages), the `favicon` permission (`chrome-extension://<id>/_favicon/` URLs do not load). `declarativeNetRequest.updateStaticRules` and `getDisabledRuleIds` work by rewriting the ruleset file, since WebKit cannot turn off one static rule.
- **Missing namespaces:** `accessibilityFeatures`, `readingList` (Lilo has no reading list), `ttsEngine`, `webAuthenticationProxy`.
- **Missing members:** `runtime.getPackageDirectoryEntry`. `ChromeSurfaceTests` checks this list, event methods and `browser.*` included, against Chrome's API schemas (`make chrome-surface` refreshes them), with each member's kind and each enum's and constant's value.
- **Content scripts and pages:** `ChromeContractTests` compares what a content script, a main-world script and the page see of `chrome` and `browser` with Chrome's. A content script has no `storage.session` (Chrome's refuses calls until `setAccessLevel` opens it), no `scripting` (`globalParams` and its enums), no `storage.AccessLevel` nor `setAccessLevel`, no `runtime.dynamicId`, no `extension.ViewType` and no `storage.sync.MAX_SUSTAINED_WRITE_OPERATIONS_PER_MINUTE`; its `storage.managed` has `get`, `getBytesInUse` and `onChanged` only, and WebKit adds `i18n.getPreferredSystemLanguages`, `i18n.getSystemUILanguage`, `runtime.getDocumentId` and `runtime.getFrameId`. Neither a content script nor the page has Chrome's `chrome.csi` and `chrome.loadTimes`, and the page has no `chrome` object at all (Chrome's holds `app`, `csi` and `loadTimes`). A main-world script (`world: 'MAIN'`) gets WebKit's `browser.runtime` with `connect` and `sendMessage`, where Chrome gives it nothing without `externally_connectable`.
- **Events that never fire:** `runtime.onConnectNative` (Lilo never starts a native host that connects first), `declarativeNetRequest.onRuleMatchedDebug` (WebKit does not say which rule matched a request), `bookmarks.onChildrenReordered`, `onImportBegan` and `onImportEnded` (nothing in Lilo sorts or imports the bookmarks extensions see), `tabGroups.onMoved` (Lilo reorders no folder), and `notifications.onPermissionLevelChanged`.
- **runtime:** messages and ports to another extension of the profile take the sender the sending extension reports: the tab its page is in, or its content script's tab, frame and url, which its worker relays. A content script reaches another extension only through its extension's worker: none, and its message fails as unreceived; a worker that does not listen to `runtime.onConnect`, and its port disconnects at once. A content script callback learns of no failure (its `runtime.lastError` stays WebKit's). A content script in a frame of an extension page no tab shows (an offscreen document's iframe) messages through that page: its listeners do not hear it, receivers get the page's sender with the frame's `url` and `origin` (its `frameId` and `documentId` are the page's), and nothing reaches the extension when its `web_accessible_resources` expose Lilo's `_lilo/` files to web pages. A port to another extension disconnects when either end disconnects, its extension unloads or the pages holding its other end go away, not when the worker holding it stops. `tabs.connect` reaches the extension's own page only where it is the tab's top page, not one framed in a web page or a frame named by id, and also reaches another tab showing the same url; it disconnects when that page goes only if no other page of the extension listening to `runtime.onConnect` is open. A port `browser.runtime.connect` opens to a page in a tab from a context listening to `runtime.onConnect` nearly always disconnects at once (WebKit). A `tabs.sendMessage` nobody answers in a tab still loading one of the extension's pages resolves, as if the page listened, instead of failing as unreceived. A message to an extension whose worker hears it without answering fails with Chrome's "message port closed" error even if one of its pages would answer later. A message posted before the port connects on a port `browser.runtime.connect` opened, between extension pages and the worker, arrives twice; on one `chrome.runtime.connect` opened, it arrives once. `onExtensionLoaded` reports `startup` after a Lilo update, never `browser_update`, and never `reload`. A web page an extension's `externally_connectable` names gets `chrome.runtime` with `sendMessage`, `connect` and `lastError` only (claude.ai hands the Claude extension its sign-in this way). An update waits for a running worker to stop, as in Chrome, but not for the extension's open pages. A port the worker opens names the extension's origin as its sender's `url`, where Chrome names the worker's script. `runtime.setUninstallURL` succeeds and does nothing: no page opens when the extension is removed.
- **Private spaces and profiles:** an extension runs in private spaces and sees their tabs only once allowed there (Chrome's incognito access); their downloads and closed tabs are never exposed. A manifest's `incognito` `split` works as `spanning`: one worker and one storage serve private spaces and the rest. Each profile has its own extensions, cookies and storage; an extension whose profile is not the active one can only `downloads.search`, and `chrome.sessions` never lists another profile's tabs.
- **Background worker:** every extension is granted `nativeMessaging`, for the worker's channel to Lilo; native hosts still require it declared or granted on request: without it, `connectNative`, which Chrome then leaves out, opens a port that disconnects on its first message, without `runtime.lastError`. After an API call, or an event of an API Lilo adds (bookmarks, notifications, idle...), an idle worker unloads 20 to 50 s later instead of 30 s. A worker that only receives messages on a port another context opened stops 2 to 2.5 minutes after it last posted one, where Chrome keeps it while messages arrive; one holding a `connectNative` port keeps running while its host sends messages, where Chrome keeps it as long as the port is open. WebKit runs the pending promise callbacks while `importScripts` loads a script the worker had not loaded yet, where Chrome runs them once the worker's top-level code is done: an extension that marks the end of its startup with a promise callback and imports a script before its listeners takes the rest of its startup for late. Lilo skips the import of an empty script, which covers Tampermonkey. The worker's `WebSocket` is Lilo's (WebKit's freezes the worker): it sends no cookies, its `Origin` is the extension's, `bufferedAmount` stays 0, no extension is negotiated, and a socket closes when the worker that opened it stops. The worker is registered for its own directory, not the extension root, and extension pages are not its clients: `clients.matchAll()` lists the pages Lilo finds open (popup, side panel, tabs and windows, but no offscreen document and no frame), each with its url as `id` and without `focused`, `visibilityState`, `focus` or `navigate`, and a message posted to one reaches every page showing that url. In a page outside that directory `navigator.serviceWorker.ready` resolves with the registration Lilo finds or, in a page WebKit shows none (one in the worker's process), with a stand-in that only carries messages to the worker (no `pushManager`, `showNotification` or `update`). Lilo relays a page's `postMessage` to the worker, and the ports it transfers, as a `message` event from a `WindowClient`; its `waitUntil` extends nothing. A `fetch` with `credentials: "include"` from an extension page or the worker, and an extension page's asynchronous `XMLHttpRequest` with `withCredentials`, to a host the extension may access go through Lilo, so they carry the site's `SameSite` cookies as in Chrome; such an `XMLHttpRequest` gets its response whole (one `progress` event, no partial `responseText`), reports an empty `statusText` and fires no `upload` event. A synchronous `XMLHttpRequest`, or one with `responseType` `document`, still leaves those cookies out. A `CryptoKey` the worker stores in IndexedDB, as the value or one of its fields, is stored as its material, unwrapped as in Chrome, and read back as a stand-in `crypto.subtle` uses as the key, in the worker and its pages alike (WebKit would stall the worker for good, which stopped 2FAS Auth's first setup); one nested deeper throws `DataCloneError` in the worker (a page stores it as WebKit does, which the worker then stalls reading), a key read back arrives as a plain object once posted or cloned, and a `postMessage` of a key from the worker still stalls it.
- **Extension manifest:** `web_accessible_resources` entries naming `extension_ids` grant nothing. An `externally_connectable` with neither `matches` nor `ids` lets every extension connect, where Chrome lets none. An `externally_connectable` match pattern naming a port matches every port of its host. The sites `externally_connectable` matches are granted to the extension as host permissions, which the install prompt lists (`<all_urls>` grants every site), where Chrome grants no access through it. An `externally_connectable` wildcard on a public suffix of the list's private section (such as `*://*.netlify.app/*`) is dropped, so those sites cannot message the extension: WebKit refuses the pattern, where Chrome accepts it. `runtime.getManifest()` returns the manifest Lilo loads: `background.service_worker` is the worker's path from the extension root (`js/sw.js` for `./js/sw.js`); `host_permissions` and `optional_host_permissions` end in `/*` (Chrome ignores their path, so Okta's `https://*/` reaches every page); an empty `_execute_action` command, `content_security_policy` or `content_scripts`, MV3's `_execute_browser_action` and `_execute_page_action` commands, those `web_accessible_resources` entries and an `externally_connectable` entry with no `matches` nor `ids` are left out; a missing or empty `description` reads `" "`; shortcut keys lose the whitespace around them; content script `file://` patterns read with Chrome's empty host (`file:///*` for `file://*`); and `externally_connectable` reads `*://*/*` for `<all_urls>` and patterns without their port. The pages `sandbox.pages` lists are served as the extension's other pages: in its origin, with `chrome.*` and the `content_security_policy.extension_pages` policy (by default `script-src 'self'`), so `eval`, `new Function` and inline scripts are refused there, where Chrome gives them `content_security_policy.sandbox` in an opaque origin without extension APIs. Resources whose `web_accessible_resources` entry sets `use_dynamic_url` are served under the extension's fixed id, so a web page can tell the extension is installed.
- **Tabs and windows:** From the start of a browser import until its screen closes, the tabs an extension opens with `tabs.create` or `runtime.openOptionsPage` open in the background, so the welcome pages of the extensions it reinstalls leave the setup on screen. A `tabs.create` or `windows.create` of Chrome's `chrome://extensions` page opens Lilo's extension settings instead (unless `active` or `focused` is false, or during that import), on the extension its `?id=` names, or on the calling extension's details page, which holds its shortcuts, for `chrome://extensions/shortcuts`; it opens no tab and returns the window's active tab, or the window, neither in a private Space the extension may not see. A tab `tabs.create` opens in a Space no window shows, in the background or while the window shows a private Space the extension may not see, stays unloaded until a window shows that Space. `tabs.highlight` only activates the first tab: Lilo has no multi-tab selection, so `highlighted` is the active tab. `tabs.setZoom` changes the tab only, never its origin. Split views are Lilo's split folders: tabs carry no `splitViewId`, and splitting two tabs of a group takes them out of it. `windows.onBoundsChanged` fires up to half a second after a move or resize ends. `tabs.discard` requires a tab id and never discards the tab on screen; a discarded tab's `goBack`/`goForward` fail. An opener (`openerTabId`) is kept for the session only. While a window shows a tab that is not a web page (a terminal or diff tab, a locked folder's tab, or at launch until the restored tab is activated), extensions see one more tab in it, active, standing for what it shows, as Chrome lists its own pages as tabs: with `tabs` it reads as Chrome's `chrome://newtab/`, and scripts, messages and navigation do not reach it. A window with no tab at all, which Chrome never has, has no active tab. `activeTab` is granted by a click on the extension's action or its own command, never by page clicks or keystrokes. `windows.create` opens a floating panel showing the first URL only, with its own window id, whatever its `type`, and with `incognito`, private data of its own, as a new private Space has, where Chrome's incognito windows share theirs; with the "Keep everything in one window" setting on, a normal `windows.create` opens its URLs as tabs of the current window and returns that window, and `windows.remove` of a Space's window then closes only the tabs `windows.create` opened there, never the browser window, failing when there are none. A page popup (`window.open` asking for one) is a `popup` window holding one tab, but `windows.getAll` and `tabs.query` leave it out until it is opened as a tab. With the `tabs` permission, a tab's `url` and `title` show in what `tabs` and `windows` methods return, an empty tab reading as Chrome's `chrome://newtab/` (the only page a `chrome://` pattern of `tabs.query` matches), but tab events leave them empty for hosts the extension may not access; without it they are left out, as in Chrome.
- **Spaces as windows:** each Space is a window to extensions, and a switch between Spaces a window focus change. A Space joins `windows.getAll` and `tabs.query` the first time a window shows it after launch. A Space shown in two browser windows is one window, so a normal `windows.create` opens a floating panel, a window of its own, rather than a second browser window on the Space, which would return the Space's window. A Space in the background reports the frame of the browser window that showed it last and the `normal` state; `windows.update` changes that browser window, `focused: true` shows the Space in it, as `tabs.create` into it does, and `windows.remove` fails. `windows.onBoundsChanged` fires for the Space shown only. A tab moved to a Space of another profile or privacy, or to one no window has shown yet, closes and opens there with a new id. A deleted Space's tabs close before its window, with `isWindowClosing` false.
- **debugger:** tabs only: no background page, worker or other target, so `getTargets` lists tabs as `page` targets whose `id` is Lilo's tab id. The Chrome DevTools Protocol is translated to WebKit's Web Inspector protocol for `Runtime` (`evaluate`, `callFunctionOn`, `awaitPromise`, `getProperties`, object release, console and exception events), `Page` (`navigate`, `reload`, `captureScreenshot`, `printToPDF`, `getLayoutMetrics`, frame and resource trees, new-document scripts, load and frame events, lifecycle events `init`, and `DOMContentLoaded` and `load` for the main frame only, from the next navigation on and whether or not `setLifecycleEventsEnabled` asked for them), `Network` (events, `getResponseBody`, extra headers, cache, user agent, `getCookies`), `DOM` (document, queries, attributes, HTML, `resolveNode`, `requestNode`, `getBoxModel`, `getContentQuads`, `focus`, search, mutation events), `Input` (mouse, keys, `insertText`) and `Emulation` (user agent, media and media features, script execution, device scale factor); every other method fails with CDP's method-not-found error, as on an older Chrome (no `Fetch`, `Debugger`, `Target`, `Overlay` or `CSS`, and no `Page.createIsolatedWorld`), though every domain's `disable` succeeds; `Log`, `Performance` and `Audits` can be enabled but send no events. `Page.navigate` returns a `loaderId` of its own, not the one the new document's events carry. `captureScreenshot` encodes `webp` as PNG and ignores `clip.scale`; `printToPDF` returns the whole page on one PDF page and ignores its options; `setDeviceMetricsOverride` applies only `deviceScaleFactor`; network events have no `ExtraInfo` companions and report no encoded sizes for responses; `callFunctionOn` drops `unserializableValue` arguments; a node's `backendNodeId` is its `nodeId`. The agent's devtools tools and the Web Inspector share the tab's protocol connection, so emulation one of them sets applies to all.
- **Tab groups:** backed by Lilo tab folders; tabs are matched across windows by window position and index. `tabGroups.move` leaves the group where it is. `tabs.group` fails for tabs of different windows, or into a group of another window, where Chrome moves the tabs to the group's window.
- **history:** `onVisited` fires up to a second after a visit, once for all the visits a page gets in that second. Lilo does not record how a page was reached: a `VisitItem`'s `transition` is always `link`, and `typedCount` is always 0.
- **Clipboard:** `clipboardRead` lets only the offscreen document paste without a user gesture (`document.execCommand('paste')`). Extension pages and the popup read the clipboard after a user gesture, through WebKit's Paste bubble, and `navigator.clipboard.read` and `readText` need a user gesture everywhere.
- **downloads:** `onDeterminingFilename` waits at most 10 s for each listening context to call `suggest`, then the download keeps its own name. Its `DownloadItem` has no `id` yet, as Lilo records a download once its file is chosen. When several extensions suggest a name, the one loaded last wins (Chrome: the most recently installed), so an extension disabled and enabled again wins over those installed after it. A suggestion's `overwrite` on a page's download removes the existing file as the download starts, not once it completes.
- **declarativeNetRequest:** `MAX_NUMBER_OF_DYNAMIC_RULES` and `MAX_NUMBER_OF_SESSION_RULES` are both 30000, one cap WebKit shares between them. An extension holding `declarativeNetRequest` also reports `declarativeNetRequestWithHostAccess` in `permissions.getAll` and `contains`: Lilo grants it so that redirect and `modifyHeaders` rules act on the sites the extension may access, as in Chrome. `getAvailableStaticRuleCount` always answers 30000, Chrome's guaranteed minimum, whatever the enabled rulesets hold. An exclusion (`excludedRequestDomains`, `excludedRequestMethods`, or `excludedInitiatorDomains` beside `initiatorDomains`) also lets the requests it excludes, of its rule's resource types and initiators, escape every rule of the extension of the same or lower priority: WebKit compiles it into a rule of its own that ignores the rules after it. `initiatorDomains` and `excludedInitiatorDomains` never match a frame whose URL names a port (`http://example.com:8080/`). The session rules of the last run that the extension does not set again keep applying for up to 30 s after its worker starts, though `getSessionRules` does not answer them. `getMatchedRules` gives each match's `request`, `tabId` and `timeStamp` but no `rule`, since WebKit does not say which rule matched. Dynamic rule ids within 30000 of 2147483647 may collide with the ids Lilo keeps session rules under. `testMatchOutcome` (with `declarativeNetRequestFeedback`) re-evaluates the rules WebKit applies with Chrome's matching. WebKit compiles each rule into one regex without alternation, `\b`, back-references or `$` inside the pattern: a `regexFilter` is rewritten into that subset where it means the same (`\w`, `{n,m}`, alternation of single characters, `(X|$)` at the end, `(^|X)` at the start), and a rule whose filter cannot be, that redirects to a non-HTTP URL other than the extension's own pages (a `data:` URL), that only modifies headers WebKit does not know (custom `X-` headers), that is conditioned on tab ids or response headers, that lists request methods or excluded methods beside excluded request domains, or that allows all requests only for some initiators, a `domainType`, some methods or outside some request domains is kept under its id, returned by `getDynamicRules` and `getSessionRules`, but never matches; `isRegexSupported` answers `syntaxError` for such a filter. A rule modifying both known and unknown headers modifies the known ones only. An `upgradeScheme` rule without a URL or request domain condition upgrades `http` requests only, not `ws` ones. A `regexFilter` rule ignores its `requestDomains`. A `requestDomains` rule matches its `urlFilter` only past the host, and with a `urlFilter` starting with `/`, also on hosts that extend a domain (`t.com` for `t.co`); with a `urlFilter` anchored at the start (`|`), it never matches, and with one anchored on the host (`||`), WebKit merges the two, which fits a filter naming the domain (`||amazon.*/dp/` for `amazon.ae`). Its `urlFilter` ending with the separator `^` does not match at the end of the URL. A redirect's `regexSubstitution` keeps the URL text around the match, as in Chrome, but from the last match of the `regexFilter` where Chrome takes the first; such a rule whose filter ends in `(X|$)`, starts with `(^|X)`, repeats a capturing group with `*`, can skip one nested in another part (`(?:a(b))?`), or would need a group past `\9` never matches. Static rulesets are rewritten so in their files when Lilo prepares the extension: one reading its own ruleset file finds each changed rule's original under `liloChromeRule`, except for a rule whose request domains alone changed: they end with `^`, and excluded ones start with `||`. WebKit expands a rule into one content blocker rule per request domain and holds 150000 per extension, so past 120000 for all its static rulesets together, rules of the rulesets the manifest leaves off go first, and among them the rules with the most request domains never match, largest first (the largest host lists of SBlock and Adblock Ad Blocker Pro); those the manifest turns on keep their rules until no other ruleset has any left. Turning every ruleset off right after an install waits until WebKit has compiled the extension's rules once (seconds for large rulesets).
- **runtime.getContexts:** popup windows from `windows.create` are reported as tabs, and `documentIds` filters match nothing.
- **Objects and errors:** `ChromeContractTests` replays calls recorded from Chrome (`make chrome-contracts`) and lists in `LiloTests/Extensions/chrome-contract-differences.txt` where Lilo answers differently, and where an object lacks a property Chrome's schemas require: WebKit's `Tab` objects lack `splitViewId`, the tabs of the window `windows.create` returns also lack `pendingUrl`, `autoDiscardable`, `discarded`, `frozen`, `groupId` and `lastAccessed`, a `MessageSender` for an extension page adds `documentId`, and a tab event (`tabs.onCreated`, `tabs.onUpdated`) that reaches a page or worker before the tab state Lilo sends it (the events that start a worker, the first ones of a window it has not seen) carries the `groupId`, `discarded` and `frozen` of a loaded tab outside any group, and in `onUpdated`, no `lastAccessed`; alarms lack `persistAcrossSessions`; bookmark nodes lack `syncing`, `dateAdded` and `folderType` on folders; `management` info lacks `homepageUrl`, `isApp`, `mayDisable`, `optionsUrl` and `versionName`; `permissions` answers the names WebKit does not know (`tabGroups`, `offscreen`, `debugger`...) from the manifest and the grants Lilo keeps, and `request` asks no user gesture for them; a worker's `runtime.sendMessage` resolves instead of rejecting when no one receives the message; a `chrome` callback left without a response gets Chrome's `runtime.lastError`, worded from whether the worker or another page listens for a page's message, and as a missing receiver for a worker's (where a listening page that does not respond gets Chrome's other wording), and a `browser.runtime` or content script callback gets none; and most error messages are worded differently. `storage` keeps a `Date` as its ISO string, where Chrome stores `{}`. Through `browser` (WebKit's object, where Chrome's shares `chrome`'s namespaces), `runtime.lastError` is `null` without an error, a copy of a namespace (`{ ...browser.alarms }`) lacks its events, and a namespace cannot be replaced; an extension assigning `browser` (`var browser = chrome`, webextension-polyfill) keeps WebKit's, through which WebKit delivers the context's events. The worker's `browser.extension` has `getBackgroundPage`, which Chrome leaves out of workers (`chrome.extension.getBackgroundPage` is `undefined` there, as in Chrome): AI Exporter, which tests it through `browser`, fails to start.
- **cookies.onChanged:** fires once Lilo has read the changed cookie store; changes made while it reads (a page load's burst of cookies) are reported as their net result, except those of a context that listens, whose `cookies.set` and `remove` settle once their change is reported. It reports a `cause` of `explicit`, `overwrite` or `expired` only (never `evicted` or `expired_overwrite`); setting a cookie again with the same value and attributes fires nothing.
- **scripting and tabs:** `scripting.insertCSS` given `frameIds` beyond the main frame's adds a style sheet to each frame's `document.adoptedStyleSheets`, where the page sees it and a `cssOrigin` of `USER` applies as an author style; `tabs.insertCSS` and `removeCSS` given a `frameId` apply to the main frame only. `matchOriginAsFallback` is not supported (WebKit has no way to inject into a frame by its parent's origin). WebKit injects an extension's manifest content scripts into the pages already open when it is installed or enabled, as Safari does; Chrome waits for their next load, so an extension whose content script asks its worker about such a page may find nothing (Avira Browser Safety logs an error). A script `scripting.registerContentScripts` registers also runs at once in the matching pages already open, and again when `updateContentScripts` changes it, where Chrome waits for their next load.
- **contextMenus:** `documentUrlPatterns` and `targetUrlPatterns` WebKit does not take (`ftp:`, `urn:`, `data:`, `about:`, `blob:`, `javascript:`, `mailto:`, `ws:` and `chrome:` schemes, explicit ports, and invalid patterns Chrome reports as `Invalid url pattern`) are dropped, so an item never shows for such a page or link; an item left with none shows nowhere. The callback of a `create` without a title (and of a `scripting.registerContentScripts` without match patterns) runs without `runtime.lastError`. An `update` moving an item under one of its ancestors other than its parent leaves it where it is. When an extension's only item for a link, a media element, a selection or an editable field is not also shown for the whole page under the same title, Lilo cannot tell which extension it belongs to: choosing it is no user gesture for `sidePanel.open`, and a private space hides it while an extension not allowed there is enabled.
- **storage:** extension pages and workers get answers in the order they make their calls, as in Chrome. A `get` may still miss a `set` not completed yet when another context made it, or in a content script, where Chrome answers calls in the order they arrive; there, several answers can also settle in one task, before the code awaiting the first has run on. Writes made while another of the same context is in flight go to WebKit together: `set`s of distinct keys, or `remove`s, made one after the other fire one `storage.onChanged` holding all their changes.
- **devtools:** WebKit's `chrome.devtools` is partial: `panels` has only `create`, `themeName` and `onThemeChanged` (no `elements`, `sources`, `Theme`, `setOpenResourceHandler`, `openResource` or `setThemeChangeHandler`, so no `createSidebarPane`), `network` only `onNavigated` (no `onRequestFinished` or `getHAR`), `inspectedWindow` only `eval`, `reload` and `tabId` (no `getResources`, `onResourceAdded` or `onResourceContentCommitted`), a panel only `onShown` and `onHidden`, and `performance` and `recorder` are missing. `inspectedWindow.eval` ignores `contextSecurityOrigin` and `useContentScriptContext`; `reload` ignores `userAgent` and `injectedScript`. An inspector Lilo connects without showing it (for `chrome.debugger` or the agent's devtools tools) starts the `devtools_page` of every extension that has one, as opening the Web Inspector does, where Chrome opens no DevTools for `chrome.debugger`.
- **webRequest:** details have no `initiator`, `frameType` or `documentLifecycle`, `statusLine` is the status text alone (`OK`, not `HTTP/1.1 200 OK`), and `onErrorOccurred`'s `error` is the system's localized description, not a `net::ERR_*` code. A proxy's `onAuthRequired`, which Lilo reports itself, has Chrome's `frameType`, `documentLifecycle` and `statusLine`.
- **webNavigation:** `onHistoryStateUpdated` and `onReferenceFragmentUpdated` fire for main frames only. Lilo cannot tell how a navigation started: `onCommitted` reports `transitionType` `link` for a main frame and `auto_subframe` for a subframe, and `onCommitted`, `onHistoryStateUpdated` and `onReferenceFragmentUpdated` report no `transitionQualifiers` but `forward_back` for a history traversal within a document. `onCreatedNavigationTarget` reports `sourceFrameId` 0, and nothing for a page popup window. `getAllFrames` and `getFrame` report a tab's initial empty document, with an empty URL, until its first navigation commits, and an empty URL for a frame the extension has no host permission for. A page Preload top hit loads before Return fires no `webNavigation` event for that load, nor `onTabReplaced`: it joins the tab under the same id, and `tabs.onUpdated` reports it from there.
- **Side panel:** docked on the right of the content (`getLayout` always reports `right`). A panel a tab switch hides is released and reloads when its tab shows it again, where Chrome keeps the page. `open` accepts a call from a page with a user gesture, or one that follows within 5 seconds a click on the extension's action, its keyboard command or its context menu item (Chrome: only from that event's listener, before any `await`). `setOptions` for a tab id unknown to Lilo is ignored. `onClosed` for a panel whose tab or window closed reports no `tabId` and `windowId` -1.
- **commands:** a command with `"global": true` acts only while Lilo is the active app, as the others do: Lilo registers no system-wide shortcut.
- **action:** `getUserSettings` reports `isOnToolbar: true` for an extension pinned to the address bar; the pins are shared by every profile, and only the active profile's extensions hear `onUserSettingsChanged`. An import from Chrome pins the imported extensions Chrome had pinned, in its toolbar order, after the ones already pinned.
- **permissions:** a host access request names a tab (`documentId`s are unknown to Lilo) and shows as an Allow button in the extensions menu. As in Chrome, Lilo never asks the user for a site an extension reaches without access (WebKit would ask for the page's full URL): the call fails, and only `permissions.request` prompts. A namespace Lilo adds (`pageCapture`, `tabGroups`, `debugger`...) whose permission is only optional shows in a page or worker once it learns of the grant, from its own `permissions.request`, `contains` or `getAll` (a worker learns it once its top-level code has run). Granting or removing such a permission, or an optional `nativeMessaging`, fires no `permissions.onAdded` or `onRemoved`. A `permissions.request` prompt is denied when its window closes or shows another profile.
- **pageCapture:** the MHTML is written from WebKit's web archive of the page, so its parts are the resources WebKit kept, each URL once, without `Content-ID` headers.
- **instanceID:** Lilo has no push backend: `getToken` and `deleteToken` fail with Chrome's `Instance ID is currently disabled.` and `onTokenRefresh` never fires (`getID`, `getCreationTime` and `deleteID` work as in Chrome).
- **cookies.set:** a cookie for an https URL with no `secure`, from an extension whose host permissions name https only, is set `secure`: WebKit checks its access over http otherwise.
- **cookies.getPartitionKey:** main frames only: another `frameId` or a `documentId` fails.
- **Sites an extension page frames:** a site the extension may access gets its cookies, `SameSite` ones included, on every request and WebSocket, and keeps those its responses set a moment after each response, as in Chrome: for WebSockets, Lilo holds copies of its `SameSite` cookies partitioned under that extension's pages while one of them frames it, which the cookies API may list. A frame of a popup that `action.openPopup` opened from the worker gets them only once the popup has loaded. A site framed in a side panel keeps its own `localStorage`, the one its tabs use, even one the extension may not access, but its IndexedDB is kept apart from its tabs'; framed in the extension's other pages, it gets a `localStorage` emptied on each load. In the action popup and the background page, WebKit filters a framed site's Content Security Policy as an extension's, so its `blob:` workers are refused there.
- **omnibox:** a `deletable` suggestion is deleted with Command-Delete.
- **Downloads:** `blob:` URLs are rejected. Lilo marks no download dangerous, so `acceptDanger` always fails; `setUiOptions` hides the download button of the sidebar. Past 200 tracked downloads, completed ones and those interrupted over a day ago stop being tracked for `onChanged`: resuming such an interrupted download fires no `onChanged` for its first change.
- **Notifications:** remote `iconUrl`s are not shown (base64 `data:` URLs and the extension's own files are).
- **Identity:** `getProfileUserInfo` is always empty: Lilo has no browser Google account. `getAuthToken` runs Google's OAuth consent in a sign-in panel where the user signs in (Lilo has no browser Google account), then caches the token in the keychain; Google may refuse a client registered only for Chrome. `launchWebAuthFlow` also completes on a redirect to a loopback host; a non-interactive one without `timeoutMsForNonInteractive` gives up after 30 seconds.
- **publicSuffix:** the Public Suffix List is the one bundled with Lilo, refreshed with its releases. `getDomain` with `encoding: "display"` shows a label in Unicode when it uses a single script (or one of the Chinese, Japanese and Korean mixes with Latin) and is not made only of Cyrillic letters that read as Latin ones; Chrome's IDN spoof checker applies more rules, so a few labels it keeps as punycode show in Unicode here.
- **tts:** `lang` is checked for a language tag's shape only, and the `end` event's `charIndex` counts UTF-16 code units where Chrome counts UTF-8 bytes.
- **i18n.detectLanguage:** uses the system language recognizer (up to three languages, `isReliable` above 80%), so results can differ from Chrome's.
- **Extension-controlled settings** (`proxy`, `fontSettings`, `contentSettings`): when several extensions set the same one, the extension loaded last wins (Chrome: the one installed last).
- **browsingData:** WebKit keeps site data per site (registrable domain), so `origins` and `excludeOrigins` clear or spare a whole site, storage included, and then ignore `since`; a non-zero `since` also clears what extension pages stored in that period. `formData` clears WebKit's search field history. `settings` reports Chrome's default selection (Lilo keeps no clear-data choices). WebKit cannot single out extension pages' data, so `originTypes` naming only `extension` or `protectedWeb` clears no site data.
- **contentSettings:** Lilo applies the rules of `camera`, `microphone`, `location`, `notifications`, `popups` and `javascript`; the other types keep and report their rules, which pages do not follow. With no rule, `get` reports Lilo's own site permission, else Chrome's default. `incognito_session_only` rules apply to private spaces and last until cleared.
- **fontSettings:** pages follow the standard and fixed families of the default script (`Zyyy`) and the three sizes; other families and scripts are kept and reported only, and extension pages keep WebKit's fonts.
- **proxy:** applied to the profile's tabs and extensions once the extension has loaded, with one server for all traffic (`singleProxy`, else `proxyForHttps`, `proxyForHttp`, `fallbackProxy`); an `http` proxy gets `CONNECT` for plain http too. `pac_script` (WebKit takes no PAC script, by URL or data, once the profile is open), `auto_detect` and `direct`, and `socks4` or `quic` servers, leave the system proxy settings in charge while `get` reports them. `bypassList` keeps host and domain entries (`<local>`, `<-loopback>` and IP ranges are dropped). Private spaces never use an extension's proxy: incognito scopes are kept and reported only. A proxy's sign-in challenge reaches `webRequest.onAuthRequired` (with `webRequestAuthProvider`) for a tab's loads only, never an extension's own requests, and as a `main_frame` request of the tab's page with no `realm` (WebKit does not report it); Lilo prompts for it at once when no listener is `blocking` or `asyncBlocking`, otherwise once those give no credentials, or after 30 seconds. `onProxyError` fires for a page that cannot connect while a proxy applies (`net::ERR_PROXY_CONNECTION_FAILED` or `net::ERR_TUNNEL_CONNECTION_FAILED`), never for PAC errors.
- **privacy:** a stub: settings report Chrome's defaults as `not_controllable`, `set`/`clear` have no effect and `onChange` never fires.
- **storage.managed:** always empty (read-only stub).
- **storage:** a `set` replacing a value with characters outside Latin-1, which WebKit miscounts against the quota, is applied as a removal then a set: `onChanged` fires for both.
- **gcm:** Lilo has no Google push backend: `register`, `unregister` and `send` fail with Chrome's `GCM is currently disabled.` and `onMessage`, `onMessagesDeleted` and `onSendError` never fire.
- **declarativeContent:** Lilo has no page actions and shows every action on every page: `onPageChanged` keeps its rules, until the worker or page that added them stops, but they never act (`ShowAction`, `SetIcon` and `RequestContentScript` included).
- **printerProvider:** the macOS print panel lists no extension printers, so its events never fire.
- **mdns:** given to any extension declaring the `mdns` permission, where Chrome allows only listed extensions. `onServiceList` finds `_tcp` and `_udp` types in the `local` domain; an instance that does not resolve to an address within 5 seconds is left out of the list. The first browse may make macOS ask whether Lilo can reach the local network.
- **management:** Lilo runs no apps: `launchApp`, `createAppShortcut` and `setLaunchType` fail with Chrome's errors for a non-app, and `generateAppForLink` fails after Chrome's checks.
- **User scripts:** `runAt: "document_idle"` runs at document end; `includeGlobs`/`excludeGlobs` are checked by a guard around the code, and the `js` sources of one registration run as one script. A page learns the "Allow user scripts" switch on its next load (a worker restarts in place). User script messages carry `frameId`, and their sender's `tab`, for main frames only; a message nobody answers fails after 5 minutes. `userScripts.execute` rejects `documentIds`, and in a `MAIN` world whose page CSP refuses `eval` a promise result reports null. A world's `csp` is stored, not applied: WebKit has no content security policy for script worlds, and its isolated worlds are not bound by the page's own either.
- **tabCapture and desktopCapture:** streams are video only, from the tab-share ferry (at most 30 fps and 1920 pixels wide): WebKit exposes no per-tab or system audio tap, so a `getUserMedia` asking for audio alone with `chromeMediaSource` rejects with `NotSupportedError`, an audio request beside video gets the video track only, and `chooseDesktopMedia` always reports `canRequestAudioTrack: false`. Other `mandatory` and `optional` constraints are ignored. `desktopCapture` shows Lilo's tab picker when `tab` is a source (with a "screen or window" choice when those are too), else the system content-sharing picker; `cancelChooseDesktopMedia` cannot close the system picker once shown, and its pick is then dropped. `onStatusChanged` fires `pending` and `active` together when the stream starts, and `getCapturedTabs` lists only streams already started.
- **Extension frames:** a web page framed inside an extension page gets none of Lilo's additions.
- **Location and notifications:** extension pages get Lilo's `navigator.geolocation` and `Notification`, granted without asking when the extension holds the `geolocation` or `notifications` permission, required or optional and granted; otherwise a page in a tab asks as a site does, and a popup, side panel or offscreen document is refused. Content scripts keep WebKit's, which deliver no position and show no notification, and the worker keeps WebKit's `showNotification`.
- **Engine globals:** extension pages and the worker have no `SharedArrayBuffer`, even with the manifest's `cross_origin_embedder_policy` and `cross_origin_opener_policy`, which WebKit ignores: Distill Web Monitor cannot open its store. They get Lilo's `navigator.connection` (WebKit has none), which always reads as a fast connection (`effectiveType` `4g`, `rtt` 50, `downlink` 10, `saveData` false) and as no speed offline; its `change` fires when the network goes or comes back, never for a change of speed. They and content scripts also get `Symbol.dispose` and `Symbol.asyncDispose`, which WebKit leaves off, so `using` compiled by TypeScript, esbuild or Babel runs (Bitwarden's vault unlock); native `using` syntax is still a syntax error there, and `DisposableStack` and `AsyncDisposableStack` are missing; main-world scripts (`world: 'MAIN'`) do not get `Symbol.dispose`, and iterators have no `[Symbol.dispose]` or `[Symbol.asyncDispose]`. JavaScriptCore also lacks `Temporal`, `Iterator.prototype.join` and `includes`, `Iterator.zip` and `zipKeyed`; before macOS 26.3, `Iterator.concat`; before 26.1, `Map` and `WeakMap`'s `getOrInsert` and `getOrInsertComputed` and `Math.sumPrecise`. `Date.parse` returns `NaN` for formats V8 accepts (`01-02-2024`, `2024.01.02`, `02.01.2024`, a time followed by `UTC`). On macOS 26, a module with top-level `await` that several modules import at once can reject one import with a `ReferenceError` (webkit.org/b/242740). `Error.prepareStackTrace` has no effect and stacks keep JavaScriptCore's format (`Error.captureStackTrace` exists). Lilo polyfills none of them. Pages Lilo opens (side panel, offscreen document, extension tabs) get the WebKit feature flags tabs get, `about:config` overrides included; the popup and the worker, which WebKit creates, keep WebKit's defaults, so `requestIdleCallback` and `showPicker()` may be missing there.

### Tested store extensions [#tested-store-extensions]

Measured on 2026-10-23 with `make ext-smoke TOP=30` (the 30 most used extensions of `top-extensions.json` as it stood then, the password managers onboarding proposes, and the new tab pages in the list), against Chrome 156's recording of the time (`make chrome-smoke`): each one installs from the store, its worker starts and its popup renders. The password managers also opened a local login form, signed out, to check their content scripts reach it. Errors Chrome reports too do not count. `ext-smoke` also checks, on a local sign-in page and signed out, that Dark Reader's theme applies and its shortcut turns it off for the site, that Grammarly, Quillbot, LastPass, Dashlane, Proton Pass, Keeper, DeepL and Immersive Translate mark the page, that DeepL and AdBlock add their context menu entries, that Adblock Plus blocks an ad request, that the toolbar buttons of WhatFont and SelectorGadget act on the page and Claude's opens its side panel. Since 2026-10-24 it also checks that uBlock Origin Lite and AdGuard block that ad request; that React, Vue.js and Redux DevTools hook into the page from its first script; that JSON Formatter and JSONVue format a JSON document; that Video Speed Controller adds its controller to a playing video; that LanguageTool and Wordtune reach the page; that Sider's toolbar button opens its side panel, Session Buddy's its manager and GoFullPage's a capture of the whole page. Ghostery blocks nothing until its onboarding terms are accepted, which the test does not do. `top-extensions.json` also lists extensions picked by hand outside the most used (`extra`), which `ext-smoke` runs every time. Nothing here was signed in, so signed-in features (autofill, translation, grammar suggestions) are not verified. The list and Chrome's recording (now Chrome 157's) have been measured again since, so the table below is not today's 30 most used: it lists the extensions measured then, plus Kami, and its statuses follow the current `knownFailures` and `intermittent` lists of `RealExtensionSmokeTests`.

On 2026-10-24, past their popups and signed out: ColorZilla's eyedropper samples the page, Wappalyzer lists a local page's technologies, FireShot captures a whole page, Okta and 2FAS Auth leave a login form alone (as in Chrome), and Google Docs Offline and Application Launcher for Drive start their workers without errors. Lilo's own content blocker and Adblock Plus block side by side without breaking the page, a request either one blocks failing once; each keeps its own allowlist, so a site paused in Adblock Plus is still filtered by Lilo's blocker until it is allowed there too.

`top-extensions.json`, measured on 2026-10-24, lists the 1000 most used extensions. `RealExtensionSmokeTests` lists, with their cause, the 36 known not to run cleanly (`knownFailures`) and the 30 that fail some runs (`intermittent`); 26 and 22 of them are among the 500 most used. In 11 of those 66 causes, Chrome fails, or would fail, the same way, or runs the same race; the others name a difference in WebKit, JavaScriptCore or Lilo, something of the test itself, or a cause not yet known. An extension whose store version differs from the one Chrome's recording holds is not compared with Chrome's rendering of its popup and pages until `make chrome-smoke` records it again; the errors Chrome recorded for it still do not count.

| Extension | Status | Differs from Chrome |
| --- | --- | --- |
| Adobe Acrobat, McAfee WebAdvisor, Thunder download support, Chrome Remote Desktop, Google Translate, Microsoft Single Sign On, Cisco Webex, uBlock Origin Lite, 360 Internet Protection, Claude, NordVPN, VeePN, Honey, Adblock for Youtube, Online Security, Teleparty, Authenticator, Foxit PDF Creator, 1clickVPN, Screencastify | works | nothing measured |
| Grammarly | works with limits | some runs log an error Chrome's recording does not: on its first start it injects a script into its welcome page beside the one it declares, and the second run fails to redeclare a name: Lilo reports the welcome page's address while it loads, where Chrome reports none, as for 1Password |
| AdBlock | works with limits | its welcome page logs an error: two of its content script entries list the same script, which WebKit runs twice in one page, where Chrome reports nothing |
| Adblock Plus | works with limits | it blocks ad requests, but its options page reads its worker's answers (its language, its features and its subscriptions) as undefined, where Chrome's renders without an error |
| Malwarebytes Browser Guard | works with limits | its cosmetic filters hide none of the test page's ads, which Chrome's hide; once its worker was idle, its popup over a blank tab shows less than half of Chrome's; its request blocking was not tested |
| GoFullPage | works with limits | its welcome page finds no window holding it ("No matching window found"), where Chrome's does |
| Zotero Connector | not verified | on the test page its content script fails to set up translation, which is how it reads what a page holds: its worker creates its offscreen document twice ("Only a single offscreen document may be created"); saving to Zotero was not tested |
| Kami | not verified | its options page renders nothing in 20 seconds, where Chrome's shows its settings; nothing else it does was tested |
| Momentum, Bonjourr | works | nothing measured (new tab override) |
| NordPass | works | nothing measured: it marks or decorates the login form |
| Dashlane | works with limits | it decorates the login form as in Chrome, but logs an error at startup: while its settings are not yet populated, its device info falls back to a command it does not have (`getCommonAppSetting: a is not a function`); in Chrome they are by then |
| Proton Pass | works | nothing measured: it injects its form UI into the login form |
| Bitwarden | works | signed out, it changes the login form in neither browser; some runs, its badge update meets a tab the test closed meanwhile (`No tab with id`) |
| KeePassXC-Browser | works | it adds its element to the login form, as in Chrome; its desktop app, reached over native messaging, was not tested |
| 1Password | works with limits | a tab activated while its core starts (within about 150 ms) logs "WASM is not initialized": Chrome reports a loading new tab with no url, which 1Password skips, where Lilo reports its target, as WebKit's own access checks on that tab need it; the login form gets its menu on focus, as in Chrome; its desktop app (unlock and Touch ID) needs the step in [Password managers](#password-managers) |
| AdGuard AdBlocker | works with limits | its popup sometimes renders nothing: it can message its worker before the worker has set up the handler, a race Chrome runs too |
| Keeper, Okta Browser Plugin, Wappalyzer, WhatFont, SelectorGadget, Google Docs Offline, Application Launcher for Drive | works | nothing measured |
| ColorZilla | works | some runs it scripts or messages a tab the test just closed (`Tab not found`); over a blank tab, WebKit refuses its `tabs.captureVisibleTab` |
| FireShot | works with limits | it captures a whole page, but over a blank tab its popup offers to capture that tab, where Chrome's recording opens the popup in a tab of its own |
| 2FAS Auth | covered by unit tests only | it installs and starts, but the smoke run cannot see the stall this row is about: its worker stores its keys in IndexedDB, which stalled it until Lilo stored them itself (see Background worker in [API differences](#api-differences)), and only unit tests check that storage; pairing with its phone app was not tested |
| Endpoint Verification | does not work | it reads `chrome.enterprise.reportingPrivate`, a private API Chrome gives this Google extension only, to report the device to Google Workspace; Lilo cannot provide it, so access policies that require a verified device refuse Lilo |
| Tampermonkey | works | nothing measured: as in Chrome, its scripts need "Allow user scripts" |

### User agent and the Chrome Web Store [#user-agent-and-the-chrome-web-store]

Lilo presents itself as Chrome where extensions expect it (`ChromeMasquerade`), and as Safari everywhere else:

- **Web pages** get WebKit's Safari user agent, with no Lilo token, so sites serve their Safari variant.
- **The Chrome Web Store** (`chromewebstore.google.com`) gets a Chrome user agent and `navigator.userAgentData`, and a `chrome.webstorePrivate` shim, so its install button works; Lilo relabels it "Add to Lilo" and hides the store's prompts to switch to Chrome. The packages and update checks Lilo downloads from `clients2.google.com` send the same Chrome user agent.
- **Extension code** (extension pages, the background worker and content scripts' isolated world) reads Chrome's `navigator.userAgent`, `appVersion`, `vendor` and `userAgentData`; the page around a content script keeps the Safari one.
- **Extension requests:** the workers, popups and panels WebKit creates for an extension send Chrome's user agent, so the servers an extension's own requests reach see Chrome.
- **What it means for you:** extensions install and update from the store as in Chrome, and the pages you visit get Safari's user agent. The store and its terms are made for Chrome, and Google does not endorse Lilo: Google could change the store or its update service so that installs or updates from Lilo stop working.

### Password managers [#password-managers]

- **Approach:** Lilo ships **no built-in password manager** by design: credential storage and autofill are left to extensions and the system. Onboarding offers to install one from the store. The managers tested are in [Tested store extensions](#tested-store-extensions); signed-in autofill is not verified.
- **Native messaging:** `chrome.runtime.connectNative` and `sendNativeMessage` follow Chrome's stdio protocol (`ExtensionNativeMessaging`) for extensions that declare `nativeMessaging` in `permissions`, or in `optional_permissions` once `permissions.request` got it granted. A host that restricts which browser may launch it still refuses Lilo (see [iCloud Passwords extension](/reference/web-compatibility#icloud-passwords-extension)).
- **Host manifests** (`<name>.json`, `type: stdio`, absolute `path`) are read from the `NativeMessagingHosts` folders in this order: under `~/Library/Application Support/`, `Lilo`, `Google/Chrome`, `Google/Chrome Beta`, `Google/Chrome Dev` and `Google/Chrome Canary`; then `/Library/Google/Chrome/NativeMessagingHosts`; `Chromium` under `~/Library/Application Support/`, then `/Library/Application Support/Chromium/NativeMessagingHosts`; `Microsoft Edge` under `~/Library/Application Support/`, then `/Library/Microsoft/Edge/NativeMessagingHosts`; and last `BraveSoftware/Brave-Browser`, `Arc/User Data` and `Vivaldi` under `~/Library/Application Support/`. Lilo's own folder comes first; the first manifest whose `allowed_origins` lists `chrome-extension://<id>/` wins. Firefox manifests are not read. The host gets that origin as `argv[1]`.
- **Failures:** a `chrome.runtime.connectNative` port to a host that is missing, refused or exits disconnects with Chrome's error as `runtime.lastError`. One opened through WebKit's `browser.runtime` disconnects without it, and only once it sends its first message (a port that never sends stays open). `sendNativeMessage` fails with `Native host has exited.` when its host neither replies nor exits within 30 s, where Chrome waits as long as the worker lives.
- **1Password** unlocks with its desktop app (and Touch ID) only once Lilo is added in the app's Settings > Browser > Add Browser: the app connects on its own only to the browsers it knows (Chrome, Firefox, Edge, Brave, Arc).
- **Dashlane** logs `Unable to find "argon2.wasm" in the extension's resources` in its worker: Emscripten resolves the binary against the worker's own directory instead of using the bytes Dashlane fetches from `assets/argon2/`. It is not a WebKit gap, and Lilo does not revive a worker for it: only a background whose content failed to load is revived (`errorsWarrantBackgroundRevive`).
- **Packaged `.wasm`:** WebKit serves extension resources without the `application/wasm` type, which `WebAssembly.instantiateStreaming` and `compileStreaming` reject. For the extension's own responses, Lilo compiles from the bytes instead (`shared/wasm-mime.ts`), in pages and in the worker.
