Skip to content
Lilo
Esc
↑↓navigate↵open⌘Jpreview
On this page

Extension compatibility

Which Chrome extension APIs Lilo supports, which ones behave differently from Chrome, and what that means for the extensions you rely on.

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.

The text below comes from the project’s compatibility tracker, 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: 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.

Chrome extensions

API differences

MV3 only, on WebKit’s WKWebExtension runtime plus Lilo’s Gap APIs (docs/ARCHITECTURE.md). 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: sets of distinct keys, or removes, 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 (documentIds 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 iconUrls 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

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
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), 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

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

  • 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; 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).
  • 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.