From 861cfecbaae1ed41534f063f08fabd47d57c3e19 Mon Sep 17 00:00:00 2001 From: Simos Mikelatos Date: Mon, 15 Jun 2026 16:21:05 +0000 Subject: [PATCH] feat: add electron app support --- .gitignore | 5 + electron/assets/logo-macos.icns | Bin 0 -> 32391 bytes electron/assets/logo-macos.png | Bin 0 -> 13690 bytes electron/cloud.js | 240 ++++++ electron/desktopWindow.js | 685 +++++++++++++++ electron/launcher/index.html | 14 + electron/launcher/launcher.css | 1 + electron/launcher/launcher.js | 520 ++++++++++++ electron/localServer.js | 483 +++++++++++ electron/main.js | 789 ++++++++++++++++++ electron/preload.cjs | 28 + electron/scripts/generate-macos-icon.js | 62 ++ electron/tabs.js | 71 ++ package-lock.json | 6 +- package.json | 3 + server/index.js | 42 + .../computer-use/computer-use.routes.ts | 19 + .../computer-use/computer-use.service.ts | 22 + src/components/computer-use/index.ts | 1 + .../computer-use/view/ComputerUsePanel.tsx | 132 +++ .../main-content/view/MainContent.tsx | 7 + .../subcomponents/MainContentTabSwitcher.tsx | 3 +- .../view/subcomponents/MainContentTitle.tsx | 4 + .../view/subcomponents/GitHubStarBadge.tsx | 2 +- .../view/subcomponents/SidebarHeader.tsx | 14 +- .../view/subcomponents/SidebarProjectItem.tsx | 4 +- .../view/subcomponents/SidebarSessionItem.tsx | 4 +- src/hooks/useProjectsState.ts | 4 +- src/i18n/locales/de/common.json | 3 +- src/i18n/locales/en/common.json | 3 +- src/i18n/locales/it/common.json | 3 +- src/i18n/locales/ja/common.json | 3 +- src/i18n/locales/ko/common.json | 3 +- src/i18n/locales/ru/common.json | 3 +- src/i18n/locales/tr/common.json | 3 +- src/i18n/locales/zh-CN/common.json | 3 +- src/i18n/locales/zh-TW/common.json | 3 +- src/types/app.ts | 2 +- 38 files changed, 3166 insertions(+), 28 deletions(-) create mode 100644 electron/assets/logo-macos.icns create mode 100644 electron/assets/logo-macos.png create mode 100644 electron/cloud.js create mode 100644 electron/desktopWindow.js create mode 100644 electron/launcher/index.html create mode 100644 electron/launcher/launcher.css create mode 100644 electron/launcher/launcher.js create mode 100644 electron/localServer.js create mode 100644 electron/main.js create mode 100644 electron/preload.cjs create mode 100644 electron/scripts/generate-macos-icon.js create mode 100644 electron/tabs.js create mode 100644 server/modules/computer-use/computer-use.routes.ts create mode 100644 server/modules/computer-use/computer-use.service.ts create mode 100644 src/components/computer-use/index.ts create mode 100644 src/components/computer-use/view/ComputerUsePanel.tsx 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 0000000000000000000000000000000000000000..c0d548ee185f715b1f1e48ef393d7152eb2e5734 GIT binary patch literal 32391 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 literal 0 HcmV?d00001 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 ''; + }).join(''); + } + + CC.titlebar = function (state) { + var conn = connected(state); + var activeRemote = state.activeTarget && state.activeTarget.kind === 'remote'; + var envActions = activeRemote ? '' : ''; + return '
' + + '
CloudCLI
' + + '
' + renderTabs(state) + '
' + + '' + + envActions + + '' + + '' + + '
'; + }; + + 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
' + + '
Local server
' + + '
' + esc(url) + '
' + + '
' + + '' + + '' + + '
' + + '
'; + }; + + 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 ''; + } + + 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') + '
' + + '
'; + } + + 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 + '' + + '' + + '
'; + } + + function cloudPane(state) { + var header = '

Environments

' + CC.esc(CC.envCount(state)) + '

'; + if (CC.authState(state) === 'expired') { + return header + '
Your CloudCLI session expired.
'; + } + if (!CC.connected(state)) { + return header + '
Connect your CloudCLI account to list hosted environments.
'; + } + 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. +

+
+ +
+ + {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. +

+
+
+
+
+ + +
+
+ ); +} 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;