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:
yunchan8804 2026-04-08 14:04:41 +09:00
parent 3a160b9032
commit 45a580878a
178 changed files with 214 additions and 0 deletions

View file

@ -1,367 +0,0 @@
// 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()
})
}