Merge pull request #704 from AutoMaker-Org/refactor/electron-main-process

refactor: Modularize Electron main process into single-responsibility components
This commit is contained in:
Shirone
2026-01-25 20:10:39 +00:00
committed by GitHub
20 changed files with 1240 additions and 847 deletions

View File

@@ -0,0 +1,47 @@
/**
* Electron main process constants
*
* Centralized configuration for window sizing, ports, and file names.
*/
// ============================================
// 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
// Minimum window dimensions - reduced to allow smaller windows since kanban now supports horizontal scrolling
export const MIN_WIDTH_COLLAPSED = 600; // Reduced - horizontal scrolling handles overflow
export const MIN_HEIGHT = 500; // Reduced to allow more flexibility
export const DEFAULT_WIDTH = 1600;
export const DEFAULT_HEIGHT = 950;
// ============================================
// Port defaults
// ============================================
// Default ports (can be overridden via env) - will be dynamically assigned if these are in use
// When launched via root init.mjs we pass:
// - PORT (backend)
// - TEST_PORT (vite dev server / static)
// Guard against NaN from non-numeric environment variables
const parsedServerPort = Number.parseInt(process.env.PORT ?? '', 10);
const parsedStaticPort = Number.parseInt(process.env.TEST_PORT ?? '', 10);
export const DEFAULT_SERVER_PORT = Number.isFinite(parsedServerPort) ? parsedServerPort : 3008;
export const DEFAULT_STATIC_PORT = Number.isFinite(parsedStaticPort) ? parsedStaticPort : 3007;
// ============================================
// File names for userData storage
// ============================================
export const API_KEY_FILENAME = '.api-key';
export const WINDOW_BOUNDS_FILENAME = 'window-bounds.json';
// ============================================
// Window bounds interface
// ============================================
// Matches @automaker/types WindowBounds
export interface WindowBounds {
x: number;
y: number;
width: number;
height: number;
isMaximized: boolean;
}

View File

@@ -0,0 +1,32 @@
/**
* Electron main process modules
*
* Re-exports for convenient importing.
*/
// Constants and types
export * from './constants';
export { state } from './state';
// Utilities
export { isPortAvailable, findAvailablePort } from './utils/port-manager';
export { getIconPath } from './utils/icon-manager';
// Security
export { ensureApiKey, getApiKey } from './security/api-key-manager';
// Windows
export {
loadWindowBounds,
saveWindowBounds,
validateBounds,
scheduleSaveWindowBounds,
} from './windows/window-bounds';
export { createWindow } from './windows/main-window';
// Server
export { startStaticServer, stopStaticServer } from './server/static-server';
export { startServer, waitForServer, stopServer } from './server/backend-server';
// IPC
export { IPC_CHANNELS, registerAllHandlers } from './ipc';

View File

@@ -0,0 +1,37 @@
/**
* App IPC handlers
*
* Handles app-related operations like getting paths, version info, and quitting.
*/
import { ipcMain, app } from 'electron';
import { createLogger } from '@automaker/utils/logger';
import { IPC_CHANNELS } from './channels';
const logger = createLogger('AppHandlers');
/**
* Register app IPC handlers
*/
export function registerAppHandlers(): void {
// Get app path
ipcMain.handle(IPC_CHANNELS.APP.GET_PATH, async (_, name: Parameters<typeof app.getPath>[0]) => {
return app.getPath(name);
});
// Get app version
ipcMain.handle(IPC_CHANNELS.APP.GET_VERSION, async () => {
return app.getVersion();
});
// Check if app is packaged
ipcMain.handle(IPC_CHANNELS.APP.IS_PACKAGED, async () => {
return app.isPackaged;
});
// Quit the application
ipcMain.handle(IPC_CHANNELS.APP.QUIT, () => {
logger.info('Quitting application via IPC request');
app.quit();
});
}

View File

@@ -0,0 +1,34 @@
/**
* Auth IPC handlers
*
* Handles authentication-related operations.
*/
import { ipcMain } from 'electron';
import { IPC_CHANNELS } from './channels';
import { state } from '../state';
/**
* Register auth IPC handlers
*/
export function registerAuthHandlers(): void {
// Get API key for authentication
// Returns null in external server mode to trigger session-based auth
// Only returns API key to the main window to prevent leaking to untrusted senders
ipcMain.handle(IPC_CHANNELS.AUTH.GET_API_KEY, (event) => {
// Validate sender is the main window
if (event.sender !== state.mainWindow?.webContents) {
return null;
}
if (state.isExternalServerMode) {
return null;
}
return state.apiKey;
});
// Check if running in external server mode (Docker API)
// Used by renderer to determine auth flow
ipcMain.handle(IPC_CHANNELS.AUTH.IS_EXTERNAL_SERVER_MODE, () => {
return state.isExternalServerMode;
});
}

View File

@@ -0,0 +1,36 @@
/**
* IPC channel constants
*
* Single source of truth for all IPC channel names.
* Used by both main process handlers and preload script.
*/
export const IPC_CHANNELS = {
DIALOG: {
OPEN_DIRECTORY: 'dialog:openDirectory',
OPEN_FILE: 'dialog:openFile',
SAVE_FILE: 'dialog:saveFile',
},
SHELL: {
OPEN_EXTERNAL: 'shell:openExternal',
OPEN_PATH: 'shell:openPath',
OPEN_IN_EDITOR: 'shell:openInEditor',
},
APP: {
GET_PATH: 'app:getPath',
GET_VERSION: 'app:getVersion',
IS_PACKAGED: 'app:isPackaged',
QUIT: 'app:quit',
},
AUTH: {
GET_API_KEY: 'auth:getApiKey',
IS_EXTERNAL_SERVER_MODE: 'auth:isExternalServerMode',
},
WINDOW: {
UPDATE_MIN_WIDTH: 'window:updateMinWidth',
},
SERVER: {
GET_URL: 'server:getUrl',
},
PING: 'ping',
} as const;

View File

@@ -0,0 +1,72 @@
/**
* Dialog IPC handlers
*
* Handles native file dialog operations.
*/
import { ipcMain, dialog } from 'electron';
import { isPathAllowed, getAllowedRootDirectory } from '@automaker/platform';
import { IPC_CHANNELS } from './channels';
import { state } from '../state';
/**
* Register dialog IPC handlers
*/
export function registerDialogHandlers(): void {
// Open directory dialog
ipcMain.handle(IPC_CHANNELS.DIALOG.OPEN_DIRECTORY, async () => {
if (!state.mainWindow) {
return { canceled: true, filePaths: [] };
}
const result = await dialog.showOpenDialog(state.mainWindow, {
properties: ['openDirectory', 'createDirectory'],
});
// Validate selected path against ALLOWED_ROOT_DIRECTORY if configured
if (!result.canceled && result.filePaths.length > 0) {
const selectedPath = result.filePaths[0];
if (!isPathAllowed(selectedPath)) {
const allowedRoot = getAllowedRootDirectory();
const errorMessage = allowedRoot
? `The selected directory is not allowed. Please select a directory within: ${allowedRoot}`
: 'The selected directory is not allowed.';
dialog.showErrorBox('Directory Not Allowed', errorMessage);
return { canceled: true, filePaths: [] };
}
}
return result;
});
// Open file dialog
// Filter properties to maintain file-only intent and prevent renderer from requesting directories
ipcMain.handle(
IPC_CHANNELS.DIALOG.OPEN_FILE,
async (_, options: Record<string, unknown> = {}) => {
if (!state.mainWindow) {
return { canceled: true, filePaths: [] };
}
// Ensure openFile is always present and filter out directory-related properties
const inputProperties = (options.properties as string[]) ?? [];
const properties = ['openFile', ...inputProperties].filter(
(p) => p !== 'openDirectory' && p !== 'createDirectory'
);
const result = await dialog.showOpenDialog(state.mainWindow, {
...options,
properties: properties as Electron.OpenDialogOptions['properties'],
});
return result;
}
);
// Save file dialog
ipcMain.handle(IPC_CHANNELS.DIALOG.SAVE_FILE, async (_, options = {}) => {
if (!state.mainWindow) {
return { canceled: true, filePath: undefined };
}
const result = await dialog.showSaveDialog(state.mainWindow, options);
return result;
});
}

View File

@@ -0,0 +1,26 @@
/**
* IPC handlers aggregator
*
* Registers all IPC handlers in one place.
*/
import { registerDialogHandlers } from './dialog-handlers';
import { registerShellHandlers } from './shell-handlers';
import { registerAppHandlers } from './app-handlers';
import { registerAuthHandlers } from './auth-handlers';
import { registerWindowHandlers } from './window-handlers';
import { registerServerHandlers } from './server-handlers';
export { IPC_CHANNELS } from './channels';
/**
* Register all IPC handlers
*/
export function registerAllHandlers(): void {
registerDialogHandlers();
registerShellHandlers();
registerAppHandlers();
registerAuthHandlers();
registerWindowHandlers();
registerServerHandlers();
}

View File

@@ -0,0 +1,24 @@
/**
* Server IPC handlers
*
* Handles server-related operations.
*/
import { ipcMain } from 'electron';
import { IPC_CHANNELS } from './channels';
import { state } from '../state';
/**
* Register server IPC handlers
*/
export function registerServerHandlers(): void {
// Get server URL for HTTP client
ipcMain.handle(IPC_CHANNELS.SERVER.GET_URL, async () => {
return `http://localhost:${state.serverPort}`;
});
// Ping - for connection check
ipcMain.handle(IPC_CHANNELS.PING, async () => {
return 'pong';
});
}

View File

@@ -0,0 +1,61 @@
/**
* Shell IPC handlers
*
* Handles shell operations like opening external links and files.
*/
import { ipcMain, shell } from 'electron';
import { IPC_CHANNELS } from './channels';
/**
* Register shell IPC handlers
*/
export function registerShellHandlers(): void {
// Open external URL
ipcMain.handle(IPC_CHANNELS.SHELL.OPEN_EXTERNAL, async (_, url: string) => {
try {
await shell.openExternal(url);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// Open file path
ipcMain.handle(IPC_CHANNELS.SHELL.OPEN_PATH, 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(
IPC_CHANNELS.SHELL.OPEN_IN_EDITOR,
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 segments = normalizedPath.split('/').map(encodeURIComponent);
const encodedPath = segments.join('/');
// VS Code URL format requires a leading slash after 'file'
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 };
}
}
);
}

View File

@@ -0,0 +1,24 @@
/**
* Window IPC handlers
*
* Handles window management operations.
*/
import { ipcMain } from 'electron';
import { IPC_CHANNELS } from './channels';
import { MIN_WIDTH_COLLAPSED, MIN_HEIGHT } from '../constants';
import { state } from '../state';
/**
* Register window IPC handlers
*/
export function registerWindowHandlers(): void {
// Update minimum width based on sidebar state
// Now uses a fixed small minimum since horizontal scrolling handles overflow
ipcMain.handle(IPC_CHANNELS.WINDOW.UPDATE_MIN_WIDTH, (_, _sidebarExpanded: boolean) => {
if (!state.mainWindow || state.mainWindow.isDestroyed()) return;
// Always use the smaller minimum width - horizontal scrolling handles any overflow
state.mainWindow.setMinimumSize(MIN_WIDTH_COLLAPSED, MIN_HEIGHT);
});
}

View File

@@ -0,0 +1,58 @@
/**
* API key management
*
* Handles generation, storage, and retrieval of the API key for CSRF protection.
* Uses centralized electronUserData methods for path validation.
*/
import crypto from 'crypto';
import {
electronUserDataExists,
electronUserDataReadFileSync,
electronUserDataWriteFileSync,
} from '@automaker/platform';
import { createLogger } from '@automaker/utils/logger';
import { API_KEY_FILENAME } from '../constants';
import { state } from '../state';
const logger = createLogger('ApiKeyManager');
/**
* Ensure an API key exists - load from file or generate new one.
* This key is passed to the server for CSRF protection.
* Uses centralized electronUserData methods for path validation.
*/
export function ensureApiKey(): string {
try {
if (electronUserDataExists(API_KEY_FILENAME)) {
const key = electronUserDataReadFileSync(API_KEY_FILENAME).trim();
if (key) {
state.apiKey = key;
logger.info('Loaded existing API key');
return state.apiKey;
}
}
} catch (error) {
logger.warn('Error reading API key:', error);
}
// Generate new key
state.apiKey = crypto.randomUUID();
try {
electronUserDataWriteFileSync(API_KEY_FILENAME, state.apiKey, {
encoding: 'utf-8',
mode: 0o600,
});
logger.info('Generated new API key');
} catch (error) {
logger.error('Failed to save API key:', error);
}
return state.apiKey;
}
/**
* Get the current API key
*/
export function getApiKey(): string | null {
return state.apiKey;
}

View File

@@ -0,0 +1,230 @@
/**
* Backend server management
*
* Handles starting, stopping, and monitoring the Express backend server.
* Uses centralized methods for path validation.
*/
import path from 'path';
import http from 'http';
import { spawn, execSync } from 'child_process';
import { app } from 'electron';
import {
findNodeExecutable,
buildEnhancedPath,
electronAppExists,
systemPathExists,
} from '@automaker/platform';
import { createLogger } from '@automaker/utils/logger';
import { state } from '../state';
const logger = createLogger('BackendServer');
const serverLogger = createLogger('Server');
/**
* Start the backend server
* Uses centralized methods for path validation.
*/
export async function startServer(): Promise<void> {
const isDev = !app.isPackaged;
// Find Node.js executable (handles desktop launcher scenarios)
const nodeResult = findNodeExecutable({
skipSearch: isDev,
logger: (msg: string) => logger.info(msg),
});
const command = nodeResult.nodePath;
// Validate that the found Node executable actually exists
// systemPathExists is used because node-finder returns system paths
if (command !== 'node') {
let exists: boolean;
try {
exists = systemPathExists(command);
} catch (error) {
const originalError = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to verify Node.js executable at: ${command} (source: ${nodeResult.source}). Reason: ${originalError}`
);
}
if (!exists) {
throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`);
}
}
let args: string[];
let serverPath: string;
// __dirname is apps/ui/dist-electron (Vite bundles all into single file)
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;
// Check for tsx in app bundle paths, fallback to require.resolve
const serverTsxPath = path.join(serverNodeModules, 'dist/cli.mjs');
const rootTsxPath = path.join(rootNodeModules, 'dist/cli.mjs');
try {
if (electronAppExists(serverTsxPath)) {
tsxCliPath = serverTsxPath;
} else if (electronAppExists(rootTsxPath)) {
tsxCliPath = rootTsxPath;
} else {
// Fallback to require.resolve
tsxCliPath = require.resolve('tsx/cli.mjs', {
paths: [path.join(__dirname, '../../server')],
});
}
} catch {
// electronAppExists threw or require.resolve failed
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 (!electronAppExists(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');
// Server root directory - where .env file is located
// In dev: apps/server (not apps/server/src)
// In production: resources/server
const serverRoot = app.isPackaged
? path.join(process.resourcesPath, 'server')
: path.join(__dirname, '../../server');
// IMPORTANT: Use shared data directory (not Electron's user data directory)
// This ensures Electron and web mode share the same settings/projects
// In dev: project root/data (navigate from __dirname which is apps/ui/dist-electron)
// In production: same as Electron user data (for app isolation)
const dataDir = app.isPackaged
? app.getPath('userData')
: path.join(__dirname, '../../..', 'data');
logger.info(
`[DATA_DIR] app.isPackaged=${app.isPackaged}, __dirname=${__dirname}, dataDir=${dataDir}`
);
// Build enhanced PATH that includes Node.js directory (cross-platform)
const enhancedPath = buildEnhancedPath(command, process.env.PATH || '');
if (enhancedPath !== process.env.PATH) {
logger.info('Enhanced PATH with Node directory:', path.dirname(command));
}
const env = {
...process.env,
PATH: enhancedPath,
PORT: state.serverPort.toString(),
DATA_DIR: dataDir,
NODE_PATH: serverNodeModules,
// Pass API key to server for CSRF protection
AUTOMAKER_API_KEY: state.apiKey!,
// 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,
}),
};
logger.info('Server will use port', state.serverPort);
logger.info('[DATA_DIR_SPAWN] env.DATA_DIR=', env.DATA_DIR);
logger.info('Starting backend server...');
logger.info('Server path:', serverPath);
logger.info('Server root (cwd):', serverRoot);
logger.info('NODE_PATH:', serverNodeModules);
state.serverProcess = spawn(command, args, {
cwd: serverRoot,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
state.serverProcess.stdout?.on('data', (data) => {
serverLogger.info(data.toString().trim());
});
state.serverProcess.stderr?.on('data', (data) => {
serverLogger.error(data.toString().trim());
});
state.serverProcess.on('close', (code) => {
serverLogger.info('Process exited with code', code);
state.serverProcess = null;
});
state.serverProcess.on('error', (err) => {
serverLogger.error('Failed to start server process:', err);
state.serverProcess = null;
});
await waitForServer();
}
/**
* Wait for server to be available
*/
export async function waitForServer(maxAttempts = 30): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
try {
await new Promise<void>((resolve, reject) => {
const req = http.get(`http://localhost:${state.serverPort}/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'));
});
});
logger.info('Server is ready');
return;
} catch {
await new Promise((r) => setTimeout(r, 500));
}
}
throw new Error('Server failed to start');
}
/**
* Stop the backend server if running
*/
export function stopServer(): void {
if (state.serverProcess && state.serverProcess.pid) {
logger.info('Stopping server...');
if (process.platform === 'win32') {
try {
// Windows: use taskkill with /t to kill entire process tree
// This prevents orphaned node processes when closing the app
// Using execSync to ensure process is killed before app exits
execSync(`taskkill /f /t /pid ${state.serverProcess.pid}`, { stdio: 'ignore' });
} catch (error) {
logger.error('Failed to kill server process:', (error as Error).message);
}
} else {
state.serverProcess.kill('SIGTERM');
}
state.serverProcess = null;
}
}

View File

@@ -0,0 +1,101 @@
/**
* Static file server for production builds
*
* Serves the built frontend files in production mode.
* Uses centralized electronApp methods for serving static files from app bundle.
*/
import path from 'path';
import http from 'http';
import { electronAppExists, electronAppStat, electronAppReadFile } from '@automaker/platform';
import { createLogger } from '@automaker/utils/logger';
import { state } from '../state';
const logger = createLogger('StaticServer');
/**
* MIME type mapping for static files
*/
const CONTENT_TYPES: Record<string, string> = {
'.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',
};
/**
* Start static file server for production builds
* Uses centralized electronApp methods for serving static files from app bundle.
*/
export async function startStaticServer(): Promise<void> {
// __dirname is apps/ui/dist-electron (Vite bundles all into single file)
const staticPath = path.join(__dirname, '../dist');
state.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';
try {
if (!electronAppExists(filePath) && !electronAppExists(possibleFile)) {
filePath = path.join(staticPath, 'index.html');
} else if (electronAppExists(possibleFile)) {
filePath = possibleFile;
}
} catch {
filePath = path.join(staticPath, 'index.html');
}
}
electronAppStat(filePath, (err, stats) => {
if (err || !stats?.isFile()) {
filePath = path.join(staticPath, 'index.html');
}
electronAppReadFile(filePath, (error, content) => {
if (error || !content) {
response.writeHead(500);
response.end('Server Error');
return;
}
const ext = path.extname(filePath);
response.writeHead(200, {
'Content-Type': CONTENT_TYPES[ext] || 'application/octet-stream',
});
response.end(content);
});
});
});
return new Promise((resolve, reject) => {
state.staticServer!.listen(state.staticPort, () => {
logger.info('Static server running at http://localhost:' + state.staticPort);
resolve();
});
state.staticServer!.on('error', reject);
});
}
/**
* Stop the static server if running
*/
export function stopStaticServer(): void {
if (state.staticServer) {
logger.info('Stopping static server...');
state.staticServer.close();
state.staticServer = null;
}
}

View File

@@ -0,0 +1,33 @@
/**
* Electron main process shared state
*
* Centralized state container to avoid circular dependencies.
* All modules access shared state through this object.
*/
import { BrowserWindow } from 'electron';
import { ChildProcess } from 'child_process';
import { Server } from 'http';
import { DEFAULT_SERVER_PORT, DEFAULT_STATIC_PORT } from './constants';
export interface ElectronState {
mainWindow: BrowserWindow | null;
serverProcess: ChildProcess | null;
staticServer: Server | null;
serverPort: number;
staticPort: number;
apiKey: string | null;
isExternalServerMode: boolean;
saveWindowBoundsTimeout: ReturnType<typeof setTimeout> | null;
}
export const state: ElectronState = {
mainWindow: null,
serverProcess: null,
staticServer: null,
serverPort: DEFAULT_SERVER_PORT,
staticPort: DEFAULT_STATIC_PORT,
apiKey: null,
isExternalServerMode: false,
saveWindowBoundsTimeout: null,
};

View File

@@ -0,0 +1,46 @@
/**
* Icon management utilities
*
* Functions for getting the application icon path.
*/
import path from 'path';
import { app } from 'electron';
import { electronAppExists } from '@automaker/platform';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('IconManager');
/**
* Get icon path - works in both dev and production, cross-platform
* Uses centralized electronApp methods for path validation.
*/
export function getIconPath(): string | null {
const isDev = !app.isPackaged;
let iconFile: string;
if (process.platform === 'win32') {
iconFile = 'icon.ico';
} else if (process.platform === 'darwin') {
iconFile = 'logo_larger.png';
} else {
iconFile = 'logo_larger.png';
}
// __dirname is apps/ui/dist-electron (Vite bundles all into single file)
const iconPath = isDev
? path.join(__dirname, '../public', iconFile)
: path.join(__dirname, '../dist/public', iconFile);
try {
if (!electronAppExists(iconPath)) {
logger.warn('Icon not found at:', iconPath);
return null;
}
} catch (error) {
logger.warn('Icon check failed:', iconPath, error);
return null;
}
return iconPath;
}

View File

@@ -0,0 +1,42 @@
/**
* Port management utilities
*
* Functions for checking port availability and finding open ports.
* No Electron dependencies - pure utility module.
*/
import net from 'net';
/**
* Check if a port is available
*/
export function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => {
resolve(false);
});
server.once('listening', () => {
server.close(() => {
resolve(true);
});
});
// Use Node's default binding semantics (matches most dev servers)
// This avoids false-positives when a port is taken on IPv6/dual-stack.
server.listen(port);
});
}
/**
* Find an available port starting from the preferred port
* Tries up to 100 ports in sequence
*/
export async function findAvailablePort(preferredPort: number): Promise<number> {
for (let offset = 0; offset < 100; offset++) {
const port = preferredPort + offset;
if (await isPortAvailable(port)) {
return port;
}
}
throw new Error(`Could not find an available port starting from ${preferredPort}`);
}

View File

@@ -0,0 +1,116 @@
/**
* Main window creation and lifecycle
*
* Handles creating the main BrowserWindow and its event handlers.
*/
import path from 'path';
import { app, BrowserWindow, shell } from 'electron';
import { createLogger } from '@automaker/utils/logger';
import { MIN_WIDTH_COLLAPSED, MIN_HEIGHT, DEFAULT_WIDTH, DEFAULT_HEIGHT } from '../constants';
import { state } from '../state';
import { getIconPath } from '../utils/icon-manager';
import {
loadWindowBounds,
saveWindowBounds,
validateBounds,
scheduleSaveWindowBounds,
} from './window-bounds';
const logger = createLogger('MainWindow');
// Development environment
const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
/**
* Create the main window
*/
export function createWindow(): void {
const isDev = !app.isPackaged;
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_COLLAPSED, // Small minimum - horizontal scrolling handles overflow
minHeight: MIN_HEIGHT,
webPreferences: {
// __dirname is apps/ui/dist-electron (Vite bundles all into single file)
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
// titleBarStyle is macOS-only; use hiddenInset for native look on macOS
...(process.platform === 'darwin' && { titleBarStyle: 'hiddenInset' as const }),
backgroundColor: '#0a0a0a',
};
if (iconPath) {
windowOptions.icon = iconPath;
}
state.mainWindow = new BrowserWindow(windowOptions);
// Restore maximized state if previously maximized
if (validBounds?.isMaximized) {
state.mainWindow.maximize();
}
// Load Vite dev server in development or static server in production
if (VITE_DEV_SERVER_URL) {
state.mainWindow.loadURL(VITE_DEV_SERVER_URL);
} else if (isDev) {
// Fallback for dev without Vite server URL
state.mainWindow.loadURL(`http://localhost:${state.staticPort}`);
} else {
state.mainWindow.loadURL(`http://localhost:${state.staticPort}`);
}
if (isDev && process.env.OPEN_DEVTOOLS === 'true') {
state.mainWindow.webContents.openDevTools();
}
// Save window bounds on close, resize, and move
state.mainWindow.on('close', () => {
// Save immediately before closing (not debounced)
if (state.mainWindow && !state.mainWindow.isDestroyed()) {
const isMaximized = state.mainWindow.isMaximized();
const bounds = isMaximized
? state.mainWindow.getNormalBounds()
: state.mainWindow.getBounds();
saveWindowBounds({
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
isMaximized,
});
}
});
state.mainWindow.on('closed', () => {
state.mainWindow = null;
});
state.mainWindow.on('resized', () => {
scheduleSaveWindowBounds();
});
state.mainWindow.on('moved', () => {
scheduleSaveWindowBounds();
});
state.mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
logger.info('Main window created');
}

View File

@@ -0,0 +1,130 @@
/**
* Window bounds management
*
* Functions for loading, saving, and validating window bounds.
* Uses centralized electronUserData methods for path validation.
*/
import { screen } from 'electron';
import {
electronUserDataExists,
electronUserDataReadFileSync,
electronUserDataWriteFileSync,
} from '@automaker/platform';
import { createLogger } from '@automaker/utils/logger';
import {
WindowBounds,
WINDOW_BOUNDS_FILENAME,
MIN_WIDTH_COLLAPSED,
MIN_HEIGHT,
} from '../constants';
import { state } from '../state';
const logger = createLogger('WindowBounds');
/**
* Load saved window bounds from disk
* Uses centralized electronUserData methods for path validation.
*/
export function loadWindowBounds(): WindowBounds | null {
try {
if (electronUserDataExists(WINDOW_BOUNDS_FILENAME)) {
const data = electronUserDataReadFileSync(WINDOW_BOUNDS_FILENAME);
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) {
logger.warn('Failed to load window bounds:', (error as Error).message);
}
return null;
}
/**
* Save window bounds to disk
* Uses centralized electronUserData methods for path validation.
*/
export function saveWindowBounds(bounds: WindowBounds): void {
try {
electronUserDataWriteFileSync(WINDOW_BOUNDS_FILENAME, JSON.stringify(bounds, null, 2));
logger.info('Window bounds saved');
} catch (error) {
logger.warn('Failed to save window bounds:', (error as Error).message);
}
}
/**
* Schedule a debounced save of window bounds (500ms delay)
*/
export function scheduleSaveWindowBounds(): void {
if (!state.mainWindow || state.mainWindow.isDestroyed()) return;
if (state.saveWindowBoundsTimeout) {
clearTimeout(state.saveWindowBoundsTimeout);
}
state.saveWindowBoundsTimeout = setTimeout(() => {
if (!state.mainWindow || state.mainWindow.isDestroyed()) return;
const isMaximized = state.mainWindow.isMaximized();
// Use getNormalBounds() for maximized windows to save pre-maximized size
const bounds = isMaximized ? state.mainWindow.getNormalBounds() : state.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
*/
export 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_COLLAPSED),
height: Math.max(bounds.height, MIN_HEIGHT),
};
}

View File

@@ -1,45 +1,42 @@
/**
* Electron main process (TypeScript)
* Electron main process entry point
*
* This version spawns the backend server and uses HTTP API for most operations.
* Only native features (dialogs, shell) use IPC.
* Handles app lifecycle, initialization, and coordination of modular components.
*
* Architecture:
* - electron/constants.ts - Window sizing, port defaults, filenames
* - electron/state.ts - Shared state container
* - electron/utils/ - Port and icon utilities
* - electron/security/ - API key management
* - electron/windows/ - Window bounds and main window creation
* - electron/server/ - Backend and static server management
* - electron/ipc/ - IPC handlers (dialog, shell, app, auth, window, server)
*
* SECURITY: All file system access uses centralized methods from @automaker/platform.
*/
import path from 'path';
import { spawn, execSync, ChildProcess } from 'child_process';
import crypto from 'crypto';
import http, { Server } from 'http';
import net from 'net';
import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron';
import { createLogger } from '@automaker/utils/logger';
import { app, BrowserWindow, dialog } from 'electron';
import {
findNodeExecutable,
buildEnhancedPath,
initAllowedPaths,
isPathAllowed,
getAllowedRootDirectory,
// Electron userData operations
setElectronUserDataPath,
electronUserDataReadFileSync,
electronUserDataWriteFileSync,
electronUserDataExists,
// Electron app bundle operations
setElectronAppPaths,
electronAppExists,
electronAppStat,
electronAppReadFile,
// System path operations
systemPathExists,
initAllowedPaths,
} from '@automaker/platform';
import { createLogger } from '@automaker/utils/logger';
import { DEFAULT_SERVER_PORT, DEFAULT_STATIC_PORT } from './electron/constants';
import { state } from './electron/state';
import { findAvailablePort } from './electron/utils/port-manager';
import { getIconPath } from './electron/utils/icon-manager';
import { ensureApiKey } from './electron/security/api-key-manager';
import { createWindow } from './electron/windows/main-window';
import { startStaticServer, stopStaticServer } from './electron/server/static-server';
import { startServer, waitForServer, stopServer } from './electron/server/backend-server';
import { registerAllHandlers } from './electron/ipc';
const logger = createLogger('Electron');
const serverLogger = createLogger('Server');
// 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) {
@@ -51,608 +48,18 @@ if (isDev) {
}
}
let mainWindow: BrowserWindow | null = null;
let serverProcess: ChildProcess | null = null;
let staticServer: Server | null = null;
// Default ports (can be overridden via env) - will be dynamically assigned if these are in use
// When launched via root init.mjs we pass:
// - PORT (backend)
// - TEST_PORT (vite dev server / static)
const DEFAULT_SERVER_PORT = parseInt(process.env.PORT || '3008', 10);
const DEFAULT_STATIC_PORT = parseInt(process.env.TEST_PORT || '3007', 10);
// Actual ports in use (set during startup)
let serverPort = DEFAULT_SERVER_PORT;
let staticPort = DEFAULT_STATIC_PORT;
/**
* Check if a port is available
*/
function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => {
resolve(false);
});
server.once('listening', () => {
server.close(() => {
resolve(true);
});
});
// Use Node's default binding semantics (matches most dev servers)
// This avoids false-positives when a port is taken on IPv6/dual-stack.
server.listen(port);
});
}
/**
* Find an available port starting from the preferred port
* Tries up to 100 ports in sequence
*/
async function findAvailablePort(preferredPort: number): Promise<number> {
for (let offset = 0; offset < 100; offset++) {
const port = preferredPort + offset;
if (await isPortAvailable(port)) {
return port;
}
}
throw new Error(`Could not find an available port starting from ${preferredPort}`);
}
// ============================================
// 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
// Minimum window dimensions - reduced to allow smaller windows since kanban now supports horizontal scrolling
const MIN_WIDTH_COLLAPSED = 600; // Reduced - horizontal scrolling handles overflow
const MIN_HEIGHT = 500; // Reduced to allow more flexibility
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<typeof setTimeout> | null = null;
// API key for CSRF protection
let apiKey: string | null = null;
// Track if we're using an external server (Docker API mode)
let isExternalServerMode = false;
/**
* Get the relative path to API key file within userData
*/
const API_KEY_FILENAME = '.api-key';
/**
* Ensure an API key exists - load from file or generate new one.
* This key is passed to the server for CSRF protection.
* Uses centralized electronUserData methods for path validation.
*/
function ensureApiKey(): string {
try {
if (electronUserDataExists(API_KEY_FILENAME)) {
const key = electronUserDataReadFileSync(API_KEY_FILENAME).trim();
if (key) {
apiKey = key;
logger.info('Loaded existing API key');
return apiKey;
}
}
} catch (error) {
logger.warn('Error reading API key:', error);
}
// Generate new key
apiKey = crypto.randomUUID();
try {
electronUserDataWriteFileSync(API_KEY_FILENAME, apiKey, { encoding: 'utf-8', mode: 0o600 });
logger.info('Generated new API key');
} catch (error) {
logger.error('Failed to save API key:', error);
}
return apiKey;
}
/**
* Get icon path - works in both dev and production, cross-platform
* Uses centralized electronApp methods for path validation.
*/
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);
try {
if (!electronAppExists(iconPath)) {
logger.warn('Icon not found at:', iconPath);
return null;
}
} catch (error) {
logger.warn('Icon check failed:', iconPath, error);
return null;
}
return iconPath;
}
/**
* Relative path to window bounds settings file within userData
*/
const WINDOW_BOUNDS_FILENAME = 'window-bounds.json';
/**
* Load saved window bounds from disk
* Uses centralized electronUserData methods for path validation.
*/
function loadWindowBounds(): WindowBounds | null {
try {
if (electronUserDataExists(WINDOW_BOUNDS_FILENAME)) {
const data = electronUserDataReadFileSync(WINDOW_BOUNDS_FILENAME);
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) {
logger.warn('Failed to load window bounds:', (error as Error).message);
}
return null;
}
/**
* Save window bounds to disk
* Uses centralized electronUserData methods for path validation.
*/
function saveWindowBounds(bounds: WindowBounds): void {
try {
electronUserDataWriteFileSync(WINDOW_BOUNDS_FILENAME, JSON.stringify(bounds, null, 2));
logger.info('Window bounds saved');
} catch (error) {
logger.warn('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_COLLAPSED),
height: Math.max(bounds.height, MIN_HEIGHT),
};
}
/**
* Start static file server for production builds
* Uses centralized electronApp methods for serving static files from app bundle.
*/
async function startStaticServer(): Promise<void> {
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';
try {
if (!electronAppExists(filePath) && !electronAppExists(possibleFile)) {
filePath = path.join(staticPath, 'index.html');
} else if (electronAppExists(possibleFile)) {
filePath = possibleFile;
}
} catch {
filePath = path.join(staticPath, 'index.html');
}
}
electronAppStat(filePath, (err, stats) => {
if (err || !stats?.isFile()) {
filePath = path.join(staticPath, 'index.html');
}
electronAppReadFile(filePath, (error, content) => {
if (error || !content) {
response.writeHead(500);
response.end('Server Error');
return;
}
const ext = path.extname(filePath);
const contentTypes: Record<string, string> = {
'.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(staticPort, () => {
logger.info('Static server running at http://localhost:' + staticPort);
resolve();
});
staticServer!.on('error', reject);
});
}
/**
* Start the backend server
* Uses centralized methods for path validation.
*/
async function startServer(): Promise<void> {
// Find Node.js executable (handles desktop launcher scenarios)
const nodeResult = findNodeExecutable({
skipSearch: isDev,
logger: (msg: string) => logger.info(msg),
});
const command = nodeResult.nodePath;
// Validate that the found Node executable actually exists
// systemPathExists is used because node-finder returns system paths
if (command !== 'node') {
let exists: boolean;
try {
exists = systemPathExists(command);
} catch (error) {
const originalError = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to verify Node.js executable at: ${command} (source: ${nodeResult.source}). Reason: ${originalError}`
);
}
if (!exists) {
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;
// Check for tsx in app bundle paths
try {
if (electronAppExists(path.join(serverNodeModules, 'dist/cli.mjs'))) {
tsxCliPath = path.join(serverNodeModules, 'dist/cli.mjs');
} else if (electronAppExists(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.");
}
}
} catch {
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];
try {
if (!electronAppExists(serverPath)) {
throw new Error(`Server not found at: ${serverPath}`);
}
} catch {
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');
// Server root directory - where .env file is located
// In dev: apps/server (not apps/server/src)
// In production: resources/server
const serverRoot = app.isPackaged
? path.join(process.resourcesPath, 'server')
: path.join(__dirname, '../../server');
// IMPORTANT: Use shared data directory (not Electron's user data directory)
// This ensures Electron and web mode share the same settings/projects
// In dev: project root/data (navigate from __dirname which is apps/server/dist or apps/ui/dist-electron)
// In production: same as Electron user data (for app isolation)
const dataDir = app.isPackaged
? app.getPath('userData')
: path.join(__dirname, '../../..', 'data');
logger.info(
`[DATA_DIR] app.isPackaged=${app.isPackaged}, __dirname=${__dirname}, dataDir=${dataDir}`
);
// Build enhanced PATH that includes Node.js directory (cross-platform)
const enhancedPath = buildEnhancedPath(command, process.env.PATH || '');
if (enhancedPath !== process.env.PATH) {
logger.info('Enhanced PATH with Node directory:', path.dirname(command));
}
const env = {
...process.env,
PATH: enhancedPath,
PORT: serverPort.toString(),
DATA_DIR: dataDir,
NODE_PATH: serverNodeModules,
// Pass API key to server for CSRF protection
AUTOMAKER_API_KEY: apiKey!,
// 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,
}),
};
logger.info('Server will use port', serverPort);
logger.info('[DATA_DIR_SPAWN] env.DATA_DIR=', env.DATA_DIR);
logger.info('Starting backend server...');
logger.info('Server path:', serverPath);
logger.info('Server root (cwd):', serverRoot);
logger.info('NODE_PATH:', serverNodeModules);
serverProcess = spawn(command, args, {
cwd: serverRoot,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
serverProcess.stdout?.on('data', (data) => {
serverLogger.info(data.toString().trim());
});
serverProcess.stderr?.on('data', (data) => {
serverLogger.error(data.toString().trim());
});
serverProcess.on('close', (code) => {
serverLogger.info('Process exited with code', code);
serverProcess = null;
});
serverProcess.on('error', (err) => {
serverLogger.error('Failed to start server process:', err);
serverProcess = null;
});
await waitForServer();
}
/**
* Wait for server to be available
*/
async function waitForServer(maxAttempts = 30): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
try {
await new Promise<void>((resolve, reject) => {
const req = http.get(`http://localhost:${serverPort}/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'));
});
});
logger.info('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_COLLAPSED, // Small minimum - horizontal scrolling handles overflow
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:${staticPort}`);
} else {
mainWindow.loadURL(`http://localhost:${staticPort}`);
}
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' };
});
}
// Register IPC handlers
registerAllHandlers();
// App lifecycle
app.whenReady().then(async () => {
app.whenReady().then(handleAppReady);
app.on('window-all-closed', handleWindowAllClosed);
app.on('before-quit', handleBeforeQuit);
/**
* Handle app.whenReady()
*/
async function handleAppReady(): Promise<void> {
// In production, use Automaker dir in appData for app isolation
// In development, use project root for shared data between Electron and web mode
let userDataPathToUse: string;
@@ -661,10 +68,12 @@ app.whenReady().then(async () => {
// Production: Ensure userData path is consistent so files land in Automaker dir
try {
const desiredUserDataPath = path.join(app.getPath('appData'), 'Automaker');
if (app.getPath('userData') !== desiredUserDataPath) {
app.setPath('userData', desiredUserDataPath);
logger.info('[PRODUCTION] userData path set to:', desiredUserDataPath);
}
userDataPathToUse = desiredUserDataPath;
} catch (error) {
logger.warn('[PRODUCTION] Failed to set userData path:', (error as Error).message);
@@ -676,6 +85,7 @@ app.whenReady().then(async () => {
// __dirname is apps/ui/dist-electron, so go up to get project root
const projectRoot = path.join(__dirname, '../../..');
userDataPathToUse = path.join(projectRoot, 'data');
try {
app.setPath('userData', userDataPathToUse);
logger.info('[DEVELOPMENT] userData path explicitly set to:', userDataPathToUse);
@@ -701,6 +111,7 @@ app.whenReady().then(async () => {
} else {
setElectronAppPaths(__dirname, process.resourcesPath);
}
logger.info('Initialized path security helpers');
// Initialize security settings for path validation
@@ -711,6 +122,7 @@ app.whenReady().then(async () => {
: path.join(process.cwd(), 'data');
process.env.DATA_DIR = mainProcessDataDir;
logger.info('[MAIN_PROCESS_DATA_DIR]', mainProcessDataDir);
// ALLOWED_ROOT_DIRECTORY should already be in process.env if set by user
// (it will be passed to server process, but we also need it in main process for dialog validation)
initAllowedPaths();
@@ -729,12 +141,12 @@ app.whenReady().then(async () => {
try {
// Check if we should skip the embedded server (for Docker API mode)
const skipEmbeddedServer = process.env.SKIP_EMBEDDED_SERVER === 'true';
isExternalServerMode = skipEmbeddedServer;
state.isExternalServerMode = skipEmbeddedServer;
if (skipEmbeddedServer) {
// Use the default server port (Docker container runs on 3008)
serverPort = DEFAULT_SERVER_PORT;
logger.info('SKIP_EMBEDDED_SERVER=true, using external server at port', serverPort);
state.serverPort = DEFAULT_SERVER_PORT;
logger.info('SKIP_EMBEDDED_SERVER=true, using external server at port', state.serverPort);
// Wait for external server to be ready
logger.info('Waiting for external server...');
@@ -751,15 +163,25 @@ app.whenReady().then(async () => {
ensureApiKey();
// Find available ports (prevents conflicts with other apps using same ports)
serverPort = await findAvailablePort(DEFAULT_SERVER_PORT);
if (serverPort !== DEFAULT_SERVER_PORT) {
logger.info('Default server port', DEFAULT_SERVER_PORT, 'in use, using port', serverPort);
state.serverPort = await findAvailablePort(DEFAULT_SERVER_PORT);
if (state.serverPort !== DEFAULT_SERVER_PORT) {
logger.info(
'Default server port',
DEFAULT_SERVER_PORT,
'in use, using port',
state.serverPort
);
}
}
staticPort = await findAvailablePort(DEFAULT_STATIC_PORT);
if (staticPort !== DEFAULT_STATIC_PORT) {
logger.info('Default static port', DEFAULT_STATIC_PORT, 'in use, using port', staticPort);
state.staticPort = await findAvailablePort(DEFAULT_STATIC_PORT);
if (state.staticPort !== DEFAULT_STATIC_PORT) {
logger.info(
'Default static port',
DEFAULT_STATIC_PORT,
'in use, using port',
state.staticPort
);
}
// Start static file server in production
@@ -776,8 +198,10 @@ app.whenReady().then(async () => {
createWindow();
} catch (error) {
logger.error('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${
@@ -794,207 +218,25 @@ app.whenReady().then(async () => {
createWindow();
}
});
});
}
app.on('window-all-closed', () => {
/**
* Handle window-all-closed event
*/
function handleWindowAllClosed(): void {
// On macOS, keep the app and servers running when all windows are closed
// (standard macOS behavior). On other platforms, stop servers and quit.
if (process.platform !== 'darwin') {
if (serverProcess && serverProcess.pid) {
logger.info('All windows closed, stopping server...');
if (process.platform === 'win32') {
try {
execSync(`taskkill /f /t /pid ${serverProcess.pid}`, { stdio: 'ignore' });
} catch (error) {
logger.error('Failed to kill server process:', (error as Error).message);
}
} else {
serverProcess.kill('SIGTERM');
}
serverProcess = null;
}
if (staticServer) {
logger.info('Stopping static server...');
staticServer.close();
staticServer = null;
}
stopServer();
stopStaticServer();
app.quit();
}
});
}
app.on('before-quit', () => {
if (serverProcess && serverProcess.pid) {
logger.info('Stopping server...');
if (process.platform === 'win32') {
try {
// Windows: use taskkill with /t to kill entire process tree
// This prevents orphaned node processes when closing the app
// Using execSync to ensure process is killed before app exits
execSync(`taskkill /f /t /pid ${serverProcess.pid}`, { stdio: 'ignore' });
} catch (error) {
logger.error('Failed to kill server process:', (error as Error).message);
}
} else {
serverProcess.kill('SIGTERM');
}
serverProcess = null;
}
if (staticServer) {
logger.info('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'],
});
// Validate selected path against ALLOWED_ROOT_DIRECTORY if configured
if (!result.canceled && result.filePaths.length > 0) {
const selectedPath = result.filePaths[0];
if (!isPathAllowed(selectedPath)) {
const allowedRoot = getAllowedRootDirectory();
const errorMessage = allowedRoot
? `The selected directory is not allowed. Please select a directory within: ${allowedRoot}`
: 'The selected directory is not allowed.';
await dialog.showErrorBox('Directory Not Allowed', errorMessage);
return { canceled: true, filePaths: [] };
}
}
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<typeof app.getPath>[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:${serverPort}`;
});
// Get API key for authentication
// Returns null in external server mode to trigger session-based auth
ipcMain.handle('auth:getApiKey', () => {
if (isExternalServerMode) {
return null;
}
return apiKey;
});
// Check if running in external server mode (Docker API)
// Used by renderer to determine auth flow
ipcMain.handle('auth:isExternalServerMode', () => {
return isExternalServerMode;
});
// Window management - update minimum width based on sidebar state
// Now uses a fixed small minimum since horizontal scrolling handles overflow
ipcMain.handle('window:updateMinWidth', (_, _sidebarExpanded: boolean) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
// Always use the smaller minimum width - horizontal scrolling handles any overflow
mainWindow.setMinimumSize(MIN_WIDTH_COLLAPSED, MIN_HEIGHT);
});
// Quit the application (used when user denies sandbox risk confirmation)
ipcMain.handle('app:quit', () => {
logger.info('Quitting application via IPC request');
app.quit();
});
/**
* Handle before-quit event
*/
function handleBeforeQuit(): void {
stopServer();
stopStaticServer();
}

View File

@@ -7,6 +7,7 @@
import { contextBridge, ipcRenderer, OpenDialogOptions, SaveDialogOptions } from 'electron';
import { createLogger } from '@automaker/utils/logger';
import { IPC_CHANNELS } from './electron/ipc/channels';
const logger = createLogger('Preload');
@@ -17,48 +18,49 @@ contextBridge.exposeInMainWorld('electronAPI', {
isElectron: true,
// Connection check
ping: (): Promise<string> => ipcRenderer.invoke('ping'),
ping: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.PING),
// Get server URL for HTTP client
getServerUrl: (): Promise<string> => ipcRenderer.invoke('server:getUrl'),
getServerUrl: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.SERVER.GET_URL),
// Get API key for authentication
getApiKey: (): Promise<string | null> => ipcRenderer.invoke('auth:getApiKey'),
getApiKey: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.AUTH.GET_API_KEY),
// Check if running in external server mode (Docker API)
isExternalServerMode: (): Promise<boolean> => ipcRenderer.invoke('auth:isExternalServerMode'),
isExternalServerMode: (): Promise<boolean> =>
ipcRenderer.invoke(IPC_CHANNELS.AUTH.IS_EXTERNAL_SERVER_MODE),
// Native dialogs - better UX than prompt()
openDirectory: (): Promise<Electron.OpenDialogReturnValue> =>
ipcRenderer.invoke('dialog:openDirectory'),
ipcRenderer.invoke(IPC_CHANNELS.DIALOG.OPEN_DIRECTORY),
openFile: (options?: OpenDialogOptions): Promise<Electron.OpenDialogReturnValue> =>
ipcRenderer.invoke('dialog:openFile', options),
ipcRenderer.invoke(IPC_CHANNELS.DIALOG.OPEN_FILE, options),
saveFile: (options?: SaveDialogOptions): Promise<Electron.SaveDialogReturnValue> =>
ipcRenderer.invoke('dialog:saveFile', options),
ipcRenderer.invoke(IPC_CHANNELS.DIALOG.SAVE_FILE, options),
// Shell operations
openExternalLink: (url: string): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('shell:openExternal', url),
ipcRenderer.invoke(IPC_CHANNELS.SHELL.OPEN_EXTERNAL, url),
openPath: (filePath: string): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('shell:openPath', filePath),
ipcRenderer.invoke(IPC_CHANNELS.SHELL.OPEN_PATH, filePath),
openInEditor: (
filePath: string,
line?: number,
column?: number
): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('shell:openInEditor', filePath, line, column),
ipcRenderer.invoke(IPC_CHANNELS.SHELL.OPEN_IN_EDITOR, filePath, line, column),
// App info
getPath: (name: string): Promise<string> => ipcRenderer.invoke('app:getPath', name),
getVersion: (): Promise<string> => ipcRenderer.invoke('app:getVersion'),
isPackaged: (): Promise<boolean> => ipcRenderer.invoke('app:isPackaged'),
getPath: (name: string): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.APP.GET_PATH, name),
getVersion: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.APP.GET_VERSION),
isPackaged: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.APP.IS_PACKAGED),
// Window management
updateMinWidth: (sidebarExpanded: boolean): Promise<void> =>
ipcRenderer.invoke('window:updateMinWidth', sidebarExpanded),
ipcRenderer.invoke(IPC_CHANNELS.WINDOW.UPDATE_MIN_WIDTH, sidebarExpanded),
// App control
quit: (): Promise<void> => ipcRenderer.invoke('app:quit'),
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.APP.QUIT),
});
logger.info('Electron API exposed (TypeScript)');