/** * Electron main process (TypeScript) * * This version spawns the backend server and uses HTTP API for most operations. * Only native features (dialogs, shell) use IPC. */ import path from 'path'; import { spawn, ChildProcess } from 'child_process'; import fs from 'fs'; import http, { Server } from 'http'; import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron'; import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform'; // Development environment const isDev = !app.isPackaged; const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL; // Load environment variables from .env file (development only) if (isDev) { try { // eslint-disable-next-line @typescript-eslint/no-require-imports require('dotenv').config({ path: path.join(__dirname, '../.env') }); } catch (error) { console.warn('[Electron] dotenv not available:', (error as Error).message); } } let mainWindow: BrowserWindow | null = null; let serverProcess: ChildProcess | null = null; let staticServer: Server | null = null; const SERVER_PORT = 3008; const STATIC_PORT = 3007; // ============================================ // Window sizing constants for kanban layout // ============================================ // Calculation: 4 columns × 280px + 3 gaps × 20px + 40px padding = 1220px board content // With sidebar expanded (288px): 1220 + 288 = 1508px // With sidebar collapsed (64px): 1220 + 64 = 1284px const COLUMN_MIN_WIDTH = 280; const COLUMN_COUNT = 4; const GAP_SIZE = 20; const BOARD_PADDING = 40; // px-5 on both sides = 40px (matches gap between columns) const SIDEBAR_EXPANDED = 288; const SIDEBAR_COLLAPSED = 64; const BOARD_CONTENT_MIN = COLUMN_MIN_WIDTH * COLUMN_COUNT + GAP_SIZE * (COLUMN_COUNT - 1) + BOARD_PADDING; const MIN_WIDTH_EXPANDED = BOARD_CONTENT_MIN + SIDEBAR_EXPANDED; // 1500px const MIN_WIDTH_COLLAPSED = BOARD_CONTENT_MIN + SIDEBAR_COLLAPSED; // 1276px const MIN_HEIGHT = 650; // Ensures sidebar content fits without scrolling const DEFAULT_WIDTH = 1600; const DEFAULT_HEIGHT = 950; // Window bounds interface (matches @automaker/types WindowBounds) interface WindowBounds { x: number; y: number; width: number; height: number; isMaximized: boolean; } // Debounce timer for saving window bounds let saveWindowBoundsTimeout: ReturnType | null = null; /** * Get icon path - works in both dev and production, cross-platform */ function getIconPath(): string | null { let iconFile: string; if (process.platform === 'win32') { iconFile = 'icon.ico'; } else if (process.platform === 'darwin') { iconFile = 'logo_larger.png'; } else { iconFile = 'logo_larger.png'; } const iconPath = isDev ? path.join(__dirname, '../public', iconFile) : path.join(__dirname, '../dist/public', iconFile); if (!fs.existsSync(iconPath)) { console.warn(`[Electron] Icon not found at: ${iconPath}`); return null; } return iconPath; } /** * Get path to window bounds settings file */ function getWindowBoundsPath(): string { return path.join(app.getPath('userData'), 'window-bounds.json'); } /** * Load saved window bounds from disk */ function loadWindowBounds(): WindowBounds | null { try { const boundsPath = getWindowBoundsPath(); if (fs.existsSync(boundsPath)) { const data = fs.readFileSync(boundsPath, 'utf-8'); const bounds = JSON.parse(data) as WindowBounds; // Validate the loaded data has required fields if ( typeof bounds.x === 'number' && typeof bounds.y === 'number' && typeof bounds.width === 'number' && typeof bounds.height === 'number' ) { return bounds; } } } catch (error) { console.warn('[Electron] Failed to load window bounds:', (error as Error).message); } return null; } /** * Save window bounds to disk */ function saveWindowBounds(bounds: WindowBounds): void { try { const boundsPath = getWindowBoundsPath(); fs.writeFileSync(boundsPath, JSON.stringify(bounds, null, 2), 'utf-8'); console.log('[Electron] Window bounds saved'); } catch (error) { console.warn('[Electron] Failed to save window bounds:', (error as Error).message); } } /** * Schedule a debounced save of window bounds (500ms delay) */ function scheduleSaveWindowBounds(): void { if (!mainWindow || mainWindow.isDestroyed()) return; if (saveWindowBoundsTimeout) { clearTimeout(saveWindowBoundsTimeout); } saveWindowBoundsTimeout = setTimeout(() => { if (!mainWindow || mainWindow.isDestroyed()) return; const isMaximized = mainWindow.isMaximized(); // Use getNormalBounds() for maximized windows to save pre-maximized size const bounds = isMaximized ? mainWindow.getNormalBounds() : mainWindow.getBounds(); saveWindowBounds({ x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, isMaximized, }); }, 500); } /** * Validate that window bounds are visible on at least one display * Returns adjusted bounds if needed, or null if completely off-screen */ function validateBounds(bounds: WindowBounds): WindowBounds { const displays = screen.getAllDisplays(); // Check if window center is visible on any display const centerX = bounds.x + bounds.width / 2; const centerY = bounds.y + bounds.height / 2; let isVisible = false; for (const display of displays) { const { x, y, width, height } = display.workArea; if (centerX >= x && centerX <= x + width && centerY >= y && centerY <= y + height) { isVisible = true; break; } } if (!isVisible) { // Window is off-screen, reset to primary display const primaryDisplay = screen.getPrimaryDisplay(); const { x, y, width, height } = primaryDisplay.workArea; return { x: x + Math.floor((width - bounds.width) / 2), y: y + Math.floor((height - bounds.height) / 2), width: Math.min(bounds.width, width), height: Math.min(bounds.height, height), isMaximized: bounds.isMaximized, }; } // Ensure minimum dimensions return { ...bounds, width: Math.max(bounds.width, MIN_WIDTH_EXPANDED), height: Math.max(bounds.height, MIN_HEIGHT), }; } /** * Start static file server for production builds */ async function startStaticServer(): Promise { const staticPath = path.join(__dirname, '../dist'); staticServer = http.createServer((request, response) => { let filePath = path.join(staticPath, request.url?.split('?')[0] || '/'); if (filePath.endsWith('/')) { filePath = path.join(filePath, 'index.html'); } else if (!path.extname(filePath)) { // For client-side routing, serve index.html for paths without extensions const possibleFile = filePath + '.html'; if (!fs.existsSync(filePath) && !fs.existsSync(possibleFile)) { filePath = path.join(staticPath, 'index.html'); } else if (fs.existsSync(possibleFile)) { filePath = possibleFile; } } fs.stat(filePath, (err, stats) => { if (err || !stats?.isFile()) { filePath = path.join(staticPath, 'index.html'); } fs.readFile(filePath, (error, content) => { if (error) { response.writeHead(500); response.end('Server Error'); return; } const ext = path.extname(filePath); const contentTypes: Record = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.eot': 'application/vnd.ms-fontobject', }; response.writeHead(200, { 'Content-Type': contentTypes[ext] || 'application/octet-stream', }); response.end(content); }); }); }); return new Promise((resolve, reject) => { staticServer!.listen(STATIC_PORT, () => { console.log(`[Electron] Static server running at http://localhost:${STATIC_PORT}`); resolve(); }); staticServer!.on('error', reject); }); } /** * Start the backend server */ async function startServer(): Promise { // Find Node.js executable (handles desktop launcher scenarios) const nodeResult = findNodeExecutable({ skipSearch: isDev, logger: (msg: string) => console.log(`[Electron] ${msg}`), }); const command = nodeResult.nodePath; // Validate that the found Node executable actually exists if (command !== 'node' && !fs.existsSync(command)) { throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`); } let args: string[]; let serverPath: string; if (isDev) { serverPath = path.join(__dirname, '../../server/src/index.ts'); const serverNodeModules = path.join(__dirname, '../../server/node_modules/tsx'); const rootNodeModules = path.join(__dirname, '../../../node_modules/tsx'); let tsxCliPath: string; if (fs.existsSync(path.join(serverNodeModules, 'dist/cli.mjs'))) { tsxCliPath = path.join(serverNodeModules, 'dist/cli.mjs'); } else if (fs.existsSync(path.join(rootNodeModules, 'dist/cli.mjs'))) { tsxCliPath = path.join(rootNodeModules, 'dist/cli.mjs'); } else { try { tsxCliPath = require.resolve('tsx/cli.mjs', { paths: [path.join(__dirname, '../../server')], }); } catch { throw new Error("Could not find tsx. Please run 'npm install' in the server directory."); } } args = [tsxCliPath, 'watch', serverPath]; } else { serverPath = path.join(process.resourcesPath, 'server', 'index.js'); args = [serverPath]; if (!fs.existsSync(serverPath)) { throw new Error(`Server not found at: ${serverPath}`); } } const serverNodeModules = app.isPackaged ? path.join(process.resourcesPath, 'server', 'node_modules') : path.join(__dirname, '../../server/node_modules'); // Build enhanced PATH that includes Node.js directory (cross-platform) const enhancedPath = buildEnhancedPath(command, process.env.PATH || ''); if (enhancedPath !== process.env.PATH) { console.log(`[Electron] Enhanced PATH with Node directory: ${path.dirname(command)}`); } const env = { ...process.env, PATH: enhancedPath, PORT: SERVER_PORT.toString(), DATA_DIR: app.getPath('userData'), NODE_PATH: serverNodeModules, // Only set ALLOWED_ROOT_DIRECTORY if explicitly provided in environment // If not set, server will allow access to all paths ...(process.env.ALLOWED_ROOT_DIRECTORY && { ALLOWED_ROOT_DIRECTORY: process.env.ALLOWED_ROOT_DIRECTORY, }), }; console.log('[Electron] Starting backend server...'); console.log('[Electron] Server path:', serverPath); console.log('[Electron] NODE_PATH:', serverNodeModules); serverProcess = spawn(command, args, { cwd: path.dirname(serverPath), env, stdio: ['ignore', 'pipe', 'pipe'], }); serverProcess.stdout?.on('data', (data) => { console.log(`[Server] ${data.toString().trim()}`); }); serverProcess.stderr?.on('data', (data) => { console.error(`[Server Error] ${data.toString().trim()}`); }); serverProcess.on('close', (code) => { console.log(`[Server] Process exited with code ${code}`); serverProcess = null; }); serverProcess.on('error', (err) => { console.error(`[Server] Failed to start server process:`, err); serverProcess = null; }); await waitForServer(); } /** * Wait for server to be available */ async function waitForServer(maxAttempts = 30): Promise { for (let i = 0; i < maxAttempts; i++) { try { await new Promise((resolve, reject) => { const req = http.get(`http://localhost:${SERVER_PORT}/api/health`, (res) => { if (res.statusCode === 200) { resolve(); } else { reject(new Error(`Status: ${res.statusCode}`)); } }); req.on('error', reject); req.setTimeout(1000, () => { req.destroy(); reject(new Error('Timeout')); }); }); console.log('[Electron] Server is ready'); return; } catch { await new Promise((r) => setTimeout(r, 500)); } } throw new Error('Server failed to start'); } /** * Create the main window */ function createWindow(): void { const iconPath = getIconPath(); // Load and validate saved window bounds const savedBounds = loadWindowBounds(); const validBounds = savedBounds ? validateBounds(savedBounds) : null; const windowOptions: Electron.BrowserWindowConstructorOptions = { width: validBounds?.width ?? DEFAULT_WIDTH, height: validBounds?.height ?? DEFAULT_HEIGHT, x: validBounds?.x, y: validBounds?.y, minWidth: MIN_WIDTH_EXPANDED, // 1500px - ensures kanban columns fit with sidebar minHeight: MIN_HEIGHT, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, }, titleBarStyle: 'hiddenInset', backgroundColor: '#0a0a0a', }; if (iconPath) { windowOptions.icon = iconPath; } mainWindow = new BrowserWindow(windowOptions); // Restore maximized state if previously maximized if (validBounds?.isMaximized) { mainWindow.maximize(); } // Load Vite dev server in development or static server in production if (VITE_DEV_SERVER_URL) { mainWindow.loadURL(VITE_DEV_SERVER_URL); } else if (isDev) { // Fallback for dev without Vite server URL mainWindow.loadURL(`http://localhost:${STATIC_PORT}`); } else { mainWindow.loadURL(`http://localhost:${STATIC_PORT}`); } if (isDev && process.env.OPEN_DEVTOOLS === 'true') { mainWindow.webContents.openDevTools(); } // Save window bounds on close, resize, and move mainWindow.on('close', () => { // Save immediately before closing (not debounced) if (mainWindow && !mainWindow.isDestroyed()) { const isMaximized = mainWindow.isMaximized(); const bounds = isMaximized ? mainWindow.getNormalBounds() : mainWindow.getBounds(); saveWindowBounds({ x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, isMaximized, }); } }); mainWindow.on('closed', () => { mainWindow = null; }); mainWindow.on('resized', () => { scheduleSaveWindowBounds(); }); mainWindow.on('moved', () => { scheduleSaveWindowBounds(); }); mainWindow.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; }); } // App lifecycle app.whenReady().then(async () => { // Ensure userData path is consistent across dev/prod so files land in Automaker dir try { const desiredUserDataPath = path.join(app.getPath('appData'), 'Automaker'); if (app.getPath('userData') !== desiredUserDataPath) { app.setPath('userData', desiredUserDataPath); console.log('[Electron] userData path set to:', desiredUserDataPath); } } catch (error) { console.warn('[Electron] Failed to set userData path:', (error as Error).message); } if (process.platform === 'darwin' && app.dock) { const iconPath = getIconPath(); if (iconPath) { try { app.dock.setIcon(iconPath); } catch (error) { console.warn('[Electron] Failed to set dock icon:', (error as Error).message); } } } try { // Start static file server in production if (app.isPackaged) { await startStaticServer(); } // Start backend server await startServer(); // Create window createWindow(); } catch (error) { console.error('[Electron] Failed to start:', error); const errorMessage = (error as Error).message; const isNodeError = errorMessage.includes('Node.js'); dialog.showErrorBox( 'Automaker Failed to Start', `The application failed to start.\n\n${errorMessage}\n\n${ isNodeError ? 'Please install Node.js from https://nodejs.org or via a package manager (Homebrew, nvm, fnm).' : 'Please check the application logs for more details.' }` ); app.quit(); } app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } }); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit(); } }); app.on('before-quit', () => { if (serverProcess) { console.log('[Electron] Stopping server...'); serverProcess.kill(); serverProcess = null; } if (staticServer) { console.log('[Electron] Stopping static server...'); staticServer.close(); staticServer = null; } }); // ============================================ // IPC Handlers - Only native features // ============================================ // Native file dialogs ipcMain.handle('dialog:openDirectory', async () => { if (!mainWindow) { return { canceled: true, filePaths: [] }; } const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory', 'createDirectory'], }); return result; }); ipcMain.handle('dialog:openFile', async (_, options = {}) => { if (!mainWindow) { return { canceled: true, filePaths: [] }; } const result = await dialog.showOpenDialog(mainWindow, { properties: ['openFile'], ...options, }); return result; }); ipcMain.handle('dialog:saveFile', async (_, options = {}) => { if (!mainWindow) { return { canceled: true, filePath: undefined }; } const result = await dialog.showSaveDialog(mainWindow, options); return result; }); // Shell operations ipcMain.handle('shell:openExternal', async (_, url: string) => { try { await shell.openExternal(url); return { success: true }; } catch (error) { return { success: false, error: (error as Error).message }; } }); ipcMain.handle('shell:openPath', async (_, filePath: string) => { try { await shell.openPath(filePath); return { success: true }; } catch (error) { return { success: false, error: (error as Error).message }; } }); // Open file in editor (VS Code, etc.) with optional line/column ipcMain.handle( 'shell:openInEditor', async (_, filePath: string, line?: number, column?: number) => { try { // Build VS Code URL scheme: vscode://file/path:line:column // This works on all platforms where VS Code is installed // URL encode the path to handle special characters (spaces, brackets, etc.) // Handle both Unix (/) and Windows (\) path separators const normalizedPath = filePath.replace(/\\/g, '/'); const encodedPath = normalizedPath.startsWith('/') ? '/' + normalizedPath.slice(1).split('/').map(encodeURIComponent).join('/') : normalizedPath.split('/').map(encodeURIComponent).join('/'); let url = `vscode://file${encodedPath}`; if (line !== undefined && line > 0) { url += `:${line}`; if (column !== undefined && column > 0) { url += `:${column}`; } } await shell.openExternal(url); return { success: true }; } catch (error) { return { success: false, error: (error as Error).message }; } } ); // App info ipcMain.handle('app:getPath', async (_, name: Parameters[0]) => { return app.getPath(name); }); ipcMain.handle('app:getVersion', async () => { return app.getVersion(); }); ipcMain.handle('app:isPackaged', async () => { return app.isPackaged; }); // Ping - for connection check ipcMain.handle('ping', async () => { return 'pong'; }); // Get server URL for HTTP client ipcMain.handle('server:getUrl', async () => { return `http://localhost:${SERVER_PORT}`; }); // Window management - update minimum width based on sidebar state ipcMain.handle('window:updateMinWidth', (_, sidebarExpanded: boolean) => { if (!mainWindow || mainWindow.isDestroyed()) return; const minWidth = sidebarExpanded ? MIN_WIDTH_EXPANDED : MIN_WIDTH_COLLAPSED; mainWindow.setMinimumSize(minWidth, MIN_HEIGHT); // If current width is below new minimum, resize window const currentBounds = mainWindow.getBounds(); if (currentBounds.width < minWidth) { mainWindow.setSize(minWidth, currentBounds.height); } });