···11+/**
22+ * Page Widget Host API
33+ *
44+ * Manages extension-provided widgets in the page host window. Extensions
55+ * communicate via pubsub to register, render, update, and close widgets.
66+ * The host publishes lifecycle events so extensions can react to page
77+ * navigation and loading.
88+ *
99+ * Integration: imported by page.js, which calls init() with the webview
1010+ * element and pubsub api reference.
1111+ *
1212+ * Pubsub topics (extension -> host):
1313+ * widget:register - Register a widget type
1414+ * widget:render - Populate a widget with content
1515+ * widget:update - Update an existing widget
1616+ * widget:close - Remove a widget
1717+ * page:execute-script - Request script execution in page webview
1818+ *
1919+ * Pubsub topics (host -> extension):
2020+ * page:loaded - Page finished loading (already exists in page.js)
2121+ * page:navigated - In-page navigation occurred
2222+ * page:will-close - Page window is about to close
2323+ * page:script-result - Result of script execution
2424+ * widget:registered - Confirmation of widget registration
2525+ */
2626+2727+/**
2828+ * @typedef {Object} WidgetRegistration
2929+ * @property {string} extensionId - The registering extension's ID
3030+ * @property {string} widgetId - Unique widget identifier
3131+ * @property {string} [title] - Widget title
3232+ * @property {string} [position='right'] - 'right' | 'left' | 'bottom'
3333+ * @property {number} [width] - Preferred width in px
3434+ * @property {number} [maxHeight] - Maximum height in px
3535+ */
3636+3737+/**
3838+ * @typedef {Object} PageWidgetHost
3939+ * @property {function} init - Initialize the widget host
4040+ * @property {function} destroy - Tear down subscriptions and widgets
4141+ * @property {function} getWidgets - Get all registered widgets
4242+ * @property {function} publishNavigated - Publish navigation event
4343+ * @property {function} publishWillClose - Publish will-close event
4444+ */
4545+4646+/**
4747+ * Create a page widget host instance.
4848+ *
4949+ * Pure factory function — no global state. All state is local to the
5050+ * returned object, making it testable without DOM.
5151+ *
5252+ * @param {object} deps - Dependencies injected by page.js
5353+ * @param {object} deps.api - The pubsub/IPC API (window.app)
5454+ * @param {HTMLElement} deps.webview - The <webview> element
5555+ * @param {HTMLElement} deps.widgetContainer - Container for right-side widgets
5656+ * @param {function} deps.addWidget - page.js addWidget(id, opts)
5757+ * @param {function} deps.updateWidget - page.js updateWidget(id, opts)
5858+ * @param {function} deps.removeWidget - page.js removeWidget(id)
5959+ * @returns {PageWidgetHost}
6060+ */
6161+export function createPageWidgetHost(deps) {
6262+ const { api, webview, widgetContainer, addWidget, updateWidget, removeWidget } = deps;
6363+6464+ /** @type {Map<string, WidgetRegistration>} */
6565+ const registrations = new Map();
6666+6767+ /** @type {Map<string, object>} widget handles from addWidget */
6868+ const activeWidgets = new Map();
6969+7070+ /** @type {Array<function>} unsubscribe callbacks */
7171+ const unsubs = [];
7272+7373+ /** @type {Map<string, {resolve: function, reject: function}>} pending script requests */
7474+ const pendingScripts = new Map();
7575+7676+ const DEBUG = false;
7777+7878+ // --- Subscription helpers ---
7979+8080+ function subscribe(topic, handler) {
8181+ const unsub = api.subscribe(topic, handler, api.scopes.GLOBAL);
8282+ unsubs.push(unsub);
8383+ return unsub;
8484+ }
8585+8686+ // --- Widget registration ---
8787+8888+ function handleRegister(msg) {
8989+ if (!msg || !msg.extensionId || !msg.widgetId) {
9090+ DEBUG && console.log('[page-widgets] Invalid register message:', msg);
9191+ return;
9292+ }
9393+9494+ const key = `${msg.extensionId}:${msg.widgetId}`;
9595+ const registration = {
9696+ extensionId: msg.extensionId,
9797+ widgetId: msg.widgetId,
9898+ title: msg.title || msg.widgetId,
9999+ position: msg.position || 'right',
100100+ width: msg.width || undefined,
101101+ maxHeight: msg.maxHeight || undefined,
102102+ };
103103+104104+ registrations.set(key, registration);
105105+106106+ DEBUG && console.log('[page-widgets] Registered widget:', key, registration);
107107+108108+ api.publish('widget:registered', {
109109+ extensionId: msg.extensionId,
110110+ widgetId: msg.widgetId,
111111+ success: true,
112112+ }, api.scopes.GLOBAL);
113113+ }
114114+115115+ // --- Widget rendering ---
116116+117117+ function handleRender(msg) {
118118+ if (!msg || !msg.extensionId || !msg.widgetId) {
119119+ DEBUG && console.log('[page-widgets] Invalid render message:', msg);
120120+ return;
121121+ }
122122+123123+ const key = `${msg.extensionId}:${msg.widgetId}`;
124124+ const registration = registrations.get(key);
125125+ if (!registration) {
126126+ console.warn('[page-widgets] Cannot render unregistered widget:', key);
127127+ return;
128128+ }
129129+130130+ // Remove existing widget if re-rendering
131131+ if (activeWidgets.has(key)) {
132132+ removeWidget(key);
133133+ activeWidgets.delete(key);
134134+ }
135135+136136+ const handle = addWidget(key, {
137137+ title: msg.title || registration.title,
138138+ content: msg.html || msg.content || '',
139139+ autoDismiss: msg.autoDismiss || 0,
140140+ onClose: () => {
141141+ activeWidgets.delete(key);
142142+ api.publish('widget:closed', {
143143+ extensionId: msg.extensionId,
144144+ widgetId: msg.widgetId,
145145+ }, api.scopes.GLOBAL);
146146+ },
147147+ });
148148+149149+ if (handle) {
150150+ activeWidgets.set(key, handle);
151151+ DEBUG && console.log('[page-widgets] Rendered widget:', key);
152152+ }
153153+ }
154154+155155+ // --- Widget update ---
156156+157157+ function handleUpdate(msg) {
158158+ if (!msg || !msg.extensionId || !msg.widgetId) return;
159159+160160+ const key = `${msg.extensionId}:${msg.widgetId}`;
161161+ if (!activeWidgets.has(key)) {
162162+ DEBUG && console.log('[page-widgets] Cannot update inactive widget:', key);
163163+ return;
164164+ }
165165+166166+ updateWidget(key, {
167167+ title: msg.title,
168168+ content: msg.html || msg.content,
169169+ });
170170+171171+ DEBUG && console.log('[page-widgets] Updated widget:', key);
172172+ }
173173+174174+ // --- Widget close ---
175175+176176+ function handleClose(msg) {
177177+ if (!msg || !msg.extensionId || !msg.widgetId) return;
178178+179179+ const key = `${msg.extensionId}:${msg.widgetId}`;
180180+ if (activeWidgets.has(key)) {
181181+ removeWidget(key);
182182+ activeWidgets.delete(key);
183183+ DEBUG && console.log('[page-widgets] Closed widget:', key);
184184+ }
185185+ }
186186+187187+ // --- Content script execution ---
188188+189189+ function handleExecuteScript(msg) {
190190+ if (!msg || !msg.extensionId || !msg.requestId || !msg.script) {
191191+ DEBUG && console.log('[page-widgets] Invalid execute-script message:', msg);
192192+ return;
193193+ }
194194+195195+ if (!webview) {
196196+ api.publish('page:script-result', {
197197+ requestId: msg.requestId,
198198+ extensionId: msg.extensionId,
199199+ error: 'No webview available',
200200+ }, api.scopes.GLOBAL);
201201+ return;
202202+ }
203203+204204+ // Execute the script in the webview and publish the result
205205+ webview.executeJavaScript(msg.script)
206206+ .then((result) => {
207207+ api.publish('page:script-result', {
208208+ requestId: msg.requestId,
209209+ extensionId: msg.extensionId,
210210+ result,
211211+ success: true,
212212+ }, api.scopes.GLOBAL);
213213+ })
214214+ .catch((err) => {
215215+ api.publish('page:script-result', {
216216+ requestId: msg.requestId,
217217+ extensionId: msg.extensionId,
218218+ error: err.message || String(err),
219219+ success: false,
220220+ }, api.scopes.GLOBAL);
221221+ });
222222+ }
223223+224224+ // --- Lifecycle event publishers ---
225225+226226+ function publishNavigated(url, title) {
227227+ api.publish('page:navigated', { url, title }, api.scopes.GLOBAL);
228228+ }
229229+230230+ function publishWillClose(url) {
231231+ api.publish('page:will-close', { url }, api.scopes.GLOBAL);
232232+ }
233233+234234+ // --- Init / Destroy ---
235235+236236+ function init() {
237237+ subscribe('widget:register', handleRegister);
238238+ subscribe('widget:render', handleRender);
239239+ subscribe('widget:update', handleUpdate);
240240+ subscribe('widget:close', handleClose);
241241+ subscribe('page:execute-script', handleExecuteScript);
242242+243243+ DEBUG && console.log('[page-widgets] Initialized');
244244+ }
245245+246246+ function destroy() {
247247+ // Publish will-close before tearing down
248248+ try {
249249+ const url = webview?.getURL?.();
250250+ if (url) publishWillClose(url);
251251+ } catch {
252252+ // webview may not be ready
253253+ }
254254+255255+ // Remove all active widgets
256256+ for (const key of activeWidgets.keys()) {
257257+ removeWidget(key);
258258+ }
259259+ activeWidgets.clear();
260260+ registrations.clear();
261261+262262+ // Clear pending script requests
263263+ for (const [, pending] of pendingScripts) {
264264+ pending.reject(new Error('Widget host destroyed'));
265265+ }
266266+ pendingScripts.clear();
267267+268268+ // Unsubscribe all
269269+ for (const unsub of unsubs) {
270270+ if (typeof unsub === 'function') unsub();
271271+ }
272272+ unsubs.length = 0;
273273+274274+ DEBUG && console.log('[page-widgets] Destroyed');
275275+ }
276276+277277+ function getWidgets() {
278278+ return new Map(registrations);
279279+ }
280280+281281+ function getActiveWidgets() {
282282+ return new Map(activeWidgets);
283283+ }
284284+285285+ return {
286286+ init,
287287+ destroy,
288288+ getWidgets,
289289+ getActiveWidgets,
290290+ publishNavigated,
291291+ publishWillClose,
292292+ // Exposed for testing
293293+ _handleRegister: handleRegister,
294294+ _handleRender: handleRender,
295295+ _handleUpdate: handleUpdate,
296296+ _handleClose: handleClose,
297297+ _handleExecuteScript: handleExecuteScript,
298298+ };
299299+}
+329
app/page/page.js
···1818 */
19192020import api from '../api.js';
2121+import { createPageWidgetHost } from './page-widgets.js';
21222223console.log('[page] Script loaded');
2324···15091510 DEBUG && console.log('[page] Widget updated:', id);
15101511}
1511151215131513+// --- Page Widget Host (extension-provided widgets via pubsub) ---
15141514+15151515+const pageWidgetHost = createPageWidgetHost({
15161516+ api,
15171517+ webview,
15181518+ widgetContainer,
15191519+ addWidget,
15201520+ updateWidget,
15211521+ removeWidget,
15221522+});
15231523+pageWidgetHost.init();
15241524+15251525+// Publish page:navigated on in-page navigation
15261526+webview.addEventListener('did-navigate', (e) => {
15271527+ const title = document.title || '';
15281528+ pageWidgetHost.publishNavigated(e.url, title);
15291529+});
15301530+15311531+// Publish page:will-close when window is closing
15321532+window.addEventListener('beforeunload', () => {
15331533+ pageWidgetHost.destroy();
15341534+});
15351535+15361536+// Expose for testing
15371537+if (typeof window !== 'undefined') {
15381538+ window.__pageWidgetHost = pageWidgetHost;
15391539+}
15401540+15121541// --- OpenSearch discovery widget ---
1513154215141543api.subscribe('websearch:engine-discovered', (msg) => {
···19742003webview.addEventListener('did-finish-load', () => {
19752004 setTimeout(extractSimpleEntities, 2000);
19762005});
20062006+20072007+// --- Page Command Handlers (pubsub) ---
20082008+// Commands from the cmd palette publish page:cmd:request, we handle them here
20092009+// by running executeJavaScript on the webview and publishing the result back.
20102010+20112011+api.subscribe('page:cmd:request', async (msg) => {
20122012+ if (!msg || !msg.requestId || !msg.action) return;
20132013+20142014+ // Only respond if we are the focused page window
20152015+ if (msg.windowId != null && msg.windowId !== myWindowId) return;
20162016+20172017+ const { requestId, action } = msg;
20182018+20192019+ const respond = (data, error) => {
20202020+ api.publish('page:cmd:response', {
20212021+ requestId,
20222022+ data: data || null,
20232023+ error: error || null
20242024+ }, api.scopes.GLOBAL);
20252025+ };
20262026+20272027+ try {
20282028+ switch (action) {
20292029+ case 'list-images': {
20302030+ const images = await webview.executeJavaScript(`
20312031+ (function() {
20322032+ var results = [];
20332033+ var seen = new Set();
20342034+ var imgs = document.querySelectorAll('img[src]');
20352035+ for (var i = 0; i < imgs.length; i++) {
20362036+ var img = imgs[i];
20372037+ var src = img.src;
20382038+ if (!src || seen.has(src)) continue;
20392039+ seen.add(src);
20402040+ var alt = (img.alt || '').trim();
20412041+ var w = img.naturalWidth || img.width || 0;
20422042+ var h = img.naturalHeight || img.height || 0;
20432043+ results.push({
20442044+ src: src,
20452045+ alt: alt,
20462046+ width: w,
20472047+ height: h,
20482048+ title: alt || src.split('/').pop().split('?')[0]
20492049+ });
20502050+ }
20512051+ // Also check CSS background images on visible elements
20522052+ var els = document.querySelectorAll('[style*="background-image"]');
20532053+ for (var j = 0; j < Math.min(els.length, 50); j++) {
20542054+ var style = window.getComputedStyle(els[j]);
20552055+ var bg = style.backgroundImage;
20562056+ if (bg && bg !== 'none') {
20572057+ var match = bg.match(/url\\(["']?([^"')]+)["']?\\)/);
20582058+ if (match && match[1] && !seen.has(match[1])) {
20592059+ seen.add(match[1]);
20602060+ results.push({
20612061+ src: match[1],
20622062+ alt: 'Background image',
20632063+ width: 0,
20642064+ height: 0,
20652065+ title: match[1].split('/').pop().split('?')[0]
20662066+ });
20672067+ }
20682068+ }
20692069+ }
20702070+ return results;
20712071+ })();
20722072+ `);
20732073+ respond(images);
20742074+ break;
20752075+ }
20762076+20772077+ case 'list-feeds': {
20782078+ const feeds = await webview.executeJavaScript(`
20792079+ (function() {
20802080+ var results = [];
20812081+ var feedLinks = document.querySelectorAll(
20822082+ 'link[type="application/rss+xml"], ' +
20832083+ 'link[type="application/atom+xml"], ' +
20842084+ 'link[type="application/feed+json"], ' +
20852085+ 'link[rel="alternate"][type*="xml"]'
20862086+ );
20872087+ for (var i = 0; i < feedLinks.length; i++) {
20882088+ var link = feedLinks[i];
20892089+ results.push({
20902090+ url: link.href,
20912091+ title: link.title || link.type || 'Feed',
20922092+ type: link.type || 'unknown'
20932093+ });
20942094+ }
20952095+ // Also look for common feed URL patterns in <a> tags
20962096+ var anchors = document.querySelectorAll('a[href]');
20972097+ var feedPatterns = ['/feed', '/rss', '/atom', '.rss', '.xml', '/feeds/'];
20982098+ var seen = new Set(results.map(function(r) { return r.url; }));
20992099+ for (var j = 0; j < anchors.length; j++) {
21002100+ var href = anchors[j].href;
21012101+ if (!href || seen.has(href)) continue;
21022102+ var lower = href.toLowerCase();
21032103+ for (var k = 0; k < feedPatterns.length; k++) {
21042104+ if (lower.includes(feedPatterns[k])) {
21052105+ seen.add(href);
21062106+ results.push({
21072107+ url: href,
21082108+ title: anchors[j].textContent.trim().slice(0, 60) || 'Feed link',
21092109+ type: 'link'
21102110+ });
21112111+ break;
21122112+ }
21132113+ }
21142114+ }
21152115+ return results;
21162116+ })();
21172117+ `);
21182118+ respond(feeds);
21192119+ break;
21202120+ }
21212121+21222122+ case 'list-links': {
21232123+ const links = await webview.executeJavaScript(`
21242124+ (function() {
21252125+ var results = [];
21262126+ var seen = new Set();
21272127+ var anchors = document.querySelectorAll('a[href]');
21282128+ var pageHost = window.location.hostname;
21292129+ for (var i = 0; i < anchors.length; i++) {
21302130+ var a = anchors[i];
21312131+ var href = a.href;
21322132+ if (!href || !href.startsWith('http') || seen.has(href)) continue;
21332133+ seen.add(href);
21342134+ var text = (a.textContent || '').trim().slice(0, 100);
21352135+ if (!text || text.length < 2) continue;
21362136+ var isExternal = false;
21372137+ try { isExternal = new URL(href).hostname !== pageHost; } catch(e) {}
21382138+ results.push({
21392139+ url: href,
21402140+ text: text,
21412141+ title: text,
21422142+ external: isExternal
21432143+ });
21442144+ }
21452145+ return results;
21462146+ })();
21472147+ `);
21482148+ respond(links);
21492149+ break;
21502150+ }
21512151+21522152+ case 'list-entities': {
21532153+ // Return cached entities if available, otherwise do live extraction
21542154+ if (currentPageEntities && currentPageEntities.length > 0) {
21552155+ respond(currentPageEntities);
21562156+ } else {
21572157+ // Do a quick extraction
21582158+ const entities = await webview.executeJavaScript(`
21592159+ (function() {
21602160+ var entities = [];
21612161+ var seen = new Set();
21622162+21632163+ // Emails from mailto links
21642164+ var mailLinks = document.querySelectorAll('a[href^="mailto:"]');
21652165+ for (var i = 0; i < mailLinks.length; i++) {
21662166+ var email = mailLinks[i].href.replace('mailto:', '').split('?')[0];
21672167+ if (email && !seen.has(email)) {
21682168+ seen.add(email);
21692169+ entities.push({ name: email, type: 'email' });
21702170+ }
21712171+ }
21722172+21732173+ // Emails from text content (regex)
21742174+ var textContent = document.body ? document.body.innerText : '';
21752175+ var emailRegex = /[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}/g;
21762176+ var emailMatches = textContent.match(emailRegex) || [];
21772177+ for (var j = 0; j < emailMatches.length; j++) {
21782178+ if (!seen.has(emailMatches[j])) {
21792179+ seen.add(emailMatches[j]);
21802180+ entities.push({ name: emailMatches[j], type: 'email' });
21812181+ }
21822182+ }
21832183+21842184+ // Phone numbers from tel links
21852185+ var telLinks = document.querySelectorAll('a[href^="tel:"]');
21862186+ for (var k = 0; k < telLinks.length; k++) {
21872187+ var phone = telLinks[k].href.replace('tel:', '');
21882188+ if (phone && !seen.has(phone)) {
21892189+ seen.add(phone);
21902190+ entities.push({ name: phone, type: 'phone' });
21912191+ }
21922192+ }
21932193+21942194+ // External domains as organizations
21952195+ var links = document.querySelectorAll('a[href]');
21962196+ var domainCounts = {};
21972197+ for (var l = 0; l < Math.min(links.length, 500); l++) {
21982198+ try {
21992199+ var host = new URL(links[l].href).hostname;
22002200+ if (host === window.location.hostname) continue;
22012201+ if (!domainCounts[host]) domainCounts[host] = 0;
22022202+ domainCounts[host]++;
22032203+ } catch(e) {}
22042204+ }
22052205+ // Only include domains referenced multiple times (likely organizations)
22062206+ Object.keys(domainCounts).forEach(function(domain) {
22072207+ if (domainCounts[domain] >= 2 && !seen.has(domain)) {
22082208+ seen.add(domain);
22092209+ entities.push({ name: domain, type: 'organization' });
22102210+ }
22112211+ });
22122212+22132213+ return entities.slice(0, 100);
22142214+ })();
22152215+ `);
22162216+ respond(entities);
22172217+ }
22182218+ break;
22192219+ }
22202220+22212221+ case 'view-reader': {
22222222+ // Toggle reader mode by injecting/removing a simplified view
22232223+ const result = await webview.executeJavaScript(`
22242224+ (function() {
22252225+ // Check if already in reader mode
22262226+ if (document.getElementById('__peek_reader_mode')) {
22272227+ // Exit reader mode: restore original content
22282228+ var overlay = document.getElementById('__peek_reader_mode');
22292229+ overlay.remove();
22302230+ document.body.style.overflow = '';
22312231+ return { active: false, message: 'Reader mode off' };
22322232+ }
22332233+22342234+ // Enter reader mode: extract readable content
22352235+ var title = document.title || '';
22362236+ var article = document.querySelector('article') ||
22372237+ document.querySelector('[role="main"]') ||
22382238+ document.querySelector('main') ||
22392239+ document.querySelector('.post-content') ||
22402240+ document.querySelector('.article-content') ||
22412241+ document.querySelector('.entry-content');
22422242+22432243+ var content = '';
22442244+ if (article) {
22452245+ content = article.innerHTML;
22462246+ } else {
22472247+ // Fallback: use body but strip nav, header, footer, aside, script, style
22482248+ var clone = document.body.cloneNode(true);
22492249+ var remove = clone.querySelectorAll('nav, header, footer, aside, script, style, .nav, .header, .footer, .sidebar, .menu, .ad, .advertisement, [role="navigation"], [role="banner"], [role="complementary"]');
22502250+ for (var i = 0; i < remove.length; i++) {
22512251+ remove[i].remove();
22522252+ }
22532253+ content = clone.innerHTML;
22542254+ }
22552255+22562256+ var overlay = document.createElement('div');
22572257+ overlay.id = '__peek_reader_mode';
22582258+ overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:999999;background:#fff;color:#222;overflow-y:auto;padding:40px;font-family:Georgia,serif;font-size:18px;line-height:1.6;';
22592259+ overlay.innerHTML =
22602260+ '<div style="max-width:700px;margin:0 auto;">' +
22612261+ '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;padding-bottom:10px;border-bottom:1px solid #ddd;">' +
22622262+ '<span style="color:#888;font-size:14px;font-family:system-ui,sans-serif;">Reader Mode</span>' +
22632263+ '<button id="__peek_reader_close" style="background:none;border:1px solid #ccc;padding:4px 12px;border-radius:4px;cursor:pointer;font-size:13px;font-family:system-ui,sans-serif;">Exit</button>' +
22642264+ '</div>' +
22652265+ '<h1 style="font-size:28px;line-height:1.3;margin-bottom:20px;">' + title.replace(/</g, '<') + '</h1>' +
22662266+ content +
22672267+ '</div>';
22682268+22692269+ document.body.appendChild(overlay);
22702270+ document.body.style.overflow = 'hidden';
22712271+22722272+ // Wire up close button
22732273+ document.getElementById('__peek_reader_close').addEventListener('click', function() {
22742274+ overlay.remove();
22752275+ document.body.style.overflow = '';
22762276+ });
22772277+22782278+ return { active: true, message: 'Reader mode on' };
22792279+ })();
22802280+ `);
22812281+ respond(result);
22822282+ break;
22832283+ }
22842284+22852285+ case 'view-source': {
22862286+ const source = await webview.executeJavaScript(`
22872287+ (function() {
22882288+ return {
22892289+ html: document.documentElement.outerHTML,
22902290+ title: document.title || window.location.href
22912291+ };
22922292+ })();
22932293+ `);
22942294+ respond(source);
22952295+ break;
22962296+ }
22972297+22982298+ default:
22992299+ respond(null, 'Unknown page command action: ' + action);
23002300+ }
23012301+ } catch (err) {
23022302+ console.error('[page] page:cmd:request error:', err);
23032303+ respond(null, err.message || 'Failed to execute page command');
23042304+ }
23052305+}, api.scopes.GLOBAL);
1977230619782307DEBUG && console.log('[page] Minimal page host initialized for:', targetUrl);
+218
docs/page-widgets.md
···11+# Page Widget Extensibility API
22+33+Extensions can hook into the page host lifecycle, execute content scripts in the page webview, and display widgets alongside the page content.
44+55+## Architecture
66+77+```
88+Extension (background.js) Page Host (page.js)
99+ | |
1010+ |-- widget:register -------------> | stores registration
1111+ |<-- widget:registered ---------- | confirms
1212+ | |
1313+ |<-- page:loaded ---------------- | page finishes loading
1414+ | |
1515+ |-- page:execute-script ---------> | runs in webview
1616+ |<-- page:script-result --------- | returns result
1717+ | |
1818+ |-- widget:render ---------------> | creates widget DOM
1919+ |-- widget:update ---------------> | updates widget content
2020+ |-- widget:close ----------------> | removes widget
2121+ | |
2222+ |<-- page:navigated ------------- | in-page navigation
2323+ |<-- page:will-close ------------ | window closing
2424+```
2525+2626+All communication uses the pubsub system (`window.app.publish` / `window.app.subscribe`) with `GLOBAL` scope.
2727+2828+## Pubsub Topics
2929+3030+### Extension to Page Host
3131+3232+#### `widget:register`
3333+3434+Register a widget type. Must be called before `widget:render`.
3535+3636+```js
3737+api.publish('widget:register', {
3838+ extensionId: 'my-extension', // Required: your extension ID
3939+ widgetId: 'my-widget', // Required: unique widget ID within your extension
4040+ title: 'My Widget', // Optional: display title (defaults to widgetId)
4141+ position: 'right', // Optional: 'right' (default). Reserved: 'left', 'bottom'
4242+}, api.scopes.GLOBAL);
4343+```
4444+4545+Response: `widget:registered` is published with `{ extensionId, widgetId, success: true }`.
4646+4747+#### `widget:render`
4848+4949+Populate a registered widget with content. If the widget is already rendered, it is replaced.
5050+5151+```js
5252+api.publish('widget:render', {
5353+ extensionId: 'my-extension',
5454+ widgetId: 'my-widget',
5555+ title: 'Updated Title', // Optional: overrides registration title
5656+ html: '<p>Widget content</p>',// HTML string for the widget body
5757+ autoDismiss: 0, // Optional: auto-close after N ms (0 = never)
5858+}, api.scopes.GLOBAL);
5959+```
6060+6161+#### `widget:update`
6262+6363+Update an already-rendered widget's content or title without replacing the DOM element.
6464+6565+```js
6666+api.publish('widget:update', {
6767+ extensionId: 'my-extension',
6868+ widgetId: 'my-widget',
6969+ html: '<p>New content</p>', // Optional: new body content
7070+ title: 'New Title', // Optional: new title
7171+}, api.scopes.GLOBAL);
7272+```
7373+7474+#### `widget:close`
7575+7676+Remove a widget from the page.
7777+7878+```js
7979+api.publish('widget:close', {
8080+ extensionId: 'my-extension',
8181+ widgetId: 'my-widget',
8282+}, api.scopes.GLOBAL);
8383+```
8484+8585+#### `page:execute-script`
8686+8787+Execute JavaScript in the page's webview. Results are returned via `page:script-result`.
8888+8989+```js
9090+api.publish('page:execute-script', {
9191+ extensionId: 'my-extension',
9292+ requestId: 'unique-request-id', // Required: correlate with response
9393+ script: 'document.title', // Required: JS code to execute
9494+}, api.scopes.GLOBAL);
9595+```
9696+9797+### Page Host to Extension
9898+9999+#### `page:loaded`
100100+101101+Published when a page finishes loading. Already existed before this API.
102102+103103+```js
104104+api.subscribe('page:loaded', (msg) => {
105105+ // msg.url - the loaded page URL
106106+ // msg.title - the page title
107107+ // msg.opensearchUrl - OpenSearch URL if detected
108108+}, api.scopes.GLOBAL);
109109+```
110110+111111+#### `page:navigated`
112112+113113+Published on in-page navigation (did-navigate).
114114+115115+```js
116116+api.subscribe('page:navigated', (msg) => {
117117+ // msg.url - the new URL
118118+ // msg.title - the page title
119119+}, api.scopes.GLOBAL);
120120+```
121121+122122+#### `page:will-close`
123123+124124+Published when the page window is about to close.
125125+126126+```js
127127+api.subscribe('page:will-close', (msg) => {
128128+ // msg.url - the current page URL
129129+}, api.scopes.GLOBAL);
130130+```
131131+132132+#### `page:script-result`
133133+134134+Response to a `page:execute-script` request.
135135+136136+```js
137137+api.subscribe('page:script-result', (msg) => {
138138+ if (msg.requestId !== myRequestId) return; // correlate
139139+ if (msg.success) {
140140+ console.log('Result:', msg.result);
141141+ } else {
142142+ console.error('Error:', msg.error);
143143+ }
144144+}, api.scopes.GLOBAL);
145145+```
146146+147147+#### `widget:registered`
148148+149149+Confirmation after `widget:register`.
150150+151151+#### `widget:closed`
152152+153153+Published when a widget is closed (by user clicking X, or via `widget:close`).
154154+155155+## Content Script Execution Pattern
156156+157157+The request/response pattern uses `requestId` for correlation:
158158+159159+```js
160160+function executeScript(script, timeout = 5000) {
161161+ return new Promise((resolve, reject) => {
162162+ const requestId = `${extensionId}-${Date.now()}-${Math.random()}`;
163163+ let timer, unsub;
164164+165165+ unsub = api.subscribe('page:script-result', (msg) => {
166166+ if (msg.requestId !== requestId) return;
167167+ clearTimeout(timer);
168168+ unsub();
169169+ msg.success ? resolve(msg.result) : reject(new Error(msg.error));
170170+ }, api.scopes.GLOBAL);
171171+172172+ timer = setTimeout(() => { unsub(); reject(new Error('Timeout')); }, timeout);
173173+174174+ api.publish('page:execute-script', {
175175+ extensionId, requestId, script
176176+ }, api.scopes.GLOBAL);
177177+ });
178178+}
179179+```
180180+181181+## Widget Positioning
182182+183183+Widgets are placed in the widget container to the right of the page webview. They stack vertically with an 8px gap. Each widget has:
184184+185185+- A header with title and close button
186186+- A body that accepts HTML content
187187+- Max width of 280px, min width of 200px
188188+- Glass-morphism background with backdrop blur
189189+190190+Widgets share the navbar show/hide lifecycle -- they are part of the page chrome.
191191+192192+## Sample Extension
193193+194194+See `extensions/pagewidgets-sample/` for a complete working example that:
195195+196196+1. Registers a "Page Summary" widget on startup
197197+2. Listens for `page:loaded` events
198198+3. Executes a content script to extract page metadata (title, description, headings, word count, link count)
199199+4. Renders the metadata in its widget
200200+5. Updates on navigation, cleans up on page close
201201+202202+## Testing
203203+204204+### Unit tests
205205+206206+```bash
207207+node --test tests/unit/page-widgets.test.js
208208+```
209209+210210+Tests the `createPageWidgetHost` factory function with mock dependencies. Covers registration, rendering, update, close, content script execution, and lifecycle events.
211211+212212+### Playwright tests
213213+214214+```bash
215215+BACKEND=electron yarn test:electron tests/desktop/page-widgets.spec.ts
216216+```
217217+218218+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.
+2
extensions/cmd/commands/index.js
···1010import editModule from './edit.js';
1111import urlModule from './url.js';
1212import historyModule from './history.js';
1313+import pageModule from './page.js';
13141415// Chaining commands - for command composition pipelines
1516import listsCommand from './lists.js';
···3839 ...editModule.commands,
3940 ...urlModule.commands,
4041 ...historyModule.commands,
4242+ ...pageModule.commands,
41434244 // Chaining commands
4345 listsCommand
+186
extensions/cmd/commands/page.js
···11+/**
22+ * Page commands - interact with web page content in the focused page window
33+ *
44+ * Two command verbs:
55+ * list <type> — enumerate page content (images, feeds, links, entities)
66+ * view <mode> — change how the page is displayed (reader, source, devtools)
77+ *
88+ * These commands communicate with page.js via pubsub:
99+ * 1. Command publishes page:cmd:request with a requestId and action
1010+ * 2. page.js receives it, runs executeJavaScript on the webview
1111+ * 3. page.js publishes page:cmd:response with the requestId and result
1212+ *
1313+ * Commands are restricted to 'page' mode via the modes property.
1414+ */
1515+import api from 'peek://app/api.js';
1616+1717+let requestCounter = 0;
1818+1919+/**
2020+ * Send a request to the focused page window and wait for a response.
2121+ * @param {string} action - The action to perform (e.g., 'list-images')
2222+ * @param {Object} params - Additional parameters
2323+ * @param {number} timeout - Timeout in ms (default 10s)
2424+ * @returns {Promise<Object>} The response data
2525+ */
2626+function pageRequest(action, params = {}, timeout = 10000) {
2727+ return new Promise((resolve, reject) => {
2828+ const requestId = `page-cmd-${++requestCounter}-${Date.now()}`;
2929+ let settled = false;
3030+3131+ const unsubscribe = api.subscribe('page:cmd:response', (msg) => {
3232+ if (msg.requestId !== requestId) return;
3333+ if (settled) return;
3434+ settled = true;
3535+ unsubscribe?.();
3636+ if (msg.error) {
3737+ reject(new Error(msg.error));
3838+ } else {
3939+ resolve(msg.data);
4040+ }
4141+ }, api.scopes.GLOBAL);
4242+4343+ api.publish('page:cmd:request', {
4444+ requestId,
4545+ action,
4646+ ...params
4747+ }, api.scopes.GLOBAL);
4848+4949+ setTimeout(() => {
5050+ if (settled) return;
5151+ settled = true;
5252+ unsubscribe?.();
5353+ reject(new Error('No page window responded. Is a web page open?'));
5454+ }, timeout);
5555+ });
5656+}
5757+5858+// --- List command ---
5959+6060+const LIST_TYPES = [
6161+ { value: 'images', title: 'images', subtitle: 'Extract all images from the page' },
6262+ { value: 'feeds', title: 'feeds', subtitle: 'Find RSS/Atom feeds linked from the page' },
6363+ { value: 'links', title: 'links', subtitle: 'Extract all links from the page' },
6464+ { value: 'entities', title: 'entities', subtitle: 'List detected entities (people, orgs, places, emails, phones)' },
6565+];
6666+6767+const listCommand = {
6868+ name: 'list',
6969+ description: 'List page content by type',
7070+ modes: ['page'],
7171+ produces: ['application/json'],
7272+ params: [{
7373+ type: 'enum',
7474+ options: LIST_TYPES
7575+ }],
7676+7777+ async execute(ctx) {
7878+ const subcommand = (ctx.params && ctx.params[0]) || (ctx.search || '').trim();
7979+8080+ if (!subcommand) {
8181+ return {
8282+ success: false,
8383+ message: 'Specify what to list: images, feeds, links, or entities'
8484+ };
8585+ }
8686+8787+ const type = subcommand.toLowerCase();
8888+8989+ try {
9090+ const data = await pageRequest(`list-${type}`);
9191+9292+ if (!data || (Array.isArray(data) && data.length === 0)) {
9393+ return {
9494+ success: true,
9595+ message: `No ${type} found on this page`
9696+ };
9797+ }
9898+9999+ return {
100100+ success: true,
101101+ output: {
102102+ data,
103103+ mimeType: 'application/json',
104104+ title: `${Array.isArray(data) ? data.length : 0} ${type} found`
105105+ }
106106+ };
107107+ } catch (err) {
108108+ return {
109109+ success: false,
110110+ message: err.message
111111+ };
112112+ }
113113+ }
114114+};
115115+116116+// --- View command ---
117117+118118+const VIEW_TYPES = [
119119+ { value: 'reader', title: 'reader', subtitle: 'Toggle reader mode (strip to readable content)' },
120120+ { value: 'source', title: 'source', subtitle: 'Show page source' },
121121+ { value: 'devtools', title: 'devtools', subtitle: 'Open DevTools for the page' },
122122+];
123123+124124+const viewCommand = {
125125+ name: 'view',
126126+ description: 'Change page display mode',
127127+ modes: ['page'],
128128+ params: [{
129129+ type: 'enum',
130130+ options: VIEW_TYPES
131131+ }],
132132+133133+ async execute(ctx) {
134134+ const subcommand = (ctx.params && ctx.params[0]) || (ctx.search || '').trim();
135135+136136+ if (!subcommand) {
137137+ return {
138138+ success: false,
139139+ message: 'Specify a view mode: reader, source, or devtools'
140140+ };
141141+ }
142142+143143+ const mode = subcommand.toLowerCase();
144144+145145+ if (mode === 'devtools') {
146146+ // DevTools is handled via IPC directly, not via page.js
147147+ try {
148148+ const result = await api.invoke('window-devtools', {});
149149+ if (result && result.success) {
150150+ return { success: true, message: 'DevTools opened' };
151151+ } else {
152152+ return { success: false, message: result?.error || 'Failed to open DevTools' };
153153+ }
154154+ } catch (err) {
155155+ return { success: false, message: err.message };
156156+ }
157157+ }
158158+159159+ try {
160160+ const data = await pageRequest(`view-${mode}`);
161161+ if (mode === 'source' && data) {
162162+ return {
163163+ success: true,
164164+ output: {
165165+ data: data.html || data,
166166+ mimeType: 'text/html',
167167+ title: `Source: ${data.title || 'page'}`
168168+ }
169169+ };
170170+ }
171171+ return {
172172+ success: true,
173173+ message: data?.message || `View mode changed to ${mode}`
174174+ };
175175+ } catch (err) {
176176+ return {
177177+ success: false,
178178+ message: err.message
179179+ };
180180+ }
181181+ }
182182+};
183183+184184+const commands = [listCommand, viewCommand];
185185+186186+export default { commands };