diff --git a/apps/desktop/build/icon.ico b/apps/desktop/build/icon.ico new file mode 100644 index 0000000..c0a730a Binary files /dev/null and b/apps/desktop/build/icon.ico differ diff --git a/apps/desktop/build/icon.png b/apps/desktop/build/icon.png new file mode 100644 index 0000000..9b10f36 Binary files /dev/null and b/apps/desktop/build/icon.png differ diff --git a/apps/desktop/build/icon.svg b/apps/desktop/build/icon.svg new file mode 100644 index 0000000..86733a3 --- /dev/null +++ b/apps/desktop/build/icon.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index ad392a9..d3ef920 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -43,6 +43,7 @@ asarUnpack: # electron 기본 아이콘 사용(아이콘 준비되면 win.icon 라인 추가). # ──────────────────────────────────────────────────────────────────── win: + icon: build/icon.ico target: - target: nsis arch: @@ -52,6 +53,8 @@ win: # macOS # ──────────────────────────────────────────────────────────────────── mac: + # icns는 electron-builder가 build/icon.png(1024)에서 자동 생성 + icon: build/icon.png category: public.app-category.productivity # 파일명 공백 금지 — GitLab Generic Package Registry가 공백을 불허해 # latest-mac.yml의 url과 레지스트리 업로드명이 어긋나면 업데이트 다운로드 404. @@ -98,6 +101,12 @@ linux: # 공통 리소스 번들 # ──────────────────────────────────────────────────────────────────── extraResources: + # 앱 아이콘 (트레이/런타임용 — paths.ts getAppIconPath) + - from: build/icon.ico + to: icons/icon.ico + - from: build/icon.png + to: icons/icon.png + # 효과음 (플랫폼 공통) - from: resources/sounds/ to: sounds/ diff --git a/apps/desktop/scripts/generate-icons.mjs b/apps/desktop/scripts/generate-icons.mjs new file mode 100644 index 0000000..c4d7701 --- /dev/null +++ b/apps/desktop/scripts/generate-icons.mjs @@ -0,0 +1,58 @@ +// scripts/generate-icons.mjs +// build/icon.svg → build/icon.png(1024) + build/icon.ico(멀티사이즈) +// 사용: node apps/desktop/scripts/generate-icons.mjs +// 의존: sharp (모노레포 루트 node_modules), Pillow는 불필요(ico도 sharp 산출 PNG로 조립) + +import sharp from 'sharp' +import { writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const buildDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'build') +const SVG = join(buildDir, 'icon.svg') + +/** ICO 컨테이너 조립 — PNG 임베드 방식 (Vista+ 표준, 모든 현대 Windows 지원) */ +function buildIco(pngs) { + const count = pngs.length + const header = Buffer.alloc(6) + header.writeUInt16LE(0, 0) // reserved + header.writeUInt16LE(1, 2) // type: icon + header.writeUInt16LE(count, 4) + + const entries = [] + let offset = 6 + 16 * count + for (const { size, buf } of pngs) { + const e = Buffer.alloc(16) + e.writeUInt8(size >= 256 ? 0 : size, 0) // width (0=256) + e.writeUInt8(size >= 256 ? 0 : size, 1) // height + e.writeUInt8(0, 2) // palette + e.writeUInt8(0, 3) // reserved + e.writeUInt16LE(1, 4) // planes + e.writeUInt16LE(32, 6) // bpp + e.writeUInt32LE(buf.length, 8) + e.writeUInt32LE(offset, 12) + entries.push(e) + offset += buf.length + } + return Buffer.concat([header, ...entries, ...pngs.map((p) => p.buf)]) +} + +async function render(size) { + return sharp(SVG, { density: (72 * size) / 1024 }) + .resize(size, size) + .png() + .toBuffer() +} + +// 1) 마스터 PNG (electron-builder가 mac icns/linux 아이콘 자동 생성에 사용) +writeFileSync(join(buildDir, 'icon.png'), await render(1024)) +console.log('build/icon.png (1024)') + +// 2) Windows ICO — 표준 사이즈 세트 +const icoSizes = [256, 128, 64, 48, 32, 16] +const pngs = [] +for (const size of icoSizes) { + pngs.push({ size, buf: await render(size) }) +} +writeFileSync(join(buildDir, 'icon.ico'), buildIco(pngs)) +console.log(`build/icon.ico (${icoSizes.join('/')})`) diff --git a/apps/desktop/src/main/ipc/audio-handlers.ts b/apps/desktop/src/main/ipc/audio-handlers.ts index d675f26..0fdd22a 100644 --- a/apps/desktop/src/main/ipc/audio-handlers.ts +++ b/apps/desktop/src/main/ipc/audio-handlers.ts @@ -71,12 +71,13 @@ export function registerAudioHandlers(): void { } service.on('audio-data', testAudioHandler) - // 100ms 간격으로 레벨을 렌더러에 전송 + // 100ms 간격으로 레벨을 렌더러에 전송. + // level 0은 "무음/초기화 중"일 뿐 종료가 아님 — 종료는 done:true로만 알린다. testLevelInterval = setInterval(() => { const level = testLastRms > 0 ? Math.min(1.0, Math.pow(testLastRms, 0.28)) : 0 const win = getMainWindow() if (win && !win.isDestroyed()) { - win.webContents.send('audio:testLevel', { level }) + win.webContents.send(IPC_CHANNELS.AUDIO.TEST_LEVEL, { level, done: false }) } }, 100) @@ -85,7 +86,7 @@ export function registerAudioHandlers(): void { stopAudioTest() const win = getMainWindow() if (win && !win.isDestroyed()) { - win.webContents.send('audio:testLevel', { level: 0 }) + win.webContents.send(IPC_CHANNELS.AUDIO.TEST_LEVEL, { level: 0, done: true }) } }, 5000) @@ -98,4 +99,14 @@ export function registerAudioHandlers(): void { ) } }) + + // 테스트 수동 중지 (STOP 버튼) + ipcMain.handle(IPC_CHANNELS.AUDIO.STOP_TEST, async () => { + stopAudioTest() + const win = getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send(IPC_CHANNELS.AUDIO.TEST_LEVEL, { level: 0, done: true }) + } + return ipcSuccess(undefined) + }) } diff --git a/apps/desktop/src/main/utils/paths.ts b/apps/desktop/src/main/utils/paths.ts index 08c9fce..f13a403 100644 --- a/apps/desktop/src/main/utils/paths.ts +++ b/apps/desktop/src/main/utils/paths.ts @@ -151,3 +151,17 @@ export function getUserDataPath(): string { export function getWhisperModelsDir(): string { return path.join(app.getPath('userData'), 'whisper-models') } + +/** + * 앱 아이콘 경로 (트레이/윈도우용). + * - production: extraResources로 번들된 icons/ (win은 멀티사이즈 ico — DPI 대응) + * - dev: build/icon.png + * 존재하지 않으면 null (호출측에서 폴백 처리). + */ +export function getAppIconPath(): string | null { + const filename = process.platform === 'win32' ? 'icon.ico' : 'icon.png' + const candidate = isPackaged() + ? path.join(process.resourcesPath, 'icons', filename) + : path.join(app.getAppPath(), 'build', filename) + return existsSync(candidate) ? candidate : null +} diff --git a/apps/desktop/src/main/windows/TrayManager.ts b/apps/desktop/src/main/windows/TrayManager.ts index 2a11ceb..68dacb2 100644 --- a/apps/desktop/src/main/windows/TrayManager.ts +++ b/apps/desktop/src/main/windows/TrayManager.ts @@ -1,9 +1,9 @@ // src/main/windows/TrayManager.ts import { Tray, Menu, app, nativeImage } from 'electron' -import { join } from 'path' import { getMainWindow } from './WindowManager' import { getLogger } from '../services/LoggerService' +import { getAppIconPath } from '../utils/paths' import { setIsQuitting } from '../lifecycle' const logger = getLogger('TrayManager') @@ -11,8 +11,9 @@ const logger = getLogger('TrayManager') let tray: Tray | null = null export function createTray(): void { - // 16x16 빈 아이콘 생성 (리소스 아이콘이 없을 때 폴백) - const icon = nativeImage.createEmpty() + // 앱 아이콘 (win=멀티사이즈 ico, 그 외=png). 없으면 빈 아이콘 폴백. + const iconPath = getAppIconPath() + const icon = iconPath ? nativeImage.createFromPath(iconPath) : nativeImage.createEmpty() tray = new Tray(icon) const contextMenu = Menu.buildFromTemplate([ diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 348b0ba..c84593c 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -203,10 +203,11 @@ const electronAPI = { invoke(IPC_CHANNELS.AUDIO.SET_SELECTED_DEVICE, params), testDevice: (params: TestDeviceParams) => invoke(IPC_CHANNELS.AUDIO.TEST_DEVICE, params), + stopTest: () => invoke(IPC_CHANNELS.AUDIO.STOP_TEST), onDeviceChanged: (cb: (e: AudioDeviceChangedEvent) => void): Unsubscribe => on(IPC_CHANNELS.AUDIO.DEVICE_CHANGED, cb), - onTestLevel: (cb: (e: { level: number }) => void): Unsubscribe => - on('audio:testLevel', cb), + onTestLevel: (cb: (e: { level: number; done: boolean }) => void): Unsubscribe => + on(IPC_CHANNELS.AUDIO.TEST_LEVEL, cb), }, // ── Config ───────────────────────────────────────────── diff --git a/apps/desktop/src/renderer/components/SettingsModal.tsx b/apps/desktop/src/renderer/components/SettingsModal.tsx index 9eff66f..d1be6c3 100644 --- a/apps/desktop/src/renderer/components/SettingsModal.tsx +++ b/apps/desktop/src/renderer/components/SettingsModal.tsx @@ -547,19 +547,28 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac size="small" onClick={async () => { if (micTesting) { + // 메인 프로세스 캡처까지 실제로 중지 — done 이벤트가 구독 해제를 마무리 + await window.electronAPI.audio.stopTest() setMicTesting(false) setMicLevel(0) } else { setMicTesting(true) setMicLevel(0) + // level 0은 무음/초기화 중일 뿐 — 종료 판정은 done 플래그로만 const unsub = window.electronAPI.audio.onTestLevel((e) => { setMicLevel(e.level) - if (e.level === 0) { + if (e.done) { setMicTesting(false) + setMicLevel(0) unsub() } }) - await window.electronAPI.audio.testDevice({ deviceId: 'default' }) + const result = await window.electronAPI.audio.testDevice({ deviceId: 'default' }) + if (!result.success) { + setMicTesting(false) + setMicLevel(0) + unsub() + } } }} sx={{ fontFamily: d3roFontMono, fontSize: '11px', minWidth: 80 }} diff --git a/memory/project_status.md b/memory/project_status.md index 356d678..bed5f2a 100644 --- a/memory/project_status.md +++ b/memory/project_status.md @@ -46,6 +46,12 @@ - **G: 온보딩 UX 수정 (v0.1.4-alpha) — 릴리스 완료(#69756 전 잡 그린), 차등 24MB로 설치 앱 전달 확인**: ①total 없는 상태(pulling/verifying/writing manifest)에서 인디터미넌트 바 + 친화적 문구(i18n 3키), ②중복 클릭 가드(runningRef), ③LocalLLMService.pullModel 동시 호출 dedupe(in-flight map), ④STT /download 409 시 기존 진행 폴링 합류 - 참고: 온보딩 STT 단계는 사용자의 기존 설정 모델(medium)을 존중해 다운로드함 — turbo 강제 아님 (설계 의도) +### H: 앱 아이콘 + 트레이 + 마이크 테스트 버튼 수정 (v0.1.5-alpha) +- **앱 아이콘 신규**: build/icon.svg(마스터, d3roPalette SSOT — 메탈 섀시+다크 글래스+오렌지 웨이브 5바 cos 분포) → icon.png(1024)+icon.ico(6사이즈). `scripts/generate-icons.mjs`(sharp, ico 컨테이너 직접 조립). electron-builder win.icon/mac.icon 연결 + extraResources icons/ +- **트레이 아이콘 버그**: createEmpty()로 빈 이미지였음 → getAppIconPath()(paths.ts, win=ico/기타=png, dev는 build/) 사용 +- **마이크 테스트 버튼 조기 종료 버그** (mic-test-debug 에이전트 진단): 렌더러가 초기 무음 `level:0`을 종료 신호로 오인 → 즉시 구독 해제. 수정: TEST_LEVEL 이벤트에 `done` 플래그(SSOT 채널 `AUDIO.TEST_LEVEL`/`STOP_TEST` 신설, 하드코딩 'audio:testLevel' 제거), 렌더러는 done으로만 종료, STOP 버튼이 실제로 메인 캡처 중지, testDevice 실패 시 상태 롤백 +- 잔여(경미): 설계서 02의 audio:testDevice 명세는 "짧은 캡처 후 결과 반환"인데 실제는 스트리밍 — 명세 갱신 후보. AudioCaptureService.testCapture() dead code 정리 후보 + #### 남은 것 1. 설치 디렉토리명 `@d3rodesktop` 정리 (electron-builder가 package name에서 유도 — 다음 릴리스에서 명시) 2. VIVEN runner의 signtool 서명이 어떤 인증서인지 확인 (twentyoz 조직 인증서면 유지) diff --git a/packages/core/src/ipc-channels.ts b/packages/core/src/ipc-channels.ts index 3539f95..762b244 100644 --- a/packages/core/src/ipc-channels.ts +++ b/packages/core/src/ipc-channels.ts @@ -22,8 +22,11 @@ export const IPC_CHANNELS = { GET_SELECTED_DEVICE: 'audio:getSelectedDevice', SET_SELECTED_DEVICE: 'audio:setSelectedDevice', TEST_DEVICE: 'audio:testDevice', + STOP_TEST: 'audio:stopTest', // Main → Renderer events - DEVICE_CHANGED: 'audio:deviceChanged' + DEVICE_CHANGED: 'audio:deviceChanged', + /** 마이크 테스트 레벨 스트림 — done=true가 테스트 종료 신호 (level 0은 무음일 뿐) */ + TEST_LEVEL: 'audio:testLevel' }, STT: {