// src/main/bootstrap.ts — 초기화 시퀀스 import { app, dialog, globalShortcut, ipcMain as ipcMainRef } from 'electron' import { uIOhook, type UiohookKeyboardEvent } from 'uiohook-napi' import { VK } from '@d3ro/core/keybinding' import { initLoggerService, getLogger } from './services/LoggerService' import { initConfigService, configGet, configSet, onConfigChanged } from './services/ConfigService' import { getKeyBindingService, uiohookCodeToVk } from './services/KeyBindingService' import { acquireGlobalInputHook } from './services/global-input-hook' import { trackModifierKeys } from './services/modifier-state' import { getVoiceModeService } from './services/VoiceModeService' import { startLocalLLMAvailability, getLocalLLMService, disposeLocalLLMService, } from './services/LocalLLMService' import { disposeLocalSTTServiceIfStarted } from './services/LocalSTTService' 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 { getInputTelemetryService } from './services/InputTelemetryService' import { getSuggestionService } from './services/SuggestionService' import { getSoundEffectService } from './services/SoundEffectService' import { getAutoLaunchService } from './services/AutoLaunchService' import { getAudioCaptureService } from './services/AudioCaptureService' import { initLicenseService } from './services/LicenseService' import { openForUser, openLocal } from './db' import { openStartupDatabase } from './db/startup-database' import { createMainWindow, getMainWindow, preloadPopupWindows, showHistoryPopup, hideHistoryPopup, sendKeyToHistoryPopup, isHistoryPopupVisible, showCommandPopup, hideCommandPopup, sendKeyToCommandPopup, isCommandPopupVisible, reapplyThemeToAllPopups, } from './windows/WindowManager' import { createTray } from './windows/TrayManager' import { RecordingTipPresenter, createWindowRecordingTipView } from './windows/recording-tip-presenter' import { SuggestionOverlayPresenter, createWindowSuggestionOverlayView } from './windows/suggestion-overlay-presenter' import { ConfigEffects } from './config-effects' import { PopupNavigationController } from './windows/popup-navigation' import { setupLifecycle } from './lifecycle' import { getShutdownRegistry } from './shutdown-registry' import { shouldDismissOnEscape } from './suggestion-overlay-policy' 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 { // 종료 훅을 가장 먼저 건다 — 초기화 도중의 app.quit()(치명 단계 실패 등)도 isQuitting 을 세우고 // 등록된 자원을 정리해야 한다. (index.ts 의 뒤늦은 호출은 멱등이라 아무것도 하지 않는다) setupLifecycle() registerCoreShutdownSteps() const steps: BootstrapStep[] = [ { name: 'logger', critical: false, fn: initLogger }, { name: 'config', critical: false, fn: initConfig }, // 빅뱅 Phase 1.5: 앱 시작 시 DB 를 먼저 연다. 로그인하지 않았으면 익명 로컬 DB(무료 로컬 모드 — // 회원가입 없이 바로 사용), 마지막으로 로그인한 계정이 있으면 그 계정 DB 를 바로 연다(세션 복원이 // 끝나기 전·오프라인에도 기록이 익명 DB 로 새지 않게). 복원이 확실히 실패하면 CloudSyncService 가 // 로컬 DB 로 되돌린다. { name: 'database', critical: true, fn: initStartupDatabase }, { name: 'license', critical: false, fn: initLicense }, { name: 'create-windows', critical: true, fn: createWindows }, { name: 'tray', critical: false, fn: initTray }, { name: 'config-effects', critical: false, fn: initConfigEffects }, { 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: 'key-bindings', critical: false, fn: initKeyBindings }, { name: 'input-intelligence', critical: false, fn: initInputIntelligence }, { 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 } } } } /** * 종료 시 정리할 자원. 등록의 역순으로 정리되며, 쓰지 않은 서비스는 새로 만들지 않는다. * (프로세스를 소유하는 서비스를 새로 추가하면 여기나 그 서비스의 초기화 단계에서 등록한다) */ function registerCoreShutdownSteps(): void { const registry = getShutdownRegistry() registry.register({ name: 'local-llm', dispose: () => disposeLocalLLMService() }) registry.register({ name: 'local-stt', dispose: () => disposeLocalSTTServiceIfStarted() }) } async function initLogger(): Promise { initLoggerService() } async function initConfig(): Promise { initConfigService() } async function initStartupDatabase(): Promise { const { getCloudSyncService } = await import('./services/CloudSyncService') const { created, dbPath, accountUserId } = openStartupDatabase({ storedAccountUserId: () => getCloudSyncService().peekStoredAccountUserId(), openForUser, openLocal, warn: (message) => logger.warn(`[bootstrap] ${message}`), }) logger.info( `[bootstrap] ${accountUserId ? 'account' : 'local'} database opened: ${dbPath} (created=${created})` ) } /** * 설정 키별 부수효과 — 어느 경로로 바뀌든(설정 화면의 범용 config:set, 전용 IPC, 원격 동기화) 한 번씩 적용한다. * 예전엔 전용 IPC 에만 붙어 있어 설정 화면에서 바꾼 자동 실행·테마가 OS·팝업에 반영되지 않았다. */ async function initConfigEffects(): Promise { const effects = new ConfigEffects({ subscribe: (listener) => onConfigChanged(listener), onError: (key, error) => logger.warn(`[config-effects] ${key} 적용 실패: ${error instanceof Error ? error.message : String(error)}`), }) // OS 로그인 항목 — setEnabled 가 같은 값을 다시 저장해도 값이 같으면 효과는 다시 돌지 않는다 effects.register('autoLaunch', (value) => getAutoLaunchService().setEnabled(value === true)) // 미리 로드된 팝업(녹음 팁·기록·명령·제안·자막)의 테마 CSS effects.register('theme', () => reapplyThemeToAllPopups()) effects.start() getShutdownRegistry().register({ name: 'config-effects', dispose: () => effects.dispose() }) } async function initLicense(): Promise { initLicenseService() } async function createWindows(): Promise { createMainWindow() } async function initTray(): Promise { createTray() } async function initIpcHandlers(): Promise { registerAllIpcHandlers() } async function initKeyBindings(): Promise { const keyBindings = getKeyBindingService() keyBindings.loadFromConfig() keyBindings.start() } /** * 입력 인텔리전스 배선 — 텔레메트리 → 제안 → 오버레이. * * 두 서비스를 직접 서로 import 하지 않고 여기서 이벤트로 는다 * (서비스 간 결합을 늘리지 않으면서 교체 가능성을 남긴다). */ async function initInputIntelligence(): Promise { const telemetry = getInputTelemetryService() const suggestion = getSuggestionService() telemetry.on('typed-context', (context) => suggestion.handleTypingContext(context)) telemetry.on('state-changed', (state) => sendToMainWindow(IPC_CHANNELS.INPUT_TELEMETRY.STATE_CHANGED, state) ) telemetry.on('activity', (payload) => sendToMainWindow(IPC_CHANNELS.INPUT_TELEMETRY.ACTIVITY, payload) ) // 오버레이 표시 정책은 프레젠터 한 곳 — 여기서는 조립과 메인 창 중계만 한다. // (프레젠터는 오버레이 예외를 삼키므로 메인 창 중계는 항상 실행된다) const overlay = new SuggestionOverlayPresenter({ view: createWindowSuggestionOverlayView(), setPresentationActive: (active) => telemetry.setSuggestionPresentationActive(active), warn: (message) => logger.warn(message), }) suggestion.on('updated', (state) => { overlay.onUpdated(state) sendToMainWindow(IPC_CHANNELS.SUGGESTION.UPDATED, state) }) suggestion.on('cleared', (payload) => { overlay.onCleared() sendToMainWindow(IPC_CHANNELS.SUGGESTION.CLEARED, payload) }) suggestion.on('state-changed', (state) => sendToMainWindow(IPC_CHANNELS.SUGGESTION.STATE_CHANGED, state) ) // 제안 수락/순환/닫기는 전역 키바인딩으로만 들어온다 — 오버레이는 포커스를 갖지 않는다. getKeyBindingService().on('triggered', (payload) => { if (payload.type !== 'pressed') return if (payload.actionId === 'suggestion-accept') void suggestion.accept() else if (payload.actionId === 'suggestion-next') suggestion.next() else if (payload.actionId === 'suggestion-prev') suggestion.previous() else if (payload.actionId === 'suggestion-dismiss') suggestion.dismiss('dismissed') }) // 평범한 Esc(수정자 없음)로 제안을 닫는다 — 등록된 바인딩이 아니라 오버레이가 떠 // 있을 때만 반응하는 전용 키다. Escape 는 단독 바인딩이 불가하므로(requiresModifier) // KeyBindingService 의 등록 바인딩 경로로는 절대 들어오지 않는다 — 원본 keydown을 // 직접 듣는다(InputTelemetryService/KeyBindingService 가 이미 쓰는 것과 같은 패턴). acquireGlobalInputHook() // 단축키로 수락할 때 Ctrl/Alt 를 뗄 때까지 기다리려면 누른 순간부터 추적해야 한다. trackModifierKeys() uIOhook.on('keydown', (event: UiohookKeyboardEvent) => { if (uiohookCodeToVk(event.keycode) !== VK.Escape) return const shouldDismiss = shouldDismissOnEscape(suggestion.isPresentationActive, { ctrl: event.ctrlKey === true, alt: event.altKey === true, shift: event.shiftKey === true, meta: event.metaKey === true }) if (shouldDismiss) suggestion.dismiss('dismissed') }) // Chromium(Electron)은 접근성 지원이 감지될 때만 AX 트리를 만든다. 켜지 않으면 // 우리 앱 자신의 입력창은 UIA 로 읽히지 않아 D3RO 안에서 타이핑할 때 제안이 죽는다. if (configGet('inputTelemetryEnabled') || configGet('suggestionEnabled')) { app.setAccessibilitySupportEnabled(true) logger.info('[bootstrap] accessibility tree enabled for UIA context capture') } // 제안이 켜져 있으면 켜진 시점부터 모델을 미리 올려 둔다 — 토글 시에만 워밍업하면 // 앱을 새로 켤 때마다 첫 제안이 콜드 로딩으로 타임아웃된다. if (configGet('suggestionEnabled')) void suggestion.warmUp() // Ollama 서버가 나중에 뜨는 경우(느린 부팅 등) 가용해지는 시점에도 워밍업한다. getLocalLLMService().on('availability-changed', ({ available }) => { if (available && configGet('suggestionEnabled')) void suggestion.warmUp() }) telemetry.start() // 전역 입력 훅·제안 타이머는 종료 시 풀어야 한다 const registry = getShutdownRegistry() registry.register({ name: 'suggestion', dispose: () => suggestion.dispose() }) registry.register({ name: 'input-telemetry', dispose: () => telemetry.dispose() }) } /** 메인 도우로만 이벤트 전송 (팝업은 자체 내부 채널을 다) */ function sendToMainWindow(channel: IPCChannel, payload: unknown): void { const win = getMainWindow() if (win && !win.isDestroyed() && !win.webContents.isDestroyed()) { win.webContents.send(channel, payload) } } 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') const updates = getUpdateService() updates.initialize() getShutdownRegistry().register({ name: 'auto-update', dispose: () => updates.dispose() }) } async function initPopupWindows(): Promise { preloadPopupWindows() // 오디오 디바이스 미리 캐싱 (Settings 열 때 즉시 반환) getAudioCaptureService().getDevices().catch(() => { /* 실패해도 무시 */ }) const popups = createPopupNavigationController() getShutdownRegistry().register({ name: 'popup-navigation', dispose: () => popups.dispose() }) // 팝업 토글도 재바인딩 가능한 액션이다 — 하드코딩 accelerator 대신 KeyBindingService를 구독한다. getKeyBindingService().on('triggered', (payload) => { if (payload.type !== 'pressed') return if (payload.actionId === 'history-popup') { popups.toggle('history') } else if (payload.actionId === 'command-popup') { popups.toggle('command') } }) setupPopupIPC(popups) } /** 팝업 컨트롤러를 Electron 창·전역 단축키·서비스 어댑터로 조립한다. */ function createPopupNavigationController(): PopupNavigationController { return new PopupNavigationController({ windows: { history: { show: () => { const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries showHistoryPopup(entries as unknown as Array>) }, hide: () => hideHistoryPopup(), isVisible: () => isHistoryPopupVisible(), sendKey: (key) => sendKeyToHistoryPopup(key), }, command: { show: () => { const instructions = getCustomInstructionService().getAll() const activeId = configGet('activeInstructionId') as string | null showCommandPopup(instructions as unknown as Array>, activeId || null) }, hide: () => hideCommandPopup(), isVisible: () => isCommandPopupVisible(), sendKey: (key) => sendKeyToCommandPopup(key), }, }, shortcuts: { register: (accelerator, callback) => globalShortcut.register(accelerator, callback), unregister: (accelerator) => globalShortcut.unregister(accelerator), }, insertText: async (text) => { await getTextInsertService().insertText(text) }, applyCommandSelection: ({ id, name }) => { if (id) { // 명령어 선택 → 활성 명령어로 설정 configSet('activeInstructionId', id) configSet('defaultLLMAction', 'custom') logger.info(`Active command set: ${name} (${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: id }) }, warn: (message) => logger.warn(message), }) } async function initVoiceMode(): Promise { const voiceMode = getVoiceModeService() voiceMode.connectKeyBindings() const soundEffect = getSoundEffectService() // RecordingTip·결과 팝업 표시는 프레젠터 한 곳이 담당한다 (취소 가능한 에러 숨김 타이머 하나) const tip = new RecordingTipPresenter(createWindowRecordingTipView()) getShutdownRegistry().register({ name: 'recording-tip', dispose: () => tip.dispose() }) getShutdownRegistry().register({ name: 'voice-mode', dispose: () => voiceMode.dispose() }) voiceMode.on('phase', ({ phase }) => tip.onPhase(phase)) voiceMode.on('audio-level', ({ level }) => tip.onAudioLevel(level)) voiceMode.on('partial-transcript', ({ text }) => tip.onPartialTranscript(text)) voiceMode.on('insert-failed', ({ text }) => tip.onInsertFailed(text)) // 효과음 연동 voiceMode.on('session-started', () => { tip.onSessionStarted() soundEffect.play('recording-start') }) voiceMode.on('session-completed', ({ session, finalText }) => { tip.onSessionEnded() soundEffect.play('recording-stop') persistCompletedVoiceSessionSafe( (error) => { voiceMode.emit('error', { error, session }) }, session, finalText, ) notifyRenderer(IPC_CHANNELS.APP.DATA_CHANGED, { type: 'session-completed' }) }) voiceMode.on('session-cancelled', ({ reason }) => { if (reason !== 'too-short') { soundEffect.play('cancel') } tip.onSessionEnded() }) voiceMode.on('error', ({ error, severity }) => { // 'warning'(예: Ollama 미가용 → 원문 삽입)은 세션을 막지 않는 알림이다 — 에러음·에러 팁 없음 if (severity !== 'warning') soundEffect.play('error') tip.onError(error.message, severity) }) } /** 메인 윈도우 렌더러에 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() // 지난 실행에서 'recording'/'processing' 으로 남은 회의(강제 종료·정전)를 닫는다. 세션 복원 뒤에 해야 // 바뀐 상태가 동기화 outbox 에 기록돼 다른 기기에서도 끝없는 녹음으로 보이지 않는다. try { const { getMeetingModeService } = await import('./services/MeetingModeService') getMeetingModeService().recoverInterruptedSessions() } catch (err) { logger.warn('Interrupted meeting recovery failed:', err) } } 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) => { // 회의 모드가 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') const { getCaptionService } = await import('./services/CaptionService') const meetingMode = getMeetingModeService() // 종료 정리는 등록의 역순이다 — 회의(전사·녹음 저장)가 자막보다 먼저, 둘 다 STT 사이드카보다 먼저 정리된다. // 예전엔 등록되지 않아, 녹음 중 종료(트레이 종료·업데이트 재시작)가 전사와 녹음을 모두 버리고 // 세션을 영원히 'recording' 으로 남겼다. getShutdownRegistry().register({ name: 'caption', dispose: () => getCaptionService().dispose() }) getShutdownRegistry().register({ name: 'meeting-mode', dispose: () => meetingMode.shutdown() }) // MeetingDocTemplateService 싱글톤 초기화 (빌트인 템플릿 보장) const { getMeetingDocTemplateService } = await import('./services/MeetingDocTemplateService') getMeetingDocTemplateService() } // ── 팝업 IPC 연동 (얇은 어댑터 — 정책은 PopupNavigationController) ───── function setupPopupIPC(popups: PopupNavigationController): void { // 히스토리 팝업 열기 (핫키 경로와 같게 내비게이션 키까지 묶는다) ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.SHOW_POPUP, () => { popups.show('history') }) // 아이템 선택 → 텍스트 삽입 ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.ITEM_SELECTED, (_event: Electron.IpcMainEvent, data: { text: string }) => { popups.selectHistoryItem(data.text) }) // 팝업 닫기 ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.POPUP_DISMISSED, () => { popups.dismiss('history') }) // 명령어 선택 → 활성 명령어로 설정 ipcMainRef.on(IPC_CHANNELS.POPUP_COMMAND.SELECTED, (_event: Electron.IpcMainEvent, data: { id: string; name: string }) => { popups.selectCommand({ id: data.id, name: data.name }) }) // 팝업 닫기 ipcMainRef.on(IPC_CHANNELS.POPUP_COMMAND.DISMISSED, () => { popups.dismiss('command') }) }