This repository has no description
0

Configure Feed

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

superconfig / skills / browser-cli / extension / background.js
21 kB 743 lines
1/// <reference types="firefox-webext-browser" /> 2 3/** 4 * Background script for Browser CLI Controller 5 * Manages native messaging connection and message routing 6 * 7 * @typedef {object} NativeMessage 8 * @property {boolean} [ready] - Whether the native messaging bridge is ready 9 * @property {string} [socket_path] - Path to the Unix socket 10 * @property {string} [command] - The command to execute 11 * @property {CommandParams} [params] - Command parameters 12 * @property {string} [id] - Unique message ID 13 * @property {string} [tabId] - Target tab ID for the command 14 * 15 * @typedef {object} Message 16 * @property {string} command - The command to execute 17 * @property {CommandParams} params - Command parameters 18 * @property {string} id - Unique message ID 19 * @property {string} [tabId] - Target tab ID for the command 20 * 21 * @typedef {object} CommandParams 22 * @property {string} [url] - URL for navigation or new tab 23 * @property {string} [tabId] - Tab ID for close-tab 24 * @property {string} [code] - JavaScript code to execute 25 * @property {string} [output_path] - Output path for screenshot 26 * @property {string} [filename] - Filename for download 27 */ 28 29/** @type {browser.runtime.Port|undefined} Native messaging port */ 30let nativePort; 31 32/** @type {Record<string, {resolve: Function, reject: Function}>} Message handlers from content scripts */ 33const messageHandlers = {}; 34 35/** @type {Map<string, {tabId: number, url: string, title: string}>} Map of managed tabs with short IDs */ 36const managedTabs = new Map(); 37 38/** @type {string|undefined} Currently active managed tab ID */ 39let activeTabId; 40 41/** 42 * Generate a short random ID for tabs 43 * @returns {string} 44 */ 45function generateTabId() { 46 const chars = 47 "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 48 let result = ""; 49 for (let i = 0; i < 6; i++) { 50 result += chars.charAt(Math.floor(Math.random() * chars.length)); 51 } 52 return result; 53} 54 55/** 56 * Connect to native messaging host 57 */ 58function connectNativeHost() { 59 if (nativePort) return; 60 61 console.log("Connecting to native messaging host..."); 62 63 try { 64 nativePort = browser.runtime.connectNative( 65 "io.thalheim.browser_cli.bridge", 66 ); 67 68 nativePort.onMessage.addListener(async (msg) => { 69 const message = /** @type {NativeMessage} */ (msg); 70 console.log("Received from native host:", message); 71 72 if (message.ready && message.socket_path) { 73 console.log( 74 "Native messaging bridge ready, socket at:", 75 message.socket_path, 76 ); 77 return; 78 } 79 80 // Handle responses to our requests (e.g., save-screenshot) 81 if (message.id && messageHandlers[message.id]) { 82 const handler = messageHandlers[message.id]; 83 delete messageHandlers[message.id]; 84 handler.resolve(message); 85 return; 86 } 87 88 if (message.command) { 89 await handleCommand(/** @type {Message} */ (message)); 90 } 91 }); 92 93 nativePort.onDisconnect.addListener(() => { 94 console.log("Native messaging disconnected"); 95 if (browser.runtime.lastError) { 96 console.error("Native messaging error:", browser.runtime.lastError); 97 } 98 nativePort = undefined; 99 setTimeout(connectNativeHost, 5000); 100 }); 101 } catch (error) { 102 console.error("Failed to connect to native messaging host:", error); 103 nativePort = undefined; 104 } 105} 106 107/** 108 * Send response back to native host 109 * @param {object} response 110 */ 111function sendResponse(response) { 112 if (nativePort) { 113 try { 114 nativePort.postMessage(response); 115 } catch (error) { 116 console.error("Failed to send response:", error); 117 } 118 } 119} 120 121/** 122 * Get the browser tab ID to use for a command 123 * @param {string} [targetTabId] 124 * @returns {Promise<number>} 125 */ 126async function getTargetTab(targetTabId) { 127 if (targetTabId) { 128 const managedTab = managedTabs.get(targetTabId); 129 if (!managedTab) { 130 throw new Error(`Tab ${targetTabId} not found`); 131 } 132 return managedTab.tabId; 133 } else if (activeTabId) { 134 const managedTab = managedTabs.get(activeTabId); 135 if (!managedTab) { 136 throw new Error(`Active tab ${activeTabId} no longer exists`); 137 } 138 return managedTab.tabId; 139 } else { 140 throw new Error("No active tab. Create a tab first with newTab()"); 141 } 142} 143 144/** 145 * Send command to content script 146 * @param {string} command 147 * @param {object} [params={}] 148 * @param {string} [targetTabId] 149 * @returns {Promise<object>} 150 */ 151async function sendToContentScript(command, params = {}, targetTabId) { 152 const tabId = await getTargetTab(targetTabId); 153 154 return new Promise((resolve, reject) => { 155 const messageId = Date.now().toString(); 156 messageHandlers[messageId] = { resolve, reject }; 157 158 browser.tabs.sendMessage(tabId, { command, params, messageId }); 159 160 setTimeout(() => { 161 if (messageHandlers[messageId]) { 162 delete messageHandlers[messageId]; 163 reject(new Error("Content script timeout")); 164 } 165 }, 30_000); 166 }); 167} 168 169// ============================================================================ 170// Browser-level commands (require background script) 171// ============================================================================ 172 173/** 174 * Navigate to URL 175 * @param {string} url 176 * @param {string} [tabId] 177 * @returns {Promise<{message: string}>} 178 */ 179async function navigate(url, tabId) { 180 const browserTabId = await getTargetTab(tabId); 181 await browser.tabs.update(browserTabId, { url }); 182 183 const managedTabId = tabId || activeTabId; 184 if (managedTabId && managedTabs.has(managedTabId)) { 185 const managedTab = managedTabs.get(managedTabId); 186 if (managedTab) managedTab.url = url; 187 } 188 189 return { message: `Navigated to ${url}` }; 190} 191 192/** 193 * Navigate back 194 * @param {string} [tabId] 195 * @returns {Promise<{message: string}>} 196 */ 197async function goBack(tabId) { 198 const browserTabId = await getTargetTab(tabId); 199 await browser.tabs.goBack(browserTabId); 200 return { message: "Navigated back" }; 201} 202 203/** 204 * Navigate forward 205 * @param {string} [tabId] 206 * @returns {Promise<{message: string}>} 207 */ 208async function goForward(tabId) { 209 const browserTabId = await getTargetTab(tabId); 210 await browser.tabs.goForward(browserTabId); 211 return { message: "Navigated forward" }; 212} 213 214/** 215 * Take a screenshot 216 * @param {string} [tabId] 217 * @returns {Promise<{screenshot: string}>} 218 */ 219async function takeScreenshot(tabId) { 220 const browserTabId = await getTargetTab(tabId); 221 await browser.tabs.update(browserTabId, { active: true }); 222 const dataUrl = await browser.tabs.captureVisibleTab(); 223 return { screenshot: dataUrl }; 224} 225 226/** 227 * Save screenshot by sending it through native messaging to the Python bridge 228 * @param {string} dataUrl - The base64 data URL of the screenshot 229 * @param {string} outputPath - The file path to save to 230 * @returns {Promise<{screenshot_path: string, message: string}>} 231 */ 232async function saveScreenshotToFile(dataUrl, outputPath) { 233 if (!nativePort) { 234 throw new Error( 235 "Native messaging not connected - cannot save screenshot to file", 236 ); 237 } 238 239 // Send screenshot save request through native messaging 240 const messageId = `screenshot_save_${Date.now()}`; 241 242 return new Promise((resolve, reject) => { 243 const timeout = setTimeout(() => { 244 delete messageHandlers[messageId]; 245 reject(new Error("Screenshot save timeout")); 246 }, 30_000); 247 248 messageHandlers[messageId] = { 249 resolve: (response) => { 250 clearTimeout(timeout); 251 if (response.success && response.result) { 252 resolve(response.result); 253 } else { 254 reject(new Error(response.error || "Failed to save screenshot")); 255 } 256 }, 257 reject: (error) => { 258 clearTimeout(timeout); 259 reject(error); 260 }, 261 }; 262 263 nativePort.postMessage({ 264 command: "save-screenshot", 265 params: { 266 screenshot: dataUrl, 267 output_path: outputPath, 268 }, 269 id: messageId, 270 }); 271 }); 272} 273 274/** 275 * List all managed tabs 276 * @returns {Promise<{tabs: Array<{id: string, tabId: number, url: string, title: string, active: boolean}>}>} 277 */ 278async function listTabs() { 279 const tabs = []; 280 const activeBrowserTab = await browser.tabs.query({ 281 active: true, 282 currentWindow: true, 283 }); 284 const activeBrowserTabId = activeBrowserTab[0]?.id; 285 286 for (const [shortId, tab] of managedTabs) { 287 try { 288 const browserTab = await browser.tabs.get(tab.tabId); 289 tabs.push({ 290 id: shortId, 291 tabId: tab.tabId, 292 url: browserTab.url || "", 293 title: browserTab.title || "Untitled", 294 active: browserTab.id === activeBrowserTabId, 295 }); 296 } catch { 297 managedTabs.delete(shortId); 298 if (activeTabId === shortId) activeTabId = undefined; 299 } 300 } 301 302 return { tabs }; 303} 304 305/** 306 * Create a new managed tab 307 * @param {string} [url] 308 * @returns {Promise<{tabId: string, url: string}>} 309 */ 310async function createNewTab(url) { 311 const shortId = generateTabId(); 312 const tabUrl = url || "about:blank"; 313 314 const tab = await browser.tabs.create({ url: tabUrl, active: true }); 315 316 if (tab.id === undefined) { 317 throw new Error("Failed to create tab"); 318 } 319 320 managedTabs.set(shortId, { 321 tabId: tab.id, 322 url: tabUrl, 323 title: tab.title || "New Tab", 324 }); 325 326 activeTabId = shortId; 327 await enableOnTab(tab.id, shortId); 328 329 return { tabId: shortId, url: tabUrl }; 330} 331 332/** 333 * Close a managed tab 334 * @param {string} [tabId] 335 * @returns {Promise<{message: string}>} 336 */ 337async function closeTab(tabId) { 338 const targetId = tabId || activeTabId; 339 if (!targetId) { 340 throw new Error("No tab to close"); 341 } 342 343 const managedTab = managedTabs.get(targetId); 344 if (!managedTab) { 345 throw new Error(`Tab ${targetId} not found`); 346 } 347 348 await browser.tabs.remove(managedTab.tabId); 349 managedTabs.delete(targetId); 350 351 if (activeTabId === targetId) { 352 activeTabId = managedTabs.keys().next().value; 353 } 354 355 return { message: `Closed tab ${targetId}` }; 356} 357 358/** 359 * Download a file 360 * @param {string} url - URL to download 361 * @param {string} [filename] - Filename (relative to downloads folder) 362 * @returns {Promise<{downloaded: string, path: string}>} 363 */ 364async function downloadFile(url, filename) { 365 /** @type {browser.downloads._DownloadOptions} */ 366 const downloadOptions = { 367 url, 368 saveAs: false, 369 filename: filename || undefined, 370 }; 371 372 const downloadId = await browser.downloads.download(downloadOptions); 373 374 // Wait for download to complete 375 return new Promise((resolve, reject) => { 376 const timeout = setTimeout(() => { 377 browser.downloads.onChanged.removeListener(listener); 378 reject(new Error("Download timeout")); 379 }, 60_000); 380 381 /** 382 * @param {browser.downloads._OnChangedDownloadDelta} delta 383 */ 384 function listener(delta) { 385 if (delta.id !== downloadId) { 386 return; 387 } 388 389 if (delta.state?.current === "complete") { 390 clearTimeout(timeout); 391 browser.downloads.onChanged.removeListener(listener); 392 browser.downloads.search({ id: downloadId }).then((downloads) => { 393 const path = downloads[0]?.filename || filename || "unknown"; 394 resolve({ downloaded: url, path }); 395 }); 396 } else if (delta.error) { 397 clearTimeout(timeout); 398 browser.downloads.onChanged.removeListener(listener); 399 reject(new Error(delta.error.current)); 400 } 401 } 402 403 browser.downloads.onChanged.addListener(listener); 404 }); 405} 406 407// ============================================================================ 408// Command handling 409// ============================================================================ 410 411/** 412 * Handle commands from CLI 413 * @param {Message} message 414 */ 415async function handleCommand(message) { 416 const { command, params, id, tabId } = message; 417 418 try { 419 let result; 420 421 switch (command) { 422 // Browser-level commands 423 case "navigate": { 424 result = await navigate(params.url || "", tabId); 425 break; 426 } 427 case "back": { 428 result = await goBack(tabId); 429 break; 430 } 431 case "forward": { 432 result = await goForward(tabId); 433 break; 434 } 435 case "screenshot": { 436 result = await takeScreenshot(tabId); 437 break; 438 } 439 case "list-tabs": { 440 result = await listTabs(); 441 break; 442 } 443 case "new-tab": { 444 result = await createNewTab(params.url); 445 break; 446 } 447 case "close-tab": { 448 result = await closeTab(params.tabId || tabId); 449 break; 450 } 451 452 case "download": { 453 result = await downloadFile(params.url || "", params.filename); 454 break; 455 } 456 457 // Execute JavaScript in content script 458 case "exec": { 459 result = await sendToContentScript("exec", params, tabId); 460 break; 461 } 462 463 default: { 464 throw new Error(`Unknown command: ${command}`); 465 } 466 } 467 468 sendResponse({ id, result, success: true }); 469 } catch (error) { 470 sendResponse({ 471 id, 472 error: error instanceof Error ? error.message : String(error), 473 success: false, 474 }); 475 } 476} 477 478// ============================================================================ 479// Tab management 480// ============================================================================ 481 482/** 483 * Enable extension on a specific tab 484 * @param {number} tabId 485 * @param {string} shortId 486 */ 487async function enableOnTab(tabId, shortId) { 488 await browser.tabs.executeScript(tabId, { file: "content.js" }); 489 490 await browser.tabs.executeScript(tabId, { 491 code: `(${function (/** @type {string} */ id) { 492 if (!document.querySelector("#browser-cli-banner")) { 493 const banner = document.createElement("div"); 494 banner.id = "browser-cli-banner"; 495 Object.assign(banner.style, { 496 position: "fixed", 497 top: "0", 498 left: "0", 499 right: "0", 500 height: "40px", 501 background: "#2563eb", 502 color: "white", 503 display: "flex", 504 alignItems: "center", 505 justifyContent: "space-between", 506 padding: "0 20px", 507 fontFamily: "system-ui, sans-serif", 508 fontSize: "14px", 509 zIndex: "999999", 510 boxShadow: "0 2px 4px rgba(0,0,0,0.1)", 511 }); 512 513 const text = document.createElement("span"); 514 text.textContent = "🤖 Browser CLI: " + id; 515 banner.append(text); 516 517 const closeBtn = document.createElement("button"); 518 closeBtn.textContent = "✕"; 519 Object.assign(closeBtn.style, { 520 background: "none", 521 border: "none", 522 color: "white", 523 fontSize: "20px", 524 cursor: "pointer", 525 padding: "0", 526 width: "30px", 527 height: "30px", 528 display: "flex", 529 alignItems: "center", 530 justifyContent: "center", 531 borderRadius: "4px", 532 transition: "background 0.2s", 533 }); 534 535 closeBtn.addEventListener( 536 "mouseover", 537 () => (closeBtn.style.background = "rgba(255,255,255,0.2)"), 538 ); 539 closeBtn.addEventListener( 540 "mouseout", 541 () => (closeBtn.style.background = "none"), 542 ); 543 closeBtn.addEventListener("click", () => { 544 browser.runtime.sendMessage({ command: "disableCLI" }); 545 }); 546 banner.append(closeBtn); 547 548 document.body.append(banner); 549 document.body.style.paddingTop = "40px"; 550 } 551 }})('${shortId}')`, 552 }); 553 554 connectNativeHost(); 555} 556 557/** 558 * Disable extension on a specific tab 559 * @param {number} tabId 560 * @param {string} shortId 561 */ 562async function disableOnTab(tabId, shortId) { 563 managedTabs.delete(shortId); 564 if (activeTabId === shortId) activeTabId = undefined; 565 566 await browser.tabs.executeScript(tabId, { 567 code: `(${function () { 568 const banner = document.querySelector("#browser-cli-banner"); 569 if (banner) { 570 banner.remove(); 571 document.body.style.paddingTop = ""; 572 } 573 }})()`, 574 }); 575} 576 577// ============================================================================ 578// Event listeners 579// ============================================================================ 580 581browser.runtime.onMessage.addListener((message, sender, _sendResponse) => { 582 // Handle disable CLI request 583 if (message.command === "disableCLI" && sender.tab?.id !== undefined) { 584 for (const [shortId, tab] of managedTabs) { 585 if (tab.tabId === sender.tab.id) { 586 disableOnTab(sender.tab.id, shortId); 587 break; 588 } 589 } 590 return true; 591 } 592 593 // Handle content script responses 594 if (message.messageId && messageHandlers[message.messageId]) { 595 const handler = messageHandlers[message.messageId]; 596 delete messageHandlers[message.messageId]; 597 598 if (message.error) { 599 handler.reject(new Error(message.error)); 600 } else { 601 handler.resolve(message.result); 602 } 603 return true; 604 } 605 606 // Handle content script requests for browser-level commands 607 if (message.bgMessageId) { 608 const { command, params, bgMessageId } = message; 609 610 // Find the tab ID for this sender 611 let senderTabShortId; 612 if (sender.tab?.id !== undefined) { 613 for (const [shortId, tab] of managedTabs) { 614 if (tab.tabId === sender.tab.id) { 615 senderTabShortId = shortId; 616 break; 617 } 618 } 619 } 620 621 (async () => { 622 try { 623 let result; 624 625 switch (command) { 626 case "navigate": { 627 result = await navigate(params.url, senderTabShortId); 628 break; 629 } 630 case "back": { 631 result = await goBack(senderTabShortId); 632 break; 633 } 634 case "forward": { 635 result = await goForward(senderTabShortId); 636 break; 637 } 638 case "screenshot": { 639 result = await takeScreenshot(senderTabShortId); 640 // Save screenshot to file if path provided 641 if (params.output_path && result.screenshot) { 642 const dataUrl = result.screenshot; 643 try { 644 const saved = await saveScreenshotToFile( 645 dataUrl, 646 params.output_path, 647 ); 648 result = saved; 649 } catch (saveError) { 650 result = { 651 error: 652 saveError instanceof Error 653 ? saveError.message 654 : String(saveError), 655 screenshot: dataUrl, 656 }; 657 } 658 } 659 break; 660 } 661 case "new-tab": { 662 result = await createNewTab(params.url); 663 break; 664 } 665 case "close-tab": { 666 result = await closeTab(params.tabId || senderTabShortId); 667 break; 668 } 669 case "list-tabs": { 670 result = await listTabs(); 671 break; 672 } 673 case "download": { 674 result = await downloadFile(params.url, params.filename); 675 break; 676 } 677 default: { 678 throw new Error(`Unknown background command: ${command}`); 679 } 680 } 681 682 // Send response back to content script 683 if (sender.tab?.id !== undefined) { 684 browser.tabs.sendMessage(sender.tab.id, { 685 bgMessageId, 686 result, 687 }); 688 } 689 } catch (error) { 690 if (sender.tab?.id !== undefined) { 691 browser.tabs.sendMessage(sender.tab.id, { 692 bgMessageId, 693 error: error instanceof Error ? error.message : String(error), 694 }); 695 } 696 } 697 })(); 698 699 return true; 700 } 701 702 return true; 703}); 704 705browser.tabs.onActivated.addListener(async (activeInfo) => { 706 for (const [shortId, tab] of managedTabs) { 707 if (tab.tabId === activeInfo.tabId) { 708 activeTabId = shortId; 709 return; 710 } 711 } 712 activeTabId = undefined; 713}); 714 715browser.tabs.onRemoved.addListener((tabId) => { 716 for (const [shortId, tab] of managedTabs) { 717 if (tab.tabId === tabId) { 718 managedTabs.delete(shortId); 719 if (activeTabId === shortId) activeTabId = undefined; 720 break; 721 } 722 } 723}); 724 725browser.browserAction.onClicked.addListener(async () => { 726 await createNewTab(); 727}); 728 729browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { 730 if (changeInfo.status === "complete") { 731 for (const [shortId, managedTab] of managedTabs) { 732 if (managedTab.tabId === tabId) { 733 managedTab.url = tab.url || managedTab.url; 734 managedTab.title = tab.title || managedTab.title; 735 await enableOnTab(tabId, shortId); 736 break; 737 } 738 } 739 } 740}); 741 742// Initialize 743connectNativeHost();