From 2f97ac05f1adf252508fdf0c92c19b9b0b83eb94 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 5 May 2026 19:20:17 -0700 Subject: [PATCH 1/6] Revert "feat(desktop): add embedded browser panel with WebContentsView (#1671)" This reverts commit 610ae1cb8984f3a3c1d3608ebde3cab0edb8243d. --- apps/app/src/app/lib/desktop.ts | 24 -- .../domains/session/browser/browser-panel.tsx | 218 ------------------ .../domains/session/chat/session-page.tsx | 38 +-- apps/desktop/electron/main.mjs | 121 +--------- apps/desktop/electron/preload.mjs | 36 --- 5 files changed, 3 insertions(+), 434 deletions(-) delete mode 100644 apps/app/src/react-app/domains/session/browser/browser-panel.tsx diff --git a/apps/app/src/app/lib/desktop.ts b/apps/app/src/app/lib/desktop.ts index 9fe81b71d..dd4a2b954 100644 --- a/apps/app/src/app/lib/desktop.ts +++ b/apps/app/src/app/lib/desktop.ts @@ -42,30 +42,6 @@ declare global { download?: () => Promise<{ ok: boolean; reason?: string }>; installAndRestart?: () => Promise<{ ok: boolean; reason?: string }>; }; - browser?: { - show?: (bounds: { x: number; y: number; width: number; height: number }) => Promise; - hide?: () => Promise; - navigate?: (url: string) => Promise; - back?: () => Promise; - forward?: () => Promise; - reload?: () => Promise; - setBounds?: (bounds: { x: number; y: number; width: number; height: number }) => Promise; - getState?: () => Promise<{ - url: string; - title: string; - canGoBack: boolean; - canGoForward: boolean; - isLoading: boolean; - } | null>; - destroy?: () => Promise; - onStateChange?: (callback: (state: { - url: string; - title: string; - canGoBack: boolean; - canGoForward: boolean; - isLoading: boolean; - }) => void) => () => void; - }; meta?: { initialDeepLinks?: string[]; platform?: "darwin" | "linux" | "windows"; diff --git a/apps/app/src/react-app/domains/session/browser/browser-panel.tsx b/apps/app/src/react-app/domains/session/browser/browser-panel.tsx deleted file mode 100644 index 11ef84668..000000000 --- a/apps/app/src/react-app/domains/session/browser/browser-panel.tsx +++ /dev/null @@ -1,218 +0,0 @@ -/** @jsxImportSource react */ -import { useCallback, useEffect, useRef, useState } from "react"; -import { - ArrowLeft, - ArrowRight, - Globe, - Loader2, - RotateCw, - X, -} from "lucide-react"; -import { isElectronRuntime } from "../../../../app/utils"; - -type BrowserState = { - url: string; - title: string; - canGoBack: boolean; - canGoForward: boolean; - isLoading: boolean; -}; - -type BrowserPanelProps = { - onClose: () => void; -}; - -const EMPTY_STATE: BrowserState = { - url: "", - title: "", - canGoBack: false, - canGoForward: false, - isLoading: false, -}; - -function getElectronBrowser() { - if (!isElectronRuntime()) return null; - return (window as Window).__OPENWORK_ELECTRON__?.browser ?? null; -} - -export function BrowserPanel({ onClose }: BrowserPanelProps) { - const [state, setState] = useState(EMPTY_STATE); - const [urlInput, setUrlInput] = useState(""); - const [urlFocused, setUrlFocused] = useState(false); - const panelRef = useRef(null); - const urlInputRef = useRef(null); - - // Subscribe to state changes from the main process - useEffect(() => { - const browser = getElectronBrowser(); - if (!browser) return; - - const unsub = browser.onStateChange((newState: BrowserState) => { - setState(newState); - if (!urlFocused) { - setUrlInput(newState.url); - } - }); - - // Get initial state - browser.getState().then((initial: BrowserState | null) => { - if (initial) { - setState(initial); - setUrlInput(initial.url); - } - }); - - return unsub; - }, [urlFocused]); - - // Show the browser view when the panel mounts, hide on unmount. - // Also update bounds when the panel resizes. - useEffect(() => { - const browser = getElectronBrowser(); - if (!browser || !panelRef.current) return; - - const updateBounds = () => { - if (!panelRef.current) return; - const rect = panelRef.current.getBoundingClientRect(); - // The toolbar is ~44px, leave space at top for it - const toolbarHeight = 44; - const bounds = { - x: Math.round(rect.x), - y: Math.round(rect.y + toolbarHeight), - width: Math.round(rect.width), - height: Math.round(rect.height - toolbarHeight), - }; - browser.setBounds(bounds); - }; - - // Show with initial bounds - const rect = panelRef.current.getBoundingClientRect(); - const toolbarHeight = 44; - browser.show({ - x: Math.round(rect.x), - y: Math.round(rect.y + toolbarHeight), - width: Math.round(rect.width), - height: Math.round(rect.height - toolbarHeight), - }); - - // Observe resize - const observer = new ResizeObserver(updateBounds); - observer.observe(panelRef.current); - - // Also update on window resize - window.addEventListener("resize", updateBounds); - - return () => { - observer.disconnect(); - window.removeEventListener("resize", updateBounds); - browser.hide(); - }; - }, []); - - const navigate = useCallback( - (url?: string) => { - const browser = getElectronBrowser(); - if (!browser) return; - browser.navigate(url ?? urlInput); - }, - [urlInput], - ); - - const handleUrlKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - navigate(); - urlInputRef.current?.blur(); - } - }, - [navigate], - ); - - const browser = getElectronBrowser(); - - if (!isElectronRuntime() || !browser) { - return ( -
-

Browser panel is only available in the desktop app.

-
- ); - } - - return ( -
- {/* Toolbar */} -
- {/* Navigation buttons */} - - - - - {/* URL bar */} -
- - setUrlInput(e.target.value)} - onKeyDown={handleUrlKeyDown} - onFocus={() => { - setUrlFocused(true); - urlInputRef.current?.select(); - }} - onBlur={() => setUrlFocused(false)} - placeholder="Enter URL..." - spellCheck={false} - autoComplete="off" - /> -
- - {/* Close button */} - -
- - {/* WebContentsView renders in this area (managed by Electron main process) */} -
-
- ); -} diff --git a/apps/app/src/react-app/domains/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx index 205b08fe5..47f23926d 100644 --- a/apps/app/src/react-app/domains/session/chat/session-page.tsx +++ b/apps/app/src/react-app/domains/session/chat/session-page.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource react */ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Check, Globe, Loader2, Minimize2, Redo2, Undo2, Zap } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { Check, Loader2, Minimize2, Redo2, Undo2, Zap } from "lucide-react"; import { t } from "../../../../i18n"; import { buildOpenworkWorkspaceBaseUrl, type OpenworkServerClient, type OpenworkServerStatus } from "../../../../app/lib/openwork-server"; @@ -32,8 +32,6 @@ import { } from "../../../shell/workspace-shell-layout"; import { OwDotTicker } from "../../../shell/dot-ticker"; import { useReactRenderWatchdog } from "../../../shell/react-render-watchdog"; -import { isElectronRuntime } from "../../../../app/utils"; -import { BrowserPanel } from "../browser/browser-panel"; type StatusBarOverrides = Pick< StatusBarProps, @@ -175,13 +173,8 @@ export function SessionPage(props: SessionPageProps) { const [deleteOpen, setDeleteOpen] = useState(false); const [deleteBusy, setDeleteBusy] = useState(false); const [todoExpanded, setTodoExpanded] = useState(true); - const [browserPanelOpen, setBrowserPanelOpen] = useState(false); const [showDelayedSessionLoadingState, setShowDelayedSessionLoadingState] = useState(false); - const toggleBrowserPanel = useCallback(() => { - setBrowserPanelOpen((prev) => !prev); - }, []); - const selectedSessionTitle = useMemo( () => sessionTitleForId(props.sidebar.workspaceSessionGroups, props.selectedSessionId), [props.selectedSessionId, props.sidebar.workspaceSessionGroups], @@ -347,23 +340,6 @@ export function SessionPage(props: SessionPageProps) {
- {isElectronRuntime() ? ( - - ) : null} {props.history ? ( <>
{props.providerAuthModal ? : null} diff --git a/apps/desktop/electron/main.mjs b/apps/desktop/electron/main.mjs index ac68edf7b..2bbcc6618 100644 --- a/apps/desktop/electron/main.mjs +++ b/apps/desktop/electron/main.mjs @@ -17,7 +17,7 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { app, BrowserWindow, WebContentsView, dialog, ipcMain, nativeImage, shell } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, nativeImage, shell } from "electron"; import { registerMigrationIpc } from "./migration.mjs"; import { createRuntimeManager } from "./runtime.mjs"; import { registerUpdaterIpc } from "./updater.mjs"; @@ -186,82 +186,6 @@ let uiControlServer = null; let uiControlDiscoveryPath = null; const uiControlToken = randomBytes(32).toString("hex"); -// ── Embedded browser panel ───────────────────────────────────────────── -let browserView = null; -let browserViewVisible = false; -const BROWSER_DEFAULT_URL = "https://www.google.com"; - -function createBrowserView() { - if (browserView) return browserView; - browserView = new WebContentsView({ - webPreferences: { - sandbox: true, - contextIsolation: true, - nodeIntegration: false, - partition: "persist:openwork-browser", - }, - }); - // Open external links from the embedded browser in the system browser - browserView.webContents.setWindowOpenHandler(({ url }) => { - void shell.openExternal(url); - return { action: "deny" }; - }); - // Notify renderer of navigation events - browserView.webContents.on("did-navigate", () => sendBrowserState()); - browserView.webContents.on("did-navigate-in-page", () => sendBrowserState()); - browserView.webContents.on("page-title-updated", () => sendBrowserState()); - browserView.webContents.on("did-start-loading", () => sendBrowserState()); - browserView.webContents.on("did-stop-loading", () => sendBrowserState()); - return browserView; -} - -function sendBrowserState() { - if (!mainWindow || !browserView) return; - try { - mainWindow.webContents.send("openwork:browser:state", { - url: browserView.webContents.getURL(), - title: browserView.webContents.getTitle(), - canGoBack: browserView.webContents.canGoBack(), - canGoForward: browserView.webContents.canGoForward(), - isLoading: browserView.webContents.isLoading(), - }); - } catch { - // ignore — window may be closing - } -} - -function showBrowserView(bounds) { - if (!mainWindow) return; - const view = createBrowserView(); - if (!mainWindow.contentView.children.includes(view)) { - mainWindow.contentView.addChildView(view); - } - view.setBounds(bounds); - browserViewVisible = true; - if (!view.webContents.getURL()) { - view.webContents.loadURL(BROWSER_DEFAULT_URL); - } - sendBrowserState(); -} - -function hideBrowserView() { - if (!mainWindow || !browserView) return; - try { - mainWindow.contentView.removeChildView(browserView); - } catch { - // already removed - } - browserViewVisible = false; -} - -function destroyBrowserView() { - hideBrowserView(); - if (browserView) { - browserView.webContents.close(); - browserView = null; - } -} - function normalizePlatform(value) { if (value === "darwin" || value === "linux") return value; if (value === "win32") return "windows"; @@ -1603,7 +1527,6 @@ async function createMainWindow() { }); mainWindow.on("closed", () => { - destroyBrowserView(); mainWindow = null; }); @@ -1642,48 +1565,6 @@ ipcMain.handle("openwork:shell:relaunch", async () => { app.exit(0); }); -// ── Embedded browser IPC ──────────────────────────────────────────────── -ipcMain.handle("openwork:browser:show", (_event, bounds) => { - showBrowserView(bounds); -}); -ipcMain.handle("openwork:browser:hide", () => { - hideBrowserView(); -}); -ipcMain.handle("openwork:browser:navigate", (_event, url) => { - if (!browserView) return; - const target = typeof url === "string" && url.trim() ? url.trim() : BROWSER_DEFAULT_URL; - // Add protocol if missing - const finalUrl = /^https?:\/\//i.test(target) ? target : `https://${target}`; - browserView.webContents.loadURL(finalUrl); -}); -ipcMain.handle("openwork:browser:back", () => { - if (browserView?.webContents.canGoBack()) browserView.webContents.goBack(); -}); -ipcMain.handle("openwork:browser:forward", () => { - if (browserView?.webContents.canGoForward()) browserView.webContents.goForward(); -}); -ipcMain.handle("openwork:browser:reload", () => { - browserView?.webContents.reload(); -}); -ipcMain.handle("openwork:browser:bounds", (_event, bounds) => { - if (browserView && browserViewVisible) { - browserView.setBounds(bounds); - } -}); -ipcMain.handle("openwork:browser:state", () => { - if (!browserView) return null; - return { - url: browserView.webContents.getURL(), - title: browserView.webContents.getTitle(), - canGoBack: browserView.webContents.canGoBack(), - canGoForward: browserView.webContents.canGoForward(), - isLoading: browserView.webContents.isLoading(), - }; -}); -ipcMain.handle("openwork:browser:destroy", () => { - destroyBrowserView(); -}); - registerMigrationIpc({ app, ipcMain }); const { ensureAutoUpdater } = registerUpdaterIpc({ app, ipcMain, getMainWindow: () => mainWindow }); diff --git a/apps/desktop/electron/preload.mjs b/apps/desktop/electron/preload.mjs index 062ac4f8f..8591b2f39 100644 --- a/apps/desktop/electron/preload.mjs +++ b/apps/desktop/electron/preload.mjs @@ -53,42 +53,6 @@ contextBridge.exposeInMainWorld("__OPENWORK_ELECTRON__", { }; }, }, - browser: { - show(bounds) { - return ipcRenderer.invoke("openwork:browser:show", bounds); - }, - hide() { - return ipcRenderer.invoke("openwork:browser:hide"); - }, - navigate(url) { - return ipcRenderer.invoke("openwork:browser:navigate", url); - }, - back() { - return ipcRenderer.invoke("openwork:browser:back"); - }, - forward() { - return ipcRenderer.invoke("openwork:browser:forward"); - }, - reload() { - return ipcRenderer.invoke("openwork:browser:reload"); - }, - setBounds(bounds) { - return ipcRenderer.invoke("openwork:browser:bounds", bounds); - }, - getState() { - return ipcRenderer.invoke("openwork:browser:state"); - }, - destroy() { - return ipcRenderer.invoke("openwork:browser:destroy"); - }, - onStateChange(callback) { - const handler = (_event, state) => callback(state); - ipcRenderer.on("openwork:browser:state", handler); - return () => { - ipcRenderer.removeListener("openwork:browser:state", handler); - }; - }, - }, meta: { initialDeepLinks: [], platform: normalizePlatform(process.platform), From 2f32c39af224ee36c60644e3029c2abaa88a8c18 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 5 May 2026 19:20:17 -0700 Subject: [PATCH 2/6] Revert "feat(desktop): bundle chrome-devtools-mcp as dependency, eliminate npx requirement (#1670)" This reverts commit fe9881b488becbfd4fe2dcbb4d4b345d472b5638. --- apps/app/src/app/mcp.ts | 40 +----- .../react-app/domains/connections/store.ts | 19 +-- .../chrome-devtools-mcp-resolution.test.ts | 120 ------------------ apps/desktop/electron/main.mjs | 16 --- apps/desktop/package.json | 1 - .../scripts/chrome-devtools-mcp-shim.test.ts | 71 ----------- .../scripts/chrome-devtools-mcp-shim.ts | 67 ++-------- apps/desktop/scripts/prepare-sidecar.mjs | 97 ++++++++++++-- pnpm-lock.yaml | 10 -- 9 files changed, 107 insertions(+), 334 deletions(-) delete mode 100644 apps/app/tests/chrome-devtools-mcp-resolution.test.ts delete mode 100644 apps/desktop/scripts/chrome-devtools-mcp-shim.test.ts diff --git a/apps/app/src/app/mcp.ts b/apps/app/src/app/mcp.ts index 909306af7..b96917d29 100644 --- a/apps/app/src/app/mcp.ts +++ b/apps/app/src/app/mcp.ts @@ -2,43 +2,11 @@ import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser"; import type { McpServerConfig, McpServerEntry } from "./types"; import { readOpencodeConfig, writeOpencodeConfig } from "./lib/desktop"; import { CHROME_DEVTOOLS_MCP_COMMAND, CHROME_DEVTOOLS_MCP_ID } from "./constants"; -import { isElectronRuntime } from "./utils"; type McpConfigValue = Record | null | undefined; export const CHROME_DEVTOOLS_AUTO_CONNECT_ARG = "--autoConnect"; -/** - * Cached result of resolving the bundled chrome-devtools-mcp binary path - * from the Electron main process. `undefined` = not yet resolved. - */ -let _resolvedBundledCommand: string[] | null | undefined; - -/** - * Resolve the chrome-devtools-mcp command for the current runtime. - * - * In Electron, the package is bundled as a dependency of `@openwork/desktop`, - * so we ask the main process for the absolute path to the bin and use - * `["node", ""]` — no npm/npx required. - * - * Falls back to the npx-based command for web/remote contexts. - */ -export async function resolveChromeDevtoolsMcpCommand(): Promise { - if (isElectronRuntime() && _resolvedBundledCommand === undefined) { - try { - const resolved = await (window as Window).__OPENWORK_ELECTRON__!.invokeDesktop( - "resolveChromeDevtoolsMcpBin", - ); - _resolvedBundledCommand = Array.isArray(resolved) && resolved.length > 0 - ? (resolved as string[]) - : null; - } catch { - _resolvedBundledCommand = null; - } - } - return _resolvedBundledCommand ?? [...CHROME_DEVTOOLS_MCP_COMMAND]; -} - type McpIdentity = { id?: string; name: string; @@ -62,14 +30,10 @@ export function usesChromeDevtoolsAutoConnect(command?: string[]): boolean { return Array.isArray(command) && command.includes(CHROME_DEVTOOLS_AUTO_CONNECT_ARG); } -export function buildChromeDevtoolsCommand( - command: string[] | undefined, - useExistingProfile: boolean, - resolvedBase?: string[], -): string[] { +export function buildChromeDevtoolsCommand(command: string[] | undefined, useExistingProfile: boolean): string[] { const base = Array.isArray(command) && command.length ? command.filter((part) => part !== CHROME_DEVTOOLS_AUTO_CONNECT_ARG) - : resolvedBase ?? [...CHROME_DEVTOOLS_MCP_COMMAND]; + : [...CHROME_DEVTOOLS_MCP_COMMAND]; return useExistingProfile ? [...base, CHROME_DEVTOOLS_AUTO_CONNECT_ARG] : base; } diff --git a/apps/app/src/react-app/domains/connections/store.ts b/apps/app/src/react-app/domains/connections/store.ts index 2505c0607..a82d6f367 100644 --- a/apps/app/src/react-app/domains/connections/store.ts +++ b/apps/app/src/react-app/domains/connections/store.ts @@ -20,7 +20,6 @@ import { toSessionTransportDirectory } from "../../../app/lib/session-scope"; import { parseMcpServersFromContent, removeMcpFromConfig, - resolveChromeDevtoolsMcpCommand, usesChromeDevtoolsAutoConnect, validateMcpServerName, } from "../../../app/mcp"; @@ -32,7 +31,7 @@ import type { ReloadReason, ReloadTrigger, } from "../../../app/types"; -import { isDesktopRuntime, isElectronRuntime, normalizeDirectoryPath, safeStringify } from "../../../app/utils"; +import { isDesktopRuntime, normalizeDirectoryPath, safeStringify } from "../../../app/utils"; import type { OpenworkServerStore } from "./openwork-server-store"; @@ -497,23 +496,11 @@ export function createConnectionsStore(options: { if (!entry.command?.length) { throw new Error("Missing MCP command."); } - - // For chrome-devtools in Electron, resolve the bundled binary so we - // don't need npx/npm at runtime. - let resolvedCommand = entry.command; - if (slug === CHROME_DEVTOOLS_MCP_ID && isElectronRuntime()) { - const bundled = await resolveChromeDevtoolsMcpCommand(); - // Preserve any extra args (e.g. --autoConnect) from the original - const extraArgs = entry.command.filter( - (arg) => arg.startsWith("--") || arg.startsWith("-"), - ); - resolvedCommand = [...bundled, ...extraArgs]; - } - mcpEntryConfig["command"] = resolvedCommand; + mcpEntryConfig["command"] = entry.command; if ( slug === CHROME_DEVTOOLS_MCP_ID && - usesChromeDevtoolsAutoConnect(resolvedCommand) && + usesChromeDevtoolsAutoConnect(entry.command) && isDesktopRuntime() ) { try { diff --git a/apps/app/tests/chrome-devtools-mcp-resolution.test.ts b/apps/app/tests/chrome-devtools-mcp-resolution.test.ts deleted file mode 100644 index d5b268943..000000000 --- a/apps/app/tests/chrome-devtools-mcp-resolution.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; - -import { - buildChromeDevtoolsCommand, - CHROME_DEVTOOLS_AUTO_CONNECT_ARG, - isChromeDevtoolsMcp, - usesChromeDevtoolsAutoConnect, -} from "../src/app/mcp"; -import { CHROME_DEVTOOLS_MCP_COMMAND } from "../src/app/constants"; - -describe("chrome-devtools-mcp bundled resolution", () => { - describe("buildChromeDevtoolsCommand", () => { - test("uses npx fallback when no resolved base is provided", () => { - const result = buildChromeDevtoolsCommand(undefined, false); - expect(result).toEqual([...CHROME_DEVTOOLS_MCP_COMMAND]); - }); - - test("uses resolved base when provided and command is undefined", () => { - const resolved = ["node", "/abs/path/to/build/src/index.js"]; - const result = buildChromeDevtoolsCommand(undefined, false, resolved); - expect(result).toEqual(resolved); - }); - - test("uses resolved base when provided and command is empty", () => { - const resolved = ["node", "/abs/path/to/build/src/index.js"]; - const result = buildChromeDevtoolsCommand([], false, resolved); - expect(result).toEqual(resolved); - }); - - test("prefers explicit command over resolved base", () => { - const resolved = ["node", "/abs/path/to/build/src/index.js"]; - const explicit = ["custom-chrome-mcp", "--flag"]; - const result = buildChromeDevtoolsCommand(explicit, false, resolved); - expect(result).toEqual(explicit); - }); - - test("appends --autoConnect when useExistingProfile is true", () => { - const resolved = ["node", "/abs/path/to/build/src/index.js"]; - const result = buildChromeDevtoolsCommand(undefined, true, resolved); - expect(result).toEqual([...resolved, CHROME_DEVTOOLS_AUTO_CONNECT_ARG]); - }); - - test("strips existing --autoConnect before re-adding", () => { - const command = ["node", "/path/index.js", "--autoConnect"]; - const result = buildChromeDevtoolsCommand(command, true); - expect(result).toEqual(["node", "/path/index.js", "--autoConnect"]); - // Should not have double --autoConnect - expect(result.filter((a) => a === "--autoConnect")).toHaveLength(1); - }); - - test("strips --autoConnect when useExistingProfile is false", () => { - const command = ["node", "/path/index.js", "--autoConnect"]; - const result = buildChromeDevtoolsCommand(command, false); - expect(result).toEqual(["node", "/path/index.js"]); - }); - }); - - describe("isChromeDevtoolsMcp", () => { - test("returns true for chrome-devtools id", () => { - expect(isChromeDevtoolsMcp({ id: "chrome-devtools", name: "Chrome" })).toBe(true); - }); - - test("returns true for control-chrome slug", () => { - expect(isChromeDevtoolsMcp({ name: "Control Chrome" })).toBe(true); - }); - - test("returns false for other MCPs", () => { - expect(isChromeDevtoolsMcp({ name: "Notion" })).toBe(false); - }); - - test("returns false for null/undefined", () => { - expect(isChromeDevtoolsMcp(null)).toBe(false); - expect(isChromeDevtoolsMcp(undefined)).toBe(false); - }); - }); - - describe("usesChromeDevtoolsAutoConnect", () => { - test("returns true when --autoConnect is present", () => { - expect(usesChromeDevtoolsAutoConnect(["node", "/path", "--autoConnect"])).toBe(true); - }); - - test("returns false when --autoConnect is absent", () => { - expect(usesChromeDevtoolsAutoConnect(["node", "/path"])).toBe(false); - }); - - test("returns false for undefined", () => { - expect(usesChromeDevtoolsAutoConnect(undefined)).toBe(false); - }); - }); -}); - -describe("bundled bin path resolution (Electron main process)", () => { - test("chrome-devtools-mcp package is installed and bin exists", () => { - // This test verifies the dependency is actually installed and the bin - // is resolvable — the same resolution the Electron main process does. - const path = require("node:path"); - const fs = require("node:fs"); - const { createRequire } = require("node:module"); - - // Resolve from the desktop package (where the dependency is declared) - const desktopDir = path.resolve(__dirname, "../../desktop"); - const require_ = createRequire(path.join(desktopDir, "package.json")); - - let pkgJsonPath: string; - try { - pkgJsonPath = require_.resolve("chrome-devtools-mcp/package.json"); - } catch { - // In CI or if node_modules isn't installed, skip gracefully - console.log("SKIP: chrome-devtools-mcp not installed (run pnpm install)"); - return; - } - - const binPath = path.join(path.dirname(pkgJsonPath), "build", "src", "index.js"); - expect(fs.existsSync(binPath)).toBe(true); - - // Verify it has the shebang (it's a CLI entry point) - const head = fs.readFileSync(binPath, "utf8").slice(0, 50); - expect(head).toContain("#!/usr/bin/env node"); - }); -}); diff --git a/apps/desktop/electron/main.mjs b/apps/desktop/electron/main.mjs index 2bbcc6618..8fb30b803 100644 --- a/apps/desktop/electron/main.mjs +++ b/apps/desktop/electron/main.mjs @@ -12,7 +12,6 @@ import { stat, writeFile, } from "node:fs/promises"; -import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -1331,21 +1330,6 @@ async function handleDesktopInvoke(event, command, ...args) { window.webContents.setZoomFactor(factor); return true; } - case "resolveChromeDevtoolsMcpBin": { - // Resolve the bundled chrome-devtools-mcp bin path so the renderer - // can write a command to opencode.json that doesn't require npx. - try { - const require_ = createRequire(import.meta.url); - const pkgJsonPath = require_.resolve("chrome-devtools-mcp/package.json"); - const binPath = path.join(path.dirname(pkgJsonPath), "build", "src", "index.js"); - if (existsSync(binPath)) { - return [process.execPath, binPath]; - } - } catch { - // package not found — fall through to null - } - return null; - } default: throw new Error(`Electron desktop bridge method is not implemented yet: ${command}`); } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5c0e5810b..22e3d85c8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -25,7 +25,6 @@ "prepare:sidecar": "node ./scripts/prepare-sidecar.mjs" }, "dependencies": { - "chrome-devtools-mcp": "0.17.0", "electron-updater": "^6.3.9" }, "devDependencies": { diff --git a/apps/desktop/scripts/chrome-devtools-mcp-shim.test.ts b/apps/desktop/scripts/chrome-devtools-mcp-shim.test.ts deleted file mode 100644 index 71ba2cf01..000000000 --- a/apps/desktop/scripts/chrome-devtools-mcp-shim.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { existsSync } from "node:fs"; -import { readFileSync } from "node:fs"; -import { createRequire } from "node:module"; -import { dirname, join } from "node:path"; - -describe("chrome-devtools-mcp-shim resolution", () => { - test("resolves bundled chrome-devtools-mcp bin from node_modules", () => { - // Replicate the exact resolution logic from the shim - const require_ = createRequire(import.meta.url); - - let pkgJsonPath: string; - try { - pkgJsonPath = require_.resolve("chrome-devtools-mcp/package.json"); - } catch { - console.log("SKIP: chrome-devtools-mcp not installed"); - return; - } - - const binPath = join(dirname(pkgJsonPath), "build", "src", "index.js"); - expect(existsSync(binPath)).toBe(true); - }); - - test("resolved bin is a valid node script with shebang", () => { - const require_ = createRequire(import.meta.url); - - let pkgJsonPath: string; - try { - pkgJsonPath = require_.resolve("chrome-devtools-mcp/package.json"); - } catch { - console.log("SKIP: chrome-devtools-mcp not installed"); - return; - } - - const binPath = join(dirname(pkgJsonPath), "build", "src", "index.js"); - const content = readFileSync(binPath, "utf8"); - expect(content.startsWith("#!/usr/bin/env node")).toBe(true); - }); - - test("package.json declares correct bin field", () => { - const require_ = createRequire(import.meta.url); - - let pkgJsonPath: string; - try { - pkgJsonPath = require_.resolve("chrome-devtools-mcp/package.json"); - } catch { - console.log("SKIP: chrome-devtools-mcp not installed"); - return; - } - - const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8")); - // The bin field should point to the index.js entry - expect(pkg.bin).toBeDefined(); - expect(typeof pkg.bin === "string" ? pkg.bin : "").toContain("index.js"); - }); - - test("version matches expected pinned version", () => { - const require_ = createRequire(import.meta.url); - - let pkgJsonPath: string; - try { - pkgJsonPath = require_.resolve("chrome-devtools-mcp/package.json"); - } catch { - console.log("SKIP: chrome-devtools-mcp not installed"); - return; - } - - const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8")); - expect(pkg.version).toBe("0.17.0"); - }); -}); diff --git a/apps/desktop/scripts/chrome-devtools-mcp-shim.ts b/apps/desktop/scripts/chrome-devtools-mcp-shim.ts index ed938cdea..e45354d20 100644 --- a/apps/desktop/scripts/chrome-devtools-mcp-shim.ts +++ b/apps/desktop/scripts/chrome-devtools-mcp-shim.ts @@ -1,76 +1,35 @@ #!/usr/bin/env node -/** - * Chrome DevTools MCP shim — resolves the bundled `chrome-devtools-mcp` - * dependency and runs it directly via Node, eliminating the runtime - * dependency on npm/npx. - * - * Fallback: if the bundled package cannot be found (e.g. standalone - * sidecar without node_modules), falls back to `npm exec` like before. - */ import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import { createRequire } from "node:module"; -import { dirname, join } from "node:path"; const packageSpec = process.env.OPENWORK_CHROME_DEVTOOLS_MCP_SPEC?.trim() || process.env.CHROME_DEVTOOLS_MCP_SPEC?.trim() || "chrome-devtools-mcp@0.17.0"; -/** - * Try to resolve the chrome-devtools-mcp entry point from node_modules. - * The package's `bin` field points at `./build/src/index.js`. - */ -function resolveBundledBin(): string | null { - try { - const require_ = createRequire(import.meta.url); - const pkgJsonPath = require_.resolve("chrome-devtools-mcp/package.json"); - const binPath = join(dirname(pkgJsonPath), "build", "src", "index.js"); - if (existsSync(binPath)) { - return binPath; - } - } catch { - // package not found in node_modules — will fall back to npm exec - } - return null; -} - -const bundledBin = resolveBundledBin(); +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; +const args = ["exec", "--yes", packageSpec, "--", ...process.argv.slice(2)]; -let child: ReturnType; - -if (bundledBin) { - // Direct invocation via Node — no npm/npx needed - child = spawn(process.execPath, [bundledBin, ...process.argv.slice(2)], { - stdio: "inherit", - env: process.env, - }); -} else { - // Fallback: npm exec (requires npm on PATH) - const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; - const args = ["exec", "--yes", packageSpec, "--", ...process.argv.slice(2)]; - child = spawn(npmCommand, args, { - stdio: "inherit", - env: { - ...process.env, - npm_config_yes: "true", - }, - }); -} +const child = spawn(npmCommand, args, { + stdio: "inherit", + env: { + ...process.env, + npm_config_yes: "true", + }, +}); -child.on("error", (error: Error) => { +child.on("error", (error) => { const message = error instanceof Error ? error.message : String(error); if (message.includes("ENOENT")) { console.error( - "Control Chrome requires Node.js. Install Node.js or configure mcp.chrome-devtools.command to a local chrome-devtools-mcp binary." + "Control Chrome requires npm (Node.js). Install Node.js or configure mcp.chrome-devtools.command to a local chrome-devtools-mcp binary." ); } else { - console.error(`Failed to start chrome-devtools-mcp: ${message}`); + console.error(`Failed to start chrome-devtools-mcp via npm exec: ${message}`); } process.exit(1); }); -child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => { +child.on("exit", (code, signal) => { if (signal) { process.kill(process.pid, signal); return; diff --git a/apps/desktop/scripts/prepare-sidecar.mjs b/apps/desktop/scripts/prepare-sidecar.mjs index 8ca4739fd..c492d6954 100644 --- a/apps/desktop/scripts/prepare-sidecar.mjs +++ b/apps/desktop/scripts/prepare-sidecar.mjs @@ -162,14 +162,20 @@ const orchestratorTargetName = orchestratorTargetTriple const orchestratorTargetPath = orchestratorTargetName ? join(sidecarDir, orchestratorTargetName) : null; const orchestratorDir = resolve(__dirname, "..", "..", "orchestrator"); -// chrome-devtools-mcp: now bundled as a node_modules dependency of -// @openwork/desktop (Electron resolves it directly). The Bun-compiled shim -// sidecar is no longer built. These variables are kept only so the -// versions.json metadata block below can record the pinned version without -// breaking the build. +// chrome-devtools-mcp shim sidecar const chromeDevtoolsBaseName = "chrome-devtools-mcp"; const chromeDevtoolsName = isWindowsTarget ? `${chromeDevtoolsBaseName}.exe` : chromeDevtoolsBaseName; const chromeDevtoolsPath = join(sidecarDir, chromeDevtoolsName); +const chromeDevtoolsBuildName = bunTarget + ? `${chromeDevtoolsBaseName}-${bunTarget}${bunTarget.includes("windows") ? ".exe" : ""}` + : chromeDevtoolsName; +const chromeDevtoolsBuildPath = join(sidecarDir, chromeDevtoolsBuildName); +const chromeDevtoolsTargetTriple = resolvedTargetTriple; +const chromeDevtoolsTargetName = chromeDevtoolsTargetTriple + ? `${chromeDevtoolsBaseName}-${chromeDevtoolsTargetTriple}${chromeDevtoolsTargetTriple.includes("windows") ? ".exe" : ""}` + : null; +const chromeDevtoolsTargetPath = chromeDevtoolsTargetName ? join(sidecarDir, chromeDevtoolsTargetName) : null; +const chromeDevtoolsShimPath = resolve(__dirname, "chrome-devtools-mcp-shim.ts"); const readHeader = (filePath, length = 256) => { const fd = openSync(filePath, "r"); @@ -554,7 +560,80 @@ if (existsSync(orchestratorBuildPath)) { } } -// chrome-devtools-mcp is now a node_modules dependency — no sidecar build needed. +// Build chrome-devtools-mcp shim sidecar +let didBuildChromeDevtools = false; +const shouldBuildChromeDevtools = + forceBuild || !existsSync(chromeDevtoolsBuildPath) || isStubBinary(chromeDevtoolsBuildPath); +if (shouldBuildChromeDevtools) { + mkdirSync(sidecarDir, { recursive: true }); + if (existsSync(chromeDevtoolsBuildPath)) { + try { + unlinkSync(chromeDevtoolsBuildPath); + } catch { + // ignore + } + } + + if (!existsSync(chromeDevtoolsShimPath)) { + console.error(`Chrome DevTools MCP shim source not found at ${chromeDevtoolsShimPath}`); + process.exit(1); + } + + const chromeDevtoolsArgs = [ + "build", + "--compile", + chromeDevtoolsShimPath, + "--outfile", + chromeDevtoolsBuildPath, + ]; + if (bunTarget) { + chromeDevtoolsArgs.push("--target", bunTarget); + } + + const result = spawnSync("bun", chromeDevtoolsArgs, { + cwd: __dirname, + stdio: "inherit", + shell: true, + env: { + ...process.env, + NODE_ENV: "production", + BUN_ENV: "production", + }, + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + + didBuildChromeDevtools = true; +} + +if (existsSync(chromeDevtoolsBuildPath)) { + const shouldCopyCanonical = + didBuildChromeDevtools || !existsSync(chromeDevtoolsPath) || isStubBinary(chromeDevtoolsPath); + if (shouldCopyCanonical && chromeDevtoolsBuildPath !== chromeDevtoolsPath) { + try { + if (existsSync(chromeDevtoolsPath)) unlinkSync(chromeDevtoolsPath); + } catch { + // ignore + } + copyFileSync(chromeDevtoolsBuildPath, chromeDevtoolsPath); + } + + if (chromeDevtoolsTargetPath) { + const shouldCopyTarget = + didBuildChromeDevtools || + !existsSync(chromeDevtoolsTargetPath) || + isStubBinary(chromeDevtoolsTargetPath); + if (shouldCopyTarget && chromeDevtoolsBuildPath !== chromeDevtoolsTargetPath) { + try { + if (existsSync(chromeDevtoolsTargetPath)) unlinkSync(chromeDevtoolsTargetPath); + } catch { + // ignore + } + copyFileSync(chromeDevtoolsBuildPath, chromeDevtoolsTargetPath); + } + } +} adHocSignDarwinSidecars([ opencodePath, @@ -565,6 +644,9 @@ adHocSignDarwinSidecars([ orchestratorBuildPath, orchestratorPath, orchestratorTargetPath, + chromeDevtoolsBuildPath, + chromeDevtoolsPath, + chromeDevtoolsTargetPath, ]); const openworkServerVersion = (() => { @@ -600,8 +682,7 @@ const versions = { }, "chrome-devtools-mcp": { version: chromeDevtoolsMcpVersion, - // No longer a sidecar binary — bundled as a node_modules dependency. - sha256: "bundled", + sha256: existsSync(chromeDevtoolsPath) ? sha256File(chromeDevtoolsPath) : null, }, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1fea3aa9..545b61f19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,9 +170,6 @@ importers: apps/desktop: dependencies: - chrome-devtools-mcp: - specifier: 0.17.0 - version: 0.17.0 electron-updater: specifier: ^6.3.9 version: 6.8.3 @@ -4383,11 +4380,6 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} - chrome-devtools-mcp@0.17.0: - resolution: {integrity: sha512-vMi2zXq2ph2EG6amyyApcvuKJcEFj4cGK1XQVb6x8vQYHk8D9ZnSxdtFqD0cRnG7SbUOrg3GhjOZEJAD1dZWSQ==} - engines: {node: ^20.19.0 || ^22.12.0 || >=23} - hasBin: true - chromium-pickle-js@0.2.0: resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==} @@ -12512,8 +12504,6 @@ snapshots: chownr@3.0.0: {} - chrome-devtools-mcp@0.17.0: {} - chromium-pickle-js@0.2.0: {} ci-info@3.9.0: {} From 9296dc66ec0b7a7b37b254c235e5cf35b186c4a9 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 5 May 2026 19:33:50 -0700 Subject: [PATCH 3/6] feat(desktop): bundle chrome-devtools-mcp + embedded browser panel (fixed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-implements both features with fixes for the issues found in #1670/#1671: Fixes from PR1 (chrome-devtools-mcp bundling): - Use 'node' string literal instead of process.execPath in the IPC handler — process.execPath in Electron is the Electron binary, not node, so OpenCode couldn't spawn the command - Fix args filter to only carry --prefixed flags (not -y from npx) - Fix mcp.add fallback path to use resolved command from mcpEntryConfig instead of the original entry.command Fixes from PR2 (embedded browser panel): - Guard against zero-dimension bounds on first render — skip browser.show() until the panel has non-zero dimensions, let ResizeObserver trigger the actual show - Defensive try/catch in destroyBrowserView for already-destroyed webContents Verified: - resolveChromeDevtoolsMcpBin returns ['node', ''] (not ['Electron', '']) - All 17 existing tests pass - Electron app starts and loads successfully - CDP targets visible at the remote debugging port --- apps/app/src/app/lib/desktop.ts | 12 ++ apps/app/src/app/mcp.ts | 40 ++++- .../react-app/domains/connections/store.ts | 19 ++- .../domains/session/browser/browser-panel.tsx | 145 ++++++++++++++++++ .../domains/session/chat/session-page.tsx | 30 +++- apps/desktop/electron/main.mjs | 133 +++++++++++++++- apps/desktop/electron/preload.mjs | 16 ++ apps/desktop/package.json | 1 + .../scripts/chrome-devtools-mcp-shim.ts | 59 +++++-- apps/desktop/scripts/prepare-sidecar.mjs | 94 +----------- pnpm-lock.yaml | 10 ++ 11 files changed, 444 insertions(+), 115 deletions(-) create mode 100644 apps/app/src/react-app/domains/session/browser/browser-panel.tsx diff --git a/apps/app/src/app/lib/desktop.ts b/apps/app/src/app/lib/desktop.ts index dd4a2b954..18a0b676e 100644 --- a/apps/app/src/app/lib/desktop.ts +++ b/apps/app/src/app/lib/desktop.ts @@ -42,6 +42,18 @@ declare global { download?: () => Promise<{ ok: boolean; reason?: string }>; installAndRestart?: () => Promise<{ ok: boolean; reason?: string }>; }; + browser?: { + show?: (bounds: { x: number; y: number; width: number; height: number }) => Promise; + hide?: () => Promise; + navigate?: (url: string) => Promise; + back?: () => Promise; + forward?: () => Promise; + reload?: () => Promise; + setBounds?: (bounds: { x: number; y: number; width: number; height: number }) => Promise; + getState?: () => Promise<{ url: string; title: string; canGoBack: boolean; canGoForward: boolean; isLoading: boolean } | null>; + destroy?: () => Promise; + onStateChange?: (callback: (state: { url: string; title: string; canGoBack: boolean; canGoForward: boolean; isLoading: boolean }) => void) => () => void; + }; meta?: { initialDeepLinks?: string[]; platform?: "darwin" | "linux" | "windows"; diff --git a/apps/app/src/app/mcp.ts b/apps/app/src/app/mcp.ts index b96917d29..909306af7 100644 --- a/apps/app/src/app/mcp.ts +++ b/apps/app/src/app/mcp.ts @@ -2,11 +2,43 @@ import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser"; import type { McpServerConfig, McpServerEntry } from "./types"; import { readOpencodeConfig, writeOpencodeConfig } from "./lib/desktop"; import { CHROME_DEVTOOLS_MCP_COMMAND, CHROME_DEVTOOLS_MCP_ID } from "./constants"; +import { isElectronRuntime } from "./utils"; type McpConfigValue = Record | null | undefined; export const CHROME_DEVTOOLS_AUTO_CONNECT_ARG = "--autoConnect"; +/** + * Cached result of resolving the bundled chrome-devtools-mcp binary path + * from the Electron main process. `undefined` = not yet resolved. + */ +let _resolvedBundledCommand: string[] | null | undefined; + +/** + * Resolve the chrome-devtools-mcp command for the current runtime. + * + * In Electron, the package is bundled as a dependency of `@openwork/desktop`, + * so we ask the main process for the absolute path to the bin and use + * `["node", ""]` — no npm/npx required. + * + * Falls back to the npx-based command for web/remote contexts. + */ +export async function resolveChromeDevtoolsMcpCommand(): Promise { + if (isElectronRuntime() && _resolvedBundledCommand === undefined) { + try { + const resolved = await (window as Window).__OPENWORK_ELECTRON__!.invokeDesktop( + "resolveChromeDevtoolsMcpBin", + ); + _resolvedBundledCommand = Array.isArray(resolved) && resolved.length > 0 + ? (resolved as string[]) + : null; + } catch { + _resolvedBundledCommand = null; + } + } + return _resolvedBundledCommand ?? [...CHROME_DEVTOOLS_MCP_COMMAND]; +} + type McpIdentity = { id?: string; name: string; @@ -30,10 +62,14 @@ export function usesChromeDevtoolsAutoConnect(command?: string[]): boolean { return Array.isArray(command) && command.includes(CHROME_DEVTOOLS_AUTO_CONNECT_ARG); } -export function buildChromeDevtoolsCommand(command: string[] | undefined, useExistingProfile: boolean): string[] { +export function buildChromeDevtoolsCommand( + command: string[] | undefined, + useExistingProfile: boolean, + resolvedBase?: string[], +): string[] { const base = Array.isArray(command) && command.length ? command.filter((part) => part !== CHROME_DEVTOOLS_AUTO_CONNECT_ARG) - : [...CHROME_DEVTOOLS_MCP_COMMAND]; + : resolvedBase ?? [...CHROME_DEVTOOLS_MCP_COMMAND]; return useExistingProfile ? [...base, CHROME_DEVTOOLS_AUTO_CONNECT_ARG] : base; } diff --git a/apps/app/src/react-app/domains/connections/store.ts b/apps/app/src/react-app/domains/connections/store.ts index a82d6f367..c20e19d4d 100644 --- a/apps/app/src/react-app/domains/connections/store.ts +++ b/apps/app/src/react-app/domains/connections/store.ts @@ -20,6 +20,7 @@ import { toSessionTransportDirectory } from "../../../app/lib/session-scope"; import { parseMcpServersFromContent, removeMcpFromConfig, + resolveChromeDevtoolsMcpCommand, usesChromeDevtoolsAutoConnect, validateMcpServerName, } from "../../../app/mcp"; @@ -31,7 +32,7 @@ import type { ReloadReason, ReloadTrigger, } from "../../../app/types"; -import { isDesktopRuntime, normalizeDirectoryPath, safeStringify } from "../../../app/utils"; +import { isDesktopRuntime, isElectronRuntime, normalizeDirectoryPath, safeStringify } from "../../../app/utils"; import type { OpenworkServerStore } from "./openwork-server-store"; @@ -496,11 +497,21 @@ export function createConnectionsStore(options: { if (!entry.command?.length) { throw new Error("Missing MCP command."); } - mcpEntryConfig["command"] = entry.command; + + // For chrome-devtools in Electron, resolve the bundled binary so we + // don't need npx/npm at runtime. Only carry over -- prefixed flags + // from the original command (skip npx flags like -y). + let resolvedCommand = entry.command; + if (slug === CHROME_DEVTOOLS_MCP_ID && isElectronRuntime()) { + const bundled = await resolveChromeDevtoolsMcpCommand(); + const extraArgs = entry.command.filter((arg) => arg.startsWith("--")); + resolvedCommand = [...bundled, ...extraArgs]; + } + mcpEntryConfig["command"] = resolvedCommand; if ( slug === CHROME_DEVTOOLS_MCP_ID && - usesChromeDevtoolsAutoConnect(entry.command) && + usesChromeDevtoolsAutoConnect(resolvedCommand) && isDesktopRuntime() ) { try { @@ -576,7 +587,7 @@ export function createConnectionsStore(options: { } : { type: "local" as const, - command: entry.command!, + command: (mcpEntryConfig["command"] as string[]) ?? entry.command!, enabled: true, ...(mcpEnvironment ? { environment: mcpEnvironment } : {}), }; diff --git a/apps/app/src/react-app/domains/session/browser/browser-panel.tsx b/apps/app/src/react-app/domains/session/browser/browser-panel.tsx new file mode 100644 index 000000000..9d58a11c6 --- /dev/null +++ b/apps/app/src/react-app/domains/session/browser/browser-panel.tsx @@ -0,0 +1,145 @@ +/** @jsxImportSource react */ +import { useCallback, useEffect, useRef, useState } from "react"; +import { ArrowLeft, ArrowRight, Globe, Loader2, RotateCw, X } from "lucide-react"; +import { isElectronRuntime } from "../../../../app/utils"; + +type BrowserState = { + url: string; + title: string; + canGoBack: boolean; + canGoForward: boolean; + isLoading: boolean; +}; + +type BrowserPanelProps = { onClose: () => void }; + +const EMPTY_STATE: BrowserState = { url: "", title: "", canGoBack: false, canGoForward: false, isLoading: false }; +const TOOLBAR_HEIGHT = 44; + +function getElectronBrowser() { + if (!isElectronRuntime()) return null; + return (window as Window).__OPENWORK_ELECTRON__?.browser ?? null; +} + +function computeBounds(el: HTMLElement) { + const rect = el.getBoundingClientRect(); + return { + x: Math.round(rect.x), + y: Math.round(rect.y + TOOLBAR_HEIGHT), + width: Math.round(rect.width), + height: Math.round(rect.height - TOOLBAR_HEIGHT), + }; +} + +export function BrowserPanel({ onClose }: BrowserPanelProps) { + const [state, setState] = useState(EMPTY_STATE); + const [urlInput, setUrlInput] = useState(""); + const [urlFocused, setUrlFocused] = useState(false); + const panelRef = useRef(null); + const urlInputRef = useRef(null); + const shownRef = useRef(false); + + // Subscribe to state changes from the main process + useEffect(() => { + const browser = getElectronBrowser(); + if (!browser) return; + const unsub = browser.onStateChange?.((s: BrowserState) => { + setState(s); + if (!urlFocused) setUrlInput(s.url); + }); + browser.getState?.().then((s: BrowserState | null) => { + if (s) { setState(s); setUrlInput(s.url); } + }); + return unsub; + }, [urlFocused]); + + // Show the browser view when the panel mounts, keep bounds in sync, hide on unmount. + useEffect(() => { + const browser = getElectronBrowser(); + if (!browser || !panelRef.current) return; + + const tryShow = () => { + if (!panelRef.current) return; + const bounds = computeBounds(panelRef.current); + if (bounds.width < 1 || bounds.height < 1) return; // not laid out yet + if (!shownRef.current) { + browser.show?.(bounds); + shownRef.current = true; + } else { + browser.setBounds?.(bounds); + } + }; + + // Initial show (may be zero-dimension if layout hasn't settled) + tryShow(); + + const observer = new ResizeObserver(tryShow); + observer.observe(panelRef.current); + window.addEventListener("resize", tryShow); + + return () => { + observer.disconnect(); + window.removeEventListener("resize", tryShow); + browser.hide?.(); + shownRef.current = false; + }; + }, []); + + const navigate = useCallback((url?: string) => { + getElectronBrowser()?.navigate?.(url ?? urlInput); + }, [urlInput]); + + const handleUrlKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + navigate(); + urlInputRef.current?.blur(); + } + }, [navigate]); + + const browser = getElectronBrowser(); + if (!isElectronRuntime() || !browser) { + return ( +
+

Browser panel is only available in the desktop app.

+
+ ); + } + + return ( +
+
+ + + +
+ + setUrlInput(e.target.value)} + onKeyDown={handleUrlKeyDown} + onFocus={() => { setUrlFocused(true); urlInputRef.current?.select(); }} + onBlur={() => setUrlFocused(false)} + placeholder="Enter URL..." + spellCheck={false} + autoComplete="off" + /> +
+ +
+ {/* WebContentsView renders in this area (managed by Electron main process) */} +
+
+ ); +} diff --git a/apps/app/src/react-app/domains/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx index 47f23926d..744c65d62 100644 --- a/apps/app/src/react-app/domains/session/chat/session-page.tsx +++ b/apps/app/src/react-app/domains/session/chat/session-page.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource react */ -import { useEffect, useMemo, useState } from "react"; -import { Check, Loader2, Minimize2, Redo2, Undo2, Zap } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Check, Globe, Loader2, Minimize2, Redo2, Undo2, Zap } from "lucide-react"; import { t } from "../../../../i18n"; import { buildOpenworkWorkspaceBaseUrl, type OpenworkServerClient, type OpenworkServerStatus } from "../../../../app/lib/openwork-server"; @@ -32,6 +32,8 @@ import { } from "../../../shell/workspace-shell-layout"; import { OwDotTicker } from "../../../shell/dot-ticker"; import { useReactRenderWatchdog } from "../../../shell/react-render-watchdog"; +import { isElectronRuntime } from "../../../../app/utils"; +import { BrowserPanel } from "../browser/browser-panel"; type StatusBarOverrides = Pick< StatusBarProps, @@ -173,6 +175,8 @@ export function SessionPage(props: SessionPageProps) { const [deleteOpen, setDeleteOpen] = useState(false); const [deleteBusy, setDeleteBusy] = useState(false); const [todoExpanded, setTodoExpanded] = useState(true); + const [browserPanelOpen, setBrowserPanelOpen] = useState(false); + const toggleBrowserPanel = useCallback(() => setBrowserPanelOpen((p) => !p), []); const [showDelayedSessionLoadingState, setShowDelayedSessionLoadingState] = useState(false); const selectedSessionTitle = useMemo( @@ -340,6 +344,19 @@ export function SessionPage(props: SessionPageProps) {
+ {isElectronRuntime() ? ( + + ) : null} {props.history ? ( <>
{props.providerAuthModal ? : null} diff --git a/apps/desktop/electron/main.mjs b/apps/desktop/electron/main.mjs index 8fb30b803..be90f7286 100644 --- a/apps/desktop/electron/main.mjs +++ b/apps/desktop/electron/main.mjs @@ -12,11 +12,12 @@ import { stat, writeFile, } from "node:fs/promises"; +import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { app, BrowserWindow, dialog, ipcMain, nativeImage, shell } from "electron"; +import { app, BrowserWindow, WebContentsView, dialog, ipcMain, nativeImage, shell } from "electron"; import { registerMigrationIpc } from "./migration.mjs"; import { createRuntimeManager } from "./runtime.mjs"; import { registerUpdaterIpc } from "./updater.mjs"; @@ -185,6 +186,100 @@ let uiControlServer = null; let uiControlDiscoveryPath = null; const uiControlToken = randomBytes(32).toString("hex"); +// ── Embedded browser panel ───────────────────────────────────────────── +let browserView = null; +let browserViewVisible = false; +const BROWSER_DEFAULT_URL = "https://www.google.com"; + +function createBrowserView() { + if (browserView) return browserView; + browserView = new WebContentsView({ + webPreferences: { + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + partition: "persist:openwork-browser", + }, + }); + browserView.webContents.setWindowOpenHandler(({ url }) => { + void shell.openExternal(url); + return { action: "deny" }; + }); + browserView.webContents.on("did-navigate", () => sendBrowserState()); + browserView.webContents.on("did-navigate-in-page", () => sendBrowserState()); + browserView.webContents.on("page-title-updated", () => sendBrowserState()); + browserView.webContents.on("did-start-loading", () => sendBrowserState()); + browserView.webContents.on("did-stop-loading", () => sendBrowserState()); + return browserView; +} + +function sendBrowserState() { + if (!mainWindow || !browserView) return; + try { + mainWindow.webContents.send("openwork:browser:state", { + url: browserView.webContents.getURL(), + title: browserView.webContents.getTitle(), + canGoBack: browserView.webContents.canGoBack(), + canGoForward: browserView.webContents.canGoForward(), + isLoading: browserView.webContents.isLoading(), + }); + } catch { + // window may be closing + } +} + +function showBrowserView(bounds) { + if (!mainWindow) return; + const view = createBrowserView(); + if (!mainWindow.contentView.children.includes(view)) { + mainWindow.contentView.addChildView(view); + } + if (bounds.width > 0 && bounds.height > 0) { + view.setBounds(bounds); + } + browserViewVisible = true; + if (!view.webContents.getURL()) { + view.webContents.loadURL(BROWSER_DEFAULT_URL); + } + sendBrowserState(); +} + +function hideBrowserView() { + if (!mainWindow || !browserView) return; + try { + mainWindow.contentView.removeChildView(browserView); + } catch { + // already removed + } + browserViewVisible = false; +} + +function destroyBrowserView() { + hideBrowserView(); + if (browserView) { + try { browserView.webContents.close(); } catch { /* already destroyed */ } + browserView = null; + } +} + +// ── Resolve bundled chrome-devtools-mcp ──────────────────────────────── +// Returns ["node", ""] or null. +// Uses "node" (not process.execPath) because OpenCode spawns the command +// and process.execPath in Electron is the Electron binary, not node. +function resolveChromeDevtoolsMcpBin() { + try { + const require_ = createRequire(import.meta.url); + const pkgJsonPath = require_.resolve("chrome-devtools-mcp/package.json"); + const binPath = path.join(path.dirname(pkgJsonPath), "build", "src", "index.js"); + if (existsSync(binPath)) { + return ["node", binPath]; + } + } catch { + // package not found + } + return null; +} + function normalizePlatform(value) { if (value === "darwin" || value === "linux") return value; if (value === "win32") return "windows"; @@ -1330,6 +1425,8 @@ async function handleDesktopInvoke(event, command, ...args) { window.webContents.setZoomFactor(factor); return true; } + case "resolveChromeDevtoolsMcpBin": + return resolveChromeDevtoolsMcpBin(); default: throw new Error(`Electron desktop bridge method is not implemented yet: ${command}`); } @@ -1511,6 +1608,7 @@ async function createMainWindow() { }); mainWindow.on("closed", () => { + destroyBrowserView(); mainWindow = null; }); @@ -1549,6 +1647,39 @@ ipcMain.handle("openwork:shell:relaunch", async () => { app.exit(0); }); +// ── Embedded browser IPC ──────────────────────────────────────────────── +ipcMain.handle("openwork:browser:show", (_event, bounds) => showBrowserView(bounds)); +ipcMain.handle("openwork:browser:hide", () => hideBrowserView()); +ipcMain.handle("openwork:browser:navigate", (_event, url) => { + if (!browserView) return; + const target = typeof url === "string" && url.trim() ? url.trim() : BROWSER_DEFAULT_URL; + const finalUrl = /^https?:\/\//i.test(target) ? target : `https://${target}`; + browserView.webContents.loadURL(finalUrl); +}); +ipcMain.handle("openwork:browser:back", () => { + if (browserView?.webContents.canGoBack()) browserView.webContents.goBack(); +}); +ipcMain.handle("openwork:browser:forward", () => { + if (browserView?.webContents.canGoForward()) browserView.webContents.goForward(); +}); +ipcMain.handle("openwork:browser:reload", () => browserView?.webContents.reload()); +ipcMain.handle("openwork:browser:bounds", (_event, bounds) => { + if (browserView && browserViewVisible && bounds.width > 0 && bounds.height > 0) { + browserView.setBounds(bounds); + } +}); +ipcMain.handle("openwork:browser:state", () => { + if (!browserView) return null; + return { + url: browserView.webContents.getURL(), + title: browserView.webContents.getTitle(), + canGoBack: browserView.webContents.canGoBack(), + canGoForward: browserView.webContents.canGoForward(), + isLoading: browserView.webContents.isLoading(), + }; +}); +ipcMain.handle("openwork:browser:destroy", () => destroyBrowserView()); + registerMigrationIpc({ app, ipcMain }); const { ensureAutoUpdater } = registerUpdaterIpc({ app, ipcMain, getMainWindow: () => mainWindow }); diff --git a/apps/desktop/electron/preload.mjs b/apps/desktop/electron/preload.mjs index 8591b2f39..aa47efd27 100644 --- a/apps/desktop/electron/preload.mjs +++ b/apps/desktop/electron/preload.mjs @@ -53,6 +53,22 @@ contextBridge.exposeInMainWorld("__OPENWORK_ELECTRON__", { }; }, }, + browser: { + show(bounds) { return ipcRenderer.invoke("openwork:browser:show", bounds); }, + hide() { return ipcRenderer.invoke("openwork:browser:hide"); }, + navigate(url) { return ipcRenderer.invoke("openwork:browser:navigate", url); }, + back() { return ipcRenderer.invoke("openwork:browser:back"); }, + forward() { return ipcRenderer.invoke("openwork:browser:forward"); }, + reload() { return ipcRenderer.invoke("openwork:browser:reload"); }, + setBounds(bounds) { return ipcRenderer.invoke("openwork:browser:bounds", bounds); }, + getState() { return ipcRenderer.invoke("openwork:browser:state"); }, + destroy() { return ipcRenderer.invoke("openwork:browser:destroy"); }, + onStateChange(callback) { + const handler = (_event, state) => callback(state); + ipcRenderer.on("openwork:browser:state", handler); + return () => ipcRenderer.removeListener("openwork:browser:state", handler); + }, + }, meta: { initialDeepLinks: [], platform: normalizePlatform(process.platform), diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 22e3d85c8..5c0e5810b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -25,6 +25,7 @@ "prepare:sidecar": "node ./scripts/prepare-sidecar.mjs" }, "dependencies": { + "chrome-devtools-mcp": "0.17.0", "electron-updater": "^6.3.9" }, "devDependencies": { diff --git a/apps/desktop/scripts/chrome-devtools-mcp-shim.ts b/apps/desktop/scripts/chrome-devtools-mcp-shim.ts index e45354d20..5ec884ce2 100644 --- a/apps/desktop/scripts/chrome-devtools-mcp-shim.ts +++ b/apps/desktop/scripts/chrome-devtools-mcp-shim.ts @@ -1,38 +1,63 @@ #!/usr/bin/env node +/** + * Chrome DevTools MCP shim — resolves the bundled `chrome-devtools-mcp` + * dependency and runs it directly via Node, eliminating the runtime + * dependency on npm/npx. + * + * Fallback: if the bundled package cannot be found (e.g. standalone + * sidecar without node_modules), falls back to `npm exec` like before. + */ import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; const packageSpec = process.env.OPENWORK_CHROME_DEVTOOLS_MCP_SPEC?.trim() || process.env.CHROME_DEVTOOLS_MCP_SPEC?.trim() || "chrome-devtools-mcp@0.17.0"; -const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; -const args = ["exec", "--yes", packageSpec, "--", ...process.argv.slice(2)]; +function resolveBundledBin(): string | null { + try { + const require_ = createRequire(import.meta.url); + const pkgJsonPath = require_.resolve("chrome-devtools-mcp/package.json"); + const binPath = join(dirname(pkgJsonPath), "build", "src", "index.js"); + if (existsSync(binPath)) return binPath; + } catch { /* not found */ } + return null; +} -const child = spawn(npmCommand, args, { - stdio: "inherit", - env: { - ...process.env, - npm_config_yes: "true", - }, -}); +const bundledBin = resolveBundledBin(); + +let child: ReturnType; -child.on("error", (error) => { +if (bundledBin) { + child = spawn(process.execPath, [bundledBin, ...process.argv.slice(2)], { + stdio: "inherit", + env: process.env, + }); +} else { + const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + const args = ["exec", "--yes", packageSpec, "--", ...process.argv.slice(2)]; + child = spawn(npmCommand, args, { + stdio: "inherit", + env: { ...process.env, npm_config_yes: "true" }, + }); +} + +child.on("error", (error: Error) => { const message = error instanceof Error ? error.message : String(error); if (message.includes("ENOENT")) { console.error( - "Control Chrome requires npm (Node.js). Install Node.js or configure mcp.chrome-devtools.command to a local chrome-devtools-mcp binary." + "Control Chrome requires Node.js. Install Node.js or configure mcp.chrome-devtools.command to a local chrome-devtools-mcp binary." ); } else { - console.error(`Failed to start chrome-devtools-mcp via npm exec: ${message}`); + console.error(`Failed to start chrome-devtools-mcp: ${message}`); } process.exit(1); }); -child.on("exit", (code, signal) => { - if (signal) { - process.kill(process.pid, signal); - return; - } +child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => { + if (signal) { process.kill(process.pid, signal); return; } process.exit(code ?? 1); }); diff --git a/apps/desktop/scripts/prepare-sidecar.mjs b/apps/desktop/scripts/prepare-sidecar.mjs index c492d6954..edf4dea7b 100644 --- a/apps/desktop/scripts/prepare-sidecar.mjs +++ b/apps/desktop/scripts/prepare-sidecar.mjs @@ -162,20 +162,12 @@ const orchestratorTargetName = orchestratorTargetTriple const orchestratorTargetPath = orchestratorTargetName ? join(sidecarDir, orchestratorTargetName) : null; const orchestratorDir = resolve(__dirname, "..", "..", "orchestrator"); -// chrome-devtools-mcp shim sidecar +// chrome-devtools-mcp: now bundled as a node_modules dependency of +// @openwork/desktop (Electron resolves it directly). The Bun-compiled shim +// sidecar is no longer built. const chromeDevtoolsBaseName = "chrome-devtools-mcp"; const chromeDevtoolsName = isWindowsTarget ? `${chromeDevtoolsBaseName}.exe` : chromeDevtoolsBaseName; const chromeDevtoolsPath = join(sidecarDir, chromeDevtoolsName); -const chromeDevtoolsBuildName = bunTarget - ? `${chromeDevtoolsBaseName}-${bunTarget}${bunTarget.includes("windows") ? ".exe" : ""}` - : chromeDevtoolsName; -const chromeDevtoolsBuildPath = join(sidecarDir, chromeDevtoolsBuildName); -const chromeDevtoolsTargetTriple = resolvedTargetTriple; -const chromeDevtoolsTargetName = chromeDevtoolsTargetTriple - ? `${chromeDevtoolsBaseName}-${chromeDevtoolsTargetTriple}${chromeDevtoolsTargetTriple.includes("windows") ? ".exe" : ""}` - : null; -const chromeDevtoolsTargetPath = chromeDevtoolsTargetName ? join(sidecarDir, chromeDevtoolsTargetName) : null; -const chromeDevtoolsShimPath = resolve(__dirname, "chrome-devtools-mcp-shim.ts"); const readHeader = (filePath, length = 256) => { const fd = openSync(filePath, "r"); @@ -560,80 +552,7 @@ if (existsSync(orchestratorBuildPath)) { } } -// Build chrome-devtools-mcp shim sidecar -let didBuildChromeDevtools = false; -const shouldBuildChromeDevtools = - forceBuild || !existsSync(chromeDevtoolsBuildPath) || isStubBinary(chromeDevtoolsBuildPath); -if (shouldBuildChromeDevtools) { - mkdirSync(sidecarDir, { recursive: true }); - if (existsSync(chromeDevtoolsBuildPath)) { - try { - unlinkSync(chromeDevtoolsBuildPath); - } catch { - // ignore - } - } - - if (!existsSync(chromeDevtoolsShimPath)) { - console.error(`Chrome DevTools MCP shim source not found at ${chromeDevtoolsShimPath}`); - process.exit(1); - } - - const chromeDevtoolsArgs = [ - "build", - "--compile", - chromeDevtoolsShimPath, - "--outfile", - chromeDevtoolsBuildPath, - ]; - if (bunTarget) { - chromeDevtoolsArgs.push("--target", bunTarget); - } - - const result = spawnSync("bun", chromeDevtoolsArgs, { - cwd: __dirname, - stdio: "inherit", - shell: true, - env: { - ...process.env, - NODE_ENV: "production", - BUN_ENV: "production", - }, - }); - if (result.status !== 0) { - process.exit(result.status ?? 1); - } - - didBuildChromeDevtools = true; -} - -if (existsSync(chromeDevtoolsBuildPath)) { - const shouldCopyCanonical = - didBuildChromeDevtools || !existsSync(chromeDevtoolsPath) || isStubBinary(chromeDevtoolsPath); - if (shouldCopyCanonical && chromeDevtoolsBuildPath !== chromeDevtoolsPath) { - try { - if (existsSync(chromeDevtoolsPath)) unlinkSync(chromeDevtoolsPath); - } catch { - // ignore - } - copyFileSync(chromeDevtoolsBuildPath, chromeDevtoolsPath); - } - - if (chromeDevtoolsTargetPath) { - const shouldCopyTarget = - didBuildChromeDevtools || - !existsSync(chromeDevtoolsTargetPath) || - isStubBinary(chromeDevtoolsTargetPath); - if (shouldCopyTarget && chromeDevtoolsBuildPath !== chromeDevtoolsTargetPath) { - try { - if (existsSync(chromeDevtoolsTargetPath)) unlinkSync(chromeDevtoolsTargetPath); - } catch { - // ignore - } - copyFileSync(chromeDevtoolsBuildPath, chromeDevtoolsTargetPath); - } - } -} +// chrome-devtools-mcp is now a node_modules dependency — no sidecar build needed. adHocSignDarwinSidecars([ opencodePath, @@ -644,9 +563,6 @@ adHocSignDarwinSidecars([ orchestratorBuildPath, orchestratorPath, orchestratorTargetPath, - chromeDevtoolsBuildPath, - chromeDevtoolsPath, - chromeDevtoolsTargetPath, ]); const openworkServerVersion = (() => { @@ -682,7 +598,7 @@ const versions = { }, "chrome-devtools-mcp": { version: chromeDevtoolsMcpVersion, - sha256: existsSync(chromeDevtoolsPath) ? sha256File(chromeDevtoolsPath) : null, + sha256: "bundled", }, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 545b61f19..f1fea3aa9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,6 +170,9 @@ importers: apps/desktop: dependencies: + chrome-devtools-mcp: + specifier: 0.17.0 + version: 0.17.0 electron-updater: specifier: ^6.3.9 version: 6.8.3 @@ -4380,6 +4383,11 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + chrome-devtools-mcp@0.17.0: + resolution: {integrity: sha512-vMi2zXq2ph2EG6amyyApcvuKJcEFj4cGK1XQVb6x8vQYHk8D9ZnSxdtFqD0cRnG7SbUOrg3GhjOZEJAD1dZWSQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + hasBin: true + chromium-pickle-js@0.2.0: resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==} @@ -12504,6 +12512,8 @@ snapshots: chownr@3.0.0: {} + chrome-devtools-mcp@0.17.0: {} + chromium-pickle-js@0.2.0: {} ci-info@3.9.0: {} From 0c77b5b4afb985d468345106947facaccfc0ffb1 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 5 May 2026 21:11:47 -0700 Subject: [PATCH 4/6] feat(desktop): auto-inject chrome-devtools MCP into opencode.json on engine start When the OpenCode engine starts in the Electron desktop app, ensureOpencodeConfig now automatically adds a chrome-devtools MCP entry to opencode.json if one doesn't already exist. The command uses the bundled chrome-devtools-mcp package (['node', '']) so no npm/npx is needed at runtime. This mirrors what Tauri's ensure_workspace_files() does in files.rs for the starter preset, making Control Chrome a zero-setup default for all Electron workspaces. --- apps/desktop/electron/runtime.mjs | 61 +++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/apps/desktop/electron/runtime.mjs b/apps/desktop/electron/runtime.mjs index cb3e92f56..b4900a73d 100644 --- a/apps/desktop/electron/runtime.mjs +++ b/apps/desktop/electron/runtime.mjs @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { spawn, spawnSync } from "node:child_process"; import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; import net from "node:net"; import os from "node:os"; import path from "node:path"; @@ -935,16 +936,62 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths } } + /** + * Resolve the bundled chrome-devtools-mcp binary for the MCP command. + * Returns `["node", ""]` or falls back to the npx command. + */ + function chromeDevtoolsMcpCommand() { + try { + const require_ = createRequire(import.meta.url); + const pkgJsonPath = require_.resolve("chrome-devtools-mcp/package.json"); + const binPath = path.join(path.dirname(pkgJsonPath), "build", "src", "index.js"); + if (existsSync(binPath)) return ["node", binPath]; + } catch { /* not found */ } + return ["npx", "-y", "chrome-devtools-mcp@latest"]; + } + async function ensureOpencodeConfig(projectDir) { const jsoncPath = path.join(projectDir, "opencode.jsonc"); const jsonPath = path.join(projectDir, "opencode.json"); - if ((await fileExists(jsoncPath)) || (await fileExists(jsonPath))) return; - await mkdir(projectDir, { recursive: true }); - await writeFile( - jsoncPath, - `${JSON.stringify({ $schema: "https://opencode.ai/config.json" }, null, 2)}\n`, - "utf8", - ); + const configPath = (await fileExists(jsoncPath)) + ? jsoncPath + : (await fileExists(jsonPath)) + ? jsonPath + : null; + + let config; + if (configPath) { + try { + config = JSON.parse(await readFile(configPath, "utf8")); + } catch { + return; // malformed — don't touch it + } + } else { + config = { $schema: "https://opencode.ai/config.json" }; + } + + // Auto-inject chrome-devtools MCP if not already present + let changed = !configPath; // new file = always write + if (!config.mcp || typeof config.mcp !== "object") { + config.mcp = {}; + } + if (!config.mcp["chrome-devtools"]) { + config.mcp["chrome-devtools"] = { + type: "local", + command: chromeDevtoolsMcpCommand(), + }; + changed = true; + } + + if (changed) { + const targetPath = configPath || jsoncPath; + await mkdir(projectDir, { recursive: true }); + await writeFile( + targetPath, + `${JSON.stringify(config, null, 2)}\n`, + "utf8", + ); + } } function generateManagedCredentials() { From 1e2d90a84cb1729a785b3c5502dbd693731a63cb Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 5 May 2026 21:31:55 -0700 Subject: [PATCH 5/6] fix(desktop): seed chrome-devtools MCP at workspace creation time ensureOpencodeConfig in runtime.mjs only runs when the OpenCode engine starts, which happens asynchronously after the workspace is selected. New workspaces were left without a chrome-devtools entry because the engine hadn't started yet. Fix: call seedChromeDevtoolsMcp(folderPath) directly inside the workspaceCreate IPC handler so the config is written synchronously during workspace creation. The runtime.mjs ensureOpencodeConfig remains as a backup for workspaces created before this change. Verified: created a workspace via IPC, confirmed opencode.jsonc contains chrome-devtools with ['node', '']. --- apps/desktop/electron/main.mjs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/apps/desktop/electron/main.mjs b/apps/desktop/electron/main.mjs index be90f7286..aba210f23 100644 --- a/apps/desktop/electron/main.mjs +++ b/apps/desktop/electron/main.mjs @@ -280,6 +280,36 @@ function resolveChromeDevtoolsMcpBin() { return null; } +/** + * Seed chrome-devtools MCP into a workspace's opencode config so Control + * Chrome works out of the box. Reads the existing config (jsonc or json), + * injects `mcp.chrome-devtools` if missing, and writes back. + */ +async function seedChromeDevtoolsMcp(workspaceDir) { + const jsoncPath = path.join(workspaceDir, "opencode.jsonc"); + const jsonPath = path.join(workspaceDir, "opencode.json"); + const configPath = existsSync(jsoncPath) ? jsoncPath : existsSync(jsonPath) ? jsonPath : null; + + let config; + if (configPath) { + try { config = JSON.parse(await readFile(configPath, "utf8")); } catch { return; } + } else { + config = { $schema: "https://opencode.ai/config.json" }; + } + + if (!config.mcp || typeof config.mcp !== "object") config.mcp = {}; + if (config.mcp["chrome-devtools"]) return; // already present + + const resolved = resolveChromeDevtoolsMcpBin(); + config.mcp["chrome-devtools"] = { + type: "local", + command: resolved ?? ["npx", "-y", "chrome-devtools-mcp@latest"], + }; + + const targetPath = configPath || jsoncPath; + await writeFile(targetPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); +} + function normalizePlatform(value) { if (value === "darwin" || value === "linux") return value; if (value === "win32") return "windows"; @@ -1021,6 +1051,10 @@ async function handleDesktopInvoke(event, command, ...args) { }); await mkdir(path.join(folderPath, ".opencode"), { recursive: true }); await writeWorkspaceOpenworkConfig(folderPath, defaultWorkspaceOpenworkConfig(folderPath, preset)); + + // Seed opencode.json with chrome-devtools MCP so Control Chrome works + // out of the box — no manual "Add connector" step needed. + await seedChromeDevtoolsMcp(folderPath); return mutateWorkspaceState((state) => { const workspacePathKey = normalizeWorkspacePathKey(workspace.path); state.workspaces = state.workspaces.filter( From 46e610306206ec566279d0386490c80f74ce15af Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 5 May 2026 21:45:19 -0700 Subject: [PATCH 6/6] fix(desktop): migrate legacy npx/bare chrome-devtools commands to bundled node path Existing workspaces created by Tauri or older Electron versions have chrome-devtools MCP entries with npx-based or bare-binary commands like ['npx','-y','chrome-devtools-mcp@latest'] or ['chrome-devtools-mcp']. Both seedChromeDevtoolsMcp (main.mjs, runs at workspace creation) and ensureOpencodeConfig (runtime.mjs, runs at engine start) now detect these legacy commands and migrate them to the bundled ['node', path] command. User-customised commands (absolute paths, etc.) are preserved. --- apps/desktop/electron/main.mjs | 27 +++++++++++++++++++++++++-- apps/desktop/electron/runtime.mjs | 18 +++++++++++++++--- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/desktop/electron/main.mjs b/apps/desktop/electron/main.mjs index aba210f23..cde9f8792 100644 --- a/apps/desktop/electron/main.mjs +++ b/apps/desktop/electron/main.mjs @@ -285,6 +285,17 @@ function resolveChromeDevtoolsMcpBin() { * Chrome works out of the box. Reads the existing config (jsonc or json), * injects `mcp.chrome-devtools` if missing, and writes back. */ +/** + * Returns true when the command looks like a generated npx/npm fallback + * or a bare binary name — i.e. not a user-customised command. + */ +function isLegacyChromeDevtoolsCommand(cmd) { + if (!Array.isArray(cmd) || cmd.length === 0) return true; + const first = String(cmd[0]); + // npx / npm exec path, or bare binary name without an absolute path + return first === "npx" || first === "npm" || first === "chrome-devtools-mcp" || first === "npm.cmd"; +} + async function seedChromeDevtoolsMcp(workspaceDir) { const jsoncPath = path.join(workspaceDir, "opencode.jsonc"); const jsonPath = path.join(workspaceDir, "opencode.json"); @@ -298,12 +309,24 @@ async function seedChromeDevtoolsMcp(workspaceDir) { } if (!config.mcp || typeof config.mcp !== "object") config.mcp = {}; - if (config.mcp["chrome-devtools"]) return; // already present const resolved = resolveChromeDevtoolsMcpBin(); + const bundledCommand = resolved ?? ["npx", "-y", "chrome-devtools-mcp@latest"]; + + // Inject if missing, or migrate if the existing entry uses a legacy + // npx / bare-binary command (not a user-customised path). + const existing = config.mcp["chrome-devtools"]; + const needsInject = !existing; + const needsMigrate = existing && resolved && isLegacyChromeDevtoolsCommand(existing.command); + + if (!needsInject && !needsMigrate) return; + config.mcp["chrome-devtools"] = { type: "local", - command: resolved ?? ["npx", "-y", "chrome-devtools-mcp@latest"], + command: bundledCommand, + // Preserve extra fields (environment, enabled, etc.) + ...(existing && typeof existing === "object" ? existing : {}), + command: bundledCommand, }; const targetPath = configPath || jsoncPath; diff --git a/apps/desktop/electron/runtime.mjs b/apps/desktop/electron/runtime.mjs index b4900a73d..85655b3c9 100644 --- a/apps/desktop/electron/runtime.mjs +++ b/apps/desktop/electron/runtime.mjs @@ -970,15 +970,27 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths config = { $schema: "https://opencode.ai/config.json" }; } - // Auto-inject chrome-devtools MCP if not already present + // Auto-inject or migrate chrome-devtools MCP. let changed = !configPath; // new file = always write if (!config.mcp || typeof config.mcp !== "object") { config.mcp = {}; } - if (!config.mcp["chrome-devtools"]) { + + const bundledCmd = chromeDevtoolsMcpCommand(); + const existing = config.mcp["chrome-devtools"]; + const isLegacy = (cmd) => { + if (!Array.isArray(cmd) || cmd.length === 0) return true; + const f = String(cmd[0]); + return f === "npx" || f === "npm" || f === "chrome-devtools-mcp" || f === "npm.cmd"; + }; + const needsInject = !existing; + const needsMigrate = existing && bundledCmd[0] === "node" && isLegacy(existing.command); + + if (needsInject || needsMigrate) { config.mcp["chrome-devtools"] = { + ...(existing && typeof existing === "object" ? existing : {}), type: "local", - command: chromeDevtoolsMcpCommand(), + command: bundledCmd, }; changed = true; }