Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions apps/ui/src/electron/constants.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 32 additions & 0 deletions apps/ui/src/electron/index.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 37 additions & 0 deletions apps/ui/src/electron/ipc/app-handlers.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 34 additions & 0 deletions apps/ui/src/electron/ipc/auth-handlers.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 36 additions & 0 deletions apps/ui/src/electron/ipc/channels.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 72 additions & 0 deletions apps/ui/src/electron/ipc/dialog-handlers.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 26 additions & 0 deletions apps/ui/src/electron/ipc/index.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 24 additions & 0 deletions apps/ui/src/electron/ipc/server-handlers.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 61 additions & 0 deletions apps/ui/src/electron/ipc/shell-handlers.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
}
);
}
Loading