Skip to content

Commit f3ce5ce

Browse files
authored
Merge pull request #704 from AutoMaker-Org/refactor/electron-main-process
refactor: Modularize Electron main process into single-responsibility components
2 parents 2f883ba + 99de781 commit f3ce5ce

20 files changed

Lines changed: 1238 additions & 845 deletions

apps/ui/src/electron/constants.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Electron main process constants
3+
*
4+
* Centralized configuration for window sizing, ports, and file names.
5+
*/
6+
7+
// ============================================
8+
// Window sizing constants for kanban layout
9+
// ============================================
10+
// Calculation: 4 columns × 280px + 3 gaps × 20px + 40px padding = 1220px board content
11+
// With sidebar expanded (288px): 1220 + 288 = 1508px
12+
// Minimum window dimensions - reduced to allow smaller windows since kanban now supports horizontal scrolling
13+
export const MIN_WIDTH_COLLAPSED = 600; // Reduced - horizontal scrolling handles overflow
14+
export const MIN_HEIGHT = 500; // Reduced to allow more flexibility
15+
export const DEFAULT_WIDTH = 1600;
16+
export const DEFAULT_HEIGHT = 950;
17+
18+
// ============================================
19+
// Port defaults
20+
// ============================================
21+
// Default ports (can be overridden via env) - will be dynamically assigned if these are in use
22+
// When launched via root init.mjs we pass:
23+
// - PORT (backend)
24+
// - TEST_PORT (vite dev server / static)
25+
// Guard against NaN from non-numeric environment variables
26+
const parsedServerPort = Number.parseInt(process.env.PORT ?? '', 10);
27+
const parsedStaticPort = Number.parseInt(process.env.TEST_PORT ?? '', 10);
28+
export const DEFAULT_SERVER_PORT = Number.isFinite(parsedServerPort) ? parsedServerPort : 3008;
29+
export const DEFAULT_STATIC_PORT = Number.isFinite(parsedStaticPort) ? parsedStaticPort : 3007;
30+
31+
// ============================================
32+
// File names for userData storage
33+
// ============================================
34+
export const API_KEY_FILENAME = '.api-key';
35+
export const WINDOW_BOUNDS_FILENAME = 'window-bounds.json';
36+
37+
// ============================================
38+
// Window bounds interface
39+
// ============================================
40+
// Matches @automaker/types WindowBounds
41+
export interface WindowBounds {
42+
x: number;
43+
y: number;
44+
width: number;
45+
height: number;
46+
isMaximized: boolean;
47+
}

apps/ui/src/electron/index.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Electron main process modules
3+
*
4+
* Re-exports for convenient importing.
5+
*/
6+
7+
// Constants and types
8+
export * from './constants';
9+
export { state } from './state';
10+
11+
// Utilities
12+
export { isPortAvailable, findAvailablePort } from './utils/port-manager';
13+
export { getIconPath } from './utils/icon-manager';
14+
15+
// Security
16+
export { ensureApiKey, getApiKey } from './security/api-key-manager';
17+
18+
// Windows
19+
export {
20+
loadWindowBounds,
21+
saveWindowBounds,
22+
validateBounds,
23+
scheduleSaveWindowBounds,
24+
} from './windows/window-bounds';
25+
export { createWindow } from './windows/main-window';
26+
27+
// Server
28+
export { startStaticServer, stopStaticServer } from './server/static-server';
29+
export { startServer, waitForServer, stopServer } from './server/backend-server';
30+
31+
// IPC
32+
export { IPC_CHANNELS, registerAllHandlers } from './ipc';
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* App IPC handlers
3+
*
4+
* Handles app-related operations like getting paths, version info, and quitting.
5+
*/
6+
7+
import { ipcMain, app } from 'electron';
8+
import { createLogger } from '@automaker/utils/logger';
9+
import { IPC_CHANNELS } from './channels';
10+
11+
const logger = createLogger('AppHandlers');
12+
13+
/**
14+
* Register app IPC handlers
15+
*/
16+
export function registerAppHandlers(): void {
17+
// Get app path
18+
ipcMain.handle(IPC_CHANNELS.APP.GET_PATH, async (_, name: Parameters<typeof app.getPath>[0]) => {
19+
return app.getPath(name);
20+
});
21+
22+
// Get app version
23+
ipcMain.handle(IPC_CHANNELS.APP.GET_VERSION, async () => {
24+
return app.getVersion();
25+
});
26+
27+
// Check if app is packaged
28+
ipcMain.handle(IPC_CHANNELS.APP.IS_PACKAGED, async () => {
29+
return app.isPackaged;
30+
});
31+
32+
// Quit the application
33+
ipcMain.handle(IPC_CHANNELS.APP.QUIT, () => {
34+
logger.info('Quitting application via IPC request');
35+
app.quit();
36+
});
37+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Auth IPC handlers
3+
*
4+
* Handles authentication-related operations.
5+
*/
6+
7+
import { ipcMain } from 'electron';
8+
import { IPC_CHANNELS } from './channels';
9+
import { state } from '../state';
10+
11+
/**
12+
* Register auth IPC handlers
13+
*/
14+
export function registerAuthHandlers(): void {
15+
// Get API key for authentication
16+
// Returns null in external server mode to trigger session-based auth
17+
// Only returns API key to the main window to prevent leaking to untrusted senders
18+
ipcMain.handle(IPC_CHANNELS.AUTH.GET_API_KEY, (event) => {
19+
// Validate sender is the main window
20+
if (event.sender !== state.mainWindow?.webContents) {
21+
return null;
22+
}
23+
if (state.isExternalServerMode) {
24+
return null;
25+
}
26+
return state.apiKey;
27+
});
28+
29+
// Check if running in external server mode (Docker API)
30+
// Used by renderer to determine auth flow
31+
ipcMain.handle(IPC_CHANNELS.AUTH.IS_EXTERNAL_SERVER_MODE, () => {
32+
return state.isExternalServerMode;
33+
});
34+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* IPC channel constants
3+
*
4+
* Single source of truth for all IPC channel names.
5+
* Used by both main process handlers and preload script.
6+
*/
7+
8+
export const IPC_CHANNELS = {
9+
DIALOG: {
10+
OPEN_DIRECTORY: 'dialog:openDirectory',
11+
OPEN_FILE: 'dialog:openFile',
12+
SAVE_FILE: 'dialog:saveFile',
13+
},
14+
SHELL: {
15+
OPEN_EXTERNAL: 'shell:openExternal',
16+
OPEN_PATH: 'shell:openPath',
17+
OPEN_IN_EDITOR: 'shell:openInEditor',
18+
},
19+
APP: {
20+
GET_PATH: 'app:getPath',
21+
GET_VERSION: 'app:getVersion',
22+
IS_PACKAGED: 'app:isPackaged',
23+
QUIT: 'app:quit',
24+
},
25+
AUTH: {
26+
GET_API_KEY: 'auth:getApiKey',
27+
IS_EXTERNAL_SERVER_MODE: 'auth:isExternalServerMode',
28+
},
29+
WINDOW: {
30+
UPDATE_MIN_WIDTH: 'window:updateMinWidth',
31+
},
32+
SERVER: {
33+
GET_URL: 'server:getUrl',
34+
},
35+
PING: 'ping',
36+
} as const;
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Dialog IPC handlers
3+
*
4+
* Handles native file dialog operations.
5+
*/
6+
7+
import { ipcMain, dialog } from 'electron';
8+
import { isPathAllowed, getAllowedRootDirectory } from '@automaker/platform';
9+
import { IPC_CHANNELS } from './channels';
10+
import { state } from '../state';
11+
12+
/**
13+
* Register dialog IPC handlers
14+
*/
15+
export function registerDialogHandlers(): void {
16+
// Open directory dialog
17+
ipcMain.handle(IPC_CHANNELS.DIALOG.OPEN_DIRECTORY, async () => {
18+
if (!state.mainWindow) {
19+
return { canceled: true, filePaths: [] };
20+
}
21+
const result = await dialog.showOpenDialog(state.mainWindow, {
22+
properties: ['openDirectory', 'createDirectory'],
23+
});
24+
25+
// Validate selected path against ALLOWED_ROOT_DIRECTORY if configured
26+
if (!result.canceled && result.filePaths.length > 0) {
27+
const selectedPath = result.filePaths[0];
28+
if (!isPathAllowed(selectedPath)) {
29+
const allowedRoot = getAllowedRootDirectory();
30+
const errorMessage = allowedRoot
31+
? `The selected directory is not allowed. Please select a directory within: ${allowedRoot}`
32+
: 'The selected directory is not allowed.';
33+
34+
dialog.showErrorBox('Directory Not Allowed', errorMessage);
35+
36+
return { canceled: true, filePaths: [] };
37+
}
38+
}
39+
40+
return result;
41+
});
42+
43+
// Open file dialog
44+
// Filter properties to maintain file-only intent and prevent renderer from requesting directories
45+
ipcMain.handle(
46+
IPC_CHANNELS.DIALOG.OPEN_FILE,
47+
async (_, options: Record<string, unknown> = {}) => {
48+
if (!state.mainWindow) {
49+
return { canceled: true, filePaths: [] };
50+
}
51+
// Ensure openFile is always present and filter out directory-related properties
52+
const inputProperties = (options.properties as string[]) ?? [];
53+
const properties = ['openFile', ...inputProperties].filter(
54+
(p) => p !== 'openDirectory' && p !== 'createDirectory'
55+
);
56+
const result = await dialog.showOpenDialog(state.mainWindow, {
57+
...options,
58+
properties: properties as Electron.OpenDialogOptions['properties'],
59+
});
60+
return result;
61+
}
62+
);
63+
64+
// Save file dialog
65+
ipcMain.handle(IPC_CHANNELS.DIALOG.SAVE_FILE, async (_, options = {}) => {
66+
if (!state.mainWindow) {
67+
return { canceled: true, filePath: undefined };
68+
}
69+
const result = await dialog.showSaveDialog(state.mainWindow, options);
70+
return result;
71+
});
72+
}

apps/ui/src/electron/ipc/index.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* IPC handlers aggregator
3+
*
4+
* Registers all IPC handlers in one place.
5+
*/
6+
7+
import { registerDialogHandlers } from './dialog-handlers';
8+
import { registerShellHandlers } from './shell-handlers';
9+
import { registerAppHandlers } from './app-handlers';
10+
import { registerAuthHandlers } from './auth-handlers';
11+
import { registerWindowHandlers } from './window-handlers';
12+
import { registerServerHandlers } from './server-handlers';
13+
14+
export { IPC_CHANNELS } from './channels';
15+
16+
/**
17+
* Register all IPC handlers
18+
*/
19+
export function registerAllHandlers(): void {
20+
registerDialogHandlers();
21+
registerShellHandlers();
22+
registerAppHandlers();
23+
registerAuthHandlers();
24+
registerWindowHandlers();
25+
registerServerHandlers();
26+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Server IPC handlers
3+
*
4+
* Handles server-related operations.
5+
*/
6+
7+
import { ipcMain } from 'electron';
8+
import { IPC_CHANNELS } from './channels';
9+
import { state } from '../state';
10+
11+
/**
12+
* Register server IPC handlers
13+
*/
14+
export function registerServerHandlers(): void {
15+
// Get server URL for HTTP client
16+
ipcMain.handle(IPC_CHANNELS.SERVER.GET_URL, async () => {
17+
return `http://localhost:${state.serverPort}`;
18+
});
19+
20+
// Ping - for connection check
21+
ipcMain.handle(IPC_CHANNELS.PING, async () => {
22+
return 'pong';
23+
});
24+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Shell IPC handlers
3+
*
4+
* Handles shell operations like opening external links and files.
5+
*/
6+
7+
import { ipcMain, shell } from 'electron';
8+
import { IPC_CHANNELS } from './channels';
9+
10+
/**
11+
* Register shell IPC handlers
12+
*/
13+
export function registerShellHandlers(): void {
14+
// Open external URL
15+
ipcMain.handle(IPC_CHANNELS.SHELL.OPEN_EXTERNAL, async (_, url: string) => {
16+
try {
17+
await shell.openExternal(url);
18+
return { success: true };
19+
} catch (error) {
20+
return { success: false, error: (error as Error).message };
21+
}
22+
});
23+
24+
// Open file path
25+
ipcMain.handle(IPC_CHANNELS.SHELL.OPEN_PATH, async (_, filePath: string) => {
26+
try {
27+
await shell.openPath(filePath);
28+
return { success: true };
29+
} catch (error) {
30+
return { success: false, error: (error as Error).message };
31+
}
32+
});
33+
34+
// Open file in editor (VS Code, etc.) with optional line/column
35+
ipcMain.handle(
36+
IPC_CHANNELS.SHELL.OPEN_IN_EDITOR,
37+
async (_, filePath: string, line?: number, column?: number) => {
38+
try {
39+
// Build VS Code URL scheme: vscode://file/path:line:column
40+
// This works on all platforms where VS Code is installed
41+
// URL encode the path to handle special characters (spaces, brackets, etc.)
42+
// Handle both Unix (/) and Windows (\) path separators
43+
const normalizedPath = filePath.replace(/\\/g, '/');
44+
const segments = normalizedPath.split('/').map(encodeURIComponent);
45+
const encodedPath = segments.join('/');
46+
// VS Code URL format requires a leading slash after 'file'
47+
let url = `vscode://file/${encodedPath}`;
48+
if (line !== undefined && line > 0) {
49+
url += `:${line}`;
50+
if (column !== undefined && column > 0) {
51+
url += `:${column}`;
52+
}
53+
}
54+
await shell.openExternal(url);
55+
return { success: true };
56+
} catch (error) {
57+
return { success: false, error: (error as Error).message };
58+
}
59+
}
60+
);
61+
}

0 commit comments

Comments
 (0)