Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 151 additions & 10 deletions desktop/electron/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,141 @@
import { app, BrowserWindow } from 'electron';
import { app, BrowserWindow, ipcMain } from 'electron';
import path from 'path';
import * as pty from 'node-pty';
import { startPythonBackend, stopPythonBackend } from './process_manager';
import * as settingsStore from './settings-store';

// Terminal PTY Storage
const ptyProcesses = new Map<string, pty.IPty>();

function setupTerminalIPC(mainWindow: BrowserWindow) {
ipcMain.on('terminal-spawn', (event, { id, shell, args, cwd, env }) => {
// Kill existing process if any
if (ptyProcesses.has(id)) {
ptyProcesses.get(id)?.kill();
ptyProcesses.delete(id);
}

// Resolve default shells and Windows extensions
let shellPath = shell;
if (!shellPath || shellPath === 'shell') {
shellPath = process.platform === 'win32' ? 'powershell.exe' : 'zsh';
}

// On Windows, commands like 'npx' need to be 'npx.cmd'
if (process.platform === 'win32' && !shellPath.endsWith('.exe') && !shellPath.endsWith('.cmd') && !shellPath.endsWith('.bat')) {
if (shellPath === 'npx' || shellPath === 'npm') {
shellPath = `${shellPath}.cmd`;
}
}

console.log(`[PTY] Spawning: ${shellPath}`, args);

// Merge custom env with process env
const spawnEnv = { ...process.env, ...(env || {}) };

try {
const ptyProcess = pty.spawn(shellPath, args || [], {
name: 'xterm-color',
cols: 80,
rows: 24,
cwd: cwd || process.cwd(),
env: spawnEnv as any,
useConpty: true // Better Windows support
});

ptyProcess.onData((data) => {
mainWindow.webContents.send(`terminal-data-${id}`, data);
});

ptyProcess.onExit(({ exitCode, signal }) => {
mainWindow.webContents.send(`terminal-exit-${id}`, { exitCode, signal });
ptyProcesses.delete(id);
});

ptyProcesses.set(id, ptyProcess);
} catch (err) {
console.error(`[PTY] Failed to spawn ${shellPath}:`, err);
mainWindow.webContents.send(`terminal-data-${id}`, `\r\n\x1b[31m[ERROR] Failed to spawn ${shellPath}\x1b[0m\r\n\x1b[31m[ERROR] Error code: ${(err as any).code || 'unknown'}\x1b[0m\r\n`);
}
});

ipcMain.on('terminal-write', (event, { id, data }) => {
const ptyProcess = ptyProcesses.get(id);
if (ptyProcess) {
ptyProcess.write(data);
}
});

ipcMain.on('terminal-resize', (event, { id, cols, rows }) => {
const ptyProcess = ptyProcesses.get(id);
if (ptyProcess) {
ptyProcess.resize(cols, rows);
}
});

ipcMain.on('terminal-kill', (event, { id }) => {
const ptyProcess = ptyProcesses.get(id);
if (ptyProcess) {
ptyProcess.kill();
ptyProcesses.delete(id);
}
});

// Settings IPC Handlers
ipcMain.handle('settings-save-key', async (_, { provider, key }) => {
return settingsStore.saveApiKey(provider, key);
});

ipcMain.handle('settings-get-key', async (_, { provider }) => {
return settingsStore.getApiKey(provider);
});

ipcMain.handle('settings-delete-key', async (_, { provider }) => {
return settingsStore.deleteApiKey(provider);
});

ipcMain.handle('settings-get-all', async () => {
return settingsStore.getAllSettings();
});

ipcMain.handle('settings-get-providers', async () => {
return settingsStore.getEnabledProviders();
});

ipcMain.handle('settings-has-key', async (_, { provider }) => {
return settingsStore.hasApiKey(provider);
});

// CLI Installation IPC Handlers
ipcMain.handle('cli-check-installed', async (_, { cli }) => {
const { exec } = require('child_process');
const command = process.platform === 'win32' ? `where ${cli}` : `which ${cli}`;

return new Promise<boolean>((resolve) => {
exec(command, (error: any) => {
resolve(!error);
});
});
});

ipcMain.handle('cli-install', async (_, { installCommand }) => {
const { exec } = require('child_process');

console.log(`[CLI Install] Running: ${installCommand}`);

return new Promise<{ success: boolean; output: string }>((resolve) => {
exec(installCommand, { shell: process.platform === 'win32' ? 'powershell.exe' : '/bin/bash' }, (error: any, stdout: string, stderr: string) => {
if (error) {
console.error(`[CLI Install] Error:`, error);
resolve({ success: false, output: stderr || error.message });
} else {
console.log(`[CLI Install] Success:`, stdout);
resolve({ success: true, output: stdout });
}
});
});
});
}

// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (require('electron-squirrel-startup')) {
Expand All @@ -9,30 +144,30 @@ if (require('electron-squirrel-startup')) {

function createWindow() {
const mainWindow = new BrowserWindow({
width: 1280,
height: 800,
width: 1400,
height: 900,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
},
titleBarStyle: 'hidden', // Auto-Claude style
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
trafficLightPosition: { x: 16, y: 16 },
title: 'Squadron',
backgroundColor: '#09090b', // Dark mode bg
backgroundColor: '#09090b',
});

// Load the index.html of the app.
// Load the index.html of the app.
setupTerminalIPC(mainWindow);

if (process.env.VITE_DEV_SERVER_URL) {
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL);
// mainWindow.webContents.openDevTools(); // Clean startup
} else {
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
}
}

app.whenReady().then(() => {
startPythonBackend(); // Start the Python server
startPythonBackend();
createWindow();

app.on('activate', function () {
Expand All @@ -41,6 +176,12 @@ app.whenReady().then(() => {
});

app.on('window-all-closed', function () {
stopPythonBackend(); // Ensure python is killed
// Kill all PTY processes on close
for (const [id, ptyProcess] of ptyProcesses) {
ptyProcess.kill();
}
ptyProcesses.clear();

stopPythonBackend();
if (process.platform !== 'darwin') app.quit();
});
35 changes: 32 additions & 3 deletions desktop/electron/preload.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,38 @@
import { contextBridge, ipcRenderer } from 'electron';

contextBridge.exposeInMainWorld('electronAPI', {
// Add IPC methods here
sendMessage: (channel: string, data: any) => ipcRenderer.send(channel, data),
onMessage: (channel: string, func: (...args: any[]) => void) => {
ipcRenderer.on(channel, (event, ...args) => func(...args));
}
const subscription = (event: any, ...args: any[]) => func(...args);
ipcRenderer.on(channel, subscription);
return () => ipcRenderer.removeListener(channel, subscription);
},
// Dedicated Terminal IPC
spawnTerminal: (id: string, shell: string, args: string[], cwd: string, env?: Record<string, string>) =>
ipcRenderer.send('terminal-spawn', { id, shell, args, cwd, env }),
writeTerminal: (id: string, data: string) => ipcRenderer.send('terminal-write', { id, data }),
resizeTerminal: (id: string, cols: number, rows: number) => ipcRenderer.send('terminal-resize', { id, cols, rows }),
killTerminal: (id: string) => ipcRenderer.send('terminal-kill', { id }),
onTerminalData: (id: string, callback: (data: string) => void) => {
const channel = `terminal-data-${id}`;
const subscription = (_: any, data: string) => callback(data);
ipcRenderer.on(channel, subscription);
return () => ipcRenderer.removeListener(channel, subscription);
},
onTerminalExit: (id: string, callback: (code: number) => void) => {
const channel = `terminal-exit-${id}`;
const subscription = (_: any, { exitCode }: { exitCode: number }) => callback(exitCode);
ipcRenderer.on(channel, subscription);
return () => ipcRenderer.removeListener(channel, subscription);
},
// Settings IPC
saveApiKey: (provider: string, key: string) => ipcRenderer.invoke('settings-save-key', { provider, key }),
getApiKey: (provider: string) => ipcRenderer.invoke('settings-get-key', { provider }),
deleteApiKey: (provider: string) => ipcRenderer.invoke('settings-delete-key', { provider }),
getSettings: () => ipcRenderer.invoke('settings-get-all'),
getEnabledProviders: () => ipcRenderer.invoke('settings-get-providers'),
hasApiKey: (provider: string) => ipcRenderer.invoke('settings-has-key', { provider }),
// CLI Installation IPC
checkCliInstalled: (cli: string) => ipcRenderer.invoke('cli-check-installed', { cli }),
installCli: (installCommand: string) => ipcRenderer.invoke('cli-install', { installCommand }),
});
132 changes: 132 additions & 0 deletions desktop/electron/settings-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { safeStorage } from 'electron'
import * as fs from 'fs'
import * as path from 'path'
import { app } from 'electron'

const SETTINGS_FILE = 'squadron-settings.json'

interface Settings {
apiKeys: Record<string, string> // encrypted keys
enabledProviders: string[]
defaultProvider: string
defaultModel: string
}

const getSettingsPath = (): string => {
return path.join(app.getPath('userData'), SETTINGS_FILE)
}

const loadSettings = (): Settings => {
try {
const settingsPath = getSettingsPath()
if (fs.existsSync(settingsPath)) {
const data = fs.readFileSync(settingsPath, 'utf-8')
return JSON.parse(data)
}
} catch (err) {
console.error('[Settings] Failed to load settings:', err)
}
return {
apiKeys: {},
enabledProviders: ['shell'],
defaultProvider: 'shell',
defaultModel: 'default'
}
}

const saveSettings = (settings: Settings): void => {
try {
const settingsPath = getSettingsPath()
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2))
} catch (err) {
console.error('[Settings] Failed to save settings:', err)
}
}

// API Key Management (encrypted)
export const saveApiKey = (provider: string, key: string): boolean => {
try {
if (!safeStorage.isEncryptionAvailable()) {
console.warn('[Settings] Encryption not available, storing in plain text')
const settings = loadSettings()
settings.apiKeys[provider] = key
if (!settings.enabledProviders.includes(provider)) {
settings.enabledProviders.push(provider)
}
saveSettings(settings)
return true
}

const encrypted = safeStorage.encryptString(key).toString('base64')
const settings = loadSettings()
settings.apiKeys[provider] = encrypted
if (!settings.enabledProviders.includes(provider)) {
settings.enabledProviders.push(provider)
}
saveSettings(settings)
return true
} catch (err) {
console.error('[Settings] Failed to save API key:', err)
return false
}
}

export const getApiKey = (provider: string): string | null => {
try {
const settings = loadSettings()
const stored = settings.apiKeys[provider]
if (!stored) return null

if (!safeStorage.isEncryptionAvailable()) {
return stored // plain text fallback
}

const buffer = Buffer.from(stored, 'base64')
return safeStorage.decryptString(buffer)
} catch (err) {
console.error('[Settings] Failed to get API key:', err)
return null
}
}

export const deleteApiKey = (provider: string): boolean => {
try {
const settings = loadSettings()
delete settings.apiKeys[provider]
settings.enabledProviders = settings.enabledProviders.filter(p => p !== provider)
saveSettings(settings)
return true
} catch (err) {
console.error('[Settings] Failed to delete API key:', err)
return false
}
}

export const getEnabledProviders = (): string[] => {
const settings = loadSettings()
return settings.enabledProviders
}

export const hasApiKey = (provider: string): boolean => {
const settings = loadSettings()
return !!settings.apiKeys[provider]
}

export const getAllSettings = (): Omit<Settings, 'apiKeys'> & { hasKeys: Record<string, boolean> } => {
const settings = loadSettings()
return {
enabledProviders: settings.enabledProviders,
defaultProvider: settings.defaultProvider,
defaultModel: settings.defaultModel,
hasKeys: Object.fromEntries(
Object.keys(settings.apiKeys).map(k => [k, true])
)
}
}

export const setDefaultProvider = (provider: string, model: string): void => {
const settings = loadSettings()
settings.defaultProvider = provider
settings.defaultModel = model
saveSettings(settings)
}
Loading
Loading