feat(desktop): SaaS [3.5] 로컬 모드 = entry point (빅뱅 Phase 1.5)

비즈니스 모델 수정:
- 무료 로컬 모드 = 사용자 onboarding funnel 입구 (킬러 피처, 회원가입 0)
- 유료 클라우드 SaaS = 수익화 (OAuth 로그인 + pro + premium 모델 + sync)
- 이전 세션 SaaS [2] OAuth 강제 게이트 철회

db/index.ts:
- LOCAL_USER_ID = '_local' 상수 export
- openLocal() 헬퍼 — userData/users/_local/d3ro.db
- isLocalMode() 헬퍼

bootstrap.ts:
- database step 복원 (critical). openLocal() 호출로 앱 시작 시
  로컬 DB 자동 오픈 — 로그인 없이 즉시 메인 UI 진입 가능

CloudSyncService._onSignOut():
- closeCurrent() 후 openLocal() 호출 — 로그아웃 시 로컬 모드 DB로 복귀
- 앱은 계속 로컬 모드로 동작 (entry point 철학)

App.tsx:
- AuthGate 상태머신 제거 (loading/login-required/authenticated/legacy)
- LoginScreen import 제거, 항상 AppLayout 렌더
- LoginScreen.tsx 파일은 유지 (추후 Welcome 화면 재활용 가능)

검증:
- desktop tsc --noEmit 
- desktop build 
- dev 런타임: [bootstrap] local database opened → Main window shown
- users/_local/d3ro.db 파일 생성 확인
This commit is contained in:
윤찬 2026-04-11 10:08:35 +09:00
parent 816f527592
commit 1a01cea33c
5 changed files with 102 additions and 91 deletions

View file

@ -15,6 +15,7 @@ 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 {
createMainWindow,
getMainWindow,
@ -45,8 +46,10 @@ export async function bootstrap(): Promise<void> {
const steps: BootstrapStep[] = [
{ name: 'logger', critical: false, fn: initLogger },
{ name: 'config', critical: false, fn: initConfig },
// Phase 1 (SaaS 빅뱅): DB는 더 이상 bootstrap 타이밍에 열지 않는다.
// CloudSyncService._onAuthenticated(session)가 사용자별 DB를 연다.
// 빅뱅 Phase 1.5: 앱 시작 시 익명 로컬 DB를 먼저 연다.
// 무료 로컬 모드가 사용자 entry point — 회원가입 없이 바로 킬러 피처 사용 가능.
// CloudSyncService._onAuthenticated()에서 로그인 시점에 user DB로 스위치한다.
{ name: 'database', critical: true, fn: initLocalDatabase },
{ name: 'license', critical: false, fn: initLicense },
{ name: 'create-windows', critical: true, fn: createWindows },
{ name: 'tray', critical: false, fn: initTray },
@ -91,6 +94,11 @@ 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 initLicense(): Promise<void> {
initLicenseService()
}

View file

@ -21,6 +21,16 @@ const logger = getLogger('database')
// 모듈 상태
// ─────────────────────────────────────────────────────────────
/**
* () user id. OAuth
* . `userData/users/_local/d3ro.db` .
*
* :
* - `_local`: entry point ( 0, STT/LLM)
* - uuid: OAuth sync + premium ( SaaS )
*/
export const LOCAL_USER_ID = '_local'
let currentUserId: string | null = null
let sqlite: Database.Database | null = null
let db: BetterSQLite3Database<typeof schema> | null = null
@ -38,6 +48,11 @@ function getUserDbPath(userId: string): string {
return path.join(app.getPath('userData'), 'users', userId, 'd3ro.db')
}
/** 현재 모드가 익명 로컬 모드인지. */
export function isLocalMode(): boolean {
return currentUserId === LOCAL_USER_ID
}
/** 레거시(V1) 단일 DB 파일 경로. 한 번도 정식 서비스되지 않았으므로 발견 시 silent archive. */
function getLegacyDbPath(): string {
return path.join(app.getPath('userData'), 'd3ro-voice.db')
@ -338,6 +353,16 @@ export function openForUser(userId: string): OpenForUserResult {
return { created: !existed, dbPath }
}
/**
* DB . entry point.
* .
*
* `userData/users/_local/d3ro.db` . .
*/
export function openLocal(): OpenForUserResult {
return openForUser(LOCAL_USER_ID)
}
/**
* DB . no-op.
*/

View file

@ -14,7 +14,7 @@ import {
import { eq, gt } from 'drizzle-orm'
import { getLogger } from './LoggerService'
import { configGet, isSupabaseBuildTimeConfigured } from './ConfigService'
import { getDatabase, openForUser, closeCurrent } from '../db'
import { getDatabase, openForUser, openLocal, closeCurrent } from '../db'
import { history, dictionary, meetingSessions, meetingMemos, meetingDocuments } from '../db/schema'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
@ -219,12 +219,22 @@ class CloudSyncService extends EventEmitter {
this._session = null
this._clearStoredRefreshToken()
// 5) renderer에 auth-changed(null) emit — 게이트가 LoginScreen으로 복귀
this.emit('auth-changed', { user: null })
// 6) DB close — 여기 이후엔 getDatabase() throw
// 5) 사용자 DB 닫고 로컬 모드 DB로 복귀
// — 로그아웃 후에도 앱은 익명 로컬 모드로 계속 동작 (entry point 철학)
closeCurrent()
logger.info('Signed out and database closed')
try {
const { dbPath } = openLocal()
logger.info(`[signOut] Reverted to local DB: ${dbPath}`)
} catch (err) {
logger.error(
`[signOut] Local DB reopen failed: ${err instanceof Error ? err.message : String(err)}`
)
}
// 6) renderer에 auth-changed(null) emit — Settings는 다시 로그인 화면으로,
// 메인 UI는 로컬 모드로 계속 동작
this.emit('auth-changed', { user: null })
logger.info('Signed out — continuing in local mode')
}
// ── 상태 조회 ──────────────────────────────────────────

View file

@ -1,24 +1,20 @@
// src/renderer/App.tsx — 루트 컴포넌트
// 테마 시스템: auto(시스템) / dark / light. 기본은 auto → 다크.
// i18n: I18nProvider가 전체 트리를 감쌈. ConfigService에서 언어 로드.
//
// 인증 정책: 무료 로컬 모드가 사용자 entry point.
// 앱은 로그인 없이 바로 메인 UI에 진입해서 로컬 STT/LLM을 사용할 수 있다.
// OAuth 로그인은 Settings → Cloud Sync 섹션에서 선택적으로 수행 (유료 SaaS 경로).
import { useState, useEffect, useMemo, useRef } from 'react'
import { ThemeProvider, CssBaseline, useMediaQuery, Box, CircularProgress } from '@mui/material'
import { ThemeProvider, CssBaseline, useMediaQuery } from '@mui/material'
import { getTheme } from '@d3ro/ui/theme'
import { I18nProvider, type I18nStorage, type Locale } from '@d3ro/i18n'
import { AppLayout } from './components/AppLayout'
import { UpgradePromptModal } from './components/UpgradePromptModal'
import { LoginScreen } from './components/LoginScreen'
import { startSystemAudioCapture, stopSystemAudioCapture } from './utils/systemAudioCapture'
import type { ThemeMode, ConfigChangedEvent } from '@d3ro/core/types'
// CloudSync 게이트 상태
type AuthGateState =
| { status: 'loading' }
| { status: 'authenticated' }
| { status: 'login-required' }
| { status: 'legacy' } // 빌드 타임 SaaS 모드 아님 → 기존 동작 (선택적 클라우드)
// Electron ConfigService에 바인딩된 i18n 영속화 어댑터
const electronI18nStorage: I18nStorage = {
load: async () => {
@ -34,7 +30,6 @@ export function App(): React.ReactElement {
const [themeMode, setThemeMode] = useState<ThemeMode>('auto')
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')
const systemAudioCleanupRef = useRef<(() => void) | null>(null)
const [authGate, setAuthGate] = useState<AuthGateState>({ status: 'loading' })
// 설정에서 테마 로드 + 변경 감지
useEffect(() => {
@ -52,38 +47,6 @@ export function App(): React.ReactElement {
return unsub
}, [])
// SaaS 인증 게이트: 빌드 타임에 Supabase가 박힌 경우(=SaaS 모드)에만 활성화.
// 인증 안 된 상태면 LoginScreen 강제. 로그인 후 자동 진입.
// 빌드 타임 env가 없으면(legacy 개발자 모드) 게이트를 비활성화 — 기존 Settings UI 유지.
useEffect(() => {
let cancelled = false
void window.electronAPI.cloudSync.getState().then((r) => {
if (cancelled || !r.success) return
if (!r.data.saasMode) {
setAuthGate({ status: 'legacy' })
return
}
setAuthGate({
status: r.data.authenticated ? 'authenticated' : 'login-required'
})
})
const unsub = window.electronAPI.cloudSync.onAuthChanged((payload) => {
if (cancelled) return
// saasMode 여부는 변하지 않으므로 인증 상태만 토글.
setAuthGate((prev) => {
if (prev.status === 'legacy') return prev
return { status: payload.user ? 'authenticated' : 'login-required' }
})
})
return () => {
cancelled = true
unsub()
}
}, [])
// 시스템 오디오 캡처: 메인 프로세스의 시작/중지 요청에 응답
useEffect(() => {
const unsubStart = window.electronAPI.caption.onStartSystemAudio(() => {
@ -111,39 +74,12 @@ export function App(): React.ReactElement {
const theme = useMemo(() => getTheme(themeMode, prefersDark), [themeMode, prefersDark])
const gateContent = (() => {
if (authGate.status === 'loading') {
return (
<Box
sx={{
height: '100vh',
width: '100vw',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<CircularProgress size={32} />
</Box>
)
}
if (authGate.status === 'login-required') {
return <LoginScreen />
}
// 'authenticated' or 'legacy' → 메인 UI 렌더
return (
<>
<AppLayout />
<UpgradePromptModal />
</>
)
})()
return (
<I18nProvider storage={electronI18nStorage}>
<ThemeProvider theme={theme}>
<CssBaseline />
{gateContent}
<AppLayout />
<UpgradePromptModal />
</ThemeProvider>
</I18nProvider>
)