fix(desktop): harden session, meeting, caption and LLM lifecycles; route LLM calls through the gateway
This commit is contained in:
parent
3a46437f28
commit
ddc78546f0
62 changed files with 4786 additions and 648 deletions
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue