d3ro-voice/apps/desktop/src/main/bootstrap.ts

544 lines
23 KiB
TypeScript

// 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<void>
}
export async function bootstrap(): Promise<void> {
// 종료 훅을 가장 먼저 건다 — 초기화 도중의 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<void> {
initLoggerService()
}
async function initConfig(): Promise<void> {
initConfigService()
}
async function initStartupDatabase(): Promise<void> {
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<void> {
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<void> {
initLicenseService()
}
async function createWindows(): Promise<void> {
createMainWindow()
}
async function initTray(): Promise<void> {
createTray()
}
async function initIpcHandlers(): Promise<void> {
registerAllIpcHandlers()
}
async function initKeyBindings(): Promise<void> {
const keyBindings = getKeyBindingService()
keyBindings.loadFromConfig()
keyBindings.start()
}
/**
* 입력 인텔리전스 배선 — 텔레메트리 → 제안 → 오버레이.
*
* 두 서비스를 직접 서로 import 하지 않고 여기서 이벤트로 는다
* (서비스 간 결합을 늘리지 않으면서 교체 가능성을 남긴다).
*/
async function initInputIntelligence(): Promise<void> {
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<void> {
getCustomInstructionService().initialize()
}
async function initVoiceCommands(): Promise<void> {
const svc = getVoiceCommandService()
svc.initialize()
svc.initDefaultKeywords()
}
async function initSoundEffects(): Promise<void> {
getSoundEffectService().initialize()
}
async function initAutoLaunch(): Promise<void> {
getAutoLaunchService().syncWithConfig()
}
async function initAutoUpdate(): Promise<void> {
const { getUpdateService } = await import('./services/UpdateService')
const updates = getUpdateService()
updates.initialize()
getShutdownRegistry().register({ name: 'auto-update', dispose: () => updates.dispose() })
}
async function initPopupWindows(): Promise<void> {
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<Record<string, unknown>>)
},
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<Record<string, unknown>>, 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<void> {
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<string, unknown>): void {
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send(channel, data ?? {})
}
}
async function initLLMPolling(): Promise<void> {
await startLocalLLMAvailability()
}
/**
* 로컬 STT(sidecar + Whisper 모델)를 앱 시작 시 백그라운드로 미리 데운다.
* 첫 받아쓰기에서 모델 로딩(수초)을 기다리는 체감 지연을 없앤다.
* bootstrap을 막지 않도록 await하지 않는다 — 실패는 warmUpLocal이 흡수한다.
*/
async function initSTTWarmup(): Promise<void> {
try {
const { getSTTManager } = await import('./services/stt/STTManager')
void getSTTManager().warmUpLocal()
} catch (err) {
logger.warn('STT warmup scheduling failed:', err)
}
}
async function initCloudSync(): Promise<void> {
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<void> {
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<void> {
// 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')
})
}