Phase 10~11 전체 구현: 킬러 피처 5종 + 수익화 시스템
Phase 10 킬러 피처: - MemoService: 태그 CRUD + 마크다운 내보내기 (memo_tags DB) - VoiceCommandService: 키워드→명령어 매칭, 프리셋 4종 - ScreenContextService: PowerShell 활성 윈도우 + Ctrl+C 선택 텍스트 - ChainService: LLM 명령어 순차 실행 파이프라인 - CaptionService: 6초 청크 연속 전사 + 시스템 오디오 루프백 VoiceModeService 파이프라인 통합: - 녹음 시작 → 컨텍스트 캡처 → STT → 키워드 매칭 → LLM(체인/컨텍스트 주입) → 삽입 시스템 오디오 캡처: - setDisplayMediaRequestHandler + audio: 'loopback' (IPC 브릿지) - electron-audio-loopback 패키지 contextIsolation 호환 불가 → 직접 구현 Phase 11 수익화: - LicenseService: Free/Pro/Pro+ 3티어, LemonSqueezy API - Feature Gate: requireFeature/checkFeature/consumeFeature - 일일 쿼터: Free dictation 20/일, LLM 10/일 (SQLite daily_usage) - LicenseModal, ProBadge, UpgradePromptModal UI 디자인 보강: - d3roTypo(13종), d3roShadow(10종), d3roRadius(7종) 토큰 시스템 - ScreenPanel, ButtonGroup DS 컴포넌트 신규 - PhosphorText 4→13종 변형, MetalDial conic-gradient 광택 - 공유 컴포넌트: EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard 기타: - 자막 핫키 SSOT 전체 연동 (Config→Hotkey→VoiceMode→Caption→Settings) - StatusBar 자막 LED + 효과음, 자막 로딩 UI - LLM 상태 이벤트 전파 수정 (폴링 제거 → onStatusChanged) - 커맨드 팝업 "선택 해제" 항목 추가
This commit is contained in:
parent
36d77ca224
commit
a31f96bbb8
97 changed files with 11853 additions and 1143 deletions
822
src/main/services/LicenseService.ts
Normal file
822
src/main/services/LicenseService.ts
Normal file
|
|
@ -0,0 +1,822 @@
|
|||
// 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 } from '../db'
|
||||
import { dailyUsage } from '../db/schema'
|
||||
import { D3ROError, ErrorCode } from '@shared/errors'
|
||||
import type {
|
||||
LicenseTier,
|
||||
LicenseInfo,
|
||||
UsageQuota,
|
||||
FeatureAccess,
|
||||
UpgradePromptEvent,
|
||||
ActivateLicenseResult,
|
||||
TierComparison,
|
||||
} from '@shared/types'
|
||||
import { Feature } from '@shared/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 = 무제한, 값이 있으면 일일 한도
|
||||
const QUOTA_LIMITS: Record<LicenseTier, Partial<Record<Feature, number>>> = {
|
||||
free: {
|
||||
[Feature.DICTATION]: 20,
|
||||
[Feature.LLM_PROCESS]: 10,
|
||||
},
|
||||
pro: {},
|
||||
pro_plus: {},
|
||||
}
|
||||
|
||||
// ── 기능별 최소 필요 티어 ──────────────────────────────────
|
||||
const FEATURE_MIN_TIER: Record<Feature, LicenseTier> = {
|
||||
[Feature.DICTATION]: 'free',
|
||||
[Feature.LLM_PROCESS]: 'free',
|
||||
|
||||
[Feature.HISTORY_UNLIMITED]: 'pro',
|
||||
[Feature.HISTORY_EXPORT]: 'pro',
|
||||
[Feature.CUSTOM_INSTRUCTION_CREATE]: 'pro',
|
||||
[Feature.LIVE_CAPTION]: 'pro',
|
||||
[Feature.SCREEN_CONTEXT]: 'pro',
|
||||
[Feature.VOICE_MEMO]: 'pro',
|
||||
[Feature.VOICE_COMMAND]: 'pro',
|
||||
[Feature.LLM_CHAIN]: 'pro',
|
||||
|
||||
[Feature.FILE_TRANSCRIPTION]: 'pro_plus',
|
||||
[Feature.VOICE_CONVERSATION]: 'pro_plus',
|
||||
[Feature.DICTATION_TEMPLATE]: 'pro_plus',
|
||||
[Feature.MEETING_SUMMARY]: 'pro_plus',
|
||||
[Feature.LOCAL_RAG]: 'pro_plus',
|
||||
[Feature.OS_AUTOMATION]: 'pro_plus',
|
||||
}
|
||||
|
||||
// ── 히스토리 보존 기간 (일) ────────────────────────────────
|
||||
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
* 기능 사용 가능 여부 확인.
|
||||
* 티어 잠금 -> FeatureAccess { allowed: false, reason: 'tier_required' }
|
||||
* 쿼터 초과 -> FeatureAccess { allowed: false, reason: 'quota_exceeded' }
|
||||
*/
|
||||
canUse(feature: Feature): FeatureAccess {
|
||||
const minTier = FEATURE_MIN_TIER[feature]
|
||||
|
||||
// 티어 체크
|
||||
if (!tierAtLeast(this._info.tier, minTier)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'tier_required',
|
||||
requiredTier: minTier,
|
||||
}
|
||||
}
|
||||
|
||||
// 쿼터 체크 (쿼터가 있는 기능만)
|
||||
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: 'pro',
|
||||
quota,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true, reason: 'ok' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 기능 사용 소비 (쿼터 차감).
|
||||
* 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 쿼터 있는 기능만 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. LemonSqueezy API 시도
|
||||
const apiResult = await this._activateViaLemonSqueezy(trimmedKey)
|
||||
if (apiResult) {
|
||||
return apiResult
|
||||
}
|
||||
|
||||
// 2. API 실패 시 로컬 키 검증 폴백 (개발/테스트용)
|
||||
logger.info('LemonSqueezy API unreachable, trying local key validation')
|
||||
const tier = this._validateKeyLocally(trimmedKey)
|
||||
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]
|
||||
const requiredTier: LicenseTier = reason === 'quota_exceeded' ? 'pro' : 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,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ── 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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue