// 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>> = { 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 = { // ── 로컬 기능 (전부 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 = new Set([ Feature.PREMIUM_LLM, Feature.CLOUD_SYNC, ]) // ── 히스토리 보존 기간 (일) ──────────────────────────────── export const HISTORY_RETENTION_DAYS: Record = { free: 3, pro: -1, pro_plus: -1, } // ── 티어 순서 (비교용) ──────────────────────────────────── const TIER_ORDER: Record = { 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('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') const storedKey = this._readStoredField('licenseKey') const storedActivatedAt = this._readStoredField('licenseActivatedAt') const storedLastVerified = this._readStoredField('licenseLastVerifiedAt') const storedGrace = this._readStoredField('licenseOfflineGraceUntil') this._instanceId = this._readStoredField('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 { 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 { // 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 { 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 { 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 { 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 = 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 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 = {} 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(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() }