Extensions in a shell

The config names Chrome extensions, and the engine installs them into the browser windows' profile. Content scripts and network rules need nothing from the shell. The shell draws their toolbar buttons (actions) and popups.

Part of How a shell works. Design: EXTENSIONS.md. Manganese's tray button: extensions/ExtensionAction.tsx.

The config

{  "extensions": {    "web_store": ["ddkjiahejlhfcafbddmgiahcphecmpfh"],    "unpacked": ["~/src/my-extension"]  }}
  • web_store: Chrome Web Store ids (this one is uBlock Origin Lite).
  • unpacked: directories, absolute or under ~.
  • Listed extensions install without a prompt and get every permission their manifest declares. Camera, microphone and the other site permissions are asked for like a site's.
  • Removing one from the list uninstalls it on reload.

The list

domicile.extensions.list is the full list; extensions.on("change") reports changes:

const Tray = () => {  const { extensions } = useDomicile();  const list = useWatch(extensions, "change", () => extensions.list) ?? [];  return list    .filter(({ enabled }) => enabled)    .map((extension) => <TrayIcon extension={extension} key={extension.id} />);};
FieldWhat
idWhat extensions.activate takes
nameThe extension's name
titleThe action's tooltip and the icon's accessible name
iconA data:image/png URL at the page's device pixel ratio
badgeText, badgeColorThe badge. badgeColor is #rrggbbaa, transparent when unset
popupThe popup's chrome-extension:// URL, or null
enabledfalse after action.disable()

The row type is DomicileExtension. extensionSchema, from @domicile-desktop/sdk/extension, parses one into an Extension.

A click

Call domicile.extensions.activate(id) on every click, with or without a popup. This grants activeTab on the focused browser window, as Chrome's toolbar does, so scripting.executeScript and tab.url work there. Then:

  • No popup: done. The engine fires action.onClicked.
  • A popup: open a <webview extensionpopup> at that URL in a panel under the icon.

An extension can also ask for its popup with action.openPopup(). Phantom does this from action.onClicked, so its row has no popup. extensions.on("popup", ({ id, popup }) => …) reports the request: open popup under id's icon as for a click. The request carries the URL because the extension may clear its row's popup straight after.

import { WEBVIEW_CLOSE_EVENT } from "@domicile-desktop/sdk/webview-element";const Popup = ({ extension, onClose }) => {  const viewRef = useRef<HTMLElement>(null);  useEffect(() => {    const view = viewRef.current;    view?.addEventListener(WEBVIEW_CLOSE_EVENT, onClose);    return () => view?.removeEventListener(WEBVIEW_CLOSE_EVENT, onClose);  }, [onClose]);  return <webview extensionpopup="" ref={viewRef} src={extension.popup} />;};
  • extensionpopup: set it before appending. The engine reads it once. Without it the popup loads as a tab, and extensions that lay out by context (for example, Bitwarden) render as a full page.
  • Size: size the view to contentWidth and contentHeight (0 until laid out), announced in domicile-content-size-change. The page sizes itself as in Chrome, between 25x25 and 800x600, and scrolls past that.
  • Showing: show the panel once the page has sized itself and loading is false, as Chrome does. While it loads it is still growing.
  • Keyboard: take it with windows.focusShell while the panel is open, and give it back on close (Who gets the keyboard).
  • Closing: close the panel on domicile-close (the popup called window.close()), on a press outside it, and on Escape. Escape reaches you only while your page has focus, not while the popup's page does (The keyboard).
  • Permissions: the popup fires domicile-permission-request like a browser window (Permissions). Unclaimed, the extension gets nothing. A choice is stored for the extension's chrome-extension:// origin, so it holds in all its pages.

Tabs and windows

Every <webview> is a tab, and the desktop is one chrome.windows window. The active tab is the view that last took focus. So view.focus() sets what tabs.query({active: true, currentWindow: true}) returns, and the tray shows that tab's state.

The browser carries out some extension calls. Others fire an event on a <webview>:

Extension callWhat happens
tabs.create({url})A browser window opens and appears in the next browserwindowschanged
windows.create({type: "popup", url})A browser window opens as that popup window's tab (below)
tabs.remove(id)That browser window closes. A view of your own fires domicile-close (Close requests)
tabs.update(id, {active: true}), windows.update(id, {focused: true})domicile-focus-request (WEBVIEW_FOCUS_REQUEST_EVENT): raise that window
tabs.setZoom(id, factor)domicile-zoom-change: already applied
action.openPopup()extensions.on("popup"): open that popup (A click)
sidePanel.open()A popup window showing the side panel, with no size (0 by 0), in browserwindowschanged: tile it; a second call sends domicile-focus-request for it. sidePanel.close() closes it

Calls with no desktop meaning (tabs.move, tabs.group, tabs.discard, …) fail with not supported on a Domicile desk.

Extension popup windows

chrome.windows.create({type: "popup", url}) (for example, Bitwarden's "Unlock") opens an ordinary browser window (The engine owns browser windows):

  • It is listed with popupWindow (its chrome.windows id) and the requested width and height (0 if unset). Placement is yours. Manganese floats one with a size and tiles one without, such as a side panel.
  • It shows an extension's page, so draw it with no address bar, as Chrome does.
  • windows.remove(popupWindow) closes it.
  • windows.update(popupWindow, {focused: true}) fires domicile-focus-request on its view.
This page on GitHub