Page Widget Extensibility API #
Extensions can hook into the page host lifecycle, execute content scripts in the page webview, and display widgets alongside the page content.
Architecture #
Extension (background.js) Page Host (page.js)
| |
|-- widget:register -------------> | stores registration
|<-- widget:registered ---------- | confirms
| |
|<-- page:loaded ---------------- | page finishes loading
| |
|-- page:execute-script ---------> | runs in webview
|<-- page:script-result --------- | returns result
| |
|-- widget:render ---------------> | creates widget DOM
|-- widget:update ---------------> | updates widget content
|-- widget:close ----------------> | removes widget
| |
|<-- page:navigated ------------- | in-page navigation
|<-- page:will-close ------------ | window closing
All communication uses the pubsub system (window.app.publish / window.app.subscribe) with GLOBAL scope.
Pubsub Topics #
Extension to Page Host #
widget:register #
Register a widget type. Must be called before widget:render.
api.publish('widget:register', {
extensionId: 'my-extension', // Required: your extension ID
widgetId: 'my-widget', // Required: unique widget ID within your extension
title: 'My Widget', // Optional: display title (defaults to widgetId)
position: 'right', // Optional: 'right' (default). Reserved: 'left', 'bottom'
}, api.scopes.GLOBAL);
Response: widget:registered is published with { extensionId, widgetId, success: true }.
widget:render #
Populate a registered widget with content. If the widget is already rendered, it is replaced.
api.publish('widget:render', {
extensionId: 'my-extension',
widgetId: 'my-widget',
title: 'Updated Title', // Optional: overrides registration title
html: '<p>Widget content</p>',// HTML string for the widget body
autoDismiss: 0, // Optional: auto-close after N ms (0 = never)
}, api.scopes.GLOBAL);
widget:update #
Update an already-rendered widget's content or title without replacing the DOM element.
api.publish('widget:update', {
extensionId: 'my-extension',
widgetId: 'my-widget',
html: '<p>New content</p>', // Optional: new body content
title: 'New Title', // Optional: new title
}, api.scopes.GLOBAL);
widget:close #
Remove a widget from the page.
api.publish('widget:close', {
extensionId: 'my-extension',
widgetId: 'my-widget',
}, api.scopes.GLOBAL);
page:execute-script #
Execute JavaScript in the page's webview. Results are returned via page:script-result.
api.publish('page:execute-script', {
extensionId: 'my-extension',
requestId: 'unique-request-id', // Required: correlate with response
script: 'document.title', // Required: JS code to execute
}, api.scopes.GLOBAL);
Page Host to Extension #
page:loaded #
Published when a page finishes loading. Already existed before this API.
api.subscribe('page:loaded', (msg) => {
// msg.url - the loaded page URL
// msg.title - the page title
// msg.opensearchUrl - OpenSearch URL if detected
}, api.scopes.GLOBAL);
page:navigated #
Published on in-page navigation (did-navigate).
api.subscribe('page:navigated', (msg) => {
// msg.url - the new URL
// msg.title - the page title
}, api.scopes.GLOBAL);
page:will-close #
Published when the page window is about to close.
api.subscribe('page:will-close', (msg) => {
// msg.url - the current page URL
}, api.scopes.GLOBAL);
page:script-result #
Response to a page:execute-script request.
api.subscribe('page:script-result', (msg) => {
if (msg.requestId !== myRequestId) return; // correlate
if (msg.success) {
console.log('Result:', msg.result);
} else {
console.error('Error:', msg.error);
}
}, api.scopes.GLOBAL);
widget:registered #
Confirmation after widget:register.
widget:closed #
Published when a widget is closed (by user clicking X, or via widget:close).
Content Script Execution Pattern #
The request/response pattern uses requestId for correlation:
function executeScript(script, timeout = 5000) {
return new Promise((resolve, reject) => {
const requestId = `${extensionId}-${Date.now()}-${Math.random()}`;
let timer, unsub;
unsub = api.subscribe('page:script-result', (msg) => {
if (msg.requestId !== requestId) return;
clearTimeout(timer);
unsub();
msg.success ? resolve(msg.result) : reject(new Error(msg.error));
}, api.scopes.GLOBAL);
timer = setTimeout(() => { unsub(); reject(new Error('Timeout')); }, timeout);
api.publish('page:execute-script', {
extensionId, requestId, script
}, api.scopes.GLOBAL);
});
}
Widget Positioning #
Widgets are placed in the widget container to the right of the page webview. They stack vertically with an 8px gap. Each widget has:
- A header with title and close button
- A body that accepts HTML content
- Max width of 280px, min width of 200px
- Glass-morphism background with backdrop blur
Widgets share the navbar show/hide lifecycle -- they are part of the page chrome.
Sample Extension #
See apps/desktop/features/pagewidgets-sample/ for a complete working example that:
- Registers a "Page Summary" widget on startup
- Listens for
page:loadedevents - Executes a content script to extract page metadata (title, description, headings, word count, link count)
- Renders the metadata in its widget
- Updates on navigation, cleans up on page close
Testing #
Unit tests #
node --test tests/unit/page-widgets.test.js
Tests the createPageWidgetHost factory function with mock dependencies. Covers registration, rendering, update, close, content script execution, and lifecycle events.
Playwright tests #
BACKEND=electron yarn test:electron tests/desktop/page-widgets.spec.ts
End-to-end tests that open real page windows and verify widget registration, rendering, update, close, and content script execution through the full pubsub pipeline.