experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(page): add page widget extensibility system

+2335
+299
app/page/page-widgets.js
··· 1 + /** 2 + * Page Widget Host API 3 + * 4 + * Manages extension-provided widgets in the page host window. Extensions 5 + * communicate via pubsub to register, render, update, and close widgets. 6 + * The host publishes lifecycle events so extensions can react to page 7 + * navigation and loading. 8 + * 9 + * Integration: imported by page.js, which calls init() with the webview 10 + * element and pubsub api reference. 11 + * 12 + * Pubsub topics (extension -> host): 13 + * widget:register - Register a widget type 14 + * widget:render - Populate a widget with content 15 + * widget:update - Update an existing widget 16 + * widget:close - Remove a widget 17 + * page:execute-script - Request script execution in page webview 18 + * 19 + * Pubsub topics (host -> extension): 20 + * page:loaded - Page finished loading (already exists in page.js) 21 + * page:navigated - In-page navigation occurred 22 + * page:will-close - Page window is about to close 23 + * page:script-result - Result of script execution 24 + * widget:registered - Confirmation of widget registration 25 + */ 26 + 27 + /** 28 + * @typedef {Object} WidgetRegistration 29 + * @property {string} extensionId - The registering extension's ID 30 + * @property {string} widgetId - Unique widget identifier 31 + * @property {string} [title] - Widget title 32 + * @property {string} [position='right'] - 'right' | 'left' | 'bottom' 33 + * @property {number} [width] - Preferred width in px 34 + * @property {number} [maxHeight] - Maximum height in px 35 + */ 36 + 37 + /** 38 + * @typedef {Object} PageWidgetHost 39 + * @property {function} init - Initialize the widget host 40 + * @property {function} destroy - Tear down subscriptions and widgets 41 + * @property {function} getWidgets - Get all registered widgets 42 + * @property {function} publishNavigated - Publish navigation event 43 + * @property {function} publishWillClose - Publish will-close event 44 + */ 45 + 46 + /** 47 + * Create a page widget host instance. 48 + * 49 + * Pure factory function — no global state. All state is local to the 50 + * returned object, making it testable without DOM. 51 + * 52 + * @param {object} deps - Dependencies injected by page.js 53 + * @param {object} deps.api - The pubsub/IPC API (window.app) 54 + * @param {HTMLElement} deps.webview - The <webview> element 55 + * @param {HTMLElement} deps.widgetContainer - Container for right-side widgets 56 + * @param {function} deps.addWidget - page.js addWidget(id, opts) 57 + * @param {function} deps.updateWidget - page.js updateWidget(id, opts) 58 + * @param {function} deps.removeWidget - page.js removeWidget(id) 59 + * @returns {PageWidgetHost} 60 + */ 61 + export function createPageWidgetHost(deps) { 62 + const { api, webview, widgetContainer, addWidget, updateWidget, removeWidget } = deps; 63 + 64 + /** @type {Map<string, WidgetRegistration>} */ 65 + const registrations = new Map(); 66 + 67 + /** @type {Map<string, object>} widget handles from addWidget */ 68 + const activeWidgets = new Map(); 69 + 70 + /** @type {Array<function>} unsubscribe callbacks */ 71 + const unsubs = []; 72 + 73 + /** @type {Map<string, {resolve: function, reject: function}>} pending script requests */ 74 + const pendingScripts = new Map(); 75 + 76 + const DEBUG = false; 77 + 78 + // --- Subscription helpers --- 79 + 80 + function subscribe(topic, handler) { 81 + const unsub = api.subscribe(topic, handler, api.scopes.GLOBAL); 82 + unsubs.push(unsub); 83 + return unsub; 84 + } 85 + 86 + // --- Widget registration --- 87 + 88 + function handleRegister(msg) { 89 + if (!msg || !msg.extensionId || !msg.widgetId) { 90 + DEBUG && console.log('[page-widgets] Invalid register message:', msg); 91 + return; 92 + } 93 + 94 + const key = `${msg.extensionId}:${msg.widgetId}`; 95 + const registration = { 96 + extensionId: msg.extensionId, 97 + widgetId: msg.widgetId, 98 + title: msg.title || msg.widgetId, 99 + position: msg.position || 'right', 100 + width: msg.width || undefined, 101 + maxHeight: msg.maxHeight || undefined, 102 + }; 103 + 104 + registrations.set(key, registration); 105 + 106 + DEBUG && console.log('[page-widgets] Registered widget:', key, registration); 107 + 108 + api.publish('widget:registered', { 109 + extensionId: msg.extensionId, 110 + widgetId: msg.widgetId, 111 + success: true, 112 + }, api.scopes.GLOBAL); 113 + } 114 + 115 + // --- Widget rendering --- 116 + 117 + function handleRender(msg) { 118 + if (!msg || !msg.extensionId || !msg.widgetId) { 119 + DEBUG && console.log('[page-widgets] Invalid render message:', msg); 120 + return; 121 + } 122 + 123 + const key = `${msg.extensionId}:${msg.widgetId}`; 124 + const registration = registrations.get(key); 125 + if (!registration) { 126 + console.warn('[page-widgets] Cannot render unregistered widget:', key); 127 + return; 128 + } 129 + 130 + // Remove existing widget if re-rendering 131 + if (activeWidgets.has(key)) { 132 + removeWidget(key); 133 + activeWidgets.delete(key); 134 + } 135 + 136 + const handle = addWidget(key, { 137 + title: msg.title || registration.title, 138 + content: msg.html || msg.content || '', 139 + autoDismiss: msg.autoDismiss || 0, 140 + onClose: () => { 141 + activeWidgets.delete(key); 142 + api.publish('widget:closed', { 143 + extensionId: msg.extensionId, 144 + widgetId: msg.widgetId, 145 + }, api.scopes.GLOBAL); 146 + }, 147 + }); 148 + 149 + if (handle) { 150 + activeWidgets.set(key, handle); 151 + DEBUG && console.log('[page-widgets] Rendered widget:', key); 152 + } 153 + } 154 + 155 + // --- Widget update --- 156 + 157 + function handleUpdate(msg) { 158 + if (!msg || !msg.extensionId || !msg.widgetId) return; 159 + 160 + const key = `${msg.extensionId}:${msg.widgetId}`; 161 + if (!activeWidgets.has(key)) { 162 + DEBUG && console.log('[page-widgets] Cannot update inactive widget:', key); 163 + return; 164 + } 165 + 166 + updateWidget(key, { 167 + title: msg.title, 168 + content: msg.html || msg.content, 169 + }); 170 + 171 + DEBUG && console.log('[page-widgets] Updated widget:', key); 172 + } 173 + 174 + // --- Widget close --- 175 + 176 + function handleClose(msg) { 177 + if (!msg || !msg.extensionId || !msg.widgetId) return; 178 + 179 + const key = `${msg.extensionId}:${msg.widgetId}`; 180 + if (activeWidgets.has(key)) { 181 + removeWidget(key); 182 + activeWidgets.delete(key); 183 + DEBUG && console.log('[page-widgets] Closed widget:', key); 184 + } 185 + } 186 + 187 + // --- Content script execution --- 188 + 189 + function handleExecuteScript(msg) { 190 + if (!msg || !msg.extensionId || !msg.requestId || !msg.script) { 191 + DEBUG && console.log('[page-widgets] Invalid execute-script message:', msg); 192 + return; 193 + } 194 + 195 + if (!webview) { 196 + api.publish('page:script-result', { 197 + requestId: msg.requestId, 198 + extensionId: msg.extensionId, 199 + error: 'No webview available', 200 + }, api.scopes.GLOBAL); 201 + return; 202 + } 203 + 204 + // Execute the script in the webview and publish the result 205 + webview.executeJavaScript(msg.script) 206 + .then((result) => { 207 + api.publish('page:script-result', { 208 + requestId: msg.requestId, 209 + extensionId: msg.extensionId, 210 + result, 211 + success: true, 212 + }, api.scopes.GLOBAL); 213 + }) 214 + .catch((err) => { 215 + api.publish('page:script-result', { 216 + requestId: msg.requestId, 217 + extensionId: msg.extensionId, 218 + error: err.message || String(err), 219 + success: false, 220 + }, api.scopes.GLOBAL); 221 + }); 222 + } 223 + 224 + // --- Lifecycle event publishers --- 225 + 226 + function publishNavigated(url, title) { 227 + api.publish('page:navigated', { url, title }, api.scopes.GLOBAL); 228 + } 229 + 230 + function publishWillClose(url) { 231 + api.publish('page:will-close', { url }, api.scopes.GLOBAL); 232 + } 233 + 234 + // --- Init / Destroy --- 235 + 236 + function init() { 237 + subscribe('widget:register', handleRegister); 238 + subscribe('widget:render', handleRender); 239 + subscribe('widget:update', handleUpdate); 240 + subscribe('widget:close', handleClose); 241 + subscribe('page:execute-script', handleExecuteScript); 242 + 243 + DEBUG && console.log('[page-widgets] Initialized'); 244 + } 245 + 246 + function destroy() { 247 + // Publish will-close before tearing down 248 + try { 249 + const url = webview?.getURL?.(); 250 + if (url) publishWillClose(url); 251 + } catch { 252 + // webview may not be ready 253 + } 254 + 255 + // Remove all active widgets 256 + for (const key of activeWidgets.keys()) { 257 + removeWidget(key); 258 + } 259 + activeWidgets.clear(); 260 + registrations.clear(); 261 + 262 + // Clear pending script requests 263 + for (const [, pending] of pendingScripts) { 264 + pending.reject(new Error('Widget host destroyed')); 265 + } 266 + pendingScripts.clear(); 267 + 268 + // Unsubscribe all 269 + for (const unsub of unsubs) { 270 + if (typeof unsub === 'function') unsub(); 271 + } 272 + unsubs.length = 0; 273 + 274 + DEBUG && console.log('[page-widgets] Destroyed'); 275 + } 276 + 277 + function getWidgets() { 278 + return new Map(registrations); 279 + } 280 + 281 + function getActiveWidgets() { 282 + return new Map(activeWidgets); 283 + } 284 + 285 + return { 286 + init, 287 + destroy, 288 + getWidgets, 289 + getActiveWidgets, 290 + publishNavigated, 291 + publishWillClose, 292 + // Exposed for testing 293 + _handleRegister: handleRegister, 294 + _handleRender: handleRender, 295 + _handleUpdate: handleUpdate, 296 + _handleClose: handleClose, 297 + _handleExecuteScript: handleExecuteScript, 298 + }; 299 + }
+329
app/page/page.js
··· 18 18 */ 19 19 20 20 import api from '../api.js'; 21 + import { createPageWidgetHost } from './page-widgets.js'; 21 22 22 23 console.log('[page] Script loaded'); 23 24 ··· 1509 1510 DEBUG && console.log('[page] Widget updated:', id); 1510 1511 } 1511 1512 1513 + // --- Page Widget Host (extension-provided widgets via pubsub) --- 1514 + 1515 + const pageWidgetHost = createPageWidgetHost({ 1516 + api, 1517 + webview, 1518 + widgetContainer, 1519 + addWidget, 1520 + updateWidget, 1521 + removeWidget, 1522 + }); 1523 + pageWidgetHost.init(); 1524 + 1525 + // Publish page:navigated on in-page navigation 1526 + webview.addEventListener('did-navigate', (e) => { 1527 + const title = document.title || ''; 1528 + pageWidgetHost.publishNavigated(e.url, title); 1529 + }); 1530 + 1531 + // Publish page:will-close when window is closing 1532 + window.addEventListener('beforeunload', () => { 1533 + pageWidgetHost.destroy(); 1534 + }); 1535 + 1536 + // Expose for testing 1537 + if (typeof window !== 'undefined') { 1538 + window.__pageWidgetHost = pageWidgetHost; 1539 + } 1540 + 1512 1541 // --- OpenSearch discovery widget --- 1513 1542 1514 1543 api.subscribe('websearch:engine-discovered', (msg) => { ··· 1974 2003 webview.addEventListener('did-finish-load', () => { 1975 2004 setTimeout(extractSimpleEntities, 2000); 1976 2005 }); 2006 + 2007 + // --- Page Command Handlers (pubsub) --- 2008 + // Commands from the cmd palette publish page:cmd:request, we handle them here 2009 + // by running executeJavaScript on the webview and publishing the result back. 2010 + 2011 + api.subscribe('page:cmd:request', async (msg) => { 2012 + if (!msg || !msg.requestId || !msg.action) return; 2013 + 2014 + // Only respond if we are the focused page window 2015 + if (msg.windowId != null && msg.windowId !== myWindowId) return; 2016 + 2017 + const { requestId, action } = msg; 2018 + 2019 + const respond = (data, error) => { 2020 + api.publish('page:cmd:response', { 2021 + requestId, 2022 + data: data || null, 2023 + error: error || null 2024 + }, api.scopes.GLOBAL); 2025 + }; 2026 + 2027 + try { 2028 + switch (action) { 2029 + case 'list-images': { 2030 + const images = await webview.executeJavaScript(` 2031 + (function() { 2032 + var results = []; 2033 + var seen = new Set(); 2034 + var imgs = document.querySelectorAll('img[src]'); 2035 + for (var i = 0; i < imgs.length; i++) { 2036 + var img = imgs[i]; 2037 + var src = img.src; 2038 + if (!src || seen.has(src)) continue; 2039 + seen.add(src); 2040 + var alt = (img.alt || '').trim(); 2041 + var w = img.naturalWidth || img.width || 0; 2042 + var h = img.naturalHeight || img.height || 0; 2043 + results.push({ 2044 + src: src, 2045 + alt: alt, 2046 + width: w, 2047 + height: h, 2048 + title: alt || src.split('/').pop().split('?')[0] 2049 + }); 2050 + } 2051 + // Also check CSS background images on visible elements 2052 + var els = document.querySelectorAll('[style*="background-image"]'); 2053 + for (var j = 0; j < Math.min(els.length, 50); j++) { 2054 + var style = window.getComputedStyle(els[j]); 2055 + var bg = style.backgroundImage; 2056 + if (bg && bg !== 'none') { 2057 + var match = bg.match(/url\\(["']?([^"')]+)["']?\\)/); 2058 + if (match && match[1] && !seen.has(match[1])) { 2059 + seen.add(match[1]); 2060 + results.push({ 2061 + src: match[1], 2062 + alt: 'Background image', 2063 + width: 0, 2064 + height: 0, 2065 + title: match[1].split('/').pop().split('?')[0] 2066 + }); 2067 + } 2068 + } 2069 + } 2070 + return results; 2071 + })(); 2072 + `); 2073 + respond(images); 2074 + break; 2075 + } 2076 + 2077 + case 'list-feeds': { 2078 + const feeds = await webview.executeJavaScript(` 2079 + (function() { 2080 + var results = []; 2081 + var feedLinks = document.querySelectorAll( 2082 + 'link[type="application/rss+xml"], ' + 2083 + 'link[type="application/atom+xml"], ' + 2084 + 'link[type="application/feed+json"], ' + 2085 + 'link[rel="alternate"][type*="xml"]' 2086 + ); 2087 + for (var i = 0; i < feedLinks.length; i++) { 2088 + var link = feedLinks[i]; 2089 + results.push({ 2090 + url: link.href, 2091 + title: link.title || link.type || 'Feed', 2092 + type: link.type || 'unknown' 2093 + }); 2094 + } 2095 + // Also look for common feed URL patterns in <a> tags 2096 + var anchors = document.querySelectorAll('a[href]'); 2097 + var feedPatterns = ['/feed', '/rss', '/atom', '.rss', '.xml', '/feeds/']; 2098 + var seen = new Set(results.map(function(r) { return r.url; })); 2099 + for (var j = 0; j < anchors.length; j++) { 2100 + var href = anchors[j].href; 2101 + if (!href || seen.has(href)) continue; 2102 + var lower = href.toLowerCase(); 2103 + for (var k = 0; k < feedPatterns.length; k++) { 2104 + if (lower.includes(feedPatterns[k])) { 2105 + seen.add(href); 2106 + results.push({ 2107 + url: href, 2108 + title: anchors[j].textContent.trim().slice(0, 60) || 'Feed link', 2109 + type: 'link' 2110 + }); 2111 + break; 2112 + } 2113 + } 2114 + } 2115 + return results; 2116 + })(); 2117 + `); 2118 + respond(feeds); 2119 + break; 2120 + } 2121 + 2122 + case 'list-links': { 2123 + const links = await webview.executeJavaScript(` 2124 + (function() { 2125 + var results = []; 2126 + var seen = new Set(); 2127 + var anchors = document.querySelectorAll('a[href]'); 2128 + var pageHost = window.location.hostname; 2129 + for (var i = 0; i < anchors.length; i++) { 2130 + var a = anchors[i]; 2131 + var href = a.href; 2132 + if (!href || !href.startsWith('http') || seen.has(href)) continue; 2133 + seen.add(href); 2134 + var text = (a.textContent || '').trim().slice(0, 100); 2135 + if (!text || text.length < 2) continue; 2136 + var isExternal = false; 2137 + try { isExternal = new URL(href).hostname !== pageHost; } catch(e) {} 2138 + results.push({ 2139 + url: href, 2140 + text: text, 2141 + title: text, 2142 + external: isExternal 2143 + }); 2144 + } 2145 + return results; 2146 + })(); 2147 + `); 2148 + respond(links); 2149 + break; 2150 + } 2151 + 2152 + case 'list-entities': { 2153 + // Return cached entities if available, otherwise do live extraction 2154 + if (currentPageEntities && currentPageEntities.length > 0) { 2155 + respond(currentPageEntities); 2156 + } else { 2157 + // Do a quick extraction 2158 + const entities = await webview.executeJavaScript(` 2159 + (function() { 2160 + var entities = []; 2161 + var seen = new Set(); 2162 + 2163 + // Emails from mailto links 2164 + var mailLinks = document.querySelectorAll('a[href^="mailto:"]'); 2165 + for (var i = 0; i < mailLinks.length; i++) { 2166 + var email = mailLinks[i].href.replace('mailto:', '').split('?')[0]; 2167 + if (email && !seen.has(email)) { 2168 + seen.add(email); 2169 + entities.push({ name: email, type: 'email' }); 2170 + } 2171 + } 2172 + 2173 + // Emails from text content (regex) 2174 + var textContent = document.body ? document.body.innerText : ''; 2175 + var emailRegex = /[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}/g; 2176 + var emailMatches = textContent.match(emailRegex) || []; 2177 + for (var j = 0; j < emailMatches.length; j++) { 2178 + if (!seen.has(emailMatches[j])) { 2179 + seen.add(emailMatches[j]); 2180 + entities.push({ name: emailMatches[j], type: 'email' }); 2181 + } 2182 + } 2183 + 2184 + // Phone numbers from tel links 2185 + var telLinks = document.querySelectorAll('a[href^="tel:"]'); 2186 + for (var k = 0; k < telLinks.length; k++) { 2187 + var phone = telLinks[k].href.replace('tel:', ''); 2188 + if (phone && !seen.has(phone)) { 2189 + seen.add(phone); 2190 + entities.push({ name: phone, type: 'phone' }); 2191 + } 2192 + } 2193 + 2194 + // External domains as organizations 2195 + var links = document.querySelectorAll('a[href]'); 2196 + var domainCounts = {}; 2197 + for (var l = 0; l < Math.min(links.length, 500); l++) { 2198 + try { 2199 + var host = new URL(links[l].href).hostname; 2200 + if (host === window.location.hostname) continue; 2201 + if (!domainCounts[host]) domainCounts[host] = 0; 2202 + domainCounts[host]++; 2203 + } catch(e) {} 2204 + } 2205 + // Only include domains referenced multiple times (likely organizations) 2206 + Object.keys(domainCounts).forEach(function(domain) { 2207 + if (domainCounts[domain] >= 2 && !seen.has(domain)) { 2208 + seen.add(domain); 2209 + entities.push({ name: domain, type: 'organization' }); 2210 + } 2211 + }); 2212 + 2213 + return entities.slice(0, 100); 2214 + })(); 2215 + `); 2216 + respond(entities); 2217 + } 2218 + break; 2219 + } 2220 + 2221 + case 'view-reader': { 2222 + // Toggle reader mode by injecting/removing a simplified view 2223 + const result = await webview.executeJavaScript(` 2224 + (function() { 2225 + // Check if already in reader mode 2226 + if (document.getElementById('__peek_reader_mode')) { 2227 + // Exit reader mode: restore original content 2228 + var overlay = document.getElementById('__peek_reader_mode'); 2229 + overlay.remove(); 2230 + document.body.style.overflow = ''; 2231 + return { active: false, message: 'Reader mode off' }; 2232 + } 2233 + 2234 + // Enter reader mode: extract readable content 2235 + var title = document.title || ''; 2236 + var article = document.querySelector('article') || 2237 + document.querySelector('[role="main"]') || 2238 + document.querySelector('main') || 2239 + document.querySelector('.post-content') || 2240 + document.querySelector('.article-content') || 2241 + document.querySelector('.entry-content'); 2242 + 2243 + var content = ''; 2244 + if (article) { 2245 + content = article.innerHTML; 2246 + } else { 2247 + // Fallback: use body but strip nav, header, footer, aside, script, style 2248 + var clone = document.body.cloneNode(true); 2249 + var remove = clone.querySelectorAll('nav, header, footer, aside, script, style, .nav, .header, .footer, .sidebar, .menu, .ad, .advertisement, [role="navigation"], [role="banner"], [role="complementary"]'); 2250 + for (var i = 0; i < remove.length; i++) { 2251 + remove[i].remove(); 2252 + } 2253 + content = clone.innerHTML; 2254 + } 2255 + 2256 + var overlay = document.createElement('div'); 2257 + overlay.id = '__peek_reader_mode'; 2258 + 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;'; 2259 + overlay.innerHTML = 2260 + '<div style="max-width:700px;margin:0 auto;">' + 2261 + '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;padding-bottom:10px;border-bottom:1px solid #ddd;">' + 2262 + '<span style="color:#888;font-size:14px;font-family:system-ui,sans-serif;">Reader Mode</span>' + 2263 + '<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>' + 2264 + '</div>' + 2265 + '<h1 style="font-size:28px;line-height:1.3;margin-bottom:20px;">' + title.replace(/</g, '&lt;') + '</h1>' + 2266 + content + 2267 + '</div>'; 2268 + 2269 + document.body.appendChild(overlay); 2270 + document.body.style.overflow = 'hidden'; 2271 + 2272 + // Wire up close button 2273 + document.getElementById('__peek_reader_close').addEventListener('click', function() { 2274 + overlay.remove(); 2275 + document.body.style.overflow = ''; 2276 + }); 2277 + 2278 + return { active: true, message: 'Reader mode on' }; 2279 + })(); 2280 + `); 2281 + respond(result); 2282 + break; 2283 + } 2284 + 2285 + case 'view-source': { 2286 + const source = await webview.executeJavaScript(` 2287 + (function() { 2288 + return { 2289 + html: document.documentElement.outerHTML, 2290 + title: document.title || window.location.href 2291 + }; 2292 + })(); 2293 + `); 2294 + respond(source); 2295 + break; 2296 + } 2297 + 2298 + default: 2299 + respond(null, 'Unknown page command action: ' + action); 2300 + } 2301 + } catch (err) { 2302 + console.error('[page] page:cmd:request error:', err); 2303 + respond(null, err.message || 'Failed to execute page command'); 2304 + } 2305 + }, api.scopes.GLOBAL); 1977 2306 1978 2307 DEBUG && console.log('[page] Minimal page host initialized for:', targetUrl);
+218
docs/page-widgets.md
··· 1 + # Page Widget Extensibility API 2 + 3 + Extensions can hook into the page host lifecycle, execute content scripts in the page webview, and display widgets alongside the page content. 4 + 5 + ## Architecture 6 + 7 + ``` 8 + Extension (background.js) Page Host (page.js) 9 + | | 10 + |-- widget:register -------------> | stores registration 11 + |<-- widget:registered ---------- | confirms 12 + | | 13 + |<-- page:loaded ---------------- | page finishes loading 14 + | | 15 + |-- page:execute-script ---------> | runs in webview 16 + |<-- page:script-result --------- | returns result 17 + | | 18 + |-- widget:render ---------------> | creates widget DOM 19 + |-- widget:update ---------------> | updates widget content 20 + |-- widget:close ----------------> | removes widget 21 + | | 22 + |<-- page:navigated ------------- | in-page navigation 23 + |<-- page:will-close ------------ | window closing 24 + ``` 25 + 26 + All communication uses the pubsub system (`window.app.publish` / `window.app.subscribe`) with `GLOBAL` scope. 27 + 28 + ## Pubsub Topics 29 + 30 + ### Extension to Page Host 31 + 32 + #### `widget:register` 33 + 34 + Register a widget type. Must be called before `widget:render`. 35 + 36 + ```js 37 + api.publish('widget:register', { 38 + extensionId: 'my-extension', // Required: your extension ID 39 + widgetId: 'my-widget', // Required: unique widget ID within your extension 40 + title: 'My Widget', // Optional: display title (defaults to widgetId) 41 + position: 'right', // Optional: 'right' (default). Reserved: 'left', 'bottom' 42 + }, api.scopes.GLOBAL); 43 + ``` 44 + 45 + Response: `widget:registered` is published with `{ extensionId, widgetId, success: true }`. 46 + 47 + #### `widget:render` 48 + 49 + Populate a registered widget with content. If the widget is already rendered, it is replaced. 50 + 51 + ```js 52 + api.publish('widget:render', { 53 + extensionId: 'my-extension', 54 + widgetId: 'my-widget', 55 + title: 'Updated Title', // Optional: overrides registration title 56 + html: '<p>Widget content</p>',// HTML string for the widget body 57 + autoDismiss: 0, // Optional: auto-close after N ms (0 = never) 58 + }, api.scopes.GLOBAL); 59 + ``` 60 + 61 + #### `widget:update` 62 + 63 + Update an already-rendered widget's content or title without replacing the DOM element. 64 + 65 + ```js 66 + api.publish('widget:update', { 67 + extensionId: 'my-extension', 68 + widgetId: 'my-widget', 69 + html: '<p>New content</p>', // Optional: new body content 70 + title: 'New Title', // Optional: new title 71 + }, api.scopes.GLOBAL); 72 + ``` 73 + 74 + #### `widget:close` 75 + 76 + Remove a widget from the page. 77 + 78 + ```js 79 + api.publish('widget:close', { 80 + extensionId: 'my-extension', 81 + widgetId: 'my-widget', 82 + }, api.scopes.GLOBAL); 83 + ``` 84 + 85 + #### `page:execute-script` 86 + 87 + Execute JavaScript in the page's webview. Results are returned via `page:script-result`. 88 + 89 + ```js 90 + api.publish('page:execute-script', { 91 + extensionId: 'my-extension', 92 + requestId: 'unique-request-id', // Required: correlate with response 93 + script: 'document.title', // Required: JS code to execute 94 + }, api.scopes.GLOBAL); 95 + ``` 96 + 97 + ### Page Host to Extension 98 + 99 + #### `page:loaded` 100 + 101 + Published when a page finishes loading. Already existed before this API. 102 + 103 + ```js 104 + api.subscribe('page:loaded', (msg) => { 105 + // msg.url - the loaded page URL 106 + // msg.title - the page title 107 + // msg.opensearchUrl - OpenSearch URL if detected 108 + }, api.scopes.GLOBAL); 109 + ``` 110 + 111 + #### `page:navigated` 112 + 113 + Published on in-page navigation (did-navigate). 114 + 115 + ```js 116 + api.subscribe('page:navigated', (msg) => { 117 + // msg.url - the new URL 118 + // msg.title - the page title 119 + }, api.scopes.GLOBAL); 120 + ``` 121 + 122 + #### `page:will-close` 123 + 124 + Published when the page window is about to close. 125 + 126 + ```js 127 + api.subscribe('page:will-close', (msg) => { 128 + // msg.url - the current page URL 129 + }, api.scopes.GLOBAL); 130 + ``` 131 + 132 + #### `page:script-result` 133 + 134 + Response to a `page:execute-script` request. 135 + 136 + ```js 137 + api.subscribe('page:script-result', (msg) => { 138 + if (msg.requestId !== myRequestId) return; // correlate 139 + if (msg.success) { 140 + console.log('Result:', msg.result); 141 + } else { 142 + console.error('Error:', msg.error); 143 + } 144 + }, api.scopes.GLOBAL); 145 + ``` 146 + 147 + #### `widget:registered` 148 + 149 + Confirmation after `widget:register`. 150 + 151 + #### `widget:closed` 152 + 153 + Published when a widget is closed (by user clicking X, or via `widget:close`). 154 + 155 + ## Content Script Execution Pattern 156 + 157 + The request/response pattern uses `requestId` for correlation: 158 + 159 + ```js 160 + function executeScript(script, timeout = 5000) { 161 + return new Promise((resolve, reject) => { 162 + const requestId = `${extensionId}-${Date.now()}-${Math.random()}`; 163 + let timer, unsub; 164 + 165 + unsub = api.subscribe('page:script-result', (msg) => { 166 + if (msg.requestId !== requestId) return; 167 + clearTimeout(timer); 168 + unsub(); 169 + msg.success ? resolve(msg.result) : reject(new Error(msg.error)); 170 + }, api.scopes.GLOBAL); 171 + 172 + timer = setTimeout(() => { unsub(); reject(new Error('Timeout')); }, timeout); 173 + 174 + api.publish('page:execute-script', { 175 + extensionId, requestId, script 176 + }, api.scopes.GLOBAL); 177 + }); 178 + } 179 + ``` 180 + 181 + ## Widget Positioning 182 + 183 + Widgets are placed in the widget container to the right of the page webview. They stack vertically with an 8px gap. Each widget has: 184 + 185 + - A header with title and close button 186 + - A body that accepts HTML content 187 + - Max width of 280px, min width of 200px 188 + - Glass-morphism background with backdrop blur 189 + 190 + Widgets share the navbar show/hide lifecycle -- they are part of the page chrome. 191 + 192 + ## Sample Extension 193 + 194 + See `extensions/pagewidgets-sample/` for a complete working example that: 195 + 196 + 1. Registers a "Page Summary" widget on startup 197 + 2. Listens for `page:loaded` events 198 + 3. Executes a content script to extract page metadata (title, description, headings, word count, link count) 199 + 4. Renders the metadata in its widget 200 + 5. Updates on navigation, cleans up on page close 201 + 202 + ## Testing 203 + 204 + ### Unit tests 205 + 206 + ```bash 207 + node --test tests/unit/page-widgets.test.js 208 + ``` 209 + 210 + Tests the `createPageWidgetHost` factory function with mock dependencies. Covers registration, rendering, update, close, content script execution, and lifecycle events. 211 + 212 + ### Playwright tests 213 + 214 + ```bash 215 + BACKEND=electron yarn test:electron tests/desktop/page-widgets.spec.ts 216 + ``` 217 + 218 + 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
··· 10 10 import editModule from './edit.js'; 11 11 import urlModule from './url.js'; 12 12 import historyModule from './history.js'; 13 + import pageModule from './page.js'; 13 14 14 15 // Chaining commands - for command composition pipelines 15 16 import listsCommand from './lists.js'; ··· 38 39 ...editModule.commands, 39 40 ...urlModule.commands, 40 41 ...historyModule.commands, 42 + ...pageModule.commands, 41 43 42 44 // Chaining commands 43 45 listsCommand
+186
extensions/cmd/commands/page.js
··· 1 + /** 2 + * Page commands - interact with web page content in the focused page window 3 + * 4 + * Two command verbs: 5 + * list <type> — enumerate page content (images, feeds, links, entities) 6 + * view <mode> — change how the page is displayed (reader, source, devtools) 7 + * 8 + * These commands communicate with page.js via pubsub: 9 + * 1. Command publishes page:cmd:request with a requestId and action 10 + * 2. page.js receives it, runs executeJavaScript on the webview 11 + * 3. page.js publishes page:cmd:response with the requestId and result 12 + * 13 + * Commands are restricted to 'page' mode via the modes property. 14 + */ 15 + import api from 'peek://app/api.js'; 16 + 17 + let requestCounter = 0; 18 + 19 + /** 20 + * Send a request to the focused page window and wait for a response. 21 + * @param {string} action - The action to perform (e.g., 'list-images') 22 + * @param {Object} params - Additional parameters 23 + * @param {number} timeout - Timeout in ms (default 10s) 24 + * @returns {Promise<Object>} The response data 25 + */ 26 + function pageRequest(action, params = {}, timeout = 10000) { 27 + return new Promise((resolve, reject) => { 28 + const requestId = `page-cmd-${++requestCounter}-${Date.now()}`; 29 + let settled = false; 30 + 31 + const unsubscribe = api.subscribe('page:cmd:response', (msg) => { 32 + if (msg.requestId !== requestId) return; 33 + if (settled) return; 34 + settled = true; 35 + unsubscribe?.(); 36 + if (msg.error) { 37 + reject(new Error(msg.error)); 38 + } else { 39 + resolve(msg.data); 40 + } 41 + }, api.scopes.GLOBAL); 42 + 43 + api.publish('page:cmd:request', { 44 + requestId, 45 + action, 46 + ...params 47 + }, api.scopes.GLOBAL); 48 + 49 + setTimeout(() => { 50 + if (settled) return; 51 + settled = true; 52 + unsubscribe?.(); 53 + reject(new Error('No page window responded. Is a web page open?')); 54 + }, timeout); 55 + }); 56 + } 57 + 58 + // --- List command --- 59 + 60 + const LIST_TYPES = [ 61 + { value: 'images', title: 'images', subtitle: 'Extract all images from the page' }, 62 + { value: 'feeds', title: 'feeds', subtitle: 'Find RSS/Atom feeds linked from the page' }, 63 + { value: 'links', title: 'links', subtitle: 'Extract all links from the page' }, 64 + { value: 'entities', title: 'entities', subtitle: 'List detected entities (people, orgs, places, emails, phones)' }, 65 + ]; 66 + 67 + const listCommand = { 68 + name: 'list', 69 + description: 'List page content by type', 70 + modes: ['page'], 71 + produces: ['application/json'], 72 + params: [{ 73 + type: 'enum', 74 + options: LIST_TYPES 75 + }], 76 + 77 + async execute(ctx) { 78 + const subcommand = (ctx.params && ctx.params[0]) || (ctx.search || '').trim(); 79 + 80 + if (!subcommand) { 81 + return { 82 + success: false, 83 + message: 'Specify what to list: images, feeds, links, or entities' 84 + }; 85 + } 86 + 87 + const type = subcommand.toLowerCase(); 88 + 89 + try { 90 + const data = await pageRequest(`list-${type}`); 91 + 92 + if (!data || (Array.isArray(data) && data.length === 0)) { 93 + return { 94 + success: true, 95 + message: `No ${type} found on this page` 96 + }; 97 + } 98 + 99 + return { 100 + success: true, 101 + output: { 102 + data, 103 + mimeType: 'application/json', 104 + title: `${Array.isArray(data) ? data.length : 0} ${type} found` 105 + } 106 + }; 107 + } catch (err) { 108 + return { 109 + success: false, 110 + message: err.message 111 + }; 112 + } 113 + } 114 + }; 115 + 116 + // --- View command --- 117 + 118 + const VIEW_TYPES = [ 119 + { value: 'reader', title: 'reader', subtitle: 'Toggle reader mode (strip to readable content)' }, 120 + { value: 'source', title: 'source', subtitle: 'Show page source' }, 121 + { value: 'devtools', title: 'devtools', subtitle: 'Open DevTools for the page' }, 122 + ]; 123 + 124 + const viewCommand = { 125 + name: 'view', 126 + description: 'Change page display mode', 127 + modes: ['page'], 128 + params: [{ 129 + type: 'enum', 130 + options: VIEW_TYPES 131 + }], 132 + 133 + async execute(ctx) { 134 + const subcommand = (ctx.params && ctx.params[0]) || (ctx.search || '').trim(); 135 + 136 + if (!subcommand) { 137 + return { 138 + success: false, 139 + message: 'Specify a view mode: reader, source, or devtools' 140 + }; 141 + } 142 + 143 + const mode = subcommand.toLowerCase(); 144 + 145 + if (mode === 'devtools') { 146 + // DevTools is handled via IPC directly, not via page.js 147 + try { 148 + const result = await api.invoke('window-devtools', {}); 149 + if (result && result.success) { 150 + return { success: true, message: 'DevTools opened' }; 151 + } else { 152 + return { success: false, message: result?.error || 'Failed to open DevTools' }; 153 + } 154 + } catch (err) { 155 + return { success: false, message: err.message }; 156 + } 157 + } 158 + 159 + try { 160 + const data = await pageRequest(`view-${mode}`); 161 + if (mode === 'source' && data) { 162 + return { 163 + success: true, 164 + output: { 165 + data: data.html || data, 166 + mimeType: 'text/html', 167 + title: `Source: ${data.title || 'page'}` 168 + } 169 + }; 170 + } 171 + return { 172 + success: true, 173 + message: data?.message || `View mode changed to ${mode}` 174 + }; 175 + } catch (err) { 176 + return { 177 + success: false, 178 + message: err.message 179 + }; 180 + } 181 + } 182 + }; 183 + 184 + const commands = [listCommand, viewCommand]; 185 + 186 + export default { commands };
+22
extensions/cmd/completers.js
··· 78 78 export function completeEnum(partial, exclude, paramDef) { 79 79 const prefix = paramDef.prefix || ''; 80 80 const lowerPartial = (partial || '').toLowerCase(); 81 + const options = paramDef.options || []; 81 82 const values = paramDef.values || []; 82 83 83 84 const matchText = lowerPartial.startsWith(prefix.toLowerCase()) 84 85 ? lowerPartial.slice(prefix.length) 85 86 : lowerPartial; 87 + 88 + // Support both rich options [{value, title, subtitle}] and plain string values 89 + if (options.length > 0) { 90 + return options 91 + .filter(opt => { 92 + const val = typeof opt === 'string' ? opt : opt.value; 93 + if (exclude.has(val.toLowerCase())) return false; 94 + if (matchText && !val.toLowerCase().startsWith(matchText)) return false; 95 + return true; 96 + }) 97 + .map(opt => { 98 + if (typeof opt === 'string') { 99 + return { title: prefix + opt, subtitle: '', value: prefix + opt }; 100 + } 101 + return { 102 + title: prefix + (opt.title || opt.value), 103 + subtitle: opt.subtitle || '', 104 + value: prefix + opt.value 105 + }; 106 + }); 107 + } 86 108 87 109 return values 88 110 .filter(val => {
+50
extensions/pagewidgets-sample/background.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6 + <title>Page Widgets Sample Extension</title> 7 + </head> 8 + <body> 9 + <script type="module"> 10 + import extension from './background.js'; 11 + 12 + const api = window.app; 13 + const extId = extension.id; 14 + 15 + console.log(`[ext:${extId}] background.html loaded`); 16 + 17 + // Signal ready to main process 18 + api.publish('ext:ready', { 19 + id: extId, 20 + manifest: { 21 + id: extension.id, 22 + labels: extension.labels, 23 + version: '1.0.0' 24 + } 25 + }, api.scopes.SYSTEM); 26 + 27 + // Initialize extension 28 + if (extension.init) { 29 + console.log(`[ext:${extId}] calling init()`); 30 + extension.init(); 31 + } 32 + 33 + // Handle shutdown request from main process 34 + api.subscribe('app:shutdown', () => { 35 + console.log(`[ext:${extId}] received shutdown`); 36 + if (extension.uninit) { 37 + extension.uninit(); 38 + } 39 + }, api.scopes.SYSTEM); 40 + 41 + // Handle extension-specific shutdown 42 + api.subscribe(`ext:${extId}:shutdown`, () => { 43 + console.log(`[ext:${extId}] received extension shutdown`); 44 + if (extension.uninit) { 45 + extension.uninit(); 46 + } 47 + }, api.scopes.SYSTEM); 48 + </script> 49 + </body> 50 + </html>
+256
extensions/pagewidgets-sample/background.js
··· 1 + /** 2 + * Page Widgets Sample Extension 3 + * 4 + * Demonstrates the page widget extensibility API: 5 + * 1. Registers a widget on startup 6 + * 2. Listens for page:loaded events 7 + * 3. Runs a content script to extract metadata from the page 8 + * 4. Renders the extracted metadata in its widget 9 + * 10 + * This is a reference implementation for extension developers. 11 + */ 12 + 13 + const api = window.app; 14 + const debug = api.debug; 15 + 16 + const EXTENSION_ID = 'pagewidgets-sample'; 17 + const WIDGET_ID = 'page-meta'; 18 + 19 + let requestCounter = 0; 20 + 21 + /** 22 + * Generate a unique request ID for script execution correlation 23 + */ 24 + function nextRequestId() { 25 + return `${EXTENSION_ID}-${++requestCounter}-${Date.now()}`; 26 + } 27 + 28 + /** 29 + * Execute a content script in the current page and wait for the result. 30 + * 31 + * Uses the page:execute-script / page:script-result pubsub pattern 32 + * with requestId correlation. 33 + * 34 + * @param {string} script - JavaScript code to execute in the page webview 35 + * @param {number} [timeout=5000] - Timeout in ms 36 + * @returns {Promise<any>} - The script result 37 + */ 38 + function executeContentScript(script, timeout = 5000) { 39 + return new Promise((resolve, reject) => { 40 + const requestId = nextRequestId(); 41 + let timer = null; 42 + let unsub = null; 43 + 44 + function cleanup() { 45 + if (timer) clearTimeout(timer); 46 + if (unsub) unsub(); 47 + } 48 + 49 + // Listen for the result 50 + unsub = api.subscribe('page:script-result', (msg) => { 51 + if (msg.requestId !== requestId) return; 52 + cleanup(); 53 + 54 + if (msg.success) { 55 + resolve(msg.result); 56 + } else { 57 + reject(new Error(msg.error || 'Script execution failed')); 58 + } 59 + }, api.scopes.GLOBAL); 60 + 61 + // Set timeout 62 + timer = setTimeout(() => { 63 + cleanup(); 64 + reject(new Error('Content script execution timed out')); 65 + }, timeout); 66 + 67 + // Request execution 68 + api.publish('page:execute-script', { 69 + extensionId: EXTENSION_ID, 70 + requestId, 71 + script, 72 + }, api.scopes.GLOBAL); 73 + }); 74 + } 75 + 76 + /** 77 + * Content script that extracts page metadata. 78 + * Runs inside the page's webview context. 79 + */ 80 + const METADATA_EXTRACTION_SCRIPT = ` 81 + (function() { 82 + function getMeta(name) { 83 + var el = document.querySelector('meta[property="' + name + '"], meta[name="' + name + '"]'); 84 + return el ? (el.getAttribute('content') || '').trim() : ''; 85 + } 86 + 87 + var headings = []; 88 + var h1s = document.querySelectorAll('h1'); 89 + for (var i = 0; i < Math.min(h1s.length, 5); i++) { 90 + var text = h1s[i].textContent.trim(); 91 + if (text) headings.push(text); 92 + } 93 + 94 + var linkCount = document.querySelectorAll('a[href]').length; 95 + var imageCount = document.querySelectorAll('img').length; 96 + var formCount = document.querySelectorAll('form').length; 97 + var scriptCount = document.querySelectorAll('script').length; 98 + 99 + var wordCount = 0; 100 + try { 101 + var text = document.body.innerText || ''; 102 + wordCount = text.split(/\\s+/).filter(function(w) { return w.length > 0; }).length; 103 + } catch(e) {} 104 + 105 + return { 106 + title: document.title || '', 107 + description: getMeta('description') || getMeta('og:description') || '', 108 + author: getMeta('author') || '', 109 + headings: headings, 110 + stats: { 111 + links: linkCount, 112 + images: imageCount, 113 + forms: formCount, 114 + scripts: scriptCount, 115 + words: wordCount 116 + } 117 + }; 118 + })(); 119 + `; 120 + 121 + /** 122 + * Build HTML content for the widget from extracted metadata 123 + */ 124 + function buildWidgetHtml(meta) { 125 + const parts = []; 126 + 127 + if (meta.description) { 128 + parts.push(`<div style="margin-bottom:8px;font-size:12px;color:var(--theme-text-secondary,#aaa);line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">${escapeHtml(meta.description)}</div>`); 129 + } 130 + 131 + if (meta.headings && meta.headings.length > 0) { 132 + const headingList = meta.headings 133 + .map(h => `<div style="font-size:12px;color:var(--theme-text,#e0e0e0);padding:2px 0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(h)}</div>`) 134 + .join(''); 135 + parts.push(`<div style="margin-bottom:8px;"><div style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.04em;color:var(--theme-text-muted,#777);margin-bottom:4px;">Headings</div>${headingList}</div>`); 136 + } 137 + 138 + if (meta.stats) { 139 + const s = meta.stats; 140 + const statItems = []; 141 + if (s.words > 0) statItems.push(`${s.words.toLocaleString()} words`); 142 + if (s.links > 0) statItems.push(`${s.links} links`); 143 + if (s.images > 0) statItems.push(`${s.images} images`); 144 + if (s.forms > 0) statItems.push(`${s.forms} forms`); 145 + 146 + if (statItems.length > 0) { 147 + parts.push(`<div style="font-size:11px;color:var(--theme-text-muted,#888);line-height:1.5;">${statItems.join(' &middot; ')}</div>`); 148 + } 149 + } 150 + 151 + if (meta.author) { 152 + parts.push(`<div style="font-size:11px;color:var(--theme-text-muted,#888);margin-top:4px;">By ${escapeHtml(meta.author)}</div>`); 153 + } 154 + 155 + if (parts.length === 0) { 156 + parts.push('<div style="font-size:12px;color:var(--theme-text-muted,#666);font-style:italic;">No metadata available</div>'); 157 + } 158 + 159 + return parts.join(''); 160 + } 161 + 162 + function escapeHtml(str) { 163 + return str 164 + .replace(/&/g, '&amp;') 165 + .replace(/</g, '&lt;') 166 + .replace(/>/g, '&gt;') 167 + .replace(/"/g, '&quot;'); 168 + } 169 + 170 + /** 171 + * Handle page:loaded — extract metadata and render widget 172 + */ 173 + async function onPageLoaded(msg) { 174 + if (!msg || !msg.url) return; 175 + 176 + debug && console.log('[ext:pagewidgets-sample] Page loaded:', msg.url); 177 + 178 + try { 179 + const meta = await executeContentScript(METADATA_EXTRACTION_SCRIPT); 180 + const html = buildWidgetHtml(meta); 181 + 182 + api.publish('widget:render', { 183 + extensionId: EXTENSION_ID, 184 + widgetId: WIDGET_ID, 185 + title: 'Page Summary', 186 + html, 187 + }, api.scopes.GLOBAL); 188 + 189 + debug && console.log('[ext:pagewidgets-sample] Widget rendered for:', msg.url); 190 + } catch (err) { 191 + console.warn('[ext:pagewidgets-sample] Failed to extract metadata:', err.message); 192 + 193 + api.publish('widget:render', { 194 + extensionId: EXTENSION_ID, 195 + widgetId: WIDGET_ID, 196 + title: 'Page Summary', 197 + html: '<div style="font-size:12px;color:var(--theme-text-muted,#666);font-style:italic;">Could not extract page metadata</div>', 198 + }, api.scopes.GLOBAL); 199 + } 200 + } 201 + 202 + /** 203 + * Handle page:navigated — re-extract for new page 204 + */ 205 + function onPageNavigated(msg) { 206 + if (!msg || !msg.url) return; 207 + debug && console.log('[ext:pagewidgets-sample] Page navigated:', msg.url); 208 + // Re-extract after a brief delay for the new page to settle 209 + setTimeout(() => onPageLoaded(msg), 500); 210 + } 211 + 212 + /** 213 + * Handle page:will-close — clean up widget 214 + */ 215 + function onPageWillClose() { 216 + api.publish('widget:close', { 217 + extensionId: EXTENSION_ID, 218 + widgetId: WIDGET_ID, 219 + }, api.scopes.GLOBAL); 220 + } 221 + 222 + // ===== Lifecycle ===== 223 + 224 + const init = async () => { 225 + console.log('[ext:pagewidgets-sample] init'); 226 + 227 + // Step 1: Register our widget 228 + api.publish('widget:register', { 229 + extensionId: EXTENSION_ID, 230 + widgetId: WIDGET_ID, 231 + title: 'Page Summary', 232 + position: 'right', 233 + }, api.scopes.GLOBAL); 234 + 235 + // Step 2: Listen for page lifecycle events 236 + api.subscribe('page:loaded', onPageLoaded, api.scopes.GLOBAL); 237 + api.subscribe('page:navigated', onPageNavigated, api.scopes.GLOBAL); 238 + api.subscribe('page:will-close', onPageWillClose, api.scopes.GLOBAL); 239 + 240 + console.log('[ext:pagewidgets-sample] Initialized — listening for page events'); 241 + }; 242 + 243 + const uninit = () => { 244 + console.log('[ext:pagewidgets-sample] uninit'); 245 + onPageWillClose(); 246 + }; 247 + 248 + export default { 249 + id: EXTENSION_ID, 250 + labels: { 251 + name: 'Page Widgets Sample', 252 + description: 'Sample extension demonstrating the page widget extensibility API' 253 + }, 254 + init, 255 + uninit 256 + };
+9
extensions/pagewidgets-sample/manifest.json
··· 1 + { 2 + "id": "pagewidgets-sample", 3 + "shortname": "pagewidgets-sample", 4 + "name": "Page Widgets Sample", 5 + "description": "Sample extension demonstrating the page widget extensibility API", 6 + "version": "1.0.0", 7 + "background": "background.html", 8 + "builtin": true 9 + }
+369
tests/desktop/page-widgets.spec.ts
··· 1 + /** 2 + * Page Widget Extensibility Tests 3 + * 4 + * Tests the page widget host API that lets extensions: 5 + * - Register widgets via pubsub 6 + * - Execute content scripts in the page webview 7 + * - Render, update, and close widgets 8 + * - React to page lifecycle events 9 + * 10 + * Run with: 11 + * BACKEND=electron yarn test:electron 12 + */ 13 + 14 + import { test, expect, DesktopApp, getSharedApp, closeSharedApp } from '../fixtures/desktop-app'; 15 + import { Page } from '@playwright/test'; 16 + import { waitForExtensionsReady } from '../helpers/window-utils'; 17 + 18 + // Shared app instance 19 + let sharedApp: DesktopApp; 20 + let sharedBgWindow: Page; 21 + 22 + test.beforeAll(async () => { 23 + sharedApp = await getSharedApp(); 24 + sharedBgWindow = await sharedApp.getBackgroundWindow(); 25 + await waitForExtensionsReady(sharedBgWindow); 26 + }); 27 + 28 + test.afterAll(async () => { 29 + await closeSharedApp(); 30 + }); 31 + 32 + // ============================================================================ 33 + // Helpers 34 + // ============================================================================ 35 + 36 + async function openCanvasPage( 37 + bgWindow: Page, 38 + url: string 39 + ): Promise<{ pageWindow: Page; windowId: number }> { 40 + const result = await bgWindow.evaluate(async (targetUrl: string) => { 41 + return await (window as any).app.window.open(targetUrl, { 42 + width: 800, 43 + height: 600, 44 + }); 45 + }, url); 46 + expect(result.success).toBe(true); 47 + const windowId = result.id; 48 + 49 + const pageWindow = await sharedApp.getWindow('page/index.html', 15000); 50 + expect(pageWindow).toBeTruthy(); 51 + 52 + return { pageWindow, windowId }; 53 + } 54 + 55 + async function waitForPageLoaded(pageWindow: Page, timeout = 30000): Promise<void> { 56 + await pageWindow.waitForFunction( 57 + () => { 58 + const webview = document.getElementById('content'); 59 + return webview && webview.classList.contains('loaded'); 60 + }, 61 + undefined, 62 + { timeout } 63 + ); 64 + } 65 + 66 + async function waitForWidgetHost(pageWindow: Page, timeout = 10000): Promise<void> { 67 + await pageWindow.waitForFunction( 68 + () => (window as any).__pageWidgetHost != null, 69 + undefined, 70 + { timeout } 71 + ); 72 + } 73 + 74 + async function closeWindow(bgWindow: Page, windowId: number): Promise<void> { 75 + await bgWindow.evaluate(async (id: number) => { 76 + return await (window as any).app.window.close(id); 77 + }, windowId); 78 + } 79 + 80 + // ============================================================================ 81 + // Page Widget Host Tests 82 + // ============================================================================ 83 + 84 + test.describe('Page Widgets @desktop', () => { 85 + test('widget host is initialized on page load', async () => { 86 + const { pageWindow, windowId } = await openCanvasPage( 87 + sharedBgWindow, 88 + 'https://example.com' 89 + ); 90 + 91 + await waitForPageLoaded(pageWindow); 92 + await waitForWidgetHost(pageWindow); 93 + 94 + const hasHost = await pageWindow.evaluate(() => { 95 + return (window as any).__pageWidgetHost != null; 96 + }); 97 + expect(hasHost).toBe(true); 98 + 99 + await closeWindow(sharedBgWindow, windowId); 100 + }); 101 + 102 + test('widget:register creates a widget registration', async () => { 103 + const { pageWindow, windowId } = await openCanvasPage( 104 + sharedBgWindow, 105 + 'https://example.com' 106 + ); 107 + 108 + await waitForPageLoaded(pageWindow); 109 + await waitForWidgetHost(pageWindow); 110 + 111 + // Register a widget via pubsub from the page window 112 + await pageWindow.evaluate(() => { 113 + const api = (window as any).app; 114 + api.publish('widget:register', { 115 + extensionId: 'test-ext', 116 + widgetId: 'test-widget', 117 + title: 'Test Widget', 118 + position: 'right', 119 + }, api.scopes.GLOBAL); 120 + }); 121 + 122 + // Verify registration 123 + const widgetCount = await pageWindow.evaluate(() => { 124 + return (window as any).__pageWidgetHost.getWidgets().size; 125 + }); 126 + expect(widgetCount).toBeGreaterThanOrEqual(1); 127 + 128 + await closeWindow(sharedBgWindow, windowId); 129 + }); 130 + 131 + test('widget:render displays a widget in the page', async () => { 132 + const { pageWindow, windowId } = await openCanvasPage( 133 + sharedBgWindow, 134 + 'https://example.com' 135 + ); 136 + 137 + await waitForPageLoaded(pageWindow); 138 + await waitForWidgetHost(pageWindow); 139 + 140 + // Register and render a widget 141 + await pageWindow.evaluate(() => { 142 + const api = (window as any).app; 143 + api.publish('widget:register', { 144 + extensionId: 'test-ext', 145 + widgetId: 'render-test', 146 + title: 'Render Test', 147 + }, api.scopes.GLOBAL); 148 + 149 + api.publish('widget:render', { 150 + extensionId: 'test-ext', 151 + widgetId: 'render-test', 152 + title: 'Rendered Widget', 153 + html: '<p class="test-content">Widget content here</p>', 154 + }, api.scopes.GLOBAL); 155 + }); 156 + 157 + // Verify widget DOM was created 158 + const widgetExists = await pageWindow.waitForFunction( 159 + () => { 160 + const container = document.getElementById('widget-container'); 161 + if (!container) return false; 162 + const widget = container.querySelector('[data-widget-id="test-ext:render-test"]'); 163 + return widget !== null; 164 + }, 165 + undefined, 166 + { timeout: 5000 } 167 + ); 168 + expect(widgetExists).toBeTruthy(); 169 + 170 + // Verify widget content 171 + const content = await pageWindow.evaluate(() => { 172 + const widget = document.querySelector('[data-widget-id="test-ext:render-test"]'); 173 + const body = widget?.querySelector('.widget-body'); 174 + return body?.innerHTML || ''; 175 + }); 176 + expect(content).toContain('Widget content here'); 177 + 178 + await closeWindow(sharedBgWindow, windowId); 179 + }); 180 + 181 + test('widget:update modifies existing widget content', async () => { 182 + const { pageWindow, windowId } = await openCanvasPage( 183 + sharedBgWindow, 184 + 'https://example.com' 185 + ); 186 + 187 + await waitForPageLoaded(pageWindow); 188 + await waitForWidgetHost(pageWindow); 189 + 190 + // Register, render, then update 191 + await pageWindow.evaluate(() => { 192 + const api = (window as any).app; 193 + api.publish('widget:register', { 194 + extensionId: 'test-ext', 195 + widgetId: 'update-test', 196 + }, api.scopes.GLOBAL); 197 + 198 + api.publish('widget:render', { 199 + extensionId: 'test-ext', 200 + widgetId: 'update-test', 201 + html: '<p>Original</p>', 202 + }, api.scopes.GLOBAL); 203 + }); 204 + 205 + // Wait for initial render 206 + await pageWindow.waitForFunction( 207 + () => document.querySelector('[data-widget-id="test-ext:update-test"]') !== null, 208 + undefined, 209 + { timeout: 5000 } 210 + ); 211 + 212 + // Update the widget 213 + await pageWindow.evaluate(() => { 214 + const api = (window as any).app; 215 + api.publish('widget:update', { 216 + extensionId: 'test-ext', 217 + widgetId: 'update-test', 218 + html: '<p>Updated content</p>', 219 + title: 'New Title', 220 + }, api.scopes.GLOBAL); 221 + }); 222 + 223 + // Verify update 224 + const updatedContent = await pageWindow.evaluate(() => { 225 + const widget = document.querySelector('[data-widget-id="test-ext:update-test"]'); 226 + const body = widget?.querySelector('.widget-body'); 227 + return body?.innerHTML || ''; 228 + }); 229 + expect(updatedContent).toContain('Updated content'); 230 + 231 + await closeWindow(sharedBgWindow, windowId); 232 + }); 233 + 234 + test('widget:close removes widget from DOM', async () => { 235 + const { pageWindow, windowId } = await openCanvasPage( 236 + sharedBgWindow, 237 + 'https://example.com' 238 + ); 239 + 240 + await waitForPageLoaded(pageWindow); 241 + await waitForWidgetHost(pageWindow); 242 + 243 + // Register and render 244 + await pageWindow.evaluate(() => { 245 + const api = (window as any).app; 246 + api.publish('widget:register', { 247 + extensionId: 'test-ext', 248 + widgetId: 'close-test', 249 + }, api.scopes.GLOBAL); 250 + 251 + api.publish('widget:render', { 252 + extensionId: 'test-ext', 253 + widgetId: 'close-test', 254 + html: '<p>To be closed</p>', 255 + }, api.scopes.GLOBAL); 256 + }); 257 + 258 + // Wait for render 259 + await pageWindow.waitForFunction( 260 + () => document.querySelector('[data-widget-id="test-ext:close-test"]') !== null, 261 + undefined, 262 + { timeout: 5000 } 263 + ); 264 + 265 + // Close the widget 266 + await pageWindow.evaluate(() => { 267 + const api = (window as any).app; 268 + api.publish('widget:close', { 269 + extensionId: 'test-ext', 270 + widgetId: 'close-test', 271 + }, api.scopes.GLOBAL); 272 + }); 273 + 274 + // Wait for removal (animation takes 300ms) 275 + await pageWindow.waitForFunction( 276 + () => { 277 + const host = (window as any).__pageWidgetHost; 278 + return host && host.getActiveWidgets().size === 0; 279 + }, 280 + undefined, 281 + { timeout: 5000 } 282 + ); 283 + 284 + await closeWindow(sharedBgWindow, windowId); 285 + }); 286 + 287 + test('page:execute-script runs code in webview and returns result', async () => { 288 + const { pageWindow, windowId } = await openCanvasPage( 289 + sharedBgWindow, 290 + 'https://example.com' 291 + ); 292 + 293 + await waitForPageLoaded(pageWindow); 294 + await waitForWidgetHost(pageWindow); 295 + 296 + // Execute a script and capture result via pubsub 297 + const result = await pageWindow.evaluate(() => { 298 + return new Promise((resolve, reject) => { 299 + const api = (window as any).app; 300 + const requestId = 'test-script-' + Date.now(); 301 + let timer: ReturnType<typeof setTimeout>; 302 + 303 + const unsub = api.subscribe('page:script-result', (msg: any) => { 304 + if (msg.requestId !== requestId) return; 305 + clearTimeout(timer); 306 + unsub(); 307 + resolve(msg); 308 + }, api.scopes.GLOBAL); 309 + 310 + timer = setTimeout(() => { 311 + unsub(); 312 + reject(new Error('Timed out waiting for script result')); 313 + }, 10000); 314 + 315 + api.publish('page:execute-script', { 316 + extensionId: 'test-ext', 317 + requestId, 318 + script: 'document.title', 319 + }, api.scopes.GLOBAL); 320 + }); 321 + }); 322 + 323 + expect((result as any).success).toBe(true); 324 + expect(typeof (result as any).result).toBe('string'); 325 + 326 + await closeWindow(sharedBgWindow, windowId); 327 + }); 328 + 329 + test('page:execute-script returns error for invalid script', async () => { 330 + const { pageWindow, windowId } = await openCanvasPage( 331 + sharedBgWindow, 332 + 'https://example.com' 333 + ); 334 + 335 + await waitForPageLoaded(pageWindow); 336 + await waitForWidgetHost(pageWindow); 337 + 338 + const result = await pageWindow.evaluate(() => { 339 + return new Promise((resolve, reject) => { 340 + const api = (window as any).app; 341 + const requestId = 'test-error-' + Date.now(); 342 + let timer: ReturnType<typeof setTimeout>; 343 + 344 + const unsub = api.subscribe('page:script-result', (msg: any) => { 345 + if (msg.requestId !== requestId) return; 346 + clearTimeout(timer); 347 + unsub(); 348 + resolve(msg); 349 + }, api.scopes.GLOBAL); 350 + 351 + timer = setTimeout(() => { 352 + unsub(); 353 + reject(new Error('Timed out')); 354 + }, 10000); 355 + 356 + api.publish('page:execute-script', { 357 + extensionId: 'test-ext', 358 + requestId, 359 + script: 'throw new Error("intentional test error")', 360 + }, api.scopes.GLOBAL); 361 + }); 362 + }); 363 + 364 + expect((result as any).success).toBe(false); 365 + expect((result as any).error).toBeTruthy(); 366 + 367 + await closeWindow(sharedBgWindow, windowId); 368 + }); 369 + });
+595
tests/unit/page-widgets.test.js
··· 1 + /** 2 + * Unit tests for the page widget host module. 3 + * 4 + * Tests the widget registration, rendering, update, close, content script 5 + * execution, and lifecycle event publishing without requiring DOM or IPC. 6 + * 7 + * Run via: node --test tests/unit/page-widgets.test.js 8 + */ 9 + import { describe, it, beforeEach } from 'node:test'; 10 + import { strict as assert } from 'node:assert'; 11 + import { join, dirname } from 'path'; 12 + import { fileURLToPath } from 'url'; 13 + 14 + const __dirname = dirname(fileURLToPath(import.meta.url)); 15 + const modulePath = join(__dirname, '..', '..', 'app', 'page', 'page-widgets.js'); 16 + 17 + const { createPageWidgetHost } = await import(modulePath); 18 + 19 + // --- Mock factories --- 20 + 21 + function createMockApi() { 22 + const subscriptions = new Map(); 23 + const published = []; 24 + 25 + return { 26 + scopes: { GLOBAL: 'GLOBAL' }, 27 + 28 + subscribe(topic, handler, scope) { 29 + if (!subscriptions.has(topic)) subscriptions.set(topic, []); 30 + subscriptions.get(topic).push(handler); 31 + // Return unsubscribe function 32 + return () => { 33 + const handlers = subscriptions.get(topic); 34 + if (handlers) { 35 + const idx = handlers.indexOf(handler); 36 + if (idx >= 0) handlers.splice(idx, 1); 37 + } 38 + }; 39 + }, 40 + 41 + publish(topic, data, scope) { 42 + published.push({ topic, data, scope }); 43 + // Also trigger local subscribers for testing 44 + const handlers = subscriptions.get(topic); 45 + if (handlers) { 46 + for (const h of handlers) h(data); 47 + } 48 + }, 49 + 50 + // Test helpers 51 + _published: published, 52 + _subscriptions: subscriptions, 53 + 54 + getPublished(topic) { 55 + return published.filter(p => p.topic === topic); 56 + }, 57 + 58 + clearPublished() { 59 + published.length = 0; 60 + }, 61 + }; 62 + } 63 + 64 + function createMockWebview() { 65 + let scriptResult = null; 66 + let scriptError = null; 67 + let currentUrl = 'https://example.com'; 68 + 69 + return { 70 + executeJavaScript(script) { 71 + if (scriptError) return Promise.reject(new Error(scriptError)); 72 + return Promise.resolve(scriptResult); 73 + }, 74 + getURL() { 75 + return currentUrl; 76 + }, 77 + // Test helpers 78 + _setScriptResult(result) { scriptResult = result; scriptError = null; }, 79 + _setScriptError(err) { scriptError = err; scriptResult = null; }, 80 + _setUrl(url) { currentUrl = url; }, 81 + }; 82 + } 83 + 84 + function createMockWidgetFns() { 85 + const added = []; 86 + const updated = []; 87 + const removed = []; 88 + 89 + return { 90 + addWidget(id, opts) { 91 + const handle = { id, ...opts, element: {}, body: {}, timers: [], close: () => {} }; 92 + added.push({ id, opts }); 93 + return handle; 94 + }, 95 + updateWidget(id, opts) { 96 + updated.push({ id, opts }); 97 + }, 98 + removeWidget(id) { 99 + removed.push(id); 100 + }, 101 + _added: added, 102 + _updated: updated, 103 + _removed: removed, 104 + }; 105 + } 106 + 107 + function createHost(overrides = {}) { 108 + const api = createMockApi(); 109 + const webview = createMockWebview(); 110 + const widgetFns = createMockWidgetFns(); 111 + 112 + const host = createPageWidgetHost({ 113 + api, 114 + webview, 115 + widgetContainer: {}, 116 + addWidget: widgetFns.addWidget, 117 + updateWidget: widgetFns.updateWidget, 118 + removeWidget: widgetFns.removeWidget, 119 + ...overrides, 120 + }); 121 + 122 + return { host, api, webview, widgetFns }; 123 + } 124 + 125 + // --- Tests --- 126 + 127 + describe('createPageWidgetHost', () => { 128 + describe('init and destroy', () => { 129 + it('subscribes to pubsub topics on init', () => { 130 + const { host, api } = createHost(); 131 + host.init(); 132 + 133 + assert.ok(api._subscriptions.has('widget:register')); 134 + assert.ok(api._subscriptions.has('widget:render')); 135 + assert.ok(api._subscriptions.has('widget:update')); 136 + assert.ok(api._subscriptions.has('widget:close')); 137 + assert.ok(api._subscriptions.has('page:execute-script')); 138 + }); 139 + 140 + it('unsubscribes and cleans up on destroy', () => { 141 + const { host, api } = createHost(); 142 + host.init(); 143 + 144 + // Register a widget 145 + host._handleRegister({ extensionId: 'ext1', widgetId: 'w1' }); 146 + assert.equal(host.getWidgets().size, 1); 147 + 148 + host.destroy(); 149 + assert.equal(host.getWidgets().size, 0); 150 + }); 151 + 152 + it('publishes page:will-close on destroy', () => { 153 + const { host, api, webview } = createHost(); 154 + host.init(); 155 + webview._setUrl('https://example.com/test'); 156 + 157 + host.destroy(); 158 + 159 + const willCloseMessages = api.getPublished('page:will-close'); 160 + assert.equal(willCloseMessages.length, 1); 161 + assert.equal(willCloseMessages[0].data.url, 'https://example.com/test'); 162 + }); 163 + }); 164 + 165 + describe('widget registration', () => { 166 + it('registers a widget with valid data', () => { 167 + const { host, api } = createHost(); 168 + host.init(); 169 + 170 + host._handleRegister({ 171 + extensionId: 'myext', 172 + widgetId: 'mywidget', 173 + title: 'My Widget', 174 + position: 'right', 175 + }); 176 + 177 + const widgets = host.getWidgets(); 178 + assert.equal(widgets.size, 1); 179 + assert.ok(widgets.has('myext:mywidget')); 180 + 181 + const reg = widgets.get('myext:mywidget'); 182 + assert.equal(reg.extensionId, 'myext'); 183 + assert.equal(reg.widgetId, 'mywidget'); 184 + assert.equal(reg.title, 'My Widget'); 185 + assert.equal(reg.position, 'right'); 186 + }); 187 + 188 + it('publishes widget:registered confirmation', () => { 189 + const { host, api } = createHost(); 190 + host.init(); 191 + 192 + host._handleRegister({ extensionId: 'ext1', widgetId: 'w1' }); 193 + 194 + const confirms = api.getPublished('widget:registered'); 195 + assert.equal(confirms.length, 1); 196 + assert.equal(confirms[0].data.extensionId, 'ext1'); 197 + assert.equal(confirms[0].data.widgetId, 'w1'); 198 + assert.equal(confirms[0].data.success, true); 199 + }); 200 + 201 + it('defaults position to right and title to widgetId', () => { 202 + const { host } = createHost(); 203 + host.init(); 204 + 205 + host._handleRegister({ extensionId: 'ext1', widgetId: 'w1' }); 206 + 207 + const reg = host.getWidgets().get('ext1:w1'); 208 + assert.equal(reg.position, 'right'); 209 + assert.equal(reg.title, 'w1'); 210 + }); 211 + 212 + it('rejects registration with missing extensionId', () => { 213 + const { host, api } = createHost(); 214 + host.init(); 215 + 216 + host._handleRegister({ widgetId: 'w1' }); 217 + 218 + assert.equal(host.getWidgets().size, 0); 219 + assert.equal(api.getPublished('widget:registered').length, 0); 220 + }); 221 + 222 + it('rejects registration with missing widgetId', () => { 223 + const { host, api } = createHost(); 224 + host.init(); 225 + 226 + host._handleRegister({ extensionId: 'ext1' }); 227 + 228 + assert.equal(host.getWidgets().size, 0); 229 + }); 230 + 231 + it('rejects null message', () => { 232 + const { host } = createHost(); 233 + host.init(); 234 + 235 + host._handleRegister(null); 236 + host._handleRegister(undefined); 237 + 238 + assert.equal(host.getWidgets().size, 0); 239 + }); 240 + 241 + it('allows multiple widgets from same extension', () => { 242 + const { host } = createHost(); 243 + host.init(); 244 + 245 + host._handleRegister({ extensionId: 'ext1', widgetId: 'w1' }); 246 + host._handleRegister({ extensionId: 'ext1', widgetId: 'w2' }); 247 + 248 + assert.equal(host.getWidgets().size, 2); 249 + }); 250 + 251 + it('allows same widget ID from different extensions', () => { 252 + const { host } = createHost(); 253 + host.init(); 254 + 255 + host._handleRegister({ extensionId: 'ext1', widgetId: 'w1' }); 256 + host._handleRegister({ extensionId: 'ext2', widgetId: 'w1' }); 257 + 258 + assert.equal(host.getWidgets().size, 2); 259 + }); 260 + }); 261 + 262 + describe('widget rendering', () => { 263 + it('renders a registered widget', () => { 264 + const { host, widgetFns } = createHost(); 265 + host.init(); 266 + 267 + host._handleRegister({ extensionId: 'ext1', widgetId: 'w1', title: 'Test' }); 268 + host._handleRender({ 269 + extensionId: 'ext1', 270 + widgetId: 'w1', 271 + html: '<p>Hello</p>', 272 + }); 273 + 274 + assert.equal(widgetFns._added.length, 1); 275 + assert.equal(widgetFns._added[0].id, 'ext1:w1'); 276 + assert.equal(widgetFns._added[0].opts.content, '<p>Hello</p>'); 277 + assert.equal(widgetFns._added[0].opts.title, 'Test'); 278 + }); 279 + 280 + it('uses msg.title over registration title', () => { 281 + const { host, widgetFns } = createHost(); 282 + host.init(); 283 + 284 + host._handleRegister({ extensionId: 'ext1', widgetId: 'w1', title: 'Original' }); 285 + host._handleRender({ 286 + extensionId: 'ext1', 287 + widgetId: 'w1', 288 + title: 'Updated Title', 289 + html: '<p>Content</p>', 290 + }); 291 + 292 + assert.equal(widgetFns._added[0].opts.title, 'Updated Title'); 293 + }); 294 + 295 + it('rejects render for unregistered widget', () => { 296 + const { host, widgetFns } = createHost(); 297 + host.init(); 298 + 299 + host._handleRender({ 300 + extensionId: 'ext1', 301 + widgetId: 'w1', 302 + html: '<p>Hello</p>', 303 + }); 304 + 305 + assert.equal(widgetFns._added.length, 0); 306 + }); 307 + 308 + it('removes existing widget before re-rendering', () => { 309 + const { host, widgetFns } = createHost(); 310 + host.init(); 311 + 312 + host._handleRegister({ extensionId: 'ext1', widgetId: 'w1' }); 313 + host._handleRender({ extensionId: 'ext1', widgetId: 'w1', html: '<p>V1</p>' }); 314 + host._handleRender({ extensionId: 'ext1', widgetId: 'w1', html: '<p>V2</p>' }); 315 + 316 + assert.equal(widgetFns._added.length, 2); 317 + assert.equal(widgetFns._removed.length, 1); 318 + assert.equal(widgetFns._removed[0], 'ext1:w1'); 319 + }); 320 + 321 + it('publishes widget:closed when onClose callback fires', () => { 322 + const { host, api, widgetFns } = createHost(); 323 + host.init(); 324 + 325 + host._handleRegister({ extensionId: 'ext1', widgetId: 'w1' }); 326 + host._handleRender({ extensionId: 'ext1', widgetId: 'w1', html: '<p>Test</p>' }); 327 + 328 + // Simulate the close callback being invoked 329 + api.clearPublished(); 330 + const onClose = widgetFns._added[0].opts.onClose; 331 + assert.ok(typeof onClose === 'function'); 332 + onClose(); 333 + 334 + const closedMsgs = api.getPublished('widget:closed'); 335 + assert.equal(closedMsgs.length, 1); 336 + assert.equal(closedMsgs[0].data.extensionId, 'ext1'); 337 + assert.equal(closedMsgs[0].data.widgetId, 'w1'); 338 + }); 339 + 340 + it('passes autoDismiss to addWidget', () => { 341 + const { host, widgetFns } = createHost(); 342 + host.init(); 343 + 344 + host._handleRegister({ extensionId: 'ext1', widgetId: 'w1' }); 345 + host._handleRender({ 346 + extensionId: 'ext1', 347 + widgetId: 'w1', 348 + html: '<p>Temp</p>', 349 + autoDismiss: 5000, 350 + }); 351 + 352 + assert.equal(widgetFns._added[0].opts.autoDismiss, 5000); 353 + }); 354 + 355 + it('rejects render with missing extensionId', () => { 356 + const { host, widgetFns } = createHost(); 357 + host.init(); 358 + 359 + host._handleRender({ widgetId: 'w1', html: '<p>Nope</p>' }); 360 + assert.equal(widgetFns._added.length, 0); 361 + }); 362 + }); 363 + 364 + describe('widget update', () => { 365 + it('updates an active widget', () => { 366 + const { host, widgetFns } = createHost(); 367 + host.init(); 368 + 369 + host._handleRegister({ extensionId: 'ext1', widgetId: 'w1' }); 370 + host._handleRender({ extensionId: 'ext1', widgetId: 'w1', html: '<p>V1</p>' }); 371 + host._handleUpdate({ 372 + extensionId: 'ext1', 373 + widgetId: 'w1', 374 + html: '<p>V2</p>', 375 + title: 'New Title', 376 + }); 377 + 378 + assert.equal(widgetFns._updated.length, 1); 379 + assert.equal(widgetFns._updated[0].id, 'ext1:w1'); 380 + assert.equal(widgetFns._updated[0].opts.content, '<p>V2</p>'); 381 + assert.equal(widgetFns._updated[0].opts.title, 'New Title'); 382 + }); 383 + 384 + it('ignores update for inactive widget', () => { 385 + const { host, widgetFns } = createHost(); 386 + host.init(); 387 + 388 + host._handleUpdate({ 389 + extensionId: 'ext1', 390 + widgetId: 'w1', 391 + html: '<p>Ghost</p>', 392 + }); 393 + 394 + assert.equal(widgetFns._updated.length, 0); 395 + }); 396 + }); 397 + 398 + describe('widget close', () => { 399 + it('closes an active widget', () => { 400 + const { host, widgetFns } = createHost(); 401 + host.init(); 402 + 403 + host._handleRegister({ extensionId: 'ext1', widgetId: 'w1' }); 404 + host._handleRender({ extensionId: 'ext1', widgetId: 'w1', html: '<p>Bye</p>' }); 405 + host._handleClose({ extensionId: 'ext1', widgetId: 'w1' }); 406 + 407 + // One from re-render removal is 0 (first render), one from close 408 + assert.ok(widgetFns._removed.includes('ext1:w1')); 409 + assert.equal(host.getActiveWidgets().size, 0); 410 + }); 411 + 412 + it('ignores close for non-existent widget', () => { 413 + const { host, widgetFns } = createHost(); 414 + host.init(); 415 + 416 + host._handleClose({ extensionId: 'ext1', widgetId: 'w1' }); 417 + assert.equal(widgetFns._removed.length, 0); 418 + }); 419 + }); 420 + 421 + describe('content script execution', () => { 422 + it('executes script and publishes result', async () => { 423 + const { host, api, webview } = createHost(); 424 + host.init(); 425 + 426 + webview._setScriptResult({ title: 'Test Page' }); 427 + 428 + host._handleExecuteScript({ 429 + extensionId: 'ext1', 430 + requestId: 'req-1', 431 + script: 'document.title', 432 + }); 433 + 434 + // Wait for the async executeJavaScript to resolve 435 + await new Promise(r => setTimeout(r, 10)); 436 + 437 + const results = api.getPublished('page:script-result'); 438 + const result = results.find(r => r.data.requestId === 'req-1'); 439 + assert.ok(result); 440 + assert.equal(result.data.success, true); 441 + assert.deepEqual(result.data.result, { title: 'Test Page' }); 442 + assert.equal(result.data.extensionId, 'ext1'); 443 + }); 444 + 445 + it('publishes error when script fails', async () => { 446 + const { host, api, webview } = createHost(); 447 + host.init(); 448 + 449 + webview._setScriptError('ReferenceError: foo is not defined'); 450 + 451 + host._handleExecuteScript({ 452 + extensionId: 'ext1', 453 + requestId: 'req-2', 454 + script: 'foo()', 455 + }); 456 + 457 + await new Promise(r => setTimeout(r, 10)); 458 + 459 + const results = api.getPublished('page:script-result'); 460 + const result = results.find(r => r.data.requestId === 'req-2'); 461 + assert.ok(result); 462 + assert.equal(result.data.success, false); 463 + assert.ok(result.data.error.includes('ReferenceError')); 464 + }); 465 + 466 + it('publishes error when no webview', () => { 467 + const api = createMockApi(); 468 + const widgetFns = createMockWidgetFns(); 469 + 470 + const host = createPageWidgetHost({ 471 + api, 472 + webview: null, 473 + widgetContainer: {}, 474 + addWidget: widgetFns.addWidget, 475 + updateWidget: widgetFns.updateWidget, 476 + removeWidget: widgetFns.removeWidget, 477 + }); 478 + host.init(); 479 + 480 + host._handleExecuteScript({ 481 + extensionId: 'ext1', 482 + requestId: 'req-3', 483 + script: 'document.title', 484 + }); 485 + 486 + const results = api.getPublished('page:script-result'); 487 + const result = results.find(r => r.data.requestId === 'req-3'); 488 + assert.ok(result); 489 + assert.ok(result.data.error.includes('No webview')); 490 + }); 491 + 492 + it('rejects invalid execute-script messages', () => { 493 + const { host, api } = createHost(); 494 + host.init(); 495 + 496 + api.clearPublished(); 497 + 498 + host._handleExecuteScript(null); 499 + host._handleExecuteScript({ extensionId: 'ext1' }); 500 + host._handleExecuteScript({ extensionId: 'ext1', requestId: 'req-4' }); 501 + 502 + assert.equal(api.getPublished('page:script-result').length, 0); 503 + }); 504 + }); 505 + 506 + describe('lifecycle events', () => { 507 + it('publishNavigated emits page:navigated', () => { 508 + const { host, api } = createHost(); 509 + host.init(); 510 + 511 + host.publishNavigated('https://example.com/new', 'New Page'); 512 + 513 + const msgs = api.getPublished('page:navigated'); 514 + assert.equal(msgs.length, 1); 515 + assert.equal(msgs[0].data.url, 'https://example.com/new'); 516 + assert.equal(msgs[0].data.title, 'New Page'); 517 + }); 518 + 519 + it('publishWillClose emits page:will-close', () => { 520 + const { host, api } = createHost(); 521 + host.init(); 522 + 523 + host.publishWillClose('https://example.com'); 524 + 525 + const msgs = api.getPublished('page:will-close'); 526 + assert.equal(msgs.length, 1); 527 + assert.equal(msgs[0].data.url, 'https://example.com'); 528 + }); 529 + }); 530 + 531 + describe('pubsub integration', () => { 532 + it('responds to widget:register pubsub messages after init', () => { 533 + const { host, api } = createHost(); 534 + host.init(); 535 + 536 + // Publish directly via API (simulates an extension sending a message) 537 + api.publish('widget:register', { 538 + extensionId: 'ext-pubsub', 539 + widgetId: 'w-pubsub', 540 + title: 'Pubsub Widget', 541 + }, api.scopes.GLOBAL); 542 + 543 + assert.equal(host.getWidgets().size, 1); 544 + assert.ok(host.getWidgets().has('ext-pubsub:w-pubsub')); 545 + }); 546 + 547 + it('responds to widget:render pubsub messages', () => { 548 + const { host, api, widgetFns } = createHost(); 549 + host.init(); 550 + 551 + api.publish('widget:register', { 552 + extensionId: 'ext1', 553 + widgetId: 'w1', 554 + }, api.scopes.GLOBAL); 555 + 556 + api.publish('widget:render', { 557 + extensionId: 'ext1', 558 + widgetId: 'w1', 559 + html: '<p>Pubsub rendered</p>', 560 + }, api.scopes.GLOBAL); 561 + 562 + assert.equal(widgetFns._added.length, 1); 563 + }); 564 + 565 + it('full lifecycle: register -> render -> update -> close', () => { 566 + const { host, api, widgetFns } = createHost(); 567 + host.init(); 568 + 569 + // Register 570 + api.publish('widget:register', { 571 + extensionId: 'ext1', widgetId: 'w1', title: 'Lifecycle' 572 + }, api.scopes.GLOBAL); 573 + assert.equal(host.getWidgets().size, 1); 574 + 575 + // Render 576 + api.publish('widget:render', { 577 + extensionId: 'ext1', widgetId: 'w1', html: '<p>V1</p>' 578 + }, api.scopes.GLOBAL); 579 + assert.equal(widgetFns._added.length, 1); 580 + assert.equal(host.getActiveWidgets().size, 1); 581 + 582 + // Update 583 + api.publish('widget:update', { 584 + extensionId: 'ext1', widgetId: 'w1', html: '<p>V2</p>', title: 'Updated' 585 + }, api.scopes.GLOBAL); 586 + assert.equal(widgetFns._updated.length, 1); 587 + 588 + // Close 589 + api.publish('widget:close', { 590 + extensionId: 'ext1', widgetId: 'w1' 591 + }, api.scopes.GLOBAL); 592 + assert.equal(host.getActiveWidgets().size, 0); 593 + }); 594 + }); 595 + });