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,runtimemessaging,scripting,search,sessions,storage(local,session,sync),system.cpu,system.display,system.memory,system.network,system.storage,topSites,chrome_url_overrides.newtab. - Missing:
webRequestblocking (observe-only, sowebRequest.onActionIgnorednever fires andhandlerBehaviorChangedhas nothing to flush), MV2 extensions,chrome_settings_overrides(a search engine extension changes neither the search engine nor the home and startup pages), thefaviconpermission (chrome-extension://<id>/_favicon/URLs do not load).declarativeNetRequest.updateStaticRulesandgetDisabledRuleIdswork 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.ChromeSurfaceTestschecks this list, event methods andbrowser.*included, against Chrome’s API schemas (make chrome-surfacerefreshes them), with each member’s kind and each enum’s and constant’s value. - Content scripts and pages:
ChromeContractTestscompares what a content script, a main-world script and the page see ofchromeandbrowserwith Chrome’s. A content script has nostorage.session(Chrome’s refuses calls untilsetAccessLevelopens it), noscripting(globalParamsand its enums), nostorage.AccessLevelnorsetAccessLevel, noruntime.dynamicId, noextension.ViewTypeand nostorage.sync.MAX_SUSTAINED_WRITE_OPERATIONS_PER_MINUTE; itsstorage.managedhasget,getBytesInUseandonChangedonly, and WebKit addsi18n.getPreferredSystemLanguages,i18n.getSystemUILanguage,runtime.getDocumentIdandruntime.getFrameId. Neither a content script nor the page has Chrome’schrome.csiandchrome.loadTimes, and the page has nochromeobject at all (Chrome’s holdsapp,csiandloadTimes). A main-world script (world: 'MAIN') gets WebKit’sbrowser.runtimewithconnectandsendMessage, where Chrome gives it nothing withoutexternally_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,onImportBeganandonImportEnded(nothing in Lilo sorts or imports the bookmarks extensions see),tabGroups.onMoved(Lilo reorders no folder), andnotifications.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 (itsruntime.lastErrorstays 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’surlandorigin(itsframeIdanddocumentIdare the page’s), and nothing reaches the extension when itsweb_accessible_resourcesexpose 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.connectreaches 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 toruntime.onConnectis open. A portbrowser.runtime.connectopens to a page in a tab from a context listening toruntime.onConnectnearly always disconnects at once (WebKit). Atabs.sendMessagenobody 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 portbrowser.runtime.connectopened, between extension pages and the worker, arrives twice; on onechrome.runtime.connectopened, it arrives once.onExtensionLoadedreportsstartupafter a Lilo update, neverbrowser_update, and neverreload. A web page an extension’sexternally_connectablenames getschrome.runtimewithsendMessage,connectandlastErroronly (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’surl, where Chrome names the worker’s script.runtime.setUninstallURLsucceeds 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
incognitosplitworks asspanning: 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 onlydownloads.search, andchrome.sessionsnever 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, withoutruntime.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 aconnectNativeport keeps running while its host sends messages, where Chrome keeps it as long as the port is open. WebKit runs the pending promise callbacks whileimportScriptsloads 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’sWebSocketis Lilo’s (WebKit’s freezes the worker): it sends no cookies, itsOriginis the extension’s,bufferedAmountstays 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 asidand withoutfocused,visibilityState,focusornavigate, and a message posted to one reaches every page showing that url. In a page outside that directorynavigator.serviceWorker.readyresolves 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 (nopushManager,showNotificationorupdate). Lilo relays a page’spostMessageto the worker, and the ports it transfers, as amessageevent from aWindowClient; itswaitUntilextends nothing. Afetchwithcredentials: "include"from an extension page or the worker, and an extension page’s asynchronousXMLHttpRequestwithwithCredentials, to a host the extension may access go through Lilo, so they carry the site’sSameSitecookies as in Chrome; such anXMLHttpRequestgets its response whole (oneprogressevent, no partialresponseText), reports an emptystatusTextand fires nouploadevent. A synchronousXMLHttpRequest, or one withresponseTypedocument, still leaves those cookies out. ACryptoKeythe 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-incrypto.subtleuses 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 throwsDataCloneErrorin 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 apostMessageof a key from the worker still stalls it. - Extension manifest:
web_accessible_resourcesentries namingextension_idsgrant nothing. Anexternally_connectablewith neithermatchesnoridslets every extension connect, where Chrome lets none. Anexternally_connectablematch pattern naming a port matches every port of its host. The sitesexternally_connectablematches 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. Anexternally_connectablewildcard 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_workeris the worker’s path from the extension root (js/sw.jsfor./js/sw.js);host_permissionsandoptional_host_permissionsend in/*(Chrome ignores their path, so Okta’shttps://*/reaches every page); an empty_execute_actioncommand,content_security_policyorcontent_scripts, MV3’s_execute_browser_actionand_execute_page_actioncommands, thoseweb_accessible_resourcesentries and anexternally_connectableentry with nomatchesnoridsare left out; a missing or emptydescriptionreads" "; shortcut keys lose the whitespace around them; content scriptfile://patterns read with Chrome’s empty host (file:///*forfile://*); andexternally_connectablereads*://*/*for<all_urls>and patterns without their port. The pagessandbox.pageslists are served as the extension’s other pages: in its origin, withchrome.*and thecontent_security_policy.extension_pagespolicy (by defaultscript-src 'self'), soeval,new Functionand inline scripts are refused there, where Chrome gives themcontent_security_policy.sandboxin an opaque origin without extension APIs. Resources whoseweb_accessible_resourcesentry setsuse_dynamic_urlare 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.createorruntime.openOptionsPageopen in the background, so the welcome pages of the extensions it reinstalls leave the setup on screen. Atabs.createorwindows.createof Chrome’schrome://extensionspage opens Lilo’s extension settings instead (unlessactiveorfocusedis false, or during that import), on the extension its?id=names, or on the calling extension’s details page, which holds its shortcuts, forchrome://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 tabtabs.createopens 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.highlightonly activates the first tab: Lilo has no multi-tab selection, sohighlightedis the active tab.tabs.setZoomchanges the tab only, never its origin. Split views are Lilo’s split folders: tabs carry nosplitViewId, and splitting two tabs of a group takes them out of it.windows.onBoundsChangedfires up to half a second after a move or resize ends.tabs.discardrequires a tab id and never discards the tab on screen; a discarded tab’sgoBack/goForwardfail. 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: withtabsit reads as Chrome’schrome://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.activeTabis granted by a click on the extension’s action or its own command, never by page clicks or keystrokes.windows.createopens a floating panel showing the first URL only, with its own window id, whatever itstype, and withincognito, 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 normalwindows.createopens its URLs as tabs of the current window and returns that window, andwindows.removeof a Space’s window then closes only the tabswindows.createopened there, never the browser window, failing when there are none. A page popup (window.openasking for one) is apopupwindow holding one tab, butwindows.getAllandtabs.queryleave it out until it is opened as a tab. With thetabspermission, a tab’surlandtitleshow in whattabsandwindowsmethods return, an empty tab reading as Chrome’schrome://newtab/(the only page achrome://pattern oftabs.querymatches), 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.getAllandtabs.querythe first time a window shows it after launch. A Space shown in two browser windows is one window, so a normalwindows.createopens 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 thenormalstate;windows.updatechanges that browser window,focused: trueshows the Space in it, astabs.createinto it does, andwindows.removefails.windows.onBoundsChangedfires 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, withisWindowClosingfalse. - debugger: tabs only: no background page, worker or other target, so
getTargetslists tabs aspagetargets whoseidis Lilo’s tab id. The Chrome DevTools Protocol is translated to WebKit’s Web Inspector protocol forRuntime(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 eventsinit, andDOMContentLoadedandloadfor the main frame only, from the next navigation on and whether or notsetLifecycleEventsEnabledasked 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) andEmulation(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 (noFetch,Debugger,Target,OverlayorCSS, and noPage.createIsolatedWorld), though every domain’sdisablesucceeds;Log,PerformanceandAuditscan be enabled but send no events.Page.navigatereturns aloaderIdof its own, not the one the new document’s events carry.captureScreenshotencodeswebpas PNG and ignoresclip.scale;printToPDFreturns the whole page on one PDF page and ignores its options;setDeviceMetricsOverrideapplies onlydeviceScaleFactor; network events have noExtraInfocompanions and report no encoded sizes for responses;callFunctionOndropsunserializableValuearguments; a node’sbackendNodeIdis itsnodeId. 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.moveleaves the group where it is.tabs.groupfails for tabs of different windows, or into a group of another window, where Chrome moves the tabs to the group’s window. - history:
onVisitedfires 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: aVisitItem’stransitionis alwayslink, andtypedCountis always 0. - Clipboard:
clipboardReadlets 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, andnavigator.clipboard.readandreadTextneed a user gesture everywhere. - downloads:
onDeterminingFilenamewaits at most 10 s for each listening context to callsuggest, then the download keeps its own name. ItsDownloadItemhas noidyet, 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’soverwriteon a page’s download removes the existing file as the download starts, not once it completes. - declarativeNetRequest:
MAX_NUMBER_OF_DYNAMIC_RULESandMAX_NUMBER_OF_SESSION_RULESare both 30000, one cap WebKit shares between them. An extension holdingdeclarativeNetRequestalso reportsdeclarativeNetRequestWithHostAccessinpermissions.getAllandcontains: Lilo grants it so that redirect andmodifyHeadersrules act on the sites the extension may access, as in Chrome.getAvailableStaticRuleCountalways answers 30000, Chrome’s guaranteed minimum, whatever the enabled rulesets hold. An exclusion (excludedRequestDomains,excludedRequestMethods, orexcludedInitiatorDomainsbesideinitiatorDomains) 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.initiatorDomainsandexcludedInitiatorDomainsnever 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, thoughgetSessionRulesdoes not answer them.getMatchedRulesgives each match’srequest,tabIdandtimeStampbut norule, 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(withdeclarativeNetRequestFeedback) 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: aregexFilteris 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 (adata:URL), that only modifies headers WebKit does not know (customX-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, adomainType, some methods or outside some request domains is kept under its id, returned bygetDynamicRulesandgetSessionRules, but never matches;isRegexSupportedanswerssyntaxErrorfor such a filter. A rule modifying both known and unknown headers modifies the known ones only. AnupgradeSchemerule without a URL or request domain condition upgradeshttprequests only, notwsones. AregexFilterrule ignores itsrequestDomains. ArequestDomainsrule matches itsurlFilteronly past the host, and with aurlFilterstarting with/, also on hosts that extend a domain (t.comfort.co); with aurlFilteranchored 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/foramazon.ae). ItsurlFilterending with the separator^does not match at the end of the URL. A redirect’sregexSubstitutionkeeps the URL text around the match, as in Chrome, but from the last match of theregexFilterwhere 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\9never 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 underliloChromeRule, 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.createare reported as tabs, anddocumentIdsfilters match nothing. - Objects and errors:
ChromeContractTestsreplays calls recorded from Chrome (make chrome-contracts) and lists inLiloTests/Extensions/chrome-contract-differences.txtwhere Lilo answers differently, and where an object lacks a property Chrome’s schemas require: WebKit’sTabobjects lacksplitViewId, the tabs of the windowwindows.createreturns also lackpendingUrl,autoDiscardable,discarded,frozen,groupIdandlastAccessed, aMessageSenderfor an extension page addsdocumentId, 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 thegroupId,discardedandfrozenof a loaded tab outside any group, and inonUpdated, nolastAccessed; alarms lackpersistAcrossSessions; bookmark nodes lacksyncing,dateAddedandfolderTypeon folders;managementinfo lackshomepageUrl,isApp,mayDisable,optionsUrlandversionName;permissionsanswers the names WebKit does not know (tabGroups,offscreen,debugger…) from the manifest and the grants Lilo keeps, andrequestasks no user gesture for them; a worker’sruntime.sendMessageresolves instead of rejecting when no one receives the message; achromecallback left without a response gets Chrome’sruntime.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 abrowser.runtimeor content script callback gets none; and most error messages are worded differently.storagekeeps aDateas its ISO string, where Chrome stores{}. Throughbrowser(WebKit’s object, where Chrome’s shareschrome’s namespaces),runtime.lastErrorisnullwithout an error, a copy of a namespace ({ ...browser.alarms }) lacks its events, and a namespace cannot be replaced; an extension assigningbrowser(var browser = chrome, webextension-polyfill) keeps WebKit’s, through which WebKit delivers the context’s events. The worker’sbrowser.extensionhasgetBackgroundPage, which Chrome leaves out of workers (chrome.extension.getBackgroundPageisundefinedthere, as in Chrome): AI Exporter, which tests it throughbrowser, 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.setandremovesettle once their change is reported. It reports acauseofexplicit,overwriteorexpiredonly (neverevictedorexpired_overwrite); setting a cookie again with the same value and attributes fires nothing. - scripting and tabs:
scripting.insertCSSgivenframeIdsbeyond the main frame’s adds a style sheet to each frame’sdocument.adoptedStyleSheets, where the page sees it and acssOriginofUSERapplies as an author style;tabs.insertCSSandremoveCSSgiven aframeIdapply to the main frame only.matchOriginAsFallbackis 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 scriptscripting.registerContentScriptsregisters also runs at once in the matching pages already open, and again whenupdateContentScriptschanges it, where Chrome waits for their next load. - contextMenus:
documentUrlPatternsandtargetUrlPatternsWebKit does not take (ftp:,urn:,data:,about:,blob:,javascript:,mailto:,ws:andchrome:schemes, explicit ports, and invalid patterns Chrome reports asInvalid 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 acreatewithout a title (and of ascripting.registerContentScriptswithout match patterns) runs withoutruntime.lastError. Anupdatemoving 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 forsidePanel.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
getmay still miss asetnot 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, orremoves, made one after the other fire onestorage.onChangedholding all their changes. - devtools: WebKit’s
chrome.devtoolsis partial:panelshas onlycreate,themeNameandonThemeChanged(noelements,sources,Theme,setOpenResourceHandler,openResourceorsetThemeChangeHandler, so nocreateSidebarPane),networkonlyonNavigated(noonRequestFinishedorgetHAR),inspectedWindowonlyeval,reloadandtabId(nogetResources,onResourceAddedoronResourceContentCommitted), a panel onlyonShownandonHidden, andperformanceandrecorderare missing.inspectedWindow.evalignorescontextSecurityOriginanduseContentScriptContext;reloadignoresuserAgentandinjectedScript. An inspector Lilo connects without showing it (forchrome.debuggeror the agent’s devtools tools) starts thedevtools_pageof every extension that has one, as opening the Web Inspector does, where Chrome opens no DevTools forchrome.debugger. - webRequest: details have no
initiator,frameTypeordocumentLifecycle,statusLineis the status text alone (OK, notHTTP/1.1 200 OK), andonErrorOccurred’serroris the system’s localized description, not anet::ERR_*code. A proxy’sonAuthRequired, which Lilo reports itself, has Chrome’sframeType,documentLifecycleandstatusLine. - webNavigation:
onHistoryStateUpdatedandonReferenceFragmentUpdatedfire for main frames only. Lilo cannot tell how a navigation started:onCommittedreportstransitionTypelinkfor a main frame andauto_subframefor a subframe, andonCommitted,onHistoryStateUpdatedandonReferenceFragmentUpdatedreport notransitionQualifiersbutforward_backfor a history traversal within a document.onCreatedNavigationTargetreportssourceFrameId0, and nothing for a page popup window.getAllFramesandgetFramereport 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 nowebNavigationevent for that load, noronTabReplaced: it joins the tab under the same id, andtabs.onUpdatedreports it from there. - Side panel: docked on the right of the content (
getLayoutalways reportsright). A panel a tab switch hides is released and reloads when its tab shows it again, where Chrome keeps the page.openaccepts 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 anyawait).setOptionsfor a tab id unknown to Lilo is ignored.onClosedfor a panel whose tab or window closed reports notabIdandwindowId-1. - commands: a command with
"global": trueacts only while Lilo is the active app, as the others do: Lilo registers no system-wide shortcut. - action:
getUserSettingsreportsisOnToolbar: truefor an extension pinned to the address bar; the pins are shared by every profile, and only the active profile’s extensions hearonUserSettingsChanged. 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 onlypermissions.requestprompts. 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 ownpermissions.request,containsorgetAll(a worker learns it once its top-level code has run). Granting or removing such a permission, or an optionalnativeMessaging, fires nopermissions.onAddedoronRemoved. Apermissions.requestprompt 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-IDheaders. - instanceID: Lilo has no push backend:
getTokenanddeleteTokenfail with Chrome’sInstance ID is currently disabled.andonTokenRefreshnever fires (getID,getCreationTimeanddeleteIDwork as in Chrome). - cookies.set: a cookie for an https URL with no
secure, from an extension whose host permissions name https only, is setsecure: WebKit checks its access over http otherwise. - cookies.getPartitionKey: main frames only: another
frameIdor adocumentIdfails. - Sites an extension page frames: a site the extension may access gets its cookies,
SameSiteones 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 itsSameSitecookies partitioned under that extension’s pages while one of them frames it, which the cookies API may list. A frame of a popup thataction.openPopupopened from the worker gets them only once the popup has loaded. A site framed in a side panel keeps its ownlocalStorage, 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 alocalStorageemptied 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 itsblob:workers are refused there. - omnibox: a
deletablesuggestion is deleted with Command-Delete. - Downloads:
blob:URLs are rejected. Lilo marks no download dangerous, soacceptDangeralways fails;setUiOptionshides the download button of the sidebar. Past 200 tracked downloads, completed ones and those interrupted over a day ago stop being tracked foronChanged: resuming such an interrupted download fires noonChangedfor its first change. - Notifications: remote
iconUrls are not shown (base64data:URLs and the extension’s own files are). - Identity:
getProfileUserInfois always empty: Lilo has no browser Google account.getAuthTokenruns 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.launchWebAuthFlowalso completes on a redirect to a loopback host; a non-interactive one withouttimeoutMsForNonInteractivegives up after 30 seconds. - publicSuffix: the Public Suffix List is the one bundled with Lilo, refreshed with its releases.
getDomainwithencoding: "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:
langis checked for a language tag’s shape only, and theendevent’scharIndexcounts UTF-16 code units where Chrome counts UTF-8 bytes. - i18n.detectLanguage: uses the system language recognizer (up to three languages,
isReliableabove 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
originsandexcludeOriginsclear or spare a whole site, storage included, and then ignoresince; a non-zerosincealso clears what extension pages stored in that period.formDataclears WebKit’s search field history.settingsreports Chrome’s default selection (Lilo keeps no clear-data choices). WebKit cannot single out extension pages’ data, sooriginTypesnaming onlyextensionorprotectedWebclears no site data. - contentSettings: Lilo applies the rules of
camera,microphone,location,notifications,popupsandjavascript; the other types keep and report their rules, which pages do not follow. With no rule,getreports Lilo’s own site permission, else Chrome’s default.incognito_session_onlyrules 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, elseproxyForHttps,proxyForHttp,fallbackProxy); anhttpproxy getsCONNECTfor plain http too.pac_script(WebKit takes no PAC script, by URL or data, once the profile is open),auto_detectanddirect, andsocks4orquicservers, leave the system proxy settings in charge whilegetreports them.bypassListkeeps 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 reacheswebRequest.onAuthRequired(withwebRequestAuthProvider) for a tab’s loads only, never an extension’s own requests, and as amain_framerequest of the tab’s page with norealm(WebKit does not report it); Lilo prompts for it at once when no listener isblockingorasyncBlocking, otherwise once those give no credentials, or after 30 seconds.onProxyErrorfires for a page that cannot connect while a proxy applies (net::ERR_PROXY_CONNECTION_FAILEDornet::ERR_TUNNEL_CONNECTION_FAILED), never for PAC errors. - privacy: a stub: settings report Chrome’s defaults as
not_controllable,set/clearhave no effect andonChangenever fires. - storage.managed: always empty (read-only stub).
- storage: a
setreplacing a value with characters outside Latin-1, which WebKit miscounts against the quota, is applied as a removal then a set:onChangedfires for both. - gcm: Lilo has no Google push backend:
register,unregisterandsendfail with Chrome’sGCM is currently disabled.andonMessage,onMessagesDeletedandonSendErrornever fire. - declarativeContent: Lilo has no page actions and shows every action on every page:
onPageChangedkeeps its rules, until the worker or page that added them stops, but they never act (ShowAction,SetIconandRequestContentScriptincluded). - printerProvider: the macOS print panel lists no extension printers, so its events never fire.
- mdns: given to any extension declaring the
mdnspermission, where Chrome allows only listed extensions.onServiceListfinds_tcpand_udptypes in thelocaldomain; 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,createAppShortcutandsetLaunchTypefail with Chrome’s errors for a non-app, andgenerateAppForLinkfails after Chrome’s checks. - User scripts:
runAt: "document_idle"runs at document end;includeGlobs/excludeGlobsare checked by a guard around the code, and thejssources 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 carryframeId, and their sender’stab, for main frames only; a message nobody answers fails after 5 minutes.userScripts.executerejectsdocumentIds, and in aMAINworld whose page CSP refusesevala promise result reports null. A world’scspis 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
getUserMediaasking for audio alone withchromeMediaSourcerejects withNotSupportedError, an audio request beside video gets the video track only, andchooseDesktopMediaalways reportscanRequestAudioTrack: false. Othermandatoryandoptionalconstraints are ignored.desktopCaptureshows Lilo’s tab picker whentabis a source (with a “screen or window” choice when those are too), else the system content-sharing picker;cancelChooseDesktopMediacannot close the system picker once shown, and its pick is then dropped.onStatusChangedfirespendingandactivetogether when the stream starts, andgetCapturedTabslists 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.geolocationandNotification, granted without asking when the extension holds thegeolocationornotificationspermission, 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’sshowNotification. - Engine globals: extension pages and the worker have no
SharedArrayBuffer, even with the manifest’scross_origin_embedder_policyandcross_origin_opener_policy, which WebKit ignores: Distill Web Monitor cannot open its store. They get Lilo’snavigator.connection(WebKit has none), which always reads as a fast connection (effectiveType4g,rtt50,downlink10,saveDatafalse) and as no speed offline; itschangefires when the network goes or comes back, never for a change of speed. They and content scripts also getSymbol.disposeandSymbol.asyncDispose, which WebKit leaves off, sousingcompiled by TypeScript, esbuild or Babel runs (Bitwarden’s vault unlock); nativeusingsyntax is still a syntax error there, andDisposableStackandAsyncDisposableStackare missing; main-world scripts (world: 'MAIN') do not getSymbol.dispose, and iterators have no[Symbol.dispose]or[Symbol.asyncDispose]. JavaScriptCore also lacksTemporal,Iterator.prototype.joinandincludes,Iterator.zipandzipKeyed; before macOS 26.3,Iterator.concat; before 26.1,MapandWeakMap’sgetOrInsertandgetOrInsertComputedandMath.sumPrecise.Date.parsereturnsNaNfor formats V8 accepts (01-02-2024,2024.01.02,02.01.2024, a time followed byUTC). On macOS 26, a module with top-levelawaitthat several modules import at once can reject one import with aReferenceError(webkit.org/b/242740).Error.prepareStackTracehas no effect and stacks keep JavaScriptCore’s format (Error.captureStackTraceexists). Lilo polyfills none of them. Pages Lilo opens (side panel, offscreen document, extension tabs) get the WebKit feature flags tabs get,about:configoverrides included; the popup and the worker, which WebKit creates, keep WebKit’s defaults, sorequestIdleCallbackandshowPicker()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 andnavigator.userAgentData, and achrome.webstorePrivateshim, 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 fromclients2.google.comsend the same Chrome user agent. - Extension code (extension pages, the background worker and content scripts’ isolated world) reads Chrome’s
navigator.userAgent,appVersion,vendoranduserAgentData; 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.connectNativeandsendNativeMessagefollow Chrome’s stdio protocol (ExtensionNativeMessaging) for extensions that declarenativeMessaginginpermissions, or inoptional_permissionsoncepermissions.requestgot it granted. A host that restricts which browser may launch it still refuses Lilo (see iCloud Passwords extension). - Host manifests (
<name>.json,type: stdio, absolutepath) are read from theNativeMessagingHostsfolders in this order: under~/Library/Application Support/,Lilo,Google/Chrome,Google/Chrome Beta,Google/Chrome DevandGoogle/Chrome Canary; then/Library/Google/Chrome/NativeMessagingHosts;Chromiumunder~/Library/Application Support/, then/Library/Application Support/Chromium/NativeMessagingHosts;Microsoft Edgeunder~/Library/Application Support/, then/Library/Microsoft/Edge/NativeMessagingHosts; and lastBraveSoftware/Brave-Browser,Arc/User DataandVivaldiunder~/Library/Application Support/. Lilo’s own folder comes first; the first manifest whoseallowed_originslistschrome-extension://<id>/wins. Firefox manifests are not read. The host gets that origin asargv[1]. - Failures: a
chrome.runtime.connectNativeport to a host that is missing, refused or exits disconnects with Chrome’s error asruntime.lastError. One opened through WebKit’sbrowser.runtimedisconnects without it, and only once it sends its first message (a port that never sends stays open).sendNativeMessagefails withNative 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 resourcesin its worker: Emscripten resolves the binary against the worker’s own directory instead of using the bytes Dashlane fetches fromassets/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 theapplication/wasmtype, whichWebAssembly.instantiateStreamingandcompileStreamingreject. For the extension’s own responses, Lilo compiles from the bytes instead (shared/wasm-mime.ts), in pages and in the worker.