/** * 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) | null = null; let onAgentLanguageUpdated: ((newLangCode: string) => Promise | 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 { return new Promise((resolve, reject) => { const resolvedEditor = resolveWindowsEditorCommand(editor); const launchOptions = { detached: true, stdio: 'ignore' as const, windowsHide: true, }; let child: ReturnType; 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; onAgentLanguageUpdated?: (newLangCode: string) => Promise | 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> { 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> { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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); 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 { 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> { 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 { 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 { 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 { 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 { 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 { 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> { 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> { 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> { 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(); 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(); 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 { 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 { 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 { 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> { 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(); 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 { 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 { 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 { 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 { 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'); }