// src/main/bootstrap.ts — 초기화 시퀀스 import { app, dialog, globalShortcut, ipcMain as ipcMainRef } from 'electron' import { initLoggerService, getLogger } from './services/LoggerService' import { initConfigService, configGet, configSet } from './services/ConfigService' import { getHotkeyService } from './services/HotkeyService' import { getVoiceModeService } from './services/VoiceModeService' import { startLocalLLMAvailability } from './services/LocalLLMService' import { getHistoryService } from './services/HistoryService' import { persistCompletedVoiceSessionSafe } from './voice-session-persist' import { getTextInsertService } from './services/TextInsertService' import { getCustomInstructionService } from './services/CustomInstructionService' import { getVoiceCommandService } from './services/VoiceCommandService' import { getSoundEffectService } from './services/SoundEffectService' import { getAutoLaunchService } from './services/AutoLaunchService' import { getAudioCaptureService } from './services/AudioCaptureService' import { initLicenseService } from './services/LicenseService' import { openLocal } from './db' import { createMainWindow, getMainWindow, preloadPopupWindows, showHistoryPopup, hideHistoryPopup, sendKeyToHistoryPopup, isHistoryPopupVisible, showCommandPopup, hideCommandPopup, sendKeyToCommandPopup, isCommandPopupVisible, hideRecordingTip, updateRecordingTipState, } from './windows/WindowManager' import { createTray } from './windows/TrayManager' import { registerAllIpcHandlers } from './ipc' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import type { IPCChannel } from '@d3ro/core/ipc-channels' const logger = getLogger('bootstrap') interface BootstrapStep { name: string critical: boolean fn: () => Promise } export async function bootstrap(): Promise { const steps: BootstrapStep[] = [ { name: 'logger', critical: false, fn: initLogger }, { name: 'config', critical: false, fn: initConfig }, // 빅뱅 Phase 1.5: 앱 시작 시 익명 로컬 DB를 먼저 연다. // 무료 로컬 모드가 사용자 entry point — 회원가입 없이 바로 킬러 피처 사용 가능. // CloudSyncService._onAuthenticated()에서 로그인 시점에 user DB로 스위치한다. { name: 'database', critical: true, fn: initLocalDatabase }, { name: 'license', critical: false, fn: initLicense }, { name: 'create-windows', critical: true, fn: createWindows }, { name: 'tray', critical: false, fn: initTray }, { name: 'ipc-handlers', critical: true, fn: initIpcHandlers }, { name: 'custom-instructions', critical: false, fn: initCustomInstructions }, { name: 'voice-commands', critical: false, fn: initVoiceCommands }, { name: 'sound-effects', critical: false, fn: initSoundEffects }, { name: 'auto-launch', critical: false, fn: initAutoLaunch }, { name: 'popup-preload', critical: false, fn: initPopupWindows }, { name: 'hotkey', critical: false, fn: initHotkey }, { name: 'voice-mode', critical: false, fn: initVoiceMode }, { name: 'stt-warmup', critical: false, fn: initSTTWarmup }, { name: 'llm-polling', critical: false, fn: initLLMPolling }, { name: 'meeting-summary-wiring', critical: false, fn: initMeetingSummaryWiring }, { name: 'meeting-mode', critical: false, fn: initMeetingMode }, // cloud-sync는 가장 마지막에 — 세션 복원 성공 시 DB를 열고 auth-changed emit. { name: 'cloud-sync', critical: false, fn: initCloudSync }, { name: 'auto-update', critical: false, fn: initAutoUpdate }, ] for (const step of steps) { try { await step.fn() logger.info(`[bootstrap] ${step.name} initialized`) } catch (error) { logger.error(`[bootstrap] ${step.name} failed:`, error) if (step.critical) { dialog.showErrorBox( 'D3RO-VOICE 초기화 실패', `${step.name}: ${error instanceof Error ? error.message : String(error)}` ) app.quit() return } } } } async function initLogger(): Promise { initLoggerService() } async function initConfig(): Promise { initConfigService() } async function initLocalDatabase(): Promise { const { created, dbPath } = openLocal() logger.info(`[bootstrap] local database opened: ${dbPath} (created=${created})`) } async function initLicense(): Promise { initLicenseService() } async function createWindows(): Promise { createMainWindow() } async function initTray(): Promise { createTray() } async function initIpcHandlers(): Promise { registerAllIpcHandlers() } async function initHotkey(): Promise { const hotkey = getHotkeyService() hotkey.loadFromConfig() hotkey.start() } async function initCustomInstructions(): Promise { getCustomInstructionService().initialize() } async function initVoiceCommands(): Promise { const svc = getVoiceCommandService() svc.initialize() svc.initDefaultKeywords() } async function initSoundEffects(): Promise { getSoundEffectService().initialize() } async function initAutoLaunch(): Promise { getAutoLaunchService().syncWithConfig() } async function initAutoUpdate(): Promise { const { getUpdateService } = await import('./services/UpdateService') getUpdateService().initialize() } async function initPopupWindows(): Promise { preloadPopupWindows() setupHistoryPopupIPC() // 오디오 디바이스 미리 캐싱 (Settings 열 때 즉시 반환) getAudioCaptureService().getDevices().catch(() => { /* 실패해도 무시 */ }) // Ctrl+Shift+V → 히스토리 팝업 토글 globalShortcut.register('Ctrl+Shift+V', () => { if (isHistoryPopupVisible()) { hideHistoryPopup() unregisterPopupNavKeys() } else { const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries showHistoryPopup(entries as unknown as Array>) registerPopupNavKeys() } }) // Ctrl+Shift+C → 커맨드 선택 팝업 토글 globalShortcut.register('Ctrl+Shift+C', () => { if (isCommandPopupVisible()) { hideCommandPopup() unregisterPopupNavKeys() } else { const instructions = getCustomInstructionService().getAll() const activeId = configGet('activeInstructionId') as string | null showCommandPopup(instructions as unknown as Array>, activeId || null) registerPopupNavKeys('command') } }) setupCommandPopupIPC() } // 히스토리 팝업 키 네비게이션 등록/해제 const POPUP_NAV_KEYS: Array<{ accel: string; key: string }> = [ { accel: 'Up', key: 'ArrowUp' }, { accel: 'Down', key: 'ArrowDown' }, { accel: 'Return', key: 'Enter' }, { accel: 'Escape', key: 'Escape' }, { accel: '1', key: '1' }, { accel: '2', key: '2' }, { accel: '3', key: '3' }, { accel: '4', key: '4' }, { accel: '5', key: '5' }, { accel: '6', key: '6' }, { accel: '7', key: '7' }, { accel: '8', key: '8' }, { accel: '9', key: '9' }, ] function registerPopupNavKeys(target: 'history' | 'command' = 'history'): void { for (const { accel, key } of POPUP_NAV_KEYS) { try { globalShortcut.register(accel, () => { if (target === 'history' && isHistoryPopupVisible()) { sendKeyToHistoryPopup(key) } else if (target === 'command' && isCommandPopupVisible()) { sendKeyToCommandPopup(key) } }) } catch { // 일부 키는 globalShortcut으로 등록 불가할 수 있음 } } } function unregisterPopupNavKeys(): void { for (const { accel } of POPUP_NAV_KEYS) { try { globalShortcut.unregister(accel) } catch { // 이미 해제된 경우 무시 } } } async function initVoiceMode(): Promise { const voiceMode = getVoiceModeService() voiceMode.connectHotkey() const soundEffect = getSoundEffectService() // 효과음 연동 (팝업 제어는 VoiceModeService 내부에서 처리) voiceMode.on('session-started', () => { soundEffect.play('recording-start') }) voiceMode.on('session-completed', ({ session, finalText }) => { soundEffect.play('recording-stop') persistCompletedVoiceSessionSafe( (error, failedSession) => { voiceMode.emit('error', { error, session: failedSession }) }, session, finalText, ) notifyRenderer(IPC_CHANNELS.APP.DATA_CHANGED, { type: 'session-completed' }) }) voiceMode.on('session-cancelled', ({ reason }) => { if (reason !== 'too-short') { soundEffect.play('cancel') } hideRecordingTip() }) voiceMode.on('error', ({ error }) => { soundEffect.play('error') updateRecordingTipState('error', { errorMessage: error.message }) setTimeout(() => hideRecordingTip(), 3000) }) } /** 메인 윈도우 렌더러에 UI 갱신 이벤트 전송 */ function notifyRenderer(channel: IPCChannel, data?: Record): void { const win = getMainWindow() if (win && !win.isDestroyed()) { win.webContents.send(channel, data ?? {}) } } async function initLLMPolling(): Promise { await startLocalLLMAvailability() } /** * 로컬 STT(sidecar + Whisper 모델)를 앱 시작 시 백그라운드로 미리 데운다. * 첫 받아쓰기에서 모델 로딩(수초)을 기다리는 체감 지연을 없앤다. * bootstrap을 막지 않도록 await하지 않는다 — 실패는 warmUpLocal이 흡수한다. */ async function initSTTWarmup(): Promise { try { const { getSTTManager } = await import('./services/stt/STTManager') void getSTTManager().warmUpLocal() } catch (err) { logger.warn('STT warmup scheduling failed:', err) } } async function initCloudSync(): Promise { const { getCloudSyncService } = await import('./services/CloudSyncService') const sync = getCloudSyncService() await sync.init() } async function initMeetingSummaryWiring(): Promise { try { const { getCaptionService } = await import('./services/CaptionService') const { getMeetingSummaryService } = await import('./services/MeetingSummaryService') const captionService = getCaptionService() const summaryService = getMeetingSummaryService() captionService.on('session-saved', (summary: { sessionId: string }) => { // 회의 모드가 CaptionService를 사용 중이면 MeetingSummary 자동 생성 건너뛰기 import('./services/MeetingModeService').then(({ getMeetingModeService }) => { if (getMeetingModeService().isMeetingModeActive()) { logger.info('Skipping auto meeting summary: meeting mode active') return } summaryService.onCaptionSessionSaved(summary).catch((err) => { logger.warn('Auto meeting summary failed:', err) }) }).catch(() => { // MeetingModeService 로드 실패 시 기본 동작 수행 summaryService.onCaptionSessionSaved(summary).catch((err) => { logger.warn('Auto meeting summary failed:', err) }) }) }) } catch (err) { logger.warn('Meeting summary wiring failed:', err) } } async function initMeetingMode(): Promise { // MeetingModeService 싱글톤 초기화 (첫 접근 시 인스턴스 생성) const { getMeetingModeService } = await import('./services/MeetingModeService') getMeetingModeService() // MeetingDocTemplateService 싱글톤 초기화 (빌트인 템플릿 보장) const { getMeetingDocTemplateService } = await import('./services/MeetingDocTemplateService') getMeetingDocTemplateService() } // ── HistoryPopup IPC 연동 ──────────────────────────── function setupHistoryPopupIPC(): void { // 히스토리 팝업 열기 (핫키에서 호출) ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.SHOW_POPUP, () => { const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries showHistoryPopup(entries as unknown as Array>) }) // 아이템 선택 → 텍스트 삽입 ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.ITEM_SELECTED, (_event: Electron.IpcMainEvent, data: { text: string }) => { hideHistoryPopup() unregisterPopupNavKeys() setTimeout(async () => { try { await getTextInsertService().insertText(data.text) } catch (error) { logger.warn(`History popup insert failed: ${error instanceof Error ? error.message : String(error)}`) } }, 200) }) // 팝업 닫기 ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.POPUP_DISMISSED, () => { hideHistoryPopup() unregisterPopupNavKeys() }) } // ── CommandPopup IPC 연동 ──────────────────────────── function setupCommandPopupIPC(): void { // 명령어 선택 → 활성 명령어로 설정 ipcMainRef.on(IPC_CHANNELS.POPUP_COMMAND.SELECTED, (_event: Electron.IpcMainEvent, data: { id: string; name: string }) => { hideCommandPopup() unregisterPopupNavKeys() if (data.id) { // 명령어 선택 → 활성 명령어로 설정 configSet('activeInstructionId', data.id) configSet('defaultLLMAction', 'custom') logger.info(`Active command set: ${data.name} (${data.id})`) } else { // 선택 해제 → 명령어 없음 (원본 삽입) configSet('activeInstructionId', '') configSet('defaultLLMAction', 'none') logger.info('Active command cleared (none)') } // CMD 페이지 UI 갱신 알림 notifyRenderer(IPC_CHANNELS.APP.DATA_CHANGED, { type: 'command-changed', activeId: data.id }) }) // 팝업 닫기 ipcMainRef.on(IPC_CHANNELS.POPUP_COMMAND.DISMISSED, () => { hideCommandPopup() unregisterPopupNavKeys() }) }