544 lines
23 KiB
TypeScript
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')
|
|
})
|
|
}
|