From 615823652c2dba83ed8f374f16b172e4eb3657b4 Mon Sep 17 00:00:00 2001 From: Shirone Date: Sun, 25 Jan 2026 20:43:08 +0100 Subject: [PATCH] refactor: Modularize Electron main process into single-responsibility components Extract the monolithic main.ts (~1000 lines) into focused modules: - electron/constants.ts - Window sizing, port defaults, filenames - electron/state.ts - Shared state container - electron/utils/ - Port availability and icon utilities - electron/security/ - API key management - electron/windows/ - Window bounds and main window creation - electron/server/ - Backend and static server management - electron/ipc/ - IPC handlers with shared channel constants Benefits: - Improved testability with isolated modules - Better discoverability and maintainability - Single source of truth for IPC channels (used by both main and preload) - Clear separation of concerns Co-Authored-By: Claude Opus 4.5 --- apps/ui/src/electron/constants.ts | 44 + apps/ui/src/electron/index.ts | 32 + apps/ui/src/electron/ipc/app-handlers.ts | 37 + apps/ui/src/electron/ipc/auth-handlers.ts | 29 + apps/ui/src/electron/ipc/channels.ts | 36 + apps/ui/src/electron/ipc/dialog-handlers.ts | 63 ++ apps/ui/src/electron/ipc/index.ts | 26 + apps/ui/src/electron/ipc/server-handlers.ts | 24 + apps/ui/src/electron/ipc/shell-handlers.ts | 61 ++ apps/ui/src/electron/ipc/window-handlers.ts | 24 + .../src/electron/security/api-key-manager.ts | 58 ++ apps/ui/src/electron/server/backend-server.ts | 232 +++++ apps/ui/src/electron/server/static-server.ts | 100 ++ apps/ui/src/electron/state.ts | 33 + apps/ui/src/electron/utils/icon-manager.ts | 45 + apps/ui/src/electron/utils/port-manager.ts | 42 + apps/ui/src/electron/windows/main-window.ts | 114 +++ apps/ui/src/electron/windows/window-bounds.ts | 130 +++ apps/ui/src/main.ts | 906 ++---------------- apps/ui/src/preload.ts | 32 +- 20 files changed, 1221 insertions(+), 847 deletions(-) create mode 100644 apps/ui/src/electron/constants.ts create mode 100644 apps/ui/src/electron/index.ts create mode 100644 apps/ui/src/electron/ipc/app-handlers.ts create mode 100644 apps/ui/src/electron/ipc/auth-handlers.ts create mode 100644 apps/ui/src/electron/ipc/channels.ts create mode 100644 apps/ui/src/electron/ipc/dialog-handlers.ts create mode 100644 apps/ui/src/electron/ipc/index.ts create mode 100644 apps/ui/src/electron/ipc/server-handlers.ts create mode 100644 apps/ui/src/electron/ipc/shell-handlers.ts create mode 100644 apps/ui/src/electron/ipc/window-handlers.ts create mode 100644 apps/ui/src/electron/security/api-key-manager.ts create mode 100644 apps/ui/src/electron/server/backend-server.ts create mode 100644 apps/ui/src/electron/server/static-server.ts create mode 100644 apps/ui/src/electron/state.ts create mode 100644 apps/ui/src/electron/utils/icon-manager.ts create mode 100644 apps/ui/src/electron/utils/port-manager.ts create mode 100644 apps/ui/src/electron/windows/main-window.ts create mode 100644 apps/ui/src/electron/windows/window-bounds.ts diff --git a/apps/ui/src/electron/constants.ts b/apps/ui/src/electron/constants.ts new file mode 100644 index 00000000..fffca657 --- /dev/null +++ b/apps/ui/src/electron/constants.ts @@ -0,0 +1,44 @@ +/** + * 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) +export const DEFAULT_SERVER_PORT = parseInt(process.env.PORT || '3008', 10); +export const DEFAULT_STATIC_PORT = parseInt(process.env.TEST_PORT || '3007', 10); + +// ============================================ +// 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; +} diff --git a/apps/ui/src/electron/index.ts b/apps/ui/src/electron/index.ts new file mode 100644 index 00000000..579398f4 --- /dev/null +++ b/apps/ui/src/electron/index.ts @@ -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'; diff --git a/apps/ui/src/electron/ipc/app-handlers.ts b/apps/ui/src/electron/ipc/app-handlers.ts new file mode 100644 index 00000000..fd27d750 --- /dev/null +++ b/apps/ui/src/electron/ipc/app-handlers.ts @@ -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[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(); + }); +} diff --git a/apps/ui/src/electron/ipc/auth-handlers.ts b/apps/ui/src/electron/ipc/auth-handlers.ts new file mode 100644 index 00000000..ea05ac18 --- /dev/null +++ b/apps/ui/src/electron/ipc/auth-handlers.ts @@ -0,0 +1,29 @@ +/** + * 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 + ipcMain.handle(IPC_CHANNELS.AUTH.GET_API_KEY, () => { + 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; + }); +} diff --git a/apps/ui/src/electron/ipc/channels.ts b/apps/ui/src/electron/ipc/channels.ts new file mode 100644 index 00000000..a0b5dcff --- /dev/null +++ b/apps/ui/src/electron/ipc/channels.ts @@ -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; diff --git a/apps/ui/src/electron/ipc/dialog-handlers.ts b/apps/ui/src/electron/ipc/dialog-handlers.ts new file mode 100644 index 00000000..1b27ed0f --- /dev/null +++ b/apps/ui/src/electron/ipc/dialog-handlers.ts @@ -0,0 +1,63 @@ +/** + * 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.'; + + await dialog.showErrorBox('Directory Not Allowed', errorMessage); + + return { canceled: true, filePaths: [] }; + } + } + + return result; + }); + + // Open file dialog + ipcMain.handle(IPC_CHANNELS.DIALOG.OPEN_FILE, async (_, options = {}) => { + if (!state.mainWindow) { + return { canceled: true, filePaths: [] }; + } + const result = await dialog.showOpenDialog(state.mainWindow, { + properties: ['openFile'], + ...options, + }); + 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; + }); +} diff --git a/apps/ui/src/electron/ipc/index.ts b/apps/ui/src/electron/ipc/index.ts new file mode 100644 index 00000000..7ce0f725 --- /dev/null +++ b/apps/ui/src/electron/ipc/index.ts @@ -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(); +} diff --git a/apps/ui/src/electron/ipc/server-handlers.ts b/apps/ui/src/electron/ipc/server-handlers.ts new file mode 100644 index 00000000..119c2b06 --- /dev/null +++ b/apps/ui/src/electron/ipc/server-handlers.ts @@ -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'; + }); +} diff --git a/apps/ui/src/electron/ipc/shell-handlers.ts b/apps/ui/src/electron/ipc/shell-handlers.ts new file mode 100644 index 00000000..8e852f5b --- /dev/null +++ b/apps/ui/src/electron/ipc/shell-handlers.ts @@ -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 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 }; + } + } + ); +} diff --git a/apps/ui/src/electron/ipc/window-handlers.ts b/apps/ui/src/electron/ipc/window-handlers.ts new file mode 100644 index 00000000..729ad6b5 --- /dev/null +++ b/apps/ui/src/electron/ipc/window-handlers.ts @@ -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); + }); +} diff --git a/apps/ui/src/electron/security/api-key-manager.ts b/apps/ui/src/electron/security/api-key-manager.ts new file mode 100644 index 00000000..87ff1f41 --- /dev/null +++ b/apps/ui/src/electron/security/api-key-manager.ts @@ -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; +} diff --git a/apps/ui/src/electron/server/backend-server.ts b/apps/ui/src/electron/server/backend-server.ts new file mode 100644 index 00000000..a4b7b2d9 --- /dev/null +++ b/apps/ui/src/electron/server/backend-server.ts @@ -0,0 +1,232 @@ +/** + * Backend server management + * + * Handles starting, stopping, and monitoring the Express backend server. + * Uses centralized methods for path validation. + */ + +import path from 'path'; +import http from 'http'; +import { spawn, execSync } from 'child_process'; +import { app } from 'electron'; +import { + findNodeExecutable, + buildEnhancedPath, + electronAppExists, + systemPathExists, +} from '@automaker/platform'; +import { createLogger } from '@automaker/utils/logger'; +import { state } from '../state'; + +const logger = createLogger('BackendServer'); +const serverLogger = createLogger('Server'); + +/** + * Start the backend server + * Uses centralized methods for path validation. + */ +export async function startServer(): Promise { + const isDev = !app.isPackaged; + + // Find Node.js executable (handles desktop launcher scenarios) + const nodeResult = findNodeExecutable({ + skipSearch: isDev, + logger: (msg: string) => logger.info(msg), + }); + const command = nodeResult.nodePath; + + // Validate that the found Node executable actually exists + // systemPathExists is used because node-finder returns system paths + if (command !== 'node') { + let exists: boolean; + try { + exists = systemPathExists(command); + } catch (error) { + const originalError = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to verify Node.js executable at: ${command} (source: ${nodeResult.source}). Reason: ${originalError}` + ); + } + if (!exists) { + throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`); + } + } + + let args: string[]; + let serverPath: string; + + if (isDev) { + serverPath = path.join(__dirname, '../../../server/src/index.ts'); + + const serverNodeModules = path.join(__dirname, '../../../server/node_modules/tsx'); + const rootNodeModules = path.join(__dirname, '../../../../node_modules/tsx'); + + let tsxCliPath: string; + // Check for tsx in app bundle paths + try { + if (electronAppExists(path.join(serverNodeModules, 'dist/cli.mjs'))) { + tsxCliPath = path.join(serverNodeModules, 'dist/cli.mjs'); + } else if (electronAppExists(path.join(rootNodeModules, 'dist/cli.mjs'))) { + tsxCliPath = path.join(rootNodeModules, 'dist/cli.mjs'); + } else { + try { + tsxCliPath = require.resolve('tsx/cli.mjs', { + paths: [path.join(__dirname, '../../../server')], + }); + } catch { + throw new Error("Could not find tsx. Please run 'npm install' in the server directory."); + } + } + } catch { + try { + tsxCliPath = require.resolve('tsx/cli.mjs', { + paths: [path.join(__dirname, '../../../server')], + }); + } catch { + throw new Error("Could not find tsx. Please run 'npm install' in the server directory."); + } + } + + args = [tsxCliPath, 'watch', serverPath]; + } else { + serverPath = path.join(process.resourcesPath, 'server', 'index.js'); + args = [serverPath]; + + try { + if (!electronAppExists(serverPath)) { + throw new Error(`Server not found at: ${serverPath}`); + } + } catch { + throw new Error(`Server not found at: ${serverPath}`); + } + } + + const serverNodeModules = app.isPackaged + ? path.join(process.resourcesPath, 'server', 'node_modules') + : path.join(__dirname, '../../../server/node_modules'); + + // Server root directory - where .env file is located + // In dev: apps/server (not apps/server/src) + // In production: resources/server + const serverRoot = app.isPackaged + ? path.join(process.resourcesPath, 'server') + : path.join(__dirname, '../../../server'); + + // IMPORTANT: Use shared data directory (not Electron's user data directory) + // This ensures Electron and web mode share the same settings/projects + // In dev: project root/data (navigate from __dirname which is apps/server/dist or apps/ui/dist-electron) + // In production: same as Electron user data (for app isolation) + const dataDir = app.isPackaged + ? app.getPath('userData') + : path.join(__dirname, '../../../..', 'data'); + logger.info( + `[DATA_DIR] app.isPackaged=${app.isPackaged}, __dirname=${__dirname}, dataDir=${dataDir}` + ); + + // Build enhanced PATH that includes Node.js directory (cross-platform) + const enhancedPath = buildEnhancedPath(command, process.env.PATH || ''); + if (enhancedPath !== process.env.PATH) { + logger.info('Enhanced PATH with Node directory:', path.dirname(command)); + } + + const env = { + ...process.env, + PATH: enhancedPath, + PORT: state.serverPort.toString(), + DATA_DIR: dataDir, + NODE_PATH: serverNodeModules, + // Pass API key to server for CSRF protection + AUTOMAKER_API_KEY: state.apiKey!, + // Only set ALLOWED_ROOT_DIRECTORY if explicitly provided in environment + // If not set, server will allow access to all paths + ...(process.env.ALLOWED_ROOT_DIRECTORY && { + ALLOWED_ROOT_DIRECTORY: process.env.ALLOWED_ROOT_DIRECTORY, + }), + }; + + logger.info('Server will use port', state.serverPort); + logger.info('[DATA_DIR_SPAWN] env.DATA_DIR=', env.DATA_DIR); + + logger.info('Starting backend server...'); + logger.info('Server path:', serverPath); + logger.info('Server root (cwd):', serverRoot); + logger.info('NODE_PATH:', serverNodeModules); + + state.serverProcess = spawn(command, args, { + cwd: serverRoot, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + state.serverProcess.stdout?.on('data', (data) => { + serverLogger.info(data.toString().trim()); + }); + + state.serverProcess.stderr?.on('data', (data) => { + serverLogger.error(data.toString().trim()); + }); + + state.serverProcess.on('close', (code) => { + serverLogger.info('Process exited with code', code); + state.serverProcess = null; + }); + + state.serverProcess.on('error', (err) => { + serverLogger.error('Failed to start server process:', err); + state.serverProcess = null; + }); + + await waitForServer(); +} + +/** + * Wait for server to be available + */ +export async function waitForServer(maxAttempts = 30): Promise { + for (let i = 0; i < maxAttempts; i++) { + try { + await new Promise((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; + } +} diff --git a/apps/ui/src/electron/server/static-server.ts b/apps/ui/src/electron/server/static-server.ts new file mode 100644 index 00000000..9ac3f86c --- /dev/null +++ b/apps/ui/src/electron/server/static-server.ts @@ -0,0 +1,100 @@ +/** + * 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 = { + '.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 { + 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; + } +} diff --git a/apps/ui/src/electron/state.ts b/apps/ui/src/electron/state.ts new file mode 100644 index 00000000..fba85afb --- /dev/null +++ b/apps/ui/src/electron/state.ts @@ -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 | 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, +}; diff --git a/apps/ui/src/electron/utils/icon-manager.ts b/apps/ui/src/electron/utils/icon-manager.ts new file mode 100644 index 00000000..34cacc15 --- /dev/null +++ b/apps/ui/src/electron/utils/icon-manager.ts @@ -0,0 +1,45 @@ +/** + * 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'; + } + + 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; +} diff --git a/apps/ui/src/electron/utils/port-manager.ts b/apps/ui/src/electron/utils/port-manager.ts new file mode 100644 index 00000000..990f4f1a --- /dev/null +++ b/apps/ui/src/electron/utils/port-manager.ts @@ -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 { + 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 { + 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}`); +} diff --git a/apps/ui/src/electron/windows/main-window.ts b/apps/ui/src/electron/windows/main-window.ts new file mode 100644 index 00000000..9cb5209f --- /dev/null +++ b/apps/ui/src/electron/windows/main-window.ts @@ -0,0 +1,114 @@ +/** + * 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: { + preload: path.join(__dirname, '../preload.js'), + contextIsolation: true, + nodeIntegration: false, + }, + titleBarStyle: 'hiddenInset', + 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'); +} diff --git a/apps/ui/src/electron/windows/window-bounds.ts b/apps/ui/src/electron/windows/window-bounds.ts new file mode 100644 index 00000000..13ea9fe1 --- /dev/null +++ b/apps/ui/src/electron/windows/window-bounds.ts @@ -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), + }; +} diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts index 45744130..7e32f077 100644 --- a/apps/ui/src/main.ts +++ b/apps/ui/src/main.ts @@ -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 { - 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 { - 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 | 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 { - 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 = { - '.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 { - // 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 { - for (let i = 0; i < maxAttempts; i++) { - try { - await new Promise((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 { // 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[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(); +} diff --git a/apps/ui/src/preload.ts b/apps/ui/src/preload.ts index 3fa70c00..131c266f 100644 --- a/apps/ui/src/preload.ts +++ b/apps/ui/src/preload.ts @@ -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 => ipcRenderer.invoke('ping'), + ping: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.PING), // Get server URL for HTTP client - getServerUrl: (): Promise => ipcRenderer.invoke('server:getUrl'), + getServerUrl: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.SERVER.GET_URL), // Get API key for authentication - getApiKey: (): Promise => ipcRenderer.invoke('auth:getApiKey'), + getApiKey: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.AUTH.GET_API_KEY), // Check if running in external server mode (Docker API) - isExternalServerMode: (): Promise => ipcRenderer.invoke('auth:isExternalServerMode'), + isExternalServerMode: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.AUTH.IS_EXTERNAL_SERVER_MODE), // Native dialogs - better UX than prompt() openDirectory: (): Promise => - ipcRenderer.invoke('dialog:openDirectory'), + ipcRenderer.invoke(IPC_CHANNELS.DIALOG.OPEN_DIRECTORY), openFile: (options?: OpenDialogOptions): Promise => - ipcRenderer.invoke('dialog:openFile', options), + ipcRenderer.invoke(IPC_CHANNELS.DIALOG.OPEN_FILE, options), saveFile: (options?: SaveDialogOptions): Promise => - 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 => ipcRenderer.invoke('app:getPath', name), - getVersion: (): Promise => ipcRenderer.invoke('app:getVersion'), - isPackaged: (): Promise => ipcRenderer.invoke('app:isPackaged'), + getPath: (name: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.APP.GET_PATH, name), + getVersion: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.APP.GET_VERSION), + isPackaged: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.APP.IS_PACKAGED), // Window management updateMinWidth: (sidebarExpanded: boolean): Promise => - ipcRenderer.invoke('window:updateMinWidth', sidebarExpanded), + ipcRenderer.invoke(IPC_CHANNELS.WINDOW.UPDATE_MIN_WIDTH, sidebarExpanded), // App control - quit: (): Promise => ipcRenderer.invoke('app:quit'), + quit: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.APP.QUIT), }); logger.info('Electron API exposed (TypeScript)');