d3ro-voice/apps/desktop/src/main/services/ConfigService.ts
윤찬 f1321df83c feat(desktop): SaaS [4] 온보딩 — Cloud Sync 티저 step + AppConfig 타입 정리 (빅뱅 Phase 2)
AppConfig 정식화:
- packages/core types.ts: onboardingCompleted: boolean 추가
- ConfigService CONFIG_DEFAULTS 동기화
- 타입 캐스팅 3곳 제거 (AppLayout/OnboardingModal/SettingsModal)

OnboardingModal 6 step 확장:
- 기존: 환영 → 마이크 → 핫키 → Ollama → 완료
- 신규: 환영 → 마이크 → 핫키 → Ollama → Cloud Sync(선택) → 완료
- saasMode === true 일 때만 Cloud Sync step 노출
- Step 내용: tagline + 3 benefit (sync/premium LLM/team share)
  + "설정에서 언제든지 로그인" 안내
- 온보딩 scope 안에서는 로그인 버튼 미노출 — 부담 제로 유지
  (Settings의 CloudSyncSection이 유일한 OAuth 진입점)

i18n:
- ko/en에 onboarding.cloud.* 6개 키 추가
- 나머지 10개 locale은 추후 일괄 (Phase 2 scope 밖)

의도적 제외:
- Supabase profiles.onboarded_at — OAuth provider 설정 전까진 의미 없음
- OAuth 후 환영 페이지 — Phase 4 티어 enforcement와 함께
- STT 모델 선택 — sidecar가 자동 다운로드
2026-04-11 10:16:06 +09:00

176 lines
4.9 KiB
TypeScript

// src/main/services/ConfigService.ts
// electron-store 기반 설정 관리. 설계서 02의 AppConfig 타입 사용.
import { EventEmitter } from 'events'
import type { AppConfig, ConfigChangedEvent } from '@d3ro/core/types'
import { getLogger } from './LoggerService'
const logger = getLogger('ConfigService')
// ============================================================
// SaaS 빌드 타임 주입 (electron.vite.config.ts의 define으로 박힘)
// ============================================================
// D3RO_SUPABASE_URL / D3RO_SUPABASE_ANON_KEY가 비어있지 않으면
// 데스크톱 사용자가 Settings에서 직접 입력할 필요 없이 SaaS 인스턴스에 자동 연결.
// 빈 문자열이면 기존 동작(사용자 입력) fallback.
const BUILD_TIME_SUPABASE_URL: string = process.env.D3RO_SUPABASE_URL ?? ''
const BUILD_TIME_SUPABASE_ANON_KEY: string = process.env.D3RO_SUPABASE_ANON_KEY ?? ''
export function isSupabaseBuildTimeConfigured(): boolean {
return BUILD_TIME_SUPABASE_URL.length > 0 && BUILD_TIME_SUPABASE_ANON_KEY.length > 0
}
export function getBuildTimeSupabaseUrl(): string {
return BUILD_TIME_SUPABASE_URL
}
export function getBuildTimeSupabaseAnonKey(): string {
return BUILD_TIME_SUPABASE_ANON_KEY
}
// electron-store v10은 ESM 전용이므로 동적 import 필요
interface ElectronStore<T extends Record<string, unknown>> {
get<K extends keyof T>(key: K): T[K]
set<K extends keyof T>(key: K, value: T[K]): void
store: T
}
const CONFIG_DEFAULTS: AppConfig = {
theme: 'auto',
language: 'ko',
closeToTray: true,
autoLaunch: false,
soundEnabled: true,
selectedDeviceId: null,
sttModelId: 'base',
sttLanguage: 'auto',
ttsVoiceId: null,
ttsSpeed: 1.0,
ollamaServerUrl: 'http://localhost:11434',
llmModelId: null,
defaultLLMAction: 'refine',
dictationShortcut: {
keyCode: 0xa5, // Right Alt
ctrl: false,
alt: false,
shift: false,
meta: false,
displayLabel: 'Right Alt'
},
handsFreeShortcut: {
keyCode: 0xa5,
ctrl: false,
alt: false,
shift: false,
meta: false,
displayLabel: 'Right Alt (double)'
},
commandShortcut: {
keyCode: 0xa5,
ctrl: true,
alt: false,
shift: false,
meta: false,
displayLabel: 'Ctrl + Right Alt'
},
captionShortcut: {
keyCode: 0xa5,
ctrl: true,
alt: false,
shift: true,
meta: false,
displayLabel: 'Ctrl + Shift + Right Alt'
},
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,
}
let store: ElectronStore<AppConfig> | null = null
const emitter = new EventEmitter()
export async function initConfigService(): Promise<void> {
const { default: Store } = await import('electron-store')
store = new Store<AppConfig>({
name: 'd3ro-voice-config',
defaults: CONFIG_DEFAULTS
})
logger.info('ConfigService initialized')
}
export function getConfigService(): ElectronStore<AppConfig> | null {
return store
}
export function configGet<K extends keyof AppConfig>(key: K): AppConfig[K] {
// SaaS 빌드 타임 주입은 사용자 설정보다 우선.
if (key === 'supabaseUrl' && BUILD_TIME_SUPABASE_URL.length > 0) {
return BUILD_TIME_SUPABASE_URL as AppConfig[K]
}
if (key === 'supabaseAnonKey' && BUILD_TIME_SUPABASE_ANON_KEY.length > 0) {
return BUILD_TIME_SUPABASE_ANON_KEY as AppConfig[K]
}
if (!store) {
logger.warn(`ConfigService not initialized, returning default for "${key}"`)
return CONFIG_DEFAULTS[key]
}
return store.get(key)
}
export function configSet<K extends keyof AppConfig>(key: K, value: AppConfig[K]): void {
// SaaS 빌드 타임 주입된 값은 변경 차단 (사용자가 잘못된 값으로 덮어쓰는 것 방지).
if (
(key === 'supabaseUrl' || key === 'supabaseAnonKey') &&
isSupabaseBuildTimeConfigured()
) {
logger.warn(`Refusing to override build-time SaaS config: "${key}"`)
return
}
if (!store) {
logger.warn(`ConfigService not initialized, cannot set "${key}"`)
return
}
const previousValue = store.get(key)
store.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)
}