refactor: Modularize Electron main process into single-responsibility components

Extract the monolithic main.ts (~1000 lines) into focused modules:

- electron/constants.ts - Window sizing, port defaults, filenames
- electron/state.ts - Shared state container
- electron/utils/ - Port availability 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 with shared channel constants

Benefits:
- Improved testability with isolated modules
- Better discoverability and maintainability
- Single source of truth for IPC channels (used by both main and preload)
- Clear separation of concerns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Shirone
2026-01-25 20:43:08 +01:00
parent 2f883bad20
commit 615823652c
20 changed files with 1221 additions and 847 deletions

View File

@@ -0,0 +1,232 @@
/**
* 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;
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: 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;
}
}