Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
44 changes: 44 additions & 0 deletions apps/ui/src/electron/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Electron main process constants
*
* Centralized configuration for window sizing, ports, and file names.
*/

// ============================================
// Window sizing constants for kanban layout
// ============================================
// Calculation: 4 columns × 280px + 3 gaps × 20px + 40px padding = 1220px board content
// With sidebar expanded (288px): 1220 + 288 = 1508px
// Minimum window dimensions - reduced to allow smaller windows since kanban now supports horizontal scrolling
export const MIN_WIDTH_COLLAPSED = 600; // Reduced - horizontal scrolling handles overflow
export const MIN_HEIGHT = 500; // Reduced to allow more flexibility
export const DEFAULT_WIDTH = 1600;
export const DEFAULT_HEIGHT = 950;

// ============================================
// Port defaults
// ============================================
// Default ports (can be overridden via env) - will be dynamically assigned if these are in use
// When launched via root init.mjs we pass:
// - PORT (backend)
// - TEST_PORT (vite dev server / static)
export const DEFAULT_SERVER_PORT = parseInt(process.env.PORT || '3008', 10);
export const DEFAULT_STATIC_PORT = parseInt(process.env.TEST_PORT || '3007', 10);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// ============================================
// 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();
});
}
29 changes: 29 additions & 0 deletions apps/ui/src/electron/ipc/auth-handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Auth IPC handlers
*
* Handles authentication-related operations.
*/

import { ipcMain } from 'electron';
import { IPC_CHANNELS } from './channels';
import { state } from '../state';

/**
* Register auth IPC handlers
*/
export function registerAuthHandlers(): void {
// Get API key for authentication
// Returns null in external server mode to trigger session-based auth
ipcMain.handle(IPC_CHANNELS.AUTH.GET_API_KEY, () => {
if (state.isExternalServerMode) {
return null;
}
return state.apiKey;
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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;
63 changes: 63 additions & 0 deletions apps/ui/src/electron/ipc/dialog-handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Dialog IPC handlers
*
* Handles native file dialog operations.
*/

import { ipcMain, dialog } from 'electron';
import { isPathAllowed, getAllowedRootDirectory } from '@automaker/platform';
import { IPC_CHANNELS } from './channels';
import { state } from '../state';

/**
* Register dialog IPC handlers
*/
export function registerDialogHandlers(): void {
// Open directory dialog
ipcMain.handle(IPC_CHANNELS.DIALOG.OPEN_DIRECTORY, async () => {
if (!state.mainWindow) {
return { canceled: true, filePaths: [] };
}
const result = await dialog.showOpenDialog(state.mainWindow, {
properties: ['openDirectory', 'createDirectory'],
});

// Validate selected path against ALLOWED_ROOT_DIRECTORY if configured
if (!result.canceled && result.filePaths.length > 0) {
const selectedPath = result.filePaths[0];
if (!isPathAllowed(selectedPath)) {
const allowedRoot = getAllowedRootDirectory();
const errorMessage = allowedRoot
? `The selected directory is not allowed. Please select a directory within: ${allowedRoot}`
: 'The selected directory is not allowed.';

await dialog.showErrorBox('Directory Not Allowed', errorMessage);

return { canceled: true, filePaths: [] };
}
}

return result;
});

// Open file dialog
ipcMain.handle(IPC_CHANNELS.DIALOG.OPEN_FILE, async (_, options = {}) => {
if (!state.mainWindow) {
return { canceled: true, filePaths: [] };
}
const result = await dialog.showOpenDialog(state.mainWindow, {
properties: ['openFile'],
...options,
});
return result;
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// 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 encodedPath = normalizedPath.startsWith('/')
? '/' + normalizedPath.slice(1).split('/').map(encodeURIComponent).join('/')
: normalizedPath.split('/').map(encodeURIComponent).join('/');
let url = `vscode://file${encodedPath}`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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 changes: 24 additions & 0 deletions apps/ui/src/electron/ipc/window-handlers.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
Loading