fix(desktop): harden session, meeting, caption and LLM lifecycles; route LLM calls through the gateway

This commit is contained in:
Yun Chan 2026-09-28 02:16:15 +09:00
parent 3a46437f28
commit ddc78546f0
62 changed files with 4786 additions and 648 deletions

View file

@ -4,7 +4,7 @@ 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 } from './services/ConfigService'
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'
@ -26,7 +26,8 @@ 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 { openForUser, openLocal } from './db'
import { openStartupDatabase } from './db/startup-database'
import {
createMainWindow,
getMainWindow,
@ -39,17 +40,16 @@ import {
hideCommandPopup,
sendKeyToCommandPopup,
isCommandPopupVisible,
showSuggestionOverlay,
updateSuggestionOverlay,
hideSuggestionOverlay,
isSuggestionOverlayVisible,
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 { decideSuggestionOverlayAction, shouldDismissOnEscape } from './suggestion-overlay-policy'
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'
@ -71,13 +71,15 @@ export async function bootstrap(): Promise<void> {
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 },
// 빅뱅 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 },
@ -132,9 +134,35 @@ async function initConfig(): Promise<void> {
initConfigService()
}
async function initLocalDatabase(): Promise<void> {
const { created, dbPath } = openLocal()
logger.info(`[bootstrap] local database opened: ${dbPath} (created=${created})`)
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> {
@ -177,56 +205,19 @@ async function initInputIntelligence(): Promise<void> {
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) => {
// 오버레이/윈도우 호출이 예외를 던지면 서비스의 진행 플래그가 굳을 수 있다.
// 이벤트 경로는 어떤 경우에도 조용히 삼킨다 (UI 문제로 기능 전체가 멈추면 안 된다).
try {
const hasSomethingToShow =
state.candidates.length > 0 || state.generating || state.warmingUp || !!state.partialText
const shouldPresent = hasSomethingToShow && !!state.anchor
telemetry.setSuggestionPresentationActive(shouldPresent)
const action = decideSuggestionOverlayAction(isSuggestionOverlayVisible(), shouldPresent)
if (action === 'hide') {
hideSuggestionOverlay()
} else if (action === 'update') {
// 이미 떠 있으면 내용만 갱신한다 — setBounds/present 를 매 스트리밍 청크마다
// 다시 부르면 X 클릭이 재present 에 가로채이고 패널이 튄다(실측).
updateSuggestionOverlay({
candidates: state.candidates,
activeIndex: state.activeIndex,
targetTotal: state.targetTotal,
generating: state.generating,
warmingUp: state.warmingUp,
partialText: state.partialText,
provenance: state.provenance
})
} else {
showSuggestionOverlay({
candidates: state.candidates,
activeIndex: state.activeIndex,
targetTotal: state.targetTotal,
generating: state.generating,
warmingUp: state.warmingUp,
partialText: state.partialText,
anchor: state.anchor,
anchorKind: state.anchorKind,
appName: state.appName,
provenance: state.provenance
})
}
} catch (error) {
logger.warn(`제안 오버레이 표시 실패: ${error instanceof Error ? error.message : String(error)}`)
}
overlay.onUpdated(state)
sendToMainWindow(IPC_CHANNELS.SUGGESTION.UPDATED, state)
})
suggestion.on('cleared', (payload) => {
telemetry.setSuggestionPresentationActive(false)
try {
hideSuggestionOverlay()
} catch (error) {
logger.warn(`제안 오버레이 숨기기 실패: ${error instanceof Error ? error.message : String(error)}`)
}
overlay.onCleared()
sendToMainWindow(IPC_CHANNELS.SUGGESTION.CLEARED, payload)
})
suggestion.on('state-changed', (state) =>
@ -469,6 +460,14 @@ 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> {
@ -503,7 +502,13 @@ async function initMeetingSummaryWiring(): Promise<void> {
async function initMeetingMode(): Promise<void> {
// MeetingModeService 싱글톤 초기화 (첫 접근 시 인스턴스 생성)
const { getMeetingModeService } = await import('./services/MeetingModeService')
getMeetingModeService()
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()