diff --git a/apps/desktop/src/main/services/CloudSyncService.ts b/apps/desktop/src/main/services/CloudSyncService.ts index 7d1d57f..d6002ce 100644 --- a/apps/desktop/src/main/services/CloudSyncService.ts +++ b/apps/desktop/src/main/services/CloudSyncService.ts @@ -173,6 +173,36 @@ class CloudSyncService extends EventEmitter { `Realtime 자동 시작 실패: ${err instanceof Error ? err.message : String(err)}` ) }) + + // 5) 초기 bi-directional sync (빅뱅 Phase 3 — local-first mirror) + // 로그인 직후 기존 로컬 데이터를 한 번 push하고, 원격 변경을 pull. + // fire-and-forget — UI는 이미 해제됨, sync는 백그라운드에서 진행. + void this._initialSync(opts.reason) + } + + /** + * 빅뱅 Phase 3: 로그인 직후 기존 로컬 데이터를 한 번 push + 원격 변경 pull. + * 데스크톱은 로컬-first이므로, 클라우드는 mirror 역할. + * - signin: 이전 익명 로컬 세션에 쌓인 데이터를 사용자 계정으로 업로드 + * - restore: 다른 기기에서 추가된 변경사항을 가져오기 + * 실패해도 warn만 찍고 앱은 계속 동작 — 수동 Sync 버튼으로 재시도 가능. + */ + private async _initialSync(reason: 'restore' | 'signin'): Promise { + try { + logger.info(`[auth:${reason}] Initial sync starting — push then pull`) + const pushResult = await this.pushAll() + logger.info( + `[auth:${reason}] Initial push done: pushed=${pushResult.pushed}, errors=${pushResult.errors.length}` + ) + const pullResult = await this.pullAll() + logger.info( + `[auth:${reason}] Initial pull done: applied=${pullResult.pushed}, errors=${pullResult.errors.length}` + ) + } catch (err) { + logger.warn( + `[auth:${reason}] Initial sync failed: ${err instanceof Error ? err.message : String(err)}` + ) + } } /** diff --git a/apps/desktop/src/main/services/LicenseService.ts b/apps/desktop/src/main/services/LicenseService.ts index 25bebc7..5136145 100644 --- a/apps/desktop/src/main/services/LicenseService.ts +++ b/apps/desktop/src/main/services/LicenseService.ts @@ -359,6 +359,14 @@ class LicenseService extends EventEmitter { { feature, requiredTier: access.requiredTier } ) } + if (access.reason === 'login_required') { + this.promptUpgrade(feature, 'login_required') + throw new D3ROError( + ErrorCode.TierRequired, + `Feature ${feature} requires sign in`, + { feature } + ) + } } // 쿼터 있는 기능만 DB에 기록 @@ -481,7 +489,15 @@ class LicenseService extends EventEmitter { /** 업그레이드 유도 이벤트 발생 */ promptUpgrade(feature: Feature, reason: UpgradePromptEvent['reason']): void { const minTier = FEATURE_MIN_TIER[feature] - const requiredTier: LicenseTier = reason === 'quota_exceeded' ? 'pro' : minTier + // 쿼터 초과: 현재 tier 상위. login_required: free(로그인하면 바로 사용 가능). 그 외: feature의 최소 tier. + const requiredTier: LicenseTier = + reason === 'quota_exceeded' + ? this._info.tier === 'free' + ? 'pro' + : 'pro_plus' + : reason === 'login_required' + ? 'free' + : minTier const event: UpgradePromptEvent = { feature, diff --git a/apps/desktop/src/renderer/components/UpgradePromptModal.tsx b/apps/desktop/src/renderer/components/UpgradePromptModal.tsx index 16e5c2d..c638cf3 100644 --- a/apps/desktop/src/renderer/components/UpgradePromptModal.tsx +++ b/apps/desktop/src/renderer/components/UpgradePromptModal.tsx @@ -44,9 +44,15 @@ export function UpgradePromptModal(): React.ReactElement { if (!event) return <> const isQuota = event.reason === 'quota_exceeded' + const isLoginRequired = event.reason === 'login_required' const featureLabel = t(`license.feature.${event.feature}`) const tierLabel = event.requiredTier === 'pro_plus' ? t('license.proPlus') : t('license.pro') + const openCloudSyncSettings = (): void => { + setOpen(false) + window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 'cloud' } })) + } + return ( {isQuota ? t('license.quotaExceeded.title', { feature: featureLabel }) - : t('license.tierRequired.title', { feature: featureLabel, tier: tierLabel })} + : isLoginRequired + ? t('license.loginRequired.title', { feature: featureLabel }) + : t('license.tierRequired.title', { feature: featureLabel, tier: tierLabel })} @@ -91,7 +99,9 @@ export function UpgradePromptModal(): React.ReactElement { > {isQuota ? t('license.quotaExceeded.desc') - : t('license.tierRequired.desc', { tier: tierLabel })} + : isLoginRequired + ? t('license.loginRequired.desc') + : t('license.tierRequired.desc', { tier: tierLabel })} {/* 쿼터 바 */} @@ -155,7 +165,7 @@ export function UpgradePromptModal(): React.ReactElement { diff --git a/memory/project_status.md b/memory/project_status.md index 66edbaf..81406eb 100644 --- a/memory/project_status.md +++ b/memory/project_status.md @@ -208,6 +208,36 @@ OAuth provider도 아직 Supabase에 설정 안 된 상태. 강제 게이트는 - `~/Library/Application Support/d3ro-voice/users/${uuid}/d3ro.db` 파일 생성 확인 - 기존 `d3ro-voice.db`가 있는 환경에서 archive rename 동작 확인 +### SaaS [6] 로컬 우선 sync + login_required UI (Phase 3, 2026-04-11) + +> 빅뱅 Phase 3의 원래 설계("Supabase = source of truth")는 로컬 entry point 철학과 충돌하므로 재정의. +> **로컬이 source of truth, 클라우드는 로그인 시 mirror + 양방향 sync.** + +- **CloudSyncService `_initialSync(reason)`** + - `_onAuthenticated` 끝에 fire-and-forget으로 호출 + - 로그인 직후 `pushAll()` 이후 `pullAll()` 순차 실행 + - signin → 익명 세션 로컬 데이터를 사용자 계정으로 업로드 + - restore → 다른 기기 변경사항 반영 + - 실패해도 warn만, 수동 Sync 버튼으로 재시도 가능 +- **`FeatureAccess.reason` / `UpgradePromptEvent.reason` 확장** + - `'login_required'` 케이스 추가 (types.ts) + - `promptUpgrade(feature, 'login_required')` — `requiredTier: 'free'`로 emit +- **`UpgradePromptModal` login_required 케이스** + - Title: "X은(는) 로그인이 필요합니다" + - Desc: "클라우드 기능을 사용하려면 로그인하세요. 로컬 기능은 로그인 없이도 계속 사용할 수 있습니다." + - Primary button: "로그인하기" → `d3ro:open-settings` custom event (tab=cloud) +- **LicenseService.consumeQuota()** 에도 `login_required` 케이스 추가 — promptUpgrade + D3ROError(TierRequired) +- **i18n ko/en**: `license.loginRequired.title/desc/signIn` +- **검증** + - desktop `tsc --noEmit` ✅ + - desktop `npm run build` ✅ (renderer 2,130kB) + - 실증은 OAuth provider 연결 후 Phase 5에서 + +**Phase 3에서 의도적으로 뺀 것 (후속 사이클로)** +- **auto push on write** — VoiceMode / MeetingMode session 완료 즉시 `pushOne()`. 현재는 수동 Sync 버튼 + Realtime debounced pull만. 실제 구현은 각 서비스에 hook 추가가 필요하므로 규모 큼. +- **오프라인 WriteQueue** — 네트워크 끊긴 상태에서 write → 디스크 큐 → 복귀 시 flush. 현재는 실패 시 logger.warn만. +- **PREMIUM_LLM 실제 호출 경로** — LocalLLMService에 cloud mode 추가해서 `llm-proxy` Edge Function 호출 + canUse(PREMIUM_LLM) gate. feature enum/policy는 Phase 4에서 준비 완료. + ### SaaS [5] 티어 enforcement — 로컬 해방 + 클라우드 gate (Phase 4, 2026-04-11) > 사용자 표현: "로컬 사용자는 풀어줘야해! 클라우드 동기화 같은 것과 고급 api가 막히는거지" diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b706191..a73907b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -943,7 +943,12 @@ export interface FeatureAccess { /** 업그레이드 유도 이벤트 */ export interface UpgradePromptEvent { feature: Feature - reason: 'quota_exceeded' | 'tier_required' + /** + * 'login_required'는 빅뱅 Phase 4 추가 — 익명 로컬 사용자가 Cloud 기능 + * (PREMIUM_LLM, CLOUD_SYNC 등)을 요청했을 때. requiredTier는 'free'로 + * 세팅되며, UI는 "로그인 필요" 메시지를 보여준다. + */ + reason: 'quota_exceeded' | 'tier_required' | 'login_required' currentTier: LicenseTier requiredTier: LicenseTier quota?: UsageQuota diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index b222e3f..19abee2 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -323,6 +323,9 @@ "license.quotaExceeded.desc": "Upgrade to Pro for unlimited usage", "license.tierRequired.title": "{{feature}} is a {{tier}} feature", "license.tierRequired.desc": "Upgrade to {{tier}} to unlock", + "license.loginRequired.title": "{{feature}} requires sign in", + "license.loginRequired.desc": "Sign in to use cloud features. Local features continue to work without signing in.", + "license.loginRequired.signIn": "Sign in", "license.tryTomorrow": "Try again tomorrow", "license.learnMore": "Learn more", "license.upgradeBenefits": "Upgrade Benefits", diff --git a/packages/i18n/src/locales/ko.json b/packages/i18n/src/locales/ko.json index 7e6375d..d4b3604 100644 --- a/packages/i18n/src/locales/ko.json +++ b/packages/i18n/src/locales/ko.json @@ -324,6 +324,9 @@ "license.quotaExceeded.desc": "Pro로 업그레이드하면 무제한으로 사용할 수 있습니다", "license.tierRequired.title": "{{feature}}은(는) {{tier}} 기능입니다", "license.tierRequired.desc": "{{tier}}로 업그레이드하여 잠금을 해제하세요", + "license.loginRequired.title": "{{feature}}은(는) 로그인이 필요합니다", + "license.loginRequired.desc": "클라우드 기능을 사용하려면 로그인하세요. 로컬 기능은 로그인 없이도 계속 사용할 수 있습니다.", + "license.loginRequired.signIn": "로그인하기", "license.tryTomorrow": "내일 다시 사용하기", "license.learnMore": "알아보기", "license.upgradeBenefits": "업그레이드 혜택",