fix(desktop): surface configuration and provider failures instead of hiding them

Several desktop paths quietly substituted defaults or partial results: a
config write could fall back to a throwaway in-memory store, speech provider
errors were absorbed into empty transcriptions, and meeting exports built
file names from raw titles.

Writes now fail explicitly when the store is unavailable, provider and model
failures reach the UI as errors, and export names pass through one
sanitizer. Settings, license, ad, and support surfaces use the shared theme
tokens, unused hotkey helpers are gone, and the package gains strict
node/renderer typecheck configs plus red-team e2e scenarios for these flows.
This commit is contained in:
Yun Chan 2026-09-16 23:23:58 +09:00
parent c8d802d78f
commit 6ba25f53b7
43 changed files with 1721 additions and 204 deletions

View file

@ -3,12 +3,13 @@
import { EventEmitter } from 'events'
import type { AppConfig, ConfigChangedEvent } from '@d3ro/core/types'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { getLogger } from './LoggerService'
const logger = getLogger('ConfigService')
// electron-store v10은 ESM 전용이므로 동적 import 필요
interface ElectronStore<T extends Record<string, unknown>> {
interface ElectronStore<T> {
get<K extends keyof T>(key: K): T[K]
set<K extends keyof T>(key: K, value: T[K]): void
store: T
@ -25,6 +26,7 @@ const CONFIG_DEFAULTS: AppConfig = {
sttProvider: 'local' as const,
sttProviderConfigs: {
local: { modelId: 'large-v3-turbo' },
'd3ro-cloud': { modelId: 'default', apiKey: '', baseUrl: 'http://localhost:5000' },
openai: { modelId: 'whisper-1', apiKey: '', baseUrl: 'https://api.openai.com/v1' },
groq: { modelId: 'whisper-large-v3-turbo', apiKey: '', baseUrl: 'https://api.groq.com/openai/v1' },
deepgram: { modelId: 'nova-3', apiKey: '', baseUrl: 'https://api.deepgram.com' },
@ -106,6 +108,9 @@ const CONFIG_DEFAULTS: AppConfig = {
activeInstructionId: '',
activeChainId: null,
captionAudioSource: 'mic',
updateChannel: 'latest',
updateDeviceId: '',
skippedUpdateVersion: null,
}
let store: ElectronStore<AppConfig> | null = null
@ -164,8 +169,12 @@ export function configSet<K extends keyof AppConfig>(key: K, value: AppConfig[K]
logger.warn(`ConfigService not initialized — using in-memory store for "${key}"`)
initInMemoryConfig()
}
const previousValue = store.get(key)
store.set(key, value)
const activeStore = store
if (!activeStore) {
throw new D3ROError(ErrorCode.ConfigWriteFailed, 'Config store unavailable')
}
const previousValue = activeStore.get(key)
activeStore.set(key, value)
const event: ConfigChangedEvent = {
key,

View file

@ -9,7 +9,10 @@ import type { CustomInstruction } from '@d3ro/core/types'
const logger = getLogger('CustomInstructionService')
type CreateInput = Omit<CustomInstruction, 'id' | 'isBuiltin' | 'order' | 'createdAt' | 'updatedAt'>
type CreateInput = Omit<
CustomInstruction,
'id' | 'isBuiltin' | 'order' | 'createdAt' | 'updatedAt' | 'icon'
> & { icon?: string }
// ============================================================
// 프리셋 명령어 (Phase 6 설계)

View file

@ -66,7 +66,7 @@ const BUILTIN_TEMPLATES: DictationTemplate[] = [
},
]
interface TemplateStoreSchema {
type TemplateStoreSchema = {
templates: DictationTemplate[]
}

View file

@ -33,7 +33,7 @@ class HistoryService {
db.insert(history).values(entry).run()
// stats 싱글톤 업데이트
this._updateStats(input.duration, input.wordCount)
this._updateStats(input.duration ?? 0, input.wordCount ?? 0)
logger.info(`History entry created: ${id}`)

View file

@ -653,16 +653,6 @@ class HotkeyService extends EventEmitter {
return null
}
/** 주어진 uiohook 키코드가 수정자 키(Ctrl/Alt/Shift/Meta)인지 확인 */
private _isModifierKeyCode(keyCode: number): boolean {
return (
this._isCtrlKeyCode(keyCode) ||
this._isAltKeyCode(keyCode) ||
this._isShiftKeyCode(keyCode) ||
this._isMetaKeyCode(keyCode)
)
}
private _isCtrlKeyCode(keyCode: number): boolean {
return keyCode === UiohookKey.Ctrl || keyCode === UiohookKey.CtrlRight
}

View file

@ -19,6 +19,7 @@ import type {
TierComparison,
} from '@d3ro/core/types'
import { Feature } from '@d3ro/core/types'
import { normalizeEntitlementTier } from '@d3ro/core/entitlement'
import { verifySignedLicenseKey, createDefaultTrialPayload } from '@d3ro/core/utils/crypto-license'
const logger = getLogger('license')
@ -147,7 +148,6 @@ function getTomorrowMidnight(): string {
// ── LicenseService 싱글톤 ──────────────────────────────────
class LicenseService extends EventEmitter {
private _info: LicenseInfo
private _initialized = false
constructor() {
super()
@ -216,7 +216,6 @@ class LicenseService extends EventEmitter {
}
}
this._initialized = true
logger.info(`LicenseService initialized: tier=${this._info.tier}, trial=${this._info.isTrial ?? false}, machineId=${this._info.machineId.substring(0, 8)}...`)
}
@ -316,14 +315,15 @@ class LicenseService extends EventEmitter {
* CloudSyncService._onAuthenticated에서 .
*/
syncFromCloud(tier: LicenseTier): void {
if (this._info.tier === tier) return
const normalized = normalizeEntitlementTier(tier)
if (this._info.tier === normalized) return
const previous = this._info.tier
this._info.tier = tier
this._info.tier = normalized
this._info.lastVerifiedAt = Date.now()
this._writeStoredField('licenseTier', tier)
this._writeStoredField('licenseTier', normalized)
this._writeStoredField('licenseLastVerifiedAt', this._info.lastVerifiedAt)
this.emit('tier-changed', this.getInfo())
logger.info(`License tier synced from cloud: ${previous}${tier}`)
logger.info(`License tier synced from cloud: ${previous}${normalized}`)
}
/**

View file

@ -145,7 +145,7 @@ const BUILTIN_TEMPLATES: MeetingDocTemplate[] = [
},
]
interface MeetingDocTemplateStoreSchema {
type MeetingDocTemplateStoreSchema = {
templates: MeetingDocTemplate[]
}

View file

@ -59,7 +59,6 @@ class MeetingModeService extends EventEmitter {
/** Phase 15.5-2: 녹음 중 오디오 WAV 파일 보존 */
private _audioBuffersForFile: Buffer[] = []
private _audioFilePath: string | null = null
// Phase 15: Meeting AI Chat
private _chatHistory: Array<{ role: 'user' | 'assistant'; content: string }> = []
@ -369,7 +368,6 @@ class MeetingModeService extends EventEmitter {
if (!fs.existsSync(audioDir)) fs.mkdirSync(audioDir, { recursive: true })
const wavPath = path.join(audioDir, `${sessionId}.wav`)
this._saveWav(wavPath, Buffer.concat(this._audioBuffersForFile))
this._audioFilePath = wavPath
logger.info(`회의 오디오 저장: ${wavPath}`)
} catch (err) {
logger.warn(`오디오 저장 실패: ${err instanceof Error ? err.message : String(err)}`)
@ -560,7 +558,6 @@ class MeetingModeService extends EventEmitter {
this._memos = []
this._meetingModeActive = false
this._audioBuffersForFile = []
this._audioFilePath = null
}
/** PCM16 버퍼를 WAV 파일로 저장 (16kHz mono 16-bit) */
@ -745,7 +742,7 @@ class MeetingModeService extends EventEmitter {
title: docTitle,
content: result.text,
promptUsed: systemPrompt,
llmModel: result.model,
llmModel: result.model ?? null,
llmLatencyMs,
createdAt: now,
updatedAt: now,
@ -797,7 +794,7 @@ class MeetingModeService extends EventEmitter {
// ── Phase 14.5: 문서 내보내기 ──
async exportDocument(documentId: string, format: MeetingExportFormat): Promise<string> {
async exportDocument(documentId: string, format: MeetingExportFormat, targetPath?: string): Promise<string> {
const db = getDatabase()
const row = db.select().from(meetingDocuments).where(eq(meetingDocuments.id, documentId)).get()
if (!row) {
@ -829,25 +826,27 @@ class MeetingModeService extends EventEmitter {
break
}
if (isShowingMeetingSaveDialog) {
return ''
}
isShowingMeetingSaveDialog = true
let filePath: string | undefined
try {
const { getMainWindow } = await import('../windows/WindowManager')
const mainWindow = getMainWindow()
const dialogOptions = {
title: '문서 내보내기',
defaultPath: defaultName,
filters,
let filePath: string | undefined = targetPath
if (!filePath) {
if (isShowingMeetingSaveDialog) {
return ''
}
isShowingMeetingSaveDialog = true
try {
const { getMainWindow } = await import('../windows/WindowManager')
const mainWindow = getMainWindow()
const dialogOptions = {
title: '문서 내보내기',
defaultPath: defaultName,
filters,
}
const res = mainWindow
? await dialog.showSaveDialog(mainWindow, dialogOptions)
: await dialog.showSaveDialog(dialogOptions)
filePath = res.filePath
} finally {
isShowingMeetingSaveDialog = false
}
const res = mainWindow
? await dialog.showSaveDialog(mainWindow, dialogOptions)
: await dialog.showSaveDialog(dialogOptions)
filePath = res.filePath
} finally {
isShowingMeetingSaveDialog = false
}
if (!filePath) return ''
@ -900,7 +899,7 @@ class MeetingModeService extends EventEmitter {
return filePath
}
async exportTranscript(sessionId: string): Promise<string> {
async exportTranscript(sessionId: string, targetPath?: string): Promise<string> {
const db = getDatabase()
const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
if (!row) {
@ -911,11 +910,15 @@ class MeetingModeService extends EventEmitter {
const dateStr = formatDateFile(row.startedAt)
const safeTitle = (row.title ?? '무제_회의').replace(/[/\\?%*:|"<>]/g, '-').slice(0, 50)
const { filePath } = await dialog.showSaveDialog({
title: '전사 내보내기',
defaultPath: `전사_${safeTitle}_${dateStr}.txt`,
filters: [{ name: 'Text', extensions: ['txt'] }],
})
let filePath: string | undefined = targetPath
if (!filePath) {
const res = await dialog.showSaveDialog({
title: '전사 내보내기',
defaultPath: `전사_${safeTitle}_${dateStr}.txt`,
filters: [{ name: 'Text', extensions: ['txt'] }],
})
filePath = res.filePath
}
if (!filePath) return ''
fs.writeFileSync(filePath, transcript, 'utf-8')

View file

@ -13,6 +13,7 @@ import { getCloudSyncService } from './CloudSyncService'
import { getDatabase } from '../db'
import { history } from '../db/schema'
import { getMainWindow } from '../windows/WindowManager'
import { toSafeFilenameSegment } from '../utils/safe-filename'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { MeetingSummaryResult, MeetingSummaryProgress } from '@d3ro/core/types'
@ -153,6 +154,8 @@ class MeetingSummaryService extends EventEmitter {
const date = new Date(entry.createdAt)
const dateStr = date.toISOString().slice(0, 10)
const safeTitle = toSafeFilenameSegment(entry.title, 'meeting-summary')
const defaultName = `d3ro-summary-${safeTitle}-${dateStr}.md`
if (isShowingSummarySaveDialog) {
throw new D3ROError(ErrorCode.MeetingSummaryExportFailed, 'Export dialog already active')
}

View file

@ -148,6 +148,7 @@ class MemoService {
const rows = db
.select({
id: history.id,
title: history.title,
originalText: history.originalText,
polishedText: history.polishedText,
focusedApp: history.focusedApp,
@ -167,7 +168,8 @@ class MemoService {
llmLatencyMs: history.llmLatencyMs,
createdAt: history.createdAt,
updatedAt: history.updatedAt,
appVersion: history.appVersion
appVersion: history.appVersion,
summaryText: history.summaryText
})
.from(memoTags)
.innerJoin(history, eq(memoTags.historyId, history.id))
@ -309,6 +311,7 @@ class MemoService {
private _toHistoryEntry(row: {
id: string
title: string | null
originalText: string
polishedText: string | null
focusedApp: string | null
@ -329,9 +332,11 @@ class MemoService {
createdAt: number
updatedAt: number
appVersion: string
summaryText: string | null
}): HistoryEntry {
return {
id: row.id,
title: row.title,
originalText: row.originalText,
polishedText: row.polishedText,
focusedApp: row.focusedApp,
@ -351,7 +356,8 @@ class MemoService {
llmLatencyMs: row.llmLatencyMs,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
appVersion: row.appVersion
appVersion: row.appVersion,
summaryText: row.summaryText
}
}
}

View file

@ -353,7 +353,8 @@ ${context}`
if (chunk.length > 10) {
chunks.push(chunk)
}
start = end - CHUNK_OVERLAP
if (end >= safeText.length) break
start = Math.max(start + 1, end - CHUNK_OVERLAP)
if (start >= safeText.length) break
}
return chunks

View file

@ -14,10 +14,12 @@ const logger = getLogger('screen-context')
// nut-js 타입 (lazy import)
// ============================================================
type NutModule = typeof import('@nut-tree-fork/nut-js')
interface NutKeyboard {
pressKey: (...keys: number[]) => Promise<void>
releaseKey: (...keys: number[]) => Promise<void>
Key: Record<string, number>
pressKey: NutModule['keyboard']['pressKey']
releaseKey: NutModule['keyboard']['releaseKey']
Key: NutModule['Key']
}
// ============================================================

View file

@ -45,7 +45,6 @@ interface TextInsertEvents {
class TextInsertService extends EventEmitter {
private _nutKeyboard: NutKeyboard | null = null
private _nutLoaded = false
private _nutLoadPromise: Promise<void> | null = null
/**
@ -64,7 +63,6 @@ class TextInsertService extends EventEmitter {
type: nut.keyboard.type.bind(nut.keyboard),
Key: nut.Key
}
this._nutLoaded = true
logger.info('nut-js loaded successfully')
} catch (error) {
logger.error(`Failed to load nut-js: ${error instanceof Error ? error.message : String(error)}`)
@ -247,11 +245,13 @@ class TextInsertService extends EventEmitter {
// ── nut-js 타입 (lazy import용) ──────────────────────────
type NutModule = typeof import('@nut-tree-fork/nut-js')
interface NutKeyboard {
pressKey: (...keys: number[]) => Promise<void>
releaseKey: (...keys: number[]) => Promise<void>
type: (text: string) => Promise<void>
Key: Record<string, number>
pressKey: NutModule['keyboard']['pressKey']
releaseKey: NutModule['keyboard']['releaseKey']
type: NutModule['keyboard']['type']
Key: NutModule['Key']
}
// ── 싱글톤 ─────────────────────────────────────────────

View file

@ -21,7 +21,7 @@ import { getLocalLLMService } from './LocalLLMService'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { TIMING } from '@d3ro/core/constants'
import { RecognitionState, AudioState } from '@d3ro/core/types'
import type { VoiceMode, VoiceState } from '@d3ro/core/types'
import type { VoiceMode, VoiceState, LLMAction } from '@d3ro/core/types'
import {
showRecordingTip,
hideRecordingTip,
@ -122,9 +122,6 @@ class VoiceModeService extends EventEmitter {
private _errorHideTimer: NodeJS.Timeout | null = null
/** 녹음 종료 후 STT 준비 대기 타이머 — 전사 시작 시 반드시 해제 */
private _sttWaitTimer: NodeJS.Timeout | null = null
/** 실시간 부분 전사 루프 */
private _interimTimer: NodeJS.Timeout | null = null
private _interimBusy = false
// Action Queue (이벤트 직렬화)
private _actionQueue: VoiceAction[] = []
@ -314,9 +311,6 @@ class VoiceModeService extends EventEmitter {
// (_initSTT는 내부에서 에러를 _handleError로 처리하므로 fire-and-forget 안전)
void this._initSTT()
await this._startAudio()
// 실시간 부분 전사 루프 시작 (STT 준비 + 스트리밍 중일 때만 동작)
this._startInterimLoop()
}
async stopSession(): Promise<void> {
@ -482,7 +476,6 @@ class VoiceModeService extends EventEmitter {
}
private async _stopAudio(): Promise<void> {
this._stopInterimLoop()
this._setAudioState(AudioState.STOPPED)
const audio = getAudioCaptureService()
@ -503,30 +496,6 @@ class VoiceModeService extends EventEmitter {
}
}
// ── 실시간 부분 전사 (interim) ──────────────────────────
// 녹음 중 1.5초 간격으로 현재 버퍼(최근 12초 윈도우)를 전사해
// RecordingTip에 "말하는 대로 적히는" 미리보기를 제공한다.
// 최종 전사는 release 후 전체 버퍼로 다시 수행 (기존 경로 그대로).
private _startInterimLoop(): void {
this._stopInterimLoop()
this._interimTimer = setInterval(() => {
void this._runInterimTranscribe()
}, 1500)
}
private _stopInterimLoop(): void {
if (this._interimTimer) {
clearInterval(this._interimTimer)
this._interimTimer = null
}
this._interimBusy = false
}
private async _runInterimTranscribe(): Promise<void> {
// Cloud STT does not support interim streaming yet
}
// ── 이중 조건 플러시 ───────────────────────────────────
private _tryFlushAll(): void {
@ -713,6 +682,10 @@ class VoiceModeService extends EventEmitter {
try {
const action = configGet('defaultLLMAction')
if (action === 'none') {
this._completeSession(transcribedText)
return
}
// Phase 10.2: 스크린 컨텍스트를 LLM 프롬프트에 주입
let contextPrefix = ''
@ -890,7 +863,6 @@ class VoiceModeService extends EventEmitter {
}
private _resetToIdle(): void {
this._stopInterimLoop()
this._clearSttWaitTimer()
this._session = null
this._audioBuffer = []

View file

@ -75,3 +75,14 @@ export function createProbeWav(durationMs: number = 500, sampleRate: number = 16
return pcmToWav(pcmBuffer, sampleRate, 1, 16)
}
/**
* Buffer를 fetch .
*
* Buffer가 Uint8Array이므로 ,
* DOM `BodyInit` @types/node의 `Buffer<ArrayBufferLike>`
* .
*/
export function bufferToBody(data: Buffer): BodyInit {
return data as unknown as BodyInit
}

View file

@ -5,11 +5,21 @@ import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { ISTTDriver } from '../types'
import type { TranscriptionResult, TranscribeOptions } from '../../LocalSTTService'
import type { STTProviderConfig } from '@d3ro/core/types'
import { pcmToWav, createProbeWav } from '../audio-utils'
import { pcmToWav, createProbeWav, bufferToBody } from '../audio-utils'
import { getLogger } from '../../LoggerService'
const logger = getLogger('AssemblyAIDriver')
/** AssemblyAI 전사 폴링 응답 (필요한 필드만) */
interface AssemblyAITranscriptResponse {
status: string
text?: string
words?: Array<{ text: string; start: number; end: number; confidence: number }>
language_code?: string
audio_duration?: number
error?: string
}
export class AssemblyAIDriver implements ISTTDriver {
readonly id = 'assemblyai' as const
readonly name = 'AssemblyAI (Universal-2)'
@ -38,7 +48,7 @@ export class AssemblyAIDriver implements ISTTDriver {
Authorization: apiKey,
'Content-Type': 'application/octet-stream',
},
body: wavBuffer,
body: bufferToBody(wavBuffer),
signal: AbortSignal.timeout(20000),
})
@ -90,14 +100,7 @@ export class AssemblyAIDriver implements ISTTDriver {
// 3. Poll for result (최대 30초 대기)
let attempts = 0
let fullResult: {
status: string
text?: string
words?: Array<{ text: string; start: number; end: number; confidence: number }>
language_code?: string
audio_duration?: number
error?: string
} | null = null
let fullResult: AssemblyAITranscriptResponse | null = null
while (attempts < 30) {
await new Promise((r) => setTimeout(r, 600))
@ -110,7 +113,7 @@ export class AssemblyAIDriver implements ISTTDriver {
if (!pollRes.ok) continue
const pollData = (await pollRes.json()) as typeof fullResult
const pollData = (await pollRes.json()) as AssemblyAITranscriptResponse
if (pollData && (pollData.status === 'completed' || pollData.status === 'error')) {
fullResult = pollData
break
@ -166,7 +169,7 @@ export class AssemblyAIDriver implements ISTTDriver {
Authorization: apiKey,
'Content-Type': 'application/octet-stream',
},
body: probe,
body: bufferToBody(probe),
signal: AbortSignal.timeout(10000),
})

View file

@ -5,7 +5,7 @@ import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { ISTTDriver } from '../types'
import type { TranscriptionResult, TranscribeOptions } from '../../LocalSTTService'
import type { STTProviderConfig } from '@d3ro/core/types'
import { pcmToWav, createProbeWav } from '../audio-utils'
import { pcmToWav, createProbeWav, bufferToBody } from '../audio-utils'
import { getLogger } from '../../LoggerService'
const logger = getLogger('DeepgramDriver')
@ -54,7 +54,7 @@ export class DeepgramDriver implements ISTTDriver {
Authorization: `Token ${apiKey}`,
'Content-Type': 'audio/wav',
},
body: wavBuffer,
body: bufferToBody(wavBuffer),
signal: AbortSignal.timeout(25000),
})
@ -137,7 +137,7 @@ export class DeepgramDriver implements ISTTDriver {
Authorization: `Token ${apiKey}`,
'Content-Type': 'audio/wav',
},
body: probe,
body: bufferToBody(probe),
signal: AbortSignal.timeout(10000),
})