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 추가
941 lines
30 KiB
TypeScript
941 lines
30 KiB
TypeScript
// src/main/services/LicenseService.ts
|
|
// Phase 11: Freemium 라이센스 관리 — Feature Gating + 사용량 추적 + LemonSqueezy 연동
|
|
|
|
import { EventEmitter } from 'events'
|
|
import { createHash } from 'crypto'
|
|
import os from 'os'
|
|
import { eq, and } from 'drizzle-orm'
|
|
import { getLogger } from './LoggerService'
|
|
import { getDatabase, isLocalMode } from '../db'
|
|
import { dailyUsage } from '../db/schema'
|
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
|
import type {
|
|
LicenseTier,
|
|
LicenseInfo,
|
|
UsageQuota,
|
|
FeatureAccess,
|
|
UpgradePromptEvent,
|
|
ActivateLicenseResult,
|
|
TierComparison,
|
|
} from '@d3ro/core/types'
|
|
import { Feature } from '@d3ro/core/types'
|
|
|
|
const logger = getLogger('license')
|
|
|
|
// ── 머신 ID 생성 ─────────────────────────────────────────
|
|
function generateMachineId(): string {
|
|
const raw = `${os.hostname()}-${os.cpus()[0]?.model ?? 'unknown'}-${os.platform()}-${os.arch()}`
|
|
return createHash('sha256').update(raw).digest('hex').substring(0, 32)
|
|
}
|
|
|
|
// ── 티어별 쿼터 한도 ──────────────────────────────────────
|
|
// -1 = 무제한, 값이 있으면 일일 한도.
|
|
// 빅뱅 Phase 4: 로컬 기능은 전부 무제한. 클라우드 기능만 quota 적용.
|
|
const QUOTA_LIMITS: Record<LicenseTier, Partial<Record<Feature, number>>> = {
|
|
free: {
|
|
// PREMIUM_LLM은 로그인한 free 사용자에게 하루 5회 맛보기
|
|
[Feature.PREMIUM_LLM]: 5,
|
|
},
|
|
pro: {
|
|
// pro는 Premium LLM 무제한이지만 fair-use cap
|
|
[Feature.PREMIUM_LLM]: 500,
|
|
},
|
|
pro_plus: {},
|
|
}
|
|
|
|
// ── 기능별 최소 필요 티어 ──────────────────────────────────
|
|
// 빅뱅 Phase 4: 모든 로컬 기능을 'free'로 해방.
|
|
// 클라우드 기능(PREMIUM_LLM, CLOUD_SYNC)만 로그인 요구 + 티어 gate.
|
|
const FEATURE_MIN_TIER: Record<Feature, LicenseTier> = {
|
|
// ── 로컬 기능 (전부 free) ──
|
|
[Feature.DICTATION]: 'free',
|
|
[Feature.LLM_PROCESS]: 'free',
|
|
[Feature.HISTORY_UNLIMITED]: 'free',
|
|
[Feature.HISTORY_EXPORT]: 'free',
|
|
[Feature.CUSTOM_INSTRUCTION_CREATE]: 'free',
|
|
[Feature.LIVE_CAPTION]: 'free',
|
|
[Feature.SCREEN_CONTEXT]: 'free',
|
|
[Feature.VOICE_MEMO]: 'free',
|
|
[Feature.VOICE_COMMAND]: 'free',
|
|
[Feature.LLM_CHAIN]: 'free',
|
|
[Feature.FILE_TRANSCRIPTION]: 'free',
|
|
[Feature.VOICE_CONVERSATION]: 'free',
|
|
[Feature.DICTATION_TEMPLATE]: 'free',
|
|
[Feature.MEETING_SUMMARY]: 'free',
|
|
[Feature.LOCAL_RAG]: 'free',
|
|
[Feature.OS_AUTOMATION]: 'free',
|
|
|
|
// ── 클라우드 기능 (로그인 필요 + 일부는 pro gate) ──
|
|
[Feature.PREMIUM_LLM]: 'free', // 로그인하면 free도 5회/일, pro는 500/일, pro_plus는 무제한
|
|
[Feature.CLOUD_SYNC]: 'free', // 로그인만 하면 free도 사용 가능
|
|
}
|
|
|
|
// ── 클라우드 기능 집합 (익명 로컬 모드에서는 login_required) ──
|
|
const CLOUD_FEATURES: Set<Feature> = new Set([
|
|
Feature.PREMIUM_LLM,
|
|
Feature.CLOUD_SYNC,
|
|
])
|
|
|
|
// ── 히스토리 보존 기간 (일) ────────────────────────────────
|
|
export const HISTORY_RETENTION_DAYS: Record<LicenseTier, number> = {
|
|
free: 3,
|
|
pro: -1,
|
|
pro_plus: -1,
|
|
}
|
|
|
|
// ── 티어 순서 (비교용) ────────────────────────────────────
|
|
const TIER_ORDER: Record<LicenseTier, number> = {
|
|
free: 0,
|
|
pro: 1,
|
|
pro_plus: 2,
|
|
}
|
|
|
|
/** 오프라인 유예 기간: 30일 */
|
|
const OFFLINE_GRACE_PERIOD_MS = 30 * 24 * 60 * 60 * 1000
|
|
|
|
/** 온라인 재검증 주기: 30일 */
|
|
const REVERIFY_INTERVAL_MS = 30 * 24 * 60 * 60 * 1000
|
|
|
|
/** LemonSqueezy API base URL */
|
|
const LEMONSQUEEZY_API = 'https://api.lemonsqueezy.com/v1/licenses'
|
|
|
|
function tierAtLeast(current: LicenseTier, required: LicenseTier): boolean {
|
|
return TIER_ORDER[current] >= TIER_ORDER[required]
|
|
}
|
|
|
|
function getTodayDate(): string {
|
|
const now = new Date()
|
|
const y = now.getFullYear()
|
|
const m = String(now.getMonth() + 1).padStart(2, '0')
|
|
const d = String(now.getDate()).padStart(2, '0')
|
|
return `${y}-${m}-${d}`
|
|
}
|
|
|
|
function getTomorrowMidnight(): string {
|
|
const tomorrow = new Date()
|
|
tomorrow.setDate(tomorrow.getDate() + 1)
|
|
tomorrow.setHours(0, 0, 0, 0)
|
|
return tomorrow.toISOString()
|
|
}
|
|
|
|
/** LemonSqueezy activate 응답 타입 */
|
|
interface LemonSqueezyActivateResponse {
|
|
activated: boolean
|
|
error?: string
|
|
license_key?: {
|
|
id: number
|
|
status: string
|
|
key: string
|
|
activation_limit: number
|
|
activation_usage: number
|
|
}
|
|
instance?: {
|
|
id: string
|
|
name: string
|
|
}
|
|
meta?: {
|
|
store_id: number
|
|
order_id: number
|
|
product_id: number
|
|
product_name: string
|
|
variant_id: number
|
|
variant_name: string
|
|
}
|
|
}
|
|
|
|
/** LemonSqueezy validate 응답 타입 */
|
|
interface LemonSqueezyValidateResponse {
|
|
valid: boolean
|
|
error?: string
|
|
license_key?: {
|
|
id: number
|
|
status: string
|
|
key: string
|
|
activation_limit: number
|
|
activation_usage: number
|
|
}
|
|
meta?: {
|
|
store_id: number
|
|
order_id: number
|
|
product_id: number
|
|
product_name: string
|
|
variant_id: number
|
|
variant_name: string
|
|
}
|
|
}
|
|
|
|
/** variant_name → LicenseTier 매핑 */
|
|
function variantNameToTier(variantName: string): LicenseTier {
|
|
const lower = variantName.toLowerCase()
|
|
if (lower.includes('pro_plus') || lower.includes('pro+') || lower.includes('proplus')) {
|
|
return 'pro_plus'
|
|
}
|
|
if (lower.includes('pro')) {
|
|
return 'pro'
|
|
}
|
|
return 'free'
|
|
}
|
|
|
|
// ── LicenseService 싱글톤 ──────────────────────────────────
|
|
class LicenseService extends EventEmitter {
|
|
private _info: LicenseInfo
|
|
private _initialized = false
|
|
/** LemonSqueezy instance_id (디바이스별, 비활성화에 필요) */
|
|
private _instanceId: string | null = null
|
|
|
|
constructor() {
|
|
super()
|
|
this._info = {
|
|
tier: 'free',
|
|
licenseKey: null,
|
|
activatedAt: null,
|
|
machineId: '',
|
|
lastVerifiedAt: null,
|
|
offlineGraceUntil: null,
|
|
}
|
|
}
|
|
|
|
/** 서비스 초기화 — bootstrap에서 호출 */
|
|
initialize(): void {
|
|
// machineId: 하드웨어 기반 해시 생성
|
|
const storedMachineId = this._readStoredField<string>('licenseMachineId')
|
|
const generatedId = generateMachineId()
|
|
|
|
if (storedMachineId && storedMachineId === generatedId) {
|
|
this._info.machineId = storedMachineId
|
|
} else if (storedMachineId) {
|
|
// 하드웨어가 바뀐 경우 — 기존 ID 유지 (이미 활성화된 키와 연결)
|
|
this._info.machineId = storedMachineId
|
|
logger.warn('Hardware changed but keeping existing machineId for license continuity')
|
|
} else {
|
|
this._info.machineId = generatedId
|
|
this._writeStoredField('licenseMachineId', this._info.machineId)
|
|
}
|
|
|
|
// 저장된 라이센스 정보 로드
|
|
const storedTier = this._readStoredField<LicenseTier>('licenseTier')
|
|
const storedKey = this._readStoredField<string>('licenseKey')
|
|
const storedActivatedAt = this._readStoredField<number>('licenseActivatedAt')
|
|
const storedLastVerified = this._readStoredField<number>('licenseLastVerifiedAt')
|
|
const storedGrace = this._readStoredField<number>('licenseOfflineGraceUntil')
|
|
this._instanceId = this._readStoredField<string>('licenseInstanceId')
|
|
|
|
if (storedTier && storedTier !== 'free' && storedKey) {
|
|
this._info.tier = storedTier
|
|
this._info.licenseKey = storedKey
|
|
this._info.activatedAt = storedActivatedAt ?? null
|
|
this._info.lastVerifiedAt = storedLastVerified ?? null
|
|
this._info.offlineGraceUntil = storedGrace ?? null
|
|
|
|
// 오프라인 유예 기간 체크
|
|
if (this._info.offlineGraceUntil && Date.now() > this._info.offlineGraceUntil) {
|
|
logger.warn('Offline grace period expired, downgrading to free')
|
|
this._downgradeToFree()
|
|
} else {
|
|
// 온라인 재검증이 필요한 경우 비동기로 시도
|
|
this._tryPeriodicValidation()
|
|
}
|
|
}
|
|
|
|
this._initialized = true
|
|
logger.info(`LicenseService initialized: tier=${this._info.tier}, machineId=${this._info.machineId.substring(0, 8)}...`)
|
|
}
|
|
|
|
// ── Public API ──────────────────────────────────────────
|
|
|
|
get tier(): LicenseTier {
|
|
return this._info.tier
|
|
}
|
|
|
|
getInfo(): LicenseInfo {
|
|
return { ...this._info }
|
|
}
|
|
|
|
/**
|
|
* 기능 사용 가능 여부 확인.
|
|
*
|
|
* 빅뱅 Phase 4 정책:
|
|
* - 로컬 기능 → 전부 free 무제한 (로컬 entry point 철학)
|
|
* - 클라우드 기능 (PREMIUM_LLM, CLOUD_SYNC) + 익명 로컬 모드
|
|
* → `login_required` (로그인 필요)
|
|
* - 클라우드 기능 + 로그인 free → quota 체크 (예: PREMIUM_LLM 5/일)
|
|
* - 클라우드 기능 + 로그인 pro/pro_plus → 허용 (더 높은 quota)
|
|
*/
|
|
canUse(feature: Feature): FeatureAccess {
|
|
// 익명 로컬 모드에서 클라우드 기능 요청 → 로그인 필요
|
|
if (CLOUD_FEATURES.has(feature) && isLocalMode()) {
|
|
return {
|
|
allowed: false,
|
|
reason: 'login_required',
|
|
requiredTier: 'free',
|
|
}
|
|
}
|
|
|
|
const minTier = FEATURE_MIN_TIER[feature]
|
|
|
|
// 티어 체크 (현재 로컬 기능은 전부 'free'라 익명도 통과)
|
|
if (!tierAtLeast(this._info.tier, minTier)) {
|
|
return {
|
|
allowed: false,
|
|
reason: 'tier_required',
|
|
requiredTier: minTier,
|
|
}
|
|
}
|
|
|
|
// 쿼터 체크 (쿼터가 있는 기능만 — 현재는 PREMIUM_LLM만 해당)
|
|
const tierLimits = QUOTA_LIMITS[this._info.tier]
|
|
const limit = tierLimits[feature]
|
|
if (limit !== undefined) {
|
|
const quota = this.getUsage(feature)
|
|
if (quota.remaining === 0) {
|
|
return {
|
|
allowed: false,
|
|
reason: 'quota_exceeded',
|
|
requiredTier: this._info.tier === 'free' ? 'pro' : 'pro_plus',
|
|
quota,
|
|
}
|
|
}
|
|
}
|
|
|
|
return { allowed: true, reason: 'ok' }
|
|
}
|
|
|
|
/**
|
|
* 빅뱅 Phase 4: Supabase `subscriptions` 테이블에서 받은 티어를 로컬 캐시에 반영.
|
|
* CloudSyncService._onAuthenticated에서 호출.
|
|
*/
|
|
syncFromCloud(tier: LicenseTier): void {
|
|
if (this._info.tier === tier) return
|
|
const previous = this._info.tier
|
|
this._info.tier = tier
|
|
this._info.lastVerifiedAt = Date.now()
|
|
this._writeStoredField('licenseTier', tier)
|
|
this._writeStoredField('licenseLastVerifiedAt', this._info.lastVerifiedAt)
|
|
this.emit('tier-changed', this.getInfo())
|
|
logger.info(`License tier synced from cloud: ${previous} → ${tier}`)
|
|
}
|
|
|
|
/**
|
|
* 빅뱅 Phase 4: 로그아웃 시 로컬 모드로 복귀 — 캐시된 pro/pro_plus 티어를 free로 리셋.
|
|
* CloudSyncService._onSignOut에서 호출.
|
|
*/
|
|
resetToFree(): void {
|
|
if (this._info.tier === 'free') return
|
|
const previous = this._info.tier
|
|
this._info.tier = 'free'
|
|
this._info.licenseKey = null
|
|
this._info.activatedAt = null
|
|
this._info.lastVerifiedAt = null
|
|
this._info.offlineGraceUntil = null
|
|
this._writeStoredField('licenseTier', 'free')
|
|
this._writeStoredField('licenseKey', null)
|
|
this._writeStoredField('licenseActivatedAt', null)
|
|
this._writeStoredField('licenseLastVerifiedAt', null)
|
|
this._writeStoredField('licenseOfflineGraceUntil', null)
|
|
this.emit('tier-changed', this.getInfo())
|
|
logger.info(`License tier reset to free (was ${previous}) — local mode`)
|
|
}
|
|
|
|
/**
|
|
* 기능 사용 소비 (쿼터 차감).
|
|
* canUse 통과 후 실제 사용 시 호출.
|
|
*/
|
|
consumeQuota(feature: Feature): void {
|
|
const access = this.canUse(feature)
|
|
if (!access.allowed) {
|
|
if (access.reason === 'quota_exceeded') {
|
|
this.promptUpgrade(feature, 'quota_exceeded')
|
|
throw new D3ROError(
|
|
ErrorCode.QuotaExceeded,
|
|
`Daily quota exceeded for ${feature}`,
|
|
{ feature, quota: access.quota }
|
|
)
|
|
}
|
|
if (access.reason === 'tier_required') {
|
|
this.promptUpgrade(feature, 'tier_required')
|
|
throw new D3ROError(
|
|
ErrorCode.TierRequired,
|
|
`Feature ${feature} requires ${access.requiredTier} tier`,
|
|
{ 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에 기록
|
|
const tierLimits = QUOTA_LIMITS[this._info.tier]
|
|
if (tierLimits[feature] !== undefined) {
|
|
this._incrementUsage(feature)
|
|
}
|
|
}
|
|
|
|
/** 일일 사용량 조회 */
|
|
getUsage(feature: Feature): UsageQuota {
|
|
const tierLimits = QUOTA_LIMITS[this._info.tier]
|
|
const limit = tierLimits[feature]
|
|
|
|
// 쿼터 없는 기능 (무제한)
|
|
if (limit === undefined) {
|
|
return {
|
|
feature,
|
|
used: 0,
|
|
limit: -1,
|
|
remaining: -1,
|
|
resetAt: getTomorrowMidnight(),
|
|
}
|
|
}
|
|
|
|
const today = getTodayDate()
|
|
const used = this._getUsageCount(today, feature)
|
|
|
|
return {
|
|
feature,
|
|
used,
|
|
limit,
|
|
remaining: Math.max(0, limit - used),
|
|
resetAt: getTomorrowMidnight(),
|
|
}
|
|
}
|
|
|
|
/** 전체 쿼터 기능 사용량 조회 */
|
|
getAllUsage(): UsageQuota[] {
|
|
const quotaFeatures = [Feature.DICTATION, Feature.LLM_PROCESS]
|
|
return quotaFeatures.map((f) => this.getUsage(f))
|
|
}
|
|
|
|
/**
|
|
* 라이센스 키 활성화.
|
|
* 1. LemonSqueezy API로 활성화 시도
|
|
* 2. API 실패 시 로컬 키 검증으로 폴백 (개발/오프라인)
|
|
*/
|
|
async activate(key: string): Promise<ActivateLicenseResult> {
|
|
const trimmedKey = key.trim()
|
|
if (!trimmedKey) {
|
|
return { success: false, tier: 'free', message: 'License key is empty' }
|
|
}
|
|
|
|
// 1. 로컬 키 검증 먼저 (개발/테스트용 D3RO-PRO-*, D3RO-PLUS-* 패턴)
|
|
const localTier = this._validateKeyLocally(trimmedKey)
|
|
if (localTier) {
|
|
logger.info(`Local key validated: tier=${localTier}`)
|
|
const now = Date.now()
|
|
this._info = {
|
|
...this._info,
|
|
tier: localTier,
|
|
licenseKey: trimmedKey,
|
|
activatedAt: now,
|
|
lastVerifiedAt: now,
|
|
offlineGraceUntil: now + OFFLINE_GRACE_PERIOD_MS,
|
|
}
|
|
this._persistLicenseInfo()
|
|
this._sendToRenderer(IPC_CHANNELS.LICENSE.TIER_CHANGED, this._info)
|
|
return { success: true, tier: localTier, message: `Activated ${localTier} license (local)` }
|
|
}
|
|
|
|
// 2. LemonSqueezy API 시도
|
|
const apiResult = await this._activateViaLemonSqueezy(trimmedKey)
|
|
if (apiResult) {
|
|
return apiResult
|
|
}
|
|
|
|
// 3. 둘 다 실패
|
|
const tier = null as LicenseTier | null
|
|
if (!tier) {
|
|
return { success: false, tier: 'free', message: 'Invalid license key' }
|
|
}
|
|
|
|
const now = Date.now()
|
|
this._info = {
|
|
...this._info,
|
|
tier,
|
|
licenseKey: trimmedKey,
|
|
activatedAt: now,
|
|
lastVerifiedAt: now,
|
|
offlineGraceUntil: now + OFFLINE_GRACE_PERIOD_MS,
|
|
}
|
|
|
|
this._persistLicenseInfo()
|
|
this.emit('tier-changed', this.getInfo())
|
|
logger.info(`License activated (local): tier=${tier}`)
|
|
|
|
return {
|
|
success: true,
|
|
tier,
|
|
message: `Successfully activated ${tier} license (offline mode)`,
|
|
}
|
|
}
|
|
|
|
/** 라이센스 비활성화 (Free로 복귀) */
|
|
async deactivate(): Promise<void> {
|
|
// LemonSqueezy API로 비활성화 시도
|
|
if (this._info.licenseKey && this._instanceId) {
|
|
await this._deactivateViaLemonSqueezy(this._info.licenseKey, this._instanceId)
|
|
}
|
|
|
|
this._downgradeToFree()
|
|
this._instanceId = null
|
|
this._writeStoredField('licenseInstanceId', null)
|
|
this.emit('tier-changed', this.getInfo())
|
|
logger.info('License deactivated, reverted to free')
|
|
}
|
|
|
|
/** 업그레이드 유도 이벤트 발생 */
|
|
promptUpgrade(feature: Feature, reason: UpgradePromptEvent['reason']): void {
|
|
const minTier = FEATURE_MIN_TIER[feature]
|
|
// 쿼터 초과: 현재 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,
|
|
reason,
|
|
currentTier: this._info.tier,
|
|
requiredTier,
|
|
quota: reason === 'quota_exceeded' ? this.getUsage(feature) : undefined,
|
|
}
|
|
|
|
this.emit('upgrade-prompt', event)
|
|
}
|
|
|
|
/** 티어 비교표 생성 */
|
|
getTierComparison(): TierComparison[] {
|
|
return [
|
|
{
|
|
feature: Feature.DICTATION,
|
|
featureLabel: 'license.feature.dictation',
|
|
free: '20/day',
|
|
pro: 'unlimited',
|
|
proPlus: 'unlimited',
|
|
},
|
|
{
|
|
feature: Feature.LLM_PROCESS,
|
|
featureLabel: 'license.feature.llmProcess',
|
|
free: '10/day',
|
|
pro: 'unlimited',
|
|
proPlus: 'unlimited',
|
|
},
|
|
{
|
|
feature: Feature.HISTORY_UNLIMITED,
|
|
featureLabel: 'license.feature.historyUnlimited',
|
|
free: false,
|
|
pro: true,
|
|
proPlus: true,
|
|
},
|
|
{
|
|
feature: Feature.LIVE_CAPTION,
|
|
featureLabel: 'license.feature.liveCaption',
|
|
free: false,
|
|
pro: true,
|
|
proPlus: true,
|
|
},
|
|
{
|
|
feature: Feature.SCREEN_CONTEXT,
|
|
featureLabel: 'license.feature.screenContext',
|
|
free: false,
|
|
pro: true,
|
|
proPlus: true,
|
|
},
|
|
{
|
|
feature: Feature.VOICE_MEMO,
|
|
featureLabel: 'license.feature.voiceMemo',
|
|
free: false,
|
|
pro: true,
|
|
proPlus: true,
|
|
},
|
|
{
|
|
feature: Feature.VOICE_COMMAND,
|
|
featureLabel: 'license.feature.voiceCommand',
|
|
free: false,
|
|
pro: true,
|
|
proPlus: true,
|
|
},
|
|
{
|
|
feature: Feature.LLM_CHAIN,
|
|
featureLabel: 'license.feature.llmChain',
|
|
free: false,
|
|
pro: true,
|
|
proPlus: true,
|
|
},
|
|
{
|
|
feature: Feature.CUSTOM_INSTRUCTION_CREATE,
|
|
featureLabel: 'license.feature.customInstruction',
|
|
free: false,
|
|
pro: true,
|
|
proPlus: true,
|
|
},
|
|
{
|
|
feature: Feature.HISTORY_EXPORT,
|
|
featureLabel: 'license.feature.historyExport',
|
|
free: false,
|
|
pro: true,
|
|
proPlus: true,
|
|
},
|
|
{
|
|
feature: Feature.FILE_TRANSCRIPTION,
|
|
featureLabel: 'license.feature.fileTranscription',
|
|
free: false,
|
|
pro: false,
|
|
proPlus: true,
|
|
},
|
|
{
|
|
feature: Feature.VOICE_CONVERSATION,
|
|
featureLabel: 'license.feature.voiceConversation',
|
|
free: false,
|
|
pro: false,
|
|
proPlus: true,
|
|
},
|
|
{
|
|
feature: Feature.MEETING_SUMMARY,
|
|
featureLabel: 'license.feature.meetingSummary',
|
|
free: false,
|
|
pro: false,
|
|
proPlus: true,
|
|
},
|
|
{
|
|
feature: Feature.DICTATION_TEMPLATE,
|
|
featureLabel: 'license.feature.dictationTemplate',
|
|
free: false,
|
|
pro: false,
|
|
proPlus: true,
|
|
},
|
|
{
|
|
feature: Feature.LOCAL_RAG,
|
|
featureLabel: 'license.feature.localRag',
|
|
free: false,
|
|
pro: false,
|
|
proPlus: true,
|
|
},
|
|
{
|
|
feature: Feature.OS_AUTOMATION,
|
|
featureLabel: 'license.feature.osAutomation',
|
|
free: false,
|
|
pro: false,
|
|
proPlus: true,
|
|
},
|
|
]
|
|
}
|
|
|
|
// ── LemonSqueezy API ───────────────────────────────────
|
|
|
|
/**
|
|
* LemonSqueezy API를 통한 라이센스 활성화.
|
|
* API 실패 시 null 반환 (폴백 처리를 호출자에게 위임).
|
|
*/
|
|
private async _activateViaLemonSqueezy(key: string): Promise<ActivateLicenseResult | null> {
|
|
try {
|
|
const response = await fetch(`${LEMONSQUEEZY_API}/activate`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
body: JSON.stringify({
|
|
license_key: key,
|
|
instance_name: this._info.machineId,
|
|
}),
|
|
signal: AbortSignal.timeout(10000),
|
|
})
|
|
|
|
const data = await response.json() as LemonSqueezyActivateResponse
|
|
|
|
if (!data.activated || !data.license_key) {
|
|
const errorMsg = data.error ?? 'Activation rejected by server'
|
|
logger.warn(`LemonSqueezy activation failed: ${errorMsg}`)
|
|
return { success: false, tier: 'free', message: errorMsg }
|
|
}
|
|
|
|
// 키 상태 체크
|
|
if (data.license_key.status === 'expired') {
|
|
return { success: false, tier: 'free', message: 'License key has expired' }
|
|
}
|
|
|
|
// variant_name으로 티어 결정
|
|
const tier = data.meta?.variant_name
|
|
? variantNameToTier(data.meta.variant_name)
|
|
: 'pro'
|
|
|
|
const now = Date.now()
|
|
this._info = {
|
|
...this._info,
|
|
tier,
|
|
licenseKey: key,
|
|
activatedAt: now,
|
|
lastVerifiedAt: now,
|
|
offlineGraceUntil: now + OFFLINE_GRACE_PERIOD_MS,
|
|
}
|
|
|
|
// instance_id 저장 (비활성화에 필요)
|
|
if (data.instance?.id) {
|
|
this._instanceId = data.instance.id
|
|
this._writeStoredField('licenseInstanceId', this._instanceId)
|
|
}
|
|
|
|
this._persistLicenseInfo()
|
|
this.emit('tier-changed', this.getInfo())
|
|
logger.info(`License activated via LemonSqueezy: tier=${tier}`)
|
|
|
|
return {
|
|
success: true,
|
|
tier,
|
|
message: `Successfully activated ${tier} license`,
|
|
}
|
|
} catch (err) {
|
|
// 네트워크 에러, 타임아웃 등 — null 반환하여 로컬 폴백
|
|
logger.warn(`LemonSqueezy API unreachable: ${err instanceof Error ? err.message : String(err)}`)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* LemonSqueezy API를 통한 라이센스 비활성화.
|
|
* 실패해도 로컬 상태는 변경 (best-effort).
|
|
*/
|
|
private async _deactivateViaLemonSqueezy(key: string, instanceId: string): Promise<void> {
|
|
try {
|
|
await fetch(`${LEMONSQUEEZY_API}/deactivate`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
body: JSON.stringify({
|
|
license_key: key,
|
|
instance_id: instanceId,
|
|
}),
|
|
signal: AbortSignal.timeout(10000),
|
|
})
|
|
logger.info('License deactivated via LemonSqueezy API')
|
|
} catch (err) {
|
|
logger.warn(`LemonSqueezy deactivation failed (best-effort): ${err instanceof Error ? err.message : String(err)}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* LemonSqueezy API를 통한 온라인 검증.
|
|
* @returns true if license is valid, false otherwise
|
|
*/
|
|
private async _validateOnline(): Promise<boolean> {
|
|
if (!this._info.licenseKey) return false
|
|
|
|
try {
|
|
const response = await fetch(`${LEMONSQUEEZY_API}/validate`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
body: JSON.stringify({
|
|
license_key: this._info.licenseKey,
|
|
instance_name: this._info.machineId,
|
|
}),
|
|
signal: AbortSignal.timeout(10000),
|
|
})
|
|
|
|
const data = await response.json() as LemonSqueezyValidateResponse
|
|
|
|
if (!data.valid) {
|
|
logger.warn(`Online validation failed: ${data.error ?? 'invalid'}`)
|
|
|
|
// 키가 expired인 경우 다운그레이드
|
|
if (data.license_key?.status === 'expired') {
|
|
logger.warn('License expired, downgrading to free')
|
|
this._downgradeToFree()
|
|
this.emit('tier-changed', this.getInfo())
|
|
return false
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// 검증 성공 — 타임스탬프 갱신
|
|
const now = Date.now()
|
|
this._info.lastVerifiedAt = now
|
|
this._info.offlineGraceUntil = now + OFFLINE_GRACE_PERIOD_MS
|
|
|
|
// variant가 바뀌었을 수 있음 (업/다운그레이드)
|
|
if (data.meta?.variant_name) {
|
|
const newTier = variantNameToTier(data.meta.variant_name)
|
|
if (newTier !== this._info.tier) {
|
|
logger.info(`Tier changed via validation: ${this._info.tier} -> ${newTier}`)
|
|
this._info.tier = newTier
|
|
this.emit('tier-changed', this.getInfo())
|
|
}
|
|
}
|
|
|
|
this._persistLicenseInfo()
|
|
logger.info('Online license validation successful')
|
|
return true
|
|
} catch (err) {
|
|
// 네트워크 에러 — 오프라인 유예 기간 유지
|
|
logger.warn(`Online validation failed (network): ${err instanceof Error ? err.message : String(err)}`)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 주기적 온라인 검증 (초기화 시 호출).
|
|
* lastVerifiedAt으로부터 REVERIFY_INTERVAL_MS 이상 경과했으면 비동기 검증.
|
|
*/
|
|
private _tryPeriodicValidation(): void {
|
|
if (!this._info.lastVerifiedAt) return
|
|
|
|
const elapsed = Date.now() - this._info.lastVerifiedAt
|
|
if (elapsed > REVERIFY_INTERVAL_MS) {
|
|
logger.info('Periodic license re-validation needed, attempting...')
|
|
// 비동기 실행 (결과를 기다리지 않음 — 초기화 차단 방지)
|
|
this._validateOnline().catch((err) => {
|
|
logger.warn(`Periodic validation error: ${err instanceof Error ? err.message : String(err)}`)
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── Private helpers ────────────────────────────────────
|
|
|
|
/**
|
|
* 로컬 키 검증 (개발/테스트 + 오프라인 패턴).
|
|
* 프로덕션에서는 LemonSqueezy API가 우선 사용됨.
|
|
*
|
|
* 키 포맷 규칙:
|
|
* D3RO-PRO-XXXX-XXXX -> pro
|
|
* D3RO-PLUS-XXXX-XXXX -> pro_plus
|
|
*/
|
|
private _validateKeyLocally(key: string): LicenseTier | null {
|
|
if (key.startsWith('D3RO-PRO-') && key.length >= 18) {
|
|
return 'pro'
|
|
}
|
|
if (key.startsWith('D3RO-PLUS-') && key.length >= 19) {
|
|
return 'pro_plus'
|
|
}
|
|
return null
|
|
}
|
|
|
|
private _downgradeToFree(): void {
|
|
this._info.tier = 'free'
|
|
this._info.licenseKey = null
|
|
this._info.activatedAt = null
|
|
this._info.lastVerifiedAt = null
|
|
this._info.offlineGraceUntil = null
|
|
this._persistLicenseInfo()
|
|
}
|
|
|
|
private _persistLicenseInfo(): void {
|
|
this._writeStoredField('licenseTier', this._info.tier)
|
|
this._writeStoredField('licenseKey', this._info.licenseKey)
|
|
this._writeStoredField('licenseActivatedAt', this._info.activatedAt)
|
|
this._writeStoredField('licenseLastVerifiedAt', this._info.lastVerifiedAt)
|
|
this._writeStoredField('licenseOfflineGraceUntil', this._info.offlineGraceUntil)
|
|
}
|
|
|
|
private _getUsageCount(date: string, feature: Feature): number {
|
|
try {
|
|
const db = getDatabase()
|
|
const rows = db
|
|
.select()
|
|
.from(dailyUsage)
|
|
.where(and(eq(dailyUsage.date, date), eq(dailyUsage.feature, feature)))
|
|
.all()
|
|
return rows.length > 0 ? rows[0].count : 0
|
|
} catch {
|
|
logger.warn(`Failed to get usage count for ${feature}`)
|
|
return 0
|
|
}
|
|
}
|
|
|
|
private _incrementUsage(feature: Feature): void {
|
|
try {
|
|
const db = getDatabase()
|
|
const today = getTodayDate()
|
|
|
|
// UPSERT: 있으면 count+1, 없으면 insert
|
|
const existing = db
|
|
.select()
|
|
.from(dailyUsage)
|
|
.where(and(eq(dailyUsage.date, today), eq(dailyUsage.feature, feature)))
|
|
.all()
|
|
|
|
if (existing.length > 0) {
|
|
db.update(dailyUsage)
|
|
.set({ count: existing[0].count + 1 })
|
|
.where(eq(dailyUsage.id, existing[0].id))
|
|
.run()
|
|
} else {
|
|
db.insert(dailyUsage)
|
|
.values({ date: today, feature, count: 1 })
|
|
.run()
|
|
}
|
|
} catch (err) {
|
|
logger.warn(`Failed to increment usage for ${feature}: ${err}`)
|
|
}
|
|
}
|
|
|
|
// ── 라이센스 전용 파일 기반 저장소 ──────────────────────
|
|
// AppConfig에 라이센스 필드가 없으므로 별도 JSON 파일 사용
|
|
|
|
private _licenseStore: Map<string, unknown> = new Map()
|
|
private _licenseStoreLoaded = false
|
|
|
|
private _ensureLicenseStore(): void {
|
|
if (this._licenseStoreLoaded) return
|
|
try {
|
|
const fs = require('fs') as typeof import('fs')
|
|
const path = require('path') as typeof import('path')
|
|
const electron = require('electron') as typeof import('electron')
|
|
const filePath = path.join(electron.app.getPath('userData'), 'd3ro-license.json')
|
|
if (fs.existsSync(filePath)) {
|
|
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record<string, unknown>
|
|
for (const [k, v] of Object.entries(data)) {
|
|
this._licenseStore.set(k, v)
|
|
}
|
|
}
|
|
} catch {
|
|
// 파일 없으면 빈 상태로 시작
|
|
}
|
|
this._licenseStoreLoaded = true
|
|
}
|
|
|
|
private _saveLicenseStore(): void {
|
|
try {
|
|
const fs = require('fs') as typeof import('fs')
|
|
const path = require('path') as typeof import('path')
|
|
const electron = require('electron') as typeof import('electron')
|
|
const filePath = path.join(electron.app.getPath('userData'), 'd3ro-license.json')
|
|
const obj: Record<string, unknown> = {}
|
|
for (const [k, v] of this._licenseStore.entries()) {
|
|
obj[k] = v
|
|
}
|
|
fs.writeFileSync(filePath, JSON.stringify(obj, null, 2), 'utf-8')
|
|
} catch (err) {
|
|
logger.warn(`Failed to save license store: ${err}`)
|
|
}
|
|
}
|
|
|
|
private _readStoredField<T>(key: string): T | null {
|
|
this._ensureLicenseStore()
|
|
const val = this._licenseStore.get(key)
|
|
return (val as T) ?? null
|
|
}
|
|
|
|
private _writeStoredField(key: string, value: unknown): void {
|
|
this._ensureLicenseStore()
|
|
this._licenseStore.set(key, value)
|
|
this._saveLicenseStore()
|
|
}
|
|
}
|
|
|
|
// ── 싱글톤 ────────────────────────────────────────────────
|
|
let instance: LicenseService | null = null
|
|
|
|
export function getLicenseService(): LicenseService {
|
|
if (!instance) {
|
|
instance = new LicenseService()
|
|
}
|
|
return instance
|
|
}
|
|
|
|
export function initLicenseService(): void {
|
|
getLicenseService().initialize()
|
|
}
|