Compare commits

...

13 Commits

Author SHA1 Message Date
Shirone
c848306e4c Merge pull request #709 from AutoMaker-Org/refactor/store-defaults
refactor(store): Extract default values into store/defaults/
2026-01-25 22:39:59 +00:00
Shirone
f0042312d0 refactor(store): Extract default values into store/defaults/
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 23:32:29 +01:00
Shirone
e876d177b8 Merge pull request #708 from AutoMaker-Org/refactor/store-utils
refactor(store): Extract utility functions into store/utils/
2026-01-25 22:18:10 +00:00
Shirone
8caec15199 refactor(store): Extract utility functions into store/utils/
Move pure utility functions from app-store.ts and type files into
dedicated utils modules for better separation of concerns:

- theme-utils.ts: Theme and font storage utilities
- shortcut-utils.ts: Keyboard shortcut parsing/formatting
- usage-utils.ts: Usage limit checking

All utilities are re-exported from store/utils/index.ts and
app-store.ts maintains backward compatibility for existing imports.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 23:03:52 +01:00
Shirone
7fe9aacb09 Merge pull request #706 from AutoMaker-Org/refactor/store-types
refactor(store): Extract types from app-store.ts into modular type files
2026-01-25 21:36:40 +00:00
Shirone
f55c985634 refactor(types): Make FeatureImage extend ImageAttachment
Address Gemini review feedback - reduce code duplication by having
FeatureImage extend ImageAttachment instead of duplicating properties.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 22:32:46 +01:00
Shirone
38e8a4c4ea refactor(store): Extract types from app-store.ts into modular type files
- Create store/types/ directory with 8 modular type files:
  - usage-types.ts: ClaudeUsage, CodexUsage, isClaudeUsageAtLimit
  - ui-types.ts: ViewMode, ThemeMode, KeyboardShortcuts, etc.
  - settings-types.ts: ApiKeys
  - chat-types.ts: ChatMessage, ChatSession, FeatureImage
  - terminal-types.ts: TerminalState, TerminalTab, etc.
  - project-types.ts: Feature, FileTreeNode, ProjectAnalysis
  - state-types.ts: AppState, AppActions interfaces
  - index.ts: Re-exports all types

- Update electron.ts to import from store/types/usage-types
  (breaks circular dependency between electron.ts and app-store.ts)

- Update app-store.ts to import and re-export types for backward
  compatibility - existing imports from @/store/app-store continue
  to work

This is PR 1 of the app-store refactoring plan.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 22:20:27 +01:00
Shirone
f3ce5ce8ab Merge pull request #704 from AutoMaker-Org/refactor/electron-main-process
refactor: Modularize Electron main process into single-responsibility components
2026-01-25 20:10:39 +00:00
Shirone
99de7813c9 fix: Apply titleBarStyle only on macOS
titleBarStyle: 'hiddenInset' is a macOS-only option. Use conditional
spread to only apply it when process.platform === 'darwin', ensuring
Windows and Linux get consistent default styling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 21:05:07 +01:00
Shirone
2de3ae69d4 fix: Address CodeRabbit security and robustness review comments
- Guard against NaN ports from non-numeric env variables in constants.ts
- Validate IPC sender before returning API key to prevent leaking to
  untrusted senders (webviews, additional windows)
- Filter dialog properties to maintain file-only intent and prevent
  renderer from requesting directories via OPEN_FILE
- Fix Windows VS Code URL paths by ensuring leading slash after 'file'

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 21:02:53 +01:00
Shirone
0b4e9573ed refactor: Simplify tsx path lookup and remove redundant try-catch
Address review feedback:
- Simplify tsx CLI path lookup by extracting path variables and
  reducing nested try-catch blocks
- Remove redundant try-catch around electronAppExists check in
  production server path validation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 20:54:10 +01:00
Shirone
d7ad87bd1b fix: Correct __dirname paths for Vite bundled electron modules
Vite bundles all electron modules into a single main.js file,
so __dirname remains apps/ui/dist-electron regardless of source
file location. Updated path comments to clarify this behavior.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 20:49:53 +01:00
Shirone
615823652c refactor: Modularize Electron main process into single-responsibility components
Extract the monolithic main.ts (~1000 lines) into focused modules:

- electron/constants.ts - Window sizing, port defaults, filenames
- electron/state.ts - Shared state container
- electron/utils/ - Port availability and icon utilities
- electron/security/ - API key management
- electron/windows/ - Window bounds and main window creation
- electron/server/ - Backend and static server management
- electron/ipc/ - IPC handlers with shared channel constants

Benefits:
- Improved testability with isolated modules
- Better discoverability and maintainability
- Single source of truth for IPC channels (used by both main and preload)
- Clear separation of concerns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 20:43:08 +01:00
37 changed files with 4574 additions and 4456 deletions

View 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;
}

View 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';

View 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();
});
}

View 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;
});
}

View 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;

View 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;
});
}

View 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();
}

View 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';
});
}

View 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 };
}
}
);
}

View 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);
});
}

View 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;
}

View 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;
}
}

View 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;
}
}

View 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,
};

View 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;
}

View 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}`);
}

View 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');
}

View 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),
};
}

View File

@@ -1,45 +1,42 @@
/**
* Electron main process (TypeScript)
* Electron main process entry point
*
* This version spawns the backend server and uses HTTP API for most operations.
* Only native features (dialogs, shell) use IPC.
* Handles app lifecycle, initialization, and coordination of modular components.
*
* Architecture:
* - electron/constants.ts - Window sizing, port defaults, filenames
* - electron/state.ts - Shared state container
* - electron/utils/ - Port and icon utilities
* - electron/security/ - API key management
* - electron/windows/ - Window bounds and main window creation
* - electron/server/ - Backend and static server management
* - electron/ipc/ - IPC handlers (dialog, shell, app, auth, window, server)
*
* SECURITY: All file system access uses centralized methods from @automaker/platform.
*/
import path from 'path';
import { spawn, execSync, ChildProcess } from 'child_process';
import crypto from 'crypto';
import http, { Server } from 'http';
import net from 'net';
import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron';
import { createLogger } from '@automaker/utils/logger';
import { app, BrowserWindow, dialog } from 'electron';
import {
findNodeExecutable,
buildEnhancedPath,
initAllowedPaths,
isPathAllowed,
getAllowedRootDirectory,
// Electron userData operations
setElectronUserDataPath,
electronUserDataReadFileSync,
electronUserDataWriteFileSync,
electronUserDataExists,
// Electron app bundle operations
setElectronAppPaths,
electronAppExists,
electronAppStat,
electronAppReadFile,
// System path operations
systemPathExists,
initAllowedPaths,
} from '@automaker/platform';
import { createLogger } from '@automaker/utils/logger';
import { DEFAULT_SERVER_PORT, DEFAULT_STATIC_PORT } from './electron/constants';
import { state } from './electron/state';
import { findAvailablePort } from './electron/utils/port-manager';
import { getIconPath } from './electron/utils/icon-manager';
import { ensureApiKey } from './electron/security/api-key-manager';
import { createWindow } from './electron/windows/main-window';
import { startStaticServer, stopStaticServer } from './electron/server/static-server';
import { startServer, waitForServer, stopServer } from './electron/server/backend-server';
import { registerAllHandlers } from './electron/ipc';
const logger = createLogger('Electron');
const serverLogger = createLogger('Server');
// Development environment
const isDev = !app.isPackaged;
const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
// Load environment variables from .env file (development only)
if (isDev) {
@@ -51,608 +48,18 @@ if (isDev) {
}
}
let mainWindow: BrowserWindow | null = null;
let serverProcess: ChildProcess | null = null;
let staticServer: Server | null = null;
// Default ports (can be overridden via env) - will be dynamically assigned if these are in use
// When launched via root init.mjs we pass:
// - PORT (backend)
// - TEST_PORT (vite dev server / static)
const DEFAULT_SERVER_PORT = parseInt(process.env.PORT || '3008', 10);
const DEFAULT_STATIC_PORT = parseInt(process.env.TEST_PORT || '3007', 10);
// Actual ports in use (set during startup)
let serverPort = DEFAULT_SERVER_PORT;
let staticPort = DEFAULT_STATIC_PORT;
/**
* Check if a port is available
*/
function isPortAvailable(port: number): Promise<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' };
});
}
// Register IPC handlers
registerAllHandlers();
// App lifecycle
app.whenReady().then(async () => {
app.whenReady().then(handleAppReady);
app.on('window-all-closed', handleWindowAllClosed);
app.on('before-quit', handleBeforeQuit);
/**
* Handle app.whenReady()
*/
async function handleAppReady(): Promise<void> {
// In production, use Automaker dir in appData for app isolation
// In development, use project root for shared data between Electron and web mode
let userDataPathToUse: string;
@@ -661,10 +68,12 @@ app.whenReady().then(async () => {
// Production: Ensure userData path is consistent so files land in Automaker dir
try {
const desiredUserDataPath = path.join(app.getPath('appData'), 'Automaker');
if (app.getPath('userData') !== desiredUserDataPath) {
app.setPath('userData', desiredUserDataPath);
logger.info('[PRODUCTION] userData path set to:', desiredUserDataPath);
}
userDataPathToUse = desiredUserDataPath;
} catch (error) {
logger.warn('[PRODUCTION] Failed to set userData path:', (error as Error).message);
@@ -676,6 +85,7 @@ app.whenReady().then(async () => {
// __dirname is apps/ui/dist-electron, so go up to get project root
const projectRoot = path.join(__dirname, '../../..');
userDataPathToUse = path.join(projectRoot, 'data');
try {
app.setPath('userData', userDataPathToUse);
logger.info('[DEVELOPMENT] userData path explicitly set to:', userDataPathToUse);
@@ -701,6 +111,7 @@ app.whenReady().then(async () => {
} else {
setElectronAppPaths(__dirname, process.resourcesPath);
}
logger.info('Initialized path security helpers');
// Initialize security settings for path validation
@@ -711,6 +122,7 @@ app.whenReady().then(async () => {
: path.join(process.cwd(), 'data');
process.env.DATA_DIR = mainProcessDataDir;
logger.info('[MAIN_PROCESS_DATA_DIR]', mainProcessDataDir);
// ALLOWED_ROOT_DIRECTORY should already be in process.env if set by user
// (it will be passed to server process, but we also need it in main process for dialog validation)
initAllowedPaths();
@@ -729,12 +141,12 @@ app.whenReady().then(async () => {
try {
// Check if we should skip the embedded server (for Docker API mode)
const skipEmbeddedServer = process.env.SKIP_EMBEDDED_SERVER === 'true';
isExternalServerMode = skipEmbeddedServer;
state.isExternalServerMode = skipEmbeddedServer;
if (skipEmbeddedServer) {
// Use the default server port (Docker container runs on 3008)
serverPort = DEFAULT_SERVER_PORT;
logger.info('SKIP_EMBEDDED_SERVER=true, using external server at port', serverPort);
state.serverPort = DEFAULT_SERVER_PORT;
logger.info('SKIP_EMBEDDED_SERVER=true, using external server at port', state.serverPort);
// Wait for external server to be ready
logger.info('Waiting for external server...');
@@ -751,15 +163,25 @@ app.whenReady().then(async () => {
ensureApiKey();
// Find available ports (prevents conflicts with other apps using same ports)
serverPort = await findAvailablePort(DEFAULT_SERVER_PORT);
if (serverPort !== DEFAULT_SERVER_PORT) {
logger.info('Default server port', DEFAULT_SERVER_PORT, 'in use, using port', serverPort);
state.serverPort = await findAvailablePort(DEFAULT_SERVER_PORT);
if (state.serverPort !== DEFAULT_SERVER_PORT) {
logger.info(
'Default server port',
DEFAULT_SERVER_PORT,
'in use, using port',
state.serverPort
);
}
}
staticPort = await findAvailablePort(DEFAULT_STATIC_PORT);
if (staticPort !== DEFAULT_STATIC_PORT) {
logger.info('Default static port', DEFAULT_STATIC_PORT, 'in use, using port', staticPort);
state.staticPort = await findAvailablePort(DEFAULT_STATIC_PORT);
if (state.staticPort !== DEFAULT_STATIC_PORT) {
logger.info(
'Default static port',
DEFAULT_STATIC_PORT,
'in use, using port',
state.staticPort
);
}
// Start static file server in production
@@ -776,8 +198,10 @@ app.whenReady().then(async () => {
createWindow();
} catch (error) {
logger.error('Failed to start:', error);
const errorMessage = (error as Error).message;
const isNodeError = errorMessage.includes('Node.js');
dialog.showErrorBox(
'Automaker Failed to Start',
`The application failed to start.\n\n${errorMessage}\n\n${
@@ -794,207 +218,25 @@ app.whenReady().then(async () => {
createWindow();
}
});
});
}
app.on('window-all-closed', () => {
/**
* Handle window-all-closed event
*/
function handleWindowAllClosed(): void {
// On macOS, keep the app and servers running when all windows are closed
// (standard macOS behavior). On other platforms, stop servers and quit.
if (process.platform !== 'darwin') {
if (serverProcess && serverProcess.pid) {
logger.info('All windows closed, stopping server...');
if (process.platform === 'win32') {
try {
execSync(`taskkill /f /t /pid ${serverProcess.pid}`, { stdio: 'ignore' });
} catch (error) {
logger.error('Failed to kill server process:', (error as Error).message);
}
} else {
serverProcess.kill('SIGTERM');
}
serverProcess = null;
}
if (staticServer) {
logger.info('Stopping static server...');
staticServer.close();
staticServer = null;
}
stopServer();
stopStaticServer();
app.quit();
}
});
}
app.on('before-quit', () => {
if (serverProcess && serverProcess.pid) {
logger.info('Stopping server...');
if (process.platform === 'win32') {
try {
// Windows: use taskkill with /t to kill entire process tree
// This prevents orphaned node processes when closing the app
// Using execSync to ensure process is killed before app exits
execSync(`taskkill /f /t /pid ${serverProcess.pid}`, { stdio: 'ignore' });
} catch (error) {
logger.error('Failed to kill server process:', (error as Error).message);
}
} else {
serverProcess.kill('SIGTERM');
}
serverProcess = null;
}
if (staticServer) {
logger.info('Stopping static server...');
staticServer.close();
staticServer = null;
}
});
// ============================================
// IPC Handlers - Only native features
// ============================================
// Native file dialogs
ipcMain.handle('dialog:openDirectory', async () => {
if (!mainWindow) {
return { canceled: true, filePaths: [] };
}
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory', 'createDirectory'],
});
// Validate selected path against ALLOWED_ROOT_DIRECTORY if configured
if (!result.canceled && result.filePaths.length > 0) {
const selectedPath = result.filePaths[0];
if (!isPathAllowed(selectedPath)) {
const allowedRoot = getAllowedRootDirectory();
const errorMessage = allowedRoot
? `The selected directory is not allowed. Please select a directory within: ${allowedRoot}`
: 'The selected directory is not allowed.';
await dialog.showErrorBox('Directory Not Allowed', errorMessage);
return { canceled: true, filePaths: [] };
}
}
return result;
});
ipcMain.handle('dialog:openFile', async (_, options = {}) => {
if (!mainWindow) {
return { canceled: true, filePaths: [] };
}
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile'],
...options,
});
return result;
});
ipcMain.handle('dialog:saveFile', async (_, options = {}) => {
if (!mainWindow) {
return { canceled: true, filePath: undefined };
}
const result = await dialog.showSaveDialog(mainWindow, options);
return result;
});
// Shell operations
ipcMain.handle('shell:openExternal', async (_, url: string) => {
try {
await shell.openExternal(url);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('shell:openPath', async (_, filePath: string) => {
try {
await shell.openPath(filePath);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// Open file in editor (VS Code, etc.) with optional line/column
ipcMain.handle(
'shell:openInEditor',
async (_, filePath: string, line?: number, column?: number) => {
try {
// Build VS Code URL scheme: vscode://file/path:line:column
// This works on all platforms where VS Code is installed
// URL encode the path to handle special characters (spaces, brackets, etc.)
// Handle both Unix (/) and Windows (\) path separators
const normalizedPath = filePath.replace(/\\/g, '/');
const encodedPath = normalizedPath.startsWith('/')
? '/' + normalizedPath.slice(1).split('/').map(encodeURIComponent).join('/')
: normalizedPath.split('/').map(encodeURIComponent).join('/');
let url = `vscode://file${encodedPath}`;
if (line !== undefined && line > 0) {
url += `:${line}`;
if (column !== undefined && column > 0) {
url += `:${column}`;
}
}
await shell.openExternal(url);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
);
// App info
ipcMain.handle('app:getPath', async (_, name: Parameters<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();
});
/**
* Handle before-quit event
*/
function handleBeforeQuit(): void {
stopServer();
stopStaticServer();
}

View File

@@ -7,6 +7,7 @@
import { contextBridge, ipcRenderer, OpenDialogOptions, SaveDialogOptions } from 'electron';
import { createLogger } from '@automaker/utils/logger';
import { IPC_CHANNELS } from './electron/ipc/channels';
const logger = createLogger('Preload');
@@ -17,48 +18,49 @@ contextBridge.exposeInMainWorld('electronAPI', {
isElectron: true,
// Connection check
ping: (): Promise<string> => ipcRenderer.invoke('ping'),
ping: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.PING),
// 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
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)
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()
openDirectory: (): Promise<Electron.OpenDialogReturnValue> =>
ipcRenderer.invoke('dialog:openDirectory'),
ipcRenderer.invoke(IPC_CHANNELS.DIALOG.OPEN_DIRECTORY),
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> =>
ipcRenderer.invoke('dialog:saveFile', options),
ipcRenderer.invoke(IPC_CHANNELS.DIALOG.SAVE_FILE, options),
// Shell operations
openExternalLink: (url: string): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('shell:openExternal', url),
ipcRenderer.invoke(IPC_CHANNELS.SHELL.OPEN_EXTERNAL, url),
openPath: (filePath: string): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('shell:openPath', filePath),
ipcRenderer.invoke(IPC_CHANNELS.SHELL.OPEN_PATH, filePath),
openInEditor: (
filePath: string,
line?: number,
column?: number
): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('shell:openInEditor', filePath, line, column),
ipcRenderer.invoke(IPC_CHANNELS.SHELL.OPEN_IN_EDITOR, filePath, line, column),
// App info
getPath: (name: string): Promise<string> => ipcRenderer.invoke('app:getPath', name),
getVersion: (): Promise<string> => ipcRenderer.invoke('app:getVersion'),
isPackaged: (): Promise<boolean> => ipcRenderer.invoke('app:isPackaged'),
getPath: (name: string): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.APP.GET_PATH, name),
getVersion: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.APP.GET_VERSION),
isPackaged: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.APP.IS_PACKAGED),
// Window management
updateMinWidth: (sidebarExpanded: boolean): Promise<void> =>
ipcRenderer.invoke('window:updateMinWidth', sidebarExpanded),
ipcRenderer.invoke(IPC_CHANNELS.WINDOW.UPDATE_MIN_WIDTH, sidebarExpanded),
// App control
quit: (): Promise<void> => ipcRenderer.invoke('app:quit'),
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.APP.QUIT),
});
logger.info('Electron API exposed (TypeScript)');

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
import type { BackgroundSettings } from '../types/ui-types';
// Default background settings for board backgrounds
export const defaultBackgroundSettings: BackgroundSettings = {
imagePath: null,
cardOpacity: 100,
columnOpacity: 100,
columnBorderEnabled: true,
cardGlassmorphism: true,
cardBorderEnabled: true,
cardBorderOpacity: 100,
hideScrollbar: false,
};

View File

@@ -0,0 +1,2 @@
// Maximum number of output lines to keep in init script state (prevents unbounded memory growth)
export const MAX_INIT_OUTPUT_LINES = 500;

View File

@@ -0,0 +1,3 @@
export { defaultBackgroundSettings } from './background-settings';
export { defaultTerminalState } from './terminal-defaults';
export { MAX_INIT_OUTPUT_LINES } from './constants';

View File

@@ -0,0 +1,21 @@
import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
import type { TerminalState } from '../types/terminal-types';
// Default terminal state values
export const defaultTerminalState: TerminalState = {
isUnlocked: false,
authToken: null,
tabs: [],
activeTabId: null,
activeSessionId: null,
maximizedSessionId: null,
defaultFontSize: 14,
defaultRunScript: '',
screenReaderMode: false,
fontFamily: DEFAULT_FONT_VALUE,
scrollbackLines: 5000,
lineHeight: 1.0,
maxSessions: 100,
lastActiveProjectPath: null,
openTerminalMode: 'newTab',
};

View File

@@ -0,0 +1,40 @@
export interface ImageAttachment {
id?: string; // Optional - may not be present in messages loaded from server
data: string; // base64 encoded image data
mimeType: string; // e.g., "image/png", "image/jpeg"
filename: string;
size?: number; // file size in bytes - optional for messages from server
}
export interface TextFileAttachment {
id: string;
content: string; // text content of the file
mimeType: string; // e.g., "text/plain", "text/markdown"
filename: string;
size: number; // file size in bytes
}
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
images?: ImageAttachment[];
textFiles?: TextFileAttachment[];
}
export interface ChatSession {
id: string;
title: string;
projectId: string;
messages: ChatMessage[];
createdAt: Date;
updatedAt: Date;
archived: boolean;
}
// UI-specific: base64-encoded images with required id and size (extends ImageAttachment)
export interface FeatureImage extends ImageAttachment {
id: string; // Required (overrides optional in ImageAttachment)
size: number; // Required (overrides optional in ImageAttachment)
}

View File

@@ -0,0 +1,7 @@
export * from './usage-types';
export * from './ui-types';
export * from './settings-types';
export * from './chat-types';
export * from './terminal-types';
export * from './project-types';
export * from './state-types';

View File

@@ -0,0 +1,66 @@
import type {
Feature as BaseFeature,
FeatureImagePath,
FeatureTextFilePath,
ThinkingLevel,
ReasoningEffort,
FeatureStatusWithPipeline,
PlanSpec,
} from '@automaker/types';
import type { FeatureImage } from './chat-types';
// Available models for feature execution
export type ClaudeModel = 'opus' | 'sonnet' | 'haiku';
export interface Feature extends Omit<
BaseFeature,
| 'steps'
| 'imagePaths'
| 'textFilePaths'
| 'status'
| 'planSpec'
| 'dependencies'
| 'model'
| 'branchName'
| 'thinkingLevel'
| 'reasoningEffort'
| 'summary'
> {
id: string;
title?: string;
titleGenerating?: boolean;
category: string;
description: string;
steps: string[]; // Required in UI (not optional)
status: FeatureStatusWithPipeline;
images?: FeatureImage[]; // UI-specific base64 images
imagePaths?: FeatureImagePath[]; // Stricter type than base (no string | union)
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
justFinishedAt?: string; // UI-specific: ISO timestamp when agent just finished
prUrl?: string; // UI-specific: Pull request URL
planSpec?: PlanSpec; // Explicit planSpec type to override BaseFeature's index signature
dependencies?: string[]; // Explicit type to override BaseFeature's index signature
model?: string; // Explicit type to override BaseFeature's index signature
branchName?: string; // Explicit type to override BaseFeature's index signature
thinkingLevel?: ThinkingLevel; // Explicit type to override BaseFeature's index signature
reasoningEffort?: ReasoningEffort; // Explicit type to override BaseFeature's index signature
summary?: string; // Explicit type to override BaseFeature's index signature
}
// File tree node for project analysis
export interface FileTreeNode {
name: string;
path: string;
isDirectory: boolean;
extension?: string;
children?: FileTreeNode[];
}
// Project analysis result
export interface ProjectAnalysis {
fileTree: FileTreeNode[];
totalFiles: number;
totalDirectories: number;
filesByExtension: Record<string, number>;
analyzedAt: string;
}

View File

@@ -0,0 +1,5 @@
export interface ApiKeys {
anthropic: string;
google: string;
openai: string;
}

View File

@@ -0,0 +1,799 @@
import type { Project, TrashedProject } from '@/lib/electron';
import type {
ModelAlias,
PlanningMode,
ThinkingLevel,
ReasoningEffort,
ModelProvider,
CursorModelId,
CodexModelId,
OpencodeModelId,
GeminiModelId,
CopilotModelId,
PhaseModelConfig,
PhaseModelKey,
PhaseModelEntry,
MCPServerConfig,
PipelineConfig,
PipelineStep,
PromptCustomization,
ModelDefinition,
ServerLogLevel,
EventHook,
ClaudeApiProfile,
ClaudeCompatibleProvider,
SidebarStyle,
} from '@automaker/types';
import type {
ViewMode,
ThemeMode,
BoardViewMode,
KeyboardShortcuts,
BackgroundSettings,
} from './ui-types';
import type { ApiKeys } from './settings-types';
import type { ChatMessage, ChatSession, FeatureImage } from './chat-types';
import type { TerminalState, TerminalPanelContent, PersistedTerminalState } from './terminal-types';
import type { Feature, ProjectAnalysis } from './project-types';
import type { ClaudeUsage, CodexUsage } from './usage-types';
/** State for worktree init script execution */
export interface InitScriptState {
status: 'idle' | 'running' | 'success' | 'failed';
branch: string;
output: string[];
error?: string;
}
export interface AutoModeActivity {
id: string;
featureId: string;
timestamp: Date;
type:
| 'start'
| 'progress'
| 'tool'
| 'complete'
| 'error'
| 'planning'
| 'action'
| 'verification';
message: string;
tool?: string;
passes?: boolean;
phase?: 'planning' | 'action' | 'verification';
errorType?: 'authentication' | 'execution';
}
export interface AppState {
// Project state
projects: Project[];
currentProject: Project | null;
trashedProjects: TrashedProject[];
projectHistory: string[]; // Array of project IDs in MRU order (most recent first)
projectHistoryIndex: number; // Current position in project history for cycling
// View state
currentView: ViewMode;
sidebarOpen: boolean;
sidebarStyle: SidebarStyle; // 'unified' (modern) or 'discord' (classic two-sidebar layout)
collapsedNavSections: Record<string, boolean>; // Collapsed state of nav sections (key: section label)
mobileSidebarHidden: boolean; // Completely hides sidebar on mobile
// Agent Session state (per-project, keyed by project path)
lastSelectedSessionByProject: Record<string, string>; // projectPath -> sessionId
// Theme
theme: ThemeMode;
// Fonts (global defaults)
fontFamilySans: string | null; // null = use default Geist Sans
fontFamilyMono: string | null; // null = use default Geist Mono
// Features/Kanban
features: Feature[];
// App spec
appSpec: string;
// IPC status
ipcConnected: boolean;
// API Keys
apiKeys: ApiKeys;
// Chat Sessions
chatSessions: ChatSession[];
currentChatSession: ChatSession | null;
chatHistoryOpen: boolean;
// Auto Mode (per-worktree state, keyed by "${projectId}::${branchName ?? '__main__'}")
autoModeByWorktree: Record<
string,
{
isRunning: boolean;
runningTasks: string[]; // Feature IDs being worked on
branchName: string | null; // null = main worktree
maxConcurrency?: number; // Maximum concurrent features for this worktree (defaults to 3)
}
>;
autoModeActivityLog: AutoModeActivity[];
maxConcurrency: number; // Legacy: Maximum number of concurrent agent tasks (deprecated, use per-worktree maxConcurrency)
// Kanban Card Display Settings
boardViewMode: BoardViewMode; // Whether to show kanban or dependency graph view
// Feature Default Settings
defaultSkipTests: boolean; // Default value for skip tests when creating new features
enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true)
skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running)
enableAiCommitMessages: boolean; // When true, auto-generate commit messages using AI when opening commit dialog
planUseSelectedWorktreeBranch: boolean; // When true, Plan dialog creates features on the currently selected worktree branch
addFeatureUseSelectedWorktreeBranch: boolean; // When true, Add Feature dialog defaults to custom mode with selected worktree branch
// Worktree Settings
useWorktrees: boolean; // Whether to use git worktree isolation for features (default: true)
// User-managed Worktrees (per-project)
// projectPath -> { path: worktreePath or null for main, branch: branch name }
currentWorktreeByProject: Record<string, { path: string | null; branch: string }>;
worktreesByProject: Record<
string,
Array<{
path: string;
branch: string;
isMain: boolean;
isCurrent: boolean;
hasWorktree: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}>
>;
// Keyboard Shortcuts
keyboardShortcuts: KeyboardShortcuts; // User-defined keyboard shortcuts
// Audio Settings
muteDoneSound: boolean; // When true, mute the notification sound when agents complete (default: false)
// Splash Screen Settings
disableSplashScreen: boolean; // When true, skip showing the splash screen overlay on startup
// Server Log Level Settings
serverLogLevel: ServerLogLevel; // Log level for the API server (error, warn, info, debug)
enableRequestLogging: boolean; // Enable HTTP request logging (Morgan)
// Developer Tools Settings
showQueryDevtools: boolean; // Show React Query DevTools panel (only in development mode)
// Enhancement Model Settings
enhancementModel: ModelAlias; // Model used for feature enhancement (default: sonnet)
// Validation Model Settings
validationModel: ModelAlias; // Model used for GitHub issue validation (default: opus)
// Phase Model Settings - per-phase AI model configuration
phaseModels: PhaseModelConfig;
favoriteModels: string[];
// Cursor CLI Settings (global)
enabledCursorModels: CursorModelId[]; // Which Cursor models are available in feature modal
cursorDefaultModel: CursorModelId; // Default Cursor model selection
// Codex CLI Settings (global)
enabledCodexModels: CodexModelId[]; // Which Codex models are available in feature modal
codexDefaultModel: CodexModelId; // Default Codex model selection
codexAutoLoadAgents: boolean; // Auto-load .codex/AGENTS.md files
codexSandboxMode: 'read-only' | 'workspace-write' | 'danger-full-access'; // Sandbox policy
codexApprovalPolicy: 'untrusted' | 'on-failure' | 'on-request' | 'never'; // Approval policy
codexEnableWebSearch: boolean; // Enable web search capability
codexEnableImages: boolean; // Enable image processing
// OpenCode CLI Settings (global)
// Static OpenCode settings are persisted via SETTINGS_FIELDS_TO_SYNC
enabledOpencodeModels: OpencodeModelId[]; // Which static OpenCode models are available
opencodeDefaultModel: OpencodeModelId; // Default OpenCode model selection
// Dynamic models are session-only (not persisted) because they're discovered at runtime
// from `opencode models` CLI and depend on current provider authentication state
dynamicOpencodeModels: ModelDefinition[]; // Dynamically discovered models from OpenCode CLI
enabledDynamicModelIds: string[]; // Which dynamic models are enabled
cachedOpencodeProviders: Array<{
id: string;
name: string;
authenticated: boolean;
authMethod?: string;
}>; // Cached providers
opencodeModelsLoading: boolean; // Whether OpenCode models are being fetched
opencodeModelsError: string | null; // Error message if fetch failed
opencodeModelsLastFetched: number | null; // Timestamp of last successful fetch
opencodeModelsLastFailedAt: number | null; // Timestamp of last failed fetch
// Gemini CLI Settings (global)
enabledGeminiModels: GeminiModelId[]; // Which Gemini models are available in feature modal
geminiDefaultModel: GeminiModelId; // Default Gemini model selection
// Copilot SDK Settings (global)
enabledCopilotModels: CopilotModelId[]; // Which Copilot models are available in feature modal
copilotDefaultModel: CopilotModelId; // Default Copilot model selection
// Provider Visibility Settings
disabledProviders: ModelProvider[]; // Providers that are disabled and hidden from dropdowns
// Claude Agent SDK Settings
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
skipSandboxWarning: boolean; // Skip the sandbox environment warning dialog on startup
// MCP Servers
mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use
// Editor Configuration
defaultEditorCommand: string | null; // Default editor for "Open In" action
// Terminal Configuration
defaultTerminalId: string | null; // Default external terminal for "Open In Terminal" action (null = integrated)
// Skills Configuration
enableSkills: boolean; // Enable Skills functionality (loads from .claude/skills/ directories)
skillsSources: Array<'user' | 'project'>; // Which directories to load Skills from
// Subagents Configuration
enableSubagents: boolean; // Enable Custom Subagents functionality (loads from .claude/agents/ directories)
subagentsSources: Array<'user' | 'project'>; // Which directories to load Subagents from
// Prompt Customization
promptCustomization: PromptCustomization; // Custom prompts for Auto Mode, Agent, Backlog Plan, Enhancement
// Event Hooks
eventHooks: EventHook[]; // Event hooks for custom commands or webhooks
// Claude-Compatible Providers (new system)
claudeCompatibleProviders: ClaudeCompatibleProvider[]; // Providers that expose models to dropdowns
// Claude API Profiles (deprecated - kept for backward compatibility)
claudeApiProfiles: ClaudeApiProfile[]; // Claude-compatible API endpoint profiles
activeClaudeApiProfileId: string | null; // Active profile ID (null = use direct Anthropic API)
// Project Analysis
projectAnalysis: ProjectAnalysis | null;
isAnalyzing: boolean;
// Board Background Settings (per-project, keyed by project path)
boardBackgroundByProject: Record<string, BackgroundSettings>;
// Theme Preview (for hover preview in theme selectors)
previewTheme: ThemeMode | null;
// Terminal state
terminalState: TerminalState;
// Terminal layout persistence (per-project, keyed by project path)
// Stores the tab/split structure so it can be restored when switching projects
terminalLayoutByProject: Record<string, PersistedTerminalState>;
// Spec Creation State (per-project, keyed by project path)
// Tracks which project is currently having its spec generated
specCreatingForProject: string | null;
defaultPlanningMode: PlanningMode;
defaultRequirePlanApproval: boolean;
defaultFeatureModel: PhaseModelEntry;
// Plan Approval State
// When a plan requires user approval, this holds the pending approval details
pendingPlanApproval: {
featureId: string;
projectPath: string;
planContent: string;
planningMode: 'lite' | 'spec' | 'full';
} | null;
// Claude Usage Tracking
claudeRefreshInterval: number; // Refresh interval in seconds (default: 60)
claudeUsage: ClaudeUsage | null;
claudeUsageLastUpdated: number | null;
// Codex Usage Tracking
codexUsage: CodexUsage | null;
codexUsageLastUpdated: number | null;
// Codex Models (dynamically fetched)
codexModels: Array<{
id: string;
label: string;
description: string;
hasThinking: boolean;
supportsVision: boolean;
tier: 'premium' | 'standard' | 'basic';
isDefault: boolean;
}>;
codexModelsLoading: boolean;
codexModelsError: string | null;
codexModelsLastFetched: number | null;
codexModelsLastFailedAt: number | null;
// Pipeline Configuration (per-project, keyed by project path)
pipelineConfigByProject: Record<string, PipelineConfig>;
// Worktree Panel Visibility (per-project, keyed by project path)
// Whether the worktree panel row is visible (default: true)
worktreePanelVisibleByProject: Record<string, boolean>;
// Init Script Indicator Visibility (per-project, keyed by project path)
// Whether to show the floating init script indicator panel (default: true)
showInitScriptIndicatorByProject: Record<string, boolean>;
// Default Delete Branch With Worktree (per-project, keyed by project path)
// Whether to default the "delete branch" checkbox when deleting a worktree (default: false)
defaultDeleteBranchByProject: Record<string, boolean>;
// Auto-dismiss Init Script Indicator (per-project, keyed by project path)
// Whether to auto-dismiss the indicator after completion (default: true)
autoDismissInitScriptIndicatorByProject: Record<string, boolean>;
// Use Worktrees Override (per-project, keyed by project path)
// undefined = use global setting, true/false = project-specific override
useWorktreesByProject: Record<string, boolean | undefined>;
// UI State (previously in localStorage, now synced via API)
/** Whether worktree panel is collapsed in board view */
worktreePanelCollapsed: boolean;
/** Last directory opened in file picker */
lastProjectDir: string;
/** Recently accessed folders for quick access */
recentFolders: string[];
// Init Script State (keyed by "projectPath::branch" to support concurrent scripts)
initScriptState: Record<string, InitScriptState>;
}
export interface AppActions {
// Project actions
setProjects: (projects: Project[]) => void;
addProject: (project: Project) => void;
removeProject: (projectId: string) => void;
moveProjectToTrash: (projectId: string) => void;
restoreTrashedProject: (projectId: string) => void;
deleteTrashedProject: (projectId: string) => void;
emptyTrash: () => void;
setCurrentProject: (project: Project | null) => void;
upsertAndSetCurrentProject: (path: string, name: string, theme?: ThemeMode) => Project; // Upsert project by path and set as current
reorderProjects: (oldIndex: number, newIndex: number) => void;
cyclePrevProject: () => void; // Cycle back through project history (Q)
cycleNextProject: () => void; // Cycle forward through project history (E)
clearProjectHistory: () => void; // Clear history, keeping only current project
toggleProjectFavorite: (projectId: string) => void; // Toggle project favorite status
setProjectIcon: (projectId: string, icon: string | null) => void; // Set project icon (null to clear)
setProjectCustomIcon: (projectId: string, customIconPath: string | null) => void; // Set custom project icon image path (null to clear)
setProjectName: (projectId: string, name: string) => void; // Update project name
// View actions
setCurrentView: (view: ViewMode) => void;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
setSidebarStyle: (style: SidebarStyle) => void;
setCollapsedNavSections: (sections: Record<string, boolean>) => void;
toggleNavSection: (sectionLabel: string) => void;
toggleMobileSidebarHidden: () => void;
setMobileSidebarHidden: (hidden: boolean) => void;
// Theme actions
setTheme: (theme: ThemeMode) => void;
setProjectTheme: (projectId: string, theme: ThemeMode | null) => void; // Set per-project theme (null to clear)
getEffectiveTheme: () => ThemeMode; // Get the effective theme (project, global, or preview if set)
setPreviewTheme: (theme: ThemeMode | null) => void; // Set preview theme for hover preview (null to clear)
// Font actions (global + per-project override)
setFontSans: (fontFamily: string | null) => void; // Set global UI/sans font (null to clear)
setFontMono: (fontFamily: string | null) => void; // Set global code/mono font (null to clear)
setProjectFontSans: (projectId: string, fontFamily: string | null) => void; // Set per-project UI/sans font override (null = use global)
setProjectFontMono: (projectId: string, fontFamily: string | null) => void; // Set per-project code/mono font override (null = use global)
getEffectiveFontSans: () => string | null; // Get effective UI font (project override -> global -> null for default)
getEffectiveFontMono: () => string | null; // Get effective code font (project override -> global -> null for default)
// Claude API Profile actions (per-project override)
/** @deprecated Use setProjectPhaseModelOverride instead */
setProjectClaudeApiProfile: (projectId: string, profileId: string | null | undefined) => void; // Set per-project Claude API profile (undefined = use global, null = direct API, string = specific profile)
// Project Phase Model Overrides
setProjectPhaseModelOverride: (
projectId: string,
phase: PhaseModelKey,
entry: PhaseModelEntry | null // null = use global
) => void;
clearAllProjectPhaseModelOverrides: (projectId: string) => void;
// Project Default Feature Model Override
setProjectDefaultFeatureModel: (
projectId: string,
entry: PhaseModelEntry | null // null = use global
) => void;
// Feature actions
setFeatures: (features: Feature[]) => void;
updateFeature: (id: string, updates: Partial<Feature>) => void;
addFeature: (feature: Omit<Feature, 'id'> & Partial<Pick<Feature, 'id'>>) => Feature;
removeFeature: (id: string) => void;
moveFeature: (id: string, newStatus: Feature['status']) => void;
// App spec actions
setAppSpec: (spec: string) => void;
// IPC actions
setIpcConnected: (connected: boolean) => void;
// API Keys actions
setApiKeys: (keys: Partial<ApiKeys>) => void;
// Chat Session actions
createChatSession: (title?: string) => ChatSession;
updateChatSession: (sessionId: string, updates: Partial<ChatSession>) => void;
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
setCurrentChatSession: (session: ChatSession | null) => void;
archiveChatSession: (sessionId: string) => void;
unarchiveChatSession: (sessionId: string) => void;
deleteChatSession: (sessionId: string) => void;
setChatHistoryOpen: (open: boolean) => void;
toggleChatHistory: () => void;
// Auto Mode actions (per-worktree)
setAutoModeRunning: (
projectId: string,
branchName: string | null,
running: boolean,
maxConcurrency?: number,
runningTasks?: string[]
) => void;
addRunningTask: (projectId: string, branchName: string | null, taskId: string) => void;
removeRunningTask: (projectId: string, branchName: string | null, taskId: string) => void;
clearRunningTasks: (projectId: string, branchName: string | null) => void;
getAutoModeState: (
projectId: string,
branchName: string | null
) => {
isRunning: boolean;
runningTasks: string[];
branchName: string | null;
maxConcurrency?: number;
};
/** Helper to generate worktree key from projectId and branchName */
getWorktreeKey: (projectId: string, branchName: string | null) => string;
addAutoModeActivity: (activity: Omit<AutoModeActivity, 'id' | 'timestamp'>) => void;
clearAutoModeActivity: () => void;
setMaxConcurrency: (max: number) => void; // Legacy: kept for backward compatibility
getMaxConcurrencyForWorktree: (projectId: string, branchName: string | null) => number;
setMaxConcurrencyForWorktree: (
projectId: string,
branchName: string | null,
maxConcurrency: number
) => void;
// Kanban Card Settings actions
setBoardViewMode: (mode: BoardViewMode) => void;
// Feature Default Settings actions
setDefaultSkipTests: (skip: boolean) => void;
setEnableDependencyBlocking: (enabled: boolean) => void;
setSkipVerificationInAutoMode: (enabled: boolean) => Promise<void>;
setEnableAiCommitMessages: (enabled: boolean) => Promise<void>;
setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
// Worktree Settings actions
setUseWorktrees: (enabled: boolean) => void;
setCurrentWorktree: (projectPath: string, worktreePath: string | null, branch: string) => void;
setWorktrees: (
projectPath: string,
worktrees: Array<{
path: string;
branch: string;
isMain: boolean;
isCurrent: boolean;
hasWorktree: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}>
) => void;
getCurrentWorktree: (projectPath: string) => { path: string | null; branch: string } | null;
getWorktrees: (projectPath: string) => Array<{
path: string;
branch: string;
isMain: boolean;
isCurrent: boolean;
hasWorktree: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}>;
isPrimaryWorktreeBranch: (projectPath: string, branchName: string) => boolean;
getPrimaryWorktreeBranch: (projectPath: string) => string | null;
// Keyboard Shortcuts actions
setKeyboardShortcut: (key: keyof KeyboardShortcuts, value: string) => void;
setKeyboardShortcuts: (shortcuts: Partial<KeyboardShortcuts>) => void;
resetKeyboardShortcuts: () => void;
// Audio Settings actions
setMuteDoneSound: (muted: boolean) => void;
// Splash Screen actions
setDisableSplashScreen: (disabled: boolean) => void;
// Server Log Level actions
setServerLogLevel: (level: ServerLogLevel) => void;
setEnableRequestLogging: (enabled: boolean) => void;
// Developer Tools actions
setShowQueryDevtools: (show: boolean) => void;
// Enhancement Model actions
setEnhancementModel: (model: ModelAlias) => void;
// Validation Model actions
setValidationModel: (model: ModelAlias) => void;
// Phase Model actions
setPhaseModel: (phase: PhaseModelKey, entry: PhaseModelEntry) => Promise<void>;
setPhaseModels: (models: Partial<PhaseModelConfig>) => Promise<void>;
resetPhaseModels: () => Promise<void>;
toggleFavoriteModel: (modelId: string) => void;
// Cursor CLI Settings actions
setEnabledCursorModels: (models: CursorModelId[]) => void;
setCursorDefaultModel: (model: CursorModelId) => void;
toggleCursorModel: (model: CursorModelId, enabled: boolean) => void;
// Codex CLI Settings actions
setEnabledCodexModels: (models: CodexModelId[]) => void;
setCodexDefaultModel: (model: CodexModelId) => void;
toggleCodexModel: (model: CodexModelId, enabled: boolean) => void;
setCodexAutoLoadAgents: (enabled: boolean) => Promise<void>;
setCodexSandboxMode: (
mode: 'read-only' | 'workspace-write' | 'danger-full-access'
) => Promise<void>;
setCodexApprovalPolicy: (
policy: 'untrusted' | 'on-failure' | 'on-request' | 'never'
) => Promise<void>;
setCodexEnableWebSearch: (enabled: boolean) => Promise<void>;
setCodexEnableImages: (enabled: boolean) => Promise<void>;
// OpenCode CLI Settings actions
setEnabledOpencodeModels: (models: OpencodeModelId[]) => void;
setOpencodeDefaultModel: (model: OpencodeModelId) => void;
toggleOpencodeModel: (model: OpencodeModelId, enabled: boolean) => void;
setDynamicOpencodeModels: (models: ModelDefinition[]) => void;
setEnabledDynamicModelIds: (ids: string[]) => void;
toggleDynamicModel: (modelId: string, enabled: boolean) => void;
setCachedOpencodeProviders: (
providers: Array<{ id: string; name: string; authenticated: boolean; authMethod?: string }>
) => void;
// Gemini CLI Settings actions
setEnabledGeminiModels: (models: GeminiModelId[]) => void;
setGeminiDefaultModel: (model: GeminiModelId) => void;
toggleGeminiModel: (model: GeminiModelId, enabled: boolean) => void;
// Copilot SDK Settings actions
setEnabledCopilotModels: (models: CopilotModelId[]) => void;
setCopilotDefaultModel: (model: CopilotModelId) => void;
toggleCopilotModel: (model: CopilotModelId, enabled: boolean) => void;
// Provider Visibility Settings actions
setDisabledProviders: (providers: ModelProvider[]) => void;
toggleProviderDisabled: (provider: ModelProvider, disabled: boolean) => void;
isProviderDisabled: (provider: ModelProvider) => boolean;
// Claude Agent SDK Settings actions
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
setSkipSandboxWarning: (skip: boolean) => Promise<void>;
// Editor Configuration actions
setDefaultEditorCommand: (command: string | null) => void;
// Terminal Configuration actions
setDefaultTerminalId: (terminalId: string | null) => void;
// Prompt Customization actions
setPromptCustomization: (customization: PromptCustomization) => Promise<void>;
// Event Hook actions
setEventHooks: (hooks: EventHook[]) => void;
// Claude-Compatible Provider actions (new system)
addClaudeCompatibleProvider: (provider: ClaudeCompatibleProvider) => Promise<void>;
updateClaudeCompatibleProvider: (
id: string,
updates: Partial<ClaudeCompatibleProvider>
) => Promise<void>;
deleteClaudeCompatibleProvider: (id: string) => Promise<void>;
setClaudeCompatibleProviders: (providers: ClaudeCompatibleProvider[]) => Promise<void>;
toggleClaudeCompatibleProviderEnabled: (id: string) => Promise<void>;
// Claude API Profile actions (deprecated - kept for backward compatibility)
addClaudeApiProfile: (profile: ClaudeApiProfile) => Promise<void>;
updateClaudeApiProfile: (id: string, updates: Partial<ClaudeApiProfile>) => Promise<void>;
deleteClaudeApiProfile: (id: string) => Promise<void>;
setActiveClaudeApiProfile: (id: string | null) => Promise<void>;
setClaudeApiProfiles: (profiles: ClaudeApiProfile[]) => Promise<void>;
// MCP Server actions
addMCPServer: (server: Omit<MCPServerConfig, 'id'>) => void;
updateMCPServer: (id: string, updates: Partial<MCPServerConfig>) => void;
removeMCPServer: (id: string) => void;
reorderMCPServers: (oldIndex: number, newIndex: number) => void;
// Project Analysis actions
setProjectAnalysis: (analysis: ProjectAnalysis | null) => void;
setIsAnalyzing: (analyzing: boolean) => void;
clearAnalysis: () => void;
// Agent Session actions
setLastSelectedSession: (projectPath: string, sessionId: string | null) => void;
getLastSelectedSession: (projectPath: string) => string | null;
// Board Background actions
setBoardBackground: (projectPath: string, imagePath: string | null) => void;
setCardOpacity: (projectPath: string, opacity: number) => void;
setColumnOpacity: (projectPath: string, opacity: number) => void;
setColumnBorderEnabled: (projectPath: string, enabled: boolean) => void;
getBoardBackground: (projectPath: string) => {
imagePath: string | null;
cardOpacity: number;
columnOpacity: number;
columnBorderEnabled: boolean;
cardGlassmorphism: boolean;
cardBorderEnabled: boolean;
cardBorderOpacity: number;
hideScrollbar: boolean;
};
setCardGlassmorphism: (projectPath: string, enabled: boolean) => void;
setCardBorderEnabled: (projectPath: string, enabled: boolean) => void;
setCardBorderOpacity: (projectPath: string, opacity: number) => void;
setHideScrollbar: (projectPath: string, hide: boolean) => void;
clearBoardBackground: (projectPath: string) => void;
// Terminal actions
setTerminalUnlocked: (unlocked: boolean, token?: string) => void;
setActiveTerminalSession: (sessionId: string | null) => void;
toggleTerminalMaximized: (sessionId: string) => void;
addTerminalToLayout: (
sessionId: string,
direction?: 'horizontal' | 'vertical',
targetSessionId?: string,
branchName?: string
) => void;
removeTerminalFromLayout: (sessionId: string) => void;
swapTerminals: (sessionId1: string, sessionId2: string) => void;
clearTerminalState: () => void;
setTerminalPanelFontSize: (sessionId: string, fontSize: number) => void;
setTerminalDefaultFontSize: (fontSize: number) => void;
setTerminalDefaultRunScript: (script: string) => void;
setTerminalScreenReaderMode: (enabled: boolean) => void;
setTerminalFontFamily: (fontFamily: string) => void;
setTerminalScrollbackLines: (lines: number) => void;
setTerminalLineHeight: (lineHeight: number) => void;
setTerminalMaxSessions: (maxSessions: number) => void;
setTerminalLastActiveProjectPath: (projectPath: string | null) => void;
setOpenTerminalMode: (mode: 'newTab' | 'split') => void;
addTerminalTab: (name?: string) => string;
removeTerminalTab: (tabId: string) => void;
setActiveTerminalTab: (tabId: string) => void;
renameTerminalTab: (tabId: string, name: string) => void;
reorderTerminalTabs: (fromTabId: string, toTabId: string) => void;
moveTerminalToTab: (sessionId: string, targetTabId: string | 'new') => void;
addTerminalToTab: (
sessionId: string,
tabId: string,
direction?: 'horizontal' | 'vertical',
branchName?: string
) => void;
setTerminalTabLayout: (
tabId: string,
layout: TerminalPanelContent,
activeSessionId?: string
) => void;
updateTerminalPanelSizes: (tabId: string, panelKeys: string[], sizes: number[]) => void;
saveTerminalLayout: (projectPath: string) => void;
getPersistedTerminalLayout: (projectPath: string) => PersistedTerminalState | null;
clearPersistedTerminalLayout: (projectPath: string) => void;
// Spec Creation actions
setSpecCreatingForProject: (projectPath: string | null) => void;
isSpecCreatingForProject: (projectPath: string) => boolean;
setDefaultPlanningMode: (mode: PlanningMode) => void;
setDefaultRequirePlanApproval: (require: boolean) => void;
setDefaultFeatureModel: (entry: PhaseModelEntry) => void;
// Plan Approval actions
setPendingPlanApproval: (
approval: {
featureId: string;
projectPath: string;
planContent: string;
planningMode: 'lite' | 'spec' | 'full';
} | null
) => void;
// Pipeline actions
setPipelineConfig: (projectPath: string, config: PipelineConfig) => void;
getPipelineConfig: (projectPath: string) => PipelineConfig | null;
addPipelineStep: (
projectPath: string,
step: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'>
) => PipelineStep;
updatePipelineStep: (
projectPath: string,
stepId: string,
updates: Partial<Omit<PipelineStep, 'id' | 'createdAt'>>
) => void;
deletePipelineStep: (projectPath: string, stepId: string) => void;
reorderPipelineSteps: (projectPath: string, stepIds: string[]) => void;
// Worktree Panel Visibility actions (per-project)
setWorktreePanelVisible: (projectPath: string, visible: boolean) => void;
getWorktreePanelVisible: (projectPath: string) => boolean;
// Init Script Indicator Visibility actions (per-project)
setShowInitScriptIndicator: (projectPath: string, visible: boolean) => void;
getShowInitScriptIndicator: (projectPath: string) => boolean;
// Default Delete Branch actions (per-project)
setDefaultDeleteBranch: (projectPath: string, deleteBranch: boolean) => void;
getDefaultDeleteBranch: (projectPath: string) => boolean;
// Auto-dismiss Init Script Indicator actions (per-project)
setAutoDismissInitScriptIndicator: (projectPath: string, autoDismiss: boolean) => void;
getAutoDismissInitScriptIndicator: (projectPath: string) => boolean;
// Use Worktrees Override actions (per-project)
setProjectUseWorktrees: (projectPath: string, useWorktrees: boolean | null) => void; // null = use global
getProjectUseWorktrees: (projectPath: string) => boolean | undefined; // undefined = using global
getEffectiveUseWorktrees: (projectPath: string) => boolean; // Returns actual value (project or global fallback)
// UI State actions (previously in localStorage, now synced via API)
setWorktreePanelCollapsed: (collapsed: boolean) => void;
setLastProjectDir: (dir: string) => void;
setRecentFolders: (folders: string[]) => void;
addRecentFolder: (folder: string) => void;
// Claude Usage Tracking actions
setClaudeRefreshInterval: (interval: number) => void;
setClaudeUsageLastUpdated: (timestamp: number) => void;
setClaudeUsage: (usage: ClaudeUsage | null) => void;
// Codex Usage Tracking actions
setCodexUsage: (usage: CodexUsage | null) => void;
// Codex Models actions
fetchCodexModels: (forceRefresh?: boolean) => Promise<void>;
setCodexModels: (
models: Array<{
id: string;
label: string;
description: string;
hasThinking: boolean;
supportsVision: boolean;
tier: 'premium' | 'standard' | 'basic';
isDefault: boolean;
}>
) => void;
// OpenCode Models actions
fetchOpencodeModels: (forceRefresh?: boolean) => Promise<void>;
// Init Script State actions (keyed by projectPath::branch to support concurrent scripts)
setInitScriptState: (
projectPath: string,
branch: string,
state: Partial<InitScriptState>
) => void;
appendInitScriptOutput: (projectPath: string, branch: string, content: string) => void;
clearInitScriptState: (projectPath: string, branch: string) => void;
getInitScriptState: (projectPath: string, branch: string) => InitScriptState | null;
getInitScriptStatesForProject: (
projectPath: string
) => Array<{ key: string; state: InitScriptState }>;
// Reset
reset: () => void;
}

View File

@@ -0,0 +1,82 @@
// Terminal panel layout types (recursive for splits)
export type TerminalPanelContent =
| { type: 'terminal'; sessionId: string; size?: number; fontSize?: number; branchName?: string }
| { type: 'testRunner'; sessionId: string; size?: number; worktreePath: string }
| {
type: 'split';
id: string; // Stable ID for React key stability
direction: 'horizontal' | 'vertical';
panels: TerminalPanelContent[];
size?: number;
};
// Terminal tab - each tab has its own layout
export interface TerminalTab {
id: string;
name: string;
layout: TerminalPanelContent | null;
}
export interface TerminalState {
isUnlocked: boolean;
authToken: string | null;
tabs: TerminalTab[];
activeTabId: string | null;
activeSessionId: string | null;
maximizedSessionId: string | null; // Session ID of the maximized terminal pane (null if none)
defaultFontSize: number; // Default font size for new terminals
defaultRunScript: string; // Script to run when a new terminal is created (e.g., "claude" to start Claude Code)
screenReaderMode: boolean; // Enable screen reader accessibility mode
fontFamily: string; // Font family for terminal text
scrollbackLines: number; // Number of lines to keep in scrollback buffer
lineHeight: number; // Line height multiplier for terminal text
maxSessions: number; // Maximum concurrent terminal sessions (server setting)
lastActiveProjectPath: string | null; // Last project path to detect route changes vs project switches
openTerminalMode: 'newTab' | 'split'; // How to open terminals from "Open in Terminal" action
}
// Persisted terminal layout - now includes sessionIds for reconnection
// Used to restore terminal layout structure when switching projects
export type PersistedTerminalPanel =
| { type: 'terminal'; size?: number; fontSize?: number; sessionId?: string; branchName?: string }
| { type: 'testRunner'; size?: number; sessionId?: string; worktreePath?: string }
| {
type: 'split';
id?: string; // Optional for backwards compatibility with older persisted layouts
direction: 'horizontal' | 'vertical';
panels: PersistedTerminalPanel[];
size?: number;
};
// Helper to generate unique split IDs
export const generateSplitId = () =>
`split-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
export interface PersistedTerminalTab {
id: string;
name: string;
layout: PersistedTerminalPanel | null;
}
export interface PersistedTerminalState {
tabs: PersistedTerminalTab[];
activeTabIndex: number; // Use index instead of ID since IDs are regenerated
defaultFontSize: number;
defaultRunScript?: string; // Optional to support existing persisted data
screenReaderMode?: boolean; // Optional to support existing persisted data
fontFamily?: string; // Optional to support existing persisted data
scrollbackLines?: number; // Optional to support existing persisted data
lineHeight?: number; // Optional to support existing persisted data
}
// Persisted terminal settings - stored globally (not per-project)
export interface PersistedTerminalSettings {
defaultFontSize: number;
defaultRunScript: string;
screenReaderMode: boolean;
fontFamily: string;
scrollbackLines: number;
lineHeight: number;
maxSessions: number;
openTerminalMode: 'newTab' | 'split';
}

View File

@@ -0,0 +1,119 @@
export type ViewMode =
| 'welcome'
| 'setup'
| 'spec'
| 'board'
| 'agent'
| 'settings'
| 'interview'
| 'context'
| 'running-agents'
| 'terminal'
| 'wiki'
| 'ideation';
export type ThemeMode =
// Special modes
| 'system'
// Dark themes
| 'dark'
| 'retro'
| 'dracula'
| 'nord'
| 'monokai'
| 'tokyonight'
| 'solarized'
| 'gruvbox'
| 'catppuccin'
| 'onedark'
| 'synthwave'
| 'red'
| 'sunset'
| 'gray'
| 'forest'
| 'ocean'
| 'ember'
| 'ayu-dark'
| 'ayu-mirage'
| 'matcha'
// Light themes
| 'light'
| 'cream'
| 'solarizedlight'
| 'github'
| 'paper'
| 'rose'
| 'mint'
| 'lavender'
| 'sand'
| 'sky'
| 'peach'
| 'snow'
| 'sepia'
| 'gruvboxlight'
| 'nordlight'
| 'blossom'
| 'ayu-light'
| 'onelight'
| 'bluloco'
| 'feather';
export type BoardViewMode = 'kanban' | 'graph';
// Keyboard Shortcut with optional modifiers
export interface ShortcutKey {
key: string; // The main key (e.g., "K", "N", "1")
shift?: boolean; // Shift key modifier
cmdCtrl?: boolean; // Cmd on Mac, Ctrl on Windows/Linux
alt?: boolean; // Alt/Option key modifier
}
// Board background settings
export interface BackgroundSettings {
imagePath: string | null;
imageVersion?: number;
cardOpacity: number;
columnOpacity: number;
columnBorderEnabled: boolean;
cardGlassmorphism: boolean;
cardBorderEnabled: boolean;
cardBorderOpacity: number;
hideScrollbar: boolean;
}
// Keyboard Shortcuts - stored as strings like "K", "Shift+N", "Cmd+K"
export interface KeyboardShortcuts {
// Navigation shortcuts
board: string;
graph: string;
agent: string;
spec: string;
context: string;
memory: string;
settings: string;
projectSettings: string;
terminal: string;
ideation: string;
notifications: string;
githubIssues: string;
githubPrs: string;
// UI shortcuts
toggleSidebar: string;
// Action shortcuts
addFeature: string;
addContextFile: string;
startNext: string;
newSession: string;
openProject: string;
projectPicker: string;
cyclePrevProject: string;
cycleNextProject: string;
// Terminal shortcuts
splitTerminalRight: string;
splitTerminalDown: string;
closeTerminal: string;
newTerminalTab: string;
}

View File

@@ -0,0 +1,60 @@
// Claude Usage interface matching the server response
export type ClaudeUsage = {
sessionTokensUsed: number;
sessionLimit: number;
sessionPercentage: number;
sessionResetTime: string;
sessionResetText: string;
weeklyTokensUsed: number;
weeklyLimit: number;
weeklyPercentage: number;
weeklyResetTime: string;
weeklyResetText: string;
sonnetWeeklyTokensUsed: number;
sonnetWeeklyPercentage: number;
sonnetResetText: string;
costUsed: number | null;
costLimit: number | null;
costCurrency: string | null;
lastUpdated: string;
userTimezone: string;
};
// Response type for Claude usage API (can be success or error)
export type ClaudeUsageResponse = ClaudeUsage | { error: string; message?: string };
// Codex Usage types
export type CodexPlanType =
| 'free'
| 'plus'
| 'pro'
| 'team'
| 'business'
| 'enterprise'
| 'edu'
| 'unknown';
export interface CodexRateLimitWindow {
limit: number;
used: number;
remaining: number;
usedPercent: number; // Percentage used (0-100)
windowDurationMins: number; // Duration in minutes
resetsAt: number; // Unix timestamp in seconds
}
export interface CodexUsage {
rateLimits: {
primary?: CodexRateLimitWindow;
secondary?: CodexRateLimitWindow;
planType?: CodexPlanType;
} | null;
lastUpdated: string;
}
// Response type for Codex usage API (can be success or error)
export type CodexUsageResponse = CodexUsage | { error: string; message?: string };

View File

@@ -0,0 +1,13 @@
// Theme utilities (PUBLIC)
export {
THEME_STORAGE_KEY,
getStoredTheme,
getStoredFontSans,
getStoredFontMono,
} from './theme-utils';
// Shortcut utilities (PUBLIC)
export { parseShortcut, formatShortcut, DEFAULT_KEYBOARD_SHORTCUTS } from './shortcut-utils';
// Usage utilities (PUBLIC)
export { isClaudeUsageAtLimit } from './usage-utils';

View File

@@ -0,0 +1,117 @@
import type { ShortcutKey, KeyboardShortcuts } from '../types/ui-types';
// Helper to parse shortcut string to ShortcutKey object
export function parseShortcut(shortcut: string | undefined | null): ShortcutKey {
if (!shortcut) return { key: '' };
const parts = shortcut.split('+').map((p) => p.trim());
const result: ShortcutKey = { key: parts[parts.length - 1] };
// Normalize common OS-specific modifiers (Cmd/Ctrl/Win/Super symbols) into cmdCtrl
for (let i = 0; i < parts.length - 1; i++) {
const modifier = parts[i].toLowerCase();
if (modifier === 'shift') result.shift = true;
else if (
modifier === 'cmd' ||
modifier === 'ctrl' ||
modifier === 'win' ||
modifier === 'super' ||
modifier === '⌘' ||
modifier === '^' ||
modifier === '⊞' ||
modifier === '◆'
)
result.cmdCtrl = true;
else if (modifier === 'alt' || modifier === 'opt' || modifier === 'option' || modifier === '⌥')
result.alt = true;
}
return result;
}
// Helper to format ShortcutKey to display string
export function formatShortcut(shortcut: string | undefined | null, forDisplay = false): string {
if (!shortcut) return '';
const parsed = parseShortcut(shortcut);
const parts: string[] = [];
// Prefer User-Agent Client Hints when available; fall back to legacy
const platform: 'darwin' | 'win32' | 'linux' = (() => {
if (typeof navigator === 'undefined') return 'linux';
const uaPlatform = (
navigator as Navigator & { userAgentData?: { platform?: string } }
).userAgentData?.platform?.toLowerCase?.();
const legacyPlatform = navigator.platform?.toLowerCase?.();
const platformString = uaPlatform || legacyPlatform || '';
if (platformString.includes('mac')) return 'darwin';
if (platformString.includes('win')) return 'win32';
return 'linux';
})();
// Primary modifier - OS-specific
if (parsed.cmdCtrl) {
if (forDisplay) {
parts.push(platform === 'darwin' ? '⌘' : platform === 'win32' ? '⊞' : '◆');
} else {
parts.push(platform === 'darwin' ? 'Cmd' : platform === 'win32' ? 'Win' : 'Super');
}
}
// Alt/Option
if (parsed.alt) {
parts.push(
forDisplay ? (platform === 'darwin' ? '⌥' : 'Alt') : platform === 'darwin' ? 'Opt' : 'Alt'
);
}
// Shift
if (parsed.shift) {
parts.push(forDisplay ? '⇧' : 'Shift');
}
parts.push(parsed.key.toUpperCase());
// Add spacing when displaying symbols
return parts.join(forDisplay ? ' ' : '+');
}
// Default keyboard shortcuts
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
// Navigation
board: 'K',
graph: 'H',
agent: 'A',
spec: 'D',
context: 'C',
memory: 'Y',
settings: 'S',
projectSettings: 'Shift+S',
terminal: 'T',
ideation: 'I',
notifications: 'X',
githubIssues: 'G',
githubPrs: 'R',
// UI
toggleSidebar: '`',
// Actions
// Note: Some shortcuts share the same key (e.g., "N" for addFeature, newSession)
// This is intentional as they are context-specific and only active in their respective views
addFeature: 'N', // Only active in board view
addContextFile: 'N', // Only active in context view
startNext: 'G', // Only active in board view
newSession: 'N', // Only active in agent view
openProject: 'O', // Global shortcut
projectPicker: 'P', // Global shortcut
cyclePrevProject: 'Q', // Global shortcut
cycleNextProject: 'E', // Global shortcut
// Terminal shortcuts (only active in terminal view)
// Using Alt modifier to avoid conflicts with both terminal signals AND browser shortcuts
splitTerminalRight: 'Alt+D',
splitTerminalDown: 'Alt+S',
closeTerminal: 'Alt+W',
newTerminalTab: 'Alt+T',
};

View File

@@ -0,0 +1,117 @@
import { getItem, setItem, removeItem } from '@/lib/storage';
import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
import type { Project } from '@/lib/electron';
import type { ThemeMode } from '../types/ui-types';
// LocalStorage keys for persistence (fallback when server settings aren't available)
export const THEME_STORAGE_KEY = 'automaker:theme';
const FONT_SANS_STORAGE_KEY = 'automaker:font-sans';
const FONT_MONO_STORAGE_KEY = 'automaker:font-mono';
/**
* Get the theme from localStorage as a fallback
* Used before server settings are loaded (e.g., on login/setup pages)
*/
export function getStoredTheme(): ThemeMode | null {
const stored = getItem(THEME_STORAGE_KEY);
if (stored) return stored as ThemeMode;
// Backwards compatibility: older versions stored theme inside the Zustand persist blob.
// We intentionally keep reading it as a fallback so users don't get a "default theme flash"
// on login/logged-out pages if THEME_STORAGE_KEY hasn't been written yet.
try {
const legacy = getItem('automaker-storage');
if (!legacy) return null;
interface LegacyStorageFormat {
state?: { theme?: string };
theme?: string;
}
const parsed = JSON.parse(legacy) as LegacyStorageFormat;
const theme = parsed.state?.theme ?? parsed.theme;
if (typeof theme === 'string' && theme.length > 0) {
return theme as ThemeMode;
}
} catch {
// Ignore legacy parse errors
}
return null;
}
/**
* Helper to get effective font value with validation
* Returns the font to use (project override -> global -> null for default)
* @param projectFont - The project-specific font override
* @param globalFont - The global font setting
* @param fontOptions - The list of valid font options for validation
*/
export function getEffectiveFont(
projectFont: string | undefined,
globalFont: string | null,
fontOptions: readonly { value: string; label: string }[]
): string | null {
const isValidFont = (font: string | null | undefined): boolean => {
if (!font || font === DEFAULT_FONT_VALUE) return true;
return fontOptions.some((opt) => opt.value === font);
};
if (projectFont) {
if (isValidFont(projectFont)) {
return projectFont === DEFAULT_FONT_VALUE ? null : projectFont;
}
// Invalid project font -> fall through to check global font
}
if (!isValidFont(globalFont)) return null; // Fallback to default if font not in list
return globalFont === DEFAULT_FONT_VALUE ? null : globalFont;
}
/**
* Save theme to localStorage for immediate persistence
* This is used as a fallback when server settings can't be loaded
*/
export function saveThemeToStorage(theme: ThemeMode): void {
setItem(THEME_STORAGE_KEY, theme);
}
/**
* Get fonts from localStorage as a fallback
* Used before server settings are loaded (e.g., on login/setup pages)
*/
export function getStoredFontSans(): string | null {
return getItem(FONT_SANS_STORAGE_KEY);
}
export function getStoredFontMono(): string | null {
return getItem(FONT_MONO_STORAGE_KEY);
}
/**
* Save fonts to localStorage for immediate persistence
* This is used as a fallback when server settings can't be loaded
*/
export function saveFontSansToStorage(fontFamily: string | null): void {
if (fontFamily) {
setItem(FONT_SANS_STORAGE_KEY, fontFamily);
} else {
// Remove from storage if null (using default)
removeItem(FONT_SANS_STORAGE_KEY);
}
}
export function saveFontMonoToStorage(fontFamily: string | null): void {
if (fontFamily) {
setItem(FONT_MONO_STORAGE_KEY, fontFamily);
} else {
// Remove from storage if null (using default)
removeItem(FONT_MONO_STORAGE_KEY);
}
}
export function persistEffectiveThemeForProject(
project: Project | null,
fallbackTheme: ThemeMode
): void {
const projectTheme = project?.theme as ThemeMode | undefined;
const themeToStore = projectTheme ?? fallbackTheme;
saveThemeToStorage(themeToStore);
}

View File

@@ -0,0 +1,34 @@
import type { ClaudeUsage } from '../types/usage-types';
/**
* Check if Claude usage is at its limit (any of: session >= 100%, weekly >= 100%, OR cost >= limit)
* Returns true if any limit is reached, meaning auto mode should pause feature pickup.
*/
export function isClaudeUsageAtLimit(claudeUsage: ClaudeUsage | null): boolean {
if (!claudeUsage) {
// No usage data available - don't block
return false;
}
// Check session limit (5-hour window)
if (claudeUsage.sessionPercentage >= 100) {
return true;
}
// Check weekly limit
if (claudeUsage.weeklyPercentage >= 100) {
return true;
}
// Check cost limit (if configured)
if (
claudeUsage.costLimit !== null &&
claudeUsage.costLimit > 0 &&
claudeUsage.costUsed !== null &&
claudeUsage.costUsed >= claudeUsage.costLimit
) {
return true;
}
return false;
}