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

@ -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')