feat(icon)+fix(audio): 앱 아이콘 적용 + 트레이 아이콘 + 마이크 테스트 조기 종료 수정
Some checks failed
Build macOS / Build & Package (macOS) (push) Failing after 5s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s

- 앱 아이콘: icon.svg 마스터(d3ro 브랜드 — 메탈 섀시+오렌지 웨이브) → png/ico
  생성 스크립트(generate-icons.mjs, sharp) + electron-builder win/mac 연결
- 트레이: createEmpty() 빈 아이콘 → 실제 앱 아이콘 (getAppIconPath)
- 마이크 테스트: 초기 무음 level:0을 종료로 오인하던 조기 종료 버그 —
  AUDIO.TEST_LEVEL에 done 플래그 신설(SSOT), STOP 버튼이 실제 캡처 중지,
  testDevice 실패 시 상태 롤백
This commit is contained in:
Yun Chan 2026-07-21 19:00:23 +09:00
parent 26b3fd1f63
commit cbed451209
12 changed files with 184 additions and 11 deletions

BIN
apps/desktop/build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
apps/desktop/build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

View file

@ -0,0 +1,61 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<!-- D3RO Voice 앱 아이콘 — 정밀 계측기 정체성: 메탈 섀시 + 다크 글래스 + 앰버 포스포 웨이브
색상 SSOT: d3roPalette (accent #f25b29 / light #ff7a4d / chassis #242528 / crtGlass #050605) -->
<defs>
<linearGradient id="chassis" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#35363a"/>
<stop offset="0.5" stop-color="#242528"/>
<stop offset="1" stop-color="#18191b"/>
</linearGradient>
<linearGradient id="glass" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#101113"/>
<stop offset="1" stop-color="#050605"/>
</linearGradient>
<linearGradient id="bar" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ff7a4d"/>
<stop offset="1" stop-color="#f25b29"/>
</linearGradient>
<linearGradient id="topSheen" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.10"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<filter id="glow" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="30"/>
</filter>
<clipPath id="glassClip">
<rect x="88" y="88" width="848" height="848" rx="176"/>
</clipPath>
</defs>
<!-- 메탈 섀시 -->
<rect x="16" y="16" width="992" height="992" rx="230" fill="url(#chassis)"/>
<!-- 섀시 상단 하이라이트 (inset 촉감) -->
<rect x="20" y="20" width="984" height="984" rx="226" fill="none"
stroke="#ffffff" stroke-opacity="0.10" stroke-width="3"/>
<!-- 다크 글래스 패널 -->
<rect x="88" y="88" width="848" height="848" rx="176" fill="url(#glass)"/>
<rect x="88" y="88" width="848" height="848" rx="176" fill="none"
stroke="#000000" stroke-opacity="0.55" stroke-width="6"/>
<!-- 포스포 글로우 (블러 레이어) -->
<g filter="url(#glow)" opacity="0.55" clip-path="url(#glassClip)">
<rect x="207" y="424.5" width="74" height="175" rx="37" fill="#f25b29"/>
<rect x="341" y="355.5" width="74" height="313" rx="37" fill="#f25b29"/>
<rect x="475" y="282" width="74" height="460" rx="37" fill="#f25b29"/>
<rect x="609" y="355.5" width="74" height="313" rx="37" fill="#f25b29"/>
<rect x="743" y="424.5" width="74" height="175" rx="37" fill="#f25b29"/>
</g>
<!-- 웨이브 바 (cos 분포 5개) -->
<g>
<rect x="207" y="424.5" width="74" height="175" rx="37" fill="url(#bar)"/>
<rect x="341" y="355.5" width="74" height="313" rx="37" fill="url(#bar)"/>
<rect x="475" y="282" width="74" height="460" rx="37" fill="url(#bar)"/>
<rect x="609" y="355.5" width="74" height="313" rx="37" fill="url(#bar)"/>
<rect x="743" y="424.5" width="74" height="175" rx="37" fill="url(#bar)"/>
</g>
<!-- 글래스 상단 반사 -->
<rect x="88" y="88" width="848" height="300" rx="176" fill="url(#topSheen)" clip-path="url(#glassClip)"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View file

@ -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/

View file

@ -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('/')})`)

View file

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

View file

@ -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
}

View file

@ -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([

View file

@ -203,10 +203,11 @@ const electronAPI = {
invoke<void>(IPC_CHANNELS.AUDIO.SET_SELECTED_DEVICE, params),
testDevice: (params: TestDeviceParams) =>
invoke<TestDeviceResult>(IPC_CHANNELS.AUDIO.TEST_DEVICE, params),
stopTest: () => invoke<void>(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 ─────────────────────────────────────────────

View file

@ -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 }}

View file

@ -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 조직 인증서면 유지)

View file

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