// src/main/services/ConfigService.ts // electron-store 기반 설정 관리. 설계서 02의 AppConfig 타입 사용. import { EventEmitter } from 'events' import type { AppConfig, ConfigChangedEvent, KeyBinding, KeyBindingActionId, KeyBindingMap } from '@d3ro/core/types' import { bindingsEqual, createDefaultBindingMap, detectBindingConflicts, findActionSpec, kb, normalizeBinding, parseBindingMap, VK } from '@d3ro/core/keybinding' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { getLogger } from './LoggerService' const logger = getLogger('ConfigService') /** 제안/텔레메트리 튜닝 기본값의 현재 개정판. 기본값을 바꾸면 올린다. */ const SUGGESTION_TUNING_REVISION = 5 const INITIAL_SUGGESTION_TUNING = { suggestionTriggerDelayMs: 300, suggestionMinPrefixChars: 8, suggestionMaxRequestsPerMinute: 12, suggestionDailyBudget: 500 } as const /** 저장된 튜닝 값을 해당 개정판에서만 변경된 항목으로 올린다. */ function migrateSuggestionTuning(activeStore: ElectronStore): void { const raw = activeStore.store as unknown as Record const current = Number(raw.suggestionTuningRevision ?? 0) if (Number.isFinite(current) && current >= SUGGESTION_TUNING_REVISION) return if (current < 1) { for (const [key, value] of Object.entries(INITIAL_SUGGESTION_TUNING)) { activeStore.set(key as keyof AppConfig, value as AppConfig[keyof AppConfig]) } } if (current < 2) { const rawBindings = raw.keyBindings as unknown const bindings = parseBindingMap(rawBindings) const defaults = createDefaultBindingMap() const suggestionActions = ['suggestion-accept', 'suggestion-next', 'suggestion-prev', 'suggestion-dismiss'] as const for (const action of suggestionActions) { const spec = findActionSpec(action) if (!spec) continue bindings[action] = defaults[action] ?? spec.defaultBindings.map((binding) => ({ ...binding })) } activeStore.set('keyBindings', bindings) } if (current < 3) activeStore.set('suggestionRequestTimeoutMs', 8000) if (current < 4) { activeStore.set('suggestionTriggerDelayMs', 600) activeStore.set('suggestionMaxRequestsPerMinute', 6) } if (current < 5) migrateSuggestionOverlayBindings(activeStore) activeStore.set('suggestionTuningRevision', SUGGESTION_TUNING_REVISION) logger.info( `Migrated suggestion tuning values to revision ${SUGGESTION_TUNING_REVISION}` ) } /** revision 2 가 심어 둔 옛 기본값 — 사용자가 정확히 이 값 그대로일 때만 옮긴다. */ const OLD_SUGGESTION_ACCEPT_DEFAULT: readonly KeyBinding[] = [kb(VK.ArrowRight, { ctrl: true, alt: true })] const OLD_SUGGESTION_DISMISS_DEFAULT: readonly KeyBinding[] = [kb(VK.ArrowLeft, { ctrl: true, alt: true })] function bindingListEquals(a: readonly KeyBinding[], b: readonly KeyBinding[]): boolean { return a.length === b.length && a.every((binding, index) => bindingsEqual(binding, b[index])) } /** * 다음 문장 페이지 넘기기(최대 12개 순차 생성) 도입에 맞춰 제안 단축키를 옮긴다. * * - 수락은 화살표(→)에서 Enter 로 옮긴다 — 화살표를 페이지 이동에 내주기 위해서다. * - 닫기는 화살표(←)에서 Backspace 로 옮긴다(평범한 Esc 가 주 수단이 됐다). * - 사용자가 정확히 옛 기본값 그대로일 때만 옮긴다. 다른 키로 커스터마이즈했다면 * 절대 건드리지 않는다(설계 요구사항). * - 새 페이지 이동 액션(suggestion-page-next/prev)의 기본값은, 위 이관 뒤에도 다른 * 액션과 충돌하지 않을 때만 켠다. 충돌하면 바인딩 없이 두고 경고를 남긴다. */ function migrateSuggestionOverlayBindings(activeStore: ElectronStore): void { const raw = activeStore.store as unknown as Record const bindings = parseBindingMap(raw.keyBindings) const migrateIfOldDefault = (actionId: KeyBindingActionId, oldDefault: readonly KeyBinding[]): void => { const existing = bindings[actionId] ?? [] if (!bindingListEquals(existing, oldDefault)) return const spec = findActionSpec(actionId) if (spec) bindings[actionId] = spec.defaultBindings.map((binding) => ({ ...binding })) } migrateIfOldDefault('suggestion-accept', OLD_SUGGESTION_ACCEPT_DEFAULT) migrateIfOldDefault('suggestion-dismiss', OLD_SUGGESTION_DISMISS_DEFAULT) for (const actionId of ['suggestion-page-next', 'suggestion-page-prev'] as const) { const candidate = bindings[actionId] ?? [] const hasConflict = candidate.some( (binding) => detectBindingConflicts(actionId, binding, bindings).length > 0 ) if (hasConflict) { logger.warn( `${actionId} 기본 바인딩이 다른 액션과 충돌해 바인딩 없이 둔다 (사용자가 설정에서 직접 지정할 수 있다)` ) bindings[actionId] = [] } } activeStore.set('keyBindings', bindings) } // electron-store v10은 ESM 전용이므로 동적 import 필요 interface ElectronStore { get(key: K): T[K] set(key: K, value: T[K]): void delete(key: string): void store: T } const CONFIG_DEFAULTS: AppConfig = { theme: 'auto', language: 'ko', closeToTray: true, autoLaunch: false, soundEnabled: true, selectedDeviceId: null, /** 기본 STT 공급자는 로컬 Whisper (오프라인/무료) */ sttProvider: 'local' as const, sttProviderConfigs: { local: { modelId: 'large-v3-turbo' }, 'd3ro-cloud': { modelId: 'default', apiKey: '', baseUrl: 'http://127.0.0.1:5000' }, openai: { modelId: 'whisper-1', apiKey: '', baseUrl: 'https://api.openai.com/v1' }, groq: { modelId: 'whisper-large-v3-turbo', apiKey: '', baseUrl: 'https://api.groq.com/openai/v1' }, deepgram: { modelId: 'nova-3', apiKey: '', baseUrl: 'https://api.deepgram.com' }, assemblyai: { modelId: 'best', apiKey: '', baseUrl: 'https://api.assemblyai.com/v2' }, google: { modelId: 'gemini-2.0-flash', apiKey: '', baseUrl: 'https://generativelanguage.googleapis.com/v1beta' }, custom: { modelId: 'whisper-1', apiKey: '', baseUrl: 'http://127.0.0.1:8000/v1' }, }, sttFallbackToLocal: true, // large-v3 대비 6배 빠르고 정확도 손실 1~2%, 다운로드 1.6GB (온보딩에서 사전 다운로드) sttModelId: 'large-v3-turbo', sttLanguage: 'auto', ttsVoiceId: null, ttsSpeed: 1.0, onlineApiUrl: 'http://127.0.0.1:5000', localModelsDir: '', llmModelId: 'gemma4:e4b', ollamaServerUrl: 'http://127.0.0.1:11434', appUsageMode: null, authToken: null, userEmail: null, llmBackend: 'local' as const, // 라이브 음성 대화 백엔드 — 'realtime'은 로그인+구독 필요 (OpenAI Realtime WebRTC) conversationBackend: 'local' as const, defaultLLMAction: 'refine', keyBindings: createDefaultBindingMap(), hotkeyEnabled: true, insertMethod: 'clipboard', autoInsert: true, maxHistoryEntries: 1000, dictationEnabled: true, agentModeEnabled: false, handsFreeEnabled: false, screenContextEnabled: false, hfToken: '', diarizationEnabled: false, supabaseUrl: '', supabaseAnonKey: '', cloudSyncLastAt: null, onboardingCompleted: false, // Phase 6/10+: AppConfig 키 기본값 (WS2 SSOT 강화 대응). // as never 제거 후 configGet이 이 키들을 반환 — 기존 사용자 config(0.1.x)에 // 없고 CONFIG_DEFAULTS에도 없으면 undefined → .map() 등에서 main 크래시. // v0.2.0-alpha 핫픽스. customInstructions: [], llmChains: [], voiceCommandRules: [], voiceCommandsEnabled: false, activeInstructionId: '', activeChainId: null, captionAudioSource: 'mic', captionOverlayPosition: null, updateChannel: 'latest', updateDeviceId: '', skippedUpdateVersion: null, // 입력 인텔리전스 — 옵트인. 켜기 전까지 어떤 입력도 수집하지 않는다. inputTelemetryEnabled: false, inputTelemetryPaused: false, inputLearnTypedText: false, inputExcludedApps: [], suggestionEnabled: false, suggestionModelId: null, // 기본값 정본: packages/core/src/input-intelligence.ts SUGGESTION_DEFAULTS suggestionTriggerDelayMs: 600, suggestionMinPrefixChars: 8, suggestionMaxRequestsPerMinute: 6, suggestionDailyBudget: 500, suggestionOverlayInteractive: true, suggestionRequestTimeoutMs: 8000, suggestionTuningRevision: 5, } let store: ElectronStore | null = null const emitter = new EventEmitter() function createMemoryStore(initial: AppConfig): ElectronStore { let data: AppConfig = { ...initial } return { get(key: K): AppConfig[K] { return data[key] }, set(key: K, value: AppConfig[K]): void { data[key] = value }, delete(key: string): void { delete (data as unknown as Record)[key] }, get store(): AppConfig { return data }, set store(next: AppConfig) { data = { ...next } }, } } // ── 키바인딩 마이그레이션 (구 *Shortcut 4개 → keyBindings) ── /** 0.x 저장 형태. 구조·라벨 정본이 keybinding.ts 로 옮겨지기 전의 값이다. */ interface LegacyShortcut { keyCode: number ctrl: boolean alt: boolean shift: boolean meta: boolean } const LEGACY_SHORTCUT_ACTIONS: Readonly> = { dictationShortcut: 'dictation', handsFreeShortcut: 'hands-free', commandShortcut: 'command', captionShortcut: 'caption', } function isLegacyShortcut(value: unknown): value is LegacyShortcut { if (typeof value !== 'object' || value === null) return false const v = value as Record return ( typeof v.keyCode === 'number' && typeof v.ctrl === 'boolean' && typeof v.alt === 'boolean' && typeof v.shift === 'boolean' && typeof v.meta === 'boolean' ) } /** * 저장된 keyBindings 를 복원하고, 남아 있는 구 `*Shortcut` 값을 1회만 이관한다. * * 구 필드는 이관 직후 삭제하므로 다시 읽히지 않는다. * `displayLabel` 은 폐기한다 — 라벨은 formatBindingSegments() 로 파생시킨다. */ function migrateKeyBindings(activeStore: ElectronStore): void { const raw = activeStore.store as unknown as Record const restored: KeyBindingMap = parseBindingMap(raw.keyBindings) const legacyKeys = Object.keys(LEGACY_SHORTCUT_ACTIONS).filter((key) => isLegacyShortcut(raw[key]), ) if (legacyKeys.length === 0) { activeStore.set('keyBindings', restored) return } for (const key of legacyKeys) { const legacy = raw[key] as LegacyShortcut const actionId = LEGACY_SHORTCUT_ACTIONS[key] if (actionId === undefined) continue restored[actionId] = [ normalizeBinding({ device: 'keyboard', code: legacy.keyCode, ctrl: legacy.ctrl, alt: legacy.alt, shift: legacy.shift, meta: legacy.meta, }), ] } activeStore.set('keyBindings', restored) for (const key of legacyKeys) { activeStore.delete(key) } logger.info(`Migrated ${legacyKeys.length} legacy shortcut(s) to keyBindings`) } /** electron-store 없이 설정 CRUD를 가능하게 한다 (테스트 + 초기화 전 안전망). */ export function initInMemoryConfig(overrides?: Partial): void { store = createMemoryStore({ ...CONFIG_DEFAULTS, ...overrides }) } export function resetInMemoryConfig(): void { store = null } export async function initConfigService(): Promise { const { default: Store } = await import('electron-store') store = new Store({ name: 'd3ro-voice-config', defaults: CONFIG_DEFAULTS }) migrateKeyBindings(store) migrateSuggestionTuning(store) logger.info('ConfigService initialized') } export function getConfigService(): ElectronStore | null { return store } export function configGet(key: K): AppConfig[K] { if (!store) { logger.warn(`ConfigService not initialized, returning default for "${key}"`) return CONFIG_DEFAULTS[key] } return store.get(key) } export function configSet(key: K, value: AppConfig[K]): void { if (!store) { logger.warn(`ConfigService not initialized — using in-memory store for "${key}"`) initInMemoryConfig() } const activeStore = store if (!activeStore) { throw new D3ROError(ErrorCode.ConfigWriteFailed, 'Config store unavailable') } const previousValue = activeStore.get(key) activeStore.set(key, value) const event: ConfigChangedEvent = { key, value, previousValue } emitter.emit('config-changed', event) logger.debug(`Config changed: ${key}`) } export function configGetAll(): AppConfig { if (!store) return { ...CONFIG_DEFAULTS } return store.store } export function configReset(key?: keyof AppConfig): void { if (!store) return if (key) { store.set(key, CONFIG_DEFAULTS[key]) } else { store.store = { ...CONFIG_DEFAULTS } } } export function onConfigChanged(callback: (event: ConfigChangedEvent) => void): () => void { emitter.on('config-changed', callback) return () => emitter.off('config-changed', callback) }