mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +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.
|
* Handles app lifecycle, initialization, and coordination of modular components.
|
||||||
* Only native features (dialogs, shell) use IPC.
|
*
|
||||||
|
* 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.
|
* SECURITY: All file system access uses centralized methods from @automaker/platform.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { spawn, execSync, ChildProcess } from 'child_process';
|
import { app, BrowserWindow, dialog } from 'electron';
|
||||||
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 {
|
import {
|
||||||
findNodeExecutable,
|
|
||||||
buildEnhancedPath,
|
|
||||||
initAllowedPaths,
|
|
||||||
isPathAllowed,
|
|
||||||
getAllowedRootDirectory,
|
|
||||||
// Electron userData operations
|
|
||||||
setElectronUserDataPath,
|
setElectronUserDataPath,
|
||||||
electronUserDataReadFileSync,
|
|
||||||
electronUserDataWriteFileSync,
|
|
||||||
electronUserDataExists,
|
|
||||||
// Electron app bundle operations
|
|
||||||
setElectronAppPaths,
|
setElectronAppPaths,
|
||||||
electronAppExists,
|
initAllowedPaths,
|
||||||
electronAppStat,
|
|
||||||
electronAppReadFile,
|
|
||||||
// System path operations
|
|
||||||
systemPathExists,
|
|
||||||
} from '@automaker/platform';
|
} 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 logger = createLogger('Electron');
|
||||||
const serverLogger = createLogger('Server');
|
|
||||||
|
|
||||||
// Development environment
|
// Development environment
|
||||||
const isDev = !app.isPackaged;
|
const isDev = !app.isPackaged;
|
||||||
const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
|
|
||||||
|
|
||||||
// Load environment variables from .env file (development only)
|
// Load environment variables from .env file (development only)
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
@@ -51,608 +48,18 @@ if (isDev) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
// Register IPC handlers
|
||||||
let serverProcess: ChildProcess | null = null;
|
registerAllHandlers();
|
||||||
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' };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// App lifecycle
|
// 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 production, use Automaker dir in appData for app isolation
|
||||||
// In development, use project root for shared data between Electron and web mode
|
// In development, use project root for shared data between Electron and web mode
|
||||||
let userDataPathToUse: string;
|
let userDataPathToUse: string;
|
||||||
@@ -661,10 +68,12 @@ app.whenReady().then(async () => {
|
|||||||
// Production: Ensure userData path is consistent so files land in Automaker dir
|
// Production: Ensure userData path is consistent so files land in Automaker dir
|
||||||
try {
|
try {
|
||||||
const desiredUserDataPath = path.join(app.getPath('appData'), 'Automaker');
|
const desiredUserDataPath = path.join(app.getPath('appData'), 'Automaker');
|
||||||
|
|
||||||
if (app.getPath('userData') !== desiredUserDataPath) {
|
if (app.getPath('userData') !== desiredUserDataPath) {
|
||||||
app.setPath('userData', desiredUserDataPath);
|
app.setPath('userData', desiredUserDataPath);
|
||||||
logger.info('[PRODUCTION] userData path set to:', desiredUserDataPath);
|
logger.info('[PRODUCTION] userData path set to:', desiredUserDataPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
userDataPathToUse = desiredUserDataPath;
|
userDataPathToUse = desiredUserDataPath;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('[PRODUCTION] Failed to set userData path:', (error as Error).message);
|
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
|
// __dirname is apps/ui/dist-electron, so go up to get project root
|
||||||
const projectRoot = path.join(__dirname, '../../..');
|
const projectRoot = path.join(__dirname, '../../..');
|
||||||
userDataPathToUse = path.join(projectRoot, 'data');
|
userDataPathToUse = path.join(projectRoot, 'data');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
app.setPath('userData', userDataPathToUse);
|
app.setPath('userData', userDataPathToUse);
|
||||||
logger.info('[DEVELOPMENT] userData path explicitly set to:', userDataPathToUse);
|
logger.info('[DEVELOPMENT] userData path explicitly set to:', userDataPathToUse);
|
||||||
@@ -701,6 +111,7 @@ app.whenReady().then(async () => {
|
|||||||
} else {
|
} else {
|
||||||
setElectronAppPaths(__dirname, process.resourcesPath);
|
setElectronAppPaths(__dirname, process.resourcesPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Initialized path security helpers');
|
logger.info('Initialized path security helpers');
|
||||||
|
|
||||||
// Initialize security settings for path validation
|
// Initialize security settings for path validation
|
||||||
@@ -711,6 +122,7 @@ app.whenReady().then(async () => {
|
|||||||
: path.join(process.cwd(), 'data');
|
: path.join(process.cwd(), 'data');
|
||||||
process.env.DATA_DIR = mainProcessDataDir;
|
process.env.DATA_DIR = mainProcessDataDir;
|
||||||
logger.info('[MAIN_PROCESS_DATA_DIR]', mainProcessDataDir);
|
logger.info('[MAIN_PROCESS_DATA_DIR]', mainProcessDataDir);
|
||||||
|
|
||||||
// ALLOWED_ROOT_DIRECTORY should already be in process.env if set by user
|
// 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)
|
// (it will be passed to server process, but we also need it in main process for dialog validation)
|
||||||
initAllowedPaths();
|
initAllowedPaths();
|
||||||
@@ -729,12 +141,12 @@ app.whenReady().then(async () => {
|
|||||||
try {
|
try {
|
||||||
// Check if we should skip the embedded server (for Docker API mode)
|
// Check if we should skip the embedded server (for Docker API mode)
|
||||||
const skipEmbeddedServer = process.env.SKIP_EMBEDDED_SERVER === 'true';
|
const skipEmbeddedServer = process.env.SKIP_EMBEDDED_SERVER === 'true';
|
||||||
isExternalServerMode = skipEmbeddedServer;
|
state.isExternalServerMode = skipEmbeddedServer;
|
||||||
|
|
||||||
if (skipEmbeddedServer) {
|
if (skipEmbeddedServer) {
|
||||||
// Use the default server port (Docker container runs on 3008)
|
// Use the default server port (Docker container runs on 3008)
|
||||||
serverPort = DEFAULT_SERVER_PORT;
|
state.serverPort = DEFAULT_SERVER_PORT;
|
||||||
logger.info('SKIP_EMBEDDED_SERVER=true, using external server at port', serverPort);
|
logger.info('SKIP_EMBEDDED_SERVER=true, using external server at port', state.serverPort);
|
||||||
|
|
||||||
// Wait for external server to be ready
|
// Wait for external server to be ready
|
||||||
logger.info('Waiting for external server...');
|
logger.info('Waiting for external server...');
|
||||||
@@ -751,15 +163,25 @@ app.whenReady().then(async () => {
|
|||||||
ensureApiKey();
|
ensureApiKey();
|
||||||
|
|
||||||
// Find available ports (prevents conflicts with other apps using same ports)
|
// Find available ports (prevents conflicts with other apps using same ports)
|
||||||
serverPort = await findAvailablePort(DEFAULT_SERVER_PORT);
|
state.serverPort = await findAvailablePort(DEFAULT_SERVER_PORT);
|
||||||
if (serverPort !== DEFAULT_SERVER_PORT) {
|
if (state.serverPort !== DEFAULT_SERVER_PORT) {
|
||||||
logger.info('Default server port', DEFAULT_SERVER_PORT, 'in use, using port', serverPort);
|
logger.info(
|
||||||
|
'Default server port',
|
||||||
|
DEFAULT_SERVER_PORT,
|
||||||
|
'in use, using port',
|
||||||
|
state.serverPort
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
staticPort = await findAvailablePort(DEFAULT_STATIC_PORT);
|
state.staticPort = await findAvailablePort(DEFAULT_STATIC_PORT);
|
||||||
if (staticPort !== DEFAULT_STATIC_PORT) {
|
if (state.staticPort !== DEFAULT_STATIC_PORT) {
|
||||||
logger.info('Default static port', DEFAULT_STATIC_PORT, 'in use, using port', staticPort);
|
logger.info(
|
||||||
|
'Default static port',
|
||||||
|
DEFAULT_STATIC_PORT,
|
||||||
|
'in use, using port',
|
||||||
|
state.staticPort
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start static file server in production
|
// Start static file server in production
|
||||||
@@ -776,8 +198,10 @@ app.whenReady().then(async () => {
|
|||||||
createWindow();
|
createWindow();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to start:', error);
|
logger.error('Failed to start:', error);
|
||||||
|
|
||||||
const errorMessage = (error as Error).message;
|
const errorMessage = (error as Error).message;
|
||||||
const isNodeError = errorMessage.includes('Node.js');
|
const isNodeError = errorMessage.includes('Node.js');
|
||||||
|
|
||||||
dialog.showErrorBox(
|
dialog.showErrorBox(
|
||||||
'Automaker Failed to Start',
|
'Automaker Failed to Start',
|
||||||
`The application failed to start.\n\n${errorMessage}\n\n${
|
`The application failed to start.\n\n${errorMessage}\n\n${
|
||||||
@@ -794,207 +218,25 @@ app.whenReady().then(async () => {
|
|||||||
createWindow();
|
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
|
// On macOS, keep the app and servers running when all windows are closed
|
||||||
// (standard macOS behavior). On other platforms, stop servers and quit.
|
// (standard macOS behavior). On other platforms, stop servers and quit.
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
if (serverProcess && serverProcess.pid) {
|
stopServer();
|
||||||
logger.info('All windows closed, stopping server...');
|
stopStaticServer();
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
app.on('before-quit', () => {
|
/**
|
||||||
if (serverProcess && serverProcess.pid) {
|
* Handle before-quit event
|
||||||
logger.info('Stopping server...');
|
*/
|
||||||
if (process.platform === 'win32') {
|
function handleBeforeQuit(): void {
|
||||||
try {
|
stopServer();
|
||||||
// Windows: use taskkill with /t to kill entire process tree
|
stopStaticServer();
|
||||||
// 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();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { contextBridge, ipcRenderer, OpenDialogOptions, SaveDialogOptions } from 'electron';
|
import { contextBridge, ipcRenderer, OpenDialogOptions, SaveDialogOptions } from 'electron';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
import { IPC_CHANNELS } from './electron/ipc/channels';
|
||||||
|
|
||||||
const logger = createLogger('Preload');
|
const logger = createLogger('Preload');
|
||||||
|
|
||||||
@@ -17,48 +18,49 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
isElectron: true,
|
isElectron: true,
|
||||||
|
|
||||||
// Connection check
|
// Connection check
|
||||||
ping: (): Promise<string> => ipcRenderer.invoke('ping'),
|
ping: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.PING),
|
||||||
|
|
||||||
// Get server URL for HTTP client
|
// 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
|
// 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)
|
// 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()
|
// Native dialogs - better UX than prompt()
|
||||||
openDirectory: (): Promise<Electron.OpenDialogReturnValue> =>
|
openDirectory: (): Promise<Electron.OpenDialogReturnValue> =>
|
||||||
ipcRenderer.invoke('dialog:openDirectory'),
|
ipcRenderer.invoke(IPC_CHANNELS.DIALOG.OPEN_DIRECTORY),
|
||||||
openFile: (options?: OpenDialogOptions): Promise<Electron.OpenDialogReturnValue> =>
|
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> =>
|
saveFile: (options?: SaveDialogOptions): Promise<Electron.SaveDialogReturnValue> =>
|
||||||
ipcRenderer.invoke('dialog:saveFile', options),
|
ipcRenderer.invoke(IPC_CHANNELS.DIALOG.SAVE_FILE, options),
|
||||||
|
|
||||||
// Shell operations
|
// Shell operations
|
||||||
openExternalLink: (url: string): Promise<{ success: boolean; error?: string }> =>
|
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 }> =>
|
openPath: (filePath: string): Promise<{ success: boolean; error?: string }> =>
|
||||||
ipcRenderer.invoke('shell:openPath', filePath),
|
ipcRenderer.invoke(IPC_CHANNELS.SHELL.OPEN_PATH, filePath),
|
||||||
openInEditor: (
|
openInEditor: (
|
||||||
filePath: string,
|
filePath: string,
|
||||||
line?: number,
|
line?: number,
|
||||||
column?: number
|
column?: number
|
||||||
): Promise<{ success: boolean; error?: string }> =>
|
): 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
|
// App info
|
||||||
getPath: (name: string): Promise<string> => ipcRenderer.invoke('app:getPath', name),
|
getPath: (name: string): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.APP.GET_PATH, name),
|
||||||
getVersion: (): Promise<string> => ipcRenderer.invoke('app:getVersion'),
|
getVersion: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.APP.GET_VERSION),
|
||||||
isPackaged: (): Promise<boolean> => ipcRenderer.invoke('app:isPackaged'),
|
isPackaged: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.APP.IS_PACKAGED),
|
||||||
|
|
||||||
// Window management
|
// Window management
|
||||||
updateMinWidth: (sidebarExpanded: boolean): Promise<void> =>
|
updateMinWidth: (sidebarExpanded: boolean): Promise<void> =>
|
||||||
ipcRenderer.invoke('window:updateMinWidth', sidebarExpanded),
|
ipcRenderer.invoke(IPC_CHANNELS.WINDOW.UPDATE_MIN_WIDTH, sidebarExpanded),
|
||||||
|
|
||||||
// App control
|
// App control
|
||||||
quit: (): Promise<void> => ipcRenderer.invoke('app:quit'),
|
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.APP.QUIT),
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Electron API exposed (TypeScript)');
|
logger.info('Electron API exposed (TypeScript)');
|
||||||
|
|||||||
Reference in New Issue
Block a user