From 0d68dc2cd0ee85a71c6bddd21aabc57bd14c6280 Mon Sep 17 00:00:00 2001 From: Simos Mikelatos Date: Wed, 24 Jun 2026 20:00:45 +0000 Subject: [PATCH] fix: add Electron tab diagnostics --- electron/desktopWindow.js | 117 ++++++++++++++++++++++++++++++++-- electron/launcher/launcher.js | 28 +++++++- electron/main.js | 17 ++++- electron/preload.cjs | 2 + electron/tabs.js | 16 +++++ electron/viewHost.js | 84 ++++++++++++++++++++++-- 6 files changed, 250 insertions(+), 14 deletions(-) diff --git a/electron/desktopWindow.js b/electron/desktopWindow.js index a106b68e..e4f2e558 100644 --- a/electron/desktopWindow.js +++ b/electron/desktopWindow.js @@ -1,4 +1,4 @@ -import { BrowserWindow, Menu, Tray, nativeImage, nativeTheme, session } from 'electron'; +import { BrowserWindow, Menu, Tray, clipboard, nativeImage, nativeTheme, session, webContents as electronWebContents } from 'electron'; import { ViewHost } from './viewHost.js'; @@ -23,6 +23,13 @@ function isAllowedPermissionOrigin(sourceUrl, controlPlaneUrl) { } } +function getWebContentsProcessId(contents) { + return { + osProcessId: typeof contents.getOSProcessId === 'function' ? contents.getOSProcessId() : null, + processId: typeof contents.getProcessId === 'function' ? contents.getProcessId() : null, + }; +} + export class DesktopWindowManager { constructor({ appName, @@ -226,6 +233,90 @@ export class DesktopWindowManager { return this.getDesktopState(); } + async reloadActiveTab() { + const activeTab = this.tabs.getActiveTab(); + if (!activeTab || activeTab.id === 'home' || activeTab.kind === 'launcher') { + this.emitDesktopState(); + return this.getDesktopState(); + } + + const reloaded = this.viewHost.reloadTab(activeTab.id); + if (!reloaded && activeTab.target?.url) { + await this.showTarget(activeTab.target, { trackTab: false }); + } + this.emitDesktopState(); + return this.getDesktopState(); + } + + openActiveTabDevTools() { + if (this.viewHost.openActiveViewDevTools()) return; + void this.actions.showError('No active BrowserView', new Error('Switch to a non-launcher tab before opening active tab DevTools.')); + } + + reloadActiveBrowserViewForDiagnostics() { + if (this.viewHost.reloadActiveView()) return; + void this.actions.showError('No active BrowserView', new Error('Switch to a non-launcher tab before reloading the active BrowserView.')); + } + + detachActiveBrowserViewForDiagnostics() { + if (this.viewHost.detachActiveView()) return; + void this.actions.showError('No active BrowserView', new Error('Switch to a non-launcher tab before detaching the active BrowserView.')); + } + + copyWebContentsDiagnostics() { + const tabViewDiagnostics = this.viewHost.getTabViewDiagnostics(); + const tabViewByContentsId = new Map( + tabViewDiagnostics + .filter((item) => item.webContentsId != null) + .map((item) => [item.webContentsId, item]) + ); + + const rows = electronWebContents.getAllWebContents().map((contents) => { + const destroyed = contents.isDestroyed(); + const processIds = destroyed ? { osProcessId: null, processId: null } : getWebContentsProcessId(contents); + const tabView = tabViewByContentsId.get(contents.id); + let owner = 'unknown'; + if (this.mainWindow?.webContents?.id === contents.id) { + owner = 'main-window'; + } else if (this.settingsWindow?.webContents?.id === contents.id) { + owner = 'settings-window'; + } else if (tabView) { + owner = `browser-view:${tabView.tabId}`; + } + + return { + id: contents.id, + owner, + osProcessId: processIds.osProcessId, + processId: processIds.processId, + url: destroyed ? null : contents.getURL(), + title: destroyed ? null : contents.getTitle(), + destroyed, + focused: destroyed || typeof contents.isFocused !== 'function' ? false : contents.isFocused(), + attached: tabView ? tabView.attached : null, + active: tabView ? tabView.active : null, + }; + }); + + const activeTab = this.tabs.getActiveTab(); + const diagnostics = { + generatedAt: new Date().toISOString(), + activeTabId: this.tabs.activeTabId, + activeTab: activeTab + ? { + id: activeTab.id, + title: activeTab.title, + kind: activeTab.kind, + targetUrl: activeTab.target?.url || null, + } + : null, + tabViews: tabViewDiagnostics, + webContents: rows, + }; + + clipboard.writeText(JSON.stringify(diagnostics, null, 2)); + } + async closeDesktopTab(tabId) { const tab = this.tabs.remove(tabId); if (!tab) return this.getDesktopState(); @@ -426,8 +517,8 @@ export class DesktopWindowManager { enabled: Boolean(cloudState.account?.apiKey), }, { - label: 'Disconnect Cloud Account', - click: () => void this.actions.clearCloudAccount().catch((error) => this.actions.showError('Could not disconnect cloud account', error)), + label: 'Logout CloudCLI Account', + click: () => void this.actions.clearCloudAccount().catch((error) => this.actions.showError('Could not logout', error)), enabled: Boolean(cloudState.account?.apiKey), }, { type: 'separator' }, @@ -455,6 +546,22 @@ export class DesktopWindowManager { { role: 'reload' }, { role: 'forceReload' }, { role: 'toggleDevTools' }, + { + label: 'Open Active Tab DevTools', + click: () => this.openActiveTabDevTools(), + }, + { + label: 'Copy WebContents Diagnostics', + click: () => this.copyWebContentsDiagnostics(), + }, + { + label: 'Reload Active BrowserView', + click: () => this.reloadActiveBrowserViewForDiagnostics(), + }, + { + label: 'Detach Active BrowserView', + click: () => this.detachActiveBrowserViewForDiagnostics(), + }, { type: 'separator' }, { role: 'resetZoom' }, { role: 'zoomIn' }, @@ -523,8 +630,8 @@ export class DesktopWindowManager { click: () => void this.actions.connectCloudAccount().catch((error) => this.actions.showError('Could not connect CloudCLI account', error)), }, { - label: 'Disconnect Cloud Account', - click: () => void this.actions.clearCloudAccount().catch((error) => this.actions.showError('Could not disconnect cloud account', error)), + label: 'Logout CloudCLI Account', + click: () => void this.actions.clearCloudAccount().catch((error) => this.actions.showError('Could not logout', error)), enabled: Boolean(cloudState.account?.apiKey), }, { type: 'separator' }, diff --git a/electron/launcher/launcher.js b/electron/launcher/launcher.js index cc0b57c2..8231d0a8 100644 --- a/electron/launcher/launcher.js +++ b/electron/launcher/launcher.js @@ -51,7 +51,16 @@ window.__MOCK_STATE__ = { mockState.account = { connected: true, email: 'you@cloudcli.ai' }; return Promise.resolve(clone(mockState)); }, + disconnectCloud: function () { + mockState.account = { connected: false, email: null }; + mockState.environments = []; + mockState.tabs = (mockState.tabs || []).filter(function (tab) { return tab.kind !== 'remote'; }); + mockState.activeTabId = 'home'; + mockState.activeTarget = { kind: 'launcher', name: 'Launcher', url: null }; + return Promise.resolve(clone(mockState)); + }, refreshEnvironments: function () { return Promise.resolve(clone(mockState)); }, + refreshActiveTab: function () { return Promise.resolve(clone(mockState)); }, copyDiagnostics: function () { return Promise.resolve(clone(mockState)); }, showComputerAccess: function () { return Promise.resolve(clone(mockState)); }, showEnvironmentPicker: function () { return Promise.resolve(clone(mockState)); }, @@ -118,6 +127,7 @@ window.__MOCK_STATE__ = { monitor: '', phone: '', x: '', + logOut: '', }; var FILLED = { play: true }; @@ -330,6 +340,8 @@ window.__MOCK_STATE__ = { return CC.run('Starting Local CloudCLI...', function () { return bridge.openLocal(); }); case 'connect': return CC.run('Opening cloudcli.ai to connect your account...', function () { return bridge.connectCloud(); }); + case 'logout': + return CC.run('Logging out...', function () { return bridge.disconnectCloud(); }); case 'open-web': return CC.run('Opening local web UI in your browser...', function () { return bridge.openLocalWebUi(); }); case 'copy-web': @@ -382,6 +394,8 @@ window.__MOCK_STATE__ = { return CC.run('Opening CloudCLI dashboard...', function () { return bridge.openCloudDashboard(); }); case 'refresh-environments': return CC.run('Refreshing cloud environments...', function () { return bridge.refreshEnvironments(); }); + case 'refresh-tab': + return CC.run('Refreshing tab...', function () { return bridge.refreshActiveTab(); }); case 'env-action': return CC.run('Opening environment...', function () { return bridge.runActiveEnvironmentAction(node.getAttribute('data-cc-env-action')); }); case 'env-menu': @@ -408,14 +422,24 @@ window.__MOCK_STATE__ = { CC.titlebar = function (state) { var conn = connected(state); - var activeRemote = state.activeTarget && state.activeTarget.kind === 'remote'; - var envActions = activeRemote ? '' : ''; + var activeTab = (state.tabs || []).filter(function (tab) { return tab.active; })[0] || null; + var activeEnvironmentId = state.activeTarget && state.activeTarget.kind === 'remote' ? state.activeTarget.id : null; + if (!activeEnvironmentId && activeTab && /^remote:/.test(activeTab.id || '')) { + activeEnvironmentId = activeTab.id.replace(/^remote:/, ''); + } + var activeRefreshable = (state.activeTarget && (state.activeTarget.kind === 'remote' || state.activeTarget.kind === 'local')) || + (activeTab && activeTab.id !== 'home'); + var envActions = activeEnvironmentId ? '' : ''; + var refreshAction = activeRefreshable ? '' : ''; + var logoutAction = (conn || authState(state) === 'expired') ? '' : ''; return '
' + '
CloudCLI
' + '
' + renderTabs(state) + '
' + '' + + refreshAction + envActions + '' + + logoutAction + '' + '
'; }; diff --git a/electron/main.js b/electron/main.js index 451ba68f..c97b7de4 100644 --- a/electron/main.js +++ b/electron/main.js @@ -200,7 +200,7 @@ async function requestComputerUsePermission(permission) { } async function openExternalUrl(url) { - if (String(url).startsWith(`${CALLBACK_PROTOCOL}://`)) { + if (String(url).startsWith(CALLBACK_PROTOCOL + "://")) { await handleDeepLink(url); return; } @@ -251,9 +251,11 @@ function getEnvironmentTarget(environment) { } async function getEnvironmentLaunchTarget(environment) { + const environmentUrl = cloud.getEnvironmentUrl(environment); return { ...getEnvironmentTarget(environment), - url: await cloud.getEnvironmentLaunchUrl(environment), + url: environmentUrl, + loadUrl: await cloud.getEnvironmentLaunchUrl(environment), }; } @@ -681,6 +683,15 @@ async function openEnvironmentInDesktop(environment) { async function clearCloudAccount() { await cloud.clearCloudAccount(); + const removedTabs = tabs.removeByKind('remote'); + for (const tab of removedTabs) { + desktopWindow?.destroyTabView(tab.id); + } + if (activeTarget?.kind === 'remote') { + await desktopWindow?.showLauncher(); + } else { + syncDesktopState(); + } return getDesktopState(); } @@ -740,6 +751,8 @@ function registerIpcHandlers() { await refreshCloudEnvironments({ showErrors: true }); return getDesktopState(); }); + ipcMain.handle('cloudcli-desktop:disconnect-cloud', async () => clearCloudAccount()); + ipcMain.handle('cloudcli-desktop:reload-active-tab', async () => desktopWindow.reloadActiveTab()); ipcMain.handle('cloudcli-desktop:show-environment-picker', async () => showEnvironmentPicker()); ipcMain.handle('cloudcli-desktop:show-launcher', async () => { await desktopWindow.showLauncher(); diff --git a/electron/preload.cjs b/electron/preload.cjs index e670aadf..82b0affc 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -3,6 +3,7 @@ const { contextBridge, ipcRenderer } = require('electron'); if (window.location.protocol === 'file:') { contextBridge.exposeInMainWorld('cloudcliDesktop', { connectCloud: () => ipcRenderer.invoke('cloudcli-desktop:connect-cloud'), + disconnectCloud: () => ipcRenderer.invoke('cloudcli-desktop:disconnect-cloud'), copyDiagnostics: () => ipcRenderer.invoke('cloudcli-desktop:copy-diagnostics'), copyLocalWebUrl: () => ipcRenderer.invoke('cloudcli-desktop:copy-local-web-url'), getState: () => ipcRenderer.invoke('cloudcli-desktop:get-state'), @@ -12,6 +13,7 @@ if (window.location.protocol === 'file:') { openLocal: () => ipcRenderer.invoke('cloudcli-desktop:open-local'), openLocalWebUi: () => ipcRenderer.invoke('cloudcli-desktop:open-local-web-ui'), refreshEnvironments: () => ipcRenderer.invoke('cloudcli-desktop:refresh-environments'), + refreshActiveTab: () => ipcRenderer.invoke('cloudcli-desktop:reload-active-tab'), showEnvironmentPicker: () => ipcRenderer.invoke('cloudcli-desktop:show-environment-picker'), showLauncher: () => ipcRenderer.invoke('cloudcli-desktop:show-launcher'), showComputerAccess: () => ipcRenderer.invoke('cloudcli-desktop:show-computer-access'), diff --git a/electron/tabs.js b/electron/tabs.js index b55a64bb..6e514f39 100644 --- a/electron/tabs.js +++ b/electron/tabs.js @@ -55,6 +55,22 @@ export class TabsController { return tab; } + removeByKind(kind) { + const removed = this.tabs.filter((tab) => tab.kind === kind && tab.closable); + if (!removed.length) return []; + + const removedIds = new Set(removed.map((tab) => tab.id)); + this.tabs = this.tabs.filter((tab) => !removedIds.has(tab.id)); + if (removedIds.has(this.activeTabId)) { + this.activeTabId = 'home'; + } + return removed; + } + + getActiveTab() { + return this.getTab(this.activeTabId); + } + getTab(tabId) { return this.tabs.find((item) => item.id === tabId) || null; } diff --git a/electron/viewHost.js b/electron/viewHost.js index 58c97a98..6153c618 100644 --- a/electron/viewHost.js +++ b/electron/viewHost.js @@ -100,6 +100,71 @@ export class ViewHost { 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; @@ -162,25 +227,34 @@ export class ViewHost { } async showContentTarget(tabId, target) { - if (!isHttpUrl(target.url)) { - throw new Error(`Refusing to load unsupported app URL: ${target.url}`); + 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 = target.url; + view.__cloudcliLoadingUrl = loadUrl; try { - await loadUrlWithTimeout(view.webContents, target.url); + await loadUrlWithTimeout(view.webContents, loadUrl); view.__cloudcliLoadedUrl = target.url; view.__cloudcliStartupHtml = null; + delete target.loadUrl; } finally { - if (view.__cloudcliLoadingUrl === target.url) { + 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;