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: {