feat(V2-1a): Monorepo 구조 전환 — apps/desktop으로 V1 이동
- npm workspaces 루트 (apps/*, packages/*) 세팅 - V1 전체를 apps/desktop/으로 git mv (src, resources, tests, sidecar, scripts, electron.vite.config.ts, electron-builder.yml, vitest.config.ts, tsconfig.node.json, tsconfig.web.json) - apps/desktop/package.json 신규 (name=@d3ro/desktop) - productName: 'd3ro-voice' 명시 — app.getName()을 고정하여 userData 경로 %APPDATA%\d3ro-voice\ 그대로 유지 (기존 DB/설정 연속성 보장) - 루트 package.json을 workspace 루트로 재구성, 공통 devDep만 유지 (typescript, eslint, prettier) - turbo.json, tsconfig.base.json 추가 (Turborepo 자체 설치는 별도 sub-phase) - memory/project_status.md 생성 (규칙 13) 검증: - npm run typecheck 통과 - npm run build 통과 (electron-vite main+preload+renderer) - npm run dev 실제 실행 → DB/핫키/Ollama 자동 실행 모두 정상
This commit is contained in:
parent
3a160b9032
commit
45a580878a
178 changed files with 214 additions and 0 deletions
367
apps/desktop/src/main/bootstrap.ts
Normal file
367
apps/desktop/src/main/bootstrap.ts
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
// src/main/bootstrap.ts — 초기화 시퀀스
|
||||
|
||||
import { join } from 'path'
|
||||
import { app, dialog, globalShortcut, ipcMain as ipcMainRef } from 'electron'
|
||||
import { initLoggerService, getLogger } from './services/LoggerService'
|
||||
import { initConfigService, configGet, configSet } from './services/ConfigService'
|
||||
import { getHotkeyService } from './services/HotkeyService'
|
||||
import { getVoiceModeService } from './services/VoiceModeService'
|
||||
import { getLocalLLMService } from './services/LocalLLMService'
|
||||
import { getHistoryService } from './services/HistoryService'
|
||||
import { getTextInsertService } from './services/TextInsertService'
|
||||
import { getCustomInstructionService } from './services/CustomInstructionService'
|
||||
import { getVoiceCommandService } from './services/VoiceCommandService'
|
||||
import { getSoundEffectService } from './services/SoundEffectService'
|
||||
import { getAutoLaunchService } from './services/AutoLaunchService'
|
||||
import { getAudioCaptureService } from './services/AudioCaptureService'
|
||||
import { initLicenseService } from './services/LicenseService'
|
||||
import { initDatabase } from './db'
|
||||
import {
|
||||
createMainWindow,
|
||||
getMainWindow,
|
||||
preloadPopupWindows,
|
||||
showHistoryPopup,
|
||||
hideHistoryPopup,
|
||||
sendKeyToHistoryPopup,
|
||||
isHistoryPopupVisible,
|
||||
showCommandPopup,
|
||||
hideCommandPopup,
|
||||
sendKeyToCommandPopup,
|
||||
isCommandPopupVisible,
|
||||
} from './windows/WindowManager'
|
||||
import { createTray } from './windows/TrayManager'
|
||||
import { registerAllIpcHandlers } from './ipc'
|
||||
|
||||
const logger = getLogger('bootstrap')
|
||||
|
||||
interface BootstrapStep {
|
||||
name: string
|
||||
critical: boolean
|
||||
fn: () => Promise<void>
|
||||
}
|
||||
|
||||
export async function bootstrap(): Promise<void> {
|
||||
const steps: BootstrapStep[] = [
|
||||
{ name: 'logger', critical: false, fn: initLogger },
|
||||
{ name: 'config', critical: false, fn: initConfig },
|
||||
{ name: 'database', critical: true, fn: initDB },
|
||||
{ name: 'license', critical: false, fn: initLicense },
|
||||
{ name: 'create-windows', critical: true, fn: createWindows },
|
||||
{ name: 'tray', critical: false, fn: initTray },
|
||||
{ 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: 'hotkey', critical: false, fn: initHotkey },
|
||||
{ name: 'voice-mode', critical: false, fn: initVoiceMode },
|
||||
{ name: 'llm-polling', critical: false, fn: initLLMPolling },
|
||||
{ name: 'meeting-summary-wiring', critical: false, fn: initMeetingSummaryWiring },
|
||||
{ name: 'meeting-mode', critical: false, fn: initMeetingMode },
|
||||
]
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function initLogger(): Promise<void> {
|
||||
initLoggerService()
|
||||
}
|
||||
|
||||
async function initConfig(): Promise<void> {
|
||||
initConfigService()
|
||||
}
|
||||
|
||||
async function initDB(): Promise<void> {
|
||||
initDatabase()
|
||||
}
|
||||
|
||||
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 initHotkey(): Promise<void> {
|
||||
const hotkey = getHotkeyService()
|
||||
hotkey.loadFromConfig()
|
||||
hotkey.start()
|
||||
}
|
||||
|
||||
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 initPopupWindows(): Promise<void> {
|
||||
preloadPopupWindows()
|
||||
setupHistoryPopupIPC()
|
||||
|
||||
// 오디오 디바이스 미리 캐싱 (Settings 열 때 즉시 반환)
|
||||
getAudioCaptureService().getDevices().catch(() => { /* 실패해도 무시 */ })
|
||||
|
||||
// Ctrl+Shift+V → 히스토리 팝업 토글
|
||||
globalShortcut.register('Ctrl+Shift+V', () => {
|
||||
if (isHistoryPopupVisible()) {
|
||||
hideHistoryPopup()
|
||||
unregisterPopupNavKeys()
|
||||
} else {
|
||||
const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries
|
||||
showHistoryPopup(entries as unknown as Array<Record<string, unknown>>)
|
||||
registerPopupNavKeys()
|
||||
}
|
||||
})
|
||||
|
||||
// Ctrl+Shift+C → 커맨드 선택 팝업 토글
|
||||
globalShortcut.register('Ctrl+Shift+C', () => {
|
||||
if (isCommandPopupVisible()) {
|
||||
hideCommandPopup()
|
||||
unregisterPopupNavKeys()
|
||||
} else {
|
||||
const instructions = getCustomInstructionService().getAll()
|
||||
const activeId = configGet('activeInstructionId' as keyof import('@shared/types').AppConfig) as unknown as string
|
||||
showCommandPopup(instructions as unknown as Array<Record<string, unknown>>, activeId || null)
|
||||
registerPopupNavKeys('command')
|
||||
}
|
||||
})
|
||||
|
||||
setupCommandPopupIPC()
|
||||
}
|
||||
|
||||
// 히스토리 팝업 키 네비게이션 등록/해제
|
||||
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.connectHotkey()
|
||||
|
||||
const soundEffect = getSoundEffectService()
|
||||
|
||||
// 효과음 연동 (팝업 제어는 VoiceModeService 내부에서 처리)
|
||||
voiceMode.on('session-started', () => {
|
||||
soundEffect.play('recording-start')
|
||||
})
|
||||
|
||||
voiceMode.on('session-completed', ({ session, finalText }) => {
|
||||
soundEffect.play('recording-stop')
|
||||
|
||||
// 이력 저장 (오디오 파일 경로 포함)
|
||||
try {
|
||||
const wordCount = finalText.split(/\s+/).filter((w) => w.length > 0).length
|
||||
const audioPath = join(app.getPath('userData'), 'recordings', `${session.id}.wav`)
|
||||
|
||||
getHistoryService().create({
|
||||
originalText: session.transcription || finalText,
|
||||
polishedText: session.processedText,
|
||||
mode: session.mode === 'hands-free' ? 'dictation' : session.mode,
|
||||
status: 'completed',
|
||||
duration: (Date.now() - session.startedAt) / 1000,
|
||||
wordCount,
|
||||
audioLocalPath: audioPath,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to save history: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
|
||||
// 렌더러 UI 갱신 알림 (Dashboard 통계 + History 목록)
|
||||
notifyRenderer('app:dataChanged', { type: 'session-completed' })
|
||||
})
|
||||
|
||||
voiceMode.on('session-cancelled', ({ reason }) => {
|
||||
if (reason !== 'too-short') {
|
||||
soundEffect.play('cancel')
|
||||
}
|
||||
hideRecordingTip()
|
||||
})
|
||||
|
||||
voiceMode.on('error', ({ error }) => {
|
||||
soundEffect.play('error')
|
||||
updateRecordingTipState('error', { errorMessage: error.message })
|
||||
setTimeout(() => hideRecordingTip(), 3000)
|
||||
})
|
||||
}
|
||||
|
||||
/** 메인 윈도우 렌더러에 UI 갱신 이벤트 전송 */
|
||||
function notifyRenderer(channel: string, data?: Record<string, unknown>): void {
|
||||
const win = getMainWindow()
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send(channel, data ?? {})
|
||||
}
|
||||
}
|
||||
|
||||
async function initLLMPolling(): Promise<void> {
|
||||
const llm = getLocalLLMService()
|
||||
// 설치되어 있는데 꺼져 있으면 자동 실행 (detached). 폴링이 준비 완료를 감지한다.
|
||||
await llm.ensureRunning()
|
||||
llm.startPolling()
|
||||
}
|
||||
|
||||
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: { sessionId: string }) => {
|
||||
// 회의 모드가 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')
|
||||
getMeetingModeService()
|
||||
// MeetingDocTemplateService 싱글톤 초기화 (빌트인 템플릿 보장)
|
||||
const { getMeetingDocTemplateService } = await import('./services/MeetingDocTemplateService')
|
||||
getMeetingDocTemplateService()
|
||||
}
|
||||
|
||||
// ── HistoryPopup IPC 연동 ────────────────────────────
|
||||
|
||||
function setupHistoryPopupIPC(): void {
|
||||
// 히스토리 팝업 열기 (핫키에서 호출)
|
||||
ipcMainRef.on('history:showPopup', () => {
|
||||
const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries
|
||||
showHistoryPopup(entries as unknown as Array<Record<string, unknown>>)
|
||||
})
|
||||
|
||||
// 아이템 선택 → 텍스트 삽입
|
||||
ipcMainRef.on('history:itemSelected', (_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)
|
||||
})
|
||||
|
||||
// 팝업 닫기
|
||||
ipcMainRef.on('history:popupDismissed', () => {
|
||||
hideHistoryPopup()
|
||||
unregisterPopupNavKeys()
|
||||
})
|
||||
}
|
||||
|
||||
// ── CommandPopup IPC 연동 ────────────────────────────
|
||||
|
||||
function setupCommandPopupIPC(): void {
|
||||
// 명령어 선택 → 활성 명령어로 설정
|
||||
ipcMainRef.on('command:selected', (_event: Electron.IpcMainEvent, data: { id: string; name: string }) => {
|
||||
hideCommandPopup()
|
||||
unregisterPopupNavKeys()
|
||||
|
||||
if (data.id) {
|
||||
// 명령어 선택 → 활성 명령어로 설정
|
||||
configSet('activeInstructionId' as keyof import('@shared/types').AppConfig, data.id as never)
|
||||
configSet('defaultLLMAction', 'custom')
|
||||
logger.info(`Active command set: ${data.name} (${data.id})`)
|
||||
} else {
|
||||
// 선택 해제 → 명령어 없음 (원본 삽입)
|
||||
configSet('activeInstructionId' as keyof import('@shared/types').AppConfig, '' as never)
|
||||
configSet('defaultLLMAction', 'none')
|
||||
logger.info('Active command cleared (none)')
|
||||
}
|
||||
|
||||
// CMD 페이지 UI 갱신 알림
|
||||
notifyRenderer('app:dataChanged', { type: 'command-changed', activeId: data.id })
|
||||
})
|
||||
|
||||
// 팝업 닫기
|
||||
ipcMainRef.on('command:dismissed', () => {
|
||||
hideCommandPopup()
|
||||
unregisterPopupNavKeys()
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue