feat(desktop): SaaS [6] 로컬 우선 sync + login_required UI (빅뱅 Phase 3)
Phase 3 재정의: Supabase = source of truth 방향 철회. 로컬 entry point 철학 하에 "로컬이 SoT, 클라우드는 로그인 시 mirror"로 확정. CloudSyncService._initialSync(): - _onAuthenticated 끝에 fire-and-forget 호출 - pushAll → pullAll 순차 실행 - signin: 익명 로컬 데이터를 사용자 계정으로 업로드 - restore: 다른 기기 변경 반영 - 실패 시 warn만, 수동 Sync 버튼으로 재시도 가능 types.ts: - FeatureAccess.reason: 'login_required' 추가 - UpgradePromptEvent.reason: 'login_required' 추가 LicenseService: - consumeQuota(): login_required 케이스 — promptUpgrade + D3ROError(TierRequired) - promptUpgrade(): login_required 시 requiredTier='free' (로그인만 하면 free로 바로 사용 가능) - quota_exceeded 시 현재 tier 상위로 승격 제안 UpgradePromptModal: - isLoginRequired 분기 — title/desc 교체 - Primary button: "로그인하기" → d3ro:open-settings event (tab=cloud) i18n ko/en: - license.loginRequired.title/desc/signIn 추가
This commit is contained in:
parent
46b77f1118
commit
ceadf1b6f3
7 changed files with 103 additions and 6 deletions
|
|
@ -173,6 +173,36 @@ class CloudSyncService extends EventEmitter {
|
||||||
`Realtime 자동 시작 실패: ${err instanceof Error ? err.message : String(err)}`
|
`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<void> {
|
||||||
|
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)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -359,6 +359,14 @@ class LicenseService extends EventEmitter {
|
||||||
{ feature, requiredTier: access.requiredTier }
|
{ 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에 기록
|
// 쿼터 있는 기능만 DB에 기록
|
||||||
|
|
@ -481,7 +489,15 @@ class LicenseService extends EventEmitter {
|
||||||
/** 업그레이드 유도 이벤트 발생 */
|
/** 업그레이드 유도 이벤트 발생 */
|
||||||
promptUpgrade(feature: Feature, reason: UpgradePromptEvent['reason']): void {
|
promptUpgrade(feature: Feature, reason: UpgradePromptEvent['reason']): void {
|
||||||
const minTier = FEATURE_MIN_TIER[feature]
|
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 = {
|
const event: UpgradePromptEvent = {
|
||||||
feature,
|
feature,
|
||||||
|
|
|
||||||
|
|
@ -44,9 +44,15 @@ export function UpgradePromptModal(): React.ReactElement {
|
||||||
if (!event) return <></>
|
if (!event) return <></>
|
||||||
|
|
||||||
const isQuota = event.reason === 'quota_exceeded'
|
const isQuota = event.reason === 'quota_exceeded'
|
||||||
|
const isLoginRequired = event.reason === 'login_required'
|
||||||
const featureLabel = t(`license.feature.${event.feature}`)
|
const featureLabel = t(`license.feature.${event.feature}`)
|
||||||
const tierLabel = event.requiredTier === 'pro_plus' ? t('license.proPlus') : t('license.pro')
|
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 (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
|
|
@ -77,6 +83,8 @@ export function UpgradePromptModal(): React.ReactElement {
|
||||||
<LockIcon sx={{ fontSize: 20 }} />
|
<LockIcon sx={{ fontSize: 20 }} />
|
||||||
{isQuota
|
{isQuota
|
||||||
? t('license.quotaExceeded.title', { feature: featureLabel })
|
? t('license.quotaExceeded.title', { feature: featureLabel })
|
||||||
|
: isLoginRequired
|
||||||
|
? t('license.loginRequired.title', { feature: featureLabel })
|
||||||
: t('license.tierRequired.title', { feature: featureLabel, tier: tierLabel })}
|
: t('license.tierRequired.title', { feature: featureLabel, tier: tierLabel })}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
|
|
@ -91,6 +99,8 @@ export function UpgradePromptModal(): React.ReactElement {
|
||||||
>
|
>
|
||||||
{isQuota
|
{isQuota
|
||||||
? t('license.quotaExceeded.desc')
|
? t('license.quotaExceeded.desc')
|
||||||
|
: isLoginRequired
|
||||||
|
? t('license.loginRequired.desc')
|
||||||
: t('license.tierRequired.desc', { tier: tierLabel })}
|
: t('license.tierRequired.desc', { tier: tierLabel })}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
|
@ -155,7 +165,7 @@ export function UpgradePromptModal(): React.ReactElement {
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleLearnMore}
|
onClick={isLoginRequired ? openCloudSyncSettings : handleLearnMore}
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: d3roFontMono,
|
fontFamily: d3roFontMono,
|
||||||
fontSize: d3roTypo.compact.size,
|
fontSize: d3roTypo.compact.size,
|
||||||
|
|
@ -170,7 +180,7 @@ export function UpgradePromptModal(): React.ReactElement {
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('license.learnMore')}
|
{isLoginRequired ? t('license.loginRequired.signIn') : t('license.learnMore')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,36 @@ OAuth provider도 아직 Supabase에 설정 안 된 상태. 강제 게이트는
|
||||||
- `~/Library/Application Support/d3ro-voice/users/${uuid}/d3ro.db` 파일 생성 확인
|
- `~/Library/Application Support/d3ro-voice/users/${uuid}/d3ro.db` 파일 생성 확인
|
||||||
- 기존 `d3ro-voice.db`가 있는 환경에서 archive rename 동작 확인
|
- 기존 `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)
|
### SaaS [5] 티어 enforcement — 로컬 해방 + 클라우드 gate (Phase 4, 2026-04-11)
|
||||||
|
|
||||||
> 사용자 표현: "로컬 사용자는 풀어줘야해! 클라우드 동기화 같은 것과 고급 api가 막히는거지"
|
> 사용자 표현: "로컬 사용자는 풀어줘야해! 클라우드 동기화 같은 것과 고급 api가 막히는거지"
|
||||||
|
|
|
||||||
|
|
@ -943,7 +943,12 @@ export interface FeatureAccess {
|
||||||
/** 업그레이드 유도 이벤트 */
|
/** 업그레이드 유도 이벤트 */
|
||||||
export interface UpgradePromptEvent {
|
export interface UpgradePromptEvent {
|
||||||
feature: Feature
|
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
|
currentTier: LicenseTier
|
||||||
requiredTier: LicenseTier
|
requiredTier: LicenseTier
|
||||||
quota?: UsageQuota
|
quota?: UsageQuota
|
||||||
|
|
|
||||||
|
|
@ -323,6 +323,9 @@
|
||||||
"license.quotaExceeded.desc": "Upgrade to Pro for unlimited usage",
|
"license.quotaExceeded.desc": "Upgrade to Pro for unlimited usage",
|
||||||
"license.tierRequired.title": "{{feature}} is a {{tier}} feature",
|
"license.tierRequired.title": "{{feature}} is a {{tier}} feature",
|
||||||
"license.tierRequired.desc": "Upgrade to {{tier}} to unlock",
|
"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.tryTomorrow": "Try again tomorrow",
|
||||||
"license.learnMore": "Learn more",
|
"license.learnMore": "Learn more",
|
||||||
"license.upgradeBenefits": "Upgrade Benefits",
|
"license.upgradeBenefits": "Upgrade Benefits",
|
||||||
|
|
|
||||||
|
|
@ -324,6 +324,9 @@
|
||||||
"license.quotaExceeded.desc": "Pro로 업그레이드하면 무제한으로 사용할 수 있습니다",
|
"license.quotaExceeded.desc": "Pro로 업그레이드하면 무제한으로 사용할 수 있습니다",
|
||||||
"license.tierRequired.title": "{{feature}}은(는) {{tier}} 기능입니다",
|
"license.tierRequired.title": "{{feature}}은(는) {{tier}} 기능입니다",
|
||||||
"license.tierRequired.desc": "{{tier}}로 업그레이드하여 잠금을 해제하세요",
|
"license.tierRequired.desc": "{{tier}}로 업그레이드하여 잠금을 해제하세요",
|
||||||
|
"license.loginRequired.title": "{{feature}}은(는) 로그인이 필요합니다",
|
||||||
|
"license.loginRequired.desc": "클라우드 기능을 사용하려면 로그인하세요. 로컬 기능은 로그인 없이도 계속 사용할 수 있습니다.",
|
||||||
|
"license.loginRequired.signIn": "로그인하기",
|
||||||
"license.tryTomorrow": "내일 다시 사용하기",
|
"license.tryTomorrow": "내일 다시 사용하기",
|
||||||
"license.learnMore": "알아보기",
|
"license.learnMore": "알아보기",
|
||||||
"license.upgradeBenefits": "업그레이드 혜택",
|
"license.upgradeBenefits": "업그레이드 혜택",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue