1230 lines
37 KiB
TypeScript
1230 lines
37 KiB
TypeScript
/**
|
|
* IPC Handlers for App Configuration.
|
|
*
|
|
* Handlers:
|
|
* - config:get: Get full app configuration
|
|
* - config:update: Update a specific config section
|
|
* - config:addIgnoreRegex: Add an ignore pattern for notifications
|
|
* - config:removeIgnoreRegex: Remove an ignore pattern
|
|
* - config:addIgnoreRepository: Add a repository to ignore list
|
|
* - config:removeIgnoreRepository: Remove a repository from ignore list
|
|
* - config:snooze: Set snooze duration for notifications
|
|
* - config:clearSnooze: Clear the snooze timer
|
|
* - config:addTrigger: Add a new notification trigger
|
|
* - config:updateTrigger: Update an existing notification trigger
|
|
* - config:removeTrigger: Remove a notification trigger
|
|
* - config:getTriggers: Get all notification triggers
|
|
* - config:testTrigger: Test a trigger against historical session data
|
|
*/
|
|
|
|
import { syncTelemetryFlag } from '@main/sentry';
|
|
import { quoteWindowsCmdArg } from '@main/utils/childProcess';
|
|
import { getAutoDetectedClaudeBasePath, getClaudeBasePath } from '@main/utils/pathDecoder';
|
|
import { getErrorMessage } from '@shared/utils/errorHandling';
|
|
import { createLogger } from '@shared/utils/logger';
|
|
import { execFile, execFileSync, spawn } from 'child_process';
|
|
import { BrowserWindow, dialog, type IpcMain, type IpcMainInvokeEvent } from 'electron';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { promisify } from 'util';
|
|
|
|
import {
|
|
type AppConfig,
|
|
ConfigManager,
|
|
type NotificationTrigger,
|
|
type TriggerContentType,
|
|
type TriggerMatchField,
|
|
type TriggerMode,
|
|
type TriggerTokenType,
|
|
} from '../services';
|
|
|
|
import { validateConfigUpdatePayload } from './configValidation';
|
|
import { validateTriggerId } from './guards';
|
|
|
|
import type { TriggerColor } from '@shared/constants/triggerColors';
|
|
import type {
|
|
ClaudeRootFolderSelection,
|
|
ClaudeRootInfo,
|
|
IpcResult,
|
|
WslClaudeRootCandidate,
|
|
} from '@shared/types';
|
|
|
|
const logger = createLogger('IPC:config');
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
// Get singleton instance
|
|
const configManager = ConfigManager.getInstance();
|
|
let onClaudeRootPathUpdated: ((claudeRootPath: string | null) => Promise<void> | void) | null =
|
|
null;
|
|
let onAgentLanguageUpdated: ((newLangCode: string) => Promise<void> | void) | null = null;
|
|
|
|
function isPathLikeCommand(command: string): boolean {
|
|
return /[\\/]/.test(command) || /^[A-Za-z]:/.test(command);
|
|
}
|
|
|
|
function resolveWindowsEditorCommand(editor: string): string {
|
|
if (process.platform !== 'win32' || isPathLikeCommand(editor)) {
|
|
return editor;
|
|
}
|
|
|
|
try {
|
|
const whereExe = path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'where.exe');
|
|
const output = execFileSync(whereExe, [editor], {
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
windowsHide: true,
|
|
});
|
|
return output.trim().split(/\r?\n/)[0] || editor;
|
|
} catch {
|
|
return editor;
|
|
}
|
|
}
|
|
|
|
function needsWindowsShell(command: string): boolean {
|
|
if (process.platform !== 'win32') return false;
|
|
const extension = path.extname(command).toLowerCase();
|
|
return extension === '.cmd' || extension === '.bat';
|
|
}
|
|
|
|
function launchExternalEditor(editor: string, configPath: string): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const resolvedEditor = resolveWindowsEditorCommand(editor);
|
|
const launchOptions = {
|
|
detached: true,
|
|
stdio: 'ignore' as const,
|
|
windowsHide: true,
|
|
};
|
|
let child: ReturnType<typeof spawn>;
|
|
if (needsWindowsShell(resolvedEditor)) {
|
|
const command = [resolvedEditor, configPath].map(quoteWindowsCmdArg).join(' ');
|
|
// eslint-disable-next-line sonarjs/os-command -- Windows .cmd launchers require cmd.exe; editor path is resolved via where.exe and args are cmd-escaped.
|
|
child = spawn(command, {
|
|
...launchOptions,
|
|
shell: true,
|
|
});
|
|
} else {
|
|
child = spawn(resolvedEditor, [configPath], launchOptions);
|
|
}
|
|
|
|
let settled = false;
|
|
function settle(fn: () => void): void {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
fn();
|
|
}
|
|
const timer = setTimeout(() => {
|
|
child.unref();
|
|
settle(() => resolve());
|
|
}, 500);
|
|
|
|
child.on('error', (err) => {
|
|
settle(() => reject(err));
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Initializes config handlers with callbacks that require app-level services.
|
|
*/
|
|
export function initializeConfigHandlers(
|
|
options: {
|
|
onClaudeRootPathUpdated?: (claudeRootPath: string | null) => Promise<void> | void;
|
|
onAgentLanguageUpdated?: (newLangCode: string) => Promise<void> | void;
|
|
} = {}
|
|
): void {
|
|
onClaudeRootPathUpdated = options.onClaudeRootPathUpdated ?? null;
|
|
onAgentLanguageUpdated = options.onAgentLanguageUpdated ?? null;
|
|
}
|
|
|
|
/**
|
|
* Registers all config-related IPC handlers.
|
|
*/
|
|
export function registerConfigHandlers(ipcMain: IpcMain): void {
|
|
// Get full configuration
|
|
ipcMain.handle('config:get', handleGetConfig);
|
|
|
|
// Update configuration section
|
|
ipcMain.handle('config:update', handleUpdateConfig);
|
|
|
|
// Ignore regex pattern handlers
|
|
ipcMain.handle('config:addIgnoreRegex', handleAddIgnoreRegex);
|
|
ipcMain.handle('config:removeIgnoreRegex', handleRemoveIgnoreRegex);
|
|
|
|
// Ignore repository handlers
|
|
ipcMain.handle('config:addIgnoreRepository', handleAddIgnoreRepository);
|
|
ipcMain.handle('config:removeIgnoreRepository', handleRemoveIgnoreRepository);
|
|
|
|
// Snooze handlers
|
|
ipcMain.handle('config:snooze', handleSnooze);
|
|
ipcMain.handle('config:clearSnooze', handleClearSnooze);
|
|
|
|
// Trigger management handlers
|
|
ipcMain.handle('config:addTrigger', handleAddTrigger);
|
|
ipcMain.handle('config:updateTrigger', handleUpdateTrigger);
|
|
ipcMain.handle('config:removeTrigger', handleRemoveTrigger);
|
|
ipcMain.handle('config:getTriggers', handleGetTriggers);
|
|
ipcMain.handle('config:testTrigger', handleTestTrigger);
|
|
|
|
// Session pin handlers
|
|
ipcMain.handle('config:pinSession', handlePinSession);
|
|
ipcMain.handle('config:unpinSession', handleUnpinSession);
|
|
|
|
// Session hide handlers
|
|
ipcMain.handle('config:hideSession', handleHideSession);
|
|
ipcMain.handle('config:unhideSession', handleUnhideSession);
|
|
ipcMain.handle('config:hideSessions', handleHideSessions);
|
|
ipcMain.handle('config:unhideSessions', handleUnhideSessions);
|
|
|
|
// Dialog handlers
|
|
ipcMain.handle('config:selectFolders', handleSelectFolders);
|
|
ipcMain.handle('config:selectClaudeRootFolder', handleSelectClaudeRootFolder);
|
|
ipcMain.handle('config:getClaudeRootInfo', handleGetClaudeRootInfo);
|
|
ipcMain.handle('config:findWslClaudeRoots', handleFindWslClaudeRoots);
|
|
|
|
// Custom project path handlers
|
|
ipcMain.handle('config:addCustomProjectPath', handleAddCustomProjectPath);
|
|
ipcMain.handle('config:removeCustomProjectPath', handleRemoveCustomProjectPath);
|
|
|
|
// Editor handlers
|
|
ipcMain.handle('config:openInEditor', handleOpenInEditor);
|
|
|
|
logger.info('Config handlers registered (including trigger management)');
|
|
}
|
|
|
|
// =============================================================================
|
|
// Handler Functions
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Handler for 'config:get' IPC call.
|
|
* Returns the full app configuration.
|
|
*/
|
|
async function handleGetConfig(_event: IpcMainInvokeEvent): Promise<IpcResult<AppConfig>> {
|
|
try {
|
|
const config = configManager.getConfig();
|
|
return { success: true, data: config };
|
|
} catch (error) {
|
|
logger.error('Error in config:get:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:update' IPC call.
|
|
* Updates a specific section of the configuration.
|
|
* Returns the full updated config.
|
|
*/
|
|
async function handleUpdateConfig(
|
|
_event: IpcMainInvokeEvent,
|
|
section: unknown,
|
|
data: unknown
|
|
): Promise<IpcResult<AppConfig>> {
|
|
try {
|
|
const validation = validateConfigUpdatePayload(section, data);
|
|
if (!validation.valid) {
|
|
return { success: false, error: validation.error };
|
|
}
|
|
|
|
const isClaudeRootUpdate =
|
|
validation.section === 'general' &&
|
|
Object.prototype.hasOwnProperty.call(validation.data, 'claudeRootPath');
|
|
|
|
// Capture previous language BEFORE applying the update so we can detect real changes
|
|
const prevAgentLanguage =
|
|
validation.section === 'general' &&
|
|
Object.prototype.hasOwnProperty.call(validation.data, 'agentLanguage')
|
|
? configManager.getConfig().general.agentLanguage
|
|
: undefined;
|
|
|
|
configManager.updateConfig(validation.section, validation.data);
|
|
|
|
// Sync Sentry opt-in when general.telemetryEnabled changes
|
|
if (
|
|
validation.section === 'general' &&
|
|
Object.prototype.hasOwnProperty.call(validation.data, 'telemetryEnabled')
|
|
) {
|
|
syncTelemetryFlag(configManager.getConfig().general.telemetryEnabled);
|
|
}
|
|
|
|
if (isClaudeRootUpdate && onClaudeRootPathUpdated) {
|
|
const nextClaudeRootPath = (validation.data as { claudeRootPath?: string | null })
|
|
.claudeRootPath;
|
|
try {
|
|
await onClaudeRootPathUpdated(nextClaudeRootPath ?? null);
|
|
} catch (callbackError) {
|
|
logger.error('Failed to apply updated Claude root path at runtime:', callbackError);
|
|
}
|
|
}
|
|
|
|
if (prevAgentLanguage !== undefined && onAgentLanguageUpdated) {
|
|
const newLangCode = (validation.data as { agentLanguage?: string }).agentLanguage;
|
|
if (newLangCode && newLangCode !== prevAgentLanguage) {
|
|
try {
|
|
await onAgentLanguageUpdated(newLangCode);
|
|
} catch (callbackError) {
|
|
logger.error('Failed to notify teams about language change:', callbackError);
|
|
}
|
|
}
|
|
}
|
|
|
|
const updatedConfig = configManager.getConfig();
|
|
return { success: true, data: updatedConfig };
|
|
} catch (error) {
|
|
logger.error('Error in config:update:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:addIgnoreRegex' IPC call.
|
|
* Adds a regex pattern to the notification ignore list.
|
|
*/
|
|
async function handleAddIgnoreRegex(
|
|
_event: IpcMainInvokeEvent,
|
|
pattern: string
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!pattern || typeof pattern !== 'string') {
|
|
return { success: false, error: 'Pattern is required and must be a string' };
|
|
}
|
|
|
|
// Validate that the pattern is a valid regex
|
|
try {
|
|
new RegExp(pattern);
|
|
} catch {
|
|
return { success: false, error: 'Invalid regex pattern' };
|
|
}
|
|
|
|
configManager.addIgnoreRegex(pattern);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:addIgnoreRegex:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:removeIgnoreRegex' IPC call.
|
|
* Removes a regex pattern from the notification ignore list.
|
|
*/
|
|
async function handleRemoveIgnoreRegex(
|
|
_event: IpcMainInvokeEvent,
|
|
pattern: string
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!pattern || typeof pattern !== 'string') {
|
|
return { success: false, error: 'Pattern is required and must be a string' };
|
|
}
|
|
|
|
configManager.removeIgnoreRegex(pattern);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:removeIgnoreRegex:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:addIgnoreRepository' IPC call.
|
|
* Adds a repository to the notification ignore list.
|
|
*/
|
|
async function handleAddIgnoreRepository(
|
|
_event: IpcMainInvokeEvent,
|
|
repositoryId: string
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!repositoryId || typeof repositoryId !== 'string') {
|
|
return { success: false, error: 'Repository ID is required and must be a string' };
|
|
}
|
|
|
|
configManager.addIgnoreRepository(repositoryId);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:addIgnoreRepository:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:removeIgnoreRepository' IPC call.
|
|
* Removes a repository from the notification ignore list.
|
|
*/
|
|
async function handleRemoveIgnoreRepository(
|
|
_event: IpcMainInvokeEvent,
|
|
repositoryId: string
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!repositoryId || typeof repositoryId !== 'string') {
|
|
return { success: false, error: 'Repository ID is required and must be a string' };
|
|
}
|
|
|
|
configManager.removeIgnoreRepository(repositoryId);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:removeIgnoreRepository:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:snooze' IPC call.
|
|
* Sets the snooze timer for notifications.
|
|
*/
|
|
async function handleSnooze(_event: IpcMainInvokeEvent, minutes: number): Promise<IpcResult> {
|
|
try {
|
|
if (typeof minutes !== 'number' || minutes <= 0 || minutes > 24 * 60) {
|
|
return { success: false, error: 'Minutes must be a positive number' };
|
|
}
|
|
|
|
configManager.setSnooze(minutes);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:snooze:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:clearSnooze' IPC call.
|
|
* Clears the snooze timer.
|
|
*/
|
|
async function handleClearSnooze(_event: IpcMainInvokeEvent): Promise<IpcResult> {
|
|
try {
|
|
configManager.clearSnooze();
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:clearSnooze:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:addTrigger' - Adds a new notification trigger.
|
|
*/
|
|
async function handleAddTrigger(
|
|
_event: IpcMainInvokeEvent,
|
|
trigger: {
|
|
id: string;
|
|
name: string;
|
|
enabled: boolean;
|
|
contentType: string;
|
|
mode?: TriggerMode;
|
|
requireError?: boolean;
|
|
toolName?: string;
|
|
matchField?: string;
|
|
matchPattern?: string;
|
|
ignorePatterns?: string[];
|
|
tokenThreshold?: number;
|
|
tokenType?: TriggerTokenType;
|
|
repositoryIds?: string[];
|
|
color?: string;
|
|
}
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!trigger.id || !trigger.name || !trigger.contentType) {
|
|
return {
|
|
success: false,
|
|
error: 'Trigger must have id, name, and contentType',
|
|
};
|
|
}
|
|
|
|
configManager.addTrigger({
|
|
id: trigger.id,
|
|
name: trigger.name,
|
|
enabled: trigger.enabled,
|
|
contentType: trigger.contentType as TriggerContentType,
|
|
mode: trigger.mode ?? (trigger.requireError ? 'error_status' : 'content_match'),
|
|
requireError: trigger.requireError,
|
|
toolName: trigger.toolName,
|
|
matchField: trigger.matchField as TriggerMatchField | undefined,
|
|
matchPattern: trigger.matchPattern,
|
|
ignorePatterns: trigger.ignorePatterns,
|
|
tokenThreshold: trigger.tokenThreshold,
|
|
tokenType: trigger.tokenType,
|
|
repositoryIds: trigger.repositoryIds,
|
|
color: trigger.color as TriggerColor | undefined,
|
|
isBuiltin: false,
|
|
});
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:addTrigger:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to add trigger',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:updateTrigger' - Updates an existing notification trigger.
|
|
*/
|
|
async function handleUpdateTrigger(
|
|
_event: IpcMainInvokeEvent,
|
|
triggerId: string,
|
|
updates: Partial<{
|
|
name: string;
|
|
enabled: boolean;
|
|
contentType: string;
|
|
requireError: boolean;
|
|
toolName: string;
|
|
matchField: string;
|
|
matchPattern: string;
|
|
ignorePatterns: string[];
|
|
mode: TriggerMode;
|
|
tokenThreshold: number;
|
|
tokenType: TriggerTokenType;
|
|
repositoryIds: string[];
|
|
color: string;
|
|
}>
|
|
): Promise<IpcResult> {
|
|
try {
|
|
const validatedTriggerId = validateTriggerId(triggerId);
|
|
if (!validatedTriggerId.valid) {
|
|
return {
|
|
success: false,
|
|
error: validatedTriggerId.error ?? 'Trigger ID is required',
|
|
};
|
|
}
|
|
|
|
configManager.updateTrigger(validatedTriggerId.value!, updates as Partial<NotificationTrigger>);
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:updateTrigger:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to update trigger',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:removeTrigger' - Removes a notification trigger.
|
|
*/
|
|
async function handleRemoveTrigger(
|
|
_event: IpcMainInvokeEvent,
|
|
triggerId: string
|
|
): Promise<IpcResult> {
|
|
try {
|
|
const validatedTriggerId = validateTriggerId(triggerId);
|
|
if (!validatedTriggerId.valid) {
|
|
return {
|
|
success: false,
|
|
error: validatedTriggerId.error ?? 'Trigger ID is required',
|
|
};
|
|
}
|
|
|
|
configManager.removeTrigger(validatedTriggerId.value!);
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:removeTrigger:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to remove trigger',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:getTriggers' - Gets all notification triggers.
|
|
*/
|
|
async function handleGetTriggers(
|
|
_event: IpcMainInvokeEvent
|
|
): Promise<IpcResult<NotificationTrigger[]>> {
|
|
try {
|
|
const triggers = configManager.getTriggers();
|
|
|
|
return { success: true, data: triggers };
|
|
} catch (error) {
|
|
logger.error('Error in config:getTriggers:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to get triggers',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:testTrigger' - Tests a trigger against historical session data.
|
|
* Returns errors that would have been detected by the trigger.
|
|
*
|
|
* Safety: Results are truncated if:
|
|
* - More than 10,000 total matches found
|
|
* - More than 100 sessions scanned
|
|
* - Test runs longer than 30 seconds
|
|
*/
|
|
async function handleTestTrigger(
|
|
_event: IpcMainInvokeEvent,
|
|
trigger: NotificationTrigger
|
|
): Promise<
|
|
IpcResult<{
|
|
totalCount: number;
|
|
errors: {
|
|
id: string;
|
|
sessionId: string;
|
|
projectId: string;
|
|
message: string;
|
|
timestamp: number;
|
|
source: string;
|
|
toolUseId?: string;
|
|
subagentId?: string;
|
|
lineNumber?: number;
|
|
context: { projectName: string };
|
|
}[];
|
|
/** True if results were truncated due to safety limits */
|
|
truncated?: boolean;
|
|
}>
|
|
> {
|
|
try {
|
|
const { errorDetector } = await import('../services');
|
|
const result = await errorDetector.testTrigger(trigger, 50);
|
|
|
|
// Map the DetectedError objects to the format expected by the renderer
|
|
// Include toolUseId, subagentId, and lineNumber for deep linking to exact error location
|
|
const errors = result.errors.map((error) => ({
|
|
id: error.id,
|
|
sessionId: error.sessionId,
|
|
projectId: error.projectId,
|
|
message: error.message,
|
|
timestamp: error.timestamp,
|
|
source: error.source,
|
|
toolUseId: error.toolUseId,
|
|
subagentId: error.subagentId,
|
|
lineNumber: error.lineNumber,
|
|
context: { projectName: error.context.projectName },
|
|
}));
|
|
|
|
return {
|
|
success: true,
|
|
data: { totalCount: result.totalCount, errors, truncated: result.truncated },
|
|
};
|
|
} catch (error) {
|
|
logger.error('Error in config:testTrigger:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to test trigger',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:pinSession' - Pins a session for a project.
|
|
*/
|
|
async function handlePinSession(
|
|
_event: IpcMainInvokeEvent,
|
|
projectId: string,
|
|
sessionId: string
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!projectId || typeof projectId !== 'string') {
|
|
return { success: false, error: 'Project ID is required and must be a string' };
|
|
}
|
|
if (!sessionId || typeof sessionId !== 'string') {
|
|
return { success: false, error: 'Session ID is required and must be a string' };
|
|
}
|
|
|
|
configManager.pinSession(projectId, sessionId);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:pinSession:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:unpinSession' - Unpins a session for a project.
|
|
*/
|
|
async function handleUnpinSession(
|
|
_event: IpcMainInvokeEvent,
|
|
projectId: string,
|
|
sessionId: string
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!projectId || typeof projectId !== 'string') {
|
|
return { success: false, error: 'Project ID is required and must be a string' };
|
|
}
|
|
if (!sessionId || typeof sessionId !== 'string') {
|
|
return { success: false, error: 'Session ID is required and must be a string' };
|
|
}
|
|
|
|
configManager.unpinSession(projectId, sessionId);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:unpinSession:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:openInEditor' - Opens the config JSON file in an external editor.
|
|
* Tries editors in order: $VISUAL, $EDITOR, cursor, code, then falls back to system open.
|
|
*/
|
|
async function handleOpenInEditor(_event: IpcMainInvokeEvent): Promise<IpcResult> {
|
|
try {
|
|
const configPath = configManager.getConfigPath();
|
|
|
|
// Try editors in priority order
|
|
const editors: string[] = [];
|
|
if (process.env.VISUAL) editors.push(process.env.VISUAL);
|
|
if (process.env.EDITOR) editors.push(process.env.EDITOR);
|
|
editors.push('cursor', 'code', 'subl', 'zed');
|
|
|
|
for (const editor of editors) {
|
|
try {
|
|
await launchExternalEditor(editor, configPath);
|
|
return { success: true };
|
|
} catch {
|
|
// Editor not found, try next
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Fallback: open with system default
|
|
const { shell } = await import('electron');
|
|
const errorMessage = await shell.openPath(configPath);
|
|
if (errorMessage) {
|
|
return { success: false, error: errorMessage };
|
|
}
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:openInEditor:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:addCustomProjectPath' - Persists a custom project path.
|
|
*/
|
|
async function handleAddCustomProjectPath(
|
|
_event: IpcMainInvokeEvent,
|
|
projectPath: string
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!projectPath || typeof projectPath !== 'string') {
|
|
return { success: false, error: 'Project path is required and must be a string' };
|
|
}
|
|
|
|
configManager.addCustomProjectPath(projectPath);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:addCustomProjectPath:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:removeCustomProjectPath' - Removes a custom project path.
|
|
*/
|
|
async function handleRemoveCustomProjectPath(
|
|
_event: IpcMainInvokeEvent,
|
|
projectPath: string
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!projectPath || typeof projectPath !== 'string') {
|
|
return { success: false, error: 'Project path is required and must be a string' };
|
|
}
|
|
|
|
configManager.removeCustomProjectPath(projectPath);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:removeCustomProjectPath:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:selectFolders' - Opens native folder selection dialog.
|
|
* Allows users to select one or more folders for trigger project scope.
|
|
*/
|
|
async function handleSelectFolders(_event: IpcMainInvokeEvent): Promise<IpcResult<string[]>> {
|
|
try {
|
|
// Get the focused window for proper dialog parenting
|
|
const focusedWindow = BrowserWindow.getFocusedWindow();
|
|
|
|
// dialog.showOpenDialog accepts either (options) or (window, options)
|
|
const dialogOptions: Electron.OpenDialogOptions = {
|
|
properties: ['openDirectory', 'multiSelections'],
|
|
title: 'Select Project Folders',
|
|
buttonLabel: 'Select',
|
|
};
|
|
|
|
const result = focusedWindow
|
|
? await dialog.showOpenDialog(focusedWindow, dialogOptions)
|
|
: await dialog.showOpenDialog(dialogOptions);
|
|
|
|
if (result.canceled) {
|
|
return { success: true, data: [] };
|
|
}
|
|
|
|
return { success: true, data: result.filePaths };
|
|
} catch (error) {
|
|
logger.error('Error in config:selectFolders:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to open folder dialog',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:selectClaudeRootFolder' - Opens native folder picker for Claude root.
|
|
*/
|
|
async function handleSelectClaudeRootFolder(
|
|
_event: IpcMainInvokeEvent
|
|
): Promise<IpcResult<ClaudeRootFolderSelection | null>> {
|
|
try {
|
|
const focusedWindow = BrowserWindow.getFocusedWindow();
|
|
const currentRootPath = getClaudeBasePath();
|
|
const dialogOptions: Electron.OpenDialogOptions = {
|
|
properties: ['openDirectory'],
|
|
title: 'Select Claude Root Folder',
|
|
buttonLabel: 'Select Folder',
|
|
defaultPath: currentRootPath,
|
|
};
|
|
|
|
const result = focusedWindow
|
|
? await dialog.showOpenDialog(focusedWindow, dialogOptions)
|
|
: await dialog.showOpenDialog(dialogOptions);
|
|
|
|
if (result.canceled || result.filePaths.length === 0) {
|
|
return { success: true, data: null };
|
|
}
|
|
|
|
const selectedPath = path.resolve(path.normalize(result.filePaths[0]));
|
|
const folderName = path.basename(selectedPath);
|
|
const projectsDir = path.join(selectedPath, 'projects');
|
|
const hasProjectsDir = (() => {
|
|
try {
|
|
return fs.existsSync(projectsDir) && fs.statSync(projectsDir).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
path: selectedPath,
|
|
isClaudeDirName: folderName === '.claude',
|
|
hasProjectsDir,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
logger.error('Error in config:selectClaudeRootFolder:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to open Claude root folder dialog',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:getClaudeRootInfo' - Returns default/custom/effective local Claude root paths.
|
|
*/
|
|
async function handleGetClaudeRootInfo(
|
|
_event: IpcMainInvokeEvent
|
|
): Promise<IpcResult<ClaudeRootInfo>> {
|
|
try {
|
|
const customPath = configManager.getConfig().general.claudeRootPath;
|
|
const defaultPath = getAutoDetectedClaudeBasePath();
|
|
const resolvedPath = getClaudeBasePath();
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
defaultPath,
|
|
resolvedPath,
|
|
customPath,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
logger.error('Error in config:getClaudeRootInfo:', error);
|
|
|
|
// Last-resort fallback to a best-effort auto-detected value.
|
|
const fallbackDefault = getAutoDetectedClaudeBasePath();
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
defaultPath: fallbackDefault,
|
|
resolvedPath: fallbackDefault,
|
|
customPath: null,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
function normalizeWslHomePath(home: string): string | null {
|
|
const trimmed = home.trim();
|
|
if (!trimmed.startsWith('/')) {
|
|
return null;
|
|
}
|
|
|
|
let normalized = path.posix.normalize(trimmed);
|
|
if (normalized.length > 1 && normalized.endsWith('/')) {
|
|
normalized = normalized.slice(0, -1);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function toWslUncPath(distro: string, posixPath: string): string {
|
|
const uncSuffix = posixPath.replace(/\//g, '\\');
|
|
return `\\\\wsl.localhost\\${distro}${uncSuffix}`;
|
|
}
|
|
|
|
function getWslExecutableCandidates(): string[] {
|
|
const candidates = new Set<string>();
|
|
|
|
const windir = process.env.WINDIR;
|
|
if (windir) {
|
|
candidates.add(path.join(windir, 'System32', 'wsl.exe'));
|
|
candidates.add(path.join(windir, 'Sysnative', 'wsl.exe'));
|
|
}
|
|
|
|
candidates.add('wsl.exe');
|
|
return Array.from(candidates);
|
|
}
|
|
|
|
function looksLikeUtf16Le(buffer: Buffer): boolean {
|
|
const sampleSize = Math.min(buffer.length, 512);
|
|
if (sampleSize < 2) {
|
|
return false;
|
|
}
|
|
|
|
let pairs = 0;
|
|
let nullsAtOddIndex = 0;
|
|
for (let i = 0; i + 1 < sampleSize; i += 2) {
|
|
pairs += 1;
|
|
if (buffer[i + 1] === 0) {
|
|
nullsAtOddIndex += 1;
|
|
}
|
|
}
|
|
|
|
return pairs > 0 && nullsAtOddIndex / pairs >= 0.3;
|
|
}
|
|
|
|
function decodeWslOutput(output: string | Buffer | undefined): string {
|
|
if (typeof output === 'string') {
|
|
return output.replace(/\0/g, '');
|
|
}
|
|
if (!output || output.length === 0) {
|
|
return '';
|
|
}
|
|
|
|
const hasUtf16LeBom = output.length >= 2 && output[0] === 0xff && output[1] === 0xfe;
|
|
const decoded =
|
|
hasUtf16LeBom || looksLikeUtf16Le(output)
|
|
? output.toString('utf16le')
|
|
: output.toString('utf8');
|
|
return decoded.replace(/\0/g, '');
|
|
}
|
|
|
|
async function runWsl(args: string[], timeout = 5000): Promise<{ stdout: string; stderr: string }> {
|
|
const candidates = getWslExecutableCandidates();
|
|
let lastError: unknown = null;
|
|
|
|
for (const executable of candidates) {
|
|
try {
|
|
const result = await execFileAsync(executable, args, {
|
|
timeout,
|
|
windowsHide: true,
|
|
maxBuffer: 1024 * 1024,
|
|
encoding: 'buffer',
|
|
});
|
|
return {
|
|
stdout: decodeWslOutput(result.stdout),
|
|
stderr: decodeWslOutput(result.stderr),
|
|
};
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
}
|
|
|
|
throw lastError instanceof Error ? lastError : new Error('Unable to execute wsl.exe');
|
|
}
|
|
|
|
function parseWslDistros(stdout: string): string[] {
|
|
const distros: string[] = [];
|
|
const seen = new Set<string>();
|
|
const lines = stdout.split(/\r?\n/);
|
|
|
|
for (const rawLine of lines) {
|
|
let line = rawLine.replace(/\0/g, '').trim();
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
|
|
line = line.replace(/^\*\s*/, '').trim();
|
|
line = stripDefaultSuffix(line);
|
|
|
|
const lower = line.toLowerCase();
|
|
if (
|
|
lower.startsWith('windows subsystem for linux') ||
|
|
lower.includes('default version') ||
|
|
lower.startsWith('the following is a list')
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const key = line.toLowerCase();
|
|
if (!seen.has(key)) {
|
|
seen.add(key);
|
|
distros.push(line);
|
|
}
|
|
}
|
|
|
|
return distros;
|
|
}
|
|
|
|
async function listWslDistros(): Promise<string[]> {
|
|
const commands: string[][] = [['--list', '--quiet'], ['-l', '-q'], ['-l']];
|
|
|
|
for (const command of commands) {
|
|
try {
|
|
const { stdout } = await runWsl(command, 4000);
|
|
const parsed = parseWslDistros(stdout);
|
|
if (parsed.length > 0) {
|
|
return parsed;
|
|
}
|
|
} catch {
|
|
// Try the next command variant.
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function stripDefaultSuffix(input: string): string {
|
|
const suffix = '(default)';
|
|
if (!input.toLowerCase().endsWith(suffix)) {
|
|
return input;
|
|
}
|
|
|
|
return input.slice(0, input.length - suffix.length).trimEnd();
|
|
}
|
|
|
|
async function resolveWslHome(distro: string): Promise<string | null> {
|
|
try {
|
|
const { stdout } = await runWsl(['-d', distro, '--', 'sh', '-lc', 'printf %s "$HOME"'], 5000);
|
|
return normalizeWslHomePath(stdout);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function resolveWslDefaultUser(distro: string): Promise<string | null> {
|
|
try {
|
|
const { stdout } = await runWsl(['-d', distro, '--', 'whoami'], 3000);
|
|
const user = stdout.trim();
|
|
return user && !user.includes('/') && !user.includes('\\') ? user : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:findWslClaudeRoots' - Find Windows UNC candidates for WSL Claude roots.
|
|
*/
|
|
async function handleFindWslClaudeRoots(
|
|
_event: IpcMainInvokeEvent
|
|
): Promise<IpcResult<WslClaudeRootCandidate[]>> {
|
|
try {
|
|
if (process.platform !== 'win32') {
|
|
return { success: true, data: [] };
|
|
}
|
|
|
|
const distros = await listWslDistros();
|
|
if (distros.length === 0) {
|
|
return { success: true, data: [] };
|
|
}
|
|
|
|
const candidates: WslClaudeRootCandidate[] = [];
|
|
const seen = new Set<string>();
|
|
for (const distro of distros) {
|
|
const resolvedHomePath = await resolveWslHome(distro);
|
|
// Fallback: query the default WSL user, then try Windows USERNAME
|
|
const wslUser = await resolveWslDefaultUser(distro);
|
|
const fallbackUser = wslUser || process.env.USERNAME;
|
|
const fallbackHomePath = fallbackUser
|
|
? fallbackUser === 'root'
|
|
? '/root'
|
|
: `/home/${fallbackUser}`
|
|
: null;
|
|
const normalizedHome =
|
|
normalizeWslHomePath(resolvedHomePath ?? '') ??
|
|
(fallbackHomePath ? normalizeWslHomePath(fallbackHomePath) : null);
|
|
|
|
if (!normalizedHome) {
|
|
continue;
|
|
}
|
|
|
|
const claudePosixPath = path.posix.join(normalizedHome, '.claude');
|
|
const claudeUncPath = toWslUncPath(distro, claudePosixPath);
|
|
const key = claudeUncPath.toLowerCase();
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
|
|
const projectsPath = path.join(claudeUncPath, 'projects');
|
|
const hasProjectsDir = (() => {
|
|
try {
|
|
return fs.existsSync(projectsPath) && fs.statSync(projectsPath).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
candidates.push({
|
|
distro,
|
|
path: claudeUncPath,
|
|
hasProjectsDir,
|
|
});
|
|
}
|
|
|
|
return { success: true, data: candidates };
|
|
} catch (error) {
|
|
logger.error('Error in config:findWslClaudeRoots:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to detect WSL Claude paths',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:hideSession' - Hides a session for a project.
|
|
*/
|
|
async function handleHideSession(
|
|
_event: IpcMainInvokeEvent,
|
|
projectId: string,
|
|
sessionId: string
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!projectId || typeof projectId !== 'string') {
|
|
return { success: false, error: 'Project ID is required and must be a string' };
|
|
}
|
|
if (!sessionId || typeof sessionId !== 'string') {
|
|
return { success: false, error: 'Session ID is required and must be a string' };
|
|
}
|
|
|
|
configManager.hideSession(projectId, sessionId);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:hideSession:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:unhideSession' - Unhides a session for a project.
|
|
*/
|
|
async function handleUnhideSession(
|
|
_event: IpcMainInvokeEvent,
|
|
projectId: string,
|
|
sessionId: string
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!projectId || typeof projectId !== 'string') {
|
|
return { success: false, error: 'Project ID is required and must be a string' };
|
|
}
|
|
if (!sessionId || typeof sessionId !== 'string') {
|
|
return { success: false, error: 'Session ID is required and must be a string' };
|
|
}
|
|
|
|
configManager.unhideSession(projectId, sessionId);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:unhideSession:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:hideSessions' - Bulk hide sessions for a project.
|
|
*/
|
|
async function handleHideSessions(
|
|
_event: IpcMainInvokeEvent,
|
|
projectId: string,
|
|
sessionIds: string[]
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!projectId || typeof projectId !== 'string') {
|
|
return { success: false, error: 'Project ID is required and must be a string' };
|
|
}
|
|
if (!Array.isArray(sessionIds) || sessionIds.some((id) => typeof id !== 'string')) {
|
|
return { success: false, error: 'Session IDs must be an array of strings' };
|
|
}
|
|
|
|
configManager.hideSessions(projectId, sessionIds);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:hideSessions:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:unhideSessions' - Bulk unhide sessions for a project.
|
|
*/
|
|
async function handleUnhideSessions(
|
|
_event: IpcMainInvokeEvent,
|
|
projectId: string,
|
|
sessionIds: string[]
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!projectId || typeof projectId !== 'string') {
|
|
return { success: false, error: 'Project ID is required and must be a string' };
|
|
}
|
|
if (!Array.isArray(sessionIds) || sessionIds.some((id) => typeof id !== 'string')) {
|
|
return { success: false, error: 'Session IDs must be an array of strings' };
|
|
}
|
|
|
|
configManager.unhideSessions(projectId, sessionIds);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:unhideSessions:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Cleanup
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Removes all config-related IPC handlers.
|
|
* Should be called when shutting down.
|
|
*/
|
|
export function removeConfigHandlers(ipcMain: IpcMain): void {
|
|
ipcMain.removeHandler('config:get');
|
|
ipcMain.removeHandler('config:update');
|
|
ipcMain.removeHandler('config:addIgnoreRegex');
|
|
ipcMain.removeHandler('config:removeIgnoreRegex');
|
|
ipcMain.removeHandler('config:addIgnoreRepository');
|
|
ipcMain.removeHandler('config:removeIgnoreRepository');
|
|
ipcMain.removeHandler('config:snooze');
|
|
ipcMain.removeHandler('config:clearSnooze');
|
|
ipcMain.removeHandler('config:addTrigger');
|
|
ipcMain.removeHandler('config:updateTrigger');
|
|
ipcMain.removeHandler('config:removeTrigger');
|
|
ipcMain.removeHandler('config:getTriggers');
|
|
ipcMain.removeHandler('config:testTrigger');
|
|
ipcMain.removeHandler('config:pinSession');
|
|
ipcMain.removeHandler('config:unpinSession');
|
|
ipcMain.removeHandler('config:hideSession');
|
|
ipcMain.removeHandler('config:unhideSession');
|
|
ipcMain.removeHandler('config:hideSessions');
|
|
ipcMain.removeHandler('config:unhideSessions');
|
|
ipcMain.removeHandler('config:addCustomProjectPath');
|
|
ipcMain.removeHandler('config:removeCustomProjectPath');
|
|
ipcMain.removeHandler('config:selectFolders');
|
|
ipcMain.removeHandler('config:selectClaudeRootFolder');
|
|
ipcMain.removeHandler('config:getClaudeRootInfo');
|
|
ipcMain.removeHandler('config:findWslClaudeRoots');
|
|
ipcMain.removeHandler('config:openInEditor');
|
|
logger.info('Config handlers removed');
|
|
}
|