refactor(desktop): session-scoped voice runtime, STT/LLM ports, caption ownership and meeting export fixes

This commit is contained in:
Yun Chan 2026-09-28 00:53:43 +09:00
parent 0adedf5e7b
commit 83cf9133ac
39 changed files with 4787 additions and 1061 deletions

View file

@ -9,7 +9,12 @@ import { getKeyBindingService, uiohookCodeToVk } from './services/KeyBindingServ
import { acquireGlobalInputHook } from './services/global-input-hook'
import { trackModifierKeys } from './services/modifier-state'
import { getVoiceModeService } from './services/VoiceModeService'
import { startLocalLLMAvailability, getLocalLLMService } from './services/LocalLLMService'
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'
@ -34,14 +39,16 @@ import {
hideCommandPopup,
sendKeyToCommandPopup,
isCommandPopupVisible,
hideRecordingTip,
updateRecordingTipState,
showSuggestionOverlay,
updateSuggestionOverlay,
hideSuggestionOverlay,
isSuggestionOverlayVisible,
} from './windows/WindowManager'
import { createTray } from './windows/TrayManager'
import { RecordingTipPresenter, createWindowRecordingTipView } from './windows/recording-tip-presenter'
import { PopupNavigationController } from './windows/popup-navigation'
import { setupLifecycle } from './lifecycle'
import { getShutdownRegistry } from './shutdown-registry'
import { decideSuggestionOverlayAction, shouldDismissOnEscape } from './suggestion-overlay-policy'
import { registerAllIpcHandlers } from './ipc'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
@ -56,6 +63,11 @@ interface BootstrapStep {
}
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 },
@ -102,6 +114,16 @@ export async function bootstrap(): Promise<void> {
}
}
/**
* 종료 시 정리할 자원. 등록의 역순으로 정리되며, 쓰지 않은 서비스는 새로 만들지 않는다.
* (프로세스를 소유하는 서비스를 새로 추가하면 여기나 그 서비스의 초기화 단계에서 등록한다)
*/
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()
}
@ -255,6 +277,11 @@ async function initInputIntelligence(): Promise<void> {
})
telemetry.start()
// 전역 입력 훅·제안 타이머는 종료 시 풀어야 한다
const registry = getShutdownRegistry()
registry.register({ name: 'suggestion', dispose: () => suggestion.dispose() })
registry.register({ name: 'input-telemetry', dispose: () => telemetry.dispose() })
}
/** 메인 도우로만 이벤트 전송 (팝업은 자체 내부 채널을 다) */
@ -285,107 +312,111 @@ async function initAutoLaunch(): Promise<void> {
async function initAutoUpdate(): Promise<void> {
const { getUpdateService } = await import('./services/UpdateService')
getUpdateService().initialize()
const updates = getUpdateService()
updates.initialize()
getShutdownRegistry().register({ name: 'auto-update', dispose: () => updates.dispose() })
}
async function initPopupWindows(): Promise<void> {
preloadPopupWindows()
setupHistoryPopupIPC()
// 오디오 디바이스 미리 캐싱 (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') {
toggleHistoryPopup()
popups.toggle('history')
} else if (payload.actionId === 'command-popup') {
toggleCommandPopup()
popups.toggle('command')
}
})
setupCommandPopupIPC()
setupPopupIPC(popups)
}
function toggleHistoryPopup(): void {
if (isHistoryPopupVisible()) {
hideHistoryPopup()
unregisterPopupNavKeys()
return
}
const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries
showHistoryPopup(entries as unknown as Array<Record<string, unknown>>)
registerPopupNavKeys()
/** 팝업 컨트롤러를 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),
})
}
function toggleCommandPopup(): void {
if (isCommandPopupVisible()) {
hideCommandPopup()
unregisterPopupNavKeys()
return
}
const instructions = getCustomInstructionService().getAll()
const activeId = configGet('activeInstructionId') as string | null
showCommandPopup(instructions as unknown as Array<Record<string, unknown>>, activeId || null)
registerPopupNavKeys('command')
}
// 히스토리 팝업 키 네비게이션 등록/해제
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<void> {
const voiceMode = getVoiceModeService()
voiceMode.connectKeyBindings()
const soundEffect = getSoundEffectService()
// 효과음 연동 (팝업 제어는 VoiceModeService 내부에서 처리)
// 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, failedSession) => {
voiceMode.emit('error', { error, session: failedSession })
(error) => {
voiceMode.emit('error', { error, session })
},
session,
finalText,
@ -398,13 +429,13 @@ async function initVoiceMode(): Promise<void> {
if (reason !== 'too-short') {
soundEffect.play('cancel')
}
hideRecordingTip()
tip.onSessionEnded()
})
voiceMode.on('error', ({ error }) => {
soundEffect.play('error')
updateRecordingTipState('error', { errorMessage: error.message })
setTimeout(() => hideRecordingTip(), 3000)
voiceMode.on('error', ({ error, severity }) => {
// 'warning'(예: Ollama 미가용 → 원문 삽입)은 세션을 막지 않는 알림이다 — 에러음·에러 팁 없음
if (severity !== 'warning') soundEffect.play('error')
tip.onError(error.message, severity)
})
}
@ -447,7 +478,7 @@ async function initMeetingSummaryWiring(): Promise<void> {
const captionService = getCaptionService()
const summaryService = getMeetingSummaryService()
captionService.on('session-saved', (summary: { sessionId: string }) => {
captionService.on('session-saved', (summary) => {
// 회의 모드가 CaptionService를 사용 중이면 MeetingSummary 자동 생성 건너뛰기
import('./services/MeetingModeService').then(({ getMeetingModeService }) => {
if (getMeetingModeService().isMeetingModeActive()) {
@ -478,62 +509,31 @@ async function initMeetingMode(): Promise<void> {
getMeetingDocTemplateService()
}
// ── HistoryPopup IPC 연동 ────────────────────────────
// ── 팝업 IPC 연동 (얇은 어댑터 — 정책은 PopupNavigationController) ─────
function setupHistoryPopupIPC(): void {
// 히스토리 팝업 열기 (핫키에서 호출)
function setupPopupIPC(popups: PopupNavigationController): void {
// 히스토리 팝업 열기 (핫키 경로와 같게 내비게이션 키까지 묶는다)
ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.SHOW_POPUP, () => {
const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries
showHistoryPopup(entries as unknown as Array<Record<string, unknown>>)
popups.show('history')
})
// 아이템 선택 → 텍스트 삽입
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)
popups.selectHistoryItem(data.text)
})
// 팝업 닫기
ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.POPUP_DISMISSED, () => {
hideHistoryPopup()
unregisterPopupNavKeys()
popups.dismiss('history')
})
}
// ── 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 })
popups.selectCommand({ id: data.id, name: data.name })
})
// 팝업 닫기
ipcMainRef.on(IPC_CHANNELS.POPUP_COMMAND.DISMISSED, () => {
hideCommandPopup()
unregisterPopupNavKeys()
popups.dismiss('command')
})
}