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;