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:
Yun Chan 2026-04-05 21:36:09 +09:00
parent 36d77ca224
commit a31f96bbb8
97 changed files with 11853 additions and 1143 deletions

View 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()
}