mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
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:
47
apps/ui/src/electron/constants.ts
Normal file
47
apps/ui/src/electron/constants.ts
Normal 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;
|
||||
}
|
||||
32
apps/ui/src/electron/index.ts
Normal file
32
apps/ui/src/electron/index.ts
Normal 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';
|
||||
37
apps/ui/src/electron/ipc/app-handlers.ts
Normal file
37
apps/ui/src/electron/ipc/app-handlers.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
34
apps/ui/src/electron/ipc/auth-handlers.ts
Normal file
34
apps/ui/src/electron/ipc/auth-handlers.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
36
apps/ui/src/electron/ipc/channels.ts
Normal file
36
apps/ui/src/electron/ipc/channels.ts
Normal 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;
|
||||
72
apps/ui/src/electron/ipc/dialog-handlers.ts
Normal file
72
apps/ui/src/electron/ipc/dialog-handlers.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
26
apps/ui/src/electron/ipc/index.ts
Normal file
26
apps/ui/src/electron/ipc/index.ts
Normal 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();
|
||||
}
|
||||
24
apps/ui/src/electron/ipc/server-handlers.ts
Normal file
24
apps/ui/src/electron/ipc/server-handlers.ts
Normal 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';
|
||||
});
|
||||
}
|
||||
61
apps/ui/src/electron/ipc/shell-handlers.ts
Normal file
61
apps/ui/src/electron/ipc/shell-handlers.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
24
apps/ui/src/electron/ipc/window-handlers.ts
Normal file
24
apps/ui/src/electron/ipc/window-handlers.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
58
apps/ui/src/electron/security/api-key-manager.ts
Normal file
58
apps/ui/src/electron/security/api-key-manager.ts
Normal 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;
|
||||
}
|
||||
230
apps/ui/src/electron/server/backend-server.ts
Normal file
230
apps/ui/src/electron/server/backend-server.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
101
apps/ui/src/electron/server/static-server.ts
Normal file
101
apps/ui/src/electron/server/static-server.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
33
apps/ui/src/electron/state.ts
Normal file
33
apps/ui/src/electron/state.ts
Normal 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,
|
||||
};
|
||||
46
apps/ui/src/electron/utils/icon-manager.ts
Normal file
46
apps/ui/src/electron/utils/icon-manager.ts
Normal 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;
|
||||
}
|
||||
42
apps/ui/src/electron/utils/port-manager.ts
Normal file
42
apps/ui/src/electron/utils/port-manager.ts
Normal 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}`);
|
||||
}
|
||||
116
apps/ui/src/electron/windows/main-window.ts
Normal file
116
apps/ui/src/electron/windows/main-window.ts
Normal 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');
|
||||
}
|
||||
130
apps/ui/src/electron/windows/window-bounds.ts
Normal file
130
apps/ui/src/electron/windows/window-bounds.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)');
|
||||
|
||||
Reference in New Issue
Block a user