diff --git a/.gitignore b/.gitignore
index e6b7985b..4b893c9e 100755
--- a/.gitignore
+++ b/.gitignore
@@ -142,3 +142,8 @@ tasks/
# Git worktrees
.worktrees/
+
+# Local desktop packaging artifacts
+cloudcli-sidebar-app-source.tar.gz
+cloudcli-sidebar.html
+electron/*.tar.gz
diff --git a/electron/assets/logo-macos.icns b/electron/assets/logo-macos.icns
new file mode 100644
index 00000000..c0d548ee
Binary files /dev/null and b/electron/assets/logo-macos.icns differ
diff --git a/electron/assets/logo-macos.png b/electron/assets/logo-macos.png
new file mode 100644
index 00000000..4f8af6ed
Binary files /dev/null and b/electron/assets/logo-macos.png differ
diff --git a/electron/cloud.js b/electron/cloud.js
new file mode 100644
index 00000000..d7b74809
--- /dev/null
+++ b/electron/cloud.js
@@ -0,0 +1,240 @@
+import crypto from 'node:crypto';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { safeStorage } from 'electron';
+
+function encryptSecret(secret) {
+ if (!safeStorage.isEncryptionAvailable()) {
+ return { encrypted: false, value: secret };
+ }
+
+ return {
+ encrypted: true,
+ value: safeStorage.encryptString(secret).toString('base64'),
+ };
+}
+
+function decryptSecret(record) {
+ if (!record?.value) return null;
+ if (!record.encrypted) return record.value;
+ return safeStorage.decryptString(Buffer.from(record.value, 'base64'));
+}
+
+export class CloudController {
+ constructor({ storePath, controlPlaneUrl, callbackUrl, onChange }) {
+ this.storePath = storePath;
+ this.controlPlaneUrl = controlPlaneUrl;
+ this.callbackUrl = callbackUrl;
+ this.onChange = onChange;
+ this.cloudAccount = null;
+ this.cloudEnvironments = [];
+ this.authState = 'logged_out';
+ }
+
+ getAccount() {
+ return this.cloudAccount;
+ }
+
+ getAuthState() {
+ return this.authState;
+ }
+
+ getEnvironments() {
+ return this.cloudEnvironments;
+ }
+
+ getEnvironmentUrl(environment) {
+ return environment.access_url || `https://${environment.subdomain}.cloudcli.ai`;
+ }
+
+ async getEnvironmentLaunchUrl(environment) {
+ if (!environment?.id) {
+ return this.getEnvironmentUrl(environment);
+ }
+
+ const data = await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/launch`, {
+ method: 'POST',
+ });
+
+ return data.launch_url || data.environment_url || this.getEnvironmentUrl(environment);
+ }
+
+ findEnvironment(environmentId) {
+ return this.cloudEnvironments.find((item) => item.id === environmentId) || null;
+ }
+
+ async loadCloudAccount() {
+ try {
+ const raw = await fs.readFile(this.storePath, 'utf8');
+ const stored = JSON.parse(raw);
+ const apiKey = decryptSecret(stored.apiKey);
+ this.cloudAccount = {
+ deviceId: stored.deviceId || crypto.randomUUID(),
+ email: stored.email || null,
+ apiKey: apiKey || null,
+ };
+ this.authState = apiKey ? 'connected' : (stored.email ? 'expired' : 'logged_out');
+ return this.cloudAccount;
+ } catch {
+ this.cloudAccount = {
+ deviceId: crypto.randomUUID(),
+ email: null,
+ apiKey: null,
+ };
+ this.authState = 'logged_out';
+ return this.cloudAccount;
+ }
+ }
+
+ async saveCloudAccount(account) {
+ const payload = {
+ deviceId: account.deviceId || crypto.randomUUID(),
+ email: account.email || null,
+ apiKey: account.apiKey ? encryptSecret(account.apiKey) : null,
+ };
+
+ await fs.mkdir(path.dirname(this.storePath), { recursive: true });
+ await fs.writeFile(this.storePath, JSON.stringify(payload, null, 2), 'utf8');
+ this.cloudAccount = {
+ deviceId: payload.deviceId,
+ email: payload.email,
+ apiKey: account.apiKey || null,
+ };
+ this.authState = account.apiKey ? 'connected' : 'logged_out';
+ this.onChange?.();
+ return this.cloudAccount;
+ }
+
+ async clearCloudAccount() {
+ this.cloudAccount = {
+ deviceId: crypto.randomUUID(),
+ email: null,
+ apiKey: null,
+ };
+ this.cloudEnvironments = [];
+ this.authState = 'logged_out';
+ await fs.rm(this.storePath, { force: true });
+ this.onChange?.();
+ }
+
+ async invalidateCloudAccount() {
+ this.cloudEnvironments = [];
+ if (!this.cloudAccount) {
+ this.cloudAccount = {
+ deviceId: crypto.randomUUID(),
+ email: null,
+ apiKey: null,
+ };
+ } else {
+ this.cloudAccount = {
+ ...this.cloudAccount,
+ apiKey: null,
+ };
+ }
+ this.authState = this.cloudAccount.email ? 'expired' : 'logged_out';
+ const payload = {
+ deviceId: this.cloudAccount.deviceId,
+ email: this.cloudAccount.email || null,
+ apiKey: null,
+ };
+ await fs.mkdir(path.dirname(this.storePath), { recursive: true });
+ await fs.writeFile(this.storePath, JSON.stringify(payload, null, 2), 'utf8');
+ this.onChange?.();
+ }
+
+ async cloudApi(pathname, options = {}) {
+ if (!this.cloudAccount?.apiKey) {
+ throw new Error('Connect your CloudCLI account first.');
+ }
+
+ const response = await fetch(`${this.controlPlaneUrl}${pathname}`, {
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-API-Key': this.cloudAccount.apiKey,
+ ...(options.headers || {}),
+ },
+ });
+
+ const body = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ if (response.status === 401 || response.status === 403) {
+ await this.invalidateCloudAccount();
+ }
+ throw new Error(body.error || `CloudCLI API request failed: ${response.status}`);
+ }
+
+ return body;
+ }
+
+ async refreshCloudEnvironments() {
+ if (!this.cloudAccount?.apiKey) {
+ this.cloudEnvironments = [];
+ this.onChange?.();
+ return [];
+ }
+
+ const data = await this.cloudApi('/api/v1/environments');
+ this.cloudEnvironments = data.environments || [];
+ this.onChange?.();
+ return this.cloudEnvironments;
+ }
+
+ async startEnvironment(environment) {
+ await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/start`, {
+ method: 'POST',
+ });
+ }
+
+ async stopEnvironment(environment) {
+ await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/stop`, {
+ method: 'POST',
+ });
+ }
+
+ async getEnvironmentCredentials(environment) {
+ return this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/credentials`);
+ }
+
+ async startEnvironmentAndWait(environment, timeoutMs) {
+ await this.startEnvironment(environment);
+
+ const startedAt = Date.now();
+ while (Date.now() - startedAt < timeoutMs) {
+ const environments = await this.refreshCloudEnvironments();
+ const current = environments.find((env) => env.id === environment.id);
+ if (current?.status === 'running') {
+ return current;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ }
+
+ throw new Error(`${environment.name} did not become ready in time.`);
+ }
+
+ buildConnectUrl() {
+ if (!this.cloudAccount?.deviceId) {
+ this.cloudAccount = {
+ deviceId: crypto.randomUUID(),
+ email: null,
+ apiKey: null,
+ };
+ }
+
+ const connectUrl = new URL('/auth/app-connect', this.controlPlaneUrl);
+ connectUrl.searchParams.set('device_id', this.cloudAccount.deviceId);
+ connectUrl.searchParams.set('callback_url', this.callbackUrl);
+ connectUrl.searchParams.set('app_surface', 'cloudcli_desktop');
+ connectUrl.searchParams.set('client_platform', 'desktop');
+ return connectUrl.toString();
+ }
+
+ async saveFromCallback({ apiKey, email }) {
+ await this.saveCloudAccount({
+ deviceId: this.cloudAccount?.deviceId || crypto.randomUUID(),
+ email,
+ apiKey,
+ });
+ return this.cloudAccount;
+ }
+}
diff --git a/electron/desktopWindow.js b/electron/desktopWindow.js
new file mode 100644
index 00000000..a22aa49d
--- /dev/null
+++ b/electron/desktopWindow.js
@@ -0,0 +1,685 @@
+import { BrowserView, BrowserWindow, Menu, Tray, nativeImage, nativeTheme, session } from 'electron';
+
+const TITLEBAR_HEIGHT = 44;
+
+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('');
+}
+
+export class DesktopWindowManager {
+ constructor({
+ appName,
+ getWindowIconPath,
+ getLauncherPath,
+ getPreloadPath,
+ openExternalUrl,
+ getDesktopState,
+ getDisplayTargetName,
+ getRemoteEnvironmentMenuItems,
+ getCloudState,
+ getLocalState,
+ actions,
+ tabs,
+ }) {
+ this.appName = appName;
+ this.getWindowIconPath = getWindowIconPath;
+ this.getLauncherPath = getLauncherPath;
+ this.getPreloadPath = getPreloadPath;
+ this.openExternalUrl = openExternalUrl;
+ this.getDesktopState = getDesktopState;
+ this.getDisplayTargetName = getDisplayTargetName;
+ this.getRemoteEnvironmentMenuItems = getRemoteEnvironmentMenuItems;
+ this.getCloudState = getCloudState;
+ this.getLocalState = getLocalState;
+ this.actions = actions;
+ this.tabs = tabs;
+
+ this.mainWindow = null;
+ this.tray = null;
+ this.launcherLoaded = false;
+ this.activeContentView = null;
+ this.tabViews = new Map();
+ }
+
+ getMainWindow() {
+ return this.mainWindow;
+ }
+
+ getTrayImage() {
+ const image = nativeImage.createFromPath(this.getWindowIconPath());
+ return image.resize({ width: 18, height: 18 });
+ }
+
+ getContentViewBounds() {
+ if (!this.mainWindow) return { x: 0, y: TITLEBAR_HEIGHT, width: 0, height: 0 };
+ const [width, height] = this.mainWindow.getContentSize();
+ return {
+ x: 0,
+ y: TITLEBAR_HEIGHT,
+ width,
+ height: Math.max(0, height - TITLEBAR_HEIGHT),
+ };
+ }
+
+ configureChildWebContents(webContents) {
+ webContents.setWindowOpenHandler(({ url }) => {
+ void this.openExternalUrl(url).catch((error) => this.actions.showError('Could not open external link', error));
+ return { action: 'deny' };
+ });
+ }
+
+ detachActiveContentView() {
+ if (!this.mainWindow || this.mainWindow.isDestroyed() || !this.activeContentView) return;
+ try {
+ if (this.mainWindow.getBrowserViews().includes(this.activeContentView)) {
+ this.mainWindow.removeBrowserView(this.activeContentView);
+ }
+ } catch {
+ // BrowserViews may already be gone during BrowserWindow teardown.
+ }
+ this.activeContentView = null;
+ }
+
+ 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;
+ }
+
+ attachContentView(view) {
+ if (!this.mainWindow || this.mainWindow.isDestroyed()) return;
+ if (this.activeContentView && this.activeContentView !== view) {
+ this.detachActiveContentView();
+ }
+ this.activeContentView = view;
+ try {
+ if (!this.mainWindow.getBrowserViews().includes(view)) {
+ this.mainWindow.addBrowserView(view);
+ }
+ } catch {
+ return;
+ }
+ view.setBounds(this.getContentViewBounds());
+ view.setAutoResize({ width: true, height: true });
+ }
+
+ async showTabPlaceholder(target, message) {
+ const tabId = this.tabs.getTabIdForTarget(target);
+ const view = this.getOrCreateTabView(tabId);
+ this.attachContentView(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(target, logs) {
+ const tabId = this.tabs.getTabIdForTarget(target);
+ const view = this.getOrCreateTabView(tabId);
+ if (view.__cloudcliLoadingUrl) return;
+ this.attachContentView(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(target) {
+ const tabId = this.tabs.getTabIdForTarget(target);
+ const view = this.getOrCreateTabView(tabId);
+ this.attachContentView(view);
+ if (view.__cloudcliLoadedUrl !== target.url) {
+ view.__cloudcliLoadingUrl = target.url;
+ try {
+ await view.webContents.loadURL(target.url);
+ view.__cloudcliLoadedUrl = target.url;
+ view.__cloudcliStartupHtml = null;
+ } finally {
+ if (view.__cloudcliLoadingUrl === target.url) {
+ view.__cloudcliLoadingUrl = null;
+ }
+ }
+ }
+ }
+
+ destroyTabView(tabId) {
+ const view = this.tabViews.get(tabId);
+ if (!view) return;
+ if (this.mainWindow && !this.mainWindow.isDestroyed()) {
+ try {
+ if (this.mainWindow.getBrowserViews().includes(view)) {
+ this.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);
+ }
+
+ emitDesktopState() {
+ if (!this.mainWindow || this.mainWindow.webContents.isDestroyed()) return;
+ this.mainWindow.webContents.send('cloudcli-desktop:state-updated', this.getDesktopState());
+ }
+
+ async showTarget(target, { trackTab = true } = {}) {
+ if (!this.mainWindow) return;
+ if (trackTab) {
+ this.tabs.upsertTarget(target);
+ }
+ this.actions.setActiveTarget(target);
+ this.buildAppMenu();
+ this.mainWindow.setTitle(`${this.appName} - ${target.name}`);
+ await this.showContentTarget(target);
+ this.emitDesktopState();
+ }
+
+ async showLauncher() {
+ if (!this.mainWindow) return;
+ const target = { kind: 'launcher', name: this.appName, url: null };
+ this.tabs.upsertTarget(target);
+ this.actions.setActiveTarget(target);
+ this.detachActiveContentView();
+ this.buildAppMenu();
+ this.mainWindow.setTitle(this.appName);
+ if (!this.launcherLoaded) {
+ await this.mainWindow.loadFile(this.getLauncherPath());
+ this.launcherLoaded = true;
+ } else {
+ this.emitDesktopState();
+ }
+ }
+
+ async switchDesktopTab(tabId) {
+ const tab = this.tabs.activate(tabId);
+ if (!tab || !this.mainWindow) return this.getDesktopState();
+
+ if (tab.id === 'home' || tab.kind === 'launcher') {
+ await this.showLauncher();
+ return this.getDesktopState();
+ }
+
+ if (!tab.target?.url) {
+ throw new Error('This tab does not have a target URL.');
+ }
+
+ await this.showTarget(tab.target, { trackTab: false });
+ return this.getDesktopState();
+ }
+
+ async closeDesktopTab(tabId) {
+ const tab = this.tabs.remove(tabId);
+ if (!tab) return this.getDesktopState();
+ this.destroyTabView(tabId);
+ if (this.tabs.activeTabId === 'home') {
+ await this.showLauncher();
+ } else {
+ this.emitDesktopState();
+ }
+ return this.getDesktopState();
+ }
+
+ buildEnvironmentActionsSubmenu(environment) {
+ const items = [];
+ const statusSuffix = environment.status === 'running' ? '' : ` (${environment.status})`;
+ items.push({
+ label: 'Open Environment',
+ click: () => void this.actions.openEnvironmentInDesktop(environment)
+ .catch((error) => this.actions.showError(`Could not open ${environment.name || environment.subdomain}${statusSuffix}`, error)),
+ });
+ items.push({
+ label: 'Open in Browser',
+ click: () => void this.actions.openEnvironmentInBrowser(environment)
+ .catch((error) => this.actions.showError('Could not open environment in browser', error)),
+ });
+ items.push({
+ label: 'Open in VS Code',
+ click: () => void this.actions.openEnvironmentInIde(environment, 'vscode')
+ .catch((error) => this.actions.showError('Could not open environment in VS Code', error)),
+ });
+ items.push({
+ label: 'Open in Cursor',
+ click: () => void this.actions.openEnvironmentInIde(environment, 'cursor')
+ .catch((error) => this.actions.showError('Could not open environment in Cursor', error)),
+ });
+ items.push({
+ label: 'Open SSH Terminal',
+ click: () => void this.actions.openEnvironmentInSsh(environment)
+ .catch((error) => this.actions.showError('Could not open SSH terminal', error)),
+ });
+ items.push({
+ label: 'Copy Mobile/Web URL',
+ click: () => this.actions.copyText(this.actions.getEnvironmentUrl(environment)),
+ });
+ if (environment.status !== 'running') {
+ items.unshift({
+ label: environment.status === 'paused' ? 'Resume' : 'Start',
+ click: () => void this.actions.startEnvironment(environment)
+ .catch((error) => this.actions.showError('Could not start environment', error)),
+ });
+ }
+ if (environment.status === 'running') {
+ items.push({
+ label: 'Stop',
+ click: () => void this.actions.stopEnvironment(environment)
+ .catch((error) => this.actions.showError('Could not stop environment', error)),
+ });
+ }
+ return items;
+ }
+
+ buildTrayEnvironmentSection() {
+ const cloudState = this.getCloudState();
+ if (!cloudState.account?.apiKey) {
+ return [
+ {
+ label: cloudState.account?.email ? `Reconnect ${cloudState.account.email}` : 'Login',
+ click: () => void this.actions.connectCloudAccount()
+ .catch((error) => this.actions.showError('Could not connect CloudCLI account', error)),
+ },
+ ];
+ }
+
+ if (!cloudState.environments.length) {
+ return [{ label: 'No environments found', enabled: false }];
+ }
+
+ return cloudState.environments.map((environment) => ({
+ label: `${environment.name || environment.subdomain} - ${environment.status}`,
+ submenu: this.buildEnvironmentActionsSubmenu(environment),
+ }));
+ }
+
+ buildAppMenu() {
+ if (!this.mainWindow) return;
+ const cloudState = this.getCloudState();
+ const localState = this.getLocalState();
+ const remoteItems = this.getRemoteEnvironmentMenuItems();
+ const cloudAccountLabel = cloudState.account?.apiKey
+ ? (cloudState.account?.email ? `Connected: ${cloudState.account.email}` : 'CloudCLI Connected')
+ : (cloudState.account?.email ? `Reconnect: ${cloudState.account.email}` : 'Connect CloudCLI Account...');
+
+ const template = [
+ {
+ label: this.appName,
+ submenu: [
+ { label: `About ${this.appName}`, role: 'about' },
+ { type: 'separator' },
+ {
+ label: 'Show Launcher',
+ accelerator: 'CmdOrCtrl+Shift+L',
+ click: () => void this.showLauncher().catch((error) => this.actions.showError('Could not show launcher', error)),
+ },
+ {
+ label: 'Switch Environment',
+ accelerator: 'CmdOrCtrl+Shift+E',
+ click: () => void this.actions.showEnvironmentPicker().catch((error) => this.actions.showError('Could not switch environment', error)),
+ },
+ { type: 'separator' },
+ {
+ label: 'Services',
+ submenu: [
+ {
+ label: 'Computer Use Preview',
+ click: () => void this.actions.showComputerUsePreview(),
+ },
+ ],
+ },
+ {
+ label: 'Diagnostics',
+ submenu: [
+ {
+ label: 'Copy Diagnostics',
+ click: () => void this.actions.copyDiagnostics(),
+ },
+ ],
+ },
+ { type: 'separator' },
+ {
+ label: process.platform === 'darwin' ? `Hide ${this.appName}` : 'Hide',
+ role: 'hide',
+ visible: process.platform === 'darwin',
+ },
+ { label: 'Hide Others', role: 'hideOthers', visible: process.platform === 'darwin' },
+ { label: 'Show All', role: 'unhide', visible: process.platform === 'darwin' },
+ { type: 'separator', visible: process.platform === 'darwin' },
+ { label: `Quit ${this.appName}`, accelerator: 'CmdOrCtrl+Q', role: 'quit' },
+ ],
+ },
+ {
+ label: 'Environment',
+ submenu: [
+ {
+ label: 'Show Launcher',
+ accelerator: 'CmdOrCtrl+Shift+L',
+ click: () => void this.showLauncher().catch((error) => this.actions.showError('Could not show launcher', error)),
+ },
+ {
+ label: 'Switch Environment',
+ accelerator: 'CmdOrCtrl+Shift+E',
+ click: () => void this.actions.showEnvironmentPicker().catch((error) => this.actions.showError('Could not switch environment', error)),
+ },
+ { type: 'separator' },
+ {
+ label: 'Open Local CloudCLI',
+ accelerator: 'CmdOrCtrl+L',
+ click: () => void this.actions.openLocalInDesktop().catch((error) => this.actions.showError('Could not open local CloudCLI', error)),
+ },
+ {
+ label: 'Open Local Web UI in Browser',
+ accelerator: 'CmdOrCtrl+Shift+W',
+ click: () => void this.actions.openLocalWebUi().catch((error) => this.actions.showError('Could not open local web UI', error)),
+ },
+ {
+ label: 'Copy Local Web URL',
+ accelerator: 'CmdOrCtrl+Shift+U',
+ click: () => void this.actions.copyLocalWebUrl().catch((error) => this.actions.showError('Could not copy local web URL', error)),
+ },
+ { type: 'separator' },
+ {
+ label: 'Keep Local Server Running After Quit',
+ type: 'checkbox',
+ checked: localState.desktopSettings.keepLocalServerRunning,
+ click: (menuItem) => void this.actions.updateDesktopSetting('keepLocalServerRunning', menuItem.checked)
+ .catch((error) => this.actions.showError('Could not update desktop setting', error)),
+ },
+ {
+ label: 'Allow LAN Access to Local Server',
+ type: 'checkbox',
+ checked: localState.desktopSettings.exposeLocalServerOnNetwork,
+ click: (menuItem) => void this.actions.updateDesktopSetting('exposeLocalServerOnNetwork', menuItem.checked)
+ .catch((error) => this.actions.showError('Could not update desktop setting', error)),
+ },
+ ],
+ },
+ {
+ label: 'Cloud',
+ submenu: [
+ {
+ label: cloudAccountLabel,
+ accelerator: 'CmdOrCtrl+Shift+C',
+ click: () => void this.actions.connectCloudAccount().catch((error) => this.actions.showError('Could not connect CloudCLI account', error)),
+ },
+ {
+ label: 'Refresh Cloud Environments',
+ click: () => void this.actions.refreshCloudEnvironments().catch((error) => this.actions.showError('Could not load CloudCLI environments', error)),
+ 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)),
+ enabled: Boolean(cloudState.account?.apiKey),
+ },
+ { type: 'separator' },
+ {
+ label: 'Remote Environments',
+ submenu: remoteItems,
+ },
+ ],
+ },
+ {
+ label: 'Edit',
+ submenu: [
+ { role: 'undo' },
+ { role: 'redo' },
+ { type: 'separator' },
+ { role: 'cut' },
+ { role: 'copy' },
+ { role: 'paste' },
+ { role: 'selectAll' },
+ ],
+ },
+ {
+ label: 'View',
+ submenu: [
+ { role: 'reload' },
+ { role: 'forceReload' },
+ { role: 'toggleDevTools' },
+ { type: 'separator' },
+ { role: 'resetZoom' },
+ { role: 'zoomIn' },
+ { role: 'zoomOut' },
+ { type: 'separator' },
+ { role: 'togglefullscreen' },
+ ],
+ },
+ {
+ label: 'Window',
+ submenu: [
+ { role: 'minimize' },
+ { role: 'zoom' },
+ ...(process.platform === 'darwin' ? [{ type: 'separator' }, { role: 'front' }] : []),
+ ],
+ },
+ {
+ label: 'Help',
+ submenu: [
+ {
+ label: 'Open cloudcli.ai',
+ click: () => void this.actions.openCloudDashboard(),
+ },
+ {
+ label: 'Copy Diagnostics',
+ click: () => void this.actions.copyDiagnostics(),
+ },
+ ],
+ },
+ ];
+
+ Menu.setApplicationMenu(Menu.buildFromTemplate(template));
+ this.buildTrayMenu();
+ }
+
+ buildTrayMenu() {
+ if (!this.tray) return;
+ const cloudState = this.getCloudState();
+ const localState = this.getLocalState();
+
+ const template = [
+ {
+ label: 'Local',
+ submenu: [
+ {
+ label: localState.localServerRunning ? 'Open Local in CloudCLI' : 'Start Local in CloudCLI',
+ click: () => void this.actions.openLocalInDesktop().catch((error) => this.actions.showError('Could not open local CloudCLI', error)),
+ },
+ {
+ label: 'Open Local in Browser',
+ click: () => void this.actions.openLocalWebUi().catch((error) => this.actions.showError('Could not open local web UI', error)),
+ },
+ {
+ label: 'Copy Local URL',
+ click: () => void this.actions.copyLocalWebUrl().catch((error) => this.actions.showError('Could not copy local web URL', error)),
+ },
+ ],
+ },
+ {
+ label: 'Cloud Environments',
+ submenu: this.buildTrayEnvironmentSection(),
+ },
+ { type: 'separator' },
+ {
+ label: cloudState.account?.email ? `Connected: ${cloudState.account.email}` : 'Login',
+ 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)),
+ enabled: Boolean(cloudState.account?.apiKey),
+ },
+ { type: 'separator' },
+ {
+ label: `Quit ${this.appName}`,
+ role: 'quit',
+ },
+ ];
+
+ this.tray.setToolTip(`${this.appName}${this.actions.getActiveTarget()?.name ? ` - ${this.actions.getActiveTarget().name}` : ''}`);
+ this.tray.setContextMenu(Menu.buildFromTemplate(template));
+ }
+
+ async showDesktopAppMenu() {
+ if (!this.mainWindow) return this.getDesktopState();
+ const menu = Menu.buildFromTemplate([
+ {
+ label: 'Copy Diagnostics',
+ click: () => void this.actions.copyDiagnostics(),
+ },
+ {
+ label: 'Computer Use Preview',
+ click: () => void this.actions.showComputerUsePreview(),
+ },
+ ]);
+ menu.popup({ window: this.mainWindow });
+ return this.getDesktopState();
+ }
+
+ async showActiveEnvironmentActionsMenu() {
+ if (!this.mainWindow) return this.getDesktopState();
+ const activeTarget = this.actions.getActiveTarget();
+ if (activeTarget?.kind !== 'remote') return this.getDesktopState();
+
+ const environment = this.getCloudState().environments.find((item) => item.id === activeTarget.id);
+ if (!environment) return this.getDesktopState();
+
+ const menu = Menu.buildFromTemplate(this.buildEnvironmentActionsSubmenu(environment));
+ menu.popup({ window: this.mainWindow });
+ return this.getDesktopState();
+ }
+
+ async showEnvironmentActionsMenu(environmentId) {
+ if (!this.mainWindow) return this.getDesktopState();
+ const environment = this.getCloudState().environments.find((item) => item.id === environmentId);
+ if (!environment) return this.getDesktopState();
+
+ const menu = Menu.buildFromTemplate(this.buildEnvironmentActionsSubmenu(environment));
+ menu.popup({ window: this.mainWindow });
+ return this.getDesktopState();
+ }
+
+ configurePermissions() {
+ session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
+ const sourceUrl = webContents.getURL();
+ const isCloudCliOrigin = sourceUrl.startsWith('http://127.0.0.1:')
+ || sourceUrl.startsWith(this.getCloudState().controlPlaneUrl)
+ || /^https:\/\/[a-z0-9-]+\.cloudcli\.ai/i.test(sourceUrl);
+ const allowedPermissions = new Set(['clipboard-read', 'media']);
+ callback(isCloudCliOrigin && allowedPermissions.has(permission));
+ });
+ }
+
+ createTray() {
+ if (this.tray) return;
+ this.tray = new Tray(this.getTrayImage());
+ this.tray.on('click', () => {
+ if (!this.mainWindow) return;
+ if (this.mainWindow.isVisible()) {
+ this.mainWindow.focus();
+ } else {
+ this.mainWindow.show();
+ }
+ });
+ this.buildTrayMenu();
+ }
+
+ async createWindow() {
+ this.mainWindow = new BrowserWindow({
+ width: 1440,
+ height: 960,
+ minWidth: 1024,
+ minHeight: 720,
+ show: false,
+ backgroundColor: '#0f172a',
+ title: this.appName,
+ icon: this.getWindowIconPath(),
+ titleBarStyle: 'hidden',
+ ...(process.platform === 'darwin'
+ ? { trafficLightPosition: { x: 18, y: 14 } }
+ : {
+ titleBarOverlay: {
+ color: nativeTheme.shouldUseDarkColors ? '#111111' : '#f7f8fa',
+ symbolColor: nativeTheme.shouldUseDarkColors ? '#a1a1a1' : '#5b6470',
+ height: 44,
+ },
+ }),
+ webPreferences: {
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
+ preload: this.getPreloadPath(),
+ },
+ });
+
+ this.mainWindow.once('ready-to-show', () => {
+ this.mainWindow?.show();
+ });
+
+ this.mainWindow.webContents.setWindowOpenHandler(({ url }) => {
+ void this.openExternalUrl(url).catch((error) => this.actions.showError('Could not open external link', error));
+ return { action: 'deny' };
+ });
+
+ this.mainWindow.on('resize', () => {
+ if (this.activeContentView) {
+ this.activeContentView.setBounds(this.getContentViewBounds());
+ }
+ });
+
+ this.mainWindow.on('closed', () => {
+ this.tabViews.clear();
+ this.activeContentView = null;
+ this.mainWindow = null;
+ this.launcherLoaded = false;
+ });
+
+ this.buildAppMenu();
+ await this.showLauncher();
+ }
+}
diff --git a/electron/launcher/index.html b/electron/launcher/index.html
new file mode 100644
index 00000000..51ac2a00
--- /dev/null
+++ b/electron/launcher/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+CloudCLI Desktop
+
+
+
+
+
+
+
diff --git a/electron/launcher/launcher.css b/electron/launcher/launcher.css
new file mode 100644
index 00000000..76fbed5f
--- /dev/null
+++ b/electron/launcher/launcher.css
@@ -0,0 +1 @@
+*{box-sizing:border-box}html,body{margin:0;height:100%}:root{--bg:#0a0a0a;--s1:#111111;--s2:#1a1a1a;--s3:#202020;--b-subtle:#1f1f1f;--b:#262626;--b-strong:#333333;--tx:#fafafa;--tx2:#a1a1a1;--tx3:#6b7280;--brand:#0b60ea;--brand-2:#60A5FA;--brand-faint:rgba(11,96,234,.16);--ok:#10b981;--warn:#f59e0b;--err:#ef4444;--tab-hover-bg:rgba(255,255,255,.10);--tab-active-bg:rgba(255,255,255,.16);--tab-active-border:rgba(255,255,255,.18);--mono:'Geist Mono','JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace;--sans:'Geist','Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;color-scheme:dark}@media (prefers-color-scheme:light){:root{--bg:#ffffff;--s1:#f7f8fa;--s2:#eef0f3;--s3:#e6e9ee;--b-subtle:#eceef1;--b:#dfe3e8;--b-strong:#c8d0d9;--tx:#0b0d10;--tx2:#5b6470;--tx3:#8a929e;--brand-faint:rgba(11,96,234,.10);--tab-hover-bg:rgba(0,0,0,.06);--tab-active-bg:rgba(0,0,0,.10);--tab-active-border:rgba(0,0,0,.12);color-scheme:light}}body{background:var(--bg);color:var(--tx);font-family:var(--sans);font-size:14px;-webkit-font-smoothing:antialiased;overflow:hidden;user-select:none}input{user-select:text}#app{height:100vh;display:flex;flex-direction:column;min-height:0}button{font:inherit;color:inherit;cursor:pointer;border:0;background:none}input{font:inherit}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-thumb{background:var(--b);border-radius:6px;border:2px solid transparent;background-clip:content-box}.mono{font-family:var(--mono)}.lbl{font-family:var(--mono);font-size:11px;letter-spacing:1.2px;text-transform:uppercase;color:var(--tx2)}svg{display:block}.dot{width:8px;height:8px;border-radius:50%;background:var(--tx3);flex:0 0 auto;display:inline-block}.titlebar{-webkit-app-region:drag;display:flex;align-items:center;gap:12px;height:44px;padding:0 12px;border-bottom:1px solid var(--b-subtle);background:var(--s1);flex:0 0 auto}.titlebar button,.titlebar input,.titlebar .no-drag{-webkit-app-region:no-drag}.brand{display:flex;align-items:center;gap:8px;font-weight:600}.brand .mk{width:22px;height:22px;display:block;flex:0 0 auto;object-fit:contain}.tb-acc{display:inline-flex;align-items:center;gap:7px;font-size:12px;color:var(--tx2);max-width:38vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.btn{display:inline-flex;align-items:center;gap:7px;height:32px;padding:0 13px;border-radius:7px;border:1px solid var(--b);background:var(--s1);color:var(--tx);font-weight:500;transition:border-color .12s,background .12s,filter .12s}.btn:hover{border-color:var(--b-strong);background:var(--s2)}.btn.pri{background:var(--brand);border-color:var(--brand);color:#fff}.btn.pri:hover{filter:brightness(1.08);background:var(--brand)}.btn.sm{height:28px;padding:0 10px;font-size:12px}.btn:disabled{opacity:.55;cursor:default}.icon-btn{width:30px;height:30px;display:grid;place-items:center;border-radius:7px;border:1px solid transparent;color:var(--tx2)}.icon-btn:hover{background:var(--s2);border-color:var(--b);color:var(--tx)}.badge{display:inline-flex;align-items:center;gap:6px;height:21px;padding:0 9px;border-radius:999px;font-size:11px;background:var(--s2);color:var(--tx2);font-family:var(--mono);white-space:nowrap}.badge.ok{color:var(--ok)}.badge.warn{color:var(--warn)}.badge.idle{color:var(--tx3)}.cc-body{flex:1;min-height:0;overflow:auto;position:relative}.statusbar{flex:0 0 auto;display:flex;align-items:center;gap:12px;height:27px;padding:0 12px;border-top:1px solid var(--b-subtle);background:var(--s1);font-size:11px;color:var(--tx2);font-family:var(--mono)}.statusbar .sep{opacity:.4}.status-msg.progress{color:var(--brand-2)}.status-msg.error{color:var(--err)}.cc-overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);display:none;z-index:50;align-items:center;justify-content:center;padding:20px}.cc-overlay.open{display:flex}.cc-sheet{width:420px;max-width:92vw;max-height:86vh;background:var(--s1);border:1px solid var(--b);border-radius:10px;padding:16px;overflow:auto;display:flex;flex-direction:column;gap:18px;box-shadow:0 20px 70px rgba(0,0,0,.35)}.cc-sheet-h{display:flex;align-items:center;justify-content:space-between}.cc-grp{display:flex;flex-direction:column;gap:10px}.cc-row2{display:grid;grid-template-columns:1fr 1fr;gap:8px}.cc-meta{color:var(--tx2);font-size:12px}.cc-toggle{display:grid;grid-template-columns:18px 1fr;gap:10px;align-items:start;color:var(--tx2);font-size:12px;line-height:1.4}.cc-toggle input{width:16px;height:16px;margin-top:1px;accent-color:var(--brand)}.cc-toggle b{color:var(--tx)}.cc-about{margin-top:auto}.v-sidebar{display:grid;grid-template-columns:248px 1fr;overflow:hidden}.sb{display:flex;flex-direction:column;gap:8px;padding:14px 12px;border-right:1px solid var(--b-subtle);background:var(--s1);overflow:auto}.sb-grp{display:flex;flex-direction:column;gap:3px}.sb-grp .lbl{padding:6px 8px}.sb-item{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;color:var(--tx2);text-align:left}.sb-item>span:nth-child(2){flex:1}.sb-item .sb-meta{font-size:11px;color:var(--tx3);font-family:var(--mono)}.sb-item:hover{background:var(--s2)}.sb-item.active{background:var(--brand-faint);color:var(--tx)}.sb-item.active svg{color:var(--brand-2)}.sb-main{overflow:auto;padding:24px;min-width:0}.pane-h{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:18px}.pane-title{margin:0;font-size:18px;font-weight:600}.pane-sub{margin:4px 0 0;color:var(--tx2);font-size:13px}.card{border:1px solid var(--b);border-radius:10px;background:var(--s1);padding:18px;display:flex;flex-direction:column;gap:16px;max-width:560px}.card-actions{display:flex;gap:8px;flex-wrap:wrap}.env{display:flex;align-items:center;gap:12px;cursor:pointer;padding:12px 14px;border:1px solid var(--b);border-radius:10px;background:var(--s1);margin-bottom:8px}.env:hover{border-color:var(--b-strong)}.env-i{flex:1;min-width:0}.env-n{font-weight:500}.env-u{font-size:12px;color:var(--tx3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.env-tags{display:flex;gap:6px}.tag{font-family:var(--mono);font-size:11px;color:var(--tx2);background:var(--s2);border:1px solid var(--b-subtle);border-radius:5px;padding:2px 7px;white-space:nowrap}.empty{border:1px dashed var(--b);border-radius:10px;padding:28px;text-align:center;color:var(--tx2);max-width:560px}body.mac .titlebar{padding-left:92px;padding-right:12px}body.win .titlebar{padding-right:150px}.titlebar .brand{margin-right:6px}.tb-tabs{display:flex;align-items:center;gap:5px;min-width:0;overflow:hidden}.tb-tab{display:inline-flex;align-items:center;gap:10px;min-width:112px;max-width:232px;flex:0 0 auto;height:30px;padding:0 7px 0 12px;border:1px solid transparent;border-radius:8px;color:var(--tx2);font-size:12px;background:transparent;transition:background .12s,color .12s}.tb-tab:hover{background:var(--tab-hover-bg)}.tb-tab.active{background:var(--tab-active-bg);backdrop-filter:blur(8px);color:var(--tx)}.tb-tab span:first-child{flex:1;min-width:0;max-width:20ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tb-close{display:grid;width:20px;height:20px;margin-left:8px;place-items:center;border-radius:6px;color:var(--tx3);font-size:14px;line-height:1;flex:0 0 auto}.tb-close:hover{background:rgba(255,255,255,.14);color:var(--tx)}.tb-env-actions{display:flex;align-items:center;gap:6px;min-width:0}.tb-env-actions .btn{height:28px;padding:0 9px;font-size:12px}.tb-action{flex:0 0 auto}.card-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.card-tools{display:flex;align-items:center;gap:8px}@media (max-width:760px){.v-sidebar{grid-template-columns:1fr}.sb{flex-direction:row;align-items:center;overflow:auto}.env-tags{display:none}}
diff --git a/electron/launcher/launcher.js b/electron/launcher/launcher.js
new file mode 100644
index 00000000..39651ecf
--- /dev/null
+++ b/electron/launcher/launcher.js
@@ -0,0 +1,520 @@
+window.__APP_VERSION__ = '1.34.0';
+window.__MOCK_STATE__ = {
+ account: { connected: true, email: 'you@cloudcli.ai' },
+ activeTarget: { kind: 'launcher', name: 'Launcher', url: null },
+ cloudLoading: false,
+ desktopSettings: { keepLocalServerRunning: false, exposeLocalServerOnNetwork: false },
+ localWebUrl: 'http://localhost:3001',
+ shareableWebUrl: 'http://localhost:3001',
+ localServerRunning: false,
+ localStartupLogs: [],
+ environments: [
+ { id: 'env-api', name: 'api-gateway', subdomain: 'api-gateway', access_url: 'https://api-gateway.cloudcli.ai', status: 'running', region: 'fra1', agent: 'Claude Code' },
+ { id: 'env-web', name: 'web-frontend', subdomain: 'web-frontend', access_url: 'https://web-frontend.cloudcli.ai', status: 'stopped', region: 'sfo1', agent: 'Codex' },
+ { id: 'env-data', name: 'data-pipeline', subdomain: 'data-pipeline', access_url: 'https://data-pipeline.cloudcli.ai', status: 'stopped', region: 'fra1', agent: 'Cursor' },
+ { id: 'env-ml', name: 'ml-trainer', subdomain: 'ml-trainer', access_url: 'https://ml-trainer.cloudcli.ai', status: 'paused', region: 'iad1', agent: 'Gemini' },
+ ],
+};
+
+(function cloudCliLauncher() {
+ var MOCK = window.__MOCK_STATE__ || {};
+ var VERSION = window.__APP_VERSION__ || '';
+ var LOGO_URL = new URL('../../public/logo-32.png', window.location.href).toString();
+
+ function clone(value) {
+ return JSON.parse(JSON.stringify(value));
+ }
+
+ var mockState = clone(MOCK);
+ var mockBridge = {
+ getState: function () { return Promise.resolve(clone(mockState)); },
+ openLocal: function () {
+ mockState.localServerRunning = true;
+ mockState.activeTarget = { kind: 'local', name: 'Local CloudCLI', url: mockState.localWebUrl };
+ return Promise.resolve(clone(mockState));
+ },
+ openLocalWebUi: function () {
+ mockState.localServerRunning = true;
+ return Promise.resolve(clone(mockState));
+ },
+ copyLocalWebUrl: function () { return Promise.resolve(clone(mockState)); },
+ connectCloud: function () {
+ mockState.account = { connected: true, email: 'you@cloudcli.ai' };
+ return Promise.resolve(clone(mockState));
+ },
+ refreshEnvironments: function () { return Promise.resolve(clone(mockState)); },
+ copyDiagnostics: function () { return Promise.resolve(clone(mockState)); },
+ showComputerUsePreview: function () { return Promise.resolve(clone(mockState)); },
+ showEnvironmentPicker: function () { return Promise.resolve(clone(mockState)); },
+ showLauncher: function () { return Promise.resolve(clone(mockState)); },
+ showDesktopAppMenu: function () { return Promise.resolve(clone(mockState)); },
+ showActiveEnvironmentActionsMenu: function () { return Promise.resolve(clone(mockState)); },
+ openCloudDashboard: function () { return Promise.resolve(clone(mockState)); },
+ runActiveEnvironmentAction: function () { return Promise.resolve(clone(mockState)); },
+ switchTab: function (id) { mockState.activeTabId = id; return Promise.resolve(clone(mockState)); },
+ closeTab: function (id) {
+ mockState.tabs = (mockState.tabs || []).filter(function (tab) { return tab.id === 'home' || tab.id !== id; });
+ if (mockState.activeTabId === id) mockState.activeTabId = 'home';
+ return Promise.resolve(clone(mockState));
+ },
+ updateSetting: function (key, value) {
+ mockState.desktopSettings = mockState.desktopSettings || {};
+ mockState.desktopSettings[key] = !!value;
+ return Promise.resolve(clone(mockState));
+ },
+ openEnvironment: function (id) {
+ var env = (mockState.environments || []).filter(function (item) { return item.id === id; })[0];
+ if (env) {
+ env.status = 'starting';
+ setTimeout(function () {
+ env.status = 'running';
+ mockState.activeTarget = { kind: 'remote', id: id, name: env.name, url: env.access_url };
+ }, 1700);
+ }
+ return Promise.resolve(clone(mockState));
+ },
+ };
+
+ var bridge = window.cloudcliDesktop || mockBridge;
+
+ var ICONS = {
+ terminal: ' ',
+ cloud: ' ',
+ refresh: ' ',
+ settings: ' ',
+ gear: ' ',
+ play: ' ',
+ arrow: ' ',
+ copy: ' ',
+ cloudPlus: ' ',
+ monitor: ' ',
+ phone: ' ',
+ x: ' ',
+ };
+ var FILLED = { play: true };
+
+ function icon(name, size) {
+ size = size || 16;
+ return '' + (ICONS[name] || '') + ' ';
+ }
+
+ function esc(value) {
+ return String(value == null ? '' : value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+
+ function statusMeta(status) {
+ var map = {
+ running: { label: 'Running', cls: 'ok', dot: '#10b981', verb: 'Opening', open: 'Open' },
+ starting: { label: 'Starting', cls: 'warn', dot: '#f59e0b', verb: 'Starting', open: 'Open', busy: true },
+ stopped: { label: 'Stopped', cls: 'idle', dot: '#6b7280', verb: 'Starting', open: 'Start & open' },
+ paused: { label: 'Paused', cls: 'warn', dot: '#f59e0b', verb: 'Resuming', open: 'Resume' },
+ };
+ return map[status] || { label: status || 'Unknown', cls: 'idle', dot: '#6b7280', verb: 'Starting', open: 'Start & open' };
+ }
+
+ function connected(state) {
+ return !!(state && state.account && state.account.connected);
+ }
+
+ function authState(state) {
+ return state && state.account ? (state.account.authState || (state.account.connected ? 'connected' : 'logged_out')) : 'logged_out';
+ }
+
+ function accountLabel(state) {
+ if (authState(state) === 'expired') return 'Reconnect';
+ if (state && state.account && state.account.email) return state.account.email;
+ if (connected(state)) return 'Connected';
+ return 'Log in';
+ }
+
+ function localUrl(state) {
+ return (state && (state.shareableWebUrl || state.localWebUrl)) || '';
+ }
+
+ function envCount(state) {
+ var count = state && state.environments ? state.environments.length : 0;
+ return count + ' environment' + (count === 1 ? '' : 's');
+ }
+
+ function errMsg(error) {
+ return error && error.message ? error.message : String(error);
+ }
+
+ var CC = {
+ icon: icon,
+ esc: esc,
+ statusMeta: statusMeta,
+ connected: connected,
+ authState: authState,
+ accountLabel: accountLabel,
+ localUrl: localUrl,
+ envCount: envCount,
+ version: VERSION,
+ logoUrl: LOGO_URL,
+ platform: 'win',
+ state: clone(MOCK),
+ ui: {},
+ _busyEnv: null,
+ _status: { msg: '', tone: '' },
+ _reg: {},
+ _wired: false,
+ _poll: null,
+ };
+
+ window.CC = CC;
+
+ var app;
+ var overlay;
+
+ CC.setState = function (state) {
+ if (state && typeof state === 'object') CC.state = state;
+ CC.render(CC.state);
+ };
+
+ CC.refresh = function () {
+ return Promise.resolve(bridge.getState()).then(function (state) {
+ CC.setState(state);
+ return state;
+ });
+ };
+
+ CC.run = function (label, fn) {
+ CC._status = { msg: label, tone: 'progress' };
+ CC.render(CC.state);
+ return Promise.resolve()
+ .then(fn)
+ .then(function (state) {
+ if (state && state.environments) CC.state = state;
+ return CC.refresh();
+ })
+ .then(function () {
+ CC._status = { msg: '', tone: '' };
+ CC.render(CC.state);
+ })
+ .catch(function (error) {
+ CC._status = { msg: errMsg(error), tone: 'error' };
+ CC.render(CC.state);
+ });
+ };
+
+ CC.startPolling = function () {
+ if (CC._poll) return;
+ var ticks = 0;
+ CC._poll = setInterval(function () {
+ ticks += 1;
+ Promise.resolve(bridge.getState()).then(function (state) {
+ CC.setState(state);
+ var anyStarting = (state.environments || []).some(function (environment) { return environment.status === 'starting'; });
+ if (!anyStarting || ticks > 16) {
+ clearInterval(CC._poll);
+ CC._poll = null;
+ if (!anyStarting) {
+ CC._status = { msg: '', tone: '' };
+ CC.render(CC.state);
+ }
+ }
+ });
+ }, 1500);
+ };
+
+ CC.openEnv = function (id) {
+ var env = (CC.state.environments || []).filter(function (environment) { return environment.id === id; })[0];
+ var meta = statusMeta(env ? env.status : '');
+ CC._busyEnv = id;
+ CC._status = { msg: (meta.verb || 'Opening') + ' ' + ((env && (env.name || env.subdomain)) || 'environment') + '...', tone: 'progress' };
+ if (env) {
+ var tabId = 'remote:' + env.id;
+ var tabs = CC.state.tabs && CC.state.tabs.length ? CC.state.tabs : [{ id: 'home', title: 'Home', kind: 'launcher', closable: false }];
+ tabs = tabs.map(function (tab) {
+ tab.active = false;
+ return tab;
+ });
+ var existing = tabs.filter(function (tab) { return tab.id === tabId; })[0];
+ if (existing) {
+ existing.active = true;
+ existing.title = env.name || env.subdomain;
+ } else {
+ tabs.push({ id: tabId, title: env.name || env.subdomain, kind: 'remote', closable: true, active: true });
+ }
+ CC.state.tabs = tabs;
+ CC.state.activeTabId = tabId;
+ }
+ if (env && env.status !== 'running') env.status = 'starting';
+ CC.render(CC.state);
+ return Promise.resolve(bridge.openEnvironment(id)).then(function (state) {
+ if (state && state.environments) CC.setState(state);
+ CC.startPolling();
+ }).catch(function (error) {
+ CC._busyEnv = null;
+ if (env) env.status = 'stopped';
+ CC._status = { msg: errMsg(error), tone: 'error' };
+ CC.render(CC.state);
+ });
+ };
+
+ CC.act = function (name, node) {
+ switch (name) {
+ case 'local':
+ 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 'open-web':
+ return CC.run('Opening local web UI in your browser...', function () { return bridge.openLocalWebUi(); });
+ case 'copy-web':
+ return CC.run('Copied local URL to clipboard', function () { return bridge.copyLocalWebUrl(); });
+ case 'diagnostics':
+ return CC.run('Copied diagnostics to clipboard', function () { return bridge.copyDiagnostics(); });
+ case 'computer-use':
+ return CC.run('Opening Computer Use preview...', function () { return bridge.showComputerUsePreview(); });
+ case 'set-setting':
+ return CC.run('Saved', function () { return bridge.updateSetting(node.key, node.value); });
+ case 'settings-toggle':
+ return CC.run('Opening settings...', function () { return bridge.showDesktopAppMenu(); });
+ case 'dashboard':
+ return CC.run('Opening CloudCLI dashboard...', function () { return bridge.openCloudDashboard(); });
+ case 'env-action':
+ return CC.run('Opening environment...', function () { return bridge.runActiveEnvironmentAction(node.getAttribute('data-cc-env-action')); });
+ case 'env-menu':
+ return CC.run('Opening environment actions...', function () { return bridge.showActiveEnvironmentActionsMenu(); });
+ case 'env-row-menu':
+ return CC.run('Opening environment actions...', function () { return bridge.showEnvironmentActionsMenu(node.getAttribute('data-cc-environment-id')); });
+ case 'local-settings-toggle':
+ CC.renderLocalSettings();
+ overlay.classList.toggle('open');
+ return;
+ case 'settings-close':
+ overlay.classList.remove('open');
+ return;
+ default:
+ return;
+ }
+ };
+
+ function renderTabs(state) {
+ var tabs = state.tabs && state.tabs.length ? state.tabs : [{ id: 'home', title: 'Home', closable: false, active: true }];
+ return tabs.map(function (tab) {
+ var title = tab.title || '';
+ var visibleChars = Math.min(title.length, 20);
+ var tabWidth = Math.max(112, Math.min(232, (visibleChars * 8) + (tab.closable ? 56 : 38)));
+ return '' +
+ '' + esc(title) + ' ' +
+ (tab.closable ? '× ' : '') +
+ ' ';
+ }).join('');
+ }
+
+ CC.titlebar = function (state) {
+ var conn = connected(state);
+ var activeRemote = state.activeTarget && state.activeTarget.kind === 'remote';
+ var envActions = activeRemote ? 'Open environment in... ' : '';
+ return '' +
+ '
CloudCLI ' +
+ '
' + renderTabs(state) + '
' +
+ '
' +
+ envActions +
+ '
' + esc(accountLabel(state)) + '' +
+ '
' + icon('settings', 16) + ' ' +
+ '
';
+ };
+
+ CC.statusbar = function (state) {
+ var status = CC._status || {};
+ var running = !!state.localServerRunning;
+ return '' +
+ ' local ' + (running ? 'running · ' + esc(localUrl(state)) : 'idle') + ' ' +
+ '· ' + esc(envCount(state)) + ' ' +
+ '· ' + (authState(state) === 'expired' ? 'session expired' : (connected(state) ? esc(accountLabel(state)) : 'not connected')) + ' ' +
+ ' ' +
+ (status.msg ? '' + esc(status.msg) + ' · ' : '') +
+ 'v' + esc(VERSION) + ' ' +
+ '
';
+ };
+
+ CC.renderLocalSettings = function () {
+ var state = CC.state || {};
+ var settings = state.desktopSettings || {};
+ var url = localUrl(state) || 'starts on demand';
+ overlay.innerHTML =
+ '' +
+ '
Local Settings ' + icon('x', 16) + '
' +
+ '
Local server
' +
+ '
' + esc(url) + '
' +
+ '
' + icon('arrow', 14) + 'Open in browser ' + icon('copy', 14) + 'Copy URL
' +
+ '
Keep server running Leave Local CloudCLI available after you quit the app.' +
+ '
Allow LAN access Use the copied URL from another device on this network.' +
+ '
' +
+ '
';
+ };
+
+ CC.render = function (state) {
+ state = state || CC.state;
+ var titlebar = (CC._reg.titlebar || CC.titlebar)(state);
+ var statusbar = (CC._reg.statusbar || CC.statusbar)(state);
+ var body = CC._reg.renderBody ? CC._reg.renderBody(state) : '';
+ app.innerHTML = titlebar + '' + body + '
' + statusbar;
+ if (CC._reg.afterRender) CC._reg.afterRender(state);
+ };
+
+ function wireEvents() {
+ if (CC._wired) return;
+ CC._wired = true;
+
+ document.addEventListener('click', function (event) {
+ if (CC._reg.onClick && CC._reg.onClick(event)) return;
+ var closeTab = event.target.closest('[data-cc-close-tab]');
+ if (closeTab) {
+ event.stopPropagation();
+ CC.run('Closing tab...', function () { return bridge.closeTab(closeTab.getAttribute('data-cc-close-tab')); });
+ return;
+ }
+ var tab = event.target.closest('[data-cc-tab]');
+ if (tab) {
+ CC.run('Switching tab...', function () { return bridge.switchTab(tab.getAttribute('data-cc-tab')); });
+ return;
+ }
+ var action = event.target.closest('[data-cc-action]');
+ if (action) {
+ CC.act(action.getAttribute('data-cc-action'), action);
+ return;
+ }
+ var env = event.target.closest('[data-cc-env]');
+ if (env) {
+ CC.openEnv(env.getAttribute('data-cc-env'));
+ return;
+ }
+ if (overlay.classList.contains('open') && !event.target.closest('.cc-sheet')) {
+ overlay.classList.remove('open');
+ }
+ });
+
+ document.addEventListener('change', function (event) {
+ var setting = event.target.closest('[data-cc-setting]');
+ if (setting) {
+ CC.act('set-setting', {
+ key: setting.getAttribute('data-cc-setting'),
+ value: setting.checked,
+ });
+ }
+ });
+
+ document.addEventListener('keydown', function (event) {
+ if (event.key === 'Escape' && overlay.classList.contains('open')) {
+ overlay.classList.remove('open');
+ return;
+ }
+ if ((event.metaKey || event.ctrlKey) && event.key === ',') {
+ event.preventDefault();
+ CC.act('settings-toggle');
+ return;
+ }
+ if (overlay.classList.contains('open')) return;
+ if (CC._reg.onKey) CC._reg.onKey(event, CC.state);
+ });
+ }
+
+ function boot() {
+ app = document.getElementById('app');
+ overlay = document.createElement('div');
+ overlay.id = 'cc-overlay';
+ overlay.className = 'cc-overlay';
+ document.body.appendChild(overlay);
+
+ var isMac = /Mac/i.test(navigator.platform) || /Mac OS X/i.test(navigator.userAgent);
+ var isWin = /Win/i.test(navigator.platform);
+ CC.platform = isMac ? 'mac' : (isWin ? 'win' : 'linux');
+ document.body.classList.add(CC.platform);
+
+ wireEvents();
+ if (bridge.onStateUpdated) {
+ bridge.onStateUpdated(function (state) { CC.setState(state); });
+ }
+ CC.refresh().catch(function (error) {
+ CC._status = { msg: errMsg(error), tone: 'error' };
+ CC.render(CC.state);
+ });
+ }
+
+ CC.register = function (registry) {
+ CC._reg = registry || {};
+ };
+
+ CC.start = function () {
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', boot);
+ } else {
+ boot();
+ }
+ };
+})();
+
+(function sidebarApp() {
+ var CC = window.CC;
+
+ function navItem(id, iconName, label, meta, selected) {
+ return '' +
+ CC.icon(iconName, 16) + '' + label + ' ' + CC.esc(meta) + ' ';
+ }
+
+ function localPane(state) {
+ return 'Local CloudCLI Run the open-source app on this machine. No account required.
' +
+ 'Local server
' + CC.esc(CC.localUrl(state) || 'Starts on demand') + '
' + CC.icon('gear', 16) + '
' +
+ '
' + CC.icon('play', 15) + 'Open Local CloudCLI ' + CC.icon('arrow', 14) + 'Open in browser ' + CC.icon('copy', 14) + 'Copy URL
';
+ }
+
+ function envRow(environment) {
+ var meta = CC.statusMeta(environment.status);
+ var tags = (environment.agent ? '' + CC.esc(environment.agent) + ' ' : '') + (environment.region ? '' + CC.esc(environment.region) + ' ' : '');
+ return '' +
+ '
' + CC.esc(environment.name || environment.subdomain) + '
' + CC.esc(environment.access_url || '') + '
' +
+ '
' + tags + '
' +
+ '
' + meta.label + ' ' +
+ '
Open environment in... ' +
+ '
' + CC.icon(meta.busy ? 'refresh' : (environment.status === 'running' ? 'arrow' : 'play'), 14) + meta.open + ' ';
+ }
+
+ function cloudPane(state) {
+ var header = 'Environments ' + CC.esc(CC.envCount(state)) + '
' + CC.icon('arrow', 14) + 'Dashboard ';
+ if (CC.authState(state) === 'expired') {
+ return header + 'Your CloudCLI session expired.
' + CC.icon('cloudPlus', 15) + 'Reconnect account
';
+ }
+ if (!CC.connected(state)) {
+ return header + 'Connect your CloudCLI account to list hosted environments.
' + CC.icon('cloudPlus', 15) + 'Connect account
';
+ }
+ if (state.cloudLoading && !(state.environments || []).length) {
+ return header + 'Loading your CloudCLI environments...
';
+ }
+
+ var list = (state.environments || []).map(envRow).join('');
+ if (!list) list = 'No hosted environments yet.
';
+ return header + list;
+ }
+
+ function renderBody(state) {
+ var section = CC.ui.section || ((CC.connected(state) || CC.authState(state) === 'expired') ? 'cloud' : 'local');
+ CC.ui.section = section;
+ var nav = 'Workspace
' +
+ navItem('local', 'terminal', 'Local', state.localServerRunning ? 'on' : 'idle', section) +
+ navItem('cloud', 'cloud', 'Cloud', (state.environments || []).length, section) +
+ '
';
+ return nav + '' + (section === 'local' ? localPane(state) : cloudPane(state)) + '
';
+ }
+
+ function onClick(event) {
+ var nav = event.target.closest('[data-cc-nav]');
+ if (!nav) return false;
+ CC.ui.section = nav.getAttribute('data-cc-nav');
+ CC.render(CC.state);
+ return true;
+ }
+
+ CC.register({
+ bodyClass: 'v-sidebar',
+ renderBody: renderBody,
+ onClick: onClick,
+ });
+ CC.start();
+})();
diff --git a/electron/localServer.js b/electron/localServer.js
new file mode 100644
index 00000000..391e0c82
--- /dev/null
+++ b/electron/localServer.js
@@ -0,0 +1,483 @@
+import { spawn } from 'node:child_process';
+import fs from 'node:fs/promises';
+import http from 'node:http';
+import net from 'node:net';
+import os from 'node:os';
+import path from 'node:path';
+
+const DEFAULT_PORT = 3001;
+const HOST = '127.0.0.1';
+const DISPLAY_HOST = 'localhost';
+const HEALTH_TIMEOUT_MS = 1000;
+const SERVER_START_TIMEOUT_MS = 30000;
+const MAX_STARTUP_LOG_LINES = 300;
+const SERVER_MARKER_PATH = path.join(os.homedir(), '.cloudcli', 'local-server.json');
+const LOCAL_SERVER_URL_ENV_KEYS = [
+ 'CLOUDCLI_DESKTOP_LOCAL_SERVER_URL',
+ 'CLOUDCLI_LOCAL_SERVER_URL',
+ 'ELECTRON_LOCAL_SERVER_URL',
+];
+const LOCAL_SERVER_PORT_ENV_KEYS = [
+ 'CLOUDCLI_DESKTOP_LOCAL_SERVER_PORT',
+ 'CLOUDCLI_SERVER_PORT',
+ 'SERVER_PORT',
+ 'PORT',
+];
+
+function requestJson(url, timeoutMs = HEALTH_TIMEOUT_MS) {
+ return new Promise((resolve) => {
+ const req = http.get(url, { timeout: timeoutMs }, (res) => {
+ let body = '';
+
+ res.setEncoding('utf8');
+ res.on('data', (chunk) => {
+ body += chunk;
+ });
+ res.on('end', () => {
+ try {
+ resolve({
+ ok: res.statusCode >= 200 && res.statusCode < 300,
+ json: JSON.parse(body),
+ });
+ } catch {
+ resolve({ ok: false, json: null });
+ }
+ });
+ });
+
+ req.on('timeout', () => {
+ req.destroy();
+ resolve({ ok: false, json: null });
+ });
+ req.on('error', () => resolve({ ok: false, json: null }));
+ });
+}
+
+async function isCloudCliServer(baseUrl) {
+ const response = await requestJson(`${baseUrl}/health`);
+ return response.ok
+ && response.json?.status === 'ok'
+ && typeof response.json?.installMode === 'string';
+}
+
+function isPortAvailable(port, host = HOST) {
+ return new Promise((resolve) => {
+ const server = net.createServer();
+
+ server.once('error', () => resolve(false));
+ server.once('listening', () => {
+ server.close(() => resolve(true));
+ });
+ server.listen(port, host);
+ });
+}
+
+function getFreePort() {
+ return new Promise((resolve, reject) => {
+ const server = net.createServer();
+
+ server.once('error', reject);
+ server.once('listening', () => {
+ const address = server.address();
+ const port = typeof address === 'object' && address ? address.port : DEFAULT_PORT;
+ server.close(() => resolve(port));
+ });
+ server.listen(0, HOST);
+ });
+}
+
+async function chooseServerPort(host) {
+ if (await isPortAvailable(DEFAULT_PORT, host)) {
+ return DEFAULT_PORT;
+ }
+
+ return getFreePort();
+}
+
+function getDesktopPath() {
+ const currentPath = process.env.PATH || '';
+ const commonPaths = process.platform === 'win32'
+ ? []
+ : ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
+
+ return [...commonPaths, currentPath].filter(Boolean).join(path.delimiter);
+}
+
+function getNodeRuntime(usePackagedElectronRuntime) {
+ if (process.env.ELECTRON_NODE_PATH) {
+ return { command: process.env.ELECTRON_NODE_PATH, env: {}, label: 'ELECTRON_NODE_PATH' };
+ }
+
+ if (usePackagedElectronRuntime && process.versions.electron) {
+ return {
+ command: process.execPath,
+ env: { ELECTRON_RUN_AS_NODE: '1' },
+ label: `Electron ${process.versions.electron} Node ${process.versions.node}`,
+ };
+ }
+
+ if (process.env.npm_node_execpath) {
+ return { command: process.env.npm_node_execpath, env: {}, label: 'npm_node_execpath' };
+ }
+
+ return { command: 'node', env: {}, label: 'PATH node' };
+}
+
+function stripTrailingSlash(value) {
+ return value.endsWith('/') ? value.slice(0, -1) : value;
+}
+
+function addCandidateUrl(urls, rawUrl) {
+ if (!rawUrl) return;
+ try {
+ const parsed = new URL(String(rawUrl));
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return;
+ parsed.hash = '';
+ parsed.search = '';
+ const normalized = stripTrailingSlash(parsed.toString());
+ if (!urls.includes(normalized)) urls.push(normalized);
+ } catch {
+ // Ignore invalid user-provided discovery values.
+ }
+}
+
+function addCandidatePort(urls, rawPort) {
+ const port = Number.parseInt(String(rawPort || ''), 10);
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return;
+ addCandidateUrl(urls, `http://${HOST}:${port}`);
+}
+
+function getPortFromUrl(baseUrl) {
+ try {
+ const parsed = new URL(baseUrl);
+ if (parsed.port) return Number.parseInt(parsed.port, 10);
+ return parsed.protocol === 'https:' ? 443 : 80;
+ } catch {
+ return null;
+ }
+}
+
+function getDisplayUrl(baseUrl) {
+ try {
+ const parsed = new URL(baseUrl);
+ if (parsed.hostname === HOST) {
+ parsed.hostname = DISPLAY_HOST;
+ }
+ return stripTrailingSlash(parsed.toString());
+ } catch {
+ return baseUrl;
+ }
+}
+
+async function readServerMarkerUrl() {
+ try {
+ const raw = await fs.readFile(SERVER_MARKER_PATH, 'utf8');
+ const marker = JSON.parse(raw);
+ return marker.url || (marker.port ? `http://${marker.host || HOST}:${marker.port}` : null);
+ } catch {
+ return null;
+ }
+}
+
+async function getExistingServerCandidateUrls(defaultUrl) {
+ const urls = [];
+
+ for (const key of LOCAL_SERVER_URL_ENV_KEYS) {
+ addCandidateUrl(urls, process.env[key]);
+ }
+
+ addCandidateUrl(urls, await readServerMarkerUrl());
+
+ for (const key of LOCAL_SERVER_PORT_ENV_KEYS) {
+ addCandidatePort(urls, process.env[key]);
+ }
+
+ addCandidateUrl(urls, defaultUrl);
+ return urls;
+}
+
+async function waitForCloudCliServer(baseUrl, timeoutMs) {
+ const startedAt = Date.now();
+
+ while (Date.now() - startedAt < timeoutMs) {
+ if (await isCloudCliServer(baseUrl)) {
+ return true;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ }
+
+ return false;
+}
+
+export class LocalServerController {
+ constructor({ appRoot, settingsPath, isPackaged = false, onChange }) {
+ this.appRoot = appRoot;
+ this.settingsPath = settingsPath;
+ this.isPackaged = isPackaged;
+ this.onChange = onChange;
+ this.localServerUrl = null;
+ this.localServerPort = null;
+ this.ownedServerProcess = null;
+ this.startupLogs = [];
+ this.desktopSettings = {
+ keepLocalServerRunning: false,
+ exposeLocalServerOnNetwork: false,
+ };
+ }
+
+ getSettings() {
+ return this.desktopSettings;
+ }
+
+ getLocalServerUrl() {
+ return this.localServerUrl;
+ }
+
+ getHealthCheckUrl() {
+ if (!this.localServerPort) return this.localServerUrl;
+ return `http://${HOST}:${this.localServerPort}`;
+ }
+
+ appendStartupLog(line) {
+ const text = String(line || '').trimEnd();
+ if (!text) return;
+ const timestamp = new Date().toLocaleTimeString();
+ this.startupLogs.push(`[${timestamp}] ${text}`);
+ if (this.startupLogs.length > MAX_STARTUP_LOG_LINES) {
+ this.startupLogs.splice(0, this.startupLogs.length - MAX_STARTUP_LOG_LINES);
+ }
+ this.onChange?.();
+ }
+
+ getStartupLogs() {
+ return [...this.startupLogs];
+ }
+
+ getPendingTarget() {
+ return {
+ kind: 'local',
+ name: 'Local CloudCLI',
+ url: this.localServerUrl || `http://${DISPLAY_HOST}:${this.localServerPort || DEFAULT_PORT}`,
+ };
+ }
+
+ getLanAddress() {
+ const interfaces = os.networkInterfaces();
+ for (const entries of Object.values(interfaces)) {
+ for (const entry of entries || []) {
+ if (entry.family === 'IPv4' && !entry.internal) {
+ return entry.address;
+ }
+ }
+ }
+ return null;
+ }
+
+ getShareableWebUrl() {
+ if (!this.localServerUrl || !this.localServerPort) return null;
+ if (this.desktopSettings.exposeLocalServerOnNetwork) {
+ const lanAddress = this.getLanAddress();
+ if (lanAddress) {
+ return `http://${lanAddress}:${this.localServerPort}`;
+ }
+ }
+ return this.getLocalServerUrl();
+ }
+
+ getServerBindHost() {
+ return this.desktopSettings.exposeLocalServerOnNetwork ? '0.0.0.0' : HOST;
+ }
+
+ async loadDesktopSettings() {
+ try {
+ const raw = await fs.readFile(this.settingsPath, 'utf8');
+ const stored = JSON.parse(raw);
+ this.desktopSettings = {
+ keepLocalServerRunning: Boolean(stored.keepLocalServerRunning),
+ exposeLocalServerOnNetwork: Boolean(stored.exposeLocalServerOnNetwork),
+ };
+ } catch {
+ this.desktopSettings = {
+ keepLocalServerRunning: false,
+ exposeLocalServerOnNetwork: false,
+ };
+ }
+ }
+
+ async saveDesktopSettings(nextSettings = this.desktopSettings) {
+ this.desktopSettings = {
+ keepLocalServerRunning: Boolean(nextSettings.keepLocalServerRunning),
+ exposeLocalServerOnNetwork: Boolean(nextSettings.exposeLocalServerOnNetwork),
+ };
+ await fs.mkdir(path.dirname(this.settingsPath), { recursive: true });
+ await fs.writeFile(this.settingsPath, JSON.stringify(this.desktopSettings, null, 2), 'utf8');
+ this.onChange?.();
+ }
+
+ async updateDesktopSetting(key, value) {
+ if (!Object.prototype.hasOwnProperty.call(this.desktopSettings, key)) {
+ throw new Error(`Unknown desktop setting: ${key}`);
+ }
+
+ const wasExposeSetting = key === 'exposeLocalServerOnNetwork';
+ const wasLocalRunning = Boolean(this.localServerUrl);
+ await this.saveDesktopSettings({ ...this.desktopSettings, [key]: Boolean(value) });
+
+ return {
+ desktopSettings: this.desktopSettings,
+ requiresRestartNotice: wasExposeSetting && wasLocalRunning,
+ };
+ }
+
+ startBundledServer(port) {
+ const serverEntry = process.env.ELECTRON_SERVER_ENTRY
+ || path.join(this.appRoot, 'dist-server', 'server', 'index.js');
+ const bindHost = this.getServerBindHost();
+ const runtime = getNodeRuntime(this.isPackaged);
+
+ const command = `${runtime.command} ${serverEntry}`;
+ this.appendStartupLog(`$ ${command}`);
+ this.appendStartupLog(`runtime: ${runtime.label}`);
+ this.appendStartupLog(`cwd: ${this.appRoot}`);
+ this.appendStartupLog(`HOST=${bindHost} SERVER_PORT=${port} NODE_ENV=production`);
+
+ this.ownedServerProcess = spawn(runtime.command, [serverEntry], {
+ cwd: this.appRoot,
+ detached: true,
+ env: {
+ ...process.env,
+ ...runtime.env,
+ HOST: bindHost,
+ SERVER_PORT: String(port),
+ NODE_ENV: 'production',
+ PATH: getDesktopPath(),
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ windowsHide: true,
+ });
+
+ this.ownedServerProcess.once('error', (error) => {
+ this.appendStartupLog(`failed to start process: ${error.message}`);
+ this.ownedServerProcess = null;
+ });
+
+ this.ownedServerProcess.stdout?.on('data', (chunk) => {
+ for (const line of String(chunk).split(/\r?\n/)) {
+ this.appendStartupLog(line);
+ }
+ });
+
+ this.ownedServerProcess.stderr?.on('data', (chunk) => {
+ for (const line of String(chunk).split(/\r?\n/)) {
+ this.appendStartupLog(`stderr: ${line}`);
+ }
+ });
+
+ this.ownedServerProcess.once('exit', (code, signal) => {
+ this.appendStartupLog(`process exited with code ${code ?? 'null'} and signal ${signal ?? 'null'}`);
+ if (this.ownedServerProcess) {
+ console.error(`CloudCLI desktop server exited with code ${code ?? 'null'} and signal ${signal ?? 'null'}`);
+ }
+ this.ownedServerProcess = null;
+ });
+ }
+
+ async resolveLocalServerUrl() {
+ const defaultUrl = `http://${HOST}:${DEFAULT_PORT}`;
+ const defaultDisplayUrl = `http://${DISPLAY_HOST}:${DEFAULT_PORT}`;
+ const devUrl = process.env.ELECTRON_DEV_URL;
+ const forceOwnServer = process.env.ELECTRON_FORCE_OWN_SERVER === '1';
+
+ if (devUrl) {
+ const ready = await waitForCloudCliServer(defaultUrl, SERVER_START_TIMEOUT_MS);
+ if (!ready) {
+ throw new Error(`Development backend did not become ready at ${defaultDisplayUrl}`);
+ }
+ this.localServerPort = DEFAULT_PORT;
+ return devUrl;
+ }
+
+ if (!forceOwnServer) {
+ const candidateUrls = await getExistingServerCandidateUrls(defaultUrl);
+ for (const candidateUrl of candidateUrls) {
+ if (await isCloudCliServer(candidateUrl)) {
+ const displayUrl = getDisplayUrl(candidateUrl);
+ this.localServerPort = getPortFromUrl(candidateUrl);
+ this.appendStartupLog(`Using existing Local CloudCLI at ${displayUrl}`);
+ return displayUrl;
+ }
+ }
+ }
+
+ const port = await chooseServerPort(this.getServerBindHost());
+ const serverUrl = `http://${HOST}:${port}`;
+ const displayUrl = `http://${DISPLAY_HOST}:${port}`;
+ this.localServerPort = port;
+ this.startBundledServer(port);
+
+ const ready = await waitForCloudCliServer(serverUrl, SERVER_START_TIMEOUT_MS);
+ if (!ready) {
+ const recentLogs = this.getStartupLogs().slice(-20).join('\n');
+ this.localServerPort = null;
+ throw new Error([
+ `Bundled backend did not become ready at ${displayUrl}.`,
+ recentLogs ? `Recent startup output:\n${recentLogs}` : 'No startup output was captured.',
+ ].join('\n\n'));
+ }
+
+ this.appendStartupLog(`Local CloudCLI ready at ${displayUrl}`);
+ this.localServerUrl = displayUrl;
+ return displayUrl;
+ }
+
+ async ensureLocalServer() {
+ if (!this.localServerUrl) {
+ this.localServerUrl = await this.resolveLocalServerUrl();
+ }
+ return this.localServerUrl;
+ }
+
+ async getResolvedTarget() {
+ await this.ensureLocalServer();
+ return {
+ kind: 'local',
+ name: 'Local CloudCLI',
+ url: this.localServerUrl,
+ };
+ }
+
+ async loadLocalTarget() {
+ return {
+ pendingTarget: this.getPendingTarget(),
+ target: await this.getResolvedTarget(),
+ };
+ }
+
+ hasOwnedServer() {
+ return Boolean(this.ownedServerProcess);
+ }
+
+ detachOwnedServer() {
+ if (!this.ownedServerProcess) return;
+ this.ownedServerProcess.unref();
+ this.ownedServerProcess = null;
+ }
+
+ async shutdownOwnedServer() {
+ if (!this.ownedServerProcess) return;
+
+ const child = this.ownedServerProcess;
+ this.ownedServerProcess = null;
+ child.kill('SIGTERM');
+
+ await new Promise((resolve) => {
+ const timeout = setTimeout(resolve, 3000);
+ child.once('exit', () => {
+ clearTimeout(timeout);
+ resolve();
+ });
+ });
+ }
+}
+
+export { DEFAULT_PORT, HOST };
diff --git a/electron/main.js b/electron/main.js
new file mode 100644
index 00000000..323e8c1a
--- /dev/null
+++ b/electron/main.js
@@ -0,0 +1,789 @@
+import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from 'electron';
+import { spawn } from 'node:child_process';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { CloudController } from './cloud.js';
+import { DesktopWindowManager } from './desktopWindow.js';
+import { LocalServerController } from './localServer.js';
+import { TabsController } from './tabs.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+const APP_NAME = 'CloudCLI';
+const CALLBACK_PROTOCOL = 'cloudcli';
+const CALLBACK_URL = `${CALLBACK_PROTOCOL}://auth/callback`;
+const CLOUDCLI_CONTROL_PLANE_URL = process.env.CLOUDCLI_CONTROL_PLANE_URL || 'https://cloudcli.ai';
+const REMOTE_START_TIMEOUT_MS = 30000;
+
+const tabs = new TabsController();
+
+let activeTarget = { kind: 'launcher', name: APP_NAME, url: null };
+let desktopWindow = null;
+let localServer = null;
+let cloud = null;
+let isQuitting = false;
+let isRefreshingCloud = false;
+
+function getAppRoot() {
+ return app.isPackaged ? app.getAppPath() : path.resolve(__dirname, '..');
+}
+
+function getLauncherPath() {
+ return path.join(__dirname, 'launcher', 'index.html');
+}
+
+function getPreloadPath() {
+ return path.join(__dirname, 'preload.cjs');
+}
+
+function getWindowIconPath() {
+ if (process.platform === 'darwin') {
+ return path.join(getAppRoot(), 'electron', 'assets', 'logo-macos.png');
+ }
+ return path.join(getAppRoot(), 'public', 'logo-512.png');
+}
+
+function getStorePath() {
+ return path.join(app.getPath('userData'), 'cloud-account.json');
+}
+
+function getSettingsPath() {
+ return path.join(app.getPath('userData'), 'desktop-settings.json');
+}
+
+function getDisplayTargetName() {
+ return activeTarget?.name || APP_NAME;
+}
+
+function getCloudState() {
+ return {
+ account: cloud.getAccount(),
+ environments: cloud.getEnvironments(),
+ controlPlaneUrl: CLOUDCLI_CONTROL_PLANE_URL,
+ };
+}
+
+function getLocalState() {
+ return {
+ desktopSettings: localServer.getSettings(),
+ localServerRunning: Boolean(localServer.getLocalServerUrl()),
+ localWebUrl: localServer.getLocalServerUrl(),
+ shareableWebUrl: localServer.getShareableWebUrl(),
+ };
+}
+
+function serializeEnvironment(environment) {
+ return {
+ id: environment.id,
+ name: environment.name,
+ subdomain: environment.subdomain,
+ access_url: cloud.getEnvironmentUrl(environment),
+ status: environment.status,
+ created_at: environment.created_at,
+ github_url: environment.github_url || null,
+ region: environment.region || null,
+ agent: environment.agent || null,
+ };
+}
+
+function getDesktopState() {
+ const cloudAccount = cloud.getAccount();
+ const localState = getLocalState();
+ const authState = cloud.getAuthState();
+ return {
+ account: {
+ connected: authState === 'connected',
+ email: cloudAccount?.email || null,
+ authState,
+ requiresReconnect: authState === 'expired',
+ },
+ activeTarget,
+ desktopSettings: localState.desktopSettings,
+ localWebUrl: localState.localWebUrl,
+ shareableWebUrl: localState.shareableWebUrl,
+ localServerRunning: localState.localServerRunning,
+ localStartupLogs: localServer.getStartupLogs(),
+ cloudLoading: isRefreshingCloud,
+ tabs: tabs.getSerializableTabs(),
+ activeTabId: tabs.activeTabId,
+ environments: cloud.getEnvironments().map(serializeEnvironment),
+ };
+}
+
+function isSafeExternalUrl(url) {
+ try {
+ const parsed = new URL(url);
+ return ['https:', 'http:', 'mailto:'].includes(parsed.protocol)
+ || (parsed.protocol === `${CALLBACK_PROTOCOL}:` && parsed.hostname === 'auth');
+ } catch {
+ return false;
+ }
+}
+
+async function openExternalUrl(url) {
+ if (!isSafeExternalUrl(url)) {
+ throw new Error(`Refusing to open unsupported external URL: ${url}`);
+ }
+
+ if (url.startsWith(`${CALLBACK_PROTOCOL}://`)) {
+ await handleDeepLink(url);
+ return;
+ }
+
+ await shell.openExternal(url);
+}
+
+async function showError(title, error) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.error(`${title}: ${message}`);
+ await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
+ type: 'error',
+ title,
+ message: title,
+ detail: message,
+ });
+}
+
+function isExpectedNavigationAbort(error) {
+ const message = error instanceof Error ? error.message : String(error);
+ return error?.code === 'ERR_ABORTED' || message.includes('ERR_ABORTED') || message.includes('(-3)');
+}
+
+function syncDesktopState() {
+ if (!desktopWindow) return;
+ desktopWindow.buildAppMenu();
+ desktopWindow.emitDesktopState();
+ if (activeTarget?.kind === 'local' && !localServer?.getLocalServerUrl()) {
+ void desktopWindow.showLocalStartupTarget(localServer.getPendingTarget(), localServer.getStartupLogs())
+ .catch((error) => {
+ if (isExpectedNavigationAbort(error)) return;
+ void showError('Could not update local startup log', error);
+ });
+ }
+}
+
+function setActiveTarget(target) {
+ activeTarget = target;
+}
+
+function getEnvironmentTarget(environment) {
+ return {
+ kind: 'remote',
+ id: environment.id,
+ name: environment.name || environment.subdomain,
+ url: cloud.getEnvironmentUrl(environment),
+ };
+}
+
+async function getEnvironmentLaunchTarget(environment) {
+ return {
+ ...getEnvironmentTarget(environment),
+ url: await cloud.getEnvironmentLaunchUrl(environment),
+ };
+}
+
+function getDiagnosticsText() {
+ const cloudAccount = cloud.getAccount();
+ const localState = getLocalState();
+ return JSON.stringify({
+ app: APP_NAME,
+ version: app.getVersion(),
+ electron: process.versions.electron,
+ node: process.versions.node,
+ platform: process.platform,
+ arch: process.arch,
+ appPath: getAppRoot(),
+ userDataPath: app.getPath('userData'),
+ activeTarget,
+ localServerUrl: localState.localWebUrl,
+ localServerPort: localServer.localServerPort,
+ localWebUrl: localState.localWebUrl,
+ shareableWebUrl: localState.shareableWebUrl,
+ desktopSettings: localState.desktopSettings,
+ cloudConnected: Boolean(cloudAccount?.apiKey),
+ cloudEmail: cloudAccount?.email || null,
+ cloudEnvironmentCount: cloud.getEnvironments().length,
+ controlPlaneUrl: CLOUDCLI_CONTROL_PLANE_URL,
+ }, null, 2);
+}
+
+async function copyDiagnostics() {
+ clipboard.writeText(getDiagnosticsText());
+ await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
+ type: 'info',
+ title: 'Diagnostics copied',
+ message: 'CloudCLI desktop diagnostics were copied to the clipboard.',
+ });
+}
+
+async function showComputerUsePreview() {
+ await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
+ type: 'info',
+ buttons: ['OK'],
+ title: 'Computer Use Preview',
+ message: 'Computer use needs an explicit safety gate before it can run.',
+ detail: [
+ 'The desktop shell is ready for controlled automation hooks, but full computer use is not enabled yet.',
+ '',
+ 'Before this is exposed, CloudCLI needs per-session consent, a stop control, screen-capture permission checks, app/window scoping, and a provider-specific action loop.',
+ ].join('\n'),
+ });
+}
+
+async function refreshCloudEnvironments({ showErrors = false } = {}) {
+ isRefreshingCloud = true;
+ syncDesktopState();
+ try {
+ return await cloud.refreshCloudEnvironments();
+ } catch (error) {
+ const authState = cloud.getAuthState();
+ if (authState === 'expired') {
+ const expiredError = new Error('Your CloudCLI session expired. Reconnect your account.');
+ if (showErrors) {
+ await showError('CloudCLI login required', expiredError);
+ return [];
+ }
+ throw expiredError;
+ }
+ if (showErrors) {
+ await showError('Could not load CloudCLI environments', error);
+ return [];
+ }
+ throw error;
+ } finally {
+ isRefreshingCloud = false;
+ syncDesktopState();
+ }
+}
+
+async function connectCloudAccount() {
+ const connectUrl = cloud.buildConnectUrl();
+ clipboard.writeText(connectUrl);
+ await openExternalUrl(connectUrl);
+ return connectUrl;
+}
+
+async function handleDeepLink(url) {
+ let parsed;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return;
+ }
+
+ if (parsed.protocol !== `${CALLBACK_PROTOCOL}:` || parsed.hostname !== 'auth') {
+ return;
+ }
+
+ const apiKey = parsed.searchParams.get('api_key');
+ if (!apiKey) {
+ await showError('CloudCLI account connection failed', new Error('The callback did not include an API key.'));
+ return;
+ }
+
+ await cloud.saveFromCallback({
+ apiKey,
+ email: parsed.searchParams.get('email'),
+ });
+ await refreshCloudEnvironments({ showErrors: true });
+
+ dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
+ type: 'info',
+ title: 'CloudCLI account connected',
+ message: cloud.getAccount()?.email ? `Connected as ${cloud.getAccount().email}.` : 'CloudCLI account connected.',
+ }).catch(() => {});
+}
+
+async function copyLocalWebUrl() {
+ await localServer.ensureLocalServer();
+ const shareableUrl = localServer.getShareableWebUrl();
+ const localUrl = localServer.getLocalServerUrl();
+
+ if (!shareableUrl) {
+ throw new Error('Local CloudCLI URL is not available yet.');
+ }
+
+ clipboard.writeText(shareableUrl);
+ const isLanUrl = shareableUrl !== localUrl;
+ await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
+ type: 'info',
+ title: 'Web URL copied',
+ message: isLanUrl ? 'LAN web URL copied.' : 'Local web URL copied.',
+ detail: isLanUrl
+ ? `${shareableUrl}\n\nUse this URL from another device on the same network.`
+ : `${shareableUrl}\n\nThis URL works on this computer. Enable LAN access before starting Local CloudCLI to copy a phone-accessible URL.`,
+ });
+
+ return getDesktopState();
+}
+
+async function openLocalWebUi() {
+ await localServer.ensureLocalServer();
+ const url = localServer.getShareableWebUrl() || localServer.getLocalServerUrl();
+ if (!url) {
+ throw new Error('Local CloudCLI URL is not available yet.');
+ }
+
+ await shell.openExternal(url);
+ return getDesktopState();
+}
+
+async function updateDesktopSetting(key, value) {
+ const result = await localServer.updateDesktopSetting(key, value);
+ syncDesktopState();
+
+ if (result.requiresRestartNotice) {
+ await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
+ type: 'info',
+ title: 'Restart local server to apply',
+ message: 'LAN access changes apply the next time the local server starts.',
+ detail: 'Quit CloudCLI and stop the local server, then open Local CloudCLI again.',
+ });
+ }
+
+ return getDesktopState();
+}
+
+async function showEnvironmentPicker() {
+ const environments = await refreshCloudEnvironments({ showErrors: true });
+ const choices = ['Local CloudCLI', ...environments.map((environment) => {
+ const status = environment.status === 'running' ? '' : ` (${environment.status})`;
+ return `${environment.name || environment.subdomain}${status}`;
+ })];
+
+ const response = await dialog.showMessageBox(desktopWindow?.getMainWindow(), {
+ type: 'question',
+ buttons: [...choices, 'Cancel'],
+ defaultId: 0,
+ cancelId: choices.length,
+ title: 'Switch CloudCLI Environment',
+ message: 'Choose where this desktop window should connect.',
+ });
+
+ if (response.response === choices.length) return getDesktopState();
+ if (response.response === 0) return openLocalInDesktop();
+ return openEnvironmentInDesktop(environments[response.response - 1]);
+}
+
+async function startEnvironment(environment) {
+ await cloud.startEnvironmentAndWait(environment, REMOTE_START_TIMEOUT_MS);
+ await refreshCloudEnvironments({ showErrors: true });
+ return getDesktopState();
+}
+
+async function stopEnvironment(environment) {
+ await cloud.stopEnvironment(environment);
+ await refreshCloudEnvironments({ showErrors: true });
+ return getDesktopState();
+}
+
+async function openEnvironmentInBrowser(environment) {
+ await shell.openExternal(await cloud.getEnvironmentLaunchUrl(environment));
+ return getDesktopState();
+}
+
+function getProjectFolder(environment) {
+ return String(environment.name || environment.subdomain || 'workspace').replace(/[^a-zA-Z0-9-]/g, '');
+}
+
+function getSshTarget(credentials) {
+ if (credentials.ssh_command) {
+ const parts = String(credentials.ssh_command).split(/\s+/);
+ if (parts.length >= 2) return parts[1];
+ }
+ return `${credentials.username}@ssh.cloudcli.ai`;
+}
+
+function getSshHost(credentials) {
+ const target = getSshTarget(credentials);
+ const atIndex = target.indexOf('@');
+ return atIndex >= 0 ? target.slice(atIndex + 1) : 'ssh.cloudcli.ai';
+}
+
+async function getEnvironmentCredentials(environment) {
+ const credentials = await cloud.getEnvironmentCredentials(environment);
+ if (credentials.password) {
+ clipboard.writeText(credentials.password);
+ }
+ return credentials;
+}
+
+async function openEnvironmentInIde(environment, ide) {
+ const credentials = await getEnvironmentCredentials(environment);
+ const scheme = ide === 'cursor' ? 'cursor' : 'vscode';
+ const remoteUri = `${scheme}://vscode-remote/ssh-remote+${credentials.username}@${getSshHost(credentials)}/workspace/${getProjectFolder(environment)}?windowId=_blank`;
+ await shell.openExternal(remoteUri);
+ return getDesktopState();
+}
+
+async function openEnvironmentInSsh(environment) {
+ const credentials = await getEnvironmentCredentials(environment);
+ const sshCommand = `ssh -t ${getSshTarget(credentials)} "cd /workspace/${getProjectFolder(environment)} && exec $SHELL -l"`;
+
+ if (process.platform === 'darwin') {
+ const escaped = sshCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+ spawn('osascript', ['-e', `tell application "Terminal" to do script "${escaped}"`], {
+ detached: true,
+ stdio: 'ignore',
+ }).unref();
+ } else {
+ clipboard.writeText(sshCommand);
+ await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
+ type: 'info',
+ title: 'SSH command copied',
+ message: 'The SSH command was copied to the clipboard.',
+ detail: sshCommand,
+ });
+ }
+
+ return getDesktopState();
+}
+
+async function copyEnvironmentMobileUrl(environment) {
+ const url = cloud.getEnvironmentUrl(environment);
+ clipboard.writeText(url);
+ await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
+ type: 'info',
+ title: 'Environment URL copied',
+ message: 'Use this URL from your mobile browser.',
+ detail: url,
+ });
+ return getDesktopState();
+}
+
+async function openCloudDashboard() {
+ await shell.openExternal(CLOUDCLI_CONTROL_PLANE_URL);
+ return getDesktopState();
+}
+
+function getActiveRemoteEnvironment() {
+ if (activeTarget?.kind !== 'remote') return null;
+ return cloud.findEnvironment(activeTarget.id);
+}
+
+async function runActiveEnvironmentAction(action) {
+ const environment = getActiveRemoteEnvironment();
+ if (!environment) {
+ throw new Error('Open a cloud environment first.');
+ }
+
+ switch (action) {
+ case 'web':
+ return openEnvironmentInBrowser(environment);
+ case 'vscode':
+ return openEnvironmentInIde(environment, 'vscode');
+ case 'cursor':
+ return openEnvironmentInIde(environment, 'cursor');
+ case 'ssh':
+ return openEnvironmentInSsh(environment);
+ case 'mobile':
+ return copyEnvironmentMobileUrl(environment);
+ default:
+ throw new Error(`Unknown environment action: ${action}`);
+ }
+}
+
+async function openLocalInDesktop() {
+ const existingTab = tabs.getTab('local');
+ if (existingTab && localServer.getLocalServerUrl()) {
+ await desktopWindow.showTarget(await localServer.getResolvedTarget());
+ return getDesktopState();
+ }
+
+ const pendingTarget = localServer.getPendingTarget();
+ tabs.upsertTarget(pendingTarget);
+ setActiveTarget(pendingTarget);
+ await desktopWindow.showLocalStartupTarget(pendingTarget, localServer.getStartupLogs());
+ desktopWindow.emitDesktopState();
+
+ const target = await localServer.getResolvedTarget();
+ await desktopWindow.showTarget(target);
+ return getDesktopState();
+}
+
+async function openEnvironmentInDesktop(environment) {
+ const pendingTarget = getEnvironmentTarget(environment);
+ const tabId = tabs.getTabIdForTarget(pendingTarget);
+ const hadTab = Boolean(tabs.getTab(tabId));
+ const previousTabId = tabs.activeTabId;
+
+ if (!hadTab) {
+ await desktopWindow.showTabPlaceholder(
+ pendingTarget,
+ `${environment.status === 'running' ? 'Opening' : 'Starting'} ${pendingTarget.name}...`,
+ );
+ tabs.upsertTarget(pendingTarget);
+ desktopWindow.emitDesktopState();
+ }
+
+ let nextEnvironment = environment;
+
+ if (environment.status !== 'running') {
+ const response = await dialog.showMessageBox(desktopWindow?.getMainWindow(), {
+ type: 'question',
+ buttons: ['Start Environment', 'Cancel'],
+ defaultId: 0,
+ cancelId: 1,
+ title: 'Start environment?',
+ message: `${pendingTarget.name} is ${environment.status}.`,
+ detail: 'CloudCLI can start it before opening the remote app.',
+ });
+
+ if (response.response !== 0) {
+ if (!hadTab) {
+ tabs.remove(tabId);
+ desktopWindow.destroyTabView(tabId);
+ if (previousTabId && previousTabId !== tabId) {
+ await desktopWindow.switchDesktopTab(previousTabId);
+ } else {
+ await desktopWindow.showLauncher();
+ }
+ }
+ return getDesktopState();
+ }
+
+ if (hadTab) {
+ await desktopWindow.showTabPlaceholder(pendingTarget, `Starting ${pendingTarget.name}...`);
+ tabs.upsertTarget(pendingTarget);
+ desktopWindow.emitDesktopState();
+ }
+
+ nextEnvironment = await cloud.startEnvironmentAndWait(environment, REMOTE_START_TIMEOUT_MS);
+ }
+
+ const target = await getEnvironmentLaunchTarget(nextEnvironment);
+ await desktopWindow.showTarget(target);
+ return getDesktopState();
+}
+
+async function clearCloudAccount() {
+ await cloud.clearCloudAccount();
+ return getDesktopState();
+}
+
+function getRemoteEnvironmentMenuItems() {
+ const cloudAccount = cloud.getAccount();
+ const environments = cloud.getEnvironments();
+
+ if (!cloudAccount?.apiKey) {
+ return [{ label: 'Connect CloudCLI Account...', click: () => void connectCloudAccount() }];
+ }
+
+ if (!environments.length) {
+ return [{ label: 'No environments found', enabled: false }];
+ }
+
+ return environments.map((environment) => ({
+ label: `${environment.name || environment.subdomain}${environment.status === 'running' ? '' : ` (${environment.status})`}`,
+ click: () => void openEnvironmentInDesktop(environment)
+ .catch((error) => showError('Could not open environment', error)),
+ }));
+}
+
+function registerProtocolHandler() {
+ const appEntry = path.join(getAppRoot(), 'electron', 'main.js');
+ if (process.defaultApp && process.argv.length >= 2) {
+ app.setAsDefaultProtocolClient(CALLBACK_PROTOCOL, process.execPath, [appEntry]);
+ } else {
+ app.setAsDefaultProtocolClient(CALLBACK_PROTOCOL);
+ }
+}
+
+function registerIpcHandlers() {
+ ipcMain.handle('cloudcli-desktop:connect-cloud', async () => ({
+ ...getDesktopState(),
+ connectUrl: await connectCloudAccount(),
+ }));
+
+ ipcMain.handle('cloudcli-desktop:copy-diagnostics', async () => {
+ await copyDiagnostics();
+ return getDesktopState();
+ });
+
+ ipcMain.handle('cloudcli-desktop:copy-local-web-url', async () => copyLocalWebUrl());
+ ipcMain.handle('cloudcli-desktop:get-state', () => getDesktopState());
+ ipcMain.handle('cloudcli-desktop:open-cloud-dashboard', async () => openCloudDashboard());
+ ipcMain.handle('cloudcli-desktop:run-active-environment-action', async (_event, action) => runActiveEnvironmentAction(action));
+ ipcMain.handle('cloudcli-desktop:open-environment', async (_event, environmentId) => {
+ const environment = cloud.findEnvironment(environmentId);
+ if (!environment) {
+ throw new Error('Environment not found. Refresh and try again.');
+ }
+ return openEnvironmentInDesktop(environment);
+ });
+ ipcMain.handle('cloudcli-desktop:open-local', async () => openLocalInDesktop());
+ ipcMain.handle('cloudcli-desktop:open-local-web-ui', async () => openLocalWebUi());
+ ipcMain.handle('cloudcli-desktop:refresh-environments', async () => {
+ await refreshCloudEnvironments({ showErrors: true });
+ return getDesktopState();
+ });
+ ipcMain.handle('cloudcli-desktop:show-environment-picker', async () => showEnvironmentPicker());
+ ipcMain.handle('cloudcli-desktop:show-launcher', async () => {
+ await desktopWindow.showLauncher();
+ return getDesktopState();
+ });
+ ipcMain.handle('cloudcli-desktop:show-computer-use-preview', async () => {
+ await showComputerUsePreview();
+ return getDesktopState();
+ });
+ ipcMain.handle('cloudcli-desktop:show-desktop-app-menu', async () => desktopWindow.showDesktopAppMenu());
+ ipcMain.handle('cloudcli-desktop:show-active-environment-actions-menu', async () => desktopWindow.showActiveEnvironmentActionsMenu());
+ ipcMain.handle('cloudcli-desktop:show-environment-actions-menu', async (_event, environmentId) => desktopWindow.showEnvironmentActionsMenu(environmentId));
+ ipcMain.handle('cloudcli-desktop:switch-tab', async (_event, tabId) => desktopWindow.switchDesktopTab(tabId));
+ ipcMain.handle('cloudcli-desktop:close-tab', async (_event, tabId) => desktopWindow.closeDesktopTab(tabId));
+ ipcMain.handle('cloudcli-desktop:update-setting', async (_event, key, value) => updateDesktopSetting(key, value));
+}
+
+function registerAppEvents() {
+ app.on('open-url', (event, url) => {
+ event.preventDefault();
+ void handleDeepLink(url);
+ });
+
+ app.on('activate', () => {
+ if (BrowserWindow.getAllWindows().length === 0) {
+ if (desktopWindow) {
+ void desktopWindow.createWindow();
+ } else {
+ void createDesktopWindow();
+ }
+ return;
+ }
+
+ const window = desktopWindow?.getMainWindow();
+ if (window) {
+ window.show();
+ window.focus();
+ }
+ });
+
+ app.on('before-quit', (event) => {
+ if (isQuitting || !localServer?.hasOwnedServer()) return;
+ if (localServer.getSettings().keepLocalServerRunning) {
+ localServer.detachOwnedServer();
+ return;
+ }
+
+ event.preventDefault();
+ isQuitting = true;
+ void localServer.shutdownOwnedServer().finally(() => app.quit());
+ });
+
+ app.on('window-all-closed', () => {
+ if (process.platform !== 'darwin') {
+ app.quit();
+ }
+ });
+}
+
+async function createDesktopWindow() {
+ desktopWindow = new DesktopWindowManager({
+ appName: APP_NAME,
+ getWindowIconPath,
+ getLauncherPath,
+ getPreloadPath,
+ openExternalUrl,
+ getDesktopState,
+ getDisplayTargetName,
+ getRemoteEnvironmentMenuItems,
+ getCloudState,
+ getLocalState,
+ tabs,
+ actions: {
+ copyDiagnostics,
+ copyText: (text) => clipboard.writeText(text),
+ clearCloudAccount,
+ connectCloudAccount,
+ getActiveTarget: () => activeTarget,
+ getEnvironmentUrl: (environment) => cloud.getEnvironmentUrl(environment),
+ openEnvironmentInBrowser,
+ openEnvironmentInDesktop,
+ openEnvironmentInIde,
+ openEnvironmentInSsh,
+ openLocalInDesktop,
+ openLocalWebUi,
+ openCloudDashboard,
+ refreshCloudEnvironments: () => refreshCloudEnvironments({ showErrors: true }),
+ setActiveTarget,
+ showComputerUsePreview,
+ showEnvironmentPicker,
+ showError,
+ startEnvironment,
+ stopEnvironment,
+ updateDesktopSetting,
+ copyLocalWebUrl,
+ },
+ });
+
+ desktopWindow.createTray();
+ desktopWindow.configurePermissions();
+ await desktopWindow.createWindow();
+}
+
+function registerSingleInstance() {
+ const gotSingleInstanceLock = app.requestSingleInstanceLock();
+ if (!gotSingleInstanceLock) {
+ app.quit();
+ return false;
+ }
+
+ app.on('second-instance', (_event, argv) => {
+ const deepLink = argv.find((arg) => arg.startsWith(`${CALLBACK_PROTOCOL}://`));
+ if (deepLink) {
+ void handleDeepLink(deepLink);
+ }
+
+ const window = desktopWindow?.getMainWindow();
+ if (window) {
+ if (window.isMinimized()) window.restore();
+ window.show();
+ window.focus();
+ }
+ });
+
+ return true;
+}
+
+async function bootstrap() {
+ app.name = APP_NAME;
+ app.setName(APP_NAME);
+ process.title = APP_NAME;
+
+ await app.whenReady();
+ app.setName(APP_NAME);
+ app.setAboutPanelOptions({
+ applicationName: APP_NAME,
+ applicationVersion: app.getVersion(),
+ copyright: 'CloudCLI',
+ });
+
+ localServer = new LocalServerController({
+ appRoot: getAppRoot(),
+ settingsPath: getSettingsPath(),
+ isPackaged: app.isPackaged,
+ onChange: syncDesktopState,
+ });
+ cloud = new CloudController({
+ storePath: getStorePath(),
+ controlPlaneUrl: CLOUDCLI_CONTROL_PLANE_URL,
+ callbackUrl: CALLBACK_URL,
+ onChange: syncDesktopState,
+ });
+
+ await localServer.loadDesktopSettings();
+ await cloud.loadCloudAccount();
+
+ registerProtocolHandler();
+ registerIpcHandlers();
+ registerAppEvents();
+ await createDesktopWindow();
+ void refreshCloudEnvironments({ showErrors: false });
+}
+
+if (registerSingleInstance()) {
+ bootstrap().catch(async (error) => {
+ await showError('CloudCLI failed to start', error);
+ app.quit();
+ });
+}
diff --git a/electron/preload.cjs b/electron/preload.cjs
new file mode 100644
index 00000000..16de7cb7
--- /dev/null
+++ b/electron/preload.cjs
@@ -0,0 +1,28 @@
+const { contextBridge, ipcRenderer } = require('electron');
+
+if (window.location.protocol === 'file:') {
+ contextBridge.exposeInMainWorld('cloudcliDesktop', {
+ connectCloud: () => ipcRenderer.invoke('cloudcli-desktop:connect-cloud'),
+ copyDiagnostics: () => ipcRenderer.invoke('cloudcli-desktop:copy-diagnostics'),
+ copyLocalWebUrl: () => ipcRenderer.invoke('cloudcli-desktop:copy-local-web-url'),
+ getState: () => ipcRenderer.invoke('cloudcli-desktop:get-state'),
+ openCloudDashboard: () => ipcRenderer.invoke('cloudcli-desktop:open-cloud-dashboard'),
+ openEnvironment: (environmentId) => ipcRenderer.invoke('cloudcli-desktop:open-environment', environmentId),
+ runActiveEnvironmentAction: (action) => ipcRenderer.invoke('cloudcli-desktop:run-active-environment-action', action),
+ openLocal: () => ipcRenderer.invoke('cloudcli-desktop:open-local'),
+ openLocalWebUi: () => ipcRenderer.invoke('cloudcli-desktop:open-local-web-ui'),
+ refreshEnvironments: () => ipcRenderer.invoke('cloudcli-desktop:refresh-environments'),
+ showEnvironmentPicker: () => ipcRenderer.invoke('cloudcli-desktop:show-environment-picker'),
+ showLauncher: () => ipcRenderer.invoke('cloudcli-desktop:show-launcher'),
+ showComputerUsePreview: () => ipcRenderer.invoke('cloudcli-desktop:show-computer-use-preview'),
+ showDesktopAppMenu: () => ipcRenderer.invoke('cloudcli-desktop:show-desktop-app-menu'),
+ showActiveEnvironmentActionsMenu: () => ipcRenderer.invoke('cloudcli-desktop:show-active-environment-actions-menu'),
+ showEnvironmentActionsMenu: (environmentId) => ipcRenderer.invoke('cloudcli-desktop:show-environment-actions-menu', environmentId),
+ switchTab: (tabId) => ipcRenderer.invoke('cloudcli-desktop:switch-tab', tabId),
+ closeTab: (tabId) => ipcRenderer.invoke('cloudcli-desktop:close-tab', tabId),
+ updateSetting: (key, value) => ipcRenderer.invoke('cloudcli-desktop:update-setting', key, value),
+ onStateUpdated: (callback) => {
+ ipcRenderer.on('cloudcli-desktop:state-updated', (_event, state) => callback(state));
+ },
+ });
+}
diff --git a/electron/scripts/generate-macos-icon.js b/electron/scripts/generate-macos-icon.js
new file mode 100644
index 00000000..921b0522
--- /dev/null
+++ b/electron/scripts/generate-macos-icon.js
@@ -0,0 +1,62 @@
+import fs from 'node:fs/promises';
+import sharp from 'sharp';
+
+const size = 1024;
+const assetsDir = 'electron/assets';
+const iconPath = 'electron/assets/logo-macos.png';
+const icnsPath = 'electron/assets/logo-macos.icns';
+
+function renderSvg(entrySize) {
+ const scale = entrySize / 32;
+ return `
+
+
+
+ `;
+}
+
+async function renderPng(entrySize) {
+ return sharp(Buffer.from(renderSvg(entrySize)))
+ .png()
+ .toBuffer();
+}
+
+await fs.mkdir(assetsDir, { recursive: true });
+await fs.writeFile(iconPath, await renderPng(size));
+
+const icnsEntries = [
+ ['icp4', 16],
+ ['icp5', 32],
+ ['icp6', 64],
+ ['ic07', 128],
+ ['ic08', 256],
+ ['ic09', 512],
+ ['ic10', 1024],
+ ['ic11', 32],
+ ['ic12', 64],
+ ['ic13', 256],
+ ['ic14', 512],
+];
+
+const blocks = await Promise.all(icnsEntries.map(async ([type, entrySize]) => {
+ const png = await renderPng(entrySize);
+ const block = Buffer.alloc(8 + png.length);
+ block.write(type, 0, 4, 'ascii');
+ block.writeUInt32BE(block.length, 4);
+ png.copy(block, 8);
+ return block;
+}));
+
+const totalLength = 8 + blocks.reduce((sum, block) => sum + block.length, 0);
+const header = Buffer.alloc(8);
+header.write('icns', 0, 4, 'ascii');
+header.writeUInt32BE(totalLength, 4);
+
+await fs.writeFile(icnsPath, Buffer.concat([header, ...blocks], totalLength));
diff --git a/electron/tabs.js b/electron/tabs.js
new file mode 100644
index 00000000..16bb4c75
--- /dev/null
+++ b/electron/tabs.js
@@ -0,0 +1,71 @@
+export class TabsController {
+ constructor() {
+ this.activeTabId = 'home';
+ this.tabs = [
+ {
+ id: 'home',
+ title: 'Home',
+ kind: 'launcher',
+ closable: false,
+ },
+ ];
+ }
+
+ getTabIdForTarget(target) {
+ if (target.kind === 'launcher') return 'home';
+ if (target.kind === 'remote' && target.id) return `remote:${target.id}`;
+ return target.kind;
+ }
+
+ upsertTarget(target) {
+ const tabId = this.getTabIdForTarget(target);
+ const existingTab = this.tabs.find((tab) => tab.id === tabId);
+ const nextTab = {
+ id: tabId,
+ title: target.kind === 'launcher' ? 'Home' : target.name,
+ kind: target.kind,
+ target,
+ closable: tabId !== 'home',
+ };
+
+ if (existingTab) {
+ Object.assign(existingTab, nextTab);
+ } else {
+ this.tabs.push(nextTab);
+ }
+
+ this.activeTabId = tabId;
+ return nextTab;
+ }
+
+ activate(tabId) {
+ const tab = this.tabs.find((item) => item.id === tabId);
+ if (!tab) return null;
+ this.activeTabId = tab.id;
+ return tab;
+ }
+
+ remove(tabId) {
+ const tab = this.tabs.find((item) => item.id === tabId);
+ if (!tab || !tab.closable) return null;
+ this.tabs = this.tabs.filter((item) => item.id !== tabId);
+ if (this.activeTabId === tabId) {
+ this.activeTabId = 'home';
+ }
+ return tab;
+ }
+
+ getTab(tabId) {
+ return this.tabs.find((item) => item.id === tabId) || null;
+ }
+
+ getSerializableTabs() {
+ return this.tabs.map((tab) => ({
+ id: tab.id,
+ title: tab.title,
+ kind: tab.kind,
+ closable: tab.closable,
+ active: tab.id === this.activeTabId,
+ }));
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 3faa74aa..2c8c3c64 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7827,9 +7827,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001761",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz",
- "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==",
+ "version": "1.0.30001799",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
+ "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
"dev": true,
"funding": [
{
diff --git a/package.json b/package.json
index ae75f9c6..a4579d82 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
"desktop:dev": "ELECTRON_DEV_URL=http://127.0.0.1:5173 electron electron/main.js",
"desktop:pack": "npm run build && electron-builder --dir",
"desktop:dist:mac": "npm run build && electron-builder --mac dmg zip",
+ "desktop:icon:mac": "node electron/scripts/generate-macos-icon.js",
"build": "npm run build:client && npm run build:server",
"build:client": "vite build",
"prebuild:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"",
@@ -54,6 +55,7 @@
"build": {
"appId": "ai.cloudcli.desktop",
"productName": "CloudCLI",
+ "asar": false,
"artifactName": "CloudCLI-${version}-${arch}.${ext}",
"directories": {
"output": "release"
@@ -80,6 +82,7 @@
],
"mac": {
"category": "public.app-category.developer-tools",
+ "icon": "electron/assets/logo-macos.icns",
"target": [
"dmg",
"zip"
diff --git a/server/index.js b/server/index.js
index 0a812920..e0254720 100755
--- a/server/index.js
+++ b/server/index.js
@@ -63,6 +63,7 @@ import pluginsRoutes from './routes/plugins.js';
import providerRoutes from './modules/providers/provider.routes.js';
import browserUseRoutes from './modules/browser-use/browser-use.routes.js';
import { browserUseService } from './modules/browser-use/browser-use.service.js';
+import computerUseRoutes from './modules/computer-use/computer-use.routes.js';
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js';
import { configureWebPush } from './services/vapid-keys.js';
@@ -198,6 +199,9 @@ app.use('/api/plugins', authenticateToken, pluginsRoutes);
// Browser Use API Routes (protected)
app.use('/api/browser-use', authenticateToken, browserUseRoutes);
+// Computer Use API Routes (protected)
+app.use('/api/computer-use', authenticateToken, computerUseRoutes);
+
// Unified provider MCP routes (protected)
app.use('/api/providers', authenticateToken, providerRoutes);
@@ -1661,6 +1665,40 @@ const SERVER_PORT = process.env.SERVER_PORT || 3001;
const HOST = process.env.HOST || '0.0.0.0';
const DISPLAY_HOST = getConnectableHost(HOST);
const VITE_PORT = process.env.VITE_PORT || 5173;
+const LOCAL_SERVER_MARKER_PATH = path.join(os.homedir(), '.cloudcli', 'local-server.json');
+
+async function writeLocalServerMarker() {
+ const marker = {
+ pid: process.pid,
+ host: HOST,
+ port: Number.parseInt(String(SERVER_PORT), 10),
+ url: `http://${DISPLAY_HOST}:${SERVER_PORT}`,
+ installMode,
+ appRoot: APP_ROOT,
+ updatedAt: new Date().toISOString(),
+ };
+
+ await fsPromises.mkdir(path.dirname(LOCAL_SERVER_MARKER_PATH), { recursive: true });
+ await fsPromises.writeFile(LOCAL_SERVER_MARKER_PATH, JSON.stringify(marker, null, 2), 'utf8');
+}
+
+async function removeLocalServerMarker() {
+ try {
+ const raw = await fsPromises.readFile(LOCAL_SERVER_MARKER_PATH, 'utf8');
+ const marker = JSON.parse(raw);
+ if (marker.pid && marker.pid !== process.pid) return;
+ } catch (error) {
+ if (error.code === 'ENOENT') return;
+ }
+
+ try {
+ await fsPromises.unlink(LOCAL_SERVER_MARKER_PATH);
+ } catch (error) {
+ if (error.code !== 'ENOENT') {
+ console.warn('[WARN] Could not remove local server marker:', error.message);
+ }
+ }
+}
// Initialize database and start server
async function startServer() {
@@ -1687,6 +1725,9 @@ async function startServer() {
server.listen(SERVER_PORT, HOST, async () => {
const appInstallPath = APP_ROOT;
+ await writeLocalServerMarker().catch((error) => {
+ console.warn('[WARN] Could not write local server marker:', error.message);
+ });
console.log('');
console.log(c.dim('═'.repeat(63)));
@@ -1712,6 +1753,7 @@ async function startServer() {
const shutdownRuntimeServices = async () => {
await browserUseService.stopAllSessions();
await stopAllPlugins();
+ await removeLocalServerMarker();
process.exit(0);
};
process.on('SIGTERM', () => void shutdownRuntimeServices());
diff --git a/server/modules/computer-use/computer-use.routes.ts b/server/modules/computer-use/computer-use.routes.ts
new file mode 100644
index 00000000..76aa35ca
--- /dev/null
+++ b/server/modules/computer-use/computer-use.routes.ts
@@ -0,0 +1,19 @@
+import express from 'express';
+
+import { computerUseService } from '@/modules/computer-use/computer-use.service.js';
+
+const router = express.Router();
+
+router.get('/status', (_req, res) => {
+ res.json({ success: true, data: computerUseService.getStatus() });
+});
+
+router.post('/sessions', (_req, res) => {
+ res.status(409).json({
+ success: false,
+ error: 'Computer Use is not enabled until a local CloudCLI Desktop Agent is connected and approved by the user.',
+ data: computerUseService.getStatus(),
+ });
+});
+
+export default router;
diff --git a/server/modules/computer-use/computer-use.service.ts b/server/modules/computer-use/computer-use.service.ts
new file mode 100644
index 00000000..63c1aa38
--- /dev/null
+++ b/server/modules/computer-use/computer-use.service.ts
@@ -0,0 +1,22 @@
+const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
+
+export const computerUseService = {
+ getStatus() {
+ return {
+ available: false,
+ bridgeConnected: false,
+ runtime: IS_PLATFORM ? 'cloud' : 'local',
+ requiresDesktopBridge: true,
+ message: IS_PLATFORM
+ ? 'Cloud Computer Use requires a linked CloudCLI Desktop Agent on the user machine.'
+ : 'Local Computer Use requires a desktop bridge with screen recording and accessibility permissions.',
+ capabilities: {
+ screenshots: false,
+ mouse: false,
+ keyboard: false,
+ clipboard: false,
+ stopControl: false,
+ },
+ };
+ },
+};
diff --git a/src/components/computer-use/index.ts b/src/components/computer-use/index.ts
new file mode 100644
index 00000000..2c1a02b8
--- /dev/null
+++ b/src/components/computer-use/index.ts
@@ -0,0 +1 @@
+export { default as ComputerUsePanel } from './view/ComputerUsePanel';
diff --git a/src/components/computer-use/view/ComputerUsePanel.tsx b/src/components/computer-use/view/ComputerUsePanel.tsx
new file mode 100644
index 00000000..e7c58ce9
--- /dev/null
+++ b/src/components/computer-use/view/ComputerUsePanel.tsx
@@ -0,0 +1,132 @@
+import { useCallback, useEffect, useState } from 'react';
+import { Cable, MonitorCog, RefreshCw, ShieldCheck } from 'lucide-react';
+
+import { Badge, Button } from '../../../shared/view/ui';
+import { authenticatedFetch } from '../../../utils/api';
+
+type ComputerUseStatus = {
+ available: boolean;
+ bridgeConnected: boolean;
+ runtime: 'cloud' | 'local';
+ requiresDesktopBridge: boolean;
+ message: string;
+ capabilities: {
+ screenshots: boolean;
+ mouse: boolean;
+ keyboard: boolean;
+ clipboard: boolean;
+ stopControl: boolean;
+ };
+};
+
+type ComputerUsePanelProps = {
+ isVisible: boolean;
+};
+
+async function readStatus(response: Response): Promise {
+ const data = await response.json();
+ if (!response.ok || data.success === false) {
+ throw new Error(data.error || `Request failed (${response.status})`);
+ }
+ return data.data;
+}
+
+export default function ComputerUsePanel({ isVisible }: ComputerUsePanelProps) {
+ const [status, setStatus] = useState(null);
+ const [error, setError] = useState(null);
+
+ const refresh = useCallback(async () => {
+ setError(null);
+ try {
+ const response = await authenticatedFetch('/api/computer-use/status');
+ setStatus(await readStatus(response));
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load Computer Use status');
+ }
+ }, []);
+
+ useEffect(() => {
+ if (isVisible) {
+ void refresh();
+ }
+ }, [isVisible, refresh]);
+
+ const capabilities = status?.capabilities || {
+ screenshots: false,
+ mouse: false,
+ keyboard: false,
+ clipboard: false,
+ stopControl: false,
+ };
+
+ return (
+
+
+
+
+
+
Computer Use
+ {status && {status.runtime} }
+
+
+ Local desktop control through a user-approved CloudCLI Desktop Agent.
+
+
+
void refresh()}>
+
+ Refresh
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+
+
+
+
Desktop bridge
+
+ {status?.bridgeConnected ? 'connected' : 'not connected'}
+
+
+
+ {status?.message || 'Loading Computer Use status...'}
+
+
+
Architecture boundary
+
+ Hosted CloudCLI can request Computer Use only through a linked local agent. The hosted server should never receive a permanent raw ability to control a user machine.
+
+
+
+
+
+
+
+
+
+
Required controls
+
+
+ {Object.entries(capabilities).map(([name, enabled]) => (
+
+ {name.replace(/([A-Z])/g, ' $1')}
+
+ {enabled ? 'ready' : 'blocked'}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx
index 12cfe7aa..5c14cd6d 100644
--- a/src/components/main-content/view/MainContent.tsx
+++ b/src/components/main-content/view/MainContent.tsx
@@ -6,6 +6,7 @@ import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
import GitPanel from '../../git-panel/view/GitPanel';
import PluginTabContent from '../../plugins/view/PluginTabContent';
import { BrowserUsePanel } from '../../browser-use';
+import { ComputerUsePanel } from '../../computer-use';
import type { MainContentProps } from '../types/types';
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext';
@@ -178,6 +179,12 @@ function MainContent({
)}
+ {activeTab === 'computer' && (
+
+
+
+ )}
+
{activeTab.startsWith('plugin:') && (
- Star
+ Star
{formattedCount && (
{formattedCount}
)}
diff --git a/src/components/sidebar/view/subcomponents/SidebarHeader.tsx b/src/components/sidebar/view/subcomponents/SidebarHeader.tsx
index 8eabab2a..57ac4aa5 100644
--- a/src/components/sidebar/view/subcomponents/SidebarHeader.tsx
+++ b/src/components/sidebar/view/subcomponents/SidebarHeader.tsx
@@ -67,7 +67,7 @@ export default function SidebarHeader({
- {t('app.title')}
+ {t('app.title')}
);
@@ -138,7 +138,7 @@ export default function SidebarHeader({
onClick={() => onSearchModeChange('projects')}
aria-pressed={searchMode === 'projects'}
className={cn(
- "flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
+ "flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
searchMode === 'projects'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
@@ -151,7 +151,7 @@ export default function SidebarHeader({
onClick={() => onSearchModeChange('conversations')}
aria-pressed={searchMode === 'conversations'}
className={cn(
- "flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
+ "flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
searchMode === 'conversations'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
@@ -190,7 +190,7 @@ export default function SidebarHeader({
aria-label={t('search.archiveOnlyTooltip', 'Archive only')}
title={t('search.archiveOnlyTooltip', 'Archive only')}
className={cn(
- "flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-medium transition-all",
+ "flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-normal transition-all",
searchMode === 'archived'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
@@ -278,7 +278,7 @@ export default function SidebarHeader({
onClick={() => onSearchModeChange('projects')}
aria-pressed={searchMode === 'projects'}
className={cn(
- "flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
+ "flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
searchMode === 'projects'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
@@ -291,7 +291,7 @@ export default function SidebarHeader({
onClick={() => onSearchModeChange('conversations')}
aria-pressed={searchMode === 'conversations'}
className={cn(
- "flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
+ "flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
searchMode === 'conversations'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
@@ -331,7 +331,7 @@ export default function SidebarHeader({
aria-label={t('search.archiveOnlyTooltip', 'Archive only')}
title={t('search.archiveOnlyTooltip', 'Archive only')}
className={cn(
- "flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-medium transition-all",
+ "flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-normal transition-all",
searchMode === 'archived'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
diff --git a/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx b/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx
index ff691335..618e0326 100644
--- a/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx
+++ b/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx
@@ -186,7 +186,7 @@ export default function SidebarProjectItem({
) : (
<>
-
{project.displayName}
+
{project.displayName}
{tasksEnabled && (
) : (
-
+
{project.displayName}
diff --git a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx
index c55d8ed3..1fdb2c6a 100644
--- a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx
+++ b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx
@@ -157,7 +157,7 @@ export default function SidebarSessionItem({
-
{sessionView.sessionName}
+
{sessionView.sessionName}
{isProcessing ? (
@@ -219,7 +219,7 @@ export default function SidebarSessionItem({
-
{sessionView.sessionName}
+
{sessionView.sessionName}
{isProcessing ? (
= new Set(['chat', 'files', 'shell', 'git', 'tasks', 'browser']);
+const VALID_TABS: Set = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'browser', 'computer']);
const isValidTab = (tab: string): tab is AppTab => {
return VALID_TABS.has(tab) || tab.startsWith('plugin:');
@@ -776,7 +776,7 @@ export function useProjectsState({
(session: ProjectSession) => {
setSelectedSession(session);
- if (activeTab === 'tasks' || activeTab === 'browser') {
+ if (activeTab === 'tasks' || activeTab === 'browser' || activeTab === 'computer') {
setActiveTab('chat');
}
diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json
index 636d2e8d..6eb1fca9 100644
--- a/src/i18n/locales/de/common.json
+++ b/src/i18n/locales/de/common.json
@@ -23,7 +23,8 @@
"files": "Dateien",
"git": "Quellcodeverwaltung",
"tasks": "Aufgaben",
- "browser": "Browser"
+ "browser": "Browser",
+ "computer": "Computer"
},
"status": {
"loading": "Lädt...",
diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json
index 9137da9a..f8ab8444 100644
--- a/src/i18n/locales/en/common.json
+++ b/src/i18n/locales/en/common.json
@@ -23,7 +23,8 @@
"files": "Files",
"git": "Source Control",
"tasks": "Tasks",
- "browser": "Browser"
+ "browser": "Browser",
+ "computer": "Computer"
},
"status": {
"loading": "Loading...",
diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json
index 79f9f675..1df91a00 100644
--- a/src/i18n/locales/it/common.json
+++ b/src/i18n/locales/it/common.json
@@ -23,7 +23,8 @@
"files": "File",
"git": "Controllo Versione",
"tasks": "Attività",
- "browser": "Browser"
+ "browser": "Browser",
+ "computer": "Computer"
},
"status": {
"loading": "Caricamento...",
diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json
index 498ee46c..d61cf4e2 100644
--- a/src/i18n/locales/ja/common.json
+++ b/src/i18n/locales/ja/common.json
@@ -23,7 +23,8 @@
"files": "ファイル",
"git": "ソース管理",
"tasks": "タスク",
- "browser": "Browser"
+ "browser": "Browser",
+ "computer": "Computer"
},
"status": {
"loading": "読み込み中...",
diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json
index 03244458..58070abf 100644
--- a/src/i18n/locales/ko/common.json
+++ b/src/i18n/locales/ko/common.json
@@ -23,7 +23,8 @@
"files": "파일",
"git": "소스 관리",
"tasks": "작업",
- "browser": "Browser"
+ "browser": "Browser",
+ "computer": "Computer"
},
"status": {
"loading": "로딩 중...",
diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json
index fc71abe1..fbde3d5f 100644
--- a/src/i18n/locales/ru/common.json
+++ b/src/i18n/locales/ru/common.json
@@ -23,7 +23,8 @@
"files": "Файлы",
"git": "Система контроля версий",
"tasks": "Задачи",
- "browser": "Browser"
+ "browser": "Browser",
+ "computer": "Computer"
},
"status": {
"loading": "Загрузка...",
diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json
index f1fa66b9..e33ed02b 100644
--- a/src/i18n/locales/tr/common.json
+++ b/src/i18n/locales/tr/common.json
@@ -23,7 +23,8 @@
"files": "Dosyalar",
"git": "Kaynak Kontrolü",
"tasks": "Görevler",
- "browser": "Browser"
+ "browser": "Browser",
+ "computer": "Computer"
},
"status": {
"loading": "Yükleniyor...",
diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json
index 69cd159a..ac9bd9c1 100644
--- a/src/i18n/locales/zh-CN/common.json
+++ b/src/i18n/locales/zh-CN/common.json
@@ -23,7 +23,8 @@
"files": "文件",
"git": "源代码管理",
"tasks": "任务",
- "browser": "Browser"
+ "browser": "Browser",
+ "computer": "Computer"
},
"status": {
"loading": "加载中...",
diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json
index 419be285..e119adb6 100644
--- a/src/i18n/locales/zh-TW/common.json
+++ b/src/i18n/locales/zh-TW/common.json
@@ -23,7 +23,8 @@
"files": "檔案",
"git": "版本控制",
"tasks": "任務",
- "browser": "Browser"
+ "browser": "Browser",
+ "computer": "Computer"
},
"status": {
"loading": "載入中...",
diff --git a/src/types/app.ts b/src/types/app.ts
index f81c3e26..42fe166c 100644
--- a/src/types/app.ts
+++ b/src/types/app.ts
@@ -17,7 +17,7 @@ export type ProviderModelsCacheInfo = {
source: 'memory' | 'disk' | 'fresh';
};
-export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'browser' | `plugin:${string}`;
+export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'browser' | 'computer' | `plugin:${string}`;
export interface ProjectSession {
id: string;