import { BrowserView } from 'electron'; const TARGET_LOAD_TIMEOUT_MS = 20000; function escapeHtml(value) { return String(value == null ? '' : value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function buildPlaceholderHtml(title, message, logs = []) { const logHtml = logs.length ? `
${logs.map(escapeHtml).join('\n')}
` : '
Waiting for process output...
'; return [ '', '', '
', `
${escapeHtml(message || `Opening ${title}...`)}
`, logHtml, '
', ].join(''); } function isHttpUrl(url) { try { const parsed = new URL(url); return parsed.protocol === 'http:' || parsed.protocol === 'https:'; } catch { return false; } } async function loadUrlWithTimeout(webContents, url, timeoutMs = TARGET_LOAD_TIMEOUT_MS) { let timedOut = false; let timeout = null; const loadPromise = webContents.loadURL(url); const timeoutPromise = new Promise((_, reject) => { timeout = setTimeout(() => { timedOut = true; try { webContents.stop(); } catch { // Ignore teardown races while reporting the original timeout. } reject(new Error(`Timed out loading ${url} after ${Math.round(timeoutMs / 1000)} seconds.`)); }, timeoutMs); }); try { await Promise.race([loadPromise, timeoutPromise]); } catch (error) { if (timedOut) { loadPromise.catch(() => {}); } throw error; } finally { if (timeout) clearTimeout(timeout); } } export class ViewHost { constructor({ appName, getMainWindow, getContentViewBounds, getPreloadPath, openExternalUrl, showError }) { this.appName = appName; this.getMainWindow = getMainWindow; this.getContentViewBounds = getContentViewBounds; this.getPreloadPath = getPreloadPath; this.openExternalUrl = openExternalUrl; this.showError = showError; this.activeContentView = null; this.tabViews = new Map(); } configureChildWebContents(webContents) { webContents.setWindowOpenHandler(({ url }) => { void this.openExternalUrl(url).catch((error) => this.showError('Could not open external link', error)); return { action: 'deny' }; }); } detachAll() { const mainWindow = this.getMainWindow(); if (!mainWindow || mainWindow.isDestroyed()) return; try { for (const view of mainWindow.getBrowserViews()) { mainWindow.removeBrowserView(view); } } catch { // BrowserViews may already be gone during BrowserWindow teardown. } this.activeContentView = null; } detachActiveView() { const mainWindow = this.getMainWindow(); const view = this.activeContentView; if (!mainWindow || mainWindow.isDestroyed() || !view) return false; try { if (mainWindow.getBrowserViews().includes(view)) { mainWindow.removeBrowserView(view); } } catch { return false; } this.activeContentView = null; return true; } getActiveView() { const view = this.activeContentView; if (!view || view.webContents.isDestroyed()) return null; return view; } openActiveViewDevTools() { const view = this.getActiveView(); if (!view) return false; view.webContents.openDevTools({ mode: 'detach' }); return true; } reloadActiveView() { const view = this.getActiveView(); if (!view) return false; view.webContents.reloadIgnoringCache(); return true; } getTabViewDiagnostics() { const mainWindow = this.getMainWindow(); const attachedViews = new Set(); if (mainWindow && !mainWindow.isDestroyed()) { try { for (const view of mainWindow.getBrowserViews()) { attachedViews.add(view); } } catch { // Ignore teardown races while gathering best-effort diagnostics. } } return Array.from(this.tabViews.entries()).map(([tabId, view]) => { const { webContents } = view; const destroyed = webContents.isDestroyed(); return { tabId, webContentsId: destroyed ? null : webContents.id, url: destroyed ? null : webContents.getURL(), title: destroyed ? null : webContents.getTitle(), osProcessId: destroyed || typeof webContents.getOSProcessId !== 'function' ? null : webContents.getOSProcessId(), processId: destroyed || typeof webContents.getProcessId !== 'function' ? null : webContents.getProcessId(), attached: attachedViews.has(view), active: this.activeContentView === view, destroyed, }; }); } getOrCreateTabView(tabId) { let view = this.tabViews.get(tabId); if (view) return view; view = new BrowserView({ webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, preload: this.getPreloadPath(), }, }); this.configureChildWebContents(view.webContents); this.tabViews.set(tabId, view); return view; } attach(view) { const mainWindow = this.getMainWindow(); if (!mainWindow || mainWindow.isDestroyed()) return; if (this.activeContentView && this.activeContentView !== view) { this.detachAll(); } this.activeContentView = view; try { if (!mainWindow.getBrowserViews().includes(view)) { mainWindow.addBrowserView(view); } } catch { return; } view.setBounds(this.getContentViewBounds()); view.setAutoResize({ width: true, height: true }); } resizeActiveView() { if (this.activeContentView) { this.activeContentView.setBounds(this.getContentViewBounds()); } } async showTabPlaceholder(tabId, target, message) { const view = this.getOrCreateTabView(tabId); this.attach(view); const html = buildPlaceholderHtml(target.name || this.appName, message); await view.webContents.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); view.__cloudcliStartupHtml = html; view.__cloudcliLoadedUrl = null; } async showLocalStartupTarget(tabId, target, logs) { const view = this.getOrCreateTabView(tabId); if (view.__cloudcliLoadingUrl) return; this.attach(view); const html = buildPlaceholderHtml(target.name || this.appName, 'Starting Local CloudCLI...', logs); if (view.__cloudcliStartupHtml === html) return; await view.webContents.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); view.__cloudcliStartupHtml = html; view.__cloudcliLoadedUrl = null; } async showContentTarget(tabId, target) { const loadUrl = target.loadUrl || target.url; if (!isHttpUrl(loadUrl)) { throw new Error(`Refusing to load unsupported app URL: ${loadUrl}`); } const view = this.getOrCreateTabView(tabId); this.attach(view); if (view.__cloudcliLoadedUrl !== target.url) { view.__cloudcliLoadingUrl = loadUrl; try { await loadUrlWithTimeout(view.webContents, loadUrl); view.__cloudcliLoadedUrl = target.url; view.__cloudcliStartupHtml = null; delete target.loadUrl; } finally { if (view.__cloudcliLoadingUrl === loadUrl) { view.__cloudcliLoadingUrl = null; } } } } reloadTab(tabId) { const view = this.tabViews.get(tabId); if (!view || view.webContents.isDestroyed()) return false; view.webContents.reloadIgnoringCache(); return true; } destroyTabView(tabId) { const view = this.tabViews.get(tabId); if (!view) return; const mainWindow = this.getMainWindow(); if (mainWindow && !mainWindow.isDestroyed()) { try { if (mainWindow.getBrowserViews().includes(view)) { mainWindow.removeBrowserView(view); } } catch { // Ignore teardown races; Electron owns final destruction during quit. } } if (this.activeContentView === view) { this.activeContentView = null; } try { if (!view.webContents.isDestroyed()) { view.webContents.destroy(); } } catch { // The view may already be destroyed by its parent BrowserWindow. } this.tabViews.delete(tabId); } clear() { this.tabViews.clear(); this.activeContentView = null; } }