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