814 lines
30 KiB
JavaScript
814 lines
30 KiB
JavaScript
// 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 = `<!doctype html><title>${marker}</title><meta name="openwork-browser-tab" content="${tabId}"><body>${marker}</body>`;
|
|
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: "<local>" });
|
|
} 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,
|
|
};
|
|
}
|