This repository has no description
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();