mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
- Modified the application to keep the app and servers running when all windows are closed on macOS, aligning with standard macOS behavior. - On other platforms, ensured that the server processes are stopped and the app quits when all windows are closed, preventing potential port conflicts.
930 lines
28 KiB
TypeScript
930 lines
28 KiB
TypeScript
/**
|
||
* Electron main process (TypeScript)
|
||
*
|
||
* This version spawns the backend server and uses HTTP API for most operations.
|
||
* Only native features (dialogs, shell) use IPC.
|
||
*
|
||
* 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 {
|
||
findNodeExecutable,
|
||
buildEnhancedPath,
|
||
initAllowedPaths,
|
||
isPathAllowed,
|
||
getAllowedRootDirectory,
|
||
// Electron userData operations
|
||
setElectronUserDataPath,
|
||
electronUserDataReadFileSync,
|
||
electronUserDataWriteFileSync,
|
||
electronUserDataExists,
|
||
// Electron app bundle operations
|
||
setElectronAppPaths,
|
||
electronAppExists,
|
||
electronAppReadFileSync,
|
||
electronAppStatSync,
|
||
electronAppStat,
|
||
electronAppReadFile,
|
||
// System path operations
|
||
systemPathExists,
|
||
} 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;
|
||
|
||
// 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 SIDEBAR_EXPANDED = 288;
|
||
const SIDEBAR_COLLAPSED = 64;
|
||
|
||
const MIN_WIDTH_EXPANDED = 800; // Reduced - horizontal scrolling handles overflow
|
||
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;
|
||
|
||
/**
|
||
* 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;
|
||
console.log('[Electron] Loaded existing API key');
|
||
return apiKey;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn('[Electron] Error reading API key:', error);
|
||
}
|
||
|
||
// Generate new key
|
||
apiKey = crypto.randomUUID();
|
||
try {
|
||
electronUserDataWriteFileSync(API_KEY_FILENAME, apiKey, { encoding: 'utf-8', mode: 0o600 });
|
||
console.log('[Electron] Generated new API key');
|
||
} catch (error) {
|
||
console.error('[Electron] 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)) {
|
||
console.warn(`[Electron] Icon not found at: ${iconPath}`);
|
||
return null;
|
||
}
|
||
} catch (error) {
|
||
console.warn(`[Electron] 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) {
|
||
console.warn('[Electron] 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));
|
||
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_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, () => {
|
||
console.log(`[Electron] 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) => console.log(`[Electron] ${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');
|
||
|
||
// 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: serverPort.toString(),
|
||
DATA_DIR: app.getPath('userData'),
|
||
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,
|
||
}),
|
||
};
|
||
|
||
console.log(`[Electron] Server will use port ${serverPort}`);
|
||
|
||
console.log('[Electron] Starting backend server...');
|
||
console.log('[Electron] Server path:', serverPath);
|
||
console.log('[Electron] Server root (cwd):', serverRoot);
|
||
console.log('[Electron] NODE_PATH:', serverNodeModules);
|
||
|
||
serverProcess = spawn(command, args, {
|
||
cwd: serverRoot,
|
||
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<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'));
|
||
});
|
||
});
|
||
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_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' };
|
||
});
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
// Initialize centralized path helpers for Electron
|
||
// This must be done before any file operations
|
||
setElectronUserDataPath(app.getPath('userData'));
|
||
|
||
// In development mode, allow access to the entire project root (for source files, node_modules, etc.)
|
||
// In production, only allow access to the built app directory and resources
|
||
if (isDev) {
|
||
// __dirname is apps/ui/dist-electron, so go up 3 levels to get project root
|
||
const projectRoot = path.join(__dirname, '../../..');
|
||
setElectronAppPaths([__dirname, projectRoot]);
|
||
} else {
|
||
setElectronAppPaths(__dirname, process.resourcesPath);
|
||
}
|
||
console.log('[Electron] Initialized path security helpers');
|
||
|
||
// Initialize security settings for path validation
|
||
// Set DATA_DIR before initializing so it's available for security checks
|
||
process.env.DATA_DIR = app.getPath('userData');
|
||
// 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();
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Generate or load API key for CSRF protection (before starting server)
|
||
ensureApiKey();
|
||
|
||
try {
|
||
// Find available ports (prevents conflicts with other apps using same ports)
|
||
serverPort = await findAvailablePort(DEFAULT_SERVER_PORT);
|
||
if (serverPort !== DEFAULT_SERVER_PORT) {
|
||
console.log(
|
||
`[Electron] Default server port ${DEFAULT_SERVER_PORT} in use, using port ${serverPort}`
|
||
);
|
||
}
|
||
|
||
staticPort = await findAvailablePort(DEFAULT_STATIC_PORT);
|
||
if (staticPort !== DEFAULT_STATIC_PORT) {
|
||
console.log(
|
||
`[Electron] Default static port ${DEFAULT_STATIC_PORT} in use, using port ${staticPort}`
|
||
);
|
||
}
|
||
|
||
// 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', () => {
|
||
// 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) {
|
||
console.log('[Electron] All windows closed, stopping server...');
|
||
if (process.platform === 'win32') {
|
||
try {
|
||
execSync(`taskkill /f /t /pid ${serverProcess.pid}`, { stdio: 'ignore' });
|
||
} catch (error) {
|
||
console.error('[Electron] Failed to kill server process:', (error as Error).message);
|
||
}
|
||
} else {
|
||
serverProcess.kill('SIGTERM');
|
||
}
|
||
serverProcess = null;
|
||
}
|
||
|
||
if (staticServer) {
|
||
console.log('[Electron] Stopping static server...');
|
||
staticServer.close();
|
||
staticServer = null;
|
||
}
|
||
|
||
app.quit();
|
||
}
|
||
});
|
||
|
||
app.on('before-quit', () => {
|
||
if (serverProcess && serverProcess.pid) {
|
||
console.log('[Electron] 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) {
|
||
console.error('[Electron] Failed to kill server process:', (error as Error).message);
|
||
}
|
||
} else {
|
||
serverProcess.kill('SIGTERM');
|
||
}
|
||
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'],
|
||
});
|
||
|
||
// 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
|
||
ipcMain.handle('auth:getApiKey', () => {
|
||
return apiKey;
|
||
});
|
||
|
||
// 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', () => {
|
||
console.log('[Electron] Quitting application via IPC request');
|
||
app.quit();
|
||
});
|