diff --git a/apps/desktop/src/main/ipc/instruction-handlers.ts b/apps/desktop/src/main/ipc/instruction-handlers.ts index 8f05d32..39ec34d 100644 --- a/apps/desktop/src/main/ipc/instruction-handlers.ts +++ b/apps/desktop/src/main/ipc/instruction-handlers.ts @@ -33,9 +33,14 @@ export function registerInstructionHandlers(): void { ipcMain.handle( IPC_CHANNELS.INSTRUCTION.UPDATE, - async (_event, params: { id: string; data: Partial }) => { + async ( + _event, + params: { id: string; data?: Partial; name?: string; description?: string; prompt?: string; icon?: string } + ) => { try { - const result = getCustomInstructionService().update(params.id, params.data) + const { id, data, ...rest } = params + const updatePayload = data ?? rest + const result = getCustomInstructionService().update(id, updatePayload) return ipcSuccess(result) } catch (error) { const message = error instanceof Error ? error.message : String(error) diff --git a/apps/desktop/src/main/ipc/meeting-mode-handlers.ts b/apps/desktop/src/main/ipc/meeting-mode-handlers.ts index b6c70f4..2d7f27a 100644 --- a/apps/desktop/src/main/ipc/meeting-mode-handlers.ts +++ b/apps/desktop/src/main/ipc/meeting-mode-handlers.ts @@ -3,7 +3,7 @@ import { ipcMain } from 'electron' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' -import { ErrorCode, ipcSuccess, ipcError } from '@d3ro/core/errors' +import { ErrorCode, ipcSuccess, ipcError, D3ROError } from '@d3ro/core/errors' import { getMeetingModeService } from '../services/MeetingModeService' import { getLogger } from '../services/LoggerService' import type { @@ -188,9 +188,9 @@ export function registerMeetingModeHandlers(): void { } }) - ipcMain.handle(ch.EXPORT_DOCUMENT, async (_event, params: { documentId: string; format: MeetingExportFormat }) => { + ipcMain.handle(ch.EXPORT_DOCUMENT, async (_event, params: { documentId: string; format: MeetingExportFormat; targetPath?: string }) => { try { - const filePath = await getMeetingModeService().exportDocument(params.documentId, params.format) + const filePath = await getMeetingModeService().exportDocument(params.documentId, params.format, params.targetPath) return ipcSuccess(filePath) } catch (err) { logger.error(`exportDocument 실패: ${err instanceof Error ? err.message : String(err)}`) @@ -201,9 +201,9 @@ export function registerMeetingModeHandlers(): void { } }) - ipcMain.handle(ch.EXPORT_TRANSCRIPT, async (_event, params: { sessionId: string }) => { + ipcMain.handle(ch.EXPORT_TRANSCRIPT, async (_event, params: { sessionId: string; targetPath?: string }) => { try { - const filePath = await getMeetingModeService().exportTranscript(params.sessionId) + const filePath = await getMeetingModeService().exportTranscript(params.sessionId, params.targetPath) return ipcSuccess(filePath) } catch (err) { logger.error(`exportTranscript 실패: ${err instanceof Error ? err.message : String(err)}`) diff --git a/apps/desktop/src/main/services/ConfigService.ts b/apps/desktop/src/main/services/ConfigService.ts index 49cb419..f6079b6 100644 --- a/apps/desktop/src/main/services/ConfigService.ts +++ b/apps/desktop/src/main/services/ConfigService.ts @@ -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> { +interface ElectronStore { get(key: K): T[K] set(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 | null = null @@ -164,8 +169,12 @@ export function configSet(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, diff --git a/apps/desktop/src/main/services/CustomInstructionService.ts b/apps/desktop/src/main/services/CustomInstructionService.ts index e0182f4..7eae7f6 100644 --- a/apps/desktop/src/main/services/CustomInstructionService.ts +++ b/apps/desktop/src/main/services/CustomInstructionService.ts @@ -9,7 +9,10 @@ import type { CustomInstruction } from '@d3ro/core/types' const logger = getLogger('CustomInstructionService') -type CreateInput = Omit +type CreateInput = Omit< + CustomInstruction, + 'id' | 'isBuiltin' | 'order' | 'createdAt' | 'updatedAt' | 'icon' +> & { icon?: string } // ============================================================ // 프리셋 명령어 (Phase 6 설계) diff --git a/apps/desktop/src/main/services/DictationTemplateService.ts b/apps/desktop/src/main/services/DictationTemplateService.ts index 0760864..a4a995b 100644 --- a/apps/desktop/src/main/services/DictationTemplateService.ts +++ b/apps/desktop/src/main/services/DictationTemplateService.ts @@ -66,7 +66,7 @@ const BUILTIN_TEMPLATES: DictationTemplate[] = [ }, ] -interface TemplateStoreSchema { +type TemplateStoreSchema = { templates: DictationTemplate[] } diff --git a/apps/desktop/src/main/services/HistoryService.ts b/apps/desktop/src/main/services/HistoryService.ts index 7a7fa5b..1ec59ee 100644 --- a/apps/desktop/src/main/services/HistoryService.ts +++ b/apps/desktop/src/main/services/HistoryService.ts @@ -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}`) diff --git a/apps/desktop/src/main/services/HotkeyService.ts b/apps/desktop/src/main/services/HotkeyService.ts index 6d9fe49..52bcbcb 100644 --- a/apps/desktop/src/main/services/HotkeyService.ts +++ b/apps/desktop/src/main/services/HotkeyService.ts @@ -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 } diff --git a/apps/desktop/src/main/services/LicenseService.ts b/apps/desktop/src/main/services/LicenseService.ts index 3565a53..9283e21 100644 --- a/apps/desktop/src/main/services/LicenseService.ts +++ b/apps/desktop/src/main/services/LicenseService.ts @@ -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}`) } /** diff --git a/apps/desktop/src/main/services/MeetingDocTemplateService.ts b/apps/desktop/src/main/services/MeetingDocTemplateService.ts index 56ae467..dacd8ba 100644 --- a/apps/desktop/src/main/services/MeetingDocTemplateService.ts +++ b/apps/desktop/src/main/services/MeetingDocTemplateService.ts @@ -145,7 +145,7 @@ const BUILTIN_TEMPLATES: MeetingDocTemplate[] = [ }, ] -interface MeetingDocTemplateStoreSchema { +type MeetingDocTemplateStoreSchema = { templates: MeetingDocTemplate[] } diff --git a/apps/desktop/src/main/services/MeetingModeService.ts b/apps/desktop/src/main/services/MeetingModeService.ts index 4c674ed..264681e 100644 --- a/apps/desktop/src/main/services/MeetingModeService.ts +++ b/apps/desktop/src/main/services/MeetingModeService.ts @@ -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 { + async exportDocument(documentId: string, format: MeetingExportFormat, targetPath?: string): Promise { 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 { + async exportTranscript(sessionId: string, targetPath?: string): Promise { 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') diff --git a/apps/desktop/src/main/services/MeetingSummaryService.ts b/apps/desktop/src/main/services/MeetingSummaryService.ts index 6dc8138..ee89506 100644 --- a/apps/desktop/src/main/services/MeetingSummaryService.ts +++ b/apps/desktop/src/main/services/MeetingSummaryService.ts @@ -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') } diff --git a/apps/desktop/src/main/services/MemoService.ts b/apps/desktop/src/main/services/MemoService.ts index f864ee0..4fe1f2f 100644 --- a/apps/desktop/src/main/services/MemoService.ts +++ b/apps/desktop/src/main/services/MemoService.ts @@ -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 } } } diff --git a/apps/desktop/src/main/services/RAGService.ts b/apps/desktop/src/main/services/RAGService.ts index c2c590b..319f91a 100644 --- a/apps/desktop/src/main/services/RAGService.ts +++ b/apps/desktop/src/main/services/RAGService.ts @@ -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 diff --git a/apps/desktop/src/main/services/ScreenContextService.ts b/apps/desktop/src/main/services/ScreenContextService.ts index 3aa25dc..412ad01 100644 --- a/apps/desktop/src/main/services/ScreenContextService.ts +++ b/apps/desktop/src/main/services/ScreenContextService.ts @@ -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 - releaseKey: (...keys: number[]) => Promise - Key: Record + pressKey: NutModule['keyboard']['pressKey'] + releaseKey: NutModule['keyboard']['releaseKey'] + Key: NutModule['Key'] } // ============================================================ diff --git a/apps/desktop/src/main/services/TextInsertService.ts b/apps/desktop/src/main/services/TextInsertService.ts index 0179fac..97f2f7e 100644 --- a/apps/desktop/src/main/services/TextInsertService.ts +++ b/apps/desktop/src/main/services/TextInsertService.ts @@ -45,7 +45,6 @@ interface TextInsertEvents { class TextInsertService extends EventEmitter { private _nutKeyboard: NutKeyboard | null = null - private _nutLoaded = false private _nutLoadPromise: Promise | 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 - releaseKey: (...keys: number[]) => Promise - type: (text: string) => Promise - Key: Record + pressKey: NutModule['keyboard']['pressKey'] + releaseKey: NutModule['keyboard']['releaseKey'] + type: NutModule['keyboard']['type'] + Key: NutModule['Key'] } // ── 싱글톤 ───────────────────────────────────────────── diff --git a/apps/desktop/src/main/services/VoiceModeService.ts b/apps/desktop/src/main/services/VoiceModeService.ts index 97c33ee..212710b 100644 --- a/apps/desktop/src/main/services/VoiceModeService.ts +++ b/apps/desktop/src/main/services/VoiceModeService.ts @@ -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 { @@ -482,7 +476,6 @@ class VoiceModeService extends EventEmitter { } private async _stopAudio(): Promise { - 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 { - // 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 = [] diff --git a/apps/desktop/src/main/services/stt/audio-utils.ts b/apps/desktop/src/main/services/stt/audio-utils.ts index 9a4d2cc..7d36747 100644 --- a/apps/desktop/src/main/services/stt/audio-utils.ts +++ b/apps/desktop/src/main/services/stt/audio-utils.ts @@ -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` 사이에 제네릭 + * 불일치가 있어 타입 단언이 필요하다. + */ +export function bufferToBody(data: Buffer): BodyInit { + return data as unknown as BodyInit +} diff --git a/apps/desktop/src/main/services/stt/drivers/AssemblyAIDriver.ts b/apps/desktop/src/main/services/stt/drivers/AssemblyAIDriver.ts index 0ab8bc1..8de29f7 100644 --- a/apps/desktop/src/main/services/stt/drivers/AssemblyAIDriver.ts +++ b/apps/desktop/src/main/services/stt/drivers/AssemblyAIDriver.ts @@ -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), }) diff --git a/apps/desktop/src/main/services/stt/drivers/DeepgramDriver.ts b/apps/desktop/src/main/services/stt/drivers/DeepgramDriver.ts index a689bb8..f9dcb57 100644 --- a/apps/desktop/src/main/services/stt/drivers/DeepgramDriver.ts +++ b/apps/desktop/src/main/services/stt/drivers/DeepgramDriver.ts @@ -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), }) diff --git a/apps/desktop/src/main/utils/safe-filename.ts b/apps/desktop/src/main/utils/safe-filename.ts new file mode 100644 index 0000000..09048b9 --- /dev/null +++ b/apps/desktop/src/main/utils/safe-filename.ts @@ -0,0 +1,30 @@ +// src/main/utils/safe-filename.ts +// 사용자 입력(제목 등)을 OS가 허용하는 파일명 조각으로 정규화한다. +// Windows 금지 문자, 제어 문자, 경로 구분자를 '-'로 치환하고 길이를 제한한다. + +/** Windows 파일명 금지 문자 (경로 구분자 포함). */ +const UNSAFE_FILENAME_CHARS = '\\/:*?"<>|%' + +/** + * 제목 같은 임의 문자열을 안전한 파일명 조각으로 변환한다. + * @param input 원본 문자열 (null/undefined 허용) + * @param fallback 비어 있거나 전부 치환된 경우 사용할 기본값 + * @param maxLength 최대 길이 (기본 60) + */ +export function toSafeFilenameSegment( + input: string | null | undefined, + fallback: string, + maxLength = 60, +): string { + const safe = Array.from(input?.trim() || fallback) + .map((character) => (isUnsafeFilenameCharacter(character) ? '-' : character)) + .slice(0, maxLength) + .join('') + .trim() + + return safe || fallback +} + +function isUnsafeFilenameCharacter(character: string): boolean { + return character.charCodeAt(0) < 0x20 || UNSAFE_FILENAME_CHARS.includes(character) +} \ No newline at end of file diff --git a/apps/desktop/src/main/windows/WindowManager.ts b/apps/desktop/src/main/windows/WindowManager.ts index 573cc79..6f39eea 100644 --- a/apps/desktop/src/main/windows/WindowManager.ts +++ b/apps/desktop/src/main/windows/WindowManager.ts @@ -139,7 +139,7 @@ export function createMainWindow(): BrowserWindow { mainWindow = null }) - mainWindow.webContents.on('console-message', (event, level, message, line, sourceId) => { + mainWindow.webContents.on('console-message', (_event, level, message, line, sourceId) => { logger.info(`[Renderer] [${level}] ${message} (${sourceId}:${line})`) }) diff --git a/apps/desktop/src/renderer/components/AppLayout.tsx b/apps/desktop/src/renderer/components/AppLayout.tsx index 18d158c..d74f2c8 100644 --- a/apps/desktop/src/renderer/components/AppLayout.tsx +++ b/apps/desktop/src/renderer/components/AppLayout.tsx @@ -282,9 +282,9 @@ export function AppLayout(): React.ReactElement { bgcolor: d3roPalette.glass.surface, transition: 'all 0.18s ease', '&:hover': { - color: d3roPalette.tag.purpleText, + color: d3roPalette.tag.purple, bgcolor: d3roPalette.glass.raised, - borderColor: d3roPalette.tag.purpleBorder, + borderColor: d3roPalette.tag.purple, }, }} > diff --git a/apps/desktop/src/renderer/components/CodexOAuthGuideModal.tsx b/apps/desktop/src/renderer/components/CodexOAuthGuideModal.tsx index c41d725..a24f6e4 100644 --- a/apps/desktop/src/renderer/components/CodexOAuthGuideModal.tsx +++ b/apps/desktop/src/renderer/components/CodexOAuthGuideModal.tsx @@ -53,7 +53,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp backgroundImage: 'none', border: `1px solid ${d3roPalette.border.default}`, boxShadow: d3roShadow.chassis, - borderRadius: d3roRadius.md, + borderRadius: d3roRadius.inner, }, }} > @@ -139,7 +139,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp p: 2, bgcolor: d3roPalette.bg.inset, border: `1px solid ${d3roPalette.border.subtle}`, - borderRadius: d3roRadius.sm, + borderRadius: d3roRadius.small, }} > @@ -157,7 +157,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp fontWeight: 500, bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, - '&:hover': { bgcolor: d3roPalette.accent.hover }, + '&:hover': { bgcolor: d3roPalette.accent.light }, }} > API Keys 페이지 열기 @@ -174,7 +174,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp p: 2, bgcolor: d3roPalette.bg.inset, border: `1px solid ${d3roPalette.border.subtle}`, - borderRadius: d3roRadius.sm, + borderRadius: d3roRadius.small, }} > @@ -201,7 +201,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp p: 2, bgcolor: d3roPalette.bg.inset, border: `1px solid ${d3roPalette.border.subtle}`, - borderRadius: d3roRadius.sm, + borderRadius: d3roRadius.small, }} > @@ -248,7 +248,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp p: 2, bgcolor: d3roPalette.bg.inset, border: `1px solid ${d3roPalette.border.subtle}`, - borderRadius: d3roRadius.sm, + borderRadius: d3roRadius.small, mb: 2, }} > @@ -289,7 +289,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp p: 2, bgcolor: d3roPalette.bg.inset, border: `1px solid ${d3roPalette.border.subtle}`, - borderRadius: d3roRadius.sm, + borderRadius: d3roRadius.small, }} > @@ -320,7 +320,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp p: 2, bgcolor: d3roPalette.bg.inset, border: `1px solid ${d3roPalette.border.subtle}`, - borderRadius: d3roRadius.sm, + borderRadius: d3roRadius.small, display: 'flex', alignItems: 'center', justifyContent: 'space-between', @@ -352,7 +352,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp p: 2, bgcolor: d3roPalette.bg.inset, border: `1px solid ${d3roPalette.border.subtle}`, - borderRadius: d3roRadius.sm, + borderRadius: d3roRadius.small, display: 'flex', alignItems: 'center', justifyContent: 'space-between', @@ -384,7 +384,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp p: 2, bgcolor: d3roPalette.bg.inset, border: `1px solid ${d3roPalette.border.subtle}`, - borderRadius: d3roRadius.sm, + borderRadius: d3roRadius.small, display: 'flex', alignItems: 'center', justifyContent: 'space-between', @@ -416,7 +416,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp p: 2, bgcolor: d3roPalette.bg.inset, border: `1px solid ${d3roPalette.border.subtle}`, - borderRadius: d3roRadius.sm, + borderRadius: d3roRadius.small, display: 'flex', alignItems: 'center', justifyContent: 'space-between', @@ -457,7 +457,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp fontSize: d3roTypo.label.size, bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, - '&:hover': { bgcolor: d3roPalette.accent.hover }, + '&:hover': { bgcolor: d3roPalette.accent.light }, }} > 확인 및 닫기 diff --git a/apps/desktop/src/renderer/components/FileDropZone.tsx b/apps/desktop/src/renderer/components/FileDropZone.tsx index 31d5209..3436989 100644 --- a/apps/desktop/src/renderer/components/FileDropZone.tsx +++ b/apps/desktop/src/renderer/components/FileDropZone.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { Box, LinearProgress, IconButton, Tooltip } from '@mui/material' import { Copy, X, FileUp } from 'lucide-react' -import { MetalCard, PhosphorText, Led } from '@d3ro/ui/components/ds' +import { MetalCard, PhosphorText, Led, PhysicalButton } from '@d3ro/ui/components/ds' import { d3roPalette, d3roTypo, d3roRadius } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import type { diff --git a/apps/desktop/src/renderer/components/LicenseTab.tsx b/apps/desktop/src/renderer/components/LicenseTab.tsx index 59ab5cd..b8966c4 100644 --- a/apps/desktop/src/renderer/components/LicenseTab.tsx +++ b/apps/desktop/src/renderer/components/LicenseTab.tsx @@ -46,7 +46,7 @@ export function LicenseTab(): React.ReactElement { {/* ── 14일 Reverse-Trial 상태 배너 (활성화 시) ── */} {licenseInfo?.isTrial && licenseInfo.trialExpiresAt && ( - + diff --git a/apps/desktop/src/renderer/components/OllamaGuideModal.tsx b/apps/desktop/src/renderer/components/OllamaGuideModal.tsx index 54e1559..6f8d648 100644 --- a/apps/desktop/src/renderer/components/OllamaGuideModal.tsx +++ b/apps/desktop/src/renderer/components/OllamaGuideModal.tsx @@ -392,7 +392,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, fontWeight: 500, - '&:hover': { bgcolor: d3roPalette.accent.hover }, + '&:hover': { bgcolor: d3roPalette.accent.light }, }} > {starting ? '실행 중...' : 'Ollama 자동 실행'} @@ -410,7 +410,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac {/* ── Step 1: Ollama 설치 및 기동 ── */} - + Ollama 설치 및 백그라운드 실행 @@ -462,7 +462,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac - + 한국어 최적화 추천 모델 1-클릭 다운로드 @@ -564,7 +564,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac py: 0.25, bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, - '&:hover': { bgcolor: d3roPalette.accent.hover }, + '&:hover': { bgcolor: d3roPalette.accent.light }, }} > 다운로드 (Pull) @@ -624,7 +624,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac minWidth: 100, bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, - '&:hover': { bgcolor: d3roPalette.accent.hover }, + '&:hover': { bgcolor: d3roPalette.accent.light }, }} > {testing ? '처리 중' : '테스트'} diff --git a/apps/desktop/src/renderer/components/OnboardingModal.tsx b/apps/desktop/src/renderer/components/OnboardingModal.tsx index 74029cd..a7aa845 100644 --- a/apps/desktop/src/renderer/components/OnboardingModal.tsx +++ b/apps/desktop/src/renderer/components/OnboardingModal.tsx @@ -31,7 +31,7 @@ import { ArrowRight, ShieldCheck, } from 'lucide-react' -import { d3roPalette, d3roRadius, typoSx, d3roFontMono, d3roFontSans } from '@d3ro/ui/theme' +import { d3roPalette, d3roRadius, d3roShadow, typoSx, d3roFontMono, d3roFontSans } from '@d3ro/ui/theme' import { Led } from '@d3ro/ui/components/ds' import type { LLMModel, LLMStatus } from '@d3ro/core/types' @@ -311,7 +311,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. fontWeight: 500, bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, - '&:hover': { bgcolor: d3roPalette.accent.hover }, + '&:hover': { bgcolor: d3roPalette.accent.light }, }} > 로컬 AI 모드로 시작 @@ -504,7 +504,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. fontWeight: 500, bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, - '&:hover': { bgcolor: d3roPalette.accent.hover }, + '&:hover': { bgcolor: d3roPalette.accent.light }, }} > {hasInstalledLlm ? 'Gemma 2 (2B) 추가 다운로드' : 'Gemma 2 (2B) 1-클릭 다운로드'} diff --git a/apps/desktop/src/renderer/components/STTTab.tsx b/apps/desktop/src/renderer/components/STTTab.tsx index 4bb062c..da7117d 100644 --- a/apps/desktop/src/renderer/components/STTTab.tsx +++ b/apps/desktop/src/renderer/components/STTTab.tsx @@ -202,10 +202,10 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen sx={{ p: 1.5, cursor: 'pointer', - borderRadius: d3roRadius.sm, + borderRadius: d3roRadius.small, bgcolor: isSelected ? d3roPalette.bg.elevated : d3roPalette.bg.inset, border: isSelected ? `1.5px solid ${d3roPalette.accent.main}` : `1px solid ${d3roPalette.border.subtle}`, - boxShadow: isSelected ? d3roShadow.elevated : 'none', + boxShadow: isSelected ? d3roShadow.card : 'none', transition: 'all 120ms ease-out', display: 'flex', flexDirection: 'column', @@ -245,7 +245,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen height: 18, fontSize: '9px', fontWeight: 500, - bgcolor: isSelected ? d3roPalette.accent.muted : 'transparent', + bgcolor: isSelected ? d3roPalette.accent.dim : 'transparent', color: isSelected ? d3roPalette.accent.main : d3roPalette.text.disabled, border: isSelected ? 'none' : `1px solid ${d3roPalette.border.subtle}`, }} @@ -263,7 +263,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen p: 2, bgcolor: d3roPalette.bg.inset, border: `1px solid ${d3roPalette.border.default}`, - borderRadius: d3roRadius.md, + borderRadius: d3roRadius.inner, }} > @@ -328,7 +328,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen p: 1.5, bgcolor: d3roPalette.bg.elevated, border: `1px solid ${d3roPalette.border.default}`, - borderRadius: d3roRadius.sm, + borderRadius: d3roRadius.small, display: 'flex', alignItems: 'center', justifyContent: 'space-between', @@ -354,7 +354,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen fontWeight: 500, bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, - '&:hover': { bgcolor: d3roPalette.accent.hover }, + '&:hover': { bgcolor: d3roPalette.accent.light }, }} > {isDownloading ? `다운로드 중 (${downloadPercent}%)` : '모델 다운로드'} @@ -440,7 +440,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen sx={{ bgcolor: d3roPalette.bg.inset, border: `1px solid ${d3roPalette.border.subtle}`, - borderRadius: `${d3roRadius.sm} !important`, + borderRadius: `${d3roRadius.small} !important`, '&:before': { display: 'none' }, }} > diff --git a/apps/desktop/src/renderer/components/meeting/MarkdownEditor.tsx b/apps/desktop/src/renderer/components/meeting/MarkdownEditor.tsx index fb38ddb..a36ff1d 100644 --- a/apps/desktop/src/renderer/components/meeting/MarkdownEditor.tsx +++ b/apps/desktop/src/renderer/components/meeting/MarkdownEditor.tsx @@ -1,7 +1,7 @@ // src/renderer/components/meeting/MarkdownEditor.tsx // Phase 14.5: 마크다운 렌더링/편집 토글 컴포넌트 -import { useState } from 'react' +import { useState, useEffect, useCallback } from 'react' import { Box } from '@mui/material' import { MarkdownRenderer } from './MarkdownRenderer' import { PhysicalButton } from '@d3ro/ui/components/ds' @@ -15,12 +15,26 @@ interface MarkdownEditorProps { } export function MarkdownEditor({ - content, + content: initialContent, onChange, readOnly = false, }: MarkdownEditorProps): React.ReactElement { const { t } = useI18n() const [preview, setPreview] = useState(true) + const [draft, setDraft] = useState(initialContent) + + useEffect(() => { + setDraft(initialContent) + }, [initialContent]) + + const handleTextChange = useCallback( + (e: React.ChangeEvent) => { + const val = e.target.value + setDraft(val) + onChange(val) + }, + [onChange], + ) return ( @@ -49,12 +63,12 @@ export function MarkdownEditor({ {/* 콘텐츠 영역 */} {preview || readOnly ? ( - + ) : ( ) => onChange(e.target.value)} + value={draft} + onChange={handleTextChange} sx={{ width: '100%', height: '100%', diff --git a/apps/desktop/src/renderer/components/payment/CheckoutModal.tsx b/apps/desktop/src/renderer/components/payment/CheckoutModal.tsx index 44f04d3..b73c597 100644 --- a/apps/desktop/src/renderer/components/payment/CheckoutModal.tsx +++ b/apps/desktop/src/renderer/components/payment/CheckoutModal.tsx @@ -81,11 +81,11 @@ export function CheckoutModal({ left: '50%', transform: 'translate(-50%, -50%)', width: { xs: '90%', sm: 520 }, - bgcolor: d3roPalette.bg.modal, + bgcolor: d3roPalette.bg.card, backdropFilter: 'blur(28px)', border: `1px solid ${d3roPalette.glass.hairline}`, borderRadius: d3roRadius.outer, - boxShadow: d3roShadow.modal, + boxShadow: d3roShadow.dialog, p: 3, outline: 'none', display: 'flex', @@ -139,9 +139,9 @@ export function CheckoutModal({ gap: 1.5 }} > - + 서버에서 구독 활성화를 확인했습니다. @@ -155,7 +155,7 @@ export function CheckoutModal({ sx={{ mt: 2, bgcolor: d3roPalette.accent.main, - color: '#fff', + color: 'var(--d3-text-inverse)', px: 3, borderRadius: '8px' }} @@ -183,8 +183,8 @@ export function CheckoutModal({ borderRadius: d3roRadius.inner, border: `1px solid ${tier === candidate ? d3roPalette.accent.main : d3roPalette.glass.hairline}`, bgcolor: - tier === candidate ? 'rgba(59, 130, 246, 0.18)' : 'rgba(255,255,255,0.03)', - color: '#fff', + tier === candidate ? 'var(--d3-accent-glow)' : 'var(--d3-overlay-strong)', + color: 'var(--d3-text-inverse)', fontFamily: d3roFontSans, fontWeight: 500, textTransform: 'none' @@ -199,7 +199,7 @@ export function CheckoutModal({ sx={{ p: 2, borderRadius: d3roRadius.inner, - bgcolor: 'rgba(17, 26, 48, 0.7)', + bgcolor: 'var(--d3-bg-card-soft)', border: `1px solid ${d3roPalette.accent.dim}`, display: 'flex', gap: 1.25, @@ -213,7 +213,7 @@ export function CheckoutModal({ fontFamily: d3roFontSans, fontSize: '13px', fontWeight: 500, - color: '#fff' + color: 'var(--d3-text-inverse)' }} > Stripe 보안 결제 @@ -252,7 +252,7 @@ export function CheckoutModal({ sx={{ py: 1.25, bgcolor: d3roPalette.accent.main, - color: '#fff', + color: 'var(--d3-text-inverse)', fontWeight: 500, fontSize: '13px', borderRadius: '10px', @@ -279,7 +279,7 @@ export function CheckoutModal({ sx={{ py: 1.25, bgcolor: d3roPalette.accent.main, - color: '#fff', + color: 'var(--d3-text-inverse)', fontWeight: 500, fontSize: '13px', borderRadius: '10px' diff --git a/apps/desktop/src/renderer/components/support/SupportModal.tsx b/apps/desktop/src/renderer/components/support/SupportModal.tsx index ca90917..976b9a1 100644 --- a/apps/desktop/src/renderer/components/support/SupportModal.tsx +++ b/apps/desktop/src/renderer/components/support/SupportModal.tsx @@ -7,6 +7,7 @@ import { Box, Typography, Button, + IconButton, Tabs, Tab, TextField, @@ -115,11 +116,11 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE transform: 'translate(-50%, -50%)', width: { xs: '95%', sm: 680 }, maxHeight: '90vh', - bgcolor: d3roPalette.bg.modal, + bgcolor: d3roPalette.bg.card, backdropFilter: 'blur(28px)', border: `1px solid ${d3roPalette.glass.hairline}`, borderRadius: d3roRadius.outer, - boxShadow: d3roShadow.modal, + boxShadow: d3roShadow.dialog, p: 3, outline: 'none', display: 'flex', @@ -131,14 +132,19 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE {/* Header */} - + D3RO Voice Customer Assistance (CA/CS) - + - + {/* Navigation Tabs */} @@ -157,9 +163,9 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE fontWeight: 600, color: d3roPalette.text.secondary, textTransform: 'none', - '&.Mui-selected': { color: d3roPalette.tag.purpleText }, + '&.Mui-selected': { color: d3roPalette.tag.purple }, }, - '& .MuiTabs-indicator': { bgcolor: d3roPalette.tag.purpleText }, + '& .MuiTabs-indicator': { bgcolor: d3roPalette.tag.purple }, }} > } iconPosition="start" label="AI 어시스턴트" /> @@ -177,7 +183,7 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE overflowY: 'auto', p: 2, borderRadius: d3roRadius.inner, - bgcolor: 'rgba(10, 14, 28, 0.7)', + bgcolor: 'var(--d3-bg-inset-soft)', border: `1px solid ${d3roPalette.glass.hairline}`, display: 'flex', flexDirection: 'column', @@ -192,8 +198,8 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE maxWidth: '85%', p: 1.5, borderRadius: msg.role === 'user' ? '14px 14px 2px 14px' : '14px 14px 14px 2px', - bgcolor: msg.role === 'user' ? d3roPalette.accent.main : 'rgba(26, 38, 68, 0.8)', - color: '#ffffff', + bgcolor: msg.role === 'user' ? d3roPalette.accent.main : 'var(--d3-bg-raised-soft)', + color: 'var(--d3-text-inverse)', fontSize: '12px', fontFamily: d3roFontSans, lineHeight: 1.5, @@ -216,21 +222,21 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE @@ -247,8 +253,8 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE onKeyDown={(e) => e.key === 'Enter' && handleAskAI()} sx={{ '& .MuiInputBase-root': { - bgcolor: 'rgba(10, 14, 28, 0.9)', - color: '#fff', + bgcolor: 'var(--d3-bg-inset-soft)', + color: 'var(--d3-text-inverse)', fontSize: '12px', fontFamily: d3roFontSans, borderRadius: '10px', @@ -258,14 +264,14 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE @@ -370,7 +376,7 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE {/* TAB 3: 7-DAY REFUND CHECKER */} {tabIndex === 3 && ( - + 7일 이내 무조건 100% 자동 환불 확인 @@ -380,19 +386,19 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE {refundStatus === 'eligible' && ( - - + + ✓ 100% 전액 환불 대상입니다! - + • 결제일: 최근 2일 전 (자격 충족) - + • 클라우드 사용량: 0% (자격 충족) @@ -406,7 +412,7 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE sx={{ py: 1.2, bgcolor: d3roPalette.accent.main, - color: '#fff', + color: 'var(--d3-text-inverse)', fontWeight: 500, fontSize: '12px', borderRadius: '10px', diff --git a/apps/desktop/src/renderer/hooks/useLicenseState.ts b/apps/desktop/src/renderer/hooks/useLicenseState.ts index 692ad95..212eedb 100644 --- a/apps/desktop/src/renderer/hooks/useLicenseState.ts +++ b/apps/desktop/src/renderer/hooks/useLicenseState.ts @@ -79,7 +79,7 @@ export function useLicenseState(): LicenseState { cloud, currentTier, isFree: currentTier === 'free', - isPro: currentTier === 'pro', + isPro: currentTier !== 'free', handleUpgrade, handleOpenBilling, openCloudSettings, diff --git a/apps/desktop/src/renderer/pages/KnowledgeBasePage.tsx b/apps/desktop/src/renderer/pages/KnowledgeBasePage.tsx index c9284c7..1bc4639 100644 --- a/apps/desktop/src/renderer/pages/KnowledgeBasePage.tsx +++ b/apps/desktop/src/renderer/pages/KnowledgeBasePage.tsx @@ -21,6 +21,7 @@ export function KnowledgeBasePage(): React.ReactElement { const [indexProgress, setIndexProgress] = useState(null) const [adding, setAdding] = useState(false) const [addError, setAddError] = useState(null) + const [queryError, setQueryError] = useState(null) const loadDocuments = useCallback(async () => { setLoading(true) @@ -36,6 +37,9 @@ export function KnowledgeBasePage(): React.ReactElement { useEffect(() => { const unsubProgress = window.electronAPI.rag.onIndexProgress((data) => { setIndexProgress(data) + if (data.currentChunk <= 1) { + loadDocuments() + } }) const unsubComplete = window.electronAPI.rag.onIndexComplete(() => { setIndexProgress(null) @@ -85,9 +89,12 @@ export function KnowledgeBasePage(): React.ReactElement { if (!query.trim()) return setQuerying(true) setResult(null) + setQueryError(null) const resp = await window.electronAPI.rag.query({ query: query.trim() }) if (resp.success) { setResult(resp.data) + } else { + setQueryError(resp.error?.message || '검색 중 오류가 발생했습니다.') } setQuerying(false) }, [query]) @@ -231,6 +238,15 @@ export function KnowledgeBasePage(): React.ReactElement { )} + {queryError && ( + + + + {queryError} + + + )} + {result && ( diff --git a/apps/desktop/src/renderer/pages/MeetingModePage.tsx b/apps/desktop/src/renderer/pages/MeetingModePage.tsx index 80e93e1..d43f553 100644 --- a/apps/desktop/src/renderer/pages/MeetingModePage.tsx +++ b/apps/desktop/src/renderer/pages/MeetingModePage.tsx @@ -428,7 +428,7 @@ export function MeetingModePage(): React.ReactElement { sx={{ p: 2, bgcolor: d3roPalette.bg.inset, - borderRadius: d3roRadius.md, + borderRadius: d3roRadius.inner, border: `1px solid ${d3roPalette.border.subtle}`, }} > @@ -475,7 +475,7 @@ export function MeetingModePage(): React.ReactElement { sx={{ color: d3roPalette.text.secondary, borderColor: d3roPalette.border.default, - borderRadius: d3roRadius.sm, + borderRadius: d3roRadius.small, fontSize: '12px', }} > diff --git a/apps/desktop/src/renderer/popups/command-popup/script.js b/apps/desktop/src/renderer/popups/command-popup/script.js index f40a72b..d41255b 100644 --- a/apps/desktop/src/renderer/popups/command-popup/script.js +++ b/apps/desktop/src/renderer/popups/command-popup/script.js @@ -33,7 +33,7 @@ if (commands.length === 0) { var empty = document.createElement('div') - empty.style.cssText = 'color: rgba(255,255,255,0.4); font-size: 13px; text-align: center; padding: 24px 16px;' + empty.style.cssText = 'color: var(--d3-overlay-strong); font-size: 13px; text-align: center; padding: 24px 16px;' empty.textContent = i18nStrings.noCommands || 'No commands' itemsContainer.appendChild(empty) return diff --git a/apps/desktop/tests/e2e/red_team_cycle1.spec.ts b/apps/desktop/tests/e2e/red_team_cycle1.spec.ts new file mode 100644 index 0000000..2df0f24 --- /dev/null +++ b/apps/desktop/tests/e2e/red_team_cycle1.spec.ts @@ -0,0 +1,327 @@ +// apps/desktop/tests/e2e/red_team_cycle1.spec.ts +// Extreme Red Team: Headful Deep Interactive Testing for Cycle 1 (Core CRUD) + +import { test, expect, _electron as electron, type Page, type ElectronApplication } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; + +const SCREENSHOT_DIR = 'C:/Users/encep/.gemini/antigravity/brain/bbff18a3-721d-4c43-989f-1d6964e15be7/screenshots'; + +test.describe('Extreme Red Team - Cycle 1: Core CRUD & State Integrity', () => { + test.describe.configure({ mode: 'serial' }); + + let electronApp: ElectronApplication; + let window: Page; + const consoleErrors: string[] = []; + const uncaughtExceptions: string[] = []; + + test.beforeAll(async () => { + if (!fs.existsSync(SCREENSHOT_DIR)) { + fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); + } + + const testUserDataDir = path.join( + 'C:/Users/encep/AppData/Local/Temp', + 'playwright-redteam-cycle1-' + Date.now() + ); + + electronApp = await electron.launch({ + args: [ + 'out/main/index.js', + '--disable-gpu', + '--no-sandbox', + `--user-data-dir=${testUserDataDir}`, + ], + env: { + ...process.env, + NODE_ENV: 'test', + }, + }); + + // Find main window (skip popups like recording-tip, caption-overlay, etc.) + for (let i = 0; i < 50; i++) { + for (const w of electronApp.windows()) { + try { + const url = w.url(); + if (url && url.includes('index.html') && !url.includes('popups/')) { + window = w; + break; + } + } catch { + // window might be navigating + } + } + if (window) break; + await new Promise((r) => setTimeout(r, 400)); + } + + if (!window) { + window = electronApp.windows()[0] || (await electronApp.firstWindow()); + } + + await window.waitForLoadState('domcontentloaded'); + + // Attach Error Sentinels + window.on('pageerror', (err) => { + const msg = `[PAGE_ERROR] ${err.message}\n${err.stack || ''}`; + uncaughtExceptions.push(msg); + // eslint-disable-next-line no-console + console.error(msg); + }); + + window.on('console', (msg) => { + if (msg.type() === 'error') { + const text = msg.text(); + // Filter out benign CSP dev warnings + if (!text.includes('Electron Security Warning') && !text.includes('Content Security Policy')) { + consoleErrors.push(text); + } + } + }); + + // Bypass Onboarding modal for testing CRUD + await window.evaluate(async () => { + if (window.electronAPI?.config) { + await window.electronAPI.config.set({ key: 'onboardingCompleted', value: true }); + await window.electronAPI.config.set({ key: 'appUsageMode', value: 'online' }); + await window.electronAPI.config.set({ key: 'llmBackend', value: 'online' }); + } + }); + + await window.waitForTimeout(500); + await window.reload(); + await window.waitForLoadState('domcontentloaded'); + await window.waitForTimeout(1000); + }); + + test.afterAll(async () => { + if (electronApp) { + await electronApp.close(); + } + }); + + test('RT-01: Full Navigation Across All Primary Sidebar Routes', async () => { + // 1. Dashboard + await window.locator('text=/^(Dashboard|대시보드)$/').first().click(); + await expect(window.locator('text=/^(Current Backend|현재 백엔드)$/').first()).toBeVisible({ timeout: 5000 }); + await window.screenshot({ path: path.join(SCREENSHOT_DIR, 'rt01_01_dashboard.png') }); + + // 2. History + await window.locator('text=/^(History|히스토리|기록)$/').first().click(); + await expect(window.locator('text=/^(History|히스토리|변환 기록)$/').first()).toBeVisible({ timeout: 5000 }); + + // 3. Dictionary + await window.locator('text=/^(Dictionary|사전|단어장)$/').first().click(); + await expect(window.locator('text=/^(Custom Dictionary|커스텀 사전|단어장)$/').first()).toBeVisible({ timeout: 5000 }); + + // 4. Commands + await window.locator('text=/^(Commands|명령어)$/').first().click(); + await expect(window.locator('text=/^(LLM Commands|LLM 명령어)$/').first()).toBeVisible({ timeout: 5000 }); + + // 5. Voice Conversation + await window.locator('text=/^(Conversation|대화|음성 대화)$/').first().click(); + await expect(window.locator('text=/^(Voice Conversation|음성 대화)$/').first()).toBeVisible({ timeout: 5000 }); + + // 6. Knowledge Base + await window.locator('text=/^(Knowledge|지식 베이스)$/').first().click(); + await expect(window.locator('text=/^(Knowledge Base|지식 베이스)$/').first()).toBeVisible({ timeout: 5000 }); + + // 7. Meeting Mode + await window.locator('text=/^(Meeting|회의|회의 모드)$/').first().click(); + await expect(window.locator('text=/^(Meeting Mode|회의 모드)$/').first()).toBeVisible({ timeout: 5000 }); + + expect(uncaughtExceptions).toHaveLength(0); + }); + + test('RT-02: Dictionary Deep CRUD, Boundary Validation & Search Interaction', async () => { + // Navigate to Dictionary Tab + await window.locator('text=/^(Dictionary|사전|단어장)$/').first().click(); + await expect(window.locator('text=/^(Custom Dictionary|커스텀 사전|단어장)$/').first()).toBeVisible(); + + // Click Add Word button (PhysicalButton tone="accent") + const addBtn = window.locator('header button:has-text("추가"), header button:has-text("Add"), button:has-text("추가"), button:has-text("Add")').first(); + await expect(addBtn).toBeVisible({ timeout: 5000 }); + await addBtn.click(); + await window.waitForTimeout(500); + + // Verify Add Dialog opens + const dialogTitle = window.locator('text=/^(단어 추가|Add Word)$/').first(); + await expect(dialogTitle).toBeVisible({ timeout: 5000 }); + + // Test Boundary: Empty input should disable save button + const saveBtn = window.locator('div[role="dialog"] button:has-text("저장"), div[role="dialog"] button:has-text("Save")').first(); + await expect(saveBtn).toBeDisabled(); + + // Fill valid data + const wordInput = window.locator('div[role="dialog"] input').first(); + const pronInput = window.locator('div[role="dialog"] input').nth(1); + + await wordInput.fill('D3RO_Voice_RedTeam_Keyword'); + await pronInput.fill('디쓰리오 보이스 레드팀 키워드'); + await expect(saveBtn).toBeEnabled(); + + // Save + await saveBtn.click(); + await window.waitForTimeout(600); + + // Verify new word appears in the list + const createdItem = window.locator('text=D3RO_Voice_RedTeam_Keyword').first(); + await expect(createdItem).toBeVisible({ timeout: 5000 }); + await window.screenshot({ path: path.join(SCREENSHOT_DIR, 'rt02_01_dict_created.png') }); + + // Test Search Functionality + const searchInput = window.locator('input[placeholder*="검색"], input[placeholder*="Search"]').first(); + await searchInput.fill('RedTeam'); + await window.waitForTimeout(400); + await expect(createdItem).toBeVisible(); + + await searchInput.fill('NonExistentKeywordXYZ999'); + await window.waitForTimeout(400); + await expect(window.locator('text=D3RO_Voice_RedTeam_Keyword')).not.toBeVisible(); + await expect(window.locator('text=/^(검색 결과 없음|검색 결과가 없습니다|일치하는 단어가 없습니다|No results)$/').first()).toBeVisible({ timeout: 5000 }); + + // Clear search + await searchInput.fill(''); + await window.waitForTimeout(400); + await expect(createdItem).toBeVisible(); + + // Test Edit + const editBtn = window.locator('button[aria-label="편집"], button[aria-label="Edit"]').first(); + await expect(editBtn).toBeVisible({ timeout: 5000 }); + await editBtn.click(); + await window.waitForTimeout(400); + + // Verify Edit Dialog opens + await expect(window.locator('text=/^(단어 편집|단어 수정|Edit Word)$/').first()).toBeVisible({ timeout: 5000 }); + const editWordInput = window.locator('div[role="dialog"] input').first(); + await editWordInput.fill('D3RO_Voice_RedTeam_Keyword_MOD'); + const editSaveBtn = window.locator('div[role="dialog"] button:has-text("저장"), div[role="dialog"] button:has-text("Save")').first(); + await editSaveBtn.click(); + await window.waitForTimeout(600); + + // Verify edited word appears + const editedItem = window.locator('text=D3RO_Voice_RedTeam_Keyword_MOD').first(); + await expect(editedItem).toBeVisible({ timeout: 5000 }); + + // Test Delete + const deleteBtn = window.locator('button[aria-label="삭제"], button[aria-label="Delete"]').first(); + await expect(deleteBtn).toBeVisible({ timeout: 5000 }); + await deleteBtn.click(); + await window.waitForTimeout(700); + + // Verify deleted item is gone + await expect(window.locator('text=D3RO_Voice_RedTeam_Keyword_MOD')).not.toBeVisible(); + + // Test Adversarial Unicode & Long Text Entry + await addBtn.click(); + await window.waitForTimeout(500); + const complexInput = '🔥⚡ 레드팀 极限 测试 12345 🚀🤖 (Special!@#$%)'; + await wordInput.fill(complexInput); + await pronInput.fill('특수문자 발음'); + await saveBtn.click(); + await window.waitForTimeout(600); + + // Verify complex item rendered safely without unhandled error + const complexItem = window.locator(`text=${complexInput}`).first(); + await expect(complexItem).toBeVisible({ timeout: 5000 }); + + // Cleanup complex item + const complexDeleteBtn = window.locator('button[aria-label="삭제"], button[aria-label="Delete"]').first(); + await complexDeleteBtn.click(); + await window.waitForTimeout(700); + await expect(complexItem).not.toBeVisible(); + + expect(uncaughtExceptions).toHaveLength(0); + }); + + test('RT-03: LLM Commands Deep CRUD, Activation Toggle & Interactive Form Verification', async () => { + // Navigate to Commands Tab + await window.locator('text=/^(Commands|명령어)$/').first().click(); + await expect(window.locator('text=/^(LLM Commands|LLM 명령어)$/').first()).toBeVisible(); + + // Click Add Command button + const addCommandBtn = window.locator('header button:has-text("추가"), header button:has-text("Add"), button:has-text("추가"), button:has-text("Add")').first(); + await expect(addCommandBtn).toBeVisible({ timeout: 5000 }); + await addCommandBtn.click(); + await window.waitForTimeout(500); + + // Verify Dialog opens + const dialogTitle = window.locator('text=/^(명령어 추가|Add Command)$/').first(); + await expect(dialogTitle).toBeVisible({ timeout: 5000 }); + + // Fill Command Form + const nameInput = window.locator('div[role="dialog"] input').first(); + const descInput = window.locator('div[role="dialog"] input').nth(1); + const promptInput = window.locator('div[role="dialog"] textarea').first(); + + await nameInput.fill('RT_BulletPoints_Custom'); + await descInput.fill('레드팀 불릿포인트 요약 테스트'); + await promptInput.fill('다음 음성 텍스트를 글머리 기호(bullet points)로 간결하게 요약해주세요:\n\n{text}'); + + const saveBtn = window.locator('div[role="dialog"] button:has-text("저장"), div[role="dialog"] button:has-text("Save")').first(); + await saveBtn.click(); + await window.waitForTimeout(600); + + // Verify card is added + const createdCmd = window.locator('text=RT_BulletPoints_Custom').first(); + await expect(createdCmd).toBeVisible({ timeout: 5000 }); + await window.screenshot({ path: path.join(SCREENSHOT_DIR, 'rt03_01_command_created.png') }); + + // Test Edit Command + const editBtn = window.getByRole('button', { name: '편집' }).last(); + await expect(editBtn).toBeVisible({ timeout: 5000 }); + await editBtn.click(); + await window.waitForTimeout(400); + + await expect(window.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + const editNameInput = window.getByRole('dialog').locator('input').first(); + await editNameInput.fill('RT_BulletPoints_Custom_MOD'); + await window.getByRole('dialog').getByRole('button', { name: '저장' }).click(); + await window.waitForTimeout(600); + + // Verify modified command + const editedCmd = window.locator('text=RT_BulletPoints_Custom_MOD').first(); + await expect(editedCmd).toBeVisible({ timeout: 5000 }); + + // Delete Command + const deleteBtn = window.getByRole('button', { name: '삭제' }).first(); + await expect(deleteBtn).toBeVisible({ timeout: 5000 }); + await deleteBtn.click(); + await window.waitForTimeout(700); + + // Verify deleted + await expect(window.locator('text=RT_BulletPoints_Custom_MOD')).not.toBeVisible(); + + expect(uncaughtExceptions).toHaveLength(0); + }); + + test('RT-04: History Search, Filter and Card Interaction Integrity', async () => { + // Navigate to History Tab + await window.locator('text=/^(History|히스토리|기록)$/').first().click(); + await expect(window.locator('text=/^(History|히스토리|변환 기록)$/').first()).toBeVisible(); + + // Verify Search Input exists and accepts query + const searchInput = window.locator('input[placeholder*="검색"], input[placeholder*="Search"]').first(); + await expect(searchInput).toBeVisible(); + await searchInput.fill('오늘'); + await window.waitForTimeout(300); + await searchInput.fill(''); + await window.waitForTimeout(300); + + await window.screenshot({ path: path.join(SCREENSHOT_DIR, 'rt04_01_history_view.png') }); + expect(uncaughtExceptions).toHaveLength(0); + }); + + test('RT-05: Dashboard Health & Stat Widgets Non-Crashing Check', async () => { + // Navigate to Dashboard Tab + await window.locator('text=/^(Dashboard|대시보드)$/').first().click(); + await expect(window.locator('text=/^(Current Backend|현재 백엔드)$/').first()).toBeVisible({ timeout: 5000 }); + + // Check stats and status widgets + await expect(window.locator('text=/^(전사 기록|최근 변환 기록|최근 전사|Recent Transcriptions)/').first()).toBeVisible({ timeout: 5000 }); + await expect(window.locator('text=/^(시스템 정상|System OK|정상)/').first()).toBeVisible({ timeout: 5000 }); + + await window.screenshot({ path: path.join(SCREENSHOT_DIR, 'rt05_01_dashboard_verified.png') }); + expect(uncaughtExceptions).toHaveLength(0); + }); +}); diff --git a/apps/desktop/tests/e2e/red_team_cycle2.spec.ts b/apps/desktop/tests/e2e/red_team_cycle2.spec.ts new file mode 100644 index 0000000..d6e14ab --- /dev/null +++ b/apps/desktop/tests/e2e/red_team_cycle2.spec.ts @@ -0,0 +1,457 @@ +// apps/desktop/tests/e2e/red_team_cycle2.spec.ts +// Extreme Red Team: Headful Deep Interactive Testing for Cycle 2 (AI Pipelines & Workspaces) + +import { test, expect, _electron as electron, type Page, type ElectronApplication } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; + +const SCREENSHOT_DIR = 'C:/Users/encep/.gemini/antigravity/brain/bbff18a3-721d-4c43-989f-1d6964e15be7/screenshots'; + +test.describe('Extreme Red Team - Cycle 2: AI Pipelines & Advanced Workspaces', () => { + test.describe.configure({ mode: 'serial' }); + + let electronApp: ElectronApplication; + let window: Page; + const consoleErrors: string[] = []; + const uncaughtExceptions: string[] = []; + let testUserDataDir: string; + + test.beforeAll(async () => { + test.setTimeout(60000); + if (!fs.existsSync(SCREENSHOT_DIR)) { + fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); + } + + testUserDataDir = path.join( + 'C:/Users/encep/AppData/Local/Temp', + 'playwright-redteam-cycle2-' + Date.now() + ); + + electronApp = await electron.launch({ + args: [ + 'out/main/index.js', + '--disable-gpu', + '--no-sandbox', + `--user-data-dir=${testUserDataDir}`, + ], + env: { + ...process.env, + NODE_ENV: 'test', + }, + }); + + // Find main window + for (let i = 0; i < 50; i++) { + for (const w of electronApp.windows()) { + try { + const url = w.url(); + if (url && url.includes('index.html') && !url.includes('popups/')) { + window = w; + break; + } + } catch { + // window might be navigating + } + } + if (window) break; + await new Promise((r) => setTimeout(r, 400)); + } + + if (!window) { + window = electronApp.windows()[0] || (await electronApp.firstWindow()); + } + + // Attach sentinels + window.on('pageerror', (err) => { + console.error('[PAGEERROR]', err.message); + uncaughtExceptions.push(err.message); + }); + + window.on('console', (msg) => { + if (msg.type() === 'error') { + const text = msg.text(); + // Ignore benign chrome font/csp warnings + if (!text.includes('Failed to load resource') && !text.includes('favicon.ico')) { + consoleErrors.push(text); + } + } + }); + + await window.waitForLoadState('domcontentloaded'); + // Bypass Onboarding modal for testing + await window.evaluate(async () => { + if (window.electronAPI?.config) { + await window.electronAPI.config.set({ key: 'onboardingCompleted', value: true }); + await window.electronAPI.config.set({ key: 'appUsageMode', value: 'online' }); + await window.electronAPI.config.set({ key: 'llmBackend', value: 'online' }); + } + }); + + await window.waitForTimeout(500); + await window.reload(); + await window.waitForLoadState('domcontentloaded'); + await window.waitForTimeout(1000); + }); + + test.afterAll(async () => { + if (electronApp) { + await electronApp.close(); + } + }); + + test('RT-06: Meeting Mode List, Start Recording Trigger & Diagnostic Dialog', async () => { + // 1. Navigate to Meeting Mode + const meetingNav = window.locator('text=/^(Meeting|회의)$/').first(); + await expect(meetingNav).toBeVisible({ timeout: 5000 }); + await meetingNav.click(); + await window.waitForTimeout(600); + + // Verify page header + await expect(window.locator('text=/^(Meeting|회의 모드)$/').first()).toBeVisible({ timeout: 5000 }); + + // 2. Click "새 회의 시작" button + const startBtn = window.getByRole('button', { name: /새 회의 시작|새 회의/i }).first(); + await expect(startBtn).toBeVisible(); + await startBtn.click(); + await window.waitForTimeout(1000); + + // If diagnostic dialog opened (e.g. no mic or whisper unavailable in test container) + const dialog = window.getByRole('dialog'); + const isDialogVisible = await dialog.isVisible({ timeout: 2000 }).catch(() => false); + if (isDialogVisible) { + // Verify diagnostic elements + await expect(window.getByText('Meeting Intelligence Diagnostics')).toBeVisible(); + await expect(window.getByText(/발생 원인/)).toBeVisible(); + await expect(window.getByText(/해결 조치/)).toBeVisible(); + + // Click "닫기" button + const closeBtn = window.getByRole('button', { name: '닫기' }); + await expect(closeBtn).toBeVisible(); + await closeBtn.click(); + await window.waitForTimeout(500); + await expect(dialog).not.toBeVisible(); + } + + // Take screenshot + await window.screenshot({ + path: path.join(SCREENSHOT_DIR, 'rt06_meeting_list_and_dialog.png'), + }); + + expect(uncaughtExceptions).toEqual([]); + }); + + test('RT-07: Meeting Mode Detail Workspace - Inline Title, ViewModes, MarkdownEditor, Action Items & Chat', async () => { + // Seed a meeting session directly via python to avoid Node ABI mismatch + const sessionId = 'test-session-redteam-' + Date.now(); + const dbPath = path.join(testUserDataDir, 'users', '_local', 'd3ro.db'); + const seedPyPath = path.join(testUserDataDir, 'seed.py'); + const now = Date.now(); + + const pyScript = `import sqlite3, sys +db_path = sys.argv[1] +session_id = sys.argv[2] +now = int(sys.argv[3]) +conn = sqlite3.connect(db_path) +c = conn.cursor() +c.execute(""" + INSERT INTO meeting_sessions (id, title, status, started_at, ended_at, duration_ms, raw_transcript, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +""", ( + session_id, + 'Red Team AI 전략 회의', + 'completed', + now - 1800000, + now, + 1800000, + '[00:05] [참석자 A] D3RO Voice 시스템 성능 점검을 시작합니다.\\n[00:15] [참석자 B] 극단적 레드팀 테스트를 수행 중입니다.\\n[01:00] [참석자 A] 품질 검증을 완벽하게 통과했습니다.', + now - 1800000, + now +)) +c.execute(""" + INSERT INTO meeting_memos (id, session_id, content, timestamp_ms, created_at) + VALUES (?, ?, ?, ?, ?) +""", ( + 'memo-' + session_id, + session_id, + '#결정 음성 인식 모델 레이턴시 최적화 완료', + 5000, + now - 1795000 +)) +c.execute(""" + INSERT INTO meeting_documents (id, session_id, template_type, title, content, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) +""", ( + 'doc-' + session_id, + session_id, + 'minutes', + '전략 회의록', + '# 레드팀 전략 회의록\\n\\n## 1. 개요\\n- 시스템 안정성 심층 검증\\n\\n## 2. 액션 아이템\\n- [ ] E2E 헤드풀 테스트 통과하기\\n- [x] SQLite ABI 불일치 수정 완료\\n\\n## 3. 결론\\n전체 파이프라인 완벽 가동', + now, + now +)) +conn.commit() +conn.close() +`; + + fs.writeFileSync(seedPyPath, pyScript, 'utf-8'); + const cp = require('child_process'); + cp.execSync(`python "${seedPyPath}" "${dbPath}" "${sessionId}" "${now}"`); + fs.unlinkSync(seedPyPath); + + // Refresh meeting list by navigating to dashboard and back to meeting + await window.locator('text=/^(Dashboard|대시보드)$/').first().click(); + await window.waitForTimeout(400); + await window.locator('text=/^(Meeting|회의)$/').first().click(); + await window.waitForTimeout(800); + + // Click on the seeded session card + const sessionCard = window.getByText('Red Team AI 전략 회의'); + await expect(sessionCard).toBeVisible({ timeout: 5000 }); + await sessionCard.click(); + await window.waitForTimeout(800); + + // 1. Verify MeetingDetailTabs is rendered + await expect(window.getByRole('tab', { name: '전략 회의록' })).toBeVisible({ timeout: 5000 }); + + // 2. Test Inline Title Editing + const editTitleBtn = window.locator('button:has(svg.lucide-pencil)'); + if (await editTitleBtn.isVisible()) { + await editTitleBtn.click(); + await window.waitForTimeout(300); + const titleInput = window.locator('input[value*="Red Team AI 전략 회의"]'); + await expect(titleInput).toBeVisible(); + await titleInput.fill('Red Team AI 전략 회의 (검증됨)'); + const saveTitleBtn = window.locator('button:has(svg.lucide-check)'); + await saveTitleBtn.click(); + await window.waitForTimeout(500); + await expect(window.getByText('Red Team AI 전략 회의 (검증됨)')).toBeVisible(); + } + + // 3. Test ViewMode switches (SegmentControl) + const transcriptOnlyBtn = window.getByText('Transcript ▤'); + if (await transcriptOnlyBtn.isVisible()) { + await transcriptOnlyBtn.click(); + await window.waitForTimeout(300); + await expect(window.getByText(/D3RO Voice 시스템 성능 점검/)).toBeVisible(); + + const docOnlyBtn = window.getByText('Minutes ▥'); + await docOnlyBtn.click(); + await window.waitForTimeout(300); + await expect(window.getByRole('heading', { name: '레드팀 전략 회의록' })).toBeVisible(); + + const splitBtn = window.getByText('Split ◫'); + await splitBtn.click(); + await window.waitForTimeout(300); + } + + // 4. Test MarkdownEditor preview vs edit toggle + const editModeBtn = window.locator('button').filter({ hasText: '편집' }).first(); + if (await editModeBtn.isVisible()) { + await editModeBtn.click(); + await window.waitForTimeout(300); + const textarea = window.locator('textarea'); + await expect(textarea).toBeVisible(); + await textarea.type('\n\n## 4. 추가 사항\n- 레드팀 사이클 2 통과'); + await window.waitForTimeout(600); // let autosave debounce fire + + const previewModeBtn = window.locator('button').filter({ hasText: '미리보기' }).first(); + await previewModeBtn.click(); + await window.waitForTimeout(300); + await expect(window.getByText(/추가 사항/)).toBeVisible(); + } + + // 5. Test Action Items tab + const actionItemsTab = window.getByText(/Action Items/i); + if (await actionItemsTab.isVisible()) { + await actionItemsTab.click(); + await window.waitForTimeout(400); + await expect(window.getByText(/E2E 헤드풀 테스트/)).toBeVisible(); + + // Toggle action item checkbox + const checkbox = window.locator('input[type="checkbox"]').first(); + await checkbox.click(); + await window.waitForTimeout(300); + } + + // 6. Test Scratchpad Notepad tab + const notepadTab = window.getByText(/Granola Notepad/i); + if (await notepadTab.isVisible()) { + await notepadTab.click(); + await window.waitForTimeout(400); + const notepadTextarea = window.locator('textarea').first(); + if (await notepadTextarea.isVisible()) { + await notepadTextarea.fill('핵심 안건: 실시간 STT 엔진 최적화 및 텍스트 렌더링 검증'); + await window.waitForTimeout(300); + } + } + + // 7. Test Export Menu + const exportBtn = window.getByRole('button', { name: /내보내기/i }); + if (await exportBtn.isVisible()) { + await exportBtn.click(); + await window.waitForTimeout(300); + await expect(window.getByText(/Markdown/i)).toBeVisible(); + // Press Escape to dismiss menu + await window.keyboard.press('Escape'); + await window.waitForTimeout(300); + } + + // 8. Test Meeting Chat Panel + const chatInput = window.locator('input[placeholder*="AI에게 질문"]'); + if (await chatInput.isVisible()) { + await chatInput.fill('회의 요약해줘'); + await window.keyboard.press('Enter'); + await window.waitForTimeout(600); + + // Verify user message appears + await expect(window.getByText('회의 요약해줘')).toBeVisible(); + + // Clear chat + const clearChatBtn = window.locator('button:has(svg.lucide-trash2)'); + if (await clearChatBtn.isVisible()) { + await clearChatBtn.click(); + await window.waitForTimeout(300); + await expect(window.getByText('회의 요약해줘')).not.toBeVisible(); + } + } + + // 9. Back to meeting list + const backBtn = window.locator('button:has(svg.lucide-arrow-left)'); + await backBtn.click(); + await window.waitForTimeout(500); + await expect(window.getByText('Red Team AI 전략 회의 (검증됨)')).toBeVisible(); + + // Take screenshot + await window.screenshot({ + path: path.join(SCREENSHOT_DIR, 'rt07_meeting_detail_workspace.png'), + }); + + expect(uncaughtExceptions).toEqual([]); + }); + + test('RT-08: Knowledge Base - Semantic Synthesis Query, Chips, Add & Delete Document', async () => { + // 1. Navigate to Knowledge Base + const ragNav = window.locator('text=/^(Knowledge Base|지식 베이스)$/').first(); + await expect(ragNav).toBeVisible({ timeout: 5000 }); + await ragNav.click(); + await window.waitForTimeout(600); + + // Verify page header + await expect(window.locator('text=/^(Knowledge Base|지식 베이스)$/').first()).toBeVisible({ timeout: 5000 }); + + // 2. Test query input + const queryInput = window.locator('input[placeholder*="질문하세요"]'); + await expect(queryInput).toBeVisible(); + await queryInput.fill('D3RO Voice 아키텍처'); + + // Click Search button + const searchBtn = window.getByRole('button', { name: /Search|검색/i }); + await expect(searchBtn).toBeVisible(); + await searchBtn.click(); + await window.waitForTimeout(600); + + // Verify searching spinner or graceful error feedback + await window.waitForTimeout(1000); + + // 3. Add a mock document via IPC + const tempFilePath = path.join(testUserDataDir, 'red_team_sample.md'); + fs.writeFileSync( + tempFilePath, + '# D3RO Voice 시스템 문서\n\nD3RO Voice는 고성능 로컬 퍼스트 음성 비서 아키텍처를 지원하며 빠른 STT 및 LLM 파이프라인을 제공합니다.', + 'utf-8' + ); + + const addDocResult = await window.evaluate(async (filePath) => { + return await window.electronAPI.rag.addDocument({ filePath }); + }, tempFilePath); + + expect(addDocResult.success).toBe(true); + + // Reload documents by re-navigating + await ragNav.click(); + await window.waitForTimeout(800); + + // Verify document card appears + await expect(window.getByText('red_team_sample.md')).toBeVisible({ timeout: 5000 }); + await expect(window.getByText('MD', { exact: true })).toBeVisible(); + + // 4. Test Reindex button + const reindexBtn = window.locator('button:has(svg.lucide-refresh-cw)').first(); + if (await reindexBtn.isVisible()) { + await reindexBtn.click(); + await window.waitForTimeout(500); + } + + // 5. Test Delete document button + const deleteDocBtn = window.locator('button:has(svg.lucide-trash-2)').first(); + await expect(deleteDocBtn).toBeVisible(); + await deleteDocBtn.click(); + await window.waitForTimeout(600); + + // Verify document is removed + await expect(window.getByText('red_team_sample.md')).not.toBeVisible(); + + // Clean up temp file + if (fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + } + + // Take screenshot + await window.screenshot({ + path: path.join(SCREENSHOT_DIR, 'rt08_knowledge_base.png'), + }); + + expect(uncaughtExceptions).toEqual([]); + }); + + test('RT-09: Voice Conversation - Canvas, Text Chat, Mic Toggle & Clear History', async () => { + // 1. Navigate to Voice Conversation + const voiceNav = window.locator('text=/^(Talk|대화)$/').first(); + await expect(voiceNav).toBeVisible({ timeout: 5000 }); + await voiceNav.click(); + await window.waitForTimeout(600); + + // Verify PageHeader + await expect(window.locator('text=/^(Voice Conversation|음성 대화)$/').first()).toBeVisible({ timeout: 5000 }); + await expect(window.getByText(/대기|IDLE/i).first()).toBeVisible(); + + // Verify empty state + await expect(window.getByText(/D3RO에게 말을 걸어보세요|Talk to D3RO/i)).toBeVisible(); + + // 2. Test Text Fallback Chat Input + const chatInput = window.locator('input[placeholder*="메시지 입력"]'); + await expect(chatInput).toBeVisible(); + await chatInput.fill('안녕 D3RO, 현재 상태 점검해줘'); + + const sendBtn = window.locator('button:has(svg.lucide-send)'); + await expect(sendBtn).toBeVisible(); + await sendBtn.click(); + await window.waitForTimeout(1000); + + // Verify user message appears in bubble + await expect(window.getByText('안녕 D3RO, 현재 상태 점검해줘')).toBeVisible({ timeout: 5000 }); + + // 3. Test Clear History + const clearBtn = window.locator('button:has(svg.lucide-trash-2)'); + if (await clearBtn.isVisible()) { + await clearBtn.click(); + await window.waitForTimeout(500); + } + + // If session is active or thinking, end session so state returns to idle + const stopSessionBtn = window.getByRole('button', { name: /종료|End/i }); + if (await stopSessionBtn.isVisible()) { + await stopSessionBtn.click(); + await window.waitForTimeout(600); + } + + await expect(window.getByText(/D3RO에게 말을 걸어보세요|Talk to D3RO/i)).toBeVisible({ timeout: 5000 }); + + // Take screenshot + await window.screenshot({ + path: path.join(SCREENSHOT_DIR, 'rt09_voice_conversation.png'), + }); + + expect(uncaughtExceptions).toEqual([]); + }); +}); diff --git a/apps/desktop/tests/e2e/red_team_cycle3.spec.ts b/apps/desktop/tests/e2e/red_team_cycle3.spec.ts new file mode 100644 index 0000000..54af8c6 --- /dev/null +++ b/apps/desktop/tests/e2e/red_team_cycle3.spec.ts @@ -0,0 +1,337 @@ +import { test, expect, _electron as electron, ElectronApplication, Page } from '@playwright/test'; +import path from 'path'; +import fs from 'fs'; + +const SCREENSHOT_DIR = path.resolve('C:/Users/encep/.gemini/antigravity/brain/bbff18a3-721d-4c43-989f-1d6964e15be7/screenshots'); + +test.describe.serial('Extreme Red Team - Cycle 3: Modals, Deep Configuration & System Shell', () => { + let app: ElectronApplication; + let window: Page; + let testUserDataDir: string; + const uncaughtExceptions: string[] = []; + + test.beforeAll(async () => { + if (!fs.existsSync(SCREENSHOT_DIR)) { + fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); + } + + testUserDataDir = path.join( + 'C:/Users/encep/AppData/Local/Temp', + 'playwright-redteam-cycle3-' + Date.now() + ); + + app = await electron.launch({ + args: [ + 'out/main/index.js', + '--disable-gpu', + '--no-sandbox', + `--user-data-dir=${testUserDataDir}`, + ], + env: { + ...process.env, + NODE_ENV: 'test', + }, + }); + + // Find main window + for (let i = 0; i < 50; i++) { + for (const w of app.windows()) { + try { + const url = w.url(); + if (url && url.includes('index.html') && !url.includes('popups/')) { + window = w; + break; + } + } catch { + // window navigating + } + } + if (window) break; + await new Promise((r) => setTimeout(r, 400)); + } + + if (!window) { + window = app.windows()[0] || (await app.firstWindow()); + } + + window.on('pageerror', (err) => { + console.error('[PAGEERROR in Electron Window]:', err.message); + uncaughtExceptions.push(err.message); + }); + + await window.waitForLoadState('domcontentloaded'); + + // Bypass Onboarding modal for testing + await window.evaluate(async () => { + if (window.electronAPI?.config) { + await window.electronAPI.config.set({ key: 'onboardingCompleted', value: true }); + await window.electronAPI.config.set({ key: 'appUsageMode', value: 'online' }); + await window.electronAPI.config.set({ key: 'llmBackend', value: 'online' }); + } + }); + + await window.waitForTimeout(500); + await window.reload(); + await window.waitForLoadState('domcontentloaded'); + await window.waitForTimeout(1000); + }); + + test.afterAll(async () => { + if (app) { + await app.close(); + } + }); + + test('RT-10: SettingsModal - Deep Tab Traversal, Live Theme Switching, Language Switch & Audio Test', async () => { + // 1. Open SettingsModal from sidebar + const settingsTrigger = window.getByText('설정', { exact: true }); + await expect(settingsTrigger).toBeVisible({ timeout: 5000 }); + await settingsTrigger.click(); + await window.waitForTimeout(600); + + // Verify modal dialog opened + await expect(window.getByText(/설정|SETTINGS/i).first()).toBeVisible({ timeout: 5000 }); + + // 2. Tab 0: General - Voice Mode Cards & Theme / Language + await expect(window.getByText(/받아쓰기|Dictation/i).first()).toBeVisible(); + await expect(window.getByText(/원터치|Hands-Free/i).first()).toBeVisible(); + await expect(window.getByText(/자막|Caption/i).first()).toBeVisible(); + + // Test Theme Selector + const themeSelect = window.locator('div[role="combobox"]').first(); + if (await themeSelect.isVisible()) { + await themeSelect.click(); + await window.waitForTimeout(300); + // Select Light theme + const lightOption = window.getByRole('option', { name: /라이트|Light/i }); + if (await lightOption.isVisible()) { + await lightOption.click(); + await window.waitForTimeout(400); + } + // Select Dark theme back + await themeSelect.click(); + await window.waitForTimeout(300); + const darkOption = window.getByRole('option', { name: /다크|Dark/i }); + if (await darkOption.isVisible()) { + await darkOption.click(); + await window.waitForTimeout(400); + } + } + + // 3. Tab 1: Audio Tab + const audioTab = window.getByRole('tab', { name: /오디오|Audio/i }); + await expect(audioTab).toBeVisible(); + await audioTab.click(); + await window.waitForTimeout(500); + + // Verify Audio Settings: Input device selector & Mic Test + await expect(window.getByText(/입력 장치|마이크|Microphone/i).first()).toBeVisible(); + const micTestBtn = window.getByRole('button', { name: /테스트|Test/i }).first(); + if (await micTestBtn.isVisible()) { + await micTestBtn.click(); + await window.waitForTimeout(400); + // Click again to stop test + await micTestBtn.click(); + await window.waitForTimeout(300); + } + + // 4. Tab 2: STT Tab + const sttTab = window.getByRole('tab', { name: 'STT' }); + await expect(sttTab).toBeVisible(); + await sttTab.click(); + await window.waitForTimeout(500); + + // Verify STT Provider / Model configurations + await expect(window.getByText(/STT 엔진|faster-whisper|모델|Provider/i).first()).toBeVisible(); + + // 5. Tab 3: LLM Tab + const llmTab = window.getByRole('tab', { name: 'LLM' }); + await expect(llmTab).toBeVisible(); + await llmTab.click(); + await window.waitForTimeout(500); + + // Verify Ollama Server URL input + await expect(window.getByText(/Ollama|Server|URL/i).first()).toBeVisible(); + const refreshLlmBtn = window.locator('button:has(svg.lucide-refresh-cw)').first(); + if (await refreshLlmBtn.isVisible()) { + await refreshLlmBtn.click(); + await window.waitForTimeout(400); + } + + // 6. Tab 4: License Tab + const licenseTab = window.getByRole('tab', { name: /라이선스|License/i }); + await expect(licenseTab).toBeVisible(); + await licenseTab.click(); + await window.waitForTimeout(500); + + // Verify Current Tier Display + await expect(window.getByText(/현재 라이선스|현재 플랜|FREE|PRO/i).first()).toBeVisible(); + + // 7. Tab 5: Cloud Sync Tab + const cloudTab = window.getByRole('tab', { name: /클라우드|Cloud/i }); + if (await cloudTab.isVisible()) { + await cloudTab.click(); + await window.waitForTimeout(500); + await expect(window.getByText(/Cloud|동기화|Sync/i).first()).toBeVisible(); + } + + // 8. Tab 6: About Tab + const aboutTab = window.getByRole('tab', { name: /정보|About/i }); + if (await aboutTab.isVisible()) { + await aboutTab.click(); + await window.waitForTimeout(500); + await expect(window.getByText(/D3RO Voice|버전|Version/i).first()).toBeVisible(); + } + + // Screenshot SettingsModal + await window.screenshot({ + path: path.join(SCREENSHOT_DIR, 'rt10_settings_modal_all_tabs.png'), + }); + + // Close SettingsModal + const closeBtn = window.locator('div[role="dialog"] button:has(svg.lucide-x)').first(); + await expect(closeBtn).toBeVisible(); + await closeBtn.click(); + await window.waitForTimeout(500); + + // Verify modal is closed + await expect(window.locator('div[role="dialog"]')).not.toBeVisible(); + + expect(uncaughtExceptions).toEqual([]); + }); + + test('RT-11: HotkeyRecordModal - Interactive Hotkey Recording & Conflict Protection', async () => { + // 1. Re-open SettingsModal to General Tab + const settingsTrigger = window.getByText('설정', { exact: true }); + await settingsTrigger.click(); + await window.waitForTimeout(600); + + // Click General tab + const generalTab = window.getByRole('tab', { name: /일반|General/i }); + await generalTab.click(); + await window.waitForTimeout(400); + + // 2. Click pencil icon on Dictation shortcut to open HotkeyRecordModal + const editHotkeyBtn = window.locator('button:has(svg.lucide-pencil)').first(); + await expect(editHotkeyBtn).toBeVisible(); + await editHotkeyBtn.click(); + await window.waitForTimeout(500); + + // Verify HotkeyRecordModal is open + await expect(window.getByText(/단축키 설정|단축키 녹화|키 조합/i).first()).toBeVisible({ timeout: 5000 }); + + // 3. Test pressing a key (F9) + await window.keyboard.press('F9'); + await window.waitForTimeout(400); + + // Verify chip shows F9 + await expect(window.getByText('F9', { exact: true })).toBeVisible(); + + // Take screenshot while modal is open with captured key + await window.screenshot({ + path: path.join(SCREENSHOT_DIR, 'rt11_hotkey_modal_verified.png'), + }); + + // 4. Click Cancel button to close HotkeyRecordModal without corrupting bindings + const cancelBtn = window.getByRole('button', { name: /취소|Cancel/i }); + await expect(cancelBtn).toBeVisible(); + await cancelBtn.click(); + await window.waitForTimeout(500); + + // Close SettingsModal + const closeSettingsBtn = window.locator('div[role="dialog"] button:has(svg.lucide-x)').first(); + if (await closeSettingsBtn.isVisible()) { + await closeSettingsBtn.click(); + await window.waitForTimeout(500); + } + + expect(uncaughtExceptions).toEqual([]); + }); + + test('RT-12: SupportModal - AI Diagnostics, FAQ Accordion, Refund Query & Telemetry', async () => { + // 1. Open SupportModal from sidebar + const supportTrigger = window.locator('text=Customer Support').first(); + await expect(supportTrigger).toBeVisible({ timeout: 5000 }); + await supportTrigger.click(); + await window.waitForTimeout(600); + + // Verify SupportModal opens + await expect(window.getByText(/Customer Assistance|고객지원/i).first()).toBeVisible({ timeout: 5000 }); + + // 2. Tab 0: AI Assistant Chat & Quick Prompt Chips + await expect(window.getByText(/AI 어시스턴트|AI Assistant/i).first()).toBeVisible(); + + // Click quick prompt chip: "🎤 마이크 인식 오류" + const micPromptChip = window.getByRole('button', { name: /마이크/i }).first(); + if (await micPromptChip.isVisible()) { + await micPromptChip.click(); + await window.waitForTimeout(800); + + // Verify AI answer appears + await expect(window.getByText(/마이크 입력 진단 결과|개인정보 권한/i)).toBeVisible({ timeout: 5000 }); + } + + // 3. Tab 1: FAQ Accordion + const faqTab = window.getByRole('tab', { name: /FAQ|자주 묻는 질문/i }); + await expect(faqTab).toBeVisible(); + await faqTab.click(); + await window.waitForTimeout(500); + + // Verify FAQ questions render + await expect(window.getByText(/마이크 음성 인식이 작동하지 않거나/i)).toBeVisible(); + await expect(window.getByText(/NVIDIA CUDA GPU 가속/i)).toBeVisible(); + + // 4. Tab 3 (Tab index 3 in DOM): 7일 자동 환불 확인 + const refundTab = window.getByRole('tab', { name: /환불|Refund/i }); + if (await refundTab.isVisible()) { + await refundTab.click(); + await window.waitForTimeout(500); + + // Verify refund check button + const checkRefundBtn = window.getByRole('button', { name: /환불 자격 조회/i }); + if (await checkRefundBtn.isVisible()) { + await checkRefundBtn.click(); + await window.waitForTimeout(1000); + // Verify eligibility result + await expect(window.getByText(/전액 환불 가능|조회 완료|환불 자격/i).first()).toBeVisible(); + } + } + + // Take screenshot + await window.screenshot({ + path: path.join(SCREENSHOT_DIR, 'rt12_support_diagnostics_modal.png'), + }); + + // Close SupportModal + await window.keyboard.press('Escape'); + await window.waitForTimeout(500); + + expect(uncaughtExceptions).toEqual([]); + }); + + test('RT-13: LicenseModal & Custom Events Fail-Closed Monetization Guardrails', async () => { + // 1. Dispatch custom event to open LicenseModal + await window.evaluate(() => { + window.dispatchEvent(new CustomEvent('d3ro:open-license-modal')); + }); + await window.waitForTimeout(600); + + // Verify LicenseModal opens + await expect(window.getByText(/라이선스|License/i).first()).toBeVisible({ timeout: 5000 }); + await expect(window.getByText(/현재 등급|FREE/i).first()).toBeVisible(); + + // Verify Tier Comparison Table exists + await expect(window.getByText(/등급 비교|Tier Comparison/i)).toBeVisible(); + + // Take screenshot + await window.screenshot({ + path: path.join(SCREENSHOT_DIR, 'rt13_license_monetization_modal.png'), + }); + + // Close LicenseModal via Escape + await window.keyboard.press('Escape'); + await window.waitForTimeout(500); + + expect(uncaughtExceptions).toEqual([]); + }); +}); diff --git a/apps/desktop/tests/e2e/red_team_cycle5.spec.ts b/apps/desktop/tests/e2e/red_team_cycle5.spec.ts new file mode 100644 index 0000000..cf87520 --- /dev/null +++ b/apps/desktop/tests/e2e/red_team_cycle5.spec.ts @@ -0,0 +1,265 @@ +import { test, expect, _electron as electron, ElectronApplication, Page } from '@playwright/test'; +import path from 'path'; +import fs from 'fs'; + +const SCREENSHOT_DIR = path.resolve('C:/Users/encep/.gemini/antigravity/brain/bbff18a3-721d-4c43-989f-1d6964e15be7/screenshots'); + +test.describe.serial('Extreme Red Team - Cycle 5: Rapid Route Thrashing, Onboarding Wizard & Input Fuzzing', () => { + let app: ElectronApplication; + let window: Page; + let testUserDataDir: string; + let uncaughtExceptions: string[] = []; + + test.beforeAll(async () => { + if (!fs.existsSync(SCREENSHOT_DIR)) { + fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); + } + + testUserDataDir = path.join( + 'C:/Users/encep/AppData/Local/Temp', + 'playwright-redteam-cycle5-' + Date.now() + ); + + app = await electron.launch({ + args: [ + 'out/main/index.js', + '--disable-gpu', + '--no-sandbox', + `--user-data-dir=${testUserDataDir}`, + ], + env: { + ...process.env, + NODE_ENV: 'test', + }, + }); + + // Find main window + for (let i = 0; i < 50; i++) { + for (const w of app.windows()) { + try { + const url = w.url(); + if (url && url.includes('index.html') && !url.includes('popups/')) { + window = w; + break; + } + } catch { + // window navigating + } + } + if (window) break; + await new Promise((r) => setTimeout(r, 400)); + } + + if (!window) { + window = app.windows()[0] || (await app.firstWindow()); + } + + window.on('pageerror', (err) => { + console.error('[PAGEERROR in Electron Window]:', err.message); + uncaughtExceptions.push(err.message); + }); + + await window.waitForLoadState('domcontentloaded'); + + // Bypass Onboarding modal initially + await window.evaluate(async () => { + if (window.electronAPI?.config) { + await window.electronAPI.config.set({ key: 'onboardingCompleted', value: true }); + await window.electronAPI.config.set({ key: 'appUsageMode', value: 'online' }); + await window.electronAPI.config.set({ key: 'llmBackend', value: 'online' }); + } + }); + + await window.waitForTimeout(500); + await window.reload(); + await window.waitForLoadState('domcontentloaded'); + await window.waitForTimeout(1000); + }); + + test.afterAll(async () => { + if (app) { + await app.close(); + } + }); + + test.beforeEach(() => { + uncaughtExceptions = []; + }); + + test('RT-16: Rapid Route Thrashing & Concurrency Stress Test', async () => { + // 7 navigation routes in AppLayout + const routes = [ + '대시보드', + '기록', + '단어장', + '명령어', + '음성 대화', + '지식 베이스', + '회의록' + ]; + + // Rapid thrashing loop - 10 full passes at high speed + for (let loop = 0; loop < 5; loop++) { + for (const routeName of routes) { + const navButton = window.getByText(routeName, { exact: true }).first(); + if (await navButton.isVisible()) { + await navButton.click(); + // Hyper rapid 40ms interval + await window.waitForTimeout(40); + } + } + } + + // Settle back to dashboard + const dashboardNav = window.getByText('대시보드', { exact: true }).first(); + await dashboardNav.click(); + await window.waitForTimeout(500); + + // Verify system survived rapid thrashing and dashboard is completely healthy + await expect(window.getByText(/총 발화|오늘 사용량|음성 인식|대시보드/i).first()).toBeVisible({ timeout: 5000 }); + + await window.screenshot({ + path: path.join(SCREENSHOT_DIR, 'rt16_route_thrashing_settled.png'), + }); + + expect(uncaughtExceptions).toEqual([]); + }); + + test('RT-17: Onboarding Modal & First-Run Wizard Headful Execution', async () => { + // 1. Reset onboarding to false and reload to trigger OnboardingModal + await window.evaluate(async () => { + if (window.electronAPI?.config) { + await window.electronAPI.config.set({ key: 'onboardingCompleted', value: false }); + } + }); + + await window.reload(); + await window.waitForLoadState('domcontentloaded'); + await window.waitForTimeout(1000); + + // 2. Verify OnboardingModal is visible + await expect(window.getByText(/D3RO Voice 시작 가이드/i)).toBeVisible({ timeout: 10000 }); + await expect(window.getByText(/로컬 AI 모드/i).first()).toBeVisible(); + await expect(window.getByText(/온라인 클라우드 모드/i).first()).toBeVisible(); + + await window.screenshot({ + path: path.join(SCREENSHOT_DIR, 'rt17_01_onboarding_select_mode.png'), + }); + + // 3. Test Local Mode Selection + await window.getByRole('button', { name: /로컬 AI 모드로 시작/i }).click(); + await window.waitForTimeout(600); + + // Verify transitions to local_ollama_setup phase + await expect(window.getByText(/Ollama 로컬 엔진/i).first()).toBeVisible({ timeout: 5000 }); + + await window.screenshot({ + path: path.join(SCREENSHOT_DIR, 'rt17_02_onboarding_local_setup.png'), + }); + + // Test back button + const backBtn = window.getByText('뒤로 가기', { exact: true }); + await expect(backBtn).toBeVisible(); + await backBtn.click(); + await window.waitForTimeout(500); + + // Verify back to select_mode + await expect(window.getByText(/로컬 AI 모드/i).first()).toBeVisible(); + + // 4. Test Online Cloud Mode Selection + await window.getByRole('button', { name: /온라인 계정 로그인/i }).click(); + await window.waitForTimeout(600); + + // Verify transitions to online_auth phase + await expect(window.getByText(/온라인 서비스를 이용하기 위해/i).first()).toBeVisible({ timeout: 5000 }); + + await window.screenshot({ + path: path.join(SCREENSHOT_DIR, 'rt17_03_onboarding_online_auth.png'), + }); + + // Go back again + await window.getByText('뒤로 가기', { exact: true }).click(); + await window.waitForTimeout(500); + + // 5. Restore onboardingCompleted + await window.evaluate(async () => { + await window.electronAPI.config.set({ key: 'onboardingCompleted', value: true }); + }); + + await window.reload(); + await window.waitForLoadState('domcontentloaded'); + await window.waitForTimeout(1000); + expect(uncaughtExceptions).toEqual([]); + }); + + test('RT-18: Adversarial Input Fuzzing, Boundary Conditions & SQL/XSS Injection Defense', async () => { + // 1. Dictionary Fuzzing + const dictNav = window.locator('text=/^(Dictionary|사전|단어장)$/').first(); + await dictNav.click(); + await window.waitForTimeout(600); + + const sqlXssPayload = '\' OR \'1\'=\'1\'; '; + const safeReplacement = 'FuzzReplacement'; + + // Click 추가 button + const addWordBtn = window.locator('button:has-text("추가"), button:has-text("Add")').first(); + await addWordBtn.click(); + await window.waitForTimeout(400); + + // Fill inputs in dialog + const wordInput = window.locator('div[role="dialog"] input').first(); + const pronInput = window.locator('div[role="dialog"] input').nth(1); + await wordInput.fill(sqlXssPayload); + await pronInput.fill(safeReplacement); + + // Save + const saveBtn = window.locator('div[role="dialog"] button:has-text("저장"), div[role="dialog"] button:has-text("Save")').first(); + await saveBtn.click(); + await window.waitForTimeout(600); + + // Verify word was stored safely without executing XSS or corrupting SQLite + await expect(window.getByText(sqlXssPayload).first()).toBeVisible({ timeout: 5000 }); + + await window.screenshot({ + path: path.join(SCREENSHOT_DIR, 'rt18_01_dictionary_fuzzing_saved.png'), + }); + + // Clean up: delete the fuzzed word + const deleteBtn = window.locator('button[aria-label*="삭제"], button:has(svg.lucide-trash-2)').first(); + if (await deleteBtn.isVisible()) { + await deleteBtn.click(); + await window.waitForTimeout(400); + // Confirm dialog if any + const confirmBtn = window.locator('div[role="dialog"] button:has-text("삭제"), div[role="dialog"] button:has-text("Delete")').first(); + if (await confirmBtn.isVisible()) { + await confirmBtn.click(); + await window.waitForTimeout(400); + } + } + + // 2. Knowledge Base Query Fuzzing (Regex Break Characters) + const kbNav = window.locator('text=/^(Knowledge|지식 베이스)$/').first(); + await kbNav.click(); + await window.waitForTimeout(600); + + const regexBreakQuery = '[([{\\\\^$|?*+'; + const kbSearchInput = window.locator('input[placeholder*="질문"], input[placeholder*="검색"]').first(); + if (await kbSearchInput.isVisible()) { + await kbSearchInput.fill(regexBreakQuery); + await window.keyboard.press('Enter'); + await window.waitForTimeout(800); + } + + await window.screenshot({ + path: path.join(SCREENSHOT_DIR, 'rt18_02_knowledge_fuzzing_query.png'), + }); + + // Settle back to dashboard + const dashboardNav = window.locator('text=/^(Dashboard|대시보드)$/').first(); + await dashboardNav.click(); + await window.waitForTimeout(500); + + expect(uncaughtExceptions).toEqual([]); + }); +}); + diff --git a/apps/desktop/tests/main/safe-filename.test.ts b/apps/desktop/tests/main/safe-filename.test.ts new file mode 100644 index 0000000..9787d69 --- /dev/null +++ b/apps/desktop/tests/main/safe-filename.test.ts @@ -0,0 +1,32 @@ +// tests/main/safe-filename.test.ts +// 사용자 제목 → 파일명 조각 정규화 테스트: 금지 문자, 제어 문자, 길이 제한, 폴백. + +import { describe, it, expect } from 'vitest' +import { toSafeFilenameSegment } from '../../src/main/utils/safe-filename' + +describe('toSafeFilenameSegment', () => { + it('일반 제목은 그대로 유지한다', () => { + expect(toSafeFilenameSegment('Weekly Sync 2026', 'meeting')).toBe('Weekly Sync 2026') + }) + + it('Windows 금지 문자와 경로 구분자를 치환한다', () => { + expect(toSafeFilenameSegment('a/b\\c:d*e?f"gi|j%k', 'meeting')).toBe( + 'a-b-c-d-e-f-g-h-i-j-k', + ) + }) + + it('제어 문자를 치환한다', () => { + expect(toSafeFilenameSegment('line\u0000break\u001f', 'meeting')).toBe('line-break-') + }) + + it('null/빈 문자열/공백은 폴백을 반환한다', () => { + expect(toSafeFilenameSegment(null, 'meeting')).toBe('meeting') + expect(toSafeFilenameSegment(undefined, 'meeting')).toBe('meeting') + expect(toSafeFilenameSegment(' ', 'meeting')).toBe('meeting') + }) + + it('최대 길이로 자르고 남은 값이 없으면 잘린 폴백을 반환한다', () => { + expect(toSafeFilenameSegment('abcdefghij', 'meeting', 4)).toBe('abcd') + expect(toSafeFilenameSegment(' ', 'meeting', 4)).toBe('meet') + }) +}) \ No newline at end of file diff --git a/apps/desktop/tsconfig.check-node.json b/apps/desktop/tsconfig.check-node.json new file mode 100644 index 0000000..f7eacdc --- /dev/null +++ b/apps/desktop/tsconfig.check-node.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.node.json", + "compilerOptions": { + "composite": false, + "incremental": false, + "noEmit": true, + "declaration": false, + "declarationMap": false, + "rootDir": "../..", + "moduleResolution": "bundler" + } +} diff --git a/apps/desktop/tsconfig.check.json b/apps/desktop/tsconfig.check.json new file mode 100644 index 0000000..b925396 --- /dev/null +++ b/apps/desktop/tsconfig.check.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.web.json", + "compilerOptions": { + "composite": false, + "incremental": false, + "noEmit": true, + "declaration": false, + "declarationMap": false, + "rootDir": "../.." + }, + "include": ["src/renderer/**/*", "src/preload/**/*"] +} diff --git a/apps/desktop/tsconfig.node.json b/apps/desktop/tsconfig.node.json index 592d492..802bcc2 100644 --- a/apps/desktop/tsconfig.node.json +++ b/apps/desktop/tsconfig.node.json @@ -18,7 +18,8 @@ "noUnusedParameters": true, "resolveJsonModule": true, "esModuleInterop": true, - "skipLibCheck": true + "skipLibCheck": true, + "moduleResolution": "bundler" }, "include": [ "src/main/**/*",