experiments in a post-browser web
10
fork

Configure Feed

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

refactor(websearch): collapse into single resident tile, drop cross-window pubsub

Before this change websearch had two tile entries: a lazy background
tile owning engine state / command registration / settings / suggestions,
and a window tile for the UI. The two communicated via GLOBAL pubsub
channels (websearch:engine-request, websearch:engines-list,
websearch:request-suggestions, websearch:suggestions). That round-trip
is currently broken at the broadcaster level for home-publish to
bg-subscribe delivery, causing 3 websearch tests to fail (engines list
never populates in the home window).

This collapses the feature into ONE tile per the docs/tiles-single-file.md
pattern used by hello-world, mcp-server, widget-demo: a single home tile
with resident: true, width/height/role/key hoisted onto the entry, no
separate background.html entry. home.js now owns the engine list, command
registration, settings, and suggestion fetching directly — no cross-tile
pubsub needed because the UI and the state live in the same renderer.

Also removes the old cross-tile channel helpers and their subscribers
(onChannel/emitChannel for engine-request/engines-list/request-suggestions/
suggestions). Exports preserved for tests: builtinEngines, getEngine,
getDefaultEngine, getEnabledEngines, buildSearchUrl, parseOpenSearchXml.

background.js and background.html intentionally left on disk — no longer
referenced by the manifest, safe to delete in a follow-up.

Tests: 10/10 websearch tests pass (was 7/10).

+828 -157
+1 -1
features/websearch/home.html
··· 2 2 <html> 3 3 <head> 4 4 <meta charset="utf-8"> 5 - <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 5 + <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'; connect-src https: http:;"> 6 6 <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1"> 7 7 <title>Web Search</title> 8 8 <link rel="stylesheet" type="text/css" href="home.css">
+821 -142
features/websearch/home.js
··· 1 1 /** 2 - * Web Search Home UI 2 + * Web Search Tile 3 3 * 4 - * Provides: 5 - * - Search input with engine selector 6 - * - Suggestion list with keyboard navigation 7 - * - Engine list when no query is entered 8 - * - Keyboard navigation (arrow keys, Enter to search) 4 + * Single-tile (resident: true) combining engine state management and UI: 5 + * - Built-in engines: Google, DuckDuckGo, Bing, Wikipedia, Kagi 6 + * - OpenSearch discovery from visited pages 7 + * - Search suggestions with debounce and caching 8 + * - Per-engine commands (/google, /ddg, /bing, /wiki, /kagi) 9 + * - A general "web search" command with engine selection 10 + * - Search UI (engine selector, suggestions, keyboard navigation) 11 + * 12 + * Commands are declared in manifest.json. 13 + * Tile loads as resident (hidden at startup); shows itself when invoked. 9 14 */ 15 + 16 + import { id, labels, schemas, storageKeys, defaults, builtinEngines } from './config.js'; 10 17 11 18 const api = window.app; 12 19 const debug = api.debug; 13 20 14 - // ===== IPC pubsub messaging ===== 15 - // Background runs at peek://ext/websearch/ while home runs at peek://websearch/ 16 - // — different origins, so BroadcastChannel cannot work. Use IPC pubsub instead. 21 + // ===== State ===== 22 + 23 + // In-memory settings cache (loaded from datastore on init) 24 + let currentSettings = { 25 + prefs: defaults.prefs 26 + }; 27 + 28 + // All engines: built-in + custom/discovered 29 + let engines = [...builtinEngines]; 30 + 31 + // Suggestion cache: Map<cacheKey, { suggestions, timestamp }> 32 + const suggestionCache = new Map(); 33 + const SUGGESTION_CACHE_TTL = 5 * 60 * 1000; // 5 minutes 34 + const SUGGESTION_CACHE_MAX = 500; 35 + 36 + // Track in-flight suggestion requests for cancellation 37 + let currentSuggestionAbortController = null; 38 + 39 + // ===== Settings ===== 40 + 41 + /** 42 + * Load settings from datastore 43 + */ 44 + const loadSettings = async () => { 45 + const result = await api.settings.get('prefs'); 46 + if (result.success && result.data) { 47 + return { 48 + prefs: { ...defaults.prefs, ...(result.data || {}) } 49 + }; 50 + } 51 + return { prefs: { ...defaults.prefs } }; 52 + }; 53 + 54 + /** 55 + * Save settings to datastore 56 + */ 57 + const saveSettings = async (settings) => { 58 + const result = await api.settings.set('prefs', settings.prefs); 59 + if (!result.success) { 60 + console.error('[ext:websearch] Failed to save settings:', result.error); 61 + } 62 + }; 63 + 64 + // ===== Engine Registry ===== 65 + 66 + /** 67 + * Load custom/discovered engines from settings 68 + */ 69 + const loadEngines = async () => { 70 + const result = await api.settings.get('engines'); 71 + const customEngines = (result.success && result.data) || []; 72 + 73 + // Merge built-in with custom, custom engines can override built-in ones by ID 74 + const customIds = new Set(customEngines.map(e => e.id)); 75 + engines = [ 76 + ...builtinEngines.filter(e => !customIds.has(e.id)), 77 + ...customEngines 78 + ]; 79 + 80 + debug && console.log('[ext:websearch] Loaded engines:', engines.map(e => e.id)); 81 + }; 82 + 83 + /** 84 + * Save custom engines to settings (only non-builtin ones) 85 + */ 86 + const saveEngines = async () => { 87 + const customEngines = engines.filter(e => !e.isBuiltIn); 88 + await api.settings.set('engines', customEngines); 89 + }; 90 + 91 + /** 92 + * Get an engine by ID 93 + */ 94 + const getEngine = (engineId) => { 95 + return engines.find(e => e.id === engineId) || null; 96 + }; 97 + 98 + /** 99 + * Get an engine by short name (keyword) 100 + */ 101 + const getEngineByShortName = (shortName) => { 102 + return engines.find(e => e.shortName === shortName && e.enabled !== false) || null; 103 + }; 104 + 105 + /** 106 + * Get the default engine 107 + */ 108 + const getDefaultEngine = () => { 109 + const defaultId = currentSettings.prefs.defaultEngine; 110 + return getEngine(defaultId) || engines.find(e => e.enabled !== false) || engines[0]; 111 + }; 112 + 113 + /** 114 + * Get all enabled engines 115 + */ 116 + const getEnabledEngines = () => { 117 + return engines.filter(e => e.enabled !== false); 118 + }; 119 + 120 + /** 121 + * Add a discovered/custom engine 122 + */ 123 + const addEngine = async (engine) => { 124 + // Check for duplicate ID 125 + const existingIndex = engines.findIndex(e => e.id === engine.id); 126 + if (existingIndex >= 0) { 127 + // Update existing 128 + engines[existingIndex] = { ...engines[existingIndex], ...engine }; 129 + } else { 130 + engines.push(engine); 131 + } 132 + await saveEngines(); 133 + 134 + // Re-register commands for the new engine 135 + unregisterEngineCommands(); 136 + registerEngineCommands(); 137 + 138 + debug && console.log('[ext:websearch] Added engine:', engine.id, engine.name); 139 + }; 140 + 141 + /** 142 + * Remove a custom engine (cannot remove built-in) 143 + */ 144 + const removeEngine = async (engineId) => { 145 + const engine = getEngine(engineId); 146 + if (!engine) return false; 147 + if (engine.isBuiltIn) { 148 + console.warn('[ext:websearch] Cannot remove built-in engine:', engineId); 149 + return false; 150 + } 151 + 152 + engines = engines.filter(e => e.id !== engineId); 153 + await saveEngines(); 154 + 155 + // Re-register commands 156 + unregisterEngineCommands(); 157 + registerEngineCommands(); 158 + 159 + return true; 160 + }; 161 + 162 + // ===== Search URL Construction ===== 163 + 164 + /** 165 + * Build a search URL from a template and query 166 + */ 167 + const buildSearchUrl = (urlTemplate, query) => { 168 + return urlTemplate.replace('{searchTerms}', encodeURIComponent(query)); 169 + }; 170 + 171 + /** 172 + * Execute a search: open the search results URL in a new window 173 + */ 174 + const executeSearch = async (engine, query) => { 175 + if (!query || !query.trim()) return; 176 + 177 + const url = buildSearchUrl(engine.searchUrl, query.trim()); 178 + debug && console.log('[ext:websearch] Searching:', engine.name, query, '->', url); 179 + 180 + api.window.open(url, { 181 + role: 'content', 182 + width: 1024, 183 + height: 768, 184 + trackingSource: 'websearch', 185 + trackingSourceId: engine.id 186 + }).catch(err => { 187 + console.error('[ext:websearch] Failed to open search window:', err); 188 + }); 189 + }; 190 + 191 + // ===== Search Suggestions ===== 192 + 193 + /** 194 + * Fetch suggestions from a search engine's suggest API. 195 + * Supports OpenSearch JSON format: ["query", ["suggestion1", "suggestion2", ...]] 196 + */ 197 + const fetchSuggestions = async (engine, query, signal) => { 198 + if (!engine.suggestUrl || !query || !query.trim()) return []; 199 + if (!currentSettings.prefs.enableSuggestions) return []; 200 + 201 + const url = buildSearchUrl(engine.suggestUrl, query.trim()); 202 + 203 + try { 204 + const controller = new AbortController(); 205 + const timeout = setTimeout(() => controller.abort(), 3000); 206 + if (signal) signal.addEventListener('abort', () => controller.abort()); 207 + 208 + const res = await fetch(url, { 209 + headers: { 'Accept': 'application/json' }, 210 + signal: controller.signal, 211 + }); 212 + clearTimeout(timeout); 213 + 214 + if (signal && signal.aborted) return []; 215 + 216 + if (!res.ok) { 217 + debug && console.log('[ext:websearch] Suggestion fetch failed:', res.status); 218 + return []; 219 + } 220 + 221 + const data = await res.json(); 222 + 223 + // OpenSearch suggestion format: ["query", ["suggestion1", "suggestion2"]] 224 + if (Array.isArray(data) && data.length >= 2 && Array.isArray(data[1])) { 225 + return data[1].slice(0, 8); 226 + } 227 + 228 + // Google-style: { suggestions: [{ value: "..." }] } 229 + if (data && Array.isArray(data.suggestions)) { 230 + return data.suggestions.map(s => s.value || s).slice(0, 8); 231 + } 232 + 233 + return []; 234 + } catch (err) { 235 + if (err.name !== 'AbortError') { 236 + debug && console.log('[ext:websearch] Suggestion fetch failed:', err.message); 237 + } 238 + return []; 239 + } 240 + }; 241 + 242 + /** 243 + * Fetch suggestions with in-memory caching 244 + */ 245 + const fetchSuggestionsWithCache = async (engine, query, signal) => { 246 + const cacheKey = `${engine.id}:${query.toLowerCase().trim()}`; 247 + const cached = suggestionCache.get(cacheKey); 248 + 249 + if (cached && Date.now() - cached.timestamp < SUGGESTION_CACHE_TTL) { 250 + return cached.suggestions; 251 + } 252 + 253 + const suggestions = await fetchSuggestions(engine, query, signal); 254 + 255 + suggestionCache.set(cacheKey, { 256 + suggestions, 257 + timestamp: Date.now() 258 + }); 259 + 260 + // Evict oldest entries if cache is too large 261 + if (suggestionCache.size > SUGGESTION_CACHE_MAX) { 262 + const oldestKey = suggestionCache.keys().next().value; 263 + suggestionCache.delete(oldestKey); 264 + } 265 + 266 + return suggestions; 267 + }; 268 + 269 + /** 270 + * Clear the suggestion cache 271 + */ 272 + const clearSuggestionCache = () => { 273 + suggestionCache.clear(); 274 + }; 275 + 276 + // ===== OpenSearch Discovery ===== 277 + 278 + /** 279 + * Parse an OpenSearch XML description document 280 + * Returns an engine object or null if invalid 281 + */ 282 + const parseOpenSearchXml = (xmlText, sourceUrl) => { 283 + try { 284 + const parser = new DOMParser(); 285 + const doc = parser.parseFromString(xmlText, 'application/xml'); 286 + 287 + // Check for parse errors 288 + const parseError = doc.querySelector('parsererror'); 289 + if (parseError) { 290 + debug && console.log('[ext:websearch] OpenSearch XML parse error'); 291 + return null; 292 + } 293 + 294 + const ns = 'http://a9.com/-/spec/opensearch/1.1/'; 295 + 296 + const shortName = doc.getElementsByTagNameNS(ns, 'ShortName')[0]?.textContent?.trim(); 297 + const description = doc.getElementsByTagNameNS(ns, 'Description')[0]?.textContent?.trim(); 298 + const imageEl = doc.getElementsByTagNameNS(ns, 'Image')[0]; 299 + const iconUrl = imageEl?.textContent?.trim(); 300 + 301 + // Find search URL (type="text/html") 302 + let searchUrl = null; 303 + let suggestUrl = null; 304 + 305 + const urlElements = doc.getElementsByTagNameNS(ns, 'Url'); 306 + for (const urlEl of urlElements) { 307 + const type = urlEl.getAttribute('type'); 308 + const template = urlEl.getAttribute('template'); 309 + if (!template) continue; 310 + 311 + if (type === 'text/html' && !searchUrl) { 312 + searchUrl = template; 313 + } else if ((type === 'application/x-suggestions+json' || type === 'application/json') && !suggestUrl) { 314 + suggestUrl = template; 315 + } 316 + } 317 + 318 + if (!shortName || !searchUrl) { 319 + debug && console.log('[ext:websearch] OpenSearch XML missing required fields (ShortName, Url)'); 320 + return null; 321 + } 322 + 323 + // Validate that searchUrl contains {searchTerms} 324 + if (!searchUrl.includes('{searchTerms}')) { 325 + debug && console.log('[ext:websearch] OpenSearch search URL missing {searchTerms} placeholder'); 326 + return null; 327 + } 328 + 329 + // Reject javascript: URLs 330 + if (searchUrl.startsWith('javascript:') || (suggestUrl && suggestUrl.startsWith('javascript:'))) { 331 + debug && console.log('[ext:websearch] Rejecting javascript: URL in OpenSearch'); 332 + return null; 333 + } 334 + 335 + // Generate a stable ID from the source URL 336 + const hostname = new URL(sourceUrl).hostname; 337 + const engineId = `discovered-${hostname.replace(/\./g, '-')}`; 338 + 339 + // Generate a short keyword from the hostname 340 + const shortNameKeyword = hostname 341 + .replace(/^www\./, '') 342 + .split('.')[0] 343 + .toLowerCase() 344 + .replace(/[^a-z0-9]/g, '') 345 + .slice(0, 12); 346 + 347 + return { 348 + id: engineId, 349 + name: shortName, 350 + shortName: shortNameKeyword, 351 + description: description || `Search ${shortName}`, 352 + searchUrl, 353 + suggestUrl: suggestUrl || null, 354 + iconUrl: iconUrl || null, 355 + isBuiltIn: false, 356 + enabled: true, 357 + discoveredFrom: sourceUrl, 358 + discoveredAt: Date.now() 359 + }; 360 + } catch (err) { 361 + console.error('[ext:websearch] Failed to parse OpenSearch XML:', err); 362 + return null; 363 + } 364 + }; 365 + 366 + /** 367 + * Attempt to discover and add a search engine from an OpenSearch URL 368 + */ 369 + const discoverEngine = async (opensearchUrl, pageUrl) => { 370 + if (!currentSettings.prefs.enableAutoDiscovery) return null; 371 + 372 + try { 373 + const response = await fetch(opensearchUrl, { 374 + method: 'GET', 375 + headers: { 'Accept': 'application/opensearchdescription+xml, application/xml, text/xml' }, 376 + signal: AbortSignal.timeout(5000) 377 + }); 378 + 379 + if (!response.ok) return null; 380 + 381 + const xmlText = await response.text(); 382 + const engine = parseOpenSearchXml(xmlText, pageUrl); 383 + 384 + if (!engine) return null; 385 + 386 + // Check if we already have this engine 387 + const existing = getEngine(engine.id); 388 + if (existing) { 389 + debug && console.log('[ext:websearch] Engine already registered:', engine.id); 390 + return existing; 391 + } 392 + 393 + // Auto-add the discovered engine 394 + await addEngine(engine); 395 + 396 + // Publish discovery event for UI notifications 397 + api.pubsub.publish('websearch:engine-discovered', { 398 + engine: { id: engine.id, name: engine.name, searchUrl: engine.searchUrl } 399 + }, api.scopes.GLOBAL); 400 + 401 + debug && console.log('[ext:websearch] Discovered search engine:', engine.name, 'from', pageUrl); 402 + return engine; 403 + } catch (err) { 404 + debug && console.log('[ext:websearch] OpenSearch discovery failed:', err.message); 405 + return null; 406 + } 407 + }; 408 + 409 + // ===== Query Extraction ===== 410 + 411 + /** 412 + * Extract the search query from a command execution context. 413 + * 414 + * The query can arrive in different fields depending on how the command 415 + * was invoked: 416 + * - Inline ("kagi foo"): ctx.search = "foo", ctx.params = ["foo"] 417 + * - Tab param mode: ctx.search = "foo", ctx.params = ["foo"] 418 + * - Direct pubsub: ctx.search = "foo" 419 + * 420 + * We check ctx.search first, then fall back to joining ctx.params, 421 + * then try extracting from ctx.typed by stripping the command name. 422 + */ 423 + const extractSearchQuery = (ctx) => { 424 + // 1. Explicit search field (set by buildExecutionContext when text follows command name) 425 + if (ctx.search && ctx.search.trim()) { 426 + return ctx.search.trim(); 427 + } 428 + 429 + // 2. Params array (same source as search, but as split tokens) 430 + if (ctx.params && ctx.params.length > 0) { 431 + const joined = ctx.params.join(' ').trim(); 432 + if (joined) return joined; 433 + } 434 + 435 + // 3. Extract from typed text by stripping the command name 436 + if (ctx.typed && ctx.name) { 437 + const typed = ctx.typed.trim(); 438 + const nameLower = ctx.name.toLowerCase(); 439 + const typedLower = typed.toLowerCase(); 440 + if (typedLower.startsWith(nameLower + ' ')) { 441 + const rest = typed.slice(ctx.name.length).trim(); 442 + if (rest) return rest; 443 + } else if (typedLower.startsWith(nameLower)) { 444 + const rest = typed.slice(ctx.name.length).trim(); 445 + if (rest) return rest; 446 + } 447 + } 448 + 449 + // 4. Fallback: extract from value field (Enter payload passes value=input.value) 450 + if (ctx.value && ctx.name) { 451 + const val = ctx.value.trim(); 452 + const nameLower = ctx.name.toLowerCase(); 453 + const valLower = val.toLowerCase(); 454 + if (valLower.startsWith(nameLower + ' ')) { 455 + const rest = val.slice(ctx.name.length).trim(); 456 + if (rest) return rest; 457 + } 458 + } 459 + 460 + // 5. Last resort: if typed contains a space, take everything after the first space 461 + // (handles case where name field might be missing) 462 + if (ctx.typed) { 463 + const spaceIdx = ctx.typed.indexOf(' '); 464 + if (spaceIdx > 0) { 465 + const rest = ctx.typed.slice(spaceIdx + 1).trim(); 466 + if (rest) return rest; 467 + } 468 + } 469 + 470 + return ''; 471 + }; 472 + 473 + // ===== Command Definitions ===== 474 + 475 + const staticCommandDefinitions = [ 476 + { 477 + name: 'web search', 478 + description: 'Search the web with your default search engine', 479 + params: [{ name: 'query', type: 'string', required: false, description: 'Search query' }], 480 + execute: async (ctx) => { 481 + debug && console.log('[ext:websearch] web search execute:', 482 + 'ctx keys:', ctx ? Object.keys(ctx) : 'null', 483 + 'search:', ctx?.search, 'params:', ctx?.params, 'typed:', ctx?.typed); 484 + const query = extractSearchQuery(ctx); 485 + if (query) { 486 + const engine = getDefaultEngine(); 487 + await executeSearch(engine, query); 488 + } else { 489 + console.warn('[ext:websearch] No query extracted for web search - showing search window. ctx:', JSON.stringify(ctx)); 490 + await api.window.showSelf(); 491 + } 492 + return { success: true }; 493 + } 494 + }, 495 + { 496 + name: 'open web search', 497 + description: 'Open the web search window', 498 + execute: async (ctx) => { 499 + await api.window.showSelf(); 500 + return { success: true }; 501 + } 502 + } 503 + ]; 504 + 505 + // Track registered commands for cleanup 506 + let registeredCommands = []; 507 + let engineCommands = []; 508 + 509 + /** 510 + * Register per-engine commands (e.g., /google, /ddg, /bing, /wiki) 511 + */ 512 + const registerEngineCommands = () => { 513 + const enabledEngines = getEnabledEngines(); 514 + 515 + for (const engine of enabledEngines) { 516 + const cmd = { 517 + name: engine.shortName, 518 + description: `Search ${engine.name}`, 519 + params: [{ name: 'query', type: 'string', required: false, description: 'Search query' }], 520 + execute: async (ctx) => { 521 + debug && console.log('[ext:websearch] Engine command execute:', engine.shortName, 522 + 'ctx keys:', ctx ? Object.keys(ctx) : 'null', 523 + 'search:', ctx?.search, 'params:', ctx?.params, 'typed:', ctx?.typed, 'name:', ctx?.name); 524 + const query = extractSearchQuery(ctx); 525 + debug && console.log('[ext:websearch] Extracted query:', JSON.stringify(query), 'from ctx for', engine.shortName); 526 + if (query) { 527 + await executeSearch(engine, query); 528 + } else { 529 + console.warn('[ext:websearch] No query extracted for', engine.shortName, '- showing search window. ctx:', JSON.stringify(ctx)); 530 + await api.window.showSelf(); 531 + } 532 + return { success: true }; 533 + } 534 + }; 535 + api.commands.register(cmd); 536 + engineCommands.push(engine.shortName); 537 + } 538 + 539 + debug && console.log('[ext:websearch] Registered engine commands:', engineCommands); 540 + }; 541 + 542 + /** 543 + * Unregister engine commands 544 + */ 545 + const unregisterEngineCommands = () => { 546 + for (const name of engineCommands) { 547 + api.commands.unregister(name); 548 + } 549 + engineCommands = []; 550 + }; 551 + 552 + /** 553 + * Register all commands (static + per-engine) 554 + */ 555 + const initCommands = () => { 556 + // Register static commands 557 + staticCommandDefinitions.forEach(cmd => { 558 + api.commands.register(cmd); 559 + registeredCommands.push(cmd.name); 560 + }); 561 + 562 + // Register engine commands 563 + registerEngineCommands(); 564 + 565 + debug && console.log('[ext:websearch] Registered commands:', [...registeredCommands, ...engineCommands]); 566 + }; 567 + 568 + /** 569 + * Unregister all commands 570 + */ 571 + const uninitCommands = () => { 572 + registeredCommands.forEach(name => { 573 + api.commands.unregister(name); 574 + }); 575 + registeredCommands = []; 576 + 577 + unregisterEngineCommands(); 578 + 579 + debug && console.log('[ext:websearch] Unregistered commands'); 580 + }; 581 + 582 + // ===== Event Handlers (background-mode) ===== 583 + 584 + /** 585 + * Handle page navigation events for OpenSearch discovery 586 + */ 587 + const handlePageLoaded = async (msg) => { 588 + if (!currentSettings.prefs.enableAutoDiscovery) return; 589 + if (!msg || !msg.url) return; 590 + 591 + // Only discover from http/https pages 592 + if (!msg.url.startsWith('http://') && !msg.url.startsWith('https://')) return; 593 + 594 + // Check if the page has an OpenSearch link 595 + if (msg.opensearchUrl) { 596 + await discoverEngine(msg.opensearchUrl, msg.url); 597 + } 598 + }; 599 + 600 + // ===== Tile Lifecycle (engine init — runs before DOM) ===== 601 + 602 + const uninit = () => { 603 + uninitCommands(); 604 + 605 + // Cancel any in-flight suggestion request 606 + if (currentSuggestionAbortController) { 607 + currentSuggestionAbortController.abort(); 608 + currentSuggestionAbortController = null; 609 + } 610 + }; 17 611 18 - function onChannel(topic, handler) { 19 - api.pubsub.subscribe(topic, handler, api.scopes.GLOBAL); 20 - } 612 + // ===== UI State ===== 21 613 22 - function emitChannel(topic, data) { 23 - api.pubsub.publish(topic, data, api.scopes.GLOBAL); 24 - } 614 + let uiState = { 615 + query: '', 616 + suggestions: [], 617 + engines: [], 618 + selectedIndex: -1, 619 + currentEngine: null, 620 + defaultEngineId: 'duckduckgo', 621 + mode: 'engines', // 'engines' | 'suggestions' 622 + }; 623 + 624 + // Expose state for testing 625 + window._websearchState = uiState; 25 626 26 - // ===== Utilities ===== 627 + // ===== UI Utilities ===== 27 628 28 629 /** 29 630 * Simple debounce helper ··· 49 650 // Search magnifying glass icon SVG 50 651 const searchIconSvg = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5L14 14"/></svg>'; 51 652 52 - // ===== State ===== 53 - 54 - let state = { 55 - query: '', 56 - suggestions: [], 57 - engines: [], 58 - selectedIndex: -1, 59 - currentEngine: null, 60 - defaultEngineId: 'duckduckgo', 61 - mode: 'engines', // 'engines' | 'suggestions' 62 - }; 63 - 64 - // Expose state for testing 65 - window._websearchState = state; 66 - 67 - // ===== Data fetching ===== 653 + // ===== UI: Engine list sync from in-process state ===== 68 654 69 655 /** 70 - * Request engine list from background 656 + * Populate UI state from the in-process engines array. 657 + * Replaces the old cross-window websearch:engines-list pubsub round-trip. 71 658 */ 72 - const requestEngines = () => { 73 - emitChannel('websearch:engine-request', { action: 'list' }); 659 + const syncEnginesToUi = () => { 660 + const defaultEngineId = currentSettings.prefs.defaultEngine; 661 + uiState.engines = engines.map(e => ({ 662 + id: e.id, 663 + name: e.name, 664 + shortName: e.shortName, 665 + description: e.description, 666 + searchUrl: e.searchUrl, 667 + suggestUrl: e.suggestUrl, 668 + iconUrl: e.iconUrl, 669 + isBuiltIn: e.isBuiltIn, 670 + enabled: e.enabled !== false, 671 + isDefault: e.id === defaultEngineId 672 + })); 673 + uiState.defaultEngineId = defaultEngineId; 674 + 675 + // Set default engine if none selected 676 + if (!uiState.currentEngine) { 677 + uiState.currentEngine = uiState.engines.find(e => e.id === uiState.defaultEngineId) || uiState.engines[0]; 678 + } 74 679 }; 75 680 681 + // ===== UI: Suggestion handling ===== 682 + 76 683 /** 77 - * Request suggestions from background 684 + * Request suggestions directly (no cross-window round-trip) 78 685 */ 79 - const requestSuggestions = debounce((query) => { 686 + const requestSuggestions = debounce(async (query) => { 80 687 if (!query || !query.trim()) { 81 - state.suggestions = []; 82 - state.mode = 'engines'; 688 + uiState.suggestions = []; 689 + uiState.mode = 'engines'; 83 690 render(); 84 691 return; 85 692 } 86 693 87 - emitChannel('websearch:request-suggestions', { 88 - query: query.trim(), 89 - engineId: state.currentEngine?.id || state.defaultEngineId 90 - }); 694 + const engineId = uiState.currentEngine?.id || uiState.defaultEngineId; 695 + const engine = getEngine(engineId) || getDefaultEngine(); 696 + 697 + // Cancel previous in-flight request 698 + if (currentSuggestionAbortController) { 699 + currentSuggestionAbortController.abort(); 700 + } 701 + currentSuggestionAbortController = new AbortController(); 702 + 703 + const suggestions = await fetchSuggestionsWithCache( 704 + engine, 705 + query, 706 + currentSuggestionAbortController.signal 707 + ); 708 + 709 + uiState.suggestions = suggestions; 710 + uiState.mode = 'suggestions'; 711 + uiState.selectedIndex = suggestions.length > 0 ? 0 : -1; 712 + render(); 91 713 }, 300); 92 714 715 + // ===== UI: Search from UI ===== 716 + 93 717 /** 94 - * Execute search with the given query 718 + * Execute search from the search window UI 95 719 */ 96 - const executeSearch = (query) => { 720 + const executeSearchFromUi = (query) => { 97 721 if (!query || !query.trim()) return; 98 722 99 - const engine = state.currentEngine || state.engines.find(e => e.id === state.defaultEngineId) || state.engines[0]; 723 + const engine = uiState.currentEngine 724 + || uiState.engines.find(e => e.id === uiState.defaultEngineId) 725 + || uiState.engines[0]; 100 726 if (!engine) return; 101 727 102 728 const url = engine.searchUrl.replace('{searchTerms}', encodeURIComponent(query.trim())); ··· 116 742 * Set the current engine 117 743 */ 118 744 const setCurrentEngine = (engine) => { 119 - state.currentEngine = engine; 745 + uiState.currentEngine = engine; 120 746 updateEngineIndicator(); 121 747 }; 122 748 123 - // ===== Rendering ===== 749 + // ===== UI: Rendering ===== 124 750 125 751 /** 126 752 * Update the engine indicator display 127 753 */ 128 754 const updateEngineIndicator = () => { 129 - const engine = state.currentEngine || state.engines.find(e => e.id === state.defaultEngineId) || state.engines[0]; 755 + const engine = uiState.currentEngine || uiState.engines.find(e => e.id === uiState.defaultEngineId) || uiState.engines[0]; 130 756 if (!engine) return; 131 757 132 758 const iconEl = document.getElementById('engine-icon'); ··· 144 770 145 771 // Update placeholder 146 772 const searchInput = document.getElementById('search-input'); 147 - searchInput.placeholder = `Search ${engine.name}...`; 773 + if (searchInput) searchInput.placeholder = `Search ${engine.name}...`; 148 774 }; 149 775 150 776 /** 151 777 * Render the current view 152 778 */ 153 779 const render = () => { 154 - if (state.mode === 'suggestions' && state.suggestions.length > 0) { 780 + if (uiState.mode === 'suggestions' && uiState.suggestions.length > 0) { 155 781 renderSuggestions(); 156 - } else if (state.query && state.query.trim()) { 782 + } else if (uiState.query && uiState.query.trim()) { 157 783 // Have a query but no suggestions yet — show nothing or a "press Enter" hint 158 784 renderSuggestions(); 159 785 } else { ··· 175 801 176 802 container.innerHTML = ''; 177 803 178 - if (state.suggestions.length === 0) { 179 - if (state.query && state.query.trim()) { 804 + if (uiState.suggestions.length === 0) { 805 + if (uiState.query && uiState.query.trim()) { 180 806 // Show "Press Enter to search" hint 181 807 const hint = document.createElement('div'); 182 808 hint.className = 'suggestion-item selected'; 183 809 hint.innerHTML = ` 184 810 <div class="suggestion-icon">${searchIconSvg}</div> 185 - <div class="suggestion-text">Search for "${escapeHtml(state.query)}"</div> 811 + <div class="suggestion-text">Search for "${escapeHtml(uiState.query)}"</div> 186 812 `; 187 813 hint.addEventListener('click', () => { 188 - executeSearch(state.query); 814 + executeSearchFromUi(uiState.query); 189 815 }); 190 816 container.appendChild(hint); 191 - state.selectedIndex = 0; 817 + uiState.selectedIndex = 0; 192 818 } 193 819 return; 194 820 } 195 821 196 - state.suggestions.forEach((suggestion, index) => { 822 + uiState.suggestions.forEach((suggestion, index) => { 197 823 const el = document.createElement('div'); 198 824 el.className = 'suggestion-item'; 199 - if (index === state.selectedIndex) { 825 + if (index === uiState.selectedIndex) { 200 826 el.classList.add('selected'); 201 827 } 202 828 el.dataset.index = index; ··· 207 833 `; 208 834 209 835 el.addEventListener('click', () => { 210 - state.selectedIndex = index; 836 + uiState.selectedIndex = index; 211 837 updateSelection(); 212 - executeSearch(suggestion); 838 + executeSearchFromUi(suggestion); 213 839 }); 214 840 215 841 container.appendChild(el); 216 842 }); 217 843 218 844 // Add attribution 219 - const engine = state.currentEngine || state.engines.find(e => e.id === state.defaultEngineId); 845 + const engine = uiState.currentEngine || uiState.engines.find(e => e.id === uiState.defaultEngineId); 220 846 if (engine) { 221 847 const attr = document.createElement('div'); 222 848 attr.className = 'attribution'; ··· 239 865 240 866 container.innerHTML = ''; 241 867 242 - if (state.engines.length === 0) { 868 + if (uiState.engines.length === 0) { 243 869 emptyState.style.display = ''; 244 870 return; 245 871 } ··· 249 875 header.textContent = 'Search Engines'; 250 876 container.appendChild(header); 251 877 252 - state.engines.forEach((engine, index) => { 878 + uiState.engines.forEach((engine, index) => { 253 879 const el = document.createElement('div'); 254 880 el.className = 'engine-item'; 255 - if (index === state.selectedIndex) { 881 + if (index === uiState.selectedIndex) { 256 882 el.classList.add('selected'); 257 883 } 258 884 el.dataset.index = index; ··· 289 915 // Update suggestions 290 916 const suggestionEls = document.querySelectorAll('.suggestion-item'); 291 917 suggestionEls.forEach((el, i) => { 292 - el.classList.toggle('selected', i === state.selectedIndex); 918 + el.classList.toggle('selected', i === uiState.selectedIndex); 293 919 }); 294 920 295 921 // Update engines 296 922 const engineEls = document.querySelectorAll('.engine-item'); 297 923 engineEls.forEach((el, i) => { 298 - el.classList.toggle('selected', i === state.selectedIndex); 924 + el.classList.toggle('selected', i === uiState.selectedIndex); 299 925 }); 300 926 301 927 // Scroll selected into view ··· 314 940 const searchInput = document.getElementById('search-input'); 315 941 316 942 // If search has content, clear it 317 - if (state.query) { 318 - state.query = ''; 943 + if (uiState.query) { 944 + uiState.query = ''; 319 945 searchInput.value = ''; 320 - state.suggestions = []; 321 - state.selectedIndex = -1; 322 - state.mode = 'engines'; 946 + uiState.suggestions = []; 947 + uiState.selectedIndex = -1; 948 + uiState.mode = 'engines'; 323 949 render(); 324 950 return { handled: true }; 325 951 } ··· 332 958 * Get the list of items for current mode 333 959 */ 334 960 const getCurrentItems = () => { 335 - if (state.mode === 'suggestions' || (state.query && state.query.trim())) { 336 - return state.suggestions.length > 0 ? state.suggestions : (state.query ? ['__search__'] : []); 961 + if (uiState.mode === 'suggestions' || (uiState.query && uiState.query.trim())) { 962 + return uiState.suggestions.length > 0 ? uiState.suggestions : (uiState.query ? ['__search__'] : []); 337 963 } 338 - return state.engines; 964 + return uiState.engines; 339 965 }; 340 966 341 967 /** ··· 356 982 switch (e.key) { 357 983 case 'ArrowDown': 358 984 e.preventDefault(); 359 - if (items.length > 0 && state.selectedIndex < items.length - 1) { 360 - state.selectedIndex++; 985 + if (items.length > 0 && uiState.selectedIndex < items.length - 1) { 986 + uiState.selectedIndex++; 361 987 updateSelection(); 362 988 } 363 989 break; 364 990 365 991 case 'ArrowUp': 366 992 e.preventDefault(); 367 - if (state.selectedIndex > 0) { 368 - state.selectedIndex--; 993 + if (uiState.selectedIndex > 0) { 994 + uiState.selectedIndex--; 369 995 updateSelection(); 370 996 } 371 997 break; 372 998 373 999 case 'Enter': 374 1000 e.preventDefault(); 375 - if (state.mode === 'suggestions' || (state.query && state.query.trim())) { 1001 + if (uiState.mode === 'suggestions' || (uiState.query && uiState.query.trim())) { 376 1002 // If a suggestion is selected, search for it 377 - if (state.selectedIndex >= 0 && state.selectedIndex < state.suggestions.length) { 378 - executeSearch(state.suggestions[state.selectedIndex]); 1003 + if (uiState.selectedIndex >= 0 && uiState.selectedIndex < uiState.suggestions.length) { 1004 + executeSearchFromUi(uiState.suggestions[uiState.selectedIndex]); 379 1005 } else { 380 1006 // Search for the raw query 381 - executeSearch(state.query); 1007 + executeSearchFromUi(uiState.query); 382 1008 } 383 - } else if (state.mode === 'engines' && state.selectedIndex >= 0 && state.selectedIndex < state.engines.length) { 1009 + } else if (uiState.mode === 'engines' && uiState.selectedIndex >= 0 && uiState.selectedIndex < uiState.engines.length) { 384 1010 // Select engine 385 - setCurrentEngine(state.engines[state.selectedIndex]); 1011 + setCurrentEngine(uiState.engines[uiState.selectedIndex]); 386 1012 searchInput.focus(); 387 1013 } 388 1014 break; 389 1015 390 1016 case 'Tab': 391 1017 // Autocomplete first suggestion 392 - if (state.suggestions.length > 0 && state.query) { 1018 + if (uiState.suggestions.length > 0 && uiState.query) { 393 1019 e.preventDefault(); 394 - const suggestion = state.suggestions[0]; 395 - state.query = suggestion; 1020 + const suggestion = uiState.suggestions[0]; 1021 + uiState.query = suggestion; 396 1022 searchInput.value = suggestion; 397 - state.selectedIndex = 0; 1023 + uiState.selectedIndex = 0; 398 1024 updateSelection(); 399 1025 } 400 1026 break; 401 1027 } 402 1028 }; 403 1029 404 - // ===== Event Subscriptions ===== 405 - 406 - /** 407 - * Handle suggestion responses from background 408 - */ 409 - const handleSuggestions = (msg) => { 410 - if (!msg) return; 411 - 412 - state.suggestions = msg.suggestions || []; 413 - state.mode = 'suggestions'; 414 - state.selectedIndex = state.suggestions.length > 0 ? 0 : -1; 415 - render(); 416 - }; 417 - 418 - /** 419 - * Handle engine list response from background 420 - */ 421 - const handleEnginesList = (msg) => { 422 - if (!msg) return; 423 - 424 - state.engines = msg.engines || []; 425 - state.defaultEngineId = msg.defaultEngineId || 'duckduckgo'; 426 - 427 - // Set default engine if none selected 428 - if (!state.currentEngine) { 429 - state.currentEngine = state.engines.find(e => e.id === state.defaultEngineId) || state.engines[0]; 430 - } 1030 + // ===== UI Initialization ===== 431 1031 432 - updateEngineIndicator(); 433 - render(); 434 - }; 435 - 436 - // ===== Initialization ===== 437 - 438 - const init = async () => { 439 - debug && console.log('[websearch:home] init'); 1032 + const initUi = () => { 1033 + debug && console.log('[websearch:home] initUi'); 440 1034 441 1035 // Register escape handler 442 1036 api.escape.onEscape(handleEscape); 443 1037 444 - // Subscribe to events via BroadcastChannel 445 - onChannel('websearch:suggestions', handleSuggestions); 446 - onChannel('websearch:engines-list', handleEnginesList); 1038 + // Subscribe to engine-discovered events to refresh UI 1039 + api.pubsub.subscribe('websearch:engine-discovered', () => { 1040 + syncEnginesToUi(); 1041 + updateEngineIndicator(); 1042 + render(); 1043 + }, api.scopes.GLOBAL); 1044 + 1045 + // Subscribe to settings changes to refresh UI 1046 + api.pubsub.subscribe('websearch:settings-changed', () => { 1047 + syncEnginesToUi(); 1048 + updateEngineIndicator(); 1049 + render(); 1050 + }, api.scopes.SELF); 447 1051 448 1052 // Set up search input 449 1053 const searchInput = document.getElementById('search-input'); ··· 455 1059 456 1060 // Handle input for suggestions 457 1061 searchInput.addEventListener('input', (e) => { 458 - state.query = e.target.value; 459 - state.selectedIndex = -1; 1062 + uiState.query = e.target.value; 1063 + uiState.selectedIndex = -1; 460 1064 461 - if (!state.query || !state.query.trim()) { 462 - state.suggestions = []; 463 - state.mode = 'engines'; 1065 + if (!uiState.query || !uiState.query.trim()) { 1066 + uiState.suggestions = []; 1067 + uiState.mode = 'engines'; 464 1068 render(); 465 1069 return; 466 1070 } 467 1071 468 - state.mode = 'suggestions'; 469 - requestSuggestions(state.query); 1072 + uiState.mode = 'suggestions'; 1073 + requestSuggestions(uiState.query); 470 1074 }); 471 1075 472 1076 // Keyboard navigation ··· 475 1079 // Engine indicator click cycles engines 476 1080 const engineIndicator = document.getElementById('engine-indicator'); 477 1081 engineIndicator.addEventListener('click', () => { 478 - if (state.engines.length === 0) return; 1082 + if (uiState.engines.length === 0) return; 479 1083 480 - const currentIndex = state.engines.findIndex(e => e.id === (state.currentEngine?.id || state.defaultEngineId)); 481 - const nextIndex = (currentIndex + 1) % state.engines.length; 482 - setCurrentEngine(state.engines[nextIndex]); 1084 + const currentIndex = uiState.engines.findIndex(e => e.id === (uiState.currentEngine?.id || uiState.defaultEngineId)); 1085 + const nextIndex = (currentIndex + 1) % uiState.engines.length; 1086 + setCurrentEngine(uiState.engines[nextIndex]); 483 1087 }); 484 1088 485 1089 // Re-focus search input on window focus ··· 487 1091 searchInput.focus(); 488 1092 }); 489 1093 490 - // Request engine list from background 491 - requestEngines(); 1094 + // Populate engine UI from in-process state (no round-trip needed) 1095 + syncEnginesToUi(); 1096 + updateEngineIndicator(); 1097 + render(); 492 1098 }; 493 1099 494 - // Initialize when DOM is ready 495 - document.addEventListener('DOMContentLoaded', init); 1100 + // ===== Top-level init (runs as module in home.html) ===== 1101 + 1102 + (async () => { 1103 + const extId = id; 1104 + console.log(`[ext:${extId}] home.js loaded`); 1105 + 1106 + // Initialize tile — validates capability token with main process 1107 + console.log(`[ext:${extId}] initializing v2 tile`); 1108 + await api.initialize(); 1109 + 1110 + // Load settings and engines 1111 + currentSettings = await loadSettings(); 1112 + await loadEngines(); 1113 + 1114 + // Register commands 1115 + initCommands(); 1116 + 1117 + // Listen for page load events for OpenSearch discovery 1118 + api.pubsub.subscribe('page:loaded', handlePageLoaded, api.scopes.GLOBAL); 1119 + 1120 + // Listen for settings updates from Settings UI 1121 + api.pubsub.subscribe('websearch:settings-update', async (msg) => { 1122 + debug && console.log('[ext:websearch] settings-update received:', msg); 1123 + 1124 + try { 1125 + if (msg.data) { 1126 + currentSettings = { 1127 + prefs: { ...currentSettings.prefs, ...(msg.data.prefs || {}) } 1128 + }; 1129 + } else if (msg.key === 'prefs' && msg.path) { 1130 + const field = msg.path.split('.')[1]; 1131 + if (field) { 1132 + currentSettings.prefs = { ...currentSettings.prefs, [field]: msg.value }; 1133 + } 1134 + } 1135 + 1136 + await saveSettings(currentSettings); 1137 + 1138 + // Clear suggestion cache if suggestions were toggled off 1139 + if (!currentSettings.prefs.enableSuggestions) { 1140 + clearSuggestionCache(); 1141 + } 1142 + 1143 + uninit(); 1144 + await loadEngines(); 1145 + initCommands(); 1146 + 1147 + api.pubsub.publish('websearch:settings-changed', currentSettings, api.scopes.SELF); 1148 + } catch (err) { 1149 + console.error('[ext:websearch] settings-update error:', err); 1150 + } 1151 + }, api.scopes.GLOBAL); 1152 + 1153 + // Register shutdown handler 1154 + api.onShutdown(() => { 1155 + console.log(`[ext:${extId}] received shutdown`); 1156 + uninit(); 1157 + }); 1158 + 1159 + // Initialize UI once DOM is ready 1160 + if (document.readyState === 'loading') { 1161 + document.addEventListener('DOMContentLoaded', initUi); 1162 + } else { 1163 + initUi(); 1164 + } 1165 + })(); 1166 + 1167 + export { 1168 + builtinEngines, 1169 + getEngine, 1170 + getDefaultEngine, 1171 + getEnabledEngines, 1172 + buildSearchUrl, 1173 + parseOpenSearchXml, 1174 + };
+6 -14
features/websearch/manifest.json
··· 9 9 "settingsSchema": "./settings-schema.json", 10 10 "tiles": [ 11 11 { 12 - "id": "background", 13 - "type": "background", 14 - "url": "background.html", 15 - "lazy": true 16 - }, 17 - { 18 12 "id": "home", 19 - "type": "window", 20 13 "url": "home.html", 21 - "windowHints": { 22 - "role": "workspace", 23 - "key": "websearch-home", 24 - "width": 600, 25 - "height": 500, 26 - "title": "Web Search" 27 - } 14 + "width": 600, 15 + "height": 500, 16 + "title": "Web Search", 17 + "role": "workspace", 18 + "key": "websearch-home", 19 + "resident": true 28 20 } 29 21 ], 30 22 "capabilities": {