From 1a01cea33c1a8dd0015e404f2ba7972e6b2faeb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=B0=AC?= Date: Sat, 11 Apr 2026 10:08:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20SaaS=20[3.5]=20=EB=A1=9C?= =?UTF-8?q?=EC=BB=AC=20=EB=AA=A8=EB=93=9C=20=3D=20entry=20point=20(?= =?UTF-8?q?=EB=B9=85=EB=B1=85=20Phase=201.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 비즈니스 모델 수정: - 무료 로컬 모드 = 사용자 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 파일 생성 확인 --- apps/desktop/src/main/bootstrap.ts | 12 ++- apps/desktop/src/main/db/index.ts | 25 ++++++ .../src/main/services/CloudSyncService.ts | 22 ++++-- apps/desktop/src/renderer/App.tsx | 78 ++----------------- memory/project_status.md | 56 ++++++++++--- 5 files changed, 102 insertions(+), 91 deletions(-) diff --git a/apps/desktop/src/main/bootstrap.ts b/apps/desktop/src/main/bootstrap.ts index 81a16b8..5ad0c1d 100644 --- a/apps/desktop/src/main/bootstrap.ts +++ b/apps/desktop/src/main/bootstrap.ts @@ -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 { 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 { initConfigService() } +async function initLocalDatabase(): Promise { + const { created, dbPath } = openLocal() + logger.info(`[bootstrap] local database opened: ${dbPath} (created=${created})`) +} + async function initLicense(): Promise { initLicenseService() } diff --git a/apps/desktop/src/main/db/index.ts b/apps/desktop/src/main/db/index.ts index 7676158..13c6580 100644 --- a/apps/desktop/src/main/db/index.ts +++ b/apps/desktop/src/main/db/index.ts @@ -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 | 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. */ diff --git a/apps/desktop/src/main/services/CloudSyncService.ts b/apps/desktop/src/main/services/CloudSyncService.ts index 049135e..0cb628a 100644 --- a/apps/desktop/src/main/services/CloudSyncService.ts +++ b/apps/desktop/src/main/services/CloudSyncService.ts @@ -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') } // ── 상태 조회 ────────────────────────────────────────── diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 45a01c2..897a4ce 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -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('auto') const prefersDark = useMediaQuery('(prefers-color-scheme: dark)') const systemAudioCleanupRef = useRef<(() => void) | null>(null) - const [authGate, setAuthGate] = useState({ 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 ( - - - - ) - } - if (authGate.status === 'login-required') { - return - } - // 'authenticated' or 'legacy' → 메인 UI 렌더 - return ( - <> - - - - ) - })() - return ( - {gateContent} + + ) diff --git a/memory/project_status.md b/memory/project_status.md index d14fe1c..b715e19 100644 --- a/memory/project_status.md +++ b/memory/project_status.md @@ -153,18 +153,16 @@ V2 6차 고도화 (2026-04-11): - `configGet('supabaseUrl'/'supabaseAnonKey')`은 빌드 타임 값 절대 우선 - `configSet`는 빌드 타임 모드일 때 supabaseUrl/AnonKey 변경 거부 -### SaaS [2] OAuth 첫 실행 강제 게이트 -- `CloudSyncState`에 `saasMode: boolean` 필드 추가 -- `LoginScreen.tsx` 신규: Google/GitHub 버튼만, MetalCard + PhosphorText, URL/Key 입력 노출 0 -- `App.tsx` `AuthGate` 상태머신: `loading | login-required | authenticated | legacy` - - `saasMode && !authenticated` → LoginScreen 강제 - - `saasMode && authenticated` → 기존 AppLayout - - `!saasMode` → legacy (기존 동작 유지) - - `onAuthChanged` 구독으로 로그인 후 자동 전환 -- `CloudSyncSection` (Settings 안): - - `saasMode`일 때 URL/Key 입력 필드 완전 숨김 (OAuth 버튼만) - - `!saasMode` (legacy)일 때만 기존 입력 화면 유지 -- 검증: 이전엔 항상 떴던 `CloudSync disabled — Supabase URL/key not configured` 로그 사라짐 +### SaaS [2] OAuth 첫 실행 강제 게이트 → **철회 (Phase 1.5에서 로컬 모드 entry point로 변경)** +- `CloudSyncState`에 `saasMode: boolean` 필드 추가 (유지) +- `CloudSyncSection`: saasMode에서 OAuth 버튼만, legacy에서는 URL/Key 입력 UI (유지) +- ~~`LoginScreen.tsx` 강제 게이트~~ → 제거 (App.tsx에서 사용 안 함, 파일 자체는 유지) +- ~~`App.tsx` AuthGate 상태머신~~ → 제거 (항상 메인 UI 렌더) + +**철회 이유** (2026-04-11 빅뱅 Phase 1.5): +사용자 피드백 "무료 로컬은 사용자 엔트리 포인트가 될 거구, 수익화는 클라우드 SaaS 서비스임". +로컬 STT/LLM이 이 앱의 킬러 피처 → 회원가입 없이 즉시 체험 가능해야 함 (onboarding funnel 입구). +OAuth provider도 아직 Supabase에 설정 안 된 상태. 강제 게이트는 철학 + 인프라 양쪽 모두 위배. ### SaaS [3] 사용자별 SQLite 격리 — ✅ **완료 (2026-04-11, 빅뱅 Phase 1)** @@ -210,6 +208,40 @@ V2 6차 고도화 (2026-04-11): - `~/Library/Application Support/d3ro-voice/users/${uuid}/d3ro.db` 파일 생성 확인 - 기존 `d3ro-voice.db`가 있는 환경에서 archive rename 동작 확인 +### SaaS [3.5] 로컬 모드 = entry point (Phase 1.5, 2026-04-11) + +> 비즈니스 모델: 무료 로컬 모드 = onboarding funnel 입구 (킬러 피처), +> 유료 클라우드 SaaS = 수익화 (OAuth 로그인 + pro 구독 + premium 모델 + sync). +> SaaS [2] OAuth 강제 게이트 철회. + +- **`db/index.ts`** + - `LOCAL_USER_ID = '_local'` 상수 export + - `openLocal()` 헬퍼 — `openForUser('_local')` 래퍼. 경로: `users/_local/d3ro.db` + - `isLocalMode()` 헬퍼 — `currentUserId === LOCAL_USER_ID` +- **`bootstrap.ts`** + - `database` step 복원 (critical). `openLocal()` 호출로 앱 시작 시 로컬 DB 자동 오픈 + - 순서: logger → config → **database (local)** → license → windows → tray → ipc → ... → cloud-sync +- **`CloudSyncService._onAuthenticated(session)`** + - `openForUser(userId)` — 내부에서 기존 로컬 DB close 후 사용자 DB 재오픈 (자동) + - auth-changed(user) emit 후 Realtime 시작 +- **`CloudSyncService._onSignOut()`** + - VoiceMode cancel → MeetingMode stopRecording → Caption stop → Realtime stop → auth.signOut → token clear + - `closeCurrent()` → `openLocal()` — **로컬 모드 DB로 복귀** (앱은 계속 로컬 모드로 동작) + - auth-changed(null) emit +- **`App.tsx`** + - AuthGate 상태머신 완전 제거. `LoginScreen` import 제거. + - 항상 ` ` 렌더링 (로그인/로그아웃 무관) +- **`LoginScreen.tsx`** + - 파일 유지 (dead code — 추후 Welcome 화면 재활용 가능) +- **검증** + - desktop `tsc --noEmit` ✅ + - desktop `npm run build` ✅ (main 992kB, renderer 2,126kB) + - dev 런타임 ✅: + - `[bootstrap] local database opened: .../users/_local/d3ro.db (created=true)` + - `[bootstrap] database initialized` → 나머지 step 정상 + - `Main window shown` — LoginScreen 없이 바로 메인 UI + - 파일시스템: `users/_local/d3ro.db` + WAL/SHM 정상 생성 + ## 빅뱅 다음 세션 목표 (2026-04-11 wrap-up) 데스크톱을 진짜 SaaS 클라이언트로 완성. 자세한 계획 + 단계별 prompt는