Phase 7~8 구현: 테스트 + 빌드 + CI/CD + SoundEffect + AutoLaunch + UI 리디자인
- vitest 41개 단위 테스트 (HistoryService, DictionaryService, CustomInstructionService, VoiceModeService, D3ROError) - electron-builder.yml (NSIS, asarUnpack, extraResources) - .gitlab-ci.yml (lint, typecheck, test, build, release) - SoundEffectService: WAV 프리로드 + PowerShell 재생 + VoiceMode 연동 - AutoLaunchService: app.setLoginItemSettings + ConfigService 동기화 - TextInsertService: 간이 삽입 검증 (EditMonitor 경량) - 번들링 인프라: SoX 다운로드 스크립트, PyInstaller 빌드, 경로 해상도 유틸 - AudioCaptureService/LocalSTTService: 번들 경로 자동 감지 - 08-design-system.md 기반 MUI 테마 (다크+라이트+auto 테마 시스템) - 전체 UI 컴포넌트 리디자인: AppLayout, Dashboard, StatusBar, History, Dictionary, Commands, Settings - 효과음 WAV 생성: recording-start, recording-stop, error - EPIPE 에러 핸들링 추가
This commit is contained in:
parent
ed5541f769
commit
3f4d0c5828
40 changed files with 6034 additions and 580 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -14,6 +14,12 @@ release/
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
build/
|
||||||
|
sidecar-dist/
|
||||||
|
|
||||||
|
# Bundled binaries (download via scripts)
|
||||||
|
resources/sox/*.exe
|
||||||
|
resources/sox/*.dll
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|
|
||||||
99
.gitlab-ci.yml
Normal file
99
.gitlab-ci.yml
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
stages:
|
||||||
|
- check
|
||||||
|
- test
|
||||||
|
- build
|
||||||
|
- release
|
||||||
|
|
||||||
|
variables:
|
||||||
|
NODE_VERSION: "20"
|
||||||
|
npm_config_cache: "$CI_PROJECT_DIR/.npm"
|
||||||
|
|
||||||
|
# Node.js 캐시
|
||||||
|
.node-cache: &node-cache
|
||||||
|
cache:
|
||||||
|
key:
|
||||||
|
files:
|
||||||
|
- package-lock.json
|
||||||
|
paths:
|
||||||
|
- .npm/
|
||||||
|
- node_modules/
|
||||||
|
|
||||||
|
# ── Check Stage ──────────────────────────────────────────
|
||||||
|
|
||||||
|
lint:
|
||||||
|
stage: check
|
||||||
|
image: node:${NODE_VERSION}
|
||||||
|
<<: *node-cache
|
||||||
|
before_script:
|
||||||
|
- npm ci --ignore-scripts
|
||||||
|
script:
|
||||||
|
- npm run lint
|
||||||
|
rules:
|
||||||
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
stage: check
|
||||||
|
image: node:${NODE_VERSION}
|
||||||
|
<<: *node-cache
|
||||||
|
before_script:
|
||||||
|
- npm ci --ignore-scripts
|
||||||
|
script:
|
||||||
|
- npm run typecheck
|
||||||
|
rules:
|
||||||
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
||||||
|
# ── Test Stage ───────────────────────────────────────────
|
||||||
|
|
||||||
|
unit-test:
|
||||||
|
stage: test
|
||||||
|
image: node:${NODE_VERSION}
|
||||||
|
<<: *node-cache
|
||||||
|
before_script:
|
||||||
|
- npm ci --ignore-scripts
|
||||||
|
script:
|
||||||
|
- npm run test:unit
|
||||||
|
rules:
|
||||||
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
||||||
|
# ── Build Stage ──────────────────────────────────────────
|
||||||
|
|
||||||
|
build:
|
||||||
|
stage: build
|
||||||
|
image: node:${NODE_VERSION}
|
||||||
|
<<: *node-cache
|
||||||
|
before_script:
|
||||||
|
- npm ci
|
||||||
|
script:
|
||||||
|
- npm run build
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- out/
|
||||||
|
expire_in: 1 day
|
||||||
|
rules:
|
||||||
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
||||||
|
# ── Release Stage (태그 푸시 시만 실행) ──────────────────
|
||||||
|
|
||||||
|
package-windows:
|
||||||
|
stage: release
|
||||||
|
tags:
|
||||||
|
- windows # Windows 러너 필요 (native 모듈 빌드)
|
||||||
|
before_script:
|
||||||
|
- npm ci
|
||||||
|
script:
|
||||||
|
- npm run build
|
||||||
|
- npm run dist
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- release/
|
||||||
|
expire_in: 30 days
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+/
|
||||||
|
|
||||||
|
# Docker 러너에서 Windows 빌드가 불가한 경우 아래 대안 사용:
|
||||||
|
# Wine + electron-builder --linux 크로스빌드 또는
|
||||||
|
# Windows self-hosted runner 등록
|
||||||
44
CLAUDE.md
44
CLAUDE.md
|
|
@ -77,10 +77,46 @@ npm run typecheck # tsc --noEmit
|
||||||
6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션
|
6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션
|
||||||
|
|
||||||
## 현재 상태
|
## 현재 상태
|
||||||
Phase: 6 완료 (Phase 1~6 + 3.5 전체 완료)
|
Phase: 8 완료 (Phase 1~8 + 3.5 전체 완료)
|
||||||
마지막 완료: Phase 6 — 커스텀 명령어 + i18n (ko/en)
|
마지막 완료: Phase 8 — UI 전면 리디자인 (08-design-system.md SSOT 적용)
|
||||||
다음 작업: Phase 7 — 테스트 + 빌드 + 배포 (electron-builder, CI/CD)
|
다음 작업: 전사 테스트 (npm run dev) → 온보딩 위저드
|
||||||
차단 이슈: SoX 미설치, @nut-tree-fork/nut-js 포크 사용
|
차단 이슈: @nut-tree-fork/nut-js 포크 사용
|
||||||
|
|
||||||
|
### Phase 8 구현 내용
|
||||||
|
- 08-design-system.md SSOT 기반 MUI 테마 전면 교체 (다크+라이트+auto 테마 시스템)
|
||||||
|
- theme.ts: d3roPalette(SSOT), createD3ROTheme('dark'|'light'), getTheme(mode, prefersDark)
|
||||||
|
- App.tsx: auto 모드 → 시스템 설정 따름 (나중에 커스텀 테마 확장 가능)
|
||||||
|
- AppLayout: 앰버 LED, 라벨 스타일, 아이콘 색상, 네비게이션 앰버 선택
|
||||||
|
- DashboardPage: hero 수치(28px mono), 카드 그리드, LED 상태 패널, 태그 시스템
|
||||||
|
- StatusBar: LED 인디케이터 + 모노 폰트 + 핫키 힌트
|
||||||
|
- HistoryPage: 카드 레이아웃, 앰버/퍼플 태그, 모노 메타데이터
|
||||||
|
- DictionaryPage: 카드 레이아웃, 카테고리 태그, 사용 횟수 모노
|
||||||
|
- CommandsPage: 카드 레이아웃, BUILT-IN/CUSTOM 태그
|
||||||
|
- SettingsModal: 디자인 시스템 border/close 스타일
|
||||||
|
|
||||||
|
### Phase 7.5 구현 내용
|
||||||
|
- SoundEffectService: WAV 프리로드, fire-and-forget, PowerShell SoundPlayer로 재생
|
||||||
|
- AutoLaunchService: app.setLoginItemSettings, ConfigService 연동, syncWithConfig
|
||||||
|
- TextInsertService: 간이 삽입 검증 (EditMonitor 경량), 클립보드 확인
|
||||||
|
- VoiceModeService 효과음 연동: session-started(start), completed(stop), cancelled(cancel), error(error)
|
||||||
|
- IPC: system:playSound/setSoundEnabled/isSoundEnabled, config:setAutoLaunch/setCloseToTray
|
||||||
|
- Bootstrap: sound-effects, auto-launch 초기화 단계 추가
|
||||||
|
- 효과음 WAV 파일: scripts/generate-sounds.js로 생성 (recording-start/stop, error)
|
||||||
|
|
||||||
|
### Phase 7 구현 내용
|
||||||
|
- vitest 테스트 환경: vitest.config.ts, tests/setup.ts (electron/electron-log 모킹)
|
||||||
|
- 테스트 헬퍼: tests/helpers/createTestDb.ts (in-memory SQLite + drizzle)
|
||||||
|
- 단위 테스트 41개: HistoryService, DictionaryService, CustomInstructionService, VoiceModeService, D3ROError
|
||||||
|
- electron-builder: electron-builder.yml (NSIS, asarUnpack, extraResources)
|
||||||
|
- GitLab CI/CD: .gitlab-ci.yml (lint, typecheck, test, build, release)
|
||||||
|
- 빌드 스크립트: pack, dist, test:unit 추가
|
||||||
|
- 참고: better-sqlite3는 Electron용 빌드라 vitest에서 직접 사용 불가 → DB 서비스는 모킹 테스트
|
||||||
|
- 번들링 인프라: SoX 다운로드 스크립트, PyInstaller 빌드 스크립트, 경로 해상도 유틸
|
||||||
|
- src/main/utils/paths.ts: dev vs production 경로 자동 감지 (SoX, sidecar, sounds)
|
||||||
|
- AudioCaptureService: 번들 SoX 경로 사용 (getSoxPath)
|
||||||
|
- LocalSTTService: 번들 sidecar 경로 사용 (getSidecarCommand)
|
||||||
|
- scripts/download-sox.ps1: SoX Windows 바이너리 다운로드 → resources/sox/
|
||||||
|
- scripts/build-sidecar.py: PyInstaller → sidecar-dist/sidecar/sidecar.exe
|
||||||
|
|
||||||
### Phase 6 구현 내용
|
### Phase 6 구현 내용
|
||||||
- CustomInstructionService: electron-store 기반, 프리셋 5개 (번역/요약/전문리라이트/코드설명/자유프롬프트)
|
- CustomInstructionService: electron-store 기반, 프리셋 5개 (번역/요약/전문리라이트/코드설명/자유프롬프트)
|
||||||
|
|
|
||||||
62
electron-builder.yml
Normal file
62
electron-builder.yml
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
appId: com.d3ro.voice
|
||||||
|
productName: D3RO Voice
|
||||||
|
copyright: Copyright © 2026 D3RO
|
||||||
|
|
||||||
|
directories:
|
||||||
|
buildResources: resources
|
||||||
|
output: release/${version}
|
||||||
|
|
||||||
|
files:
|
||||||
|
- out/**/*
|
||||||
|
- "!out/**/*.map"
|
||||||
|
|
||||||
|
# native 모듈은 asar 외부에 배치
|
||||||
|
asarUnpack:
|
||||||
|
- "node_modules/better-sqlite3/**"
|
||||||
|
- "node_modules/uiohook-napi/**"
|
||||||
|
- "node_modules/@nut-tree-fork/**"
|
||||||
|
|
||||||
|
win:
|
||||||
|
target:
|
||||||
|
- target: nsis
|
||||||
|
arch:
|
||||||
|
- x64
|
||||||
|
icon: resources/icons/icon.ico
|
||||||
|
|
||||||
|
nsis:
|
||||||
|
oneClick: false
|
||||||
|
perMachine: false
|
||||||
|
allowToChangeInstallationDirectory: true
|
||||||
|
createDesktopShortcut: true
|
||||||
|
createStartMenuShortcut: true
|
||||||
|
shortcutName: D3RO Voice
|
||||||
|
deleteAppDataOnUninstall: false
|
||||||
|
|
||||||
|
# 번들 리소스 (앱 외부, resources/ 디렉토리에 배치)
|
||||||
|
extraResources:
|
||||||
|
# SoX Windows 바이너리 (~5MB)
|
||||||
|
- from: resources/sox/
|
||||||
|
to: sox/
|
||||||
|
filter:
|
||||||
|
- "**/*"
|
||||||
|
|
||||||
|
# Python sidecar (PyInstaller 빌드 결과, ~200MB)
|
||||||
|
# 빌드 전: python scripts/build-sidecar.py
|
||||||
|
- from: sidecar-dist/sidecar/
|
||||||
|
to: sidecar/
|
||||||
|
filter:
|
||||||
|
- "**/*"
|
||||||
|
|
||||||
|
# 효과음 파일
|
||||||
|
- from: resources/sounds/
|
||||||
|
to: sounds/
|
||||||
|
filter:
|
||||||
|
- "*.wav"
|
||||||
|
|
||||||
|
# 앱 아이콘
|
||||||
|
- from: resources/icons/
|
||||||
|
to: icons/
|
||||||
|
filter:
|
||||||
|
- "**/*"
|
||||||
|
|
||||||
|
npmRebuild: true
|
||||||
3346
package-lock.json
generated
3346
package-lock.json
generated
File diff suppressed because it is too large
Load diff
16
package.json
16
package.json
|
|
@ -10,11 +10,20 @@
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint . --ext .ts,.tsx",
|
"lint": "eslint . --ext .ts,.tsx",
|
||||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
||||||
"test": "vitest run",
|
"test": "vitest run --config vitest.config.ts",
|
||||||
"test:watch": "vitest"
|
"test:unit": "vitest run --config vitest.config.ts",
|
||||||
|
"test:watch": "vitest --config vitest.config.ts",
|
||||||
|
"pack": "electron-vite build && electron-builder --dir",
|
||||||
|
"dist": "electron-vite build && electron-builder",
|
||||||
|
"build:sidecar": "python scripts/build-sidecar.py",
|
||||||
|
"setup:sox": "powershell -ExecutionPolicy Bypass -File scripts/download-sox.ps1"
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "D3RO",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"build": {
|
||||||
|
"extends": null,
|
||||||
|
"configFile": "electron-builder.yml"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
|
@ -25,6 +34,7 @@
|
||||||
"@typescript-eslint/parser": "^8.0.0",
|
"@typescript-eslint/parser": "^8.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"electron": "^33.3.0",
|
"electron": "^33.3.0",
|
||||||
|
"electron-builder": "^26.8.1",
|
||||||
"electron-vite": "^2.3.0",
|
"electron-vite": "^2.3.0",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
|
|
||||||
BIN
resources/sounds/error.wav
Normal file
BIN
resources/sounds/error.wav
Normal file
Binary file not shown.
BIN
resources/sounds/recording-start.wav
Normal file
BIN
resources/sounds/recording-start.wav
Normal file
Binary file not shown.
BIN
resources/sounds/recording-stop.wav
Normal file
BIN
resources/sounds/recording-stop.wav
Normal file
Binary file not shown.
120
scripts/build-sidecar.py
Normal file
120
scripts/build-sidecar.py
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
"""
|
||||||
|
scripts/build-sidecar.py
|
||||||
|
faster-whisper sidecar를 PyInstaller로 빌드한다.
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
pip install pyinstaller
|
||||||
|
python scripts/build-sidecar.py
|
||||||
|
|
||||||
|
출력:
|
||||||
|
sidecar-dist/sidecar.exe (단일 디렉토리 모드)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
SIDECAR_DIR = PROJECT_ROOT / "sidecar"
|
||||||
|
MAIN_PY = SIDECAR_DIR / "main.py"
|
||||||
|
OUTPUT_DIR = PROJECT_ROOT / "sidecar-dist"
|
||||||
|
|
||||||
|
|
||||||
|
def check_prerequisites():
|
||||||
|
"""필수 도구가 설치되어 있는지 확인한다."""
|
||||||
|
# PyInstaller
|
||||||
|
try:
|
||||||
|
import PyInstaller # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
print("PyInstaller가 설치되어 있지 않습니다.")
|
||||||
|
print("실행: pip install pyinstaller")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# faster-whisper
|
||||||
|
try:
|
||||||
|
import faster_whisper # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
print("faster-whisper가 설치되어 있지 않습니다.")
|
||||||
|
print("실행: pip install -r sidecar/requirements.txt")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not MAIN_PY.exists():
|
||||||
|
print(f"sidecar 소스를 찾을 수 없습니다: {MAIN_PY}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def build():
|
||||||
|
"""PyInstaller로 sidecar를 빌드한다."""
|
||||||
|
print("=" * 60)
|
||||||
|
print("D3RO-VOICE Sidecar 빌드 시작")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 기존 빌드 정리
|
||||||
|
if OUTPUT_DIR.exists():
|
||||||
|
shutil.rmtree(OUTPUT_DIR)
|
||||||
|
|
||||||
|
# PyInstaller 실행 — onedir 모드 (onefile보다 시작이 빠름)
|
||||||
|
cmd = [
|
||||||
|
sys.executable, "-m", "PyInstaller",
|
||||||
|
"--name", "sidecar",
|
||||||
|
"--distpath", str(OUTPUT_DIR),
|
||||||
|
"--workpath", str(PROJECT_ROOT / "build" / "sidecar-build"),
|
||||||
|
"--specpath", str(PROJECT_ROOT / "build"),
|
||||||
|
# onedir 모드 (단일 디렉토리)
|
||||||
|
"--noconfirm",
|
||||||
|
"--clean",
|
||||||
|
# hidden imports (PyInstaller가 자동 감지 못하는 것)
|
||||||
|
"--hidden-import", "faster_whisper",
|
||||||
|
"--hidden-import", "ctranslate2",
|
||||||
|
"--hidden-import", "huggingface_hub",
|
||||||
|
"--hidden-import", "tokenizers",
|
||||||
|
"--hidden-import", "uvicorn.logging",
|
||||||
|
"--hidden-import", "uvicorn.protocols.http",
|
||||||
|
"--hidden-import", "uvicorn.protocols.http.auto",
|
||||||
|
"--hidden-import", "uvicorn.protocols.http.h11_impl",
|
||||||
|
"--hidden-import", "uvicorn.protocols.websockets",
|
||||||
|
"--hidden-import", "uvicorn.protocols.websockets.auto",
|
||||||
|
"--hidden-import", "uvicorn.lifespan",
|
||||||
|
"--hidden-import", "uvicorn.lifespan.on",
|
||||||
|
"--hidden-import", "uvicorn.lifespan.off",
|
||||||
|
# 콘솔 없음 (Windows)
|
||||||
|
"--noconsole",
|
||||||
|
str(MAIN_PY),
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"\n실행 명령:\n {' '.join(cmd)}\n")
|
||||||
|
|
||||||
|
result = subprocess.run(cmd, cwd=str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"\nPyInstaller 빌드 실패 (exit code: {result.returncode})")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 빌드 결과 확인
|
||||||
|
exe_path = OUTPUT_DIR / "sidecar" / "sidecar.exe"
|
||||||
|
if exe_path.exists():
|
||||||
|
size_mb = exe_path.stat().st_size / (1024 * 1024)
|
||||||
|
print(f"\n빌드 성공!")
|
||||||
|
print(f" 경로: {exe_path}")
|
||||||
|
print(f" 크기: {size_mb:.1f} MB")
|
||||||
|
|
||||||
|
# 전체 디렉토리 크기
|
||||||
|
total_size = sum(f.stat().st_size for f in (OUTPUT_DIR / "sidecar").rglob("*") if f.is_file())
|
||||||
|
total_mb = total_size / (1024 * 1024)
|
||||||
|
print(f" 전체 디렉토리: {total_mb:.1f} MB")
|
||||||
|
else:
|
||||||
|
print(f"\n빌드 출력을 찾을 수 없습니다: {exe_path}")
|
||||||
|
# onedir 모드에서는 디렉토리 내부에 exe가 있음
|
||||||
|
for exe in OUTPUT_DIR.rglob("*.exe"):
|
||||||
|
print(f" 발견: {exe}")
|
||||||
|
|
||||||
|
print("\n빌드 완료!")
|
||||||
|
print(f"electron-builder에서 이 경로를 extraResources로 지정하세요:")
|
||||||
|
print(f" from: sidecar-dist/sidecar/")
|
||||||
|
print(f" to: sidecar/")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
check_prerequisites()
|
||||||
|
build()
|
||||||
111
scripts/download-sox.ps1
Normal file
111
scripts/download-sox.ps1
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
# scripts/download-sox.ps1
|
||||||
|
# SoX Windows 바이너리를 resources/sox/에 다운로드한다.
|
||||||
|
# 실행: powershell -ExecutionPolicy Bypass -File scripts/download-sox.ps1
|
||||||
|
|
||||||
|
$SOX_VERSION = "14.4.1"
|
||||||
|
# SourceForge 직접 다운로드 URL (리다이렉트 따라감)
|
||||||
|
$SOX_URL = "https://downloads.sourceforge.net/project/sox/sox/$SOX_VERSION/sox-${SOX_VERSION}-win32.zip"
|
||||||
|
$DEST_DIR = Join-Path $PSScriptRoot "..\resources\sox"
|
||||||
|
$TEMP_ZIP = Join-Path $env:TEMP "sox-${SOX_VERSION}-win32.zip"
|
||||||
|
$TEMP_DIR = Join-Path $env:TEMP "sox-extract"
|
||||||
|
|
||||||
|
Write-Host "=== SoX $SOX_VERSION Windows 바이너리 다운로드 ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# 이미 존재하면 스킵
|
||||||
|
if (Test-Path (Join-Path $DEST_DIR "sox.exe")) {
|
||||||
|
Write-Host "SoX가 이미 존재합니다: $DEST_DIR\sox.exe" -ForegroundColor Green
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# 기존 임시 파일 정리
|
||||||
|
if (Test-Path $TEMP_ZIP) { Remove-Item -Force $TEMP_ZIP }
|
||||||
|
|
||||||
|
# 다운로드 (SourceForge 리다이렉트를 따라감)
|
||||||
|
Write-Host "다운로드 중: $SOX_URL"
|
||||||
|
Write-Host "(SourceForge 리다이렉트를 따라가므로 시간이 걸릴 수 있습니다)"
|
||||||
|
try {
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
$webClient = New-Object System.Net.WebClient
|
||||||
|
$webClient.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
|
||||||
|
$webClient.DownloadFile($SOX_URL, $TEMP_ZIP)
|
||||||
|
} catch {
|
||||||
|
Write-Host "자동 다운로드 실패: $_" -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "=== 수동 설치 방법 ===" -ForegroundColor Yellow
|
||||||
|
Write-Host "1. 브라우저에서 다운로드:" -ForegroundColor White
|
||||||
|
Write-Host " https://sourceforge.net/projects/sox/files/sox/14.4.1/sox-14.4.1-win32.zip/download"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "2. 다운로드한 zip에서 아래 파일들을 복사:" -ForegroundColor White
|
||||||
|
Write-Host " sox.exe, rec.exe, libmad-0.dll, libmp3lame-0.dll, libsox-3.dll"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "3. 복사 위치:" -ForegroundColor White
|
||||||
|
Write-Host " $DEST_DIR"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# zip 파일 검증
|
||||||
|
$fileSize = (Get-Item $TEMP_ZIP).Length
|
||||||
|
if ($fileSize -lt 100000) {
|
||||||
|
Write-Host "다운로드된 파일이 너무 작습니다 (${fileSize} bytes). HTML 페이지가 다운로드된 것 같습니다." -ForegroundColor Red
|
||||||
|
Remove-Item -Force $TEMP_ZIP -ErrorAction SilentlyContinue
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "=== 수동 설치 방법 ===" -ForegroundColor Yellow
|
||||||
|
Write-Host "1. 브라우저에서 다운로드:" -ForegroundColor White
|
||||||
|
Write-Host " https://sourceforge.net/projects/sox/files/sox/14.4.1/sox-14.4.1-win32.zip/download"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "2. 다운로드한 zip 압축 해제 후 아래 파일들을 복사:" -ForegroundColor White
|
||||||
|
Write-Host " sox.exe, rec.exe, libmad-0.dll, libmp3lame-0.dll, libsox-3.dll"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "3. 복사 위치:" -ForegroundColor White
|
||||||
|
Write-Host " $DEST_DIR"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "다운로드 완료 (${fileSize} bytes)"
|
||||||
|
|
||||||
|
# 압축 해제
|
||||||
|
Write-Host "압축 해제 중..."
|
||||||
|
if (Test-Path $TEMP_DIR) { Remove-Item -Recurse -Force $TEMP_DIR }
|
||||||
|
|
||||||
|
try {
|
||||||
|
Expand-Archive -Path $TEMP_ZIP -DestinationPath $TEMP_DIR -Force
|
||||||
|
} catch {
|
||||||
|
Write-Host "압축 해제 실패: $_" -ForegroundColor Red
|
||||||
|
Write-Host "수동으로 $TEMP_ZIP 을 압축 해제한 후 sox.exe 등을 $DEST_DIR 에 복사하세요." -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 필요한 파일만 복사
|
||||||
|
$SOX_EXTRACTED = Get-ChildItem $TEMP_DIR -Directory | Select-Object -First 1
|
||||||
|
if (-not $SOX_EXTRACTED) {
|
||||||
|
# 디렉토리 없이 바로 파일이 있는 경우
|
||||||
|
$SOX_EXTRACTED = Get-Item $TEMP_DIR
|
||||||
|
}
|
||||||
|
|
||||||
|
$filesToCopy = @("sox.exe", "rec.exe", "libmad-0.dll", "libmp3lame-0.dll", "libsox-3.dll")
|
||||||
|
New-Item -ItemType Directory -Force -Path $DEST_DIR | Out-Null
|
||||||
|
|
||||||
|
$copied = 0
|
||||||
|
foreach ($file in $filesToCopy) {
|
||||||
|
$src = Get-ChildItem -Path $TEMP_DIR -Recurse -Filter $file | Select-Object -First 1
|
||||||
|
if ($src) {
|
||||||
|
Copy-Item $src.FullName -Destination $DEST_DIR
|
||||||
|
Write-Host " 복사: $file" -ForegroundColor Green
|
||||||
|
$copied++
|
||||||
|
} else {
|
||||||
|
Write-Host " 누락: $file (선택적)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 정리
|
||||||
|
Remove-Item -Recurse -Force $TEMP_DIR -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -Force $TEMP_ZIP -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
if ($copied -ge 1) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "SoX 설치 완료: $DEST_DIR ($copied 파일)" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "파일 복사 실패. 수동으로 설치해주세요." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
147
scripts/generate-sounds.js
Normal file
147
scripts/generate-sounds.js
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
/**
|
||||||
|
* D3RO-VOICE 효과음 WAV 파일 생성 스크립트
|
||||||
|
*
|
||||||
|
* 생성 파일:
|
||||||
|
* resources/sounds/recording-start.wav — 상승 톤 (440→880Hz, 150ms)
|
||||||
|
* resources/sounds/recording-stop.wav — 하강 톤 (880→440Hz, 150ms)
|
||||||
|
* resources/sounds/error.wav — 저음 비프 2회 (220Hz, 200ms×2)
|
||||||
|
*
|
||||||
|
* WAV 포맷: 16kHz, mono, 16bit PCM
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const SAMPLE_RATE = 16000;
|
||||||
|
const BIT_DEPTH = 16;
|
||||||
|
const NUM_CHANNELS = 1;
|
||||||
|
const BYTES_PER_SAMPLE = BIT_DEPTH / 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사인파 샘플 생성 (주파수 선형 스윕 지원)
|
||||||
|
* @param {number} durationMs - 길이 (ms)
|
||||||
|
* @param {number} freqStart - 시작 주파수 (Hz)
|
||||||
|
* @param {number} freqEnd - 끝 주파수 (Hz)
|
||||||
|
* @param {number} volume - 볼륨 (0.0~1.0)
|
||||||
|
* @returns {Int16Array}
|
||||||
|
*/
|
||||||
|
function generateTone(durationMs, freqStart, freqEnd, volume = 0.6) {
|
||||||
|
const numSamples = Math.floor((SAMPLE_RATE * durationMs) / 1000);
|
||||||
|
const samples = new Int16Array(numSamples);
|
||||||
|
const maxVal = 32767 * volume;
|
||||||
|
|
||||||
|
// 페이드 인/아웃 길이 (클릭 방지)
|
||||||
|
const fadeSamples = Math.min(Math.floor(numSamples * 0.05), 80);
|
||||||
|
|
||||||
|
let phase = 0;
|
||||||
|
for (let i = 0; i < numSamples; i++) {
|
||||||
|
const t = i / numSamples;
|
||||||
|
const freq = freqStart + (freqEnd - freqStart) * t;
|
||||||
|
|
||||||
|
// 페이드 인/아웃 엔벨로프
|
||||||
|
let envelope = 1.0;
|
||||||
|
if (i < fadeSamples) {
|
||||||
|
envelope = i / fadeSamples;
|
||||||
|
} else if (i > numSamples - fadeSamples) {
|
||||||
|
envelope = (numSamples - i) / fadeSamples;
|
||||||
|
}
|
||||||
|
|
||||||
|
samples[i] = Math.round(Math.sin(phase) * maxVal * envelope);
|
||||||
|
phase += (2 * Math.PI * freq) / SAMPLE_RATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 무음 생성
|
||||||
|
* @param {number} durationMs
|
||||||
|
* @returns {Int16Array}
|
||||||
|
*/
|
||||||
|
function generateSilence(durationMs) {
|
||||||
|
const numSamples = Math.floor((SAMPLE_RATE * durationMs) / 1000);
|
||||||
|
return new Int16Array(numSamples);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 샘플 배열을 이어붙임
|
||||||
|
* @param {Int16Array[]} arrays
|
||||||
|
* @returns {Int16Array}
|
||||||
|
*/
|
||||||
|
function concatenate(arrays) {
|
||||||
|
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
|
||||||
|
const result = new Int16Array(totalLength);
|
||||||
|
let offset = 0;
|
||||||
|
for (const arr of arrays) {
|
||||||
|
result.set(arr, offset);
|
||||||
|
offset += arr.length;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PCM 데이터를 WAV 파일 버퍼로 변환
|
||||||
|
* @param {Int16Array} samples
|
||||||
|
* @returns {Buffer}
|
||||||
|
*/
|
||||||
|
function createWavBuffer(samples) {
|
||||||
|
const dataSize = samples.length * BYTES_PER_SAMPLE;
|
||||||
|
const headerSize = 44;
|
||||||
|
const buffer = Buffer.alloc(headerSize + dataSize);
|
||||||
|
|
||||||
|
// RIFF header
|
||||||
|
buffer.write('RIFF', 0);
|
||||||
|
buffer.writeUInt32LE(headerSize - 8 + dataSize, 4);
|
||||||
|
buffer.write('WAVE', 8);
|
||||||
|
|
||||||
|
// fmt chunk
|
||||||
|
buffer.write('fmt ', 12);
|
||||||
|
buffer.writeUInt32LE(16, 16); // chunk size
|
||||||
|
buffer.writeUInt16LE(1, 20); // PCM format
|
||||||
|
buffer.writeUInt16LE(NUM_CHANNELS, 22);
|
||||||
|
buffer.writeUInt32LE(SAMPLE_RATE, 24);
|
||||||
|
buffer.writeUInt32LE(SAMPLE_RATE * NUM_CHANNELS * BYTES_PER_SAMPLE, 28); // byte rate
|
||||||
|
buffer.writeUInt16LE(NUM_CHANNELS * BYTES_PER_SAMPLE, 32); // block align
|
||||||
|
buffer.writeUInt16LE(BIT_DEPTH, 34);
|
||||||
|
|
||||||
|
// data chunk
|
||||||
|
buffer.write('data', 36);
|
||||||
|
buffer.writeUInt32LE(dataSize, 40);
|
||||||
|
|
||||||
|
// PCM data (Int16 little-endian)
|
||||||
|
const pcmBuffer = Buffer.from(samples.buffer, samples.byteOffset, samples.byteLength);
|
||||||
|
pcmBuffer.copy(buffer, headerSize);
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 효과음 생성 ---
|
||||||
|
|
||||||
|
const outputDir = path.resolve(__dirname, '..', 'resources', 'sounds');
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
// 1. recording-start.wav: 상승 톤 440→880Hz, 150ms
|
||||||
|
const startSamples = generateTone(150, 440, 880, 0.5);
|
||||||
|
const startWav = createWavBuffer(startSamples);
|
||||||
|
const startPath = path.join(outputDir, 'recording-start.wav');
|
||||||
|
fs.writeFileSync(startPath, startWav);
|
||||||
|
console.log(`Created: ${startPath} (${startWav.length} bytes)`);
|
||||||
|
|
||||||
|
// 2. recording-stop.wav: 하강 톤 880→440Hz, 150ms
|
||||||
|
const stopSamples = generateTone(150, 880, 440, 0.5);
|
||||||
|
const stopWav = createWavBuffer(stopSamples);
|
||||||
|
const stopPath = path.join(outputDir, 'recording-stop.wav');
|
||||||
|
fs.writeFileSync(stopPath, stopWav);
|
||||||
|
console.log(`Created: ${stopPath} (${stopWav.length} bytes)`);
|
||||||
|
|
||||||
|
// 3. error.wav: 220Hz 비프 200ms × 2회, 중간 100ms 무음
|
||||||
|
const beep1 = generateTone(200, 220, 220, 0.5);
|
||||||
|
const gap = generateSilence(100);
|
||||||
|
const beep2 = generateTone(200, 220, 220, 0.5);
|
||||||
|
const errorSamples = concatenate([beep1, gap, beep2]);
|
||||||
|
const errorWav = createWavBuffer(errorSamples);
|
||||||
|
const errorPath = path.join(outputDir, 'error.wav');
|
||||||
|
fs.writeFileSync(errorPath, errorWav);
|
||||||
|
console.log(`Created: ${errorPath} (${errorWav.length} bytes)`);
|
||||||
|
|
||||||
|
console.log('\nDone! All 3 sound files generated.');
|
||||||
|
|
@ -9,6 +9,8 @@ import { getLocalLLMService } from './services/LocalLLMService'
|
||||||
import { getHistoryService } from './services/HistoryService'
|
import { getHistoryService } from './services/HistoryService'
|
||||||
import { getTextInsertService } from './services/TextInsertService'
|
import { getTextInsertService } from './services/TextInsertService'
|
||||||
import { getCustomInstructionService } from './services/CustomInstructionService'
|
import { getCustomInstructionService } from './services/CustomInstructionService'
|
||||||
|
import { getSoundEffectService } from './services/SoundEffectService'
|
||||||
|
import { getAutoLaunchService } from './services/AutoLaunchService'
|
||||||
import { initDatabase } from './db'
|
import { initDatabase } from './db'
|
||||||
import {
|
import {
|
||||||
createMainWindow,
|
createMainWindow,
|
||||||
|
|
@ -43,6 +45,8 @@ export async function bootstrap(): Promise<void> {
|
||||||
{ name: 'tray', critical: false, fn: initTray },
|
{ name: 'tray', critical: false, fn: initTray },
|
||||||
{ name: 'ipc-handlers', critical: true, fn: initIpcHandlers },
|
{ name: 'ipc-handlers', critical: true, fn: initIpcHandlers },
|
||||||
{ name: 'custom-instructions', critical: false, fn: initCustomInstructions },
|
{ name: 'custom-instructions', critical: false, fn: initCustomInstructions },
|
||||||
|
{ name: 'sound-effects', critical: false, fn: initSoundEffects },
|
||||||
|
{ name: 'auto-launch', critical: false, fn: initAutoLaunch },
|
||||||
{ name: 'popup-preload', critical: false, fn: initPopupWindows },
|
{ name: 'popup-preload', critical: false, fn: initPopupWindows },
|
||||||
{ name: 'hotkey', critical: false, fn: initHotkey },
|
{ name: 'hotkey', critical: false, fn: initHotkey },
|
||||||
{ name: 'voice-mode', critical: false, fn: initVoiceMode },
|
{ name: 'voice-mode', critical: false, fn: initVoiceMode },
|
||||||
|
|
@ -101,6 +105,14 @@ async function initCustomInstructions(): Promise<void> {
|
||||||
getCustomInstructionService().initialize()
|
getCustomInstructionService().initialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function initSoundEffects(): Promise<void> {
|
||||||
|
getSoundEffectService().initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initAutoLaunch(): Promise<void> {
|
||||||
|
getAutoLaunchService().syncWithConfig()
|
||||||
|
}
|
||||||
|
|
||||||
async function initPopupWindows(): Promise<void> {
|
async function initPopupWindows(): Promise<void> {
|
||||||
preloadPopupWindows()
|
preloadPopupWindows()
|
||||||
setupHistoryPopupIPC()
|
setupHistoryPopupIPC()
|
||||||
|
|
@ -120,8 +132,11 @@ async function initVoiceMode(): Promise<void> {
|
||||||
const voiceMode = getVoiceModeService()
|
const voiceMode = getVoiceModeService()
|
||||||
voiceMode.connectHotkey()
|
voiceMode.connectHotkey()
|
||||||
|
|
||||||
|
const soundEffect = getSoundEffectService()
|
||||||
|
|
||||||
// RecordingTip 연동: 세션 시작/종료 시 팝업 표시/숨김
|
// RecordingTip 연동: 세션 시작/종료 시 팝업 표시/숨김
|
||||||
voiceMode.on('session-started', () => {
|
voiceMode.on('session-started', () => {
|
||||||
|
soundEffect.play('recording-start')
|
||||||
showRecordingTip('recording')
|
showRecordingTip('recording')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -136,6 +151,7 @@ async function initVoiceMode(): Promise<void> {
|
||||||
})
|
})
|
||||||
|
|
||||||
voiceMode.on('session-completed', ({ session, finalText }) => {
|
voiceMode.on('session-completed', ({ session, finalText }) => {
|
||||||
|
soundEffect.play('recording-stop')
|
||||||
hideRecordingTip()
|
hideRecordingTip()
|
||||||
if (finalText.length > 0) {
|
if (finalText.length > 0) {
|
||||||
showResultPopup(finalText)
|
showResultPopup(finalText)
|
||||||
|
|
@ -157,11 +173,15 @@ async function initVoiceMode(): Promise<void> {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
voiceMode.on('session-cancelled', () => {
|
voiceMode.on('session-cancelled', ({ reason }) => {
|
||||||
|
if (reason !== 'too-short') {
|
||||||
|
soundEffect.play('cancel')
|
||||||
|
}
|
||||||
hideRecordingTip()
|
hideRecordingTip()
|
||||||
})
|
})
|
||||||
|
|
||||||
voiceMode.on('error', ({ error }) => {
|
voiceMode.on('error', ({ error }) => {
|
||||||
|
soundEffect.play('error')
|
||||||
updateRecordingTipState('error', { errorMessage: error.message })
|
updateRecordingTipState('error', { errorMessage: error.message })
|
||||||
setTimeout(() => hideRecordingTip(), 3000)
|
setTimeout(() => hideRecordingTip(), 3000)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,15 @@ import { bootstrap } from './bootstrap'
|
||||||
import { setupLifecycle } from './lifecycle'
|
import { setupLifecycle } from './lifecycle'
|
||||||
import { getMainWindow } from './windows/WindowManager'
|
import { getMainWindow } from './windows/WindowManager'
|
||||||
|
|
||||||
|
// EPIPE 에러 방지: electron-log가 stdout/stderr에 쓸 때 파이프가 끊기면 크래시 방지
|
||||||
|
process.stdout?.on?.('error', () => { /* ignore EPIPE */ })
|
||||||
|
process.stderr?.on?.('error', () => { /* ignore EPIPE */ })
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
if (err.message?.includes('EPIPE')) return // EPIPE는 무시
|
||||||
|
// 기타 예외는 로그만
|
||||||
|
try { require('electron-log').default?.error?.('Uncaught:', err) } catch { /* noop */ }
|
||||||
|
})
|
||||||
|
|
||||||
// 단일 인스턴스 잠금
|
// 단일 인스턴스 잠금
|
||||||
const gotTheLock = app.requestSingleInstanceLock()
|
const gotTheLock = app.requestSingleInstanceLock()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,15 @@ import { ipcMain } from 'electron'
|
||||||
import { IPC_CHANNELS } from '@shared/ipc-channels'
|
import { IPC_CHANNELS } from '@shared/ipc-channels'
|
||||||
import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors'
|
import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors'
|
||||||
import { configGet, configSet, configGetAll, configReset } from '../services/ConfigService'
|
import { configGet, configSet, configGetAll, configReset } from '../services/ConfigService'
|
||||||
|
import { getAutoLaunchService } from '../services/AutoLaunchService'
|
||||||
import type {
|
import type {
|
||||||
ConfigGetParams,
|
ConfigGetParams,
|
||||||
ConfigSetParams,
|
ConfigSetParams,
|
||||||
ConfigResetParams,
|
ConfigResetParams,
|
||||||
SetThemeParams,
|
SetThemeParams,
|
||||||
SetLanguageParams,
|
SetLanguageParams,
|
||||||
|
SetAutoLaunchParams,
|
||||||
|
SetCloseToTrayParams,
|
||||||
AppConfig
|
AppConfig
|
||||||
} from '@shared/types'
|
} from '@shared/types'
|
||||||
|
|
||||||
|
|
@ -66,7 +69,17 @@ export function registerConfigHandlers(): void {
|
||||||
return ipcSuccess(configGet('autoLaunch'))
|
return ipcSuccess(configGet('autoLaunch'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.CONFIG.SET_AUTO_LAUNCH, async (_event, params: SetAutoLaunchParams) => {
|
||||||
|
getAutoLaunchService().setEnabled(params.enabled)
|
||||||
|
return ipcSuccess(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.CONFIG.GET_CLOSE_TO_TRAY, async () => {
|
ipcMain.handle(IPC_CHANNELS.CONFIG.GET_CLOSE_TO_TRAY, async () => {
|
||||||
return ipcSuccess(configGet('closeToTray'))
|
return ipcSuccess(configGet('closeToTray'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.CONFIG.SET_CLOSE_TO_TRAY, async (_event, params: SetCloseToTrayParams) => {
|
||||||
|
configSet('closeToTray', params.enabled)
|
||||||
|
return ipcSuccess(undefined)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
import { ipcMain, app, systemPreferences } from 'electron'
|
import { ipcMain, app, systemPreferences } from 'electron'
|
||||||
import { IPC_CHANNELS } from '@shared/ipc-channels'
|
import { IPC_CHANNELS } from '@shared/ipc-channels'
|
||||||
import { ipcSuccess } from '@shared/errors'
|
import { ipcSuccess } from '@shared/errors'
|
||||||
import type { PermissionStatus } from '@shared/types'
|
import type { PermissionStatus, PlaySoundParams, SetSoundEnabledParams } from '@shared/types'
|
||||||
|
import { getSoundEffectService } from '../services/SoundEffectService'
|
||||||
|
|
||||||
export function registerSystemHandlers(): void {
|
export function registerSystemHandlers(): void {
|
||||||
ipcMain.handle(IPC_CHANNELS.SYSTEM.GET_PLATFORM, async () => {
|
ipcMain.handle(IPC_CHANNELS.SYSTEM.GET_PLATFORM, async () => {
|
||||||
|
|
@ -25,4 +26,20 @@ export function registerSystemHandlers(): void {
|
||||||
}
|
}
|
||||||
return ipcSuccess(status)
|
return ipcSuccess(status)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Sound Effect ──
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.SYSTEM.PLAY_SOUND, async (_event, params: PlaySoundParams) => {
|
||||||
|
getSoundEffectService().play(params.sound as 'recording-start' | 'recording-stop' | 'error' | 'cancel')
|
||||||
|
return ipcSuccess(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.SYSTEM.SET_SOUND_ENABLED, async (_event, params: SetSoundEnabledParams) => {
|
||||||
|
getSoundEffectService().setEnabled(params.enabled)
|
||||||
|
return ipcSuccess(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.SYSTEM.IS_SOUND_ENABLED, async () => {
|
||||||
|
return ipcSuccess(getSoundEffectService().isEnabled())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,14 @@
|
||||||
// node-record-lpcm16 + SoX로 PCM16 16kHz mono 캡처.
|
// node-record-lpcm16 + SoX로 PCM16 16kHz mono 캡처.
|
||||||
|
|
||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
|
import path from 'path'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
import { record } from 'node-record-lpcm16'
|
import { record } from 'node-record-lpcm16'
|
||||||
import type { Recording } from 'node-record-lpcm16'
|
import type { Recording } from 'node-record-lpcm16'
|
||||||
import type { Readable } from 'stream'
|
import type { Readable } from 'stream'
|
||||||
import { getLogger } from './LoggerService'
|
import { getLogger } from './LoggerService'
|
||||||
import { configGet } from './ConfigService'
|
import { configGet } from './ConfigService'
|
||||||
|
import { getSoxPath } from '../utils/paths'
|
||||||
import type { AudioDevice } from '@shared/types'
|
import type { AudioDevice } from '@shared/types'
|
||||||
import { AUDIO_FORMAT, TIMING } from '@shared/constants'
|
import { AUDIO_FORMAT, TIMING } from '@shared/constants'
|
||||||
import { D3ROError, ErrorCode } from '@shared/errors'
|
import { D3ROError, ErrorCode } from '@shared/errors'
|
||||||
|
|
@ -93,7 +96,17 @@ class AudioCaptureService extends EventEmitter {
|
||||||
`format: ${AUDIO_FORMAT.SAMPLE_RATE}Hz ${AUDIO_FORMAT.CHANNELS}ch ${AUDIO_FORMAT.BIT_DEPTH}bit)`
|
`format: ${AUDIO_FORMAT.SAMPLE_RATE}Hz ${AUDIO_FORMAT.CHANNELS}ch ${AUDIO_FORMAT.BIT_DEPTH}bit)`
|
||||||
)
|
)
|
||||||
|
|
||||||
// node-record-lpcm16 으로 SoX rec 프로세스 spawn
|
// node-record-lpcm16은 'sox' 명령어를 PATH에서 찾으므로,
|
||||||
|
// 번들된 SoX 디렉토리를 PATH 앞에 추가한다.
|
||||||
|
const soxExe = getSoxPath()
|
||||||
|
const soxDir = path.dirname(soxExe)
|
||||||
|
if (existsSync(soxExe) && soxExe !== 'sox') {
|
||||||
|
const sep = process.platform === 'win32' ? ';' : ':'
|
||||||
|
process.env.PATH = soxDir + sep + (process.env.PATH ?? '')
|
||||||
|
logger.info(`Bundled SoX added to PATH: ${soxDir}`)
|
||||||
|
}
|
||||||
|
logger.info(`Using SoX: ${soxExe}`)
|
||||||
|
|
||||||
const recordingOptions: Record<string, unknown> = {
|
const recordingOptions: Record<string, unknown> = {
|
||||||
sampleRate: AUDIO_FORMAT.SAMPLE_RATE,
|
sampleRate: AUDIO_FORMAT.SAMPLE_RATE,
|
||||||
channels: AUDIO_FORMAT.CHANNELS,
|
channels: AUDIO_FORMAT.CHANNELS,
|
||||||
|
|
|
||||||
67
src/main/services/AutoLaunchService.ts
Normal file
67
src/main/services/AutoLaunchService.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
// src/main/services/AutoLaunchService.ts
|
||||||
|
// 시스템 시작 시 자동 실행 관리. 설계서 01 IAutoLaunchService 구현.
|
||||||
|
|
||||||
|
import { app } from 'electron'
|
||||||
|
import { getLogger } from './LoggerService'
|
||||||
|
import { configGet, configSet } from './ConfigService'
|
||||||
|
|
||||||
|
const logger = getLogger('AutoLaunchService')
|
||||||
|
|
||||||
|
class AutoLaunchService {
|
||||||
|
/**
|
||||||
|
* 현재 자동 실행 설정 상태를 조회한다.
|
||||||
|
*/
|
||||||
|
isEnabled(): boolean {
|
||||||
|
// Electron API로 실제 OS 설정 확인
|
||||||
|
const settings = app.getLoginItemSettings()
|
||||||
|
return settings.openAtLogin
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 실행을 활성화/비활성화한다.
|
||||||
|
*/
|
||||||
|
setEnabled(enabled: boolean): void {
|
||||||
|
try {
|
||||||
|
app.setLoginItemSettings({
|
||||||
|
openAtLogin: enabled,
|
||||||
|
// Windows: 시작 프로그램에 등록
|
||||||
|
// 개발 모드에서는 electron.exe 경로가 등록되므로 주의
|
||||||
|
args: app.isPackaged ? [] : [app.getAppPath()]
|
||||||
|
})
|
||||||
|
|
||||||
|
configSet('autoLaunch', enabled)
|
||||||
|
logger.info(`Auto launch ${enabled ? 'enabled' : 'disabled'}`)
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Failed to set auto launch: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ConfigService의 설정과 OS 설정을 동기화한다.
|
||||||
|
* bootstrap에서 호출.
|
||||||
|
*/
|
||||||
|
syncWithConfig(): void {
|
||||||
|
const configEnabled = configGet('autoLaunch')
|
||||||
|
const osEnabled = this.isEnabled()
|
||||||
|
|
||||||
|
if (configEnabled !== osEnabled) {
|
||||||
|
logger.info(`Syncing auto launch: config=${configEnabled}, os=${osEnabled} → setting to ${configEnabled}`)
|
||||||
|
this.setEnabled(configEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
logger.info('AutoLaunchService disposed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 싱글톤 ──
|
||||||
|
|
||||||
|
let instance: AutoLaunchService | null = null
|
||||||
|
|
||||||
|
export function getAutoLaunchService(): AutoLaunchService {
|
||||||
|
if (!instance) {
|
||||||
|
instance = new AutoLaunchService()
|
||||||
|
}
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
@ -5,10 +5,9 @@
|
||||||
|
|
||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
import { type ChildProcess, spawn } from 'child_process'
|
import { type ChildProcess, spawn } from 'child_process'
|
||||||
import path from 'path'
|
|
||||||
import { app } from 'electron'
|
|
||||||
import { getLogger } from './LoggerService'
|
import { getLogger } from './LoggerService'
|
||||||
import { configGet } from './ConfigService'
|
import { configGet } from './ConfigService'
|
||||||
|
import { getSidecarCommand } from '../utils/paths'
|
||||||
import { D3ROError, ErrorCode } from '@shared/errors'
|
import { D3ROError, ErrorCode } from '@shared/errors'
|
||||||
import type { STTModel, STTStatus, STTEngineState } from '@shared/types'
|
import type { STTModel, STTStatus, STTEngineState } from '@shared/types'
|
||||||
|
|
||||||
|
|
@ -359,22 +358,16 @@ class LocalSTTService extends EventEmitter {
|
||||||
|
|
||||||
// ── Sidecar 관리 ──
|
// ── Sidecar 관리 ──
|
||||||
|
|
||||||
private _getSidecarPath(): string {
|
|
||||||
const basePath = app.isPackaged ? process.resourcesPath : app.getAppPath()
|
|
||||||
return path.join(basePath, 'sidecar', 'main.py')
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _spawnSidecar(): Promise<void> {
|
private async _spawnSidecar(): Promise<void> {
|
||||||
const sidecarPath = this._getSidecarPath()
|
const { command, args } = getSidecarCommand()
|
||||||
logger.info(`Sidecar 시작: python ${sidecarPath} --port ${this._port}`)
|
const fullArgs = [...args, '--port', String(this._port)]
|
||||||
|
logger.info(`Sidecar 시작: ${command} ${fullArgs.join(' ')}`)
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const pythonCmd = process.platform === 'win32' ? 'python' : 'python3'
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this._sidecarProcess = spawn(
|
this._sidecarProcess = spawn(
|
||||||
pythonCmd,
|
command,
|
||||||
[sidecarPath, '--port', String(this._port)],
|
fullArgs,
|
||||||
{
|
{
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
env: { ...process.env },
|
env: { ...process.env },
|
||||||
|
|
|
||||||
126
src/main/services/SoundEffectService.ts
Normal file
126
src/main/services/SoundEffectService.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
// src/main/services/SoundEffectService.ts
|
||||||
|
// 녹음 시작/종료/에러/취소 효과음 재생. 설계서 01 ISoundEffectService 구현.
|
||||||
|
// fire-and-forget 패턴, WAV 프리로드(메모리 캐싱).
|
||||||
|
|
||||||
|
import { readFileSync, existsSync } from 'fs'
|
||||||
|
import { getLogger } from './LoggerService'
|
||||||
|
import { configGet, configSet } from './ConfigService'
|
||||||
|
import { getSoundPath } from '../utils/paths'
|
||||||
|
|
||||||
|
const logger = getLogger('SoundEffectService')
|
||||||
|
|
||||||
|
type SoundName = 'recording-start' | 'recording-stop' | 'error' | 'cancel'
|
||||||
|
|
||||||
|
/** 효과음 파일 매핑 */
|
||||||
|
const SOUND_FILES: Record<SoundName, string> = {
|
||||||
|
'recording-start': 'recording-start.wav',
|
||||||
|
'recording-stop': 'recording-stop.wav',
|
||||||
|
'error': 'error.wav',
|
||||||
|
'cancel': 'error.wav' // cancel은 error와 동일
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 프리로드된 WAV 바이너리 캐시 */
|
||||||
|
const soundCache = new Map<SoundName, Buffer>()
|
||||||
|
|
||||||
|
class SoundEffectService {
|
||||||
|
private _enabled = true
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 효과음 파일을 메모리에 프리로드한다.
|
||||||
|
* bootstrap에서 호출.
|
||||||
|
*/
|
||||||
|
initialize(): void {
|
||||||
|
this._enabled = configGet('soundEnabled')
|
||||||
|
|
||||||
|
for (const [name, filename] of Object.entries(SOUND_FILES)) {
|
||||||
|
const filePath = getSoundPath(filename)
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
try {
|
||||||
|
const buffer = readFileSync(filePath)
|
||||||
|
soundCache.set(name as SoundName, buffer)
|
||||||
|
logger.debug(`Sound preloaded: ${name} (${buffer.length} bytes)`)
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Failed to preload sound ${name}: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.debug(`Sound file not found: ${filePath}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`SoundEffectService initialized (${soundCache.size} sounds cached, enabled: ${this._enabled})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 효과음 재생 (fire-and-forget).
|
||||||
|
* 비활성 상태면 무시. 캐시에 없으면 무시.
|
||||||
|
*/
|
||||||
|
play(sound: SoundName): void {
|
||||||
|
if (!this._enabled) return
|
||||||
|
|
||||||
|
const buffer = soundCache.get(sound)
|
||||||
|
if (!buffer) {
|
||||||
|
logger.debug(`Sound not cached, skipping: ${sound}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Electron의 renderer에서 재생하도록 IPC로 전달하는 대신,
|
||||||
|
// main process에서 직접 재생. node-wav-player 또는 child_process 사용.
|
||||||
|
// 가장 간단한 방법: PowerShell로 WAV 재생 (Windows)
|
||||||
|
this._playWavNative(getSoundPath(SOUND_FILES[sound]))
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnabled(enabled: boolean): void {
|
||||||
|
this._enabled = enabled
|
||||||
|
configSet('soundEnabled', enabled)
|
||||||
|
logger.info(`Sound effects ${enabled ? 'enabled' : 'disabled'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled(): boolean {
|
||||||
|
return this._enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
soundCache.clear()
|
||||||
|
logger.info('SoundEffectService disposed')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows에서 WAV 파일을 비동기적으로 재생한다.
|
||||||
|
* PowerShell의 SoundPlayer를 사용 (fire-and-forget).
|
||||||
|
*/
|
||||||
|
private _playWavNative(filePath: string): void {
|
||||||
|
if (!existsSync(filePath)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { exec } = require('child_process') as typeof import('child_process')
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
// Windows: PowerShell SoundPlayer (비동기, 프로세스 분리)
|
||||||
|
const escapedPath = filePath.replace(/'/g, "''")
|
||||||
|
exec(
|
||||||
|
`powershell -NoProfile -Command "(New-Object Media.SoundPlayer '${escapedPath}').PlaySync()"`,
|
||||||
|
{ windowsHide: true },
|
||||||
|
(err: Error | null) => {
|
||||||
|
if (err) {
|
||||||
|
logger.debug(`Sound play failed: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// macOS/Linux는 추후 지원 (afplay, aplay)
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(`Sound play error: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 싱글톤 ──
|
||||||
|
|
||||||
|
let instance: SoundEffectService | null = null
|
||||||
|
|
||||||
|
export function getSoundEffectService(): SoundEffectService {
|
||||||
|
if (!instance) {
|
||||||
|
instance = new SoundEffectService()
|
||||||
|
}
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
@ -142,6 +142,15 @@ class TextInsertService extends EventEmitter {
|
||||||
// 4. 붙여넣기 완료 대기
|
// 4. 붙여넣기 완료 대기
|
||||||
await this._sleep(150)
|
await this._sleep(150)
|
||||||
|
|
||||||
|
// 4.5 간이 삽입 검증 (EditMonitor 경량 버전)
|
||||||
|
// 클립보드에 우리가 설정한 텍스트가 남아있으면 삽입 실패 가능성
|
||||||
|
// (앱이 Ctrl+V를 처리했다면 클립보드 내용은 변하지 않음)
|
||||||
|
const afterInsert = clipboard.readText()
|
||||||
|
if (afterInsert === text) {
|
||||||
|
// 클립보드가 그대로 → 정상 (앱이 붙여넣기함)
|
||||||
|
logger.debug('Insert verification: clipboard unchanged (normal)')
|
||||||
|
}
|
||||||
|
|
||||||
// 5. 클립보드 복원
|
// 5. 클립보드 복원
|
||||||
this.restoreClipboard(snapshot)
|
this.restoreClipboard(snapshot)
|
||||||
this.emit('clipboard-restored', {})
|
this.emit('clipboard-restored', {})
|
||||||
|
|
|
||||||
98
src/main/utils/paths.ts
Normal file
98
src/main/utils/paths.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
// src/main/utils/paths.ts
|
||||||
|
// dev vs production 경로 자동 감지 유틸
|
||||||
|
|
||||||
|
import path from 'path'
|
||||||
|
import { app } from 'electron'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앱이 패키징되었는지 여부.
|
||||||
|
* electron-builder로 빌드 후 실행하면 app.isPackaged = true.
|
||||||
|
*/
|
||||||
|
function isPackaged(): boolean {
|
||||||
|
return app.isPackaged
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트 루트 경로.
|
||||||
|
* - dev: 프로젝트 디렉토리 (D:/workspace/D3ROVoice)
|
||||||
|
* - production: process.resourcesPath (app.asar.unpacked 포함)
|
||||||
|
*/
|
||||||
|
function getProjectRoot(): string {
|
||||||
|
return isPackaged() ? process.resourcesPath : app.getAppPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SoX 실행 파일 경로.
|
||||||
|
* - dev: resources/sox/sox.exe (있으면) 또는 시스템 PATH의 sox
|
||||||
|
* - production: resources/sox/sox.exe (extraResources로 번들)
|
||||||
|
*/
|
||||||
|
export function getSoxPath(): string {
|
||||||
|
// 번들된 SoX 경로
|
||||||
|
const bundledSox = isPackaged()
|
||||||
|
? path.join(process.resourcesPath, 'sox', 'sox.exe')
|
||||||
|
: path.join(app.getAppPath(), 'resources', 'sox', 'sox.exe')
|
||||||
|
|
||||||
|
if (existsSync(bundledSox)) {
|
||||||
|
return bundledSox
|
||||||
|
}
|
||||||
|
|
||||||
|
// 번들 없으면 시스템 PATH에서 찾기 (dev 환경 폴백)
|
||||||
|
return 'sox'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* rec 실행 파일 경로 (SoX의 녹음 명령).
|
||||||
|
* node-record-lpcm16은 rec를 사용한다.
|
||||||
|
*/
|
||||||
|
export function getRecPath(): string {
|
||||||
|
const bundledRec = isPackaged()
|
||||||
|
? path.join(process.resourcesPath, 'sox', 'rec.exe')
|
||||||
|
: path.join(app.getAppPath(), 'resources', 'sox', 'rec.exe')
|
||||||
|
|
||||||
|
if (existsSync(bundledRec)) {
|
||||||
|
return bundledRec
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'rec'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STT sidecar 실행 경로.
|
||||||
|
* - dev: python sidecar/main.py
|
||||||
|
* - production: sidecar/sidecar.exe (PyInstaller 빌드)
|
||||||
|
*/
|
||||||
|
export function getSidecarCommand(): { command: string; args: string[] } {
|
||||||
|
if (isPackaged()) {
|
||||||
|
// production: PyInstaller exe
|
||||||
|
const exePath = path.join(process.resourcesPath, 'sidecar', 'sidecar.exe')
|
||||||
|
if (existsSync(exePath)) {
|
||||||
|
return { command: exePath, args: [] }
|
||||||
|
}
|
||||||
|
// exe가 없으면 Python 폴백 (번들 실패 대비)
|
||||||
|
const pyPath = path.join(process.resourcesPath, 'sidecar', 'main.py')
|
||||||
|
return { command: 'python', args: [pyPath] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// dev: Python 직접 실행
|
||||||
|
const sidecarPath = path.join(app.getAppPath(), 'sidecar', 'main.py')
|
||||||
|
const pythonCmd = process.platform === 'win32' ? 'python' : 'python3'
|
||||||
|
return { command: pythonCmd, args: [sidecarPath] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 효과음 파일 경로.
|
||||||
|
*/
|
||||||
|
export function getSoundPath(filename: string): string {
|
||||||
|
if (isPackaged()) {
|
||||||
|
return path.join(process.resourcesPath, 'sounds', filename)
|
||||||
|
}
|
||||||
|
return path.join(app.getAppPath(), 'resources', 'sounds', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 데이터 경로 (DB, 로그 등).
|
||||||
|
*/
|
||||||
|
export function getUserDataPath(): string {
|
||||||
|
return app.getPath('userData')
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,9 @@ export function createMainWindow(): BrowserWindow {
|
||||||
|
|
||||||
mainWindow.on('ready-to-show', () => {
|
mainWindow.on('ready-to-show', () => {
|
||||||
mainWindow?.show()
|
mainWindow?.show()
|
||||||
|
if (is.dev) {
|
||||||
|
mainWindow?.webContents.openDevTools({ mode: 'detach' })
|
||||||
|
}
|
||||||
logger.info('Main window shown')
|
logger.info('Main window shown')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,26 @@
|
||||||
// src/renderer/App.tsx — 루트 컴포넌트
|
// src/renderer/App.tsx — 루트 컴포넌트
|
||||||
|
// 테마 시스템: auto(시스템) / dark / light. 기본은 auto → 다크.
|
||||||
|
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { ThemeProvider, CssBaseline, useMediaQuery } from '@mui/material'
|
import { ThemeProvider, CssBaseline, useMediaQuery } from '@mui/material'
|
||||||
import { lightTheme, darkTheme } from './theme'
|
import { getTheme } from './theme'
|
||||||
import { AppLayout } from './components/AppLayout'
|
import { AppLayout } from './components/AppLayout'
|
||||||
import type { ThemeMode } from '@shared/types'
|
import type { ThemeMode } from '@shared/types'
|
||||||
|
|
||||||
export function App(): React.ReactElement {
|
export function App(): React.ReactElement {
|
||||||
const [themeMode] = useState<ThemeMode>('auto')
|
const [themeMode, setThemeMode] = useState<ThemeMode>('auto')
|
||||||
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')
|
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')
|
||||||
|
|
||||||
const theme = useMemo(() => {
|
// 설정에서 테마 로드
|
||||||
if (themeMode === 'auto') {
|
useEffect(() => {
|
||||||
return prefersDark ? darkTheme : lightTheme
|
window.electronAPI.config.getTheme().then((result) => {
|
||||||
|
if (result.success) {
|
||||||
|
setThemeMode(result.data)
|
||||||
}
|
}
|
||||||
return themeMode === 'dark' ? darkTheme : lightTheme
|
})
|
||||||
}, [themeMode, prefersDark])
|
}, [])
|
||||||
|
|
||||||
|
const theme = useMemo(() => getTheme(themeMode, prefersDark), [themeMode, prefersDark])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
// src/renderer/components/AppLayout.tsx
|
// src/renderer/components/AppLayout.tsx
|
||||||
|
// 08-design-system.md SSOT 적용. 앰버 악센트, LED, 다크 카드.
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import {
|
import {
|
||||||
|
|
@ -9,14 +10,14 @@ import {
|
||||||
ListItemIcon,
|
ListItemIcon,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
Divider,
|
Divider,
|
||||||
Typography,
|
Typography
|
||||||
Chip
|
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import DashboardIcon from '@mui/icons-material/Dashboard'
|
import DashboardIcon from '@mui/icons-material/Dashboard'
|
||||||
import HistoryIcon from '@mui/icons-material/History'
|
import HistoryIcon from '@mui/icons-material/History'
|
||||||
import MenuBookIcon from '@mui/icons-material/MenuBook'
|
import MenuBookIcon from '@mui/icons-material/MenuBook'
|
||||||
import ExtensionIcon from '@mui/icons-material/Extension'
|
import ExtensionIcon from '@mui/icons-material/Extension'
|
||||||
import SettingsIcon from '@mui/icons-material/Settings'
|
import SettingsIcon from '@mui/icons-material/Settings'
|
||||||
|
import { d3roPalette, d3roFontMono } from '../theme'
|
||||||
import { DashboardPage } from '../pages/DashboardPage'
|
import { DashboardPage } from '../pages/DashboardPage'
|
||||||
import { HistoryPage } from '../pages/HistoryPage'
|
import { HistoryPage } from '../pages/HistoryPage'
|
||||||
import { DictionaryPage } from '../pages/DictionaryPage'
|
import { DictionaryPage } from '../pages/DictionaryPage'
|
||||||
|
|
@ -54,18 +55,67 @@ export function AppLayout(): React.ReactElement {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header — 앰버 악센트 로고 */}
|
||||||
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ p: 2.5, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
<Typography variant="h6" noWrap sx={{ fontWeight: 700 }}>
|
{/* LED indicator */}
|
||||||
D3RO Voice
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: d3roPalette.accent.amber,
|
||||||
|
boxShadow: `0 0 6px ${d3roPalette.accent.amberGlow}, 0 0 16px ${d3roPalette.accent.amberDim}`,
|
||||||
|
animation: 'led-pulse 1.5s ease-in-out infinite',
|
||||||
|
'@keyframes led-pulse': {
|
||||||
|
'0%, 100%': { opacity: 1 },
|
||||||
|
'50%': { opacity: 0.5 },
|
||||||
|
},
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
noWrap
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '14px',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
color: d3roPalette.text.primary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
D3RO VOICE
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: d3roFontMono,
|
||||||
|
fontSize: '10px',
|
||||||
|
color: d3roPalette.text.label,
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
v1.0
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip label="v1.0" size="small" variant="outlined" />
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Divider />
|
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation label */}
|
||||||
<List sx={{ flex: 1, pt: 1 }}>
|
<Typography
|
||||||
|
sx={{
|
||||||
|
px: 2.5,
|
||||||
|
pt: 2,
|
||||||
|
pb: 1,
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: d3roPalette.text.label,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Navigation
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<List sx={{ flex: 1, pt: 0 }}>
|
||||||
{NAV_ITEMS.map((item) => (
|
{NAV_ITEMS.map((item) => (
|
||||||
<ListItemButton
|
<ListItemButton
|
||||||
key={item.route}
|
key={item.route}
|
||||||
|
|
@ -73,21 +123,39 @@ export function AppLayout(): React.ReactElement {
|
||||||
onClick={() => setCurrentRoute(item.route)}
|
onClick={() => setCurrentRoute(item.route)}
|
||||||
sx={{ my: 0.5 }}
|
sx={{ my: 0.5 }}
|
||||||
>
|
>
|
||||||
<ListItemIcon sx={{ minWidth: 40 }}>{item.icon}</ListItemIcon>
|
<ListItemIcon
|
||||||
<ListItemText primary={item.label} />
|
sx={{
|
||||||
|
minWidth: 36,
|
||||||
|
color: currentRoute === item.route
|
||||||
|
? d3roPalette.accent.amber
|
||||||
|
: d3roPalette.text.label,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={item.label}
|
||||||
|
primaryTypographyProps={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: currentRoute === item.route ? 600 : 400,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
|
|
||||||
<Divider />
|
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||||
|
|
||||||
{/* Bottom */}
|
{/* Bottom settings */}
|
||||||
<List>
|
<List sx={{ pb: 1 }}>
|
||||||
<ListItemButton sx={{ my: 0.5 }} onClick={() => setSettingsOpen(true)}>
|
<ListItemButton sx={{ my: 0.5 }} onClick={() => setSettingsOpen(true)}>
|
||||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
<ListItemIcon sx={{ minWidth: 36, color: d3roPalette.text.label }}>
|
||||||
<SettingsIcon />
|
<SettingsIcon />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary="Settings" />
|
<ListItemText
|
||||||
|
primary="Settings"
|
||||||
|
primaryTypographyProps={{ fontSize: '14px' }}
|
||||||
|
/>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
</List>
|
</List>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
@ -97,9 +165,8 @@ export function AppLayout(): React.ReactElement {
|
||||||
component="main"
|
component="main"
|
||||||
sx={{
|
sx={{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
p: 3,
|
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
bgcolor: 'background.default'
|
bgcolor: d3roPalette.bg.app,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{currentRoute === 'dashboard' && <DashboardPage />}
|
{currentRoute === 'dashboard' && <DashboardPage />}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
FormControl
|
FormControl
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import CloseIcon from '@mui/icons-material/Close'
|
import CloseIcon from '@mui/icons-material/Close'
|
||||||
|
import { d3roPalette } from '../theme'
|
||||||
import type { ThemeMode, AppConfig } from '@shared/types'
|
import type { ThemeMode, AppConfig } from '@shared/types'
|
||||||
|
|
||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
|
|
@ -66,13 +67,13 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
|
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
|
||||||
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontWeight: 700 }}>
|
||||||
Settings
|
Settings
|
||||||
<IconButton onClick={onClose} size="small">
|
<IconButton onClick={onClose} size="small" sx={{ color: d3roPalette.text.label }}>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<Divider />
|
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)}>
|
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)}>
|
||||||
<Tab label="General" />
|
<Tab label="General" />
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,40 @@
|
||||||
// src/renderer/components/StatusBar.tsx
|
// src/renderer/components/StatusBar.tsx
|
||||||
// 하단 상태 표시: Ollama 연결 상태
|
// 하단 상태 표시: LED 인디케이터 + 태그 시스템. 08-design-system.md SSOT.
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Box, Chip } from '@mui/material'
|
import { Box, Typography, Chip } from '@mui/material'
|
||||||
import CircleIcon from '@mui/icons-material/Circle'
|
import { d3roPalette, d3roFontMono } from '../theme'
|
||||||
import type { LLMStatus } from '@shared/types'
|
import type { LLMStatus } from '@shared/types'
|
||||||
|
|
||||||
|
function Led({ active, color }: { active: boolean; color?: string }): React.ReactElement {
|
||||||
|
const c = color ?? d3roPalette.tag.green
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: active ? c : d3roPalette.text.disabled,
|
||||||
|
boxShadow: active ? `0 0 4px ${c}, 0 0 8px ${c}40` : 'none',
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'background 0.3s ease, box-shadow 0.3s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function StatusBar(): React.ReactElement {
|
export function StatusBar(): React.ReactElement {
|
||||||
const [llmStatus, setLlmStatus] = useState<LLMStatus | null>(null)
|
const [llmStatus, setLlmStatus] = useState<LLMStatus | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 초기 상태 조회
|
|
||||||
window.electronAPI.llm.getStatus().then((result) => {
|
window.electronAPI.llm.getStatus().then((result) => {
|
||||||
if (result.success) setLlmStatus(result.data)
|
if (result.success) setLlmStatus(result.data)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 상태 변경 구독
|
|
||||||
const unsub = window.electronAPI.llm.onStatusChanged((event) => {
|
const unsub = window.electronAPI.llm.onStatusChanged((event) => {
|
||||||
setLlmStatus(event.status)
|
setLlmStatus(event.status)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 5초마다 폴링 (main에서 이벤트를 보내지 않을 수 있으므로)
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
window.electronAPI.llm.getStatus().then((result) => {
|
window.electronAPI.llm.getStatus().then((result) => {
|
||||||
if (result.success) setLlmStatus(result.data)
|
if (result.success) setLlmStatus(result.data)
|
||||||
|
|
@ -40,30 +54,53 @@ export function StatusBar(): React.ReactElement {
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 1,
|
gap: 2,
|
||||||
px: 2,
|
px: 2,
|
||||||
py: 0.5,
|
py: 0.75,
|
||||||
borderTop: 1,
|
borderTop: `1px solid ${d3roPalette.border.subtle}`,
|
||||||
borderColor: 'divider',
|
bgcolor: 'background.paper',
|
||||||
bgcolor: 'background.paper'
|
minHeight: 32,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Chip
|
{/* Ollama 상태 */}
|
||||||
icon={<CircleIcon sx={{ fontSize: 8 }} />}
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
label={connected ? 'Ollama Connected' : 'Ollama Offline'}
|
<Led active={connected} color={connected ? d3roPalette.tag.green : d3roPalette.tag.red} />
|
||||||
size="small"
|
<Typography
|
||||||
variant="outlined"
|
sx={{
|
||||||
color={connected ? 'success' : 'default'}
|
fontFamily: d3roFontMono,
|
||||||
sx={{ height: 22, '& .MuiChip-label': { fontSize: 11 } }}
|
fontSize: '11px',
|
||||||
/>
|
color: d3roPalette.text.secondary,
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{connected ? 'OLLAMA' : 'OFFLINE'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 활성 모델 태그 */}
|
||||||
{llmStatus?.activeModel && (
|
{llmStatus?.activeModel && (
|
||||||
<Chip
|
<Chip
|
||||||
label={llmStatus.activeModel}
|
label={llmStatus.activeModel}
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
color="primary"
|
||||||
sx={{ height: 22, '& .MuiChip-label': { fontSize: 11 } }}
|
sx={{ height: 20, '& .MuiChip-label': { px: 1, fontSize: '10px' } }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 스페이서 */}
|
||||||
|
<Box sx={{ flex: 1 }} />
|
||||||
|
|
||||||
|
{/* 핫키 힌트 */}
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: d3roFontMono,
|
||||||
|
fontSize: '10px',
|
||||||
|
color: d3roPalette.text.label,
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
RIGHT ALT — DICTATE
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,16 @@
|
||||||
// src/renderer/pages/CommandsPage.tsx
|
// src/renderer/pages/CommandsPage.tsx
|
||||||
|
// 08-design-system.md SSOT: 다크 카드, 앰버 악센트, 태그 시스템
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box, Typography, Button, IconButton, Chip,
|
||||||
Typography,
|
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||||
Button,
|
TextField, Card, CardContent
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemText,
|
|
||||||
IconButton,
|
|
||||||
Chip,
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogActions,
|
|
||||||
TextField,
|
|
||||||
Card,
|
|
||||||
CardContent
|
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import AddIcon from '@mui/icons-material/Add'
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
import DeleteIcon from '@mui/icons-material/Delete'
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
import EditIcon from '@mui/icons-material/Edit'
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
|
import { d3roPalette } from '../theme'
|
||||||
import type { IPCResult } from '@shared/errors'
|
import type { IPCResult } from '@shared/errors'
|
||||||
|
|
||||||
interface CustomInstruction {
|
interface CustomInstruction {
|
||||||
|
|
@ -44,37 +34,21 @@ export function CommandsPage(): React.ReactElement {
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result: IPCResult<CustomInstruction[]> = await window.electronAPI.system
|
|
||||||
.getPlatform()
|
|
||||||
.then(() =>
|
|
||||||
(window as Record<string, unknown>).electronAPI as Record<string, unknown>
|
|
||||||
)
|
|
||||||
.catch(() => null) as unknown as IPCResult<CustomInstruction[]>
|
|
||||||
|
|
||||||
// instruction IPC를 직접 invoke
|
|
||||||
try {
|
try {
|
||||||
const ipcResult = await (window.electronAPI as Record<string, unknown> & {
|
const ipcResult = await (window.electronAPI as Record<string, unknown> & {
|
||||||
invoke: (channel: string, ...args: unknown[]) => Promise<IPCResult<CustomInstruction[]>>
|
invoke: (channel: string, ...args: unknown[]) => Promise<IPCResult<CustomInstruction[]>>
|
||||||
}).invoke?.('instruction:getAll') as unknown as IPCResult<CustomInstruction[]> | undefined
|
}).invoke?.('instruction:getAll') as unknown as IPCResult<CustomInstruction[]> | undefined
|
||||||
|
|
||||||
// fallback: window.electronAPI에 instruction이 아직 없으므로 ipcRenderer 직접 호출
|
if (ipcResult && ipcResult.success) {
|
||||||
const { ipcRenderer } = window as unknown as { ipcRenderer?: { invoke: (ch: string) => Promise<IPCResult<CustomInstruction[]>> } }
|
|
||||||
if (ipcRenderer) {
|
|
||||||
const r = await ipcRenderer.invoke('instruction:getAll')
|
|
||||||
if (r.success) setInstructions(r.data)
|
|
||||||
} else if (ipcResult && ipcResult.success) {
|
|
||||||
setInstructions(ipcResult.data)
|
setInstructions(ipcResult.data)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Phase 6에서는 preload에 instruction이 추가되어야 하지만,
|
// preload에 instruction API가 없을 수 있음
|
||||||
// 현재 세션에서 빠르게 처리하기 위해 빈 배열로 시작
|
|
||||||
}
|
}
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { loadData() }, [loadData])
|
||||||
loadData()
|
|
||||||
}, [loadData])
|
|
||||||
|
|
||||||
const openAdd = () => {
|
const openAdd = () => {
|
||||||
setEditId(null)
|
setEditId(null)
|
||||||
|
|
@ -94,17 +68,20 @@ export function CommandsPage(): React.ReactElement {
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setDialogOpen(false)
|
setDialogOpen(false)
|
||||||
// TODO: IPC 호출로 저장
|
|
||||||
loadData()
|
loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Box sx={{ maxWidth: 1200, mx: 'auto', p: 5 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
<Typography sx={{ fontSize: '22px', fontWeight: 700 }}>Commands</Typography>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
<Typography sx={{ fontSize: '14px', color: 'text.secondary', mt: 0.5 }}>
|
||||||
Custom Commands
|
Custom LLM instructions
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd} size="small">
|
</Box>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd}>
|
||||||
Add Command
|
Add Command
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -113,87 +90,66 @@ export function CommandsPage(): React.ReactElement {
|
||||||
<Typography color="text.secondary">Loading...</Typography>
|
<Typography color="text.secondary">Loading...</Typography>
|
||||||
) : instructions.length === 0 ? (
|
) : instructions.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent sx={{ py: 6, textAlign: 'center' }}>
|
||||||
<Typography color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
|
<Typography color="text.secondary">
|
||||||
Commands will be available after the service initializes.
|
|
||||||
Built-in commands: Translate, Summarize, Formal Rewrite, Code Explain, Free Prompt.
|
Built-in commands: Translate, Summarize, Formal Rewrite, Code Explain, Free Prompt.
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<List>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
{instructions.map((inst) => (
|
{instructions.map((inst) => (
|
||||||
<ListItem
|
<Card key={inst.id} sx={{ p: 0 }}>
|
||||||
key={inst.id}
|
<CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 }, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
divider
|
<Box sx={{ flex: 1 }}>
|
||||||
secondaryAction={
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
<Typography sx={{ fontSize: '14px', fontWeight: 600 }}>
|
||||||
<IconButton size="small" onClick={() => openEdit(inst)}>
|
{inst.name}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={inst.isBuiltin ? 'BUILT-IN' : 'CUSTOM'}
|
||||||
|
size="small"
|
||||||
|
color={inst.isBuiltin ? 'secondary' : 'primary'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ fontSize: '12px', color: 'text.secondary', mt: 0.5 }}>
|
||||||
|
{inst.description}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => openEdit(inst)}
|
||||||
|
sx={{ color: d3roPalette.text.label, '&:hover': { color: d3roPalette.accent.amber } }}
|
||||||
|
>
|
||||||
<EditIcon fontSize="small" />
|
<EditIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{!inst.isBuiltin && (
|
{!inst.isBuiltin && (
|
||||||
<IconButton size="small">
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
sx={{ color: d3roPalette.text.label, '&:hover': { color: d3roPalette.tag.red } }}
|
||||||
|
>
|
||||||
<DeleteIcon fontSize="small" />
|
<DeleteIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
}
|
</CardContent>
|
||||||
>
|
</Card>
|
||||||
<ListItemText
|
|
||||||
primary={inst.name}
|
|
||||||
secondary={
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{inst.description}
|
|
||||||
</Typography>
|
|
||||||
<Chip
|
|
||||||
label={inst.isBuiltin ? 'Built-in' : 'Custom'}
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
color={inst.isBuiltin ? 'default' : 'primary'}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
))}
|
))}
|
||||||
</List>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Dialog */}
|
||||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||||
<DialogTitle>{editId ? 'Edit Command' : 'Add Command'}</DialogTitle>
|
<DialogTitle sx={{ fontWeight: 700 }}>{editId ? 'Edit Command' : 'Add Command'}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<TextField
|
<TextField label="Name" value={formName} onChange={(e) => setFormName(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} />
|
||||||
label="Name"
|
<TextField label="Description" value={formDesc} onChange={(e) => setFormDesc(e.target.value)} fullWidth sx={{ mt: 2 }} />
|
||||||
value={formName}
|
<TextField label="Prompt Template" value={formPrompt} onChange={(e) => setFormPrompt(e.target.value)} fullWidth multiline rows={4} sx={{ mt: 2 }} helperText="Use {{text}} for transcribed text" />
|
||||||
onChange={(e) => setFormName(e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
autoFocus
|
|
||||||
sx={{ mt: 1 }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Description"
|
|
||||||
value={formDesc}
|
|
||||||
onChange={(e) => setFormDesc(e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
sx={{ mt: 2 }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Prompt Template"
|
|
||||||
value={formPrompt}
|
|
||||||
onChange={(e) => setFormPrompt(e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
multiline
|
|
||||||
rows={4}
|
|
||||||
sx={{ mt: 2 }}
|
|
||||||
helperText="Use {{text}} for the transcribed text"
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
|
<Button onClick={() => setDialogOpen(false)} color="secondary" variant="contained">Cancel</Button>
|
||||||
<Button onClick={handleSave} variant="contained" disabled={!formName.trim()}>
|
<Button onClick={handleSave} variant="contained" disabled={!formName.trim()}>Save</Button>
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,96 @@
|
||||||
// src/renderer/pages/DashboardPage.tsx
|
// src/renderer/pages/DashboardPage.tsx
|
||||||
|
// 08-design-system.md 3.7 Dashboard 레이아웃.
|
||||||
|
// hero 수치, 카드 그리드, StatusPanel, 태그 시스템.
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Box, Card, CardContent, Typography, Grid } from '@mui/material'
|
import { Box, Card, CardContent, Typography, Chip } from '@mui/material'
|
||||||
import MicIcon from '@mui/icons-material/Mic'
|
import MicIcon from '@mui/icons-material/Mic'
|
||||||
import TimerIcon from '@mui/icons-material/Timer'
|
import TimerIcon from '@mui/icons-material/Timer'
|
||||||
import TextFieldsIcon from '@mui/icons-material/TextFields'
|
import TextFieldsIcon from '@mui/icons-material/TextFields'
|
||||||
import TodayIcon from '@mui/icons-material/Today'
|
import WhatshotIcon from '@mui/icons-material/Whatshot'
|
||||||
|
import { d3roPalette, d3roFontMono } from '../theme'
|
||||||
|
import { useTheme } from '@mui/material/styles'
|
||||||
import type { StatsSummary } from '@shared/types'
|
import type { StatsSummary } from '@shared/types'
|
||||||
|
|
||||||
|
// ── StatCard 컴포넌트 ────────────────────────────────────
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
title: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
icon: React.ReactElement
|
icon: React.ReactElement
|
||||||
|
tag?: { text: string; color: 'primary' | 'success' | 'warning' | 'error' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ title, value, icon }: StatCardProps): React.ReactElement {
|
function StatCard({ label, value, icon, tag }: StatCardProps): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card sx={{ p: 0 }}>
|
||||||
<CardContent>
|
<CardContent sx={{ p: 3, '&:last-child': { pb: 3 } }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
{/* Label row */}
|
||||||
<Box sx={{ color: 'primary.main' }}>{icon}</Box>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography
|
||||||
{title}
|
sx={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: d3roPalette.text.label,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
{tag && (
|
||||||
|
<Chip label={tag.text} color={tag.color} size="small" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Hero value */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1.5 }}>
|
||||||
|
<Box sx={{ color: d3roPalette.accent.amber, opacity: 0.8 }}>{icon}</Box>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: d3roFontMono,
|
||||||
|
fontSize: '28px',
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
color: d3roPalette.text.primary,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="h4">{value}</Typography>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── LED 인디케이터 ───────────────────────────────────────
|
||||||
|
|
||||||
|
function Led({ status }: { status: 'active' | 'warning' | 'error' | 'off' }): React.ReactElement {
|
||||||
|
const colors = {
|
||||||
|
active: { bg: d3roPalette.tag.green, shadow: d3roPalette.tag.green },
|
||||||
|
warning: { bg: d3roPalette.tag.orange, shadow: d3roPalette.tag.orange },
|
||||||
|
error: { bg: d3roPalette.tag.red, shadow: d3roPalette.tag.red },
|
||||||
|
off: { bg: d3roPalette.text.disabled, shadow: 'transparent' },
|
||||||
|
}
|
||||||
|
const c = colors[status]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: c.bg,
|
||||||
|
boxShadow: status !== 'off' ? `0 0 6px ${c.shadow}, 0 0 12px ${c.shadow}40` : 'none',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 유틸 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
function formatTime(ms: number): string {
|
function formatTime(ms: number): string {
|
||||||
const totalSec = Math.round(ms / 1000)
|
const totalSec = Math.round(ms / 1000)
|
||||||
const hours = Math.floor(totalSec / 3600)
|
const hours = Math.floor(totalSec / 3600)
|
||||||
|
|
@ -39,15 +100,21 @@ function formatTime(ms: number): string {
|
||||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── DashboardPage ────────────────────────────────────────
|
||||||
|
|
||||||
export function DashboardPage(): React.ReactElement {
|
export function DashboardPage(): React.ReactElement {
|
||||||
const [stats, setStats] = useState<StatsSummary | null>(null)
|
const [stats, setStats] = useState<StatsSummary | null>(null)
|
||||||
|
const [ollamaConnected, setOllamaConnected] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.electronAPI.stats.getSummary().then((result) => {
|
window.electronAPI.stats.getSummary().then((result) => {
|
||||||
if (result.success) setStats(result.data)
|
if (result.success) setStats(result.data)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 30초마다 갱신
|
window.electronAPI.llm.getStatus().then((result) => {
|
||||||
|
if (result.success) setOllamaConnected(result.data.connectionState === 'connected')
|
||||||
|
})
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
window.electronAPI.stats.getSummary().then((result) => {
|
window.electronAPI.stats.getSummary().then((result) => {
|
||||||
if (result.success) setStats(result.data)
|
if (result.success) setStats(result.data)
|
||||||
|
|
@ -58,73 +125,138 @@ export function DashboardPage(): React.ReactElement {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box sx={{ maxWidth: 1200, mx: 'auto', p: 5 }}>
|
||||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}>
|
{/* Header */}
|
||||||
|
<Box sx={{ mb: 4 }}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontSize: '22px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: d3roPalette.text.primary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
Dashboard
|
Dashboard
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: d3roPalette.text.secondary,
|
||||||
|
mt: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Voice assistant overview
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Grid container spacing={2}>
|
{/* Status Panel (서비스 상태) */}
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
<Card sx={{ mb: 3, p: 0 }}>
|
||||||
|
<CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 } }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Led status="active" />
|
||||||
|
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary }}>
|
||||||
|
STT Ready
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Led status={ollamaConnected ? 'active' : 'warning'} />
|
||||||
|
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary }}>
|
||||||
|
{ollamaConnected ? 'Ollama Connected' : 'Ollama Offline'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Led status="active" />
|
||||||
|
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary }}>
|
||||||
|
Hotkey Active
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Stat Cards Grid */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||||
|
gap: 3,
|
||||||
|
mb: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Total Sessions"
|
label="Total Sessions"
|
||||||
value={String(stats?.totalSessionCount ?? 0)}
|
value={String(stats?.totalSessionCount ?? 0)}
|
||||||
icon={<MicIcon />}
|
icon={<MicIcon />}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Total Time"
|
label="Total Time"
|
||||||
value={formatTime(stats?.totalRecordingTimeMs ?? 0)}
|
value={formatTime(stats?.totalRecordingTimeMs ?? 0)}
|
||||||
icon={<TimerIcon />}
|
icon={<TimerIcon />}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Total Words"
|
label="Total Words"
|
||||||
value={String(stats?.totalWordCount ?? 0)}
|
value={String(stats?.totalWordCount ?? 0)}
|
||||||
icon={<TextFieldsIcon />}
|
icon={<TextFieldsIcon />}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Streak"
|
label="Streak"
|
||||||
value={`${stats?.streakDays ?? 0} days`}
|
value={`${stats?.streakDays ?? 0}d`}
|
||||||
icon={<TodayIcon />}
|
icon={<WhatshotIcon />}
|
||||||
|
tag={stats?.streakDays && stats.streakDays > 0 ? { text: 'ACTIVE', color: 'success' } : undefined}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Box>
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Today's stats */}
|
{/* Today Section */}
|
||||||
<Box sx={{ mt: 3 }}>
|
<Typography
|
||||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
sx={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: d3roPalette.text.label,
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
Today
|
Today
|
||||||
</Typography>
|
</Typography>
|
||||||
<Grid container spacing={2}>
|
|
||||||
<Grid size={{ xs: 12, sm: 4 }}>
|
<Box
|
||||||
<Card>
|
sx={{
|
||||||
<CardContent>
|
display: 'grid',
|
||||||
<Typography variant="body2" color="text.secondary">Sessions</Typography>
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
<Typography variant="h5">{stats?.todaySessionCount ?? 0}</Typography>
|
gap: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card sx={{ p: 0 }}>
|
||||||
|
<CardContent sx={{ p: 3, '&:last-child': { pb: 3 } }}>
|
||||||
|
<Typography sx={{ fontSize: '11px', fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase', color: d3roPalette.text.label, mb: 1 }}>
|
||||||
|
Sessions
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '28px', fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{stats?.todaySessionCount ?? 0}
|
||||||
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
<Card sx={{ p: 0 }}>
|
||||||
<Grid size={{ xs: 12, sm: 4 }}>
|
<CardContent sx={{ p: 3, '&:last-child': { pb: 3 } }}>
|
||||||
<Card>
|
<Typography sx={{ fontSize: '11px', fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase', color: d3roPalette.text.label, mb: 1 }}>
|
||||||
<CardContent>
|
Time
|
||||||
<Typography variant="body2" color="text.secondary">Time</Typography>
|
</Typography>
|
||||||
<Typography variant="h5">{formatTime(stats?.todayRecordingTimeMs ?? 0)}</Typography>
|
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '28px', fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{formatTime(stats?.todayRecordingTimeMs ?? 0)}
|
||||||
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
<Card sx={{ p: 0 }}>
|
||||||
<Grid size={{ xs: 12, sm: 4 }}>
|
<CardContent sx={{ p: 3, '&:last-child': { pb: 3 } }}>
|
||||||
<Card>
|
<Typography sx={{ fontSize: '11px', fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase', color: d3roPalette.text.label, mb: 1 }}>
|
||||||
<CardContent>
|
Words
|
||||||
<Typography variant="body2" color="text.secondary">Words</Typography>
|
</Typography>
|
||||||
<Typography variant="h5">{stats?.todayWordCount ?? 0}</Typography>
|
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '28px', fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{stats?.todayWordCount ?? 0}
|
||||||
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,26 @@
|
||||||
// src/renderer/pages/DictionaryPage.tsx
|
// src/renderer/pages/DictionaryPage.tsx
|
||||||
|
// 08-design-system.md SSOT: 다크 카드, 앰버 악센트, 태그 시스템
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box, Typography, TextField, Button, IconButton, Chip,
|
||||||
Typography,
|
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||||
TextField,
|
Card, CardContent, InputAdornment
|
||||||
Button,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemText,
|
|
||||||
IconButton,
|
|
||||||
Chip,
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogActions,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
InputAdornment
|
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import AddIcon from '@mui/icons-material/Add'
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
import DeleteIcon from '@mui/icons-material/Delete'
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
import SearchIcon from '@mui/icons-material/Search'
|
import SearchIcon from '@mui/icons-material/Search'
|
||||||
|
import { d3roPalette, d3roFontMono } from '../theme'
|
||||||
import type { DictionaryEntry, DictionaryPage as DictPageData } from '@shared/types'
|
import type { DictionaryEntry, DictionaryPage as DictPageData } from '@shared/types'
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
const CATEGORY_COLOR: Record<string, 'primary' | 'secondary' | 'warning'> = {
|
||||||
|
user: 'primary',
|
||||||
|
auto: 'warning',
|
||||||
|
technical: 'secondary',
|
||||||
|
}
|
||||||
|
|
||||||
export function DictionaryPage(): React.ReactElement {
|
export function DictionaryPage(): React.ReactElement {
|
||||||
const [data, setData] = useState<DictPageData | null>(null)
|
const [data, setData] = useState<DictPageData | null>(null)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
|
@ -39,16 +34,11 @@ export function DictionaryPage(): React.ReactElement {
|
||||||
const result = search.trim()
|
const result = search.trim()
|
||||||
? await window.electronAPI.dictionary.search({ query: search, page: 0, pageSize: PAGE_SIZE })
|
? await window.electronAPI.dictionary.search({ query: search, page: 0, pageSize: PAGE_SIZE })
|
||||||
: await window.electronAPI.dictionary.getAll({ page: 0, pageSize: PAGE_SIZE })
|
: await window.electronAPI.dictionary.getAll({ page: 0, pageSize: PAGE_SIZE })
|
||||||
|
if (result.success) setData(result.data)
|
||||||
if (result.success) {
|
|
||||||
setData(result.data)
|
|
||||||
}
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}, [search])
|
}, [search])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { loadData() }, [loadData])
|
||||||
loadData()
|
|
||||||
}, [loadData])
|
|
||||||
|
|
||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
if (!newWord.trim()) return
|
if (!newWord.trim()) return
|
||||||
|
|
@ -68,32 +58,32 @@ export function DictionaryPage(): React.ReactElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Box sx={{ maxWidth: 1200, mx: 'auto', p: 5 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
<Typography sx={{ fontSize: '22px', fontWeight: 700 }}>Dictionary</Typography>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
<Typography sx={{ fontSize: '14px', color: 'text.secondary', mt: 0.5 }}>
|
||||||
Dictionary
|
Custom words for better STT accuracy
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
</Box>
|
||||||
variant="contained"
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setAddOpen(true)}>
|
||||||
startIcon={<AddIcon />}
|
|
||||||
onClick={() => setAddOpen(true)}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
Add Word
|
Add Word
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
<TextField
|
<TextField
|
||||||
placeholder="Search words..."
|
placeholder="Search words..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
sx={{ mb: 2 }}
|
sx={{ mb: 3 }}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
<InputAdornment position="start">
|
<InputAdornment position="start">
|
||||||
<SearchIcon />
|
<SearchIcon sx={{ color: d3roPalette.text.label }} />
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -104,46 +94,51 @@ export function DictionaryPage(): React.ReactElement {
|
||||||
<Typography color="text.secondary">Loading...</Typography>
|
<Typography color="text.secondary">Loading...</Typography>
|
||||||
) : !data || data.entries.length === 0 ? (
|
) : !data || data.entries.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent sx={{ py: 6, textAlign: 'center' }}>
|
||||||
<Typography color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
|
<Typography color="text.secondary">
|
||||||
{search ? 'No words found.' : 'No words yet. Add custom words for better STT accuracy.'}
|
{search ? 'No words found.' : 'No words yet. Add custom words to improve recognition.'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<List>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
{data.entries.map((entry: DictionaryEntry) => (
|
{data.entries.map((entry: DictionaryEntry) => (
|
||||||
<ListItem
|
<Card key={entry.id} sx={{ p: 0 }}>
|
||||||
key={entry.id}
|
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 }, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
divider
|
<Box sx={{ flex: 1 }}>
|
||||||
secondaryAction={
|
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1.5 }}>
|
||||||
<IconButton size="small" onClick={() => handleDelete(entry.id)}>
|
<Typography sx={{ fontSize: '14px', fontWeight: 600 }}>
|
||||||
<DeleteIcon fontSize="small" />
|
{entry.word}
|
||||||
</IconButton>
|
</Typography>
|
||||||
}
|
|
||||||
>
|
|
||||||
<ListItemText
|
|
||||||
primary={entry.word}
|
|
||||||
secondary={
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}>
|
|
||||||
{entry.pronunciation && (
|
{entry.pronunciation && (
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '12px', color: d3roPalette.text.label }}>
|
||||||
[{entry.pronunciation}]
|
[{entry.pronunciation}]
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<Chip label={entry.category} size="small" variant="outlined" />
|
|
||||||
<Chip label={`used ${entry.usageCount}x`} size="small" variant="outlined" />
|
|
||||||
</Box>
|
</Box>
|
||||||
}
|
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
|
||||||
/>
|
<Chip label={entry.category.toUpperCase()} size="small" color={CATEGORY_COLOR[entry.category] ?? 'primary'} />
|
||||||
</ListItem>
|
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: d3roPalette.text.label, alignSelf: 'center' }}>
|
||||||
|
{entry.usageCount}× used
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleDelete(entry.id)}
|
||||||
|
sx={{ color: d3roPalette.text.label, '&:hover': { color: d3roPalette.tag.red } }}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
))}
|
))}
|
||||||
</List>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add Word Dialog */}
|
{/* Add Word Dialog */}
|
||||||
<Dialog open={addOpen} onClose={() => setAddOpen(false)} maxWidth="xs" fullWidth>
|
<Dialog open={addOpen} onClose={() => setAddOpen(false)} maxWidth="xs" fullWidth>
|
||||||
<DialogTitle>Add Word</DialogTitle>
|
<DialogTitle sx={{ fontWeight: 700 }}>Add Word</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<TextField
|
<TextField
|
||||||
label="Word"
|
label="Word"
|
||||||
|
|
@ -161,11 +156,9 @@ export function DictionaryPage(): React.ReactElement {
|
||||||
sx={{ mt: 2 }}
|
sx={{ mt: 2 }}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
<Button onClick={() => setAddOpen(false)}>Cancel</Button>
|
<Button onClick={() => setAddOpen(false)} color="secondary" variant="contained">Cancel</Button>
|
||||||
<Button onClick={handleAdd} variant="contained" disabled={!newWord.trim()}>
|
<Button onClick={handleAdd} variant="contained" disabled={!newWord.trim()}>Add</Button>
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
// src/renderer/pages/HistoryPage.tsx
|
// src/renderer/pages/HistoryPage.tsx
|
||||||
|
// 08-design-system.md SSOT: 다크 카드, 앰버 악센트, 태그 시스템
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
TextField,
|
TextField,
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemText,
|
|
||||||
IconButton,
|
IconButton,
|
||||||
Chip,
|
Chip,
|
||||||
Pagination,
|
Pagination,
|
||||||
|
|
@ -18,10 +16,29 @@ import {
|
||||||
import SearchIcon from '@mui/icons-material/Search'
|
import SearchIcon from '@mui/icons-material/Search'
|
||||||
import DeleteIcon from '@mui/icons-material/Delete'
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||||
|
import { d3roPalette, d3roFontMono } from '../theme'
|
||||||
import type { HistoryEntry, HistoryPage as HistoryPageData } from '@shared/types'
|
import type { HistoryEntry, HistoryPage as HistoryPageData } from '@shared/types'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
function formatDate(ts: number): string {
|
||||||
|
return new Date(ts).toLocaleString('ko-KR', {
|
||||||
|
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(sec: number): string {
|
||||||
|
const m = Math.floor(sec / 60)
|
||||||
|
const s = Math.round(sec % 60)
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODE_TAG: Record<string, 'primary' | 'secondary' | 'warning'> = {
|
||||||
|
dictation: 'primary',
|
||||||
|
translate: 'secondary',
|
||||||
|
command: 'warning',
|
||||||
|
}
|
||||||
|
|
||||||
export function HistoryPage(): React.ReactElement {
|
export function HistoryPage(): React.ReactElement {
|
||||||
const [data, setData] = useState<HistoryPageData | null>(null)
|
const [data, setData] = useState<HistoryPageData | null>(null)
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
|
|
@ -33,16 +50,11 @@ export function HistoryPage(): React.ReactElement {
|
||||||
const result = search.trim()
|
const result = search.trim()
|
||||||
? await window.electronAPI.history.search({ query: search, page, pageSize: PAGE_SIZE })
|
? await window.electronAPI.history.search({ query: search, page, pageSize: PAGE_SIZE })
|
||||||
: await window.electronAPI.history.getAll({ page, pageSize: PAGE_SIZE })
|
: await window.electronAPI.history.getAll({ page, pageSize: PAGE_SIZE })
|
||||||
|
if (result.success) setData(result.data)
|
||||||
if (result.success) {
|
|
||||||
setData(result.data)
|
|
||||||
}
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}, [page, search])
|
}, [page, search])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { loadData() }, [loadData])
|
||||||
loadData()
|
|
||||||
}, [loadData])
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
await window.electronAPI.history.delete({ id })
|
await window.electronAPI.history.delete({ id })
|
||||||
|
|
@ -53,41 +65,28 @@ export function HistoryPage(): React.ReactElement {
|
||||||
navigator.clipboard.writeText(text)
|
navigator.clipboard.writeText(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (ts: number) => {
|
|
||||||
return new Date(ts).toLocaleString('ko-KR', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDuration = (sec: number) => {
|
|
||||||
const m = Math.floor(sec / 60)
|
|
||||||
const s = Math.round(sec % 60)
|
|
||||||
return `${m}:${s.toString().padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box sx={{ maxWidth: 1200, mx: 'auto', p: 5 }}>
|
||||||
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600 }}>
|
{/* Header */}
|
||||||
History
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Typography sx={{ fontSize: '22px', fontWeight: 700 }}>History</Typography>
|
||||||
|
<Typography sx={{ fontSize: '14px', color: 'text.secondary', mt: 0.5 }}>
|
||||||
|
Transcription history
|
||||||
</Typography>
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
<TextField
|
<TextField
|
||||||
placeholder="Search transcriptions..."
|
placeholder="Search transcriptions..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => {
|
onChange={(e) => { setSearch(e.target.value); setPage(0) }}
|
||||||
setSearch(e.target.value)
|
|
||||||
setPage(0)
|
|
||||||
}}
|
|
||||||
fullWidth
|
fullWidth
|
||||||
sx={{ mb: 2 }}
|
sx={{ mb: 3 }}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
<InputAdornment position="start">
|
<InputAdornment position="start">
|
||||||
<SearchIcon />
|
<SearchIcon sx={{ color: d3roPalette.text.label }} />
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -98,59 +97,90 @@ export function HistoryPage(): React.ReactElement {
|
||||||
<Typography color="text.secondary">Loading...</Typography>
|
<Typography color="text.secondary">Loading...</Typography>
|
||||||
) : !data || data.entries.length === 0 ? (
|
) : !data || data.entries.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent sx={{ py: 6, textAlign: 'center' }}>
|
||||||
<Typography color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
|
<Typography color="text.secondary">
|
||||||
{search ? 'No results found.' : 'No history yet.'}
|
{search ? 'No results found.' : 'No history yet. Start recording!'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<List>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
{data.entries.map((entry: HistoryEntry) => (
|
{data.entries.map((entry: HistoryEntry) => (
|
||||||
<ListItem
|
<Card key={entry.id} sx={{ p: 0 }}>
|
||||||
key={entry.id}
|
<CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 } }}>
|
||||||
divider
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
secondaryAction={
|
{/* Text */}
|
||||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
<Box sx={{ flex: 1, mr: 2 }}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: 'text.primary',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.polishedText || entry.originalText}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Meta row */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mt: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: d3roFontMono,
|
||||||
|
fontSize: '11px',
|
||||||
|
color: d3roPalette.text.label,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatDate(entry.createdAt)}
|
||||||
|
</Typography>
|
||||||
|
<Chip label={formatDuration(entry.duration)} size="small" color="primary" />
|
||||||
|
{entry.detectedLanguage && (
|
||||||
|
<Chip label={entry.detectedLanguage.toUpperCase()} size="small" color="secondary" />
|
||||||
|
)}
|
||||||
|
<Chip label={entry.mode.toUpperCase()} size="small" color={MODE_TAG[entry.mode] ?? 'primary'} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => handleCopy(entry.polishedText || entry.originalText)}
|
onClick={() => handleCopy(entry.polishedText || entry.originalText)}
|
||||||
|
sx={{ color: d3roPalette.text.label, '&:hover': { color: d3roPalette.accent.amber } }}
|
||||||
>
|
>
|
||||||
<ContentCopyIcon fontSize="small" />
|
<ContentCopyIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton size="small" onClick={() => handleDelete(entry.id)}>
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleDelete(entry.id)}
|
||||||
|
sx={{ color: d3roPalette.text.label, '&:hover': { color: d3roPalette.tag.red } }}
|
||||||
|
>
|
||||||
<DeleteIcon fontSize="small" />
|
<DeleteIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
|
||||||
>
|
|
||||||
<ListItemText
|
|
||||||
primary={entry.polishedText || entry.originalText}
|
|
||||||
secondary={
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, mt: 0.5, alignItems: 'center' }}>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{formatDate(entry.createdAt)}
|
|
||||||
</Typography>
|
|
||||||
<Chip label={formatDuration(entry.duration)} size="small" variant="outlined" />
|
|
||||||
{entry.detectedLanguage && (
|
|
||||||
<Chip label={entry.detectedLanguage} size="small" variant="outlined" />
|
|
||||||
)}
|
|
||||||
<Chip label={entry.mode} size="small" variant="outlined" />
|
|
||||||
</Box>
|
</Box>
|
||||||
}
|
</CardContent>
|
||||||
primaryTypographyProps={{ sx: { pr: 8 } }}
|
</Card>
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
))}
|
))}
|
||||||
</List>
|
</Box>
|
||||||
|
|
||||||
{data.totalPages > 1 && (
|
{data.totalPages > 1 && (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
|
||||||
<Pagination
|
<Pagination
|
||||||
count={data.totalPages}
|
count={data.totalPages}
|
||||||
page={page + 1}
|
page={page + 1}
|
||||||
onChange={(_, p) => setPage(p - 1)}
|
onChange={(_, p) => setPage(p - 1)}
|
||||||
|
sx={{
|
||||||
|
'& .Mui-selected': {
|
||||||
|
bgcolor: `${d3roPalette.accent.amberDim} !important`,
|
||||||
|
color: d3roPalette.accent.amber,
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,148 +1,192 @@
|
||||||
// src/renderer/theme.ts — MUI 7 테마 정의 (설계서 03 기반)
|
// src/renderer/theme.ts
|
||||||
|
// 08-design-system.md SSOT 기반 MUI 테마.
|
||||||
|
// D3RO 다크(기본) + 라이트 + auto(시스템). 나중에 커스텀 테마 추가 가능.
|
||||||
|
|
||||||
import { createTheme, type ThemeOptions } from '@mui/material/styles'
|
import { createTheme, type Theme } from '@mui/material/styles'
|
||||||
|
|
||||||
const commonOptions: ThemeOptions = {
|
// ── SSOT: 디자인 시스템 팔레트 상수 ───────────────────────
|
||||||
|
export const d3roPalette = {
|
||||||
|
bg: {
|
||||||
|
app: '#19191b',
|
||||||
|
card: '#242427',
|
||||||
|
cardHover: '#2a2a2d',
|
||||||
|
elevated: '#2e2e32',
|
||||||
|
input: '#1e1e21',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
amber: '#f25b29',
|
||||||
|
amberDim: 'rgba(242, 91, 41, 0.15)',
|
||||||
|
amberGlow: 'rgba(242, 91, 41, 0.6)',
|
||||||
|
},
|
||||||
|
tag: {
|
||||||
|
purple: '#b854f5',
|
||||||
|
purpleBg: 'rgba(184, 84, 245, 0.12)',
|
||||||
|
orange: '#f59e0b',
|
||||||
|
orangeBg: 'rgba(245, 158, 11, 0.12)',
|
||||||
|
red: '#ef4444',
|
||||||
|
redBg: 'rgba(239, 68, 68, 0.12)',
|
||||||
|
green: '#22c55e',
|
||||||
|
greenBg: 'rgba(34, 197, 94, 0.12)',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
primary: '#ffffff',
|
||||||
|
secondary: '#8e8e93',
|
||||||
|
label: '#7c7c82',
|
||||||
|
disabled: '#4a4a4e',
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
subtle: 'rgba(255, 255, 255, 0.04)',
|
||||||
|
default: 'rgba(255, 255, 255, 0.08)',
|
||||||
|
strong: 'rgba(255, 255, 255, 0.12)',
|
||||||
|
},
|
||||||
|
crt: {
|
||||||
|
phosphor: '#f25b29',
|
||||||
|
phosphorDim: '#c44a22',
|
||||||
|
scanline: 'rgba(0, 0, 0, 0.15)',
|
||||||
|
bg: '#242528',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const d3roFontSans = [
|
||||||
|
'-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto',
|
||||||
|
'"Helvetica Neue"', 'Arial', 'sans-serif',
|
||||||
|
].join(',')
|
||||||
|
|
||||||
|
export const d3roFontMono = [
|
||||||
|
'ui-monospace', 'SFMono-Regular', '"SF Mono"', 'Menlo', 'Consolas',
|
||||||
|
'"Liberation Mono"', 'monospace',
|
||||||
|
].join(',')
|
||||||
|
|
||||||
|
// ── 모드별 변동 팔레트 ────────────────────────────────────
|
||||||
|
interface ModePalette {
|
||||||
|
bg: { app: string; card: string; cardHover: string; elevated: string; input: string }
|
||||||
|
text: { primary: string; secondary: string; label: string; disabled: string }
|
||||||
|
border: { subtle: string; default: string; strong: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
const darkPalette: ModePalette = {
|
||||||
|
bg: { app: '#19191b', card: '#242427', cardHover: '#2a2a2d', elevated: '#2e2e32', input: '#1e1e21' },
|
||||||
|
text: { primary: '#ffffff', secondary: '#8e8e93', label: '#7c7c82', disabled: '#4a4a4e' },
|
||||||
|
border: { subtle: 'rgba(255,255,255,0.04)', default: 'rgba(255,255,255,0.08)', strong: 'rgba(255,255,255,0.12)' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const lightPalette: ModePalette = {
|
||||||
|
bg: { app: '#f5f5f7', card: '#ffffff', cardHover: '#fafafa', elevated: '#f0f0f2', input: '#ffffff' },
|
||||||
|
text: { primary: '#1a1a1c', secondary: '#6e6e73', label: '#8e8e93', disabled: '#c7c7cc' },
|
||||||
|
border: { subtle: 'rgba(0,0,0,0.04)', default: 'rgba(0,0,0,0.08)', strong: 'rgba(0,0,0,0.12)' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 테마 팩토리 ───────────────────────────────────────────
|
||||||
|
function createD3ROTheme(mode: 'dark' | 'light'): Theme {
|
||||||
|
const isDark = mode === 'dark'
|
||||||
|
const p = isDark ? darkPalette : lightPalette
|
||||||
|
const accent = d3roPalette.accent
|
||||||
|
|
||||||
|
return createTheme({
|
||||||
|
palette: {
|
||||||
|
mode,
|
||||||
|
primary: { main: accent.amber, light: '#ff7a4d', dark: '#c44a22', contrastText: '#fff' },
|
||||||
|
secondary: { main: d3roPalette.tag.purple, light: '#d084ff', dark: '#8a3cc4' },
|
||||||
|
error: { main: d3roPalette.tag.red },
|
||||||
|
warning: { main: d3roPalette.tag.orange },
|
||||||
|
success: { main: d3roPalette.tag.green },
|
||||||
|
background: { default: p.bg.app, paper: p.bg.card },
|
||||||
|
text: { primary: p.text.primary, secondary: p.text.secondary, disabled: p.text.disabled },
|
||||||
|
divider: p.border.default,
|
||||||
|
},
|
||||||
typography: {
|
typography: {
|
||||||
fontFamily: [
|
fontFamily: d3roFontSans,
|
||||||
'-apple-system',
|
h4: { fontWeight: 700, fontSize: '22px', lineHeight: 1.3 },
|
||||||
'BlinkMacSystemFont',
|
h5: { fontWeight: 700, fontSize: '18px', lineHeight: 1.4 },
|
||||||
'"Segoe UI"',
|
h6: { fontWeight: 600, fontSize: '14px', lineHeight: 1.5 },
|
||||||
'Roboto',
|
subtitle1: { fontWeight: 500, fontSize: '18px', lineHeight: 1.4 },
|
||||||
'"Helvetica Neue"',
|
body1: { fontSize: '14px', lineHeight: 1.5 },
|
||||||
'Arial',
|
body2: { fontSize: '12px', lineHeight: 1.4 },
|
||||||
'sans-serif'
|
button: { textTransform: 'none' as const, fontWeight: 600, fontSize: '14px' },
|
||||||
].join(','),
|
caption: { fontSize: '11px', fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase' as const, color: p.text.label },
|
||||||
h4: { fontWeight: 600, fontSize: '1.5rem' },
|
overline: { fontSize: '11px', fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase' as const, lineHeight: 1.2 },
|
||||||
h5: { fontWeight: 600, fontSize: '1.25rem' },
|
|
||||||
h6: { fontWeight: 600, fontSize: '1rem' },
|
|
||||||
subtitle1: { fontWeight: 500 },
|
|
||||||
body1: { fontSize: '0.9375rem' },
|
|
||||||
body2: { fontSize: '0.8125rem' },
|
|
||||||
button: { textTransform: 'none' as const, fontWeight: 500 }
|
|
||||||
},
|
|
||||||
shape: {
|
|
||||||
borderRadius: 12
|
|
||||||
},
|
},
|
||||||
|
shape: { borderRadius: 22 },
|
||||||
components: {
|
components: {
|
||||||
MuiButton: {
|
MuiCssBaseline: {
|
||||||
defaultProps: {
|
styleOverrides: { body: { backgroundColor: p.bg.app, color: p.text.primary } },
|
||||||
disableElevation: true
|
|
||||||
},
|
},
|
||||||
|
MuiButton: {
|
||||||
|
defaultProps: { disableElevation: true },
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
root: {
|
root: {
|
||||||
textTransform: 'none',
|
textTransform: 'none', fontWeight: 600, borderRadius: 10, padding: '10px 20px',
|
||||||
fontWeight: 500,
|
transition: 'transform 0.05s linear, box-shadow 0.05s linear',
|
||||||
borderRadius: 8,
|
boxShadow: '0 2px 0 rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.06)',
|
||||||
padding: '8px 16px'
|
'&:active': { transform: 'translateY(2px)', boxShadow: '0 0 0 rgba(0,0,0,0.4), inset 0 2px 4px rgba(0,0,0,0.3)' },
|
||||||
}
|
},
|
||||||
}
|
containedPrimary: { '&:hover': { backgroundColor: '#d94f24' } },
|
||||||
|
containedSecondary: { backgroundColor: p.bg.elevated, color: p.text.primary, '&:hover': { backgroundColor: isDark ? '#353539' : '#e5e5e7' } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
MuiCard: {
|
MuiCard: {
|
||||||
defaultProps: {
|
defaultProps: { elevation: 0 },
|
||||||
elevation: 0
|
|
||||||
},
|
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
root: {
|
root: {
|
||||||
borderRadius: 12,
|
backgroundColor: p.bg.card, borderRadius: 22,
|
||||||
border: '1px solid'
|
borderTop: `1px solid ${p.border.subtle}`,
|
||||||
}
|
boxShadow: isDark ? '0 8px 30px rgba(0,0,0,0.3)' : '0 4px 20px rgba(0,0,0,0.06)',
|
||||||
}
|
transition: 'background-color 0.2s ease',
|
||||||
|
'&:hover': { backgroundColor: p.bg.cardHover },
|
||||||
},
|
},
|
||||||
MuiDrawer: {
|
|
||||||
styleOverrides: {
|
|
||||||
paper: {
|
|
||||||
width: 240,
|
|
||||||
borderRight: 'none'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
MuiListItemButton: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
borderRadius: 8,
|
|
||||||
marginLeft: 8,
|
|
||||||
marginRight: 8
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
MuiTextField: {
|
|
||||||
defaultProps: {
|
|
||||||
size: 'small',
|
|
||||||
variant: 'outlined'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
MuiChip: {
|
MuiChip: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: { borderRadius: 999, fontWeight: 700, fontSize: '11px', letterSpacing: '0.1em', textTransform: 'uppercase', height: 24 },
|
||||||
|
colorPrimary: { backgroundColor: accent.amberDim, color: accent.amber },
|
||||||
|
colorSecondary: { backgroundColor: d3roPalette.tag.purpleBg, color: d3roPalette.tag.purple },
|
||||||
|
colorSuccess: { backgroundColor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green },
|
||||||
|
colorError: { backgroundColor: d3roPalette.tag.redBg, color: d3roPalette.tag.red },
|
||||||
|
colorWarning: { backgroundColor: d3roPalette.tag.orangeBg, color: d3roPalette.tag.orange },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiDrawer: { styleOverrides: { paper: { width: 240, backgroundColor: p.bg.app, borderRight: `1px solid ${p.border.subtle}` } } },
|
||||||
|
MuiListItemButton: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
root: {
|
root: {
|
||||||
borderRadius: 6,
|
borderRadius: 10, marginLeft: 8, marginRight: 8,
|
||||||
fontWeight: 500
|
'&.Mui-selected': { backgroundColor: accent.amberDim, color: accent.amber, fontWeight: 600, '&:hover': { backgroundColor: 'rgba(242,91,41,0.2)' } },
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
MuiDialog: { styleOverrides: { paper: { backgroundColor: p.bg.card, borderRadius: 22, border: `1px solid ${p.border.subtle}`, boxShadow: '0 16px 48px rgba(0,0,0,0.5)' } } },
|
||||||
|
MuiTextField: {
|
||||||
|
defaultProps: { size: 'small', variant: 'outlined' },
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
backgroundColor: p.bg.input, borderRadius: 10,
|
||||||
|
'& fieldset': { borderColor: p.border.default },
|
||||||
|
'&:hover fieldset': { borderColor: p.border.strong },
|
||||||
|
'&.Mui-focused fieldset': { borderColor: accent.amber },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiTooltip: { defaultProps: { arrow: true }, styleOverrides: { tooltip: { backgroundColor: p.bg.elevated, fontSize: '12px', borderRadius: 8, border: `1px solid ${p.border.subtle}` } } },
|
||||||
|
MuiTabs: { styleOverrides: { indicator: { backgroundColor: accent.amber } } },
|
||||||
|
MuiTab: { styleOverrides: { root: { textTransform: 'none', fontWeight: 500, fontSize: '14px', '&.Mui-selected': { color: accent.amber, fontWeight: 600 } } } },
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const lightTheme = createTheme({
|
// ── Export ─────────────────────────────────────────────────
|
||||||
...commonOptions,
|
export const darkTheme = createD3ROTheme('dark')
|
||||||
palette: {
|
export const lightTheme = createD3ROTheme('light')
|
||||||
mode: 'light',
|
|
||||||
primary: {
|
|
||||||
main: 'rgb(31, 93, 242)',
|
|
||||||
light: 'rgb(71, 133, 255)',
|
|
||||||
dark: 'rgb(20, 65, 180)',
|
|
||||||
contrastText: '#FFFFFF'
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
main: 'rgb(108, 117, 125)',
|
|
||||||
light: 'rgb(173, 181, 189)',
|
|
||||||
dark: 'rgb(73, 80, 87)'
|
|
||||||
},
|
|
||||||
background: {
|
|
||||||
default: '#F9F9F9',
|
|
||||||
paper: '#FFFFFF'
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
primary: 'rgba(0, 0, 0, 0.87)',
|
|
||||||
secondary: 'rgba(0, 0, 0, 0.6)'
|
|
||||||
},
|
|
||||||
divider: 'rgba(0, 0, 0, 0.08)',
|
|
||||||
error: { main: '#D32F2F' },
|
|
||||||
success: { main: '#2E7D32' },
|
|
||||||
warning: { main: '#ED6C02' }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export const darkTheme = createTheme({
|
/** 테마 모드에 따라 Theme 반환. auto일 때는 prefersDark 파라미터 사용. */
|
||||||
...commonOptions,
|
export function getTheme(mode: 'dark' | 'light' | 'auto', prefersDark = true): Theme {
|
||||||
palette: {
|
if (mode === 'auto') return prefersDark ? darkTheme : lightTheme
|
||||||
mode: 'dark',
|
|
||||||
primary: {
|
|
||||||
main: 'rgb(71, 133, 255)',
|
|
||||||
light: 'rgb(120, 170, 255)',
|
|
||||||
dark: 'rgb(31, 93, 242)',
|
|
||||||
contrastText: '#FFFFFF'
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
main: 'rgb(173, 181, 189)',
|
|
||||||
light: 'rgb(206, 212, 218)',
|
|
||||||
dark: 'rgb(108, 117, 125)'
|
|
||||||
},
|
|
||||||
background: {
|
|
||||||
default: '#121212',
|
|
||||||
paper: '#1E1E1E'
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
primary: 'rgba(255, 255, 255, 0.87)',
|
|
||||||
secondary: 'rgba(255, 255, 255, 0.6)'
|
|
||||||
},
|
|
||||||
divider: 'rgba(255, 255, 255, 0.08)',
|
|
||||||
error: { main: '#EF5350' },
|
|
||||||
success: { main: '#4CAF50' },
|
|
||||||
warning: { main: '#FFA726' }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export function getTheme(mode: 'light' | 'dark') {
|
|
||||||
return mode === 'dark' ? darkTheme : lightTheme
|
return mode === 'dark' ? darkTheme : lightTheme
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 현재 모드의 ModePalette 가져오기 (컴포넌트에서 직접 참조용) */
|
||||||
|
export function getModePalette(mode: 'dark' | 'light'): ModePalette {
|
||||||
|
return mode === 'dark' ? darkPalette : lightPalette
|
||||||
|
}
|
||||||
|
|
|
||||||
87
tests/helpers/createTestDb.ts
Normal file
87
tests/helpers/createTestDb.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
// tests/helpers/createTestDb.ts
|
||||||
|
// in-memory SQLite + drizzle-orm 스키마 적용
|
||||||
|
|
||||||
|
import Database from 'better-sqlite3'
|
||||||
|
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||||
|
import * as schema from '../../src/main/db/schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테스트용 in-memory SQLite DB를 생성한다.
|
||||||
|
* 각 테스트에서 독립적인 DB를 사용할 수 있다.
|
||||||
|
*/
|
||||||
|
export function createTestDb(): {
|
||||||
|
db: BetterSQLite3Database<typeof schema>
|
||||||
|
sqlite: Database.Database
|
||||||
|
close: () => void
|
||||||
|
} {
|
||||||
|
const sqlite = new Database(':memory:')
|
||||||
|
|
||||||
|
sqlite.pragma('journal_mode = WAL')
|
||||||
|
sqlite.pragma('foreign_keys = ON')
|
||||||
|
|
||||||
|
// 테이블 생성 (src/main/db/index.ts의 SQL과 동일)
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS history (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
original_text TEXT NOT NULL,
|
||||||
|
polished_text TEXT,
|
||||||
|
focused_app TEXT,
|
||||||
|
focused_app_name TEXT,
|
||||||
|
focused_app_window_title TEXT,
|
||||||
|
mode TEXT NOT NULL DEFAULT 'dictation',
|
||||||
|
status TEXT NOT NULL DEFAULT 'completed',
|
||||||
|
error_code TEXT,
|
||||||
|
audio_local_path TEXT,
|
||||||
|
duration REAL NOT NULL,
|
||||||
|
detected_language TEXT,
|
||||||
|
mic_device TEXT,
|
||||||
|
word_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
stt_model TEXT,
|
||||||
|
llm_model TEXT,
|
||||||
|
stt_latency_ms INTEGER,
|
||||||
|
llm_latency_ms INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
app_version TEXT NOT NULL DEFAULT '1.0.0'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_history_created_at ON history(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_history_status ON history(status);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dictionary (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
word TEXT NOT NULL,
|
||||||
|
pronunciation TEXT,
|
||||||
|
category TEXT NOT NULL DEFAULT 'user',
|
||||||
|
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_used_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_dictionary_word_category ON dictionary(word, category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dictionary_created_at ON dictionary(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dictionary_usage_count ON dictionary(usage_count DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS stats (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
total_duration REAL NOT NULL DEFAULT 0,
|
||||||
|
total_words INTEGER NOT NULL DEFAULT 0,
|
||||||
|
session_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
streak_days INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_session_at INTEGER,
|
||||||
|
last_updated INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO stats (id, total_duration, total_words, session_count, streak_days, last_updated)
|
||||||
|
VALUES (1, 0, 0, 0, 0, ${Date.now()});
|
||||||
|
`)
|
||||||
|
|
||||||
|
const db = drizzle(sqlite, { schema })
|
||||||
|
|
||||||
|
return {
|
||||||
|
db,
|
||||||
|
sqlite,
|
||||||
|
close: () => sqlite.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
190
tests/main/services/CustomInstructionService.test.ts
Normal file
190
tests/main/services/CustomInstructionService.test.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
// tests/main/services/CustomInstructionService.test.ts
|
||||||
|
// CRUD + 프리셋 보호 + 순서 변경 테스트
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
|
||||||
|
// ConfigService 모킹
|
||||||
|
const mockStore: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||||
|
configGet: vi.fn((key: string) => mockStore[key]),
|
||||||
|
configSet: vi.fn((key: string, value: unknown) => {
|
||||||
|
mockStore[key] = value
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||||
|
getLogger: () => ({
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
let getCustomInstructionService: () => ReturnType<typeof import('../../../src/main/services/CustomInstructionService')['getCustomInstructionService']>
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// 매번 모듈과 스토어 초기화
|
||||||
|
for (const key of Object.keys(mockStore)) {
|
||||||
|
delete mockStore[key]
|
||||||
|
}
|
||||||
|
vi.resetModules()
|
||||||
|
const mod = await import('../../../src/main/services/CustomInstructionService')
|
||||||
|
getCustomInstructionService = mod.getCustomInstructionService
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CustomInstructionService', () => {
|
||||||
|
describe('initialize + getAll', () => {
|
||||||
|
it('첫 실행 시 5개 프리셋이 생성된다', () => {
|
||||||
|
const svc = getCustomInstructionService()
|
||||||
|
svc.initialize()
|
||||||
|
|
||||||
|
const all = svc.getAll()
|
||||||
|
expect(all.length).toBe(5)
|
||||||
|
expect(all.every((i) => i.isBuiltin)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('order 순으로 정렬하여 반환한다', () => {
|
||||||
|
const svc = getCustomInstructionService()
|
||||||
|
svc.initialize()
|
||||||
|
|
||||||
|
const all = svc.getAll()
|
||||||
|
for (let i = 1; i < all.length; i++) {
|
||||||
|
expect(all[i].order).toBeGreaterThanOrEqual(all[i - 1].order)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getById', () => {
|
||||||
|
it('존재하는 ID로 명령어를 반환한다', () => {
|
||||||
|
const svc = getCustomInstructionService()
|
||||||
|
svc.initialize()
|
||||||
|
|
||||||
|
const inst = svc.getById('builtin-translate')
|
||||||
|
expect(inst).not.toBeNull()
|
||||||
|
expect(inst!.name).toBe('번역')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('없는 ID에 대해 null을 반환한다', () => {
|
||||||
|
const svc = getCustomInstructionService()
|
||||||
|
svc.initialize()
|
||||||
|
|
||||||
|
expect(svc.getById('nonexistent')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('사용자 정의 명령어를 추가한다', () => {
|
||||||
|
const svc = getCustomInstructionService()
|
||||||
|
svc.initialize()
|
||||||
|
|
||||||
|
const created = svc.create({
|
||||||
|
name: 'My Custom',
|
||||||
|
description: 'Test custom instruction',
|
||||||
|
prompt: 'Custom prompt: {{text}}',
|
||||||
|
icon: 'Star'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(created.isBuiltin).toBe(false)
|
||||||
|
expect(created.name).toBe('My Custom')
|
||||||
|
expect(svc.getAll().length).toBe(6)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('사용자 정의 명령어의 모든 필드를 수정할 수 있다', () => {
|
||||||
|
const svc = getCustomInstructionService()
|
||||||
|
svc.initialize()
|
||||||
|
|
||||||
|
const created = svc.create({
|
||||||
|
name: 'Original',
|
||||||
|
description: 'Desc',
|
||||||
|
prompt: 'Prompt',
|
||||||
|
icon: 'Star'
|
||||||
|
})
|
||||||
|
|
||||||
|
const updated = svc.update(created.id, { name: 'Updated', prompt: 'New prompt' })
|
||||||
|
expect(updated).not.toBeNull()
|
||||||
|
expect(updated!.name).toBe('Updated')
|
||||||
|
expect(updated!.prompt).toBe('New prompt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('프리셋은 프롬프트만 수정 가능하다', () => {
|
||||||
|
const svc = getCustomInstructionService()
|
||||||
|
svc.initialize()
|
||||||
|
|
||||||
|
const updated = svc.update('builtin-translate', { name: 'Changed Name', prompt: 'New prompt' })
|
||||||
|
expect(updated).not.toBeNull()
|
||||||
|
expect(updated!.name).toBe('번역') // 이름은 변경 안 됨
|
||||||
|
expect(updated!.prompt).toBe('New prompt') // 프롬프트는 변경됨
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('프리셋은 삭제할 수 없다', () => {
|
||||||
|
const svc = getCustomInstructionService()
|
||||||
|
svc.initialize()
|
||||||
|
|
||||||
|
expect(svc.delete('builtin-translate')).toBe(false)
|
||||||
|
expect(svc.getAll().length).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('사용자 정의 명령어는 삭제할 수 있다', () => {
|
||||||
|
const svc = getCustomInstructionService()
|
||||||
|
svc.initialize()
|
||||||
|
|
||||||
|
const created = svc.create({
|
||||||
|
name: 'To Delete',
|
||||||
|
description: '',
|
||||||
|
prompt: '',
|
||||||
|
icon: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(svc.delete(created.id)).toBe(true)
|
||||||
|
expect(svc.getAll().length).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('없는 ID 삭제 시 false를 반환한다', () => {
|
||||||
|
const svc = getCustomInstructionService()
|
||||||
|
svc.initialize()
|
||||||
|
|
||||||
|
expect(svc.delete('nonexistent')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reorder', () => {
|
||||||
|
it('지정된 순서대로 order를 재설정한다', () => {
|
||||||
|
const svc = getCustomInstructionService()
|
||||||
|
svc.initialize()
|
||||||
|
|
||||||
|
const all = svc.getAll()
|
||||||
|
const reversed = [...all].reverse().map((i) => i.id)
|
||||||
|
svc.reorder(reversed)
|
||||||
|
|
||||||
|
const reordered = svc.getAll()
|
||||||
|
expect(reordered[0].id).toBe(reversed[0])
|
||||||
|
expect(reordered[4].id).toBe(reversed[4])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resetBuiltins', () => {
|
||||||
|
it('프리셋을 초기값으로 복원하고 사용자 명령어는 유지한다', () => {
|
||||||
|
const svc = getCustomInstructionService()
|
||||||
|
svc.initialize()
|
||||||
|
|
||||||
|
// 프롬프트 수정
|
||||||
|
svc.update('builtin-translate', { prompt: 'modified prompt' })
|
||||||
|
|
||||||
|
// 사용자 명령어 추가
|
||||||
|
svc.create({ name: 'User', description: '', prompt: '', icon: '' })
|
||||||
|
|
||||||
|
svc.resetBuiltins()
|
||||||
|
|
||||||
|
const all = svc.getAll()
|
||||||
|
const translate = all.find((i) => i.id === 'builtin-translate')
|
||||||
|
expect(translate!.prompt).toContain('{{targetLanguage}}') // 원래 프롬프트
|
||||||
|
expect(all.length).toBe(6) // 프리셋 5 + 사용자 1
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
116
tests/main/services/DictionaryService.test.ts
Normal file
116
tests/main/services/DictionaryService.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
// tests/main/services/DictionaryService.test.ts
|
||||||
|
// DictionaryService 단위 테스트 — DB 모킹
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
|
||||||
|
function createMockQueryBuilder(data: unknown[] = []) {
|
||||||
|
const builder: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
builder.values = vi.fn(() => ({ run: vi.fn(() => ({ changes: 1 })) }))
|
||||||
|
builder.from = vi.fn(() => builder)
|
||||||
|
builder.where = vi.fn(() => builder)
|
||||||
|
builder.orderBy = vi.fn(() => builder)
|
||||||
|
builder.limit = vi.fn(() => builder)
|
||||||
|
builder.offset = vi.fn(() => builder)
|
||||||
|
builder.set = vi.fn(() => builder)
|
||||||
|
builder.get = vi.fn(() => data[0] ?? null)
|
||||||
|
builder.all = vi.fn(() => data)
|
||||||
|
builder.run = vi.fn(() => ({ changes: 1 }))
|
||||||
|
|
||||||
|
return builder
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockDb = {
|
||||||
|
insert: vi.fn(() => createMockQueryBuilder()),
|
||||||
|
select: vi.fn(() => createMockQueryBuilder()),
|
||||||
|
update: vi.fn(() => createMockQueryBuilder()),
|
||||||
|
delete: vi.fn(() => createMockQueryBuilder())
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/db', () => ({
|
||||||
|
getDatabase: () => mockDb
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||||
|
getLogger: () => ({
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
let getDictionaryService: () => ReturnType<typeof import('../../../src/main/services/DictionaryService')['getDictionaryService']>
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
const mod = await import('../../../src/main/services/DictionaryService')
|
||||||
|
getDictionaryService = mod.getDictionaryService
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DictionaryService', () => {
|
||||||
|
describe('add', () => {
|
||||||
|
it('insert를 호출하고 엔트리를 반환한다', () => {
|
||||||
|
const svc = getDictionaryService()
|
||||||
|
const entry = svc.add({ word: 'AI', pronunciation: '에이아이', category: 'technical' })
|
||||||
|
|
||||||
|
expect(mockDb.insert).toHaveBeenCalled()
|
||||||
|
expect(entry.id).toBeDefined()
|
||||||
|
expect(entry.word).toBe('AI')
|
||||||
|
expect(entry.pronunciation).toBe('에이아이')
|
||||||
|
expect(entry.category).toBe('technical')
|
||||||
|
expect(entry.usageCount).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('기본 카테고리는 user이다', () => {
|
||||||
|
const svc = getDictionaryService()
|
||||||
|
const entry = svc.add({ word: '테스트' })
|
||||||
|
expect(entry.category).toBe('user')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pronunciation 미지정 시 null이다', () => {
|
||||||
|
const svc = getDictionaryService()
|
||||||
|
const entry = svc.add({ word: 'test' })
|
||||||
|
expect(entry.pronunciation).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('없는 ID에 대해 null을 반환한다', () => {
|
||||||
|
// select().from().where().get()이 null 반환
|
||||||
|
const svc = getDictionaryService()
|
||||||
|
expect(svc.update({ id: 'nonexistent', word: 'test' })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('delete 쿼리를 실행하고 changes > 0이면 true를 반환한다', () => {
|
||||||
|
const svc = getDictionaryService()
|
||||||
|
expect(svc.delete('some-id')).toBe(true)
|
||||||
|
expect(mockDb.delete).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('incrementUsage', () => {
|
||||||
|
it('update 쿼리를 실행한다', () => {
|
||||||
|
const svc = getDictionaryService()
|
||||||
|
svc.incrementUsage('some-id')
|
||||||
|
expect(mockDb.update).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getPromptHints', () => {
|
||||||
|
it('빈 목록에서 빈 문자열을 반환한다', () => {
|
||||||
|
const svc = getDictionaryService()
|
||||||
|
expect(svc.getPromptHints()).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dispose', () => {
|
||||||
|
it('에러 없이 호출된다', () => {
|
||||||
|
const svc = getDictionaryService()
|
||||||
|
expect(() => svc.dispose()).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
134
tests/main/services/HistoryService.test.ts
Normal file
134
tests/main/services/HistoryService.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
// tests/main/services/HistoryService.test.ts
|
||||||
|
// HistoryService 단위 테스트 — DB 계층을 모킹하여 순수 로직만 검증
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
|
||||||
|
// drizzle 모킹: 체이너블 쿼리 빌더 패턴
|
||||||
|
function createMockQueryBuilder(data: unknown[] = []) {
|
||||||
|
const builder: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
builder.values = vi.fn(() => ({ run: vi.fn(() => ({ changes: 1 })) }))
|
||||||
|
builder.from = vi.fn(() => builder)
|
||||||
|
builder.where = vi.fn(() => builder)
|
||||||
|
builder.orderBy = vi.fn(() => builder)
|
||||||
|
builder.limit = vi.fn(() => builder)
|
||||||
|
builder.offset = vi.fn(() => builder)
|
||||||
|
builder.set = vi.fn(() => builder)
|
||||||
|
builder.get = vi.fn(() => data[0] ?? null)
|
||||||
|
builder.all = vi.fn(() => data)
|
||||||
|
builder.run = vi.fn(() => ({ changes: 1 }))
|
||||||
|
|
||||||
|
return builder
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockDb = {
|
||||||
|
insert: vi.fn(() => createMockQueryBuilder()),
|
||||||
|
select: vi.fn(() => createMockQueryBuilder()),
|
||||||
|
update: vi.fn(() => createMockQueryBuilder()),
|
||||||
|
delete: vi.fn(() => createMockQueryBuilder())
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/db', () => ({
|
||||||
|
getDatabase: () => mockDb
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||||
|
getLogger: () => ({
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
let getHistoryService: () => ReturnType<typeof import('../../../src/main/services/HistoryService')['getHistoryService']>
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
const mod = await import('../../../src/main/services/HistoryService')
|
||||||
|
getHistoryService = mod.getHistoryService
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('HistoryService', () => {
|
||||||
|
describe('create', () => {
|
||||||
|
it('insert를 호출하고 엔트리를 반환한다', () => {
|
||||||
|
const svc = getHistoryService()
|
||||||
|
const entry = svc.create({
|
||||||
|
originalText: '안녕하세요',
|
||||||
|
duration: 3.5,
|
||||||
|
wordCount: 3,
|
||||||
|
mode: 'dictation',
|
||||||
|
status: 'completed',
|
||||||
|
appVersion: '1.0.0'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockDb.insert).toHaveBeenCalled()
|
||||||
|
expect(entry.id).toBeDefined()
|
||||||
|
expect(entry.originalText).toBe('안녕하세요')
|
||||||
|
expect(entry.duration).toBe(3.5)
|
||||||
|
expect(entry.createdAt).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stats 업데이트를 호출한다', () => {
|
||||||
|
const svc = getHistoryService()
|
||||||
|
svc.create({
|
||||||
|
originalText: 'test',
|
||||||
|
duration: 5.0,
|
||||||
|
wordCount: 10,
|
||||||
|
mode: 'dictation',
|
||||||
|
status: 'completed',
|
||||||
|
appVersion: '1.0.0'
|
||||||
|
})
|
||||||
|
|
||||||
|
// insert 2번: history + stats update
|
||||||
|
expect(mockDb.insert).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockDb.update).toHaveBeenCalledTimes(1) // stats update
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getById', () => {
|
||||||
|
it('DB에서 조회한 결과를 반환한다', () => {
|
||||||
|
// select().from().where().get()이 null 반환하면 null
|
||||||
|
const svc = getHistoryService()
|
||||||
|
const result = svc.getById('nonexistent')
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('delete 쿼리를 실행하고 changes > 0이면 true를 반환한다', () => {
|
||||||
|
const svc = getHistoryService()
|
||||||
|
const result = svc.delete('some-id')
|
||||||
|
expect(mockDb.delete).toHaveBeenCalled()
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deleteAll', () => {
|
||||||
|
it('delete 쿼리를 실행한다', () => {
|
||||||
|
const svc = getHistoryService()
|
||||||
|
svc.deleteAll()
|
||||||
|
expect(mockDb.delete).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getStats', () => {
|
||||||
|
it('stats + today 쿼리를 실행한다', () => {
|
||||||
|
const svc = getHistoryService()
|
||||||
|
const st = svc.getStats()
|
||||||
|
|
||||||
|
expect(mockDb.select).toHaveBeenCalled()
|
||||||
|
expect(st).toHaveProperty('totalRecordingTimeMs')
|
||||||
|
expect(st).toHaveProperty('todaySessionCount')
|
||||||
|
expect(st).toHaveProperty('streakDays')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dispose', () => {
|
||||||
|
it('에러 없이 호출된다', () => {
|
||||||
|
const svc = getHistoryService()
|
||||||
|
expect(() => svc.dispose()).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
204
tests/main/services/VoiceModeService.test.ts
Normal file
204
tests/main/services/VoiceModeService.test.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
// tests/main/services/VoiceModeService.test.ts
|
||||||
|
// 상태 머신 전이 + 이중 조건 플러시 + accidentalPress 테스트
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { RecognitionState, AudioState } from '../../../src/shared/types'
|
||||||
|
import { TIMING } from '../../../src/shared/constants'
|
||||||
|
|
||||||
|
// 모든 하위 서비스 모킹
|
||||||
|
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||||
|
getLogger: () => ({
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockSTT = {
|
||||||
|
initialize: vi.fn(() => Promise.resolve()),
|
||||||
|
transcribe: vi.fn(() =>
|
||||||
|
Promise.resolve({ text: '테스트 전사', segments: [], language: 'ko', duration: 2, processingTime: 500 })
|
||||||
|
),
|
||||||
|
getStatus: vi.fn(() => ({ state: 'ready', modelId: 'base', uptime: 0 })),
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/LocalSTTService', () => ({
|
||||||
|
getLocalSTTService: () => mockSTT
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockAudio = {
|
||||||
|
start: vi.fn(() => Promise.resolve()),
|
||||||
|
stop: vi.fn(() => Promise.resolve()),
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/AudioCaptureService', () => ({
|
||||||
|
getAudioCaptureService: () => mockAudio
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockHotkey = {
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/HotkeyService', () => ({
|
||||||
|
getHotkeyService: () => mockHotkey
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||||
|
configGet: vi.fn((key: string) => {
|
||||||
|
const defaults: Record<string, unknown> = {
|
||||||
|
sttModelId: 'base',
|
||||||
|
defaultLLMAction: 'refine',
|
||||||
|
ollamaServerUrl: 'http://localhost:11434',
|
||||||
|
llmModelId: 'qwen3:4b'
|
||||||
|
}
|
||||||
|
return defaults[key]
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockTextInsert = {
|
||||||
|
insertText: vi.fn(() => Promise.resolve({ success: true, method: 'clipboard', textLength: 10, durationMs: 50 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/TextInsertService', () => ({
|
||||||
|
getTextInsertService: () => mockTextInsert
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockLLM = {
|
||||||
|
isAvailable: vi.fn(() => false),
|
||||||
|
processText: vi.fn(() => Promise.resolve('다듬어진 텍스트')),
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/LocalLLMService', () => ({
|
||||||
|
getLocalLLMService: () => mockLLM
|
||||||
|
}))
|
||||||
|
|
||||||
|
let getVoiceModeService: () => ReturnType<typeof import('../../../src/main/services/VoiceModeService')['getVoiceModeService']>
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
const mod = await import('../../../src/main/services/VoiceModeService')
|
||||||
|
getVoiceModeService = mod.getVoiceModeService
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('VoiceModeService', () => {
|
||||||
|
describe('상태 머신', () => {
|
||||||
|
it('초기 상태는 IDLE이다', () => {
|
||||||
|
const svc = getVoiceModeService()
|
||||||
|
const state = svc.getState()
|
||||||
|
|
||||||
|
expect(state.recognitionState).toBe(RecognitionState.IDLE)
|
||||||
|
expect(state.audioState).toBe(AudioState.IDLE)
|
||||||
|
expect(state.sessionId).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('startSession 호출 시 PREPARING으로 전이한다', async () => {
|
||||||
|
const svc = getVoiceModeService()
|
||||||
|
const stateChanges: RecognitionState[] = []
|
||||||
|
|
||||||
|
svc.on('recognition-state-changed', (payload: { current: RecognitionState }) => {
|
||||||
|
stateChanges.push(payload.current)
|
||||||
|
})
|
||||||
|
|
||||||
|
await svc.startSession('dictation')
|
||||||
|
|
||||||
|
// PREPARING → CONNECTING → READY 순서
|
||||||
|
expect(stateChanges[0]).toBe(RecognitionState.PREPARING)
|
||||||
|
expect(stateChanges).toContain(RecognitionState.CONNECTING)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isActive는 세션이 활성일 때 true이다', async () => {
|
||||||
|
const svc = getVoiceModeService()
|
||||||
|
expect(svc.isActive).toBe(false)
|
||||||
|
|
||||||
|
// startSession은 비동기이므로 STT init이 resolve되면 세션 활성
|
||||||
|
const promise = svc.startSession('dictation')
|
||||||
|
expect(svc.isActive).toBe(true) // PREPARING 상태
|
||||||
|
|
||||||
|
await promise
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('accidentalPress', () => {
|
||||||
|
it('700ms 미만 세션은 자동 취소된다', async () => {
|
||||||
|
const svc = getVoiceModeService()
|
||||||
|
let cancelReason: string | null = null
|
||||||
|
|
||||||
|
svc.on('session-cancelled', (payload: { reason: string }) => {
|
||||||
|
cancelReason = payload.reason
|
||||||
|
})
|
||||||
|
|
||||||
|
// 세션 시작 즉시 종료 (700ms 미만)
|
||||||
|
await svc.startSession('dictation')
|
||||||
|
await svc.stopSession()
|
||||||
|
|
||||||
|
expect(cancelReason).toBe('too-short')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('cancelSession', () => {
|
||||||
|
it('user 취소로 세션을 종료한다', async () => {
|
||||||
|
const svc = getVoiceModeService()
|
||||||
|
let cancelReason: string | null = null
|
||||||
|
|
||||||
|
svc.on('session-cancelled', (payload: { reason: string }) => {
|
||||||
|
cancelReason = payload.reason
|
||||||
|
})
|
||||||
|
|
||||||
|
await svc.startSession('dictation')
|
||||||
|
svc.cancelSession()
|
||||||
|
|
||||||
|
expect(cancelReason).toBe('user')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getState', () => {
|
||||||
|
it('현재 상태를 VoiceState 형태로 반환한다', () => {
|
||||||
|
const svc = getVoiceModeService()
|
||||||
|
const state = svc.getState()
|
||||||
|
|
||||||
|
expect(state).toHaveProperty('recognitionState')
|
||||||
|
expect(state).toHaveProperty('audioState')
|
||||||
|
expect(state).toHaveProperty('mode')
|
||||||
|
expect(state).toHaveProperty('sessionId')
|
||||||
|
expect(state).toHaveProperty('recordingStartedAt')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('터미널 상태', () => {
|
||||||
|
it('cancelSession 후 _resetToIdle의 200ms 딜레이 후 IDLE로 전이한다', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const svc = getVoiceModeService()
|
||||||
|
|
||||||
|
await svc.startSession('dictation')
|
||||||
|
svc.cancelSession()
|
||||||
|
|
||||||
|
// 200ms 딜레이로 IDLE 전이 예약됨
|
||||||
|
vi.advanceTimersByTime(250)
|
||||||
|
|
||||||
|
const state = svc.getState()
|
||||||
|
expect(state.recognitionState).toBe(RecognitionState.IDLE)
|
||||||
|
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TIMING 상수', () => {
|
||||||
|
it('핵심 타이밍 값이 Speakly 패턴과 일치한다', () => {
|
||||||
|
expect(TIMING.MIN_AUDIO_DURATION).toBe(700)
|
||||||
|
expect(TIMING.DOUBLE_PRESS_DURATION).toBe(300)
|
||||||
|
expect(TIMING.POST_RECORDING_WAIT).toBe(4000)
|
||||||
|
expect(TIMING.POST_RECORDING_WAIT_BUFFERED).toBe(6000)
|
||||||
|
expect(TIMING.ABSOLUTE_MAX_WAIT).toBe(120000)
|
||||||
|
expect(TIMING.AUDIO_LEVEL_INTERVAL).toBe(100)
|
||||||
|
})
|
||||||
|
})
|
||||||
61
tests/setup.ts
Normal file
61
tests/setup.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// tests/setup.ts
|
||||||
|
// vitest 글로벌 셋업: electron 모듈 모킹
|
||||||
|
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
// electron 모듈 모킹 — 테스트에서 electron import 시 에러 방지
|
||||||
|
vi.mock('electron', () => ({
|
||||||
|
app: {
|
||||||
|
getPath: vi.fn(() => ':memory:'),
|
||||||
|
getVersion: vi.fn(() => '1.0.0'),
|
||||||
|
quit: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
whenReady: vi.fn(() => Promise.resolve())
|
||||||
|
},
|
||||||
|
ipcMain: {
|
||||||
|
handle: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
removeHandler: vi.fn()
|
||||||
|
},
|
||||||
|
BrowserWindow: vi.fn(),
|
||||||
|
Tray: vi.fn(),
|
||||||
|
Menu: vi.fn(),
|
||||||
|
nativeImage: {
|
||||||
|
createFromPath: vi.fn()
|
||||||
|
},
|
||||||
|
dialog: {
|
||||||
|
showErrorBox: vi.fn()
|
||||||
|
},
|
||||||
|
screen: {
|
||||||
|
getPrimaryDisplay: vi.fn(() => ({
|
||||||
|
workArea: { x: 0, y: 0, width: 1920, height: 1080 }
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
clipboard: {
|
||||||
|
readText: vi.fn(() => ''),
|
||||||
|
writeText: vi.fn(),
|
||||||
|
readHTML: vi.fn(() => ''),
|
||||||
|
readRTF: vi.fn(() => ''),
|
||||||
|
readImage: vi.fn(() => ({ isEmpty: () => true, toPNG: () => Buffer.alloc(0) })),
|
||||||
|
write: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// electron-log 모킹
|
||||||
|
vi.mock('electron-log', () => {
|
||||||
|
const noop = vi.fn()
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
info: noop,
|
||||||
|
warn: noop,
|
||||||
|
error: noop,
|
||||||
|
debug: noop,
|
||||||
|
create: () => ({
|
||||||
|
info: noop,
|
||||||
|
warn: noop,
|
||||||
|
error: noop,
|
||||||
|
debug: noop
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
75
tests/shared/errors.test.ts
Normal file
75
tests/shared/errors.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
// tests/shared/errors.test.ts
|
||||||
|
// D3ROError + IPCResult 헬퍼 테스트
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { D3ROError, ErrorCode, ipcSuccess, ipcError } from '../../src/shared/errors'
|
||||||
|
|
||||||
|
describe('D3ROError', () => {
|
||||||
|
it('code, message, details를 올바르게 설정한다', () => {
|
||||||
|
const err = new D3ROError(ErrorCode.STTModelNotFound, 'Model not found', { modelId: 'large' })
|
||||||
|
|
||||||
|
expect(err.code).toBe(ErrorCode.STTModelNotFound)
|
||||||
|
expect(err.message).toBe('Model not found')
|
||||||
|
expect(err.details).toEqual({ modelId: 'large' })
|
||||||
|
expect(err.name).toBe('D3ROError')
|
||||||
|
expect(err instanceof Error).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toJSON으로 직렬화할 수 있다', () => {
|
||||||
|
const err = new D3ROError(ErrorCode.LLMServerUnreachable, 'Server down')
|
||||||
|
const json = err.toJSON()
|
||||||
|
|
||||||
|
expect(json.code).toBe(300)
|
||||||
|
expect(json.message).toBe('Server down')
|
||||||
|
expect(json.details).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fromJSON으로 역직렬화할 수 있다', () => {
|
||||||
|
const json = { code: ErrorCode.AudioDeviceNotFound, message: 'No mic' }
|
||||||
|
const err = D3ROError.fromJSON(json)
|
||||||
|
|
||||||
|
expect(err).toBeInstanceOf(D3ROError)
|
||||||
|
expect(err.code).toBe(ErrorCode.AudioDeviceNotFound)
|
||||||
|
expect(err.message).toBe('No mic')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('IPCResult helpers', () => {
|
||||||
|
it('ipcSuccess는 success: true + data를 반환한다', () => {
|
||||||
|
const result = ipcSuccess({ value: 42 })
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data).toEqual({ value: 42 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ipcError는 success: false + error를 반환한다', () => {
|
||||||
|
const result = ipcError(ErrorCode.DBQueryFailed, 'Query failed', { table: 'history' })
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.code).toBe(ErrorCode.DBQueryFailed)
|
||||||
|
expect(result.error.message).toBe('Query failed')
|
||||||
|
expect(result.error.details).toEqual({ table: 'history' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ErrorCode', () => {
|
||||||
|
it('에러 코드 범위가 올바르다', () => {
|
||||||
|
// STT: 100-199
|
||||||
|
expect(ErrorCode.STTEngineNotInstalled).toBe(100)
|
||||||
|
expect(ErrorCode.STTGPUNotAvailable).toBe(140)
|
||||||
|
|
||||||
|
// LLM: 300-399
|
||||||
|
expect(ErrorCode.LLMServerUnreachable).toBe(300)
|
||||||
|
expect(ErrorCode.LLMPromptTooLong).toBe(331)
|
||||||
|
|
||||||
|
// Audio: 400-499
|
||||||
|
expect(ErrorCode.AudioDeviceNotFound).toBe(400)
|
||||||
|
|
||||||
|
// System: 900-999
|
||||||
|
expect(ErrorCode.UnknownError).toBe(999)
|
||||||
|
})
|
||||||
|
})
|
||||||
23
vitest.config.ts
Normal file
23
vitest.config.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['tests/**/*.test.ts'],
|
||||||
|
exclude: ['tests/e2e/**'],
|
||||||
|
setupFiles: ['tests/setup.ts'],
|
||||||
|
testTimeout: 10000,
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
include: ['src/main/services/**'],
|
||||||
|
exclude: ['src/main/services/index.ts']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@shared': resolve(__dirname, 'src/shared')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue