mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-18 22:57:31 +08:00
feat: add electron app support
This commit is contained in:
BIN
electron/assets/logo-macos.icns
Normal file
BIN
electron/assets/logo-macos.icns
Normal file
Binary file not shown.
BIN
electron/assets/logo-macos.png
Normal file
BIN
electron/assets/logo-macos.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
240
electron/cloud.js
Normal file
240
electron/cloud.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
685
electron/desktopWindow.js
Normal file
685
electron/desktopWindow.js
Normal file
@@ -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, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function buildPlaceholderHtml(title, message, logs = []) {
|
||||
const logHtml = logs.length
|
||||
? `<pre>${logs.map(escapeHtml).join('\n')}</pre>`
|
||||
: '<pre>Waiting for process output...</pre>';
|
||||
return [
|
||||
'<!doctype html><meta charset="utf-8">',
|
||||
'<style>',
|
||||
'html,body{margin:0;height:100%;background:#0a0a0a;color:#fafafa;font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}',
|
||||
'body{padding:28px;overflow:hidden}',
|
||||
'.shell{height:100%;display:flex;flex-direction:column;gap:16px}',
|
||||
'.box{display:flex;align-items:center;gap:10px;color:#d4d4d4;flex:0 0 auto}',
|
||||
'.dot{width:8px;height:8px;border-radius:50%;background:#0b60ea;box-shadow:0 0 0 6px rgba(11,96,234,.15)}',
|
||||
'pre{margin:0;flex:1;overflow:auto;border:1px solid #262626;border-radius:10px;background:#050505;color:#d4d4d4;padding:14px;font:12px/1.55 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;white-space:pre-wrap;user-select:text}',
|
||||
'</style>',
|
||||
'<div class="shell">',
|
||||
`<div class="box"><span class="dot"></span><span>${escapeHtml(message || `Opening ${title}...`)}</span></div>`,
|
||||
logHtml,
|
||||
'</div>',
|
||||
].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();
|
||||
}
|
||||
}
|
||||
14
electron/launcher/index.html
Normal file
14
electron/launcher/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' data:; connect-src *; img-src 'self' data:" />
|
||||
<title>CloudCLI Desktop</title>
|
||||
<link rel="stylesheet" href="./launcher.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="./launcher.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
electron/launcher/launcher.css
Normal file
1
electron/launcher/launcher.css
Normal file
File diff suppressed because one or more lines are too long
520
electron/launcher/launcher.js
Normal file
520
electron/launcher/launcher.js
Normal file
@@ -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: '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
|
||||
cloud: '<path d="M17.5 19a4.5 4.5 0 0 0 .5-8.97A6 6 0 0 0 6.34 9 4 4 0 0 0 7 19z"/>',
|
||||
refresh: '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
|
||||
settings: '<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>',
|
||||
gear: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6l-.03.08a2 2 0 1 1-3.94 0L10 20a1.7 1.7 0 0 0-1-.6 1.7 1.7 0 0 0-1.88.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1l-.08-.03a2 2 0 1 1 0-3.94L4 10a1.7 1.7 0 0 0 .6-1 1.7 1.7 0 0 0-.34-1.88l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6l.03-.08a2 2 0 1 1 3.94 0L14 4a1.7 1.7 0 0 0 1 .6 1.7 1.7 0 0 0 1.88-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.2.36.4.7.6 1l.08.03a2 2 0 1 1 0 3.94L20 14a1.7 1.7 0 0 0-.6 1z"/>',
|
||||
play: '<polygon points="6 4 20 12 6 20 6 4"/>',
|
||||
arrow: '<line x1="7" y1="17" x2="17" y2="7"/><polyline points="8 7 17 7 17 16"/>',
|
||||
copy: '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
||||
cloudPlus: '<path d="M17.5 19a4.5 4.5 0 0 0 .5-8.97A6 6 0 0 0 6.34 9 4 4 0 0 0 7 19z"/><line x1="12" y1="9" x2="12" y2="15"/><line x1="9" y1="12" x2="15" y2="12"/>',
|
||||
monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
|
||||
phone: '<rect x="7" y="2" width="10" height="20" rx="2"/><line x1="11" y1="18" x2="13" y2="18"/>',
|
||||
x: '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
||||
};
|
||||
var FILLED = { play: true };
|
||||
|
||||
function icon(name, size) {
|
||||
size = size || 16;
|
||||
return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="' + (FILLED[name] ? 'currentColor' : 'none') + '" stroke="' + (FILLED[name] ? 'none' : 'currentColor') + '" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' + (ICONS[name] || '') + '</svg>';
|
||||
}
|
||||
|
||||
function esc(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.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 '<button class="tb-tab no-drag' + (tab.active ? ' active' : '') + '" data-cc-tab="' + esc(tab.id) + '" title="' + esc(title) + '" style="width:' + tabWidth + 'px;flex-basis:' + tabWidth + 'px">' +
|
||||
'<span>' + esc(title) + '</span>' +
|
||||
(tab.closable ? '<span class="tb-close" data-cc-close-tab="' + esc(tab.id) + '" title="Close tab">×</span>' : '') +
|
||||
'</button>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
CC.titlebar = function (state) {
|
||||
var conn = connected(state);
|
||||
var activeRemote = state.activeTarget && state.activeTarget.kind === 'remote';
|
||||
var envActions = activeRemote ? '<button class="btn sm tb-action no-drag" data-cc-action="env-menu" title="Open environment actions">Open environment in...</button>' : '';
|
||||
return '<div class="titlebar">' +
|
||||
'<div class="brand"><img class="mk" src="' + esc(LOGO_URL) + '" alt=""><span>CloudCLI</span></div>' +
|
||||
'<div class="tb-tabs no-drag">' + renderTabs(state) + '</div>' +
|
||||
'<span style="flex:1"></span>' +
|
||||
envActions +
|
||||
'<button class="btn sm tb-action no-drag" data-cc-action="connect" title="' + esc(authState(state) === 'expired' ? 'Reconnect your CloudCLI account' : accountLabel(state)) + '"><span class="dot" style="background:' + (conn ? 'var(--ok)' : (authState(state) === 'expired' ? 'var(--warn)' : 'var(--tx3)')) + '"></span>' + esc(accountLabel(state)) + '</button>' +
|
||||
'<button class="icon-btn tb-action no-drag" data-cc-action="settings-toggle" title="Settings">' + icon('settings', 16) + '</button>' +
|
||||
'</div>';
|
||||
};
|
||||
|
||||
CC.statusbar = function (state) {
|
||||
var status = CC._status || {};
|
||||
var running = !!state.localServerRunning;
|
||||
return '<div class="statusbar">' +
|
||||
'<span><span class="dot" style="width:7px;height:7px;background:' + (running ? 'var(--ok)' : 'var(--tx3)') + '"></span> local ' + (running ? 'running · ' + esc(localUrl(state)) : 'idle') + '</span>' +
|
||||
'<span class="sep">·</span><span>' + esc(envCount(state)) + '</span>' +
|
||||
'<span class="sep">·</span><span>' + (authState(state) === 'expired' ? 'session expired' : (connected(state) ? esc(accountLabel(state)) : 'not connected')) + '</span>' +
|
||||
'<span style="flex:1"></span>' +
|
||||
(status.msg ? '<span class="status-msg ' + esc(status.tone) + '">' + esc(status.msg) + '</span><span class="sep">·</span>' : '') +
|
||||
'<span>v' + esc(VERSION) + '</span>' +
|
||||
'</div>';
|
||||
};
|
||||
|
||||
CC.renderLocalSettings = function () {
|
||||
var state = CC.state || {};
|
||||
var settings = state.desktopSettings || {};
|
||||
var url = localUrl(state) || 'starts on demand';
|
||||
overlay.innerHTML =
|
||||
'<div class="cc-sheet cc-modal">' +
|
||||
'<div class="cc-sheet-h"><span class="lbl">Local Settings</span><button class="icon-btn" data-cc-action="settings-close">' + icon('x', 16) + '</button></div>' +
|
||||
'<div class="cc-grp"><div class="lbl">Local server</div>' +
|
||||
'<div class="cc-meta mono">' + esc(url) + '</div>' +
|
||||
'<div class="cc-row2"><button class="btn sm" data-cc-action="open-web">' + icon('arrow', 14) + 'Open in browser</button><button class="btn sm" data-cc-action="copy-web">' + icon('copy', 14) + 'Copy URL</button></div>' +
|
||||
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="keepLocalServerRunning"' + (settings.keepLocalServerRunning ? ' checked' : '') + '><span><b>Keep server running</b><br>Leave Local CloudCLI available after you quit the app.</span></label>' +
|
||||
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="exposeLocalServerOnNetwork"' + (settings.exposeLocalServerOnNetwork ? ' checked' : '') + '><span><b>Allow LAN access</b><br>Use the copied URL from another device on this network.</span></label>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
};
|
||||
|
||||
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 + '<div class="cc-body ' + (CC._reg.bodyClass || '') + '">' + body + '</div>' + 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 '<button class="sb-item' + (selected === id ? ' active' : '') + '" data-cc-nav="' + id + '">' +
|
||||
CC.icon(iconName, 16) + '<span>' + label + '</span><span class="sb-meta">' + CC.esc(meta) + '</span></button>';
|
||||
}
|
||||
|
||||
function localPane(state) {
|
||||
return '<div class="pane-h"><div><h2 class="pane-title">Local CloudCLI</h2><p class="pane-sub">Run the open-source app on this machine. No account required.</p></div></div>' +
|
||||
'<div class="card"><div class="card-head"><div><div class="card-t">Local server</div><div class="card-sub mono">' + CC.esc(CC.localUrl(state) || 'Starts on demand') + '</div></div><div class="card-tools"><span class="dot" style="background:' + (state.localServerRunning ? 'var(--ok)' : 'var(--tx3)') + '"></span><button class="icon-btn" data-cc-action="local-settings-toggle" title="Local settings">' + CC.icon('gear', 16) + '</button></div></div>' +
|
||||
'<div class="card-actions"><button class="btn pri" data-cc-action="local">' + CC.icon('play', 15) + 'Open Local CloudCLI</button><button class="btn" data-cc-action="open-web">' + CC.icon('arrow', 14) + 'Open in browser</button><button class="btn" data-cc-action="copy-web">' + CC.icon('copy', 14) + 'Copy URL</button></div></div>';
|
||||
}
|
||||
|
||||
function envRow(environment) {
|
||||
var meta = CC.statusMeta(environment.status);
|
||||
var tags = (environment.agent ? '<span class="tag">' + CC.esc(environment.agent) + '</span>' : '') + (environment.region ? '<span class="tag">' + CC.esc(environment.region) + '</span>' : '');
|
||||
return '<div class="env" data-cc-env="' + environment.id + '"><span class="dot" style="background:' + meta.dot + '"></span>' +
|
||||
'<div class="env-i"><div class="env-n">' + CC.esc(environment.name || environment.subdomain) + '</div><div class="env-u mono">' + CC.esc(environment.access_url || '') + '</div></div>' +
|
||||
'<div class="env-tags">' + tags + '</div>' +
|
||||
'<span class="badge ' + meta.cls + '">' + meta.label + '</span>' +
|
||||
'<button class="btn sm" data-cc-action="env-row-menu" data-cc-environment-id="' + environment.id + '">Open environment in...</button>' +
|
||||
'<button class="btn sm ' + (environment.status === 'running' ? 'pri' : '') + '">' + CC.icon(meta.busy ? 'refresh' : (environment.status === 'running' ? 'arrow' : 'play'), 14) + meta.open + '</button></div>';
|
||||
}
|
||||
|
||||
function cloudPane(state) {
|
||||
var header = '<div class="pane-h"><div><h2 class="pane-title">Environments</h2><p class="pane-sub">' + CC.esc(CC.envCount(state)) + '</p></div><button class="btn sm" data-cc-action="dashboard">' + CC.icon('arrow', 14) + 'Dashboard</button></div>';
|
||||
if (CC.authState(state) === 'expired') {
|
||||
return header + '<div class="empty">Your CloudCLI session expired.<div style="margin-top:14px"><button class="btn pri" data-cc-action="connect">' + CC.icon('cloudPlus', 15) + 'Reconnect account</button></div></div>';
|
||||
}
|
||||
if (!CC.connected(state)) {
|
||||
return header + '<div class="empty">Connect your CloudCLI account to list hosted environments.<div style="margin-top:14px"><button class="btn pri" data-cc-action="connect">' + CC.icon('cloudPlus', 15) + 'Connect account</button></div></div>';
|
||||
}
|
||||
if (state.cloudLoading && !(state.environments || []).length) {
|
||||
return header + '<div class="empty">Loading your CloudCLI environments...</div>';
|
||||
}
|
||||
|
||||
var list = (state.environments || []).map(envRow).join('');
|
||||
if (!list) list = '<div class="empty">No hosted environments yet.</div>';
|
||||
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 = '<div class="sb"><div class="sb-grp"><div class="lbl">Workspace</div>' +
|
||||
navItem('local', 'terminal', 'Local', state.localServerRunning ? 'on' : 'idle', section) +
|
||||
navItem('cloud', 'cloud', 'Cloud', (state.environments || []).length, section) +
|
||||
'</div></div>';
|
||||
return nav + '<div class="sb-main">' + (section === 'local' ? localPane(state) : cloudPane(state)) + '</div>';
|
||||
}
|
||||
|
||||
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();
|
||||
})();
|
||||
483
electron/localServer.js
Normal file
483
electron/localServer.js
Normal file
@@ -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 };
|
||||
789
electron/main.js
Normal file
789
electron/main.js
Normal file
@@ -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();
|
||||
});
|
||||
}
|
||||
28
electron/preload.cjs
Normal file
28
electron/preload.cjs
Normal file
@@ -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));
|
||||
},
|
||||
});
|
||||
}
|
||||
62
electron/scripts/generate-macos-icon.js
Normal file
62
electron/scripts/generate-macos-icon.js
Normal file
@@ -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 `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${entrySize}" height="${entrySize}" viewBox="0 0 ${entrySize} ${entrySize}">
|
||||
<rect width="${entrySize}" height="${entrySize}" fill="#2563eb"/>
|
||||
<path
|
||||
d="M${8 * scale} ${9 * scale}C${8 * scale} ${8.44772 * scale} ${8.44772 * scale} ${8 * scale} ${9 * scale} ${8 * scale}H${23 * scale}C${23.5523 * scale} ${8 * scale} ${24 * scale} ${8.44772 * scale} ${24 * scale} ${9 * scale}V${18 * scale}C${24 * scale} ${18.5523 * scale} ${23.5523 * scale} ${19 * scale} ${23 * scale} ${19 * scale}H${12 * scale}L${8 * scale} ${23 * scale}V${9 * scale}Z"
|
||||
stroke="white"
|
||||
stroke-width="${2 * scale}"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
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));
|
||||
71
electron/tabs.js
Normal file
71
electron/tabs.js
Normal file
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user