// Embedded browser panel: tab state, BrowserView lifecycle, menu overlay, // proxy configuration, and browser IPC registrations. Extracted from // main.mjs as a factory so the main process only owns window creation. import path from "node:path"; import { fileURLToPath } from "node:url"; import { app, WebContentsView, clipboard, session, shell } from "electron"; import { runDetachedTask } from "./process-resilience.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const BROWSER_SESSION_PARTITION = "persist:openwork-browser"; const BROWSER_DEFAULT_URL = "about:blank"; // URL a user-initiated new tab (the "+" button / opening the browser panel) // lands on. The agent's programmatic path keeps BROWSER_DEFAULT_URL. const BROWSER_NEW_TAB_URL = "https://www.google.com"; const BROWSER_TARGET_RESOLVE_TIMEOUT_MS = 2500; const BROWSER_TARGET_RESOLVE_INTERVAL_MS = 80; const MENU_OVERLAY_HTML = "overlay.html"; const MENU_OVERLAY_WIDTH = 196; const MENU_OVERLAY_HEIGHT = 176; const MENU_OVERLAY_READY_TIMEOUT_MS = 2000; export function createBrowserPanel({ getWindow, remoteDebugPort, onDeepLink }) { const browserTabs = new Map(); let browserTabOrder = []; let activeBrowserTabId = null; let browserViewVisible = false; // Last browser panel bounds reported by the renderer, in renderer CSS pixels. // Converted to window device-independent pixels at every setBounds call. let lastBrowserBounds = null; let browserTabCounter = 0; // Active proxy for the built-in browser session: { rules, username, password }. let browserProxy = null; let menuOverlayView = null; let menuOverlayRequest = null; let menuOverlayReady = false; let menuOverlayReadyResolvers = []; let menuOverlayShowSerial = 0; function window() { return getWindow?.() ?? null; } function resetMenuOverlayReady({ resolvePending = false } = {}) { menuOverlayReady = false; if (resolvePending) { const resolvers = menuOverlayReadyResolvers.splice(0); for (const resolve of resolvers) resolve(false); } } function markMenuOverlayReady(view) { if (!view || view.webContents.isDestroyed()) return; menuOverlayReady = true; const resolvers = menuOverlayReadyResolvers.splice(0); for (const resolve of resolvers) resolve(true); } function waitForMenuOverlayReady(view) { if (menuOverlayReady) return Promise.resolve(true); return new Promise((resolve) => { let timer = null; const done = (ready) => { if (timer) clearTimeout(timer); menuOverlayReadyResolvers = menuOverlayReadyResolvers.filter((candidate) => candidate !== done); resolve(ready); }; timer = setTimeout(() => done(false), MENU_OVERLAY_READY_TIMEOUT_MS); menuOverlayReadyResolvers.push(done); if (!view || view.webContents.isDestroyed()) done(false); }); } /** Send an IPC message to the main renderer, guarding against disposed frames. */ function sendToRenderer(channel, payload) { const mainWindow = window(); if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed()) return; try { mainWindow.webContents.send(channel, payload); } catch { /* window closing */ } } function createBrowserTabId() { browserTabCounter += 1; return `tab_${Date.now().toString(36)}_${browserTabCounter.toString(36)}`; } function normalizeBrowserUrl(url, fallback = BROWSER_DEFAULT_URL) { const target = typeof url === "string" && url.trim() ? url.trim() : fallback; if (!target || target === "about:blank") return "about:blank"; return /^https?:\/\//i.test(target) ? target : `https://${target}`; } function isMainWindowAllowedNavigation(url) { if (!url) return true; if (url.startsWith("file://") || url.startsWith("data:")) return true; try { const target = new URL(url); if (target.hostname === "127.0.0.1" || target.hostname === "localhost" || target.hostname === "[::1]") return true; const currentUrl = window()?.webContents.getURL(); if (!currentUrl || currentUrl === "about:blank") return true; const current = new URL(currentUrl); return target.origin === current.origin; } catch { return true; } } function routeBlockedMainWindowNavigation(url) { if (!/^https?:\/\//i.test(String(url ?? ""))) return; void openBrowserUrlForAutomation(url).catch((error) => { console.warn("[browser] failed to route blocked main-window navigation", error); }); } function cdpBrowserUrl() { return `http://127.0.0.1:${remoteDebugPort}`; } function browserTargetMarkerUrl(tabId) { const marker = `openwork-browser-tab:${tabId}`; const html = `${marker}${marker}`; return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; } async function listCdpTargets() { if (!remoteDebugPort || remoteDebugPort <= 0) return []; // loopback-fetch: CDP discovery targets Electron's local remote debugging port on 127.0.0.1. const response = await fetch(`${cdpBrowserUrl()}/json/list`, { signal: AbortSignal.timeout(1000) }); if (!response.ok) throw new Error(`CDP target list failed: HTTP ${response.status}`); const targets = await response.json(); return Array.isArray(targets) ? targets : []; } async function resolveBrowserCdpTargetId(tabId) { const marker = encodeURIComponent(`openwork-browser-tab:${tabId}`); const deadline = Date.now() + BROWSER_TARGET_RESOLVE_TIMEOUT_MS; while (Date.now() < deadline) { const targets = await listCdpTargets().catch(() => []); const target = targets.find((candidate) => ( candidate?.type === "page" && typeof candidate.id === "string" && typeof candidate.url === "string" && candidate.url.includes(marker) )); if (target?.id) return target.id; await new Promise((resolve) => setTimeout(resolve, BROWSER_TARGET_RESOLVE_INTERVAL_MS)); } throw new Error("Could not resolve built-in browser CDP target."); } async function openBrowserUrlForAutomation(rawUrl, provider = "auto") { const requestedProvider = String(provider || "auto").trim().toLowerCase(); if (requestedProvider && requestedProvider !== "auto" && requestedProvider !== "builtin") { throw new Error(`Browser provider is not available yet: ${requestedProvider}`); } const url = normalizeBrowserUrl(rawUrl); const tab = createBrowserTab("about:blank", { select: true }); await tab.view.webContents.loadURL(browserTargetMarkerUrl(tab.tabId)); const targetId = await resolveBrowserCdpTargetId(tab.tabId); await tab.view.webContents.loadURL(url); return { provider: "builtin", browser_url: cdpBrowserUrl(), target_id: targetId, tab_id: tab.tabId, url, }; } function getBrowserTab(tabId = activeBrowserTabId) { return tabId ? browserTabs.get(tabId) ?? null : null; } function getActiveBrowserView() { return getBrowserTab()?.view ?? null; } function getActiveWebContents() { return getActiveBrowserView()?.webContents ?? null; } function getBrowserTabLabel(title, url) { if (title) { return title; } if (url && url !== "about:blank") { return url; } return "New tab"; } function browserTabToPanelTab(tabId, tab) { const webContents = tab.view.webContents; const url = webContents.getURL(); const title = webContents.getTitle(); const isLoading = webContents.isLoading(); return { id: tabId, type: "browser", label: getBrowserTabLabel(title, url), url, favicon: tab.favicon ?? null, status: isLoading ? "loading" : "ready", canGoBack: webContents.canGoBack(), canGoForward: webContents.canGoForward(), }; } function listBrowserTabs() { return browserTabOrder .map((tabId) => { const tab = browserTabs.get(tabId); if (!tab || tab.view.webContents.isDestroyed()) return null; return browserTabToPanelTab(tabId, tab); }) .filter(Boolean); } function browserStatePayload() { return { activeTabId: activeBrowserTabId, tabs: listBrowserTabs(), }; } function browserTabUrl(tab) { const url = tab?.view?.webContents?.getURL?.(); return typeof url === "string" && url && url !== "about:blank" ? url : null; } function isHttpUrl(url) { try { const parsed = new URL(url); return parsed.protocol === "http:" || parsed.protocol === "https:"; } catch { return false; } } function normalizeMenuOverlayPoint(point) { if (!point || typeof point !== "object") { return { x: 0, y: 0 }; } const x = Number(point.x); const y = Number(point.y); if (!Number.isFinite(x) || !Number.isFinite(y)) { return { x: 0, y: 0 }; } return { x: Math.round(x), y: Math.round(y) }; } function menuOverlayBounds(point) { const [contentWidth, contentHeight] = window()?.getContentSize?.() ?? [MENU_OVERLAY_WIDTH, MENU_OVERLAY_HEIGHT]; return { x: Math.min(Math.max(point.x, 0), Math.max(contentWidth - MENU_OVERLAY_WIDTH - 4, 0)), y: Math.min(Math.max(point.y, 0), Math.max(contentHeight - MENU_OVERLAY_HEIGHT - 4, 0)), width: MENU_OVERLAY_WIDTH, height: MENU_OVERLAY_HEIGHT, }; } function menuOverlayUrl() { const currentUrl = window()?.webContents?.getURL?.(); if (currentUrl && /^https?:\/\//i.test(currentUrl)) { return new URL(MENU_OVERLAY_HTML, currentUrl).toString(); } return null; } async function loadMenuOverlayRenderer(view) { const devUrl = menuOverlayUrl(); if (devUrl) { await view.webContents.loadURL(devUrl); return; } const packagedOverlayPath = path.join(process.resourcesPath, "app-dist", MENU_OVERLAY_HTML); const devOverlayPath = path.resolve(__dirname, "../../app/dist", MENU_OVERLAY_HTML); await view.webContents.loadFile(app.isPackaged ? packagedOverlayPath : devOverlayPath); } async function ensureMenuOverlayView() { if (menuOverlayView && !menuOverlayView.webContents.isDestroyed()) { return menuOverlayView; } const view = new WebContentsView({ webPreferences: { // Electron only runs ESM preload scripts reliably with sandbox disabled. // Keep the bridge isolated and node-free for the React overlay document. backgroundThrottling: false, sandbox: false, contextIsolation: true, nodeIntegration: false, preload: path.join(__dirname, "menu-overlay-preload.mjs"), }, }); view.setBackgroundColor?.("#00000000"); view.setVisible?.(false); view.webContents.setWindowOpenHandler(() => ({ action: "deny" })); view.webContents.on("did-start-navigation", (_event, _url, isInPlace, isMainFrame) => { if (isMainFrame && !isInPlace) resetMenuOverlayReady(); }); view.webContents.once("destroyed", () => { if (menuOverlayView === view) { menuOverlayView = null; menuOverlayRequest = null; resetMenuOverlayReady({ resolvePending: true }); } }); menuOverlayView = view; resetMenuOverlayReady({ resolvePending: true }); await loadMenuOverlayRenderer(view); return view; } function hideMenuOverlay() { const view = menuOverlayView; const mainWindow = window(); menuOverlayShowSerial += 1; menuOverlayRequest = null; if (!view || !mainWindow) return; view.setVisible?.(false); try { if (mainWindow.contentView.children.includes(view)) { mainWindow.contentView.removeChildView(view); } } catch { // already removed } } function bringMenuOverlayToTop(view) { const mainWindow = window(); if (!mainWindow) return; try { if (mainWindow.contentView.children.includes(view)) { mainWindow.contentView.removeChildView(view); } } catch { // already removed } mainWindow.contentView.addChildView(view); } function tabMenuRequest(tab, point) { const url = browserTabUrl(tab); return { id: `tab-menu:${tab.tabId}:${Date.now()}`, source: "tab", tabId: tab.tabId, url, bounds: menuOverlayBounds(normalizeMenuOverlayPoint(point)), items: [ { id: "copy-url", label: "Copy URL", iconName: "copy", disabled: !url }, { id: "open-external", label: "Open in Browser", iconName: "external", disabled: !(url && isHttpUrl(url)) }, { id: "close-tab", label: "Close Tab", iconName: "close", separatorBefore: true }, { id: "close-all-tabs", label: "Close All Tabs", iconName: "close" }, ], }; } async function showBrowserTabContextMenu(tabId, point) { const tab = getBrowserTab(String(tabId ?? "")); if (!window() || !tab || tab.view.webContents.isDestroyed()) return; const showSerial = menuOverlayShowSerial + 1; menuOverlayShowSerial = showSerial; const request = tabMenuRequest(tab, point ? scaleRendererPoint(point) : point); const view = await ensureMenuOverlayView(); if (showSerial !== menuOverlayShowSerial || menuOverlayView !== view) return; menuOverlayRequest = request; view.setBounds(request.bounds); view.setVisible?.(true); bringMenuOverlayToTop(view); const ready = await waitForMenuOverlayReady(view); if (showSerial !== menuOverlayShowSerial || menuOverlayRequest !== request || menuOverlayView !== view) return; if (!ready) { console.warn("[menu-overlay] renderer did not signal readiness before show"); } view.webContents.send("openwork:menu-overlay:show", { id: request.id, source: request.source, items: request.items, }); view.webContents.focus(); } function handleMenuOverlayChoice(payload) { if (!payload || payload.requestId !== menuOverlayRequest?.id) return; const request = menuOverlayRequest; const tab = getBrowserTab(request.tabId); hideMenuOverlay(); switch (payload.itemId) { case "copy-url": if (request.url) clipboard.writeText(request.url); break; case "open-external": if (request.url && isHttpUrl(request.url)) { runDetachedTask("open browser tab externally", () => shell.openExternal(request.url)); } break; case "close-tab": if (tab) closeBrowserTab(tab.tabId); break; case "close-all-tabs": closeAllBrowserTabs(); break; } } function resolveBrowserProxyInput(input) { const raw = String(input ?? "").trim(); const envMatch = raw.match(/^env:([A-Za-z0-9_]+)$/i); if (!envMatch) return raw; const key = `OPENWORK_BROWSER_PROXY_${envMatch[1].toUpperCase()}`; const value = String(process.env[key] ?? "").trim(); if (!value) throw new Error(`No proxy configured: set the ${key} environment variable to a proxy URL.`); return value; } function parseBrowserProxyInput(input) { const raw = resolveBrowserProxyInput(input); if (!raw) return null; const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`; let url; try { url = new URL(withScheme); } catch { throw new Error(`Invalid proxy URL: ${raw}`); } if (!url.hostname || !url.port) { throw new Error("Proxy must include host and port, e.g. http://user:pass@host:8080 or socks5://host:1080."); } const scheme = url.protocol.replace(/:$/, "").toLowerCase(); return { rules: `${scheme}://${url.hostname}:${url.port}`, username: decodeURIComponent(url.username), password: decodeURIComponent(url.password), }; } function browserProxyState() { return { proxy: browserProxy ? { rules: browserProxy.rules, authenticated: Boolean(browserProxy.username) } : null, }; } async function setBrowserProxy(proxyInput) { const browserSession = session.fromPartition(BROWSER_SESSION_PARTITION); const parsed = parseBrowserProxyInput(proxyInput); if (parsed) { await browserSession.setProxy({ proxyRules: parsed.rules, proxyBypassRules: "" }); } else { await browserSession.setProxy({ mode: "system" }); } browserProxy = parsed; // Drop keep-alive connections so existing tabs cannot bypass the new proxy. await browserSession.closeAllConnections(); return browserProxyState(); } app.on("login", (event, _webContents, _details, authInfo, callback) => { if (!authInfo?.isProxy || !browserProxy?.username) return; event.preventDefault(); callback(browserProxy.username, browserProxy.password); }); function createBrowserTab(url = "about:blank", { select = true } = {}) { const tabId = createBrowserTabId(); const view = new WebContentsView({ webPreferences: { backgroundThrottling: false, sandbox: true, contextIsolation: true, nodeIntegration: false, preload: path.join(__dirname, "browser-content-preload.cjs"), partition: BROWSER_SESSION_PARTITION, }, }); const tab = { tabId, view, favicon: null }; browserTabs.set(tabId, tab); browserTabOrder.push(tabId); // Load about:blank immediately to preempt persistent-session restore. // Cookies live on the session object, not the document — they survive this. runDetachedTask("initialize browser tab", () => view.webContents.loadURL("about:blank")); view.webContents.setWindowOpenHandler(({ url: targetUrl }) => { runDetachedTask("open browser popup externally", () => shell.openExternal(targetUrl)); return { action: "deny" }; }); view.webContents.on("did-start-navigation", (_event, targetUrl, isInPlace, isMainFrame) => { if (!isMainFrame || isInPlace) return; const target = String(targetUrl ?? ""); // data: loads are internal plumbing (CDP target-marker pages), not // user-visible navigations — don't surface the panel for them. if (target === "about:blank" || target.startsWith("data:")) return; // Intercept openwork:// deep links (e.g. den-auth handoff grants) so // in-app browser auth works without the system protocol handler. if (target.startsWith("openwork://") || target.startsWith("openwork-dev://")) { if (typeof onDeepLink === "function") { onDeepLink([target]); } // Navigate the tab to about:blank to prevent the custom-scheme load // from erroring, then hide the panel. Avoid closing the tab // synchronously during a navigation event to prevent renderer crashes. setTimeout(() => { try { if (!view.webContents.isDestroyed()) { runDetachedTask("clear completed browser handoff", () => view.webContents.loadURL("about:blank")); } hideBrowserView(); } catch { /* tab already gone */ } }, 200); return; } // Agent-driven CDP navigation can target a background tab whose view is // detached. Bring that tab on screen, otherwise navigation "succeeds" // while the visible tab stays on about:blank (#2015). if (activeBrowserTabId !== tabId) { try { selectBrowserTab(tabId); } catch { // The tab may be mid-close; the panel-opened event below still fires. } } sendToRenderer("openwork:browser:panel-opened"); }); view.webContents.on("did-navigate", () => sendBrowserState()); view.webContents.on("did-navigate-in-page", () => sendBrowserState()); view.webContents.on("page-title-updated", () => sendBrowserState()); view.webContents.on("page-favicon-updated", (_event, favicons) => { tab.favicon = Array.isArray(favicons) ? favicons[0] ?? null : null; sendBrowserState(); }); view.webContents.on("did-start-loading", () => sendBrowserState()); view.webContents.on("did-stop-loading", () => sendBrowserState()); view.webContents.once("destroyed", () => { browserTabs.delete(tabId); browserTabOrder = browserTabOrder.filter((id) => id !== tabId); if (activeBrowserTabId === tabId) activeBrowserTabId = browserTabOrder[0] ?? null; sendBrowserState(); }); if (select || !activeBrowserTabId) { selectBrowserTab(tabId); } else { sendBrowserState(); } const finalUrl = normalizeBrowserUrl(url, "about:blank"); if (finalUrl !== "about:blank") { runDetachedTask("navigate new browser tab", () => view.webContents.loadURL(finalUrl)); } return tab; } function detachBrowserView(view) { const mainWindow = window(); if (!mainWindow || !view) return; try { if (mainWindow.contentView.children.includes(view)) { mainWindow.contentView.removeChildView(view); } } catch { // already removed } } // The renderer reports bounds in CSS pixels, which Electron scales by the main // window's zoom factor. Read the factor from the webContents at apply time so // the conversion is always correct, no matter how the zoom was changed // (shortcuts, native menu, or Chromium's persisted per-origin zoom). function mainWindowZoomFactor() { try { const factor = window()?.webContents.getZoomFactor(); return typeof factor === "number" && factor > 0 ? factor : 1; } catch { return 1; } } function scaleRendererBounds(bounds) { const zoom = mainWindowZoomFactor(); // Round edges (not width/height) so the far edge has no sub-pixel seam. const x = Math.round(bounds.x * zoom); const y = Math.round(bounds.y * zoom); return { x, y, width: Math.round((bounds.x + bounds.width) * zoom) - x, height: Math.round((bounds.y + bounds.height) * zoom) - y, }; } function scaleRendererPoint(point) { const zoom = mainWindowZoomFactor(); return { x: Math.round(point.x * zoom), y: Math.round(point.y * zoom) }; } function attachActiveBrowserView() { const mainWindow = window(); if (!mainWindow || !browserViewVisible) return; const view = getActiveBrowserView(); if (!view) return; for (const tab of browserTabs.values()) { if (tab.view !== view) detachBrowserView(tab.view); } if (!mainWindow.contentView.children.includes(view)) { mainWindow.contentView.addChildView(view); } if (lastBrowserBounds && lastBrowserBounds.width > 0 && lastBrowserBounds.height > 0) { view.setBounds(scaleRendererBounds(lastBrowserBounds)); } } function selectBrowserTab(tabId) { if (!browserTabs.has(tabId)) throw new Error(`Unknown browser tab: ${tabId}`); hideMenuOverlay(); const previousView = getActiveBrowserView(); activeBrowserTabId = tabId; if (previousView && previousView !== getActiveBrowserView()) { detachBrowserView(previousView); } attachActiveBrowserView(); sendBrowserState(); return getBrowserTab(tabId); } function closeBrowserTab(tabId = activeBrowserTabId) { const tab = getBrowserTab(tabId); if (!tab) return null; if (menuOverlayRequest?.tabId === tabId) hideMenuOverlay(); const closingIndex = browserTabOrder.indexOf(tabId); const wasActive = activeBrowserTabId === tabId; detachBrowserView(tab.view); browserTabs.delete(tabId); browserTabOrder = browserTabOrder.filter((id) => id !== tabId); if (wasActive) { const nextTabId = browserTabOrder[Math.min(closingIndex, browserTabOrder.length - 1)] ?? browserTabOrder[closingIndex - 1] ?? null; activeBrowserTabId = nextTabId; if (nextTabId) { attachActiveBrowserView(); } else { hideBrowserView(); sendToRenderer("openwork:browser:panel-closed"); } } try { tab.view.webContents.close(); } catch { /* already destroyed */ } sendBrowserState(); return tabId; } function closeAllBrowserTabs() { const closedTabIds = [...browserTabOrder]; if (closedTabIds.length === 0) return []; hideMenuOverlay(); const tabsToClose = closedTabIds .map((tabId) => browserTabs.get(tabId)) .filter(Boolean); hideBrowserView(); browserTabs.clear(); browserTabOrder = []; activeBrowserTabId = null; for (const tab of tabsToClose) { try { tab.view.webContents.close(); } catch { /* already destroyed */ } } sendToRenderer("openwork:browser:panel-closed"); sendBrowserState(); return closedTabIds; } function reorderBrowserTabs(tabIds) { const nextOrder = Array.isArray(tabIds) ? tabIds.map(String) : []; if (nextOrder.length !== browserTabOrder.length) { throw new Error("Tab order must include every open tab."); } if (new Set(nextOrder).size !== nextOrder.length) { throw new Error("Tab order must not contain duplicate tabs."); } const current = new Set(browserTabOrder); if (nextOrder.some((tabId) => !current.has(tabId))) { throw new Error("Tab order contains an unknown tab."); } browserTabOrder = nextOrder; sendBrowserState(); return listBrowserTabs(); } function sendBrowserState() { sendToRenderer("openwork:browser:state", browserStatePayload()); } /** * Attach the browser view to the main window. * @param {object} bounds — { x, y, width, height } * @param {object} [opts] * @param {boolean} [opts.preloadDefault=false] - load default URL if the view has no URL * @param {boolean} [opts.ensureTab=false] - create a blank tab if needed */ function attachBrowserView(bounds, { preloadDefault = false, ensureTab = false } = {}) { if (!window()) return; lastBrowserBounds = bounds; browserViewVisible = true; if (ensureTab && !activeBrowserTabId) createBrowserTab("about:blank"); const view = getActiveBrowserView(); attachActiveBrowserView(); if (bounds.width > 0 && bounds.height > 0) { view?.setBounds(scaleRendererBounds(bounds)); } const url = view?.webContents.getURL(); if (preloadDefault && (!url || url === "about:blank")) { runDetachedTask("load browser default page", () => view?.webContents.loadURL(BROWSER_DEFAULT_URL)); } sendBrowserState(); } function hideBrowserView() { hideMenuOverlay(); browserViewVisible = false; if (!window()) return; for (const tab of browserTabs.values()) { detachBrowserView(tab.view); } } function destroyBrowserView() { hideBrowserView(); const overlayView = menuOverlayView; menuOverlayView = null; menuOverlayRequest = null; try { overlayView?.webContents.close(); } catch { /* already destroyed */ } for (const tab of browserTabs.values()) { try { tab.view.webContents.close(); } catch { /* already destroyed */ } } browserTabs.clear(); browserTabOrder = []; activeBrowserTabId = null; lastBrowserBounds = null; sendBrowserState(); } function registerIpc(ipcMain) { ipcMain.handle("openwork:browser:show", (_event, bounds) => attachBrowserView(bounds)); ipcMain.handle("openwork:browser:hide", () => hideBrowserView()); ipcMain.handle("openwork:browser:openUrl", (_event, url, provider) => openBrowserUrlForAutomation(url, provider)); ipcMain.handle("openwork:browser:navigate", (_event, url) => { const view = getActiveBrowserView() ?? createBrowserTab("about:blank", { select: true }).view; runDetachedTask("navigate browser tab", () => view.webContents.loadURL(normalizeBrowserUrl(url))); }); ipcMain.handle("openwork:browser:back", () => { const webContents = getActiveWebContents(); if (webContents?.canGoBack()) webContents.goBack(); }); ipcMain.handle("openwork:browser:forward", () => { const webContents = getActiveWebContents(); if (webContents?.canGoForward()) webContents.goForward(); }); ipcMain.handle("openwork:browser:reload", () => getActiveWebContents()?.reload()); ipcMain.handle("openwork:browser:bounds", (_event, bounds) => { lastBrowserBounds = bounds; const view = getActiveBrowserView(); if (view && browserViewVisible && bounds.width > 0 && bounds.height > 0) { view.setBounds(scaleRendererBounds(bounds)); } }); ipcMain.handle("openwork:browser:state", () => browserStatePayload()); ipcMain.handle("openwork:browser:createTab", (_event, url) => { const target = typeof url === "string" && url.trim() ? url : BROWSER_NEW_TAB_URL; const tab = createBrowserTab(target, { select: true }); return { tabId: tab.tabId }; }); ipcMain.handle("openwork:browser:closeTab", (_event, tabId) => closeBrowserTab(tabId == null ? undefined : String(tabId))); ipcMain.handle("openwork:browser:closeAllTabs", () => closeAllBrowserTabs()); ipcMain.handle("openwork:browser:selectTab", (_event, tabId) => selectBrowserTab(String(tabId ?? "")).tabId); ipcMain.handle("openwork:browser:reorderTabs", (_event, tabIds) => reorderBrowserTabs(tabIds)); ipcMain.handle("openwork:browser:listTabs", () => listBrowserTabs()); ipcMain.handle("openwork:browser:setProxy", (_event, proxy) => setBrowserProxy(proxy)); ipcMain.handle("openwork:browser:getProxy", () => browserProxyState()); ipcMain.handle("openwork:browser:tabContextMenu", (_event, tabId, point) => showBrowserTabContextMenu(tabId, point)); ipcMain.handle("openwork:browser:destroy", () => destroyBrowserView()); ipcMain.on("openwork:menu-overlay:ready", (event) => { if (event.sender !== menuOverlayView?.webContents) return; markMenuOverlayReady(menuOverlayView); }); ipcMain.on("openwork:menu-overlay:choose", (event, payload) => { if (event.sender !== menuOverlayView?.webContents) return; handleMenuOverlayChoice(payload); }); ipcMain.on("openwork:menu-overlay:close", (event, payload) => { if (event.sender !== menuOverlayView?.webContents) return; if (payload?.requestId && payload.requestId !== menuOverlayRequest?.id) return; hideMenuOverlay(); }); ipcMain.on("openwork:menu-overlay:dismiss", (event) => { if (event.sender === menuOverlayView?.webContents) return; hideMenuOverlay(); }); } return { destroy: destroyBrowserView, isMainWindowAllowedNavigation, registerIpc, routeBlockedMainWindowNavigation, }; }