@different-ai/opencode-browser
Advanced tools
+207
| #!/usr/bin/env node | ||
| /** | ||
| * Persistent Browser Bridge Daemon | ||
| * | ||
| * Runs as a background service and bridges: | ||
| * - Chrome extension (via WebSocket on localhost) | ||
| * - MCP server (via Unix socket) | ||
| * | ||
| * This allows scheduled jobs to use browser tools even if | ||
| * the OpenCode session that created the job isn't running. | ||
| */ | ||
| import { createServer as createNetServer } from "net"; | ||
| import { WebSocketServer } from "ws"; | ||
| import { existsSync, mkdirSync, unlinkSync, appendFileSync } from "fs"; | ||
| import { homedir } from "os"; | ||
| import { join } from "path"; | ||
| const BASE_DIR = join(homedir(), ".opencode-browser"); | ||
| const LOG_DIR = join(BASE_DIR, "logs"); | ||
| const SOCKET_PATH = join(BASE_DIR, "browser.sock"); | ||
| const WS_PORT = 19222; | ||
| if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true }); | ||
| const LOG_FILE = join(LOG_DIR, "daemon.log"); | ||
| function log(...args) { | ||
| const timestamp = new Date().toISOString(); | ||
| const message = `[${timestamp}] ${args.join(" ")}\n`; | ||
| appendFileSync(LOG_FILE, message); | ||
| console.error(message.trim()); | ||
| } | ||
| log("Daemon starting..."); | ||
| // State | ||
| let chromeConnection = null; | ||
| let mcpConnections = new Set(); | ||
| let pendingRequests = new Map(); | ||
| let requestId = 0; | ||
| // ============================================================================ | ||
| // WebSocket Server for Chrome Extension | ||
| // ============================================================================ | ||
| const wss = new WebSocketServer({ port: WS_PORT }); | ||
| wss.on("connection", (ws) => { | ||
| log("Chrome extension connected via WebSocket"); | ||
| chromeConnection = ws; | ||
| ws.on("message", (data) => { | ||
| try { | ||
| const message = JSON.parse(data.toString()); | ||
| handleChromeMessage(message); | ||
| } catch (e) { | ||
| log("Failed to parse Chrome message:", e.message); | ||
| } | ||
| }); | ||
| ws.on("close", () => { | ||
| log("Chrome extension disconnected"); | ||
| chromeConnection = null; | ||
| }); | ||
| ws.on("error", (err) => { | ||
| log("Chrome WebSocket error:", err.message); | ||
| }); | ||
| }); | ||
| wss.on("listening", () => { | ||
| log(`WebSocket server listening on port ${WS_PORT}`); | ||
| }); | ||
| function sendToChrome(message) { | ||
| if (chromeConnection && chromeConnection.readyState === 1) { | ||
| chromeConnection.send(JSON.stringify(message)); | ||
| return true; | ||
| } | ||
| return false; | ||
| } | ||
| function handleChromeMessage(message) { | ||
| log("From Chrome:", message.type); | ||
| if (message.type === "tool_response") { | ||
| const pending = pendingRequests.get(message.id); | ||
| if (pending) { | ||
| pendingRequests.delete(message.id); | ||
| sendToMcp(pending.socket, { | ||
| type: "tool_response", | ||
| id: pending.mcpId, | ||
| result: message.result, | ||
| error: message.error | ||
| }); | ||
| } | ||
| } else if (message.type === "pong") { | ||
| log("Chrome ping OK"); | ||
| } | ||
| } | ||
| // ============================================================================ | ||
| // Unix Socket Server for MCP | ||
| // ============================================================================ | ||
| try { | ||
| if (existsSync(SOCKET_PATH)) { | ||
| unlinkSync(SOCKET_PATH); | ||
| } | ||
| } catch {} | ||
| const unixServer = createNetServer((socket) => { | ||
| log("MCP server connected"); | ||
| mcpConnections.add(socket); | ||
| let buffer = ""; | ||
| socket.on("data", (data) => { | ||
| buffer += data.toString(); | ||
| const lines = buffer.split("\n"); | ||
| buffer = lines.pop() || ""; | ||
| for (const line of lines) { | ||
| if (line.trim()) { | ||
| try { | ||
| const message = JSON.parse(line); | ||
| handleMcpMessage(socket, message); | ||
| } catch (e) { | ||
| log("Failed to parse MCP message:", e.message); | ||
| } | ||
| } | ||
| } | ||
| }); | ||
| socket.on("close", () => { | ||
| log("MCP server disconnected"); | ||
| mcpConnections.delete(socket); | ||
| }); | ||
| socket.on("error", (err) => { | ||
| log("MCP socket error:", err.message); | ||
| mcpConnections.delete(socket); | ||
| }); | ||
| }); | ||
| unixServer.listen(SOCKET_PATH, () => { | ||
| log(`Unix socket listening at ${SOCKET_PATH}`); | ||
| }); | ||
| function sendToMcp(socket, message) { | ||
| if (socket && !socket.destroyed) { | ||
| socket.write(JSON.stringify(message) + "\n"); | ||
| } | ||
| } | ||
| function handleMcpMessage(socket, message) { | ||
| log("From MCP:", message.type, message.tool || ""); | ||
| if (message.type === "tool_request") { | ||
| if (!chromeConnection) { | ||
| sendToMcp(socket, { | ||
| type: "tool_response", | ||
| id: message.id, | ||
| error: { content: "Chrome extension not connected. Open Chrome and ensure the OpenCode extension is enabled." } | ||
| }); | ||
| return; | ||
| } | ||
| const id = ++requestId; | ||
| pendingRequests.set(id, { socket, mcpId: message.id }); | ||
| sendToChrome({ | ||
| type: "tool_request", | ||
| id, | ||
| tool: message.tool, | ||
| args: message.args | ||
| }); | ||
| } | ||
| } | ||
| // ============================================================================ | ||
| // Health Check | ||
| // ============================================================================ | ||
| setInterval(() => { | ||
| if (chromeConnection) { | ||
| sendToChrome({ type: "ping" }); | ||
| } | ||
| }, 30000); | ||
| // ============================================================================ | ||
| // Graceful Shutdown | ||
| // ============================================================================ | ||
| function shutdown() { | ||
| log("Shutting down..."); | ||
| wss.close(); | ||
| unixServer.close(); | ||
| try { unlinkSync(SOCKET_PATH); } catch {} | ||
| process.exit(0); | ||
| } | ||
| process.on("SIGTERM", shutdown); | ||
| process.on("SIGINT", shutdown); | ||
| log("Daemon started successfully"); |
+89
-4
@@ -88,10 +88,21 @@ #!/usr/bin/env node | ||
| await uninstall(); | ||
| } else if (command === "daemon") { | ||
| await startDaemon(); | ||
| } else if (command === "daemon-install") { | ||
| await installDaemon(); | ||
| } else if (command === "start") { | ||
| rl.close(); | ||
| await import("../src/server.js"); | ||
| return; | ||
| } else { | ||
| log(` | ||
| ${color("bright", "Usage:")} | ||
| npx opencode-browser install Install extension and native host | ||
| npx opencode-browser uninstall Remove native host registration | ||
| npx @different-ai/opencode-browser install Install extension | ||
| npx @different-ai/opencode-browser daemon-install Install background daemon | ||
| npx @different-ai/opencode-browser daemon Run daemon (foreground) | ||
| npx @different-ai/opencode-browser start Run MCP server | ||
| npx @different-ai/opencode-browser uninstall Remove installation | ||
| ${color("bright", "After installation:")} | ||
| The MCP server starts automatically when OpenCode connects. | ||
| ${color("bright", "For scheduled jobs:")} | ||
| Run 'daemon-install' to enable browser tools in background jobs. | ||
| `); | ||
@@ -287,2 +298,76 @@ } | ||
| async function startDaemon() { | ||
| const { spawn } = await import("child_process"); | ||
| const daemonPath = join(PACKAGE_ROOT, "src", "daemon.js"); | ||
| log("Starting daemon..."); | ||
| const child = spawn(process.execPath, [daemonPath], { stdio: "inherit" }); | ||
| child.on("exit", (code) => process.exit(code || 0)); | ||
| } | ||
| async function installDaemon() { | ||
| header("Installing Background Daemon"); | ||
| const os = platform(); | ||
| if (os !== "darwin") { | ||
| error("Daemon auto-install currently supports macOS only"); | ||
| log("On Linux, create a systemd service manually."); | ||
| process.exit(1); | ||
| } | ||
| const nodePath = process.execPath; | ||
| const daemonPath = join(PACKAGE_ROOT, "src", "daemon.js"); | ||
| const logsDir = join(homedir(), ".opencode-browser", "logs"); | ||
| mkdirSync(logsDir, { recursive: true }); | ||
| const plist = `<?xml version="1.0" encoding="UTF-8"?> | ||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
| <plist version="1.0"> | ||
| <dict> | ||
| <key>Label</key> | ||
| <string>com.opencode.browser-daemon</string> | ||
| <key>ProgramArguments</key> | ||
| <array> | ||
| <string>${nodePath}</string> | ||
| <string>${daemonPath}</string> | ||
| </array> | ||
| <key>RunAtLoad</key> | ||
| <true/> | ||
| <key>KeepAlive</key> | ||
| <true/> | ||
| <key>StandardOutPath</key> | ||
| <string>${logsDir}/daemon.log</string> | ||
| <key>StandardErrorPath</key> | ||
| <string>${logsDir}/daemon.log</string> | ||
| </dict> | ||
| </plist>`; | ||
| const plistPath = join(homedir(), "Library", "LaunchAgents", "com.opencode.browser-daemon.plist"); | ||
| writeFileSync(plistPath, plist); | ||
| success(`Created launchd plist: ${plistPath}`); | ||
| try { | ||
| execSync(`launchctl unload "${plistPath}" 2>/dev/null || true`); | ||
| execSync(`launchctl load "${plistPath}"`); | ||
| success("Daemon started"); | ||
| } catch (e) { | ||
| error(`Failed to load daemon: ${e.message}`); | ||
| } | ||
| log(` | ||
| ${color("green", "✓")} Daemon installed and running | ||
| The daemon bridges Chrome extension ↔ MCP server. | ||
| It runs automatically on login and enables browser | ||
| tools in scheduled OpenCode jobs. | ||
| ${color("bright", "Logs:")} ${logsDir}/daemon.log | ||
| ${color("bright", "Control:")} | ||
| launchctl stop com.opencode.browser-daemon | ||
| launchctl start com.opencode.browser-daemon | ||
| launchctl unload ~/Library/LaunchAgents/com.opencode.browser-daemon.plist | ||
| `); | ||
| } | ||
| async function uninstall() { | ||
@@ -289,0 +374,0 @@ header("Uninstalling OpenCode Browser"); |
+130
-301
@@ -1,106 +0,83 @@ | ||
| // OpenCode Browser Automation - Background Service Worker | ||
| // Native Messaging Host: com.opencode.browser_automation | ||
| const DAEMON_URL = "ws://localhost:19222"; | ||
| const KEEPALIVE_ALARM = "keepalive"; | ||
| const NATIVE_HOST_NAME = "com.opencode.browser_automation"; | ||
| let nativePort = null; | ||
| let ws = null; | ||
| let isConnected = false; | ||
| // ============================================================================ | ||
| // Native Messaging Connection | ||
| // ============================================================================ | ||
| chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: 0.25 }); | ||
| async function connectToNativeHost() { | ||
| if (nativePort) { | ||
| return true; | ||
| chrome.alarms.onAlarm.addListener((alarm) => { | ||
| if (alarm.name === KEEPALIVE_ALARM) { | ||
| if (!isConnected) { | ||
| console.log("[OpenCode] Alarm triggered reconnect"); | ||
| connect(); | ||
| } | ||
| } | ||
| }); | ||
| function connect() { | ||
| if (ws && ws.readyState === WebSocket.OPEN) return; | ||
| if (ws) { | ||
| try { ws.close(); } catch {} | ||
| ws = null; | ||
| } | ||
| try { | ||
| nativePort = chrome.runtime.connectNative(NATIVE_HOST_NAME); | ||
| ws = new WebSocket(DAEMON_URL); | ||
| nativePort.onMessage.addListener(handleNativeMessage); | ||
| ws.onopen = () => { | ||
| console.log("[OpenCode] Connected to daemon"); | ||
| isConnected = true; | ||
| updateBadge(true); | ||
| }; | ||
| nativePort.onDisconnect.addListener(() => { | ||
| const error = chrome.runtime.lastError?.message; | ||
| console.log("[OpenCode] Native host disconnected:", error); | ||
| nativePort = null; | ||
| ws.onmessage = async (event) => { | ||
| try { | ||
| const message = JSON.parse(event.data); | ||
| await handleMessage(message); | ||
| } catch (e) { | ||
| console.error("[OpenCode] Parse error:", e); | ||
| } | ||
| }; | ||
| ws.onclose = () => { | ||
| console.log("[OpenCode] Disconnected"); | ||
| isConnected = false; | ||
| }); | ||
| // Ping to verify connection | ||
| const connected = await new Promise((resolve) => { | ||
| const timeout = setTimeout(() => resolve(false), 5000); | ||
| const pingHandler = (msg) => { | ||
| if (msg.type === "pong") { | ||
| clearTimeout(timeout); | ||
| nativePort.onMessage.removeListener(pingHandler); | ||
| resolve(true); | ||
| } | ||
| }; | ||
| nativePort.onMessage.addListener(pingHandler); | ||
| nativePort.postMessage({ type: "ping" }); | ||
| }); | ||
| if (connected) { | ||
| isConnected = true; | ||
| console.log("[OpenCode] Connected to native host"); | ||
| return true; | ||
| } else { | ||
| nativePort.disconnect(); | ||
| nativePort = null; | ||
| return false; | ||
| } | ||
| } catch (error) { | ||
| console.error("[OpenCode] Failed to connect:", error); | ||
| nativePort = null; | ||
| return false; | ||
| ws = null; | ||
| updateBadge(false); | ||
| }; | ||
| ws.onerror = (err) => { | ||
| console.error("[OpenCode] WebSocket error"); | ||
| isConnected = false; | ||
| updateBadge(false); | ||
| }; | ||
| } catch (e) { | ||
| console.error("[OpenCode] Connect failed:", e); | ||
| isConnected = false; | ||
| updateBadge(false); | ||
| } | ||
| } | ||
| function disconnectNativeHost() { | ||
| if (nativePort) { | ||
| nativePort.disconnect(); | ||
| nativePort = null; | ||
| isConnected = false; | ||
| } | ||
| function updateBadge(connected) { | ||
| chrome.action.setBadgeText({ text: connected ? "ON" : "" }); | ||
| chrome.action.setBadgeBackgroundColor({ color: connected ? "#22c55e" : "#ef4444" }); | ||
| } | ||
| // ============================================================================ | ||
| // Message Handling from Native Host | ||
| // ============================================================================ | ||
| async function handleNativeMessage(message) { | ||
| console.log("[OpenCode] Received from native:", message.type); | ||
| switch (message.type) { | ||
| case "tool_request": | ||
| await handleToolRequest(message); | ||
| break; | ||
| case "ping": | ||
| sendToNative({ type: "pong" }); | ||
| break; | ||
| case "get_status": | ||
| sendToNative({ | ||
| type: "status_response", | ||
| connected: isConnected, | ||
| version: chrome.runtime.getManifest().version | ||
| }); | ||
| break; | ||
| function send(message) { | ||
| if (ws && ws.readyState === WebSocket.OPEN) { | ||
| ws.send(JSON.stringify(message)); | ||
| return true; | ||
| } | ||
| return false; | ||
| } | ||
| function sendToNative(message) { | ||
| if (nativePort) { | ||
| nativePort.postMessage(message); | ||
| } else { | ||
| console.error("[OpenCode] Cannot send - not connected"); | ||
| async function handleMessage(message) { | ||
| if (message.type === "tool_request") { | ||
| await handleToolRequest(message); | ||
| } else if (message.type === "ping") { | ||
| send({ type: "pong" }); | ||
| } | ||
| } | ||
| // ============================================================================ | ||
| // Tool Execution | ||
| // ============================================================================ | ||
| async function handleToolRequest(request) { | ||
@@ -111,13 +88,5 @@ const { id, tool, args } = request; | ||
| const result = await executeTool(tool, args || {}); | ||
| sendToNative({ | ||
| type: "tool_response", | ||
| id, | ||
| result: { content: result } | ||
| }); | ||
| send({ type: "tool_response", id, result: { content: result } }); | ||
| } catch (error) { | ||
| sendToNative({ | ||
| type: "tool_response", | ||
| id, | ||
| error: { content: error.message || String(error) } | ||
| }); | ||
| send({ type: "tool_response", id, error: { content: error.message || String(error) } }); | ||
| } | ||
@@ -127,30 +96,19 @@ } | ||
| async function executeTool(toolName, args) { | ||
| switch (toolName) { | ||
| case "navigate": | ||
| return await toolNavigate(args); | ||
| case "click": | ||
| return await toolClick(args); | ||
| case "type": | ||
| return await toolType(args); | ||
| case "screenshot": | ||
| return await toolScreenshot(args); | ||
| case "snapshot": | ||
| return await toolSnapshot(args); | ||
| case "get_tabs": | ||
| return await toolGetTabs(args); | ||
| case "execute_script": | ||
| return await toolExecuteScript(args); | ||
| case "scroll": | ||
| return await toolScroll(args); | ||
| case "wait": | ||
| return await toolWait(args); | ||
| default: | ||
| throw new Error(`Unknown tool: ${toolName}`); | ||
| } | ||
| const tools = { | ||
| navigate: toolNavigate, | ||
| click: toolClick, | ||
| type: toolType, | ||
| screenshot: toolScreenshot, | ||
| snapshot: toolSnapshot, | ||
| get_tabs: toolGetTabs, | ||
| execute_script: toolExecuteScript, | ||
| scroll: toolScroll, | ||
| wait: toolWait | ||
| }; | ||
| const fn = tools[toolName]; | ||
| if (!fn) throw new Error(`Unknown tool: ${toolName}`); | ||
| return await fn(args); | ||
| } | ||
| // ============================================================================ | ||
| // Tool Implementations | ||
| // ============================================================================ | ||
| async function getActiveTab() { | ||
@@ -163,6 +121,3 @@ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); | ||
| async function getTabById(tabId) { | ||
| if (tabId) { | ||
| return await chrome.tabs.get(tabId); | ||
| } | ||
| return await getActiveTab(); | ||
| return tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); | ||
| } | ||
@@ -172,7 +127,5 @@ | ||
| if (!url) throw new Error("URL is required"); | ||
| const tab = await getTabById(tabId); | ||
| await chrome.tabs.update(tab.id, { url }); | ||
| // Wait for page to load | ||
| await new Promise((resolve) => { | ||
@@ -186,7 +139,3 @@ const listener = (updatedTabId, info) => { | ||
| chrome.tabs.onUpdated.addListener(listener); | ||
| // Timeout after 30 seconds | ||
| setTimeout(() => { | ||
| chrome.tabs.onUpdated.removeListener(listener); | ||
| resolve(); | ||
| }, 30000); | ||
| setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, 30000); | ||
| }); | ||
@@ -199,3 +148,2 @@ | ||
| if (!selector) throw new Error("Selector is required"); | ||
| const tab = await getTabById(tabId); | ||
@@ -206,5 +154,5 @@ | ||
| func: (sel) => { | ||
| const element = document.querySelector(sel); | ||
| if (!element) return { success: false, error: `Element not found: ${sel}` }; | ||
| element.click(); | ||
| const el = document.querySelector(sel); | ||
| if (!el) return { success: false, error: `Element not found: ${sel}` }; | ||
| el.click(); | ||
| return { success: true }; | ||
@@ -215,6 +163,3 @@ }, | ||
| if (!result[0]?.result?.success) { | ||
| throw new Error(result[0]?.result?.error || "Click failed"); | ||
| } | ||
| if (!result[0]?.result?.success) throw new Error(result[0]?.result?.error || "Click failed"); | ||
| return `Clicked ${selector}`; | ||
@@ -226,3 +171,2 @@ } | ||
| if (text === undefined) throw new Error("Text is required"); | ||
| const tab = await getTabById(tabId); | ||
@@ -233,19 +177,13 @@ | ||
| func: (sel, txt, shouldClear) => { | ||
| const element = document.querySelector(sel); | ||
| if (!element) return { success: false, error: `Element not found: ${sel}` }; | ||
| element.focus(); | ||
| if (shouldClear) { | ||
| element.value = ""; | ||
| } | ||
| // For input/textarea, set value directly | ||
| if (element.tagName === "INPUT" || element.tagName === "TEXTAREA") { | ||
| element.value = element.value + txt; | ||
| element.dispatchEvent(new Event("input", { bubbles: true })); | ||
| element.dispatchEvent(new Event("change", { bubbles: true })); | ||
| } else if (element.isContentEditable) { | ||
| const el = document.querySelector(sel); | ||
| if (!el) return { success: false, error: `Element not found: ${sel}` }; | ||
| el.focus(); | ||
| if (shouldClear) el.value = ""; | ||
| if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") { | ||
| el.value = el.value + txt; | ||
| el.dispatchEvent(new Event("input", { bubbles: true })); | ||
| el.dispatchEvent(new Event("change", { bubbles: true })); | ||
| } else if (el.isContentEditable) { | ||
| document.execCommand("insertText", false, txt); | ||
| } | ||
| return { success: true }; | ||
@@ -256,18 +194,9 @@ }, | ||
| if (!result[0]?.result?.success) { | ||
| throw new Error(result[0]?.result?.error || "Type failed"); | ||
| } | ||
| if (!result[0]?.result?.success) throw new Error(result[0]?.result?.error || "Type failed"); | ||
| return `Typed "${text}" into ${selector}`; | ||
| } | ||
| async function toolScreenshot({ tabId, fullPage = false }) { | ||
| async function toolScreenshot({ tabId }) { | ||
| const tab = await getTabById(tabId); | ||
| // Capture visible area | ||
| const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { | ||
| format: "png" | ||
| }); | ||
| return dataUrl; | ||
| return await chrome.tabs.captureVisibleTab(tab.windowId, { format: "png" }); | ||
| } | ||
@@ -281,65 +210,28 @@ | ||
| func: () => { | ||
| // Build accessibility tree snapshot | ||
| function getAccessibleName(element) { | ||
| return element.getAttribute("aria-label") || | ||
| element.getAttribute("alt") || | ||
| element.getAttribute("title") || | ||
| element.getAttribute("placeholder") || | ||
| element.innerText?.slice(0, 100) || | ||
| ""; | ||
| function getName(el) { | ||
| return el.getAttribute("aria-label") || el.getAttribute("alt") || | ||
| el.getAttribute("title") || el.getAttribute("placeholder") || | ||
| el.innerText?.slice(0, 100) || ""; | ||
| } | ||
| function getRole(element) { | ||
| return element.getAttribute("role") || | ||
| element.tagName.toLowerCase(); | ||
| } | ||
| function buildSnapshot(element, depth = 0, uid = 0) { | ||
| function build(el, depth = 0, uid = 0) { | ||
| if (depth > 10) return { nodes: [], nextUid: uid }; | ||
| const nodes = []; | ||
| const style = window.getComputedStyle(element); | ||
| const style = window.getComputedStyle(el); | ||
| if (style.display === "none" || style.visibility === "hidden") return { nodes: [], nextUid: uid }; | ||
| // Skip hidden elements | ||
| if (style.display === "none" || style.visibility === "hidden") { | ||
| return { nodes: [], nextUid: uid }; | ||
| } | ||
| const isInteractive = ["A", "BUTTON", "INPUT", "TEXTAREA", "SELECT"].includes(el.tagName) || | ||
| el.getAttribute("onclick") || el.getAttribute("role") === "button" || el.isContentEditable; | ||
| const rect = el.getBoundingClientRect(); | ||
| const isInteractive = | ||
| element.tagName === "A" || | ||
| element.tagName === "BUTTON" || | ||
| element.tagName === "INPUT" || | ||
| element.tagName === "TEXTAREA" || | ||
| element.tagName === "SELECT" || | ||
| element.getAttribute("onclick") || | ||
| element.getAttribute("role") === "button" || | ||
| element.isContentEditable; | ||
| const rect = element.getBoundingClientRect(); | ||
| const isVisible = rect.width > 0 && rect.height > 0; | ||
| if (isVisible && (isInteractive || element.innerText?.trim())) { | ||
| const node = { | ||
| uid: `e${uid}`, | ||
| role: getRole(element), | ||
| name: getAccessibleName(element).slice(0, 200), | ||
| tag: element.tagName.toLowerCase() | ||
| }; | ||
| if (element.tagName === "A" && element.href) { | ||
| node.href = element.href; | ||
| if (rect.width > 0 && rect.height > 0 && (isInteractive || el.innerText?.trim())) { | ||
| const node = { uid: `e${uid}`, role: el.getAttribute("role") || el.tagName.toLowerCase(), | ||
| name: getName(el).slice(0, 200), tag: el.tagName.toLowerCase() }; | ||
| if (el.tagName === "A" && el.href) node.href = el.href; | ||
| if (el.tagName === "INPUT") { node.type = el.type; node.value = el.value; } | ||
| if (el.id) node.selector = `#${el.id}`; | ||
| else if (el.className && typeof el.className === "string") { | ||
| const cls = el.className.trim().split(/\s+/).slice(0, 2).join("."); | ||
| if (cls) node.selector = `${el.tagName.toLowerCase()}.${cls}`; | ||
| } | ||
| if (element.tagName === "INPUT") { | ||
| node.type = element.type; | ||
| node.value = element.value; | ||
| } | ||
| // Generate a selector | ||
| if (element.id) { | ||
| node.selector = `#${element.id}`; | ||
| } else if (element.className && typeof element.className === "string") { | ||
| const classes = element.className.trim().split(/\s+/).slice(0, 2).join("."); | ||
| if (classes) node.selector = `${element.tagName.toLowerCase()}.${classes}`; | ||
| } | ||
| nodes.push(node); | ||
@@ -349,18 +241,11 @@ uid++; | ||
| for (const child of element.children) { | ||
| const childResult = buildSnapshot(child, depth + 1, uid); | ||
| nodes.push(...childResult.nodes); | ||
| uid = childResult.nextUid; | ||
| for (const child of el.children) { | ||
| const r = build(child, depth + 1, uid); | ||
| nodes.push(...r.nodes); | ||
| uid = r.nextUid; | ||
| } | ||
| return { nodes, nextUid: uid }; | ||
| } | ||
| const { nodes } = buildSnapshot(document.body); | ||
| return { | ||
| url: window.location.href, | ||
| title: document.title, | ||
| nodes: nodes.slice(0, 500) // Limit to 500 nodes | ||
| }; | ||
| return { url: location.href, title: document.title, nodes: build(document.body).nodes.slice(0, 500) }; | ||
| } | ||
@@ -374,9 +259,3 @@ }); | ||
| const tabs = await chrome.tabs.query({}); | ||
| return JSON.stringify(tabs.map(t => ({ | ||
| id: t.id, | ||
| url: t.url, | ||
| title: t.title, | ||
| active: t.active, | ||
| windowId: t.windowId | ||
| })), null, 2); | ||
| return JSON.stringify(tabs.map(t => ({ id: t.id, url: t.url, title: t.title, active: t.active, windowId: t.windowId })), null, 2); | ||
| } | ||
@@ -386,10 +265,4 @@ | ||
| if (!code) throw new Error("Code is required"); | ||
| const tab = await getTabById(tabId); | ||
| const result = await chrome.scripting.executeScript({ | ||
| target: { tabId: tab.id }, | ||
| func: new Function(code) | ||
| }); | ||
| const result = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: new Function(code) }); | ||
| return JSON.stringify(result[0]?.result); | ||
@@ -400,4 +273,2 @@ } | ||
| const tab = await getTabById(tabId); | ||
| // Ensure selector is null (not undefined) for proper serialization | ||
| const sel = selector || null; | ||
@@ -408,9 +279,3 @@ | ||
| func: (scrollX, scrollY, sel) => { | ||
| if (sel) { | ||
| const element = document.querySelector(sel); | ||
| if (element) { | ||
| element.scrollIntoView({ behavior: "smooth", block: "center" }); | ||
| return; | ||
| } | ||
| } | ||
| if (sel) { const el = document.querySelector(sel); if (el) { el.scrollIntoView({ behavior: "smooth", block: "center" }); return; } } | ||
| window.scrollBy(scrollX, scrollY); | ||
@@ -429,46 +294,10 @@ }, | ||
| // ============================================================================ | ||
| // Extension Lifecycle | ||
| // ============================================================================ | ||
| chrome.runtime.onInstalled.addListener(async () => { | ||
| console.log("[OpenCode] Extension installed"); | ||
| await connectToNativeHost(); | ||
| chrome.runtime.onInstalled.addListener(() => connect()); | ||
| chrome.runtime.onStartup.addListener(() => connect()); | ||
| chrome.action.onClicked.addListener(() => { | ||
| connect(); | ||
| chrome.notifications.create({ type: "basic", iconUrl: "icons/icon128.png", title: "OpenCode Browser", | ||
| message: isConnected ? "Connected" : "Reconnecting..." }); | ||
| }); | ||
| chrome.runtime.onStartup.addListener(async () => { | ||
| console.log("[OpenCode] Extension started"); | ||
| await connectToNativeHost(); | ||
| }); | ||
| // Auto-reconnect on action click | ||
| chrome.action.onClicked.addListener(async () => { | ||
| if (!isConnected) { | ||
| const connected = await connectToNativeHost(); | ||
| if (connected) { | ||
| chrome.notifications.create({ | ||
| type: "basic", | ||
| iconUrl: "icons/icon128.png", | ||
| title: "OpenCode Browser", | ||
| message: "Connected to native host" | ||
| }); | ||
| } else { | ||
| chrome.notifications.create({ | ||
| type: "basic", | ||
| iconUrl: "icons/icon128.png", | ||
| title: "OpenCode Browser", | ||
| message: "Failed to connect. Is the native host installed?" | ||
| }); | ||
| } | ||
| } else { | ||
| chrome.notifications.create({ | ||
| type: "basic", | ||
| iconUrl: "icons/icon128.png", | ||
| title: "OpenCode Browser", | ||
| message: "Already connected" | ||
| }); | ||
| } | ||
| }); | ||
| // Try to connect on load | ||
| connectToNativeHost(); | ||
| connect(); |
@@ -12,3 +12,4 @@ { | ||
| "storage", | ||
| "notifications" | ||
| "notifications", | ||
| "alarms" | ||
| ], | ||
@@ -15,0 +16,0 @@ "host_permissions": [ |
+3
-2
| { | ||
| "name": "@different-ai/opencode-browser", | ||
| "version": "1.0.0", | ||
| "version": "1.0.1", | ||
| "description": "Browser automation for OpenCode via Chrome extension + Native Messaging. Inspired by Claude in Chrome.", | ||
@@ -33,4 +33,5 @@ "type": "module", | ||
| "dependencies": { | ||
| "@modelcontextprotocol/sdk": "^1.0.0" | ||
| "@modelcontextprotocol/sdk": "^1.0.0", | ||
| "ws": "^8.18.3" | ||
| } | ||
| } |
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Uses eval
Supply chain riskPackage uses dynamic code execution (e.g., eval()), which is a dangerous practice. This can prevent the code from running in certain environments and increases the risk that the code may contain exploits or malicious behavior.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Uses eval
Supply chain riskPackage uses dynamic code execution (e.g., eval()), which is a dangerous practice. This can prevent the code from running in certain environments and increases the risk that the code may contain exploits or malicious behavior.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
50675
9.1%11
10%1355
8.31%2
100%5
25%5
25%+ Added
+ Added