diff --git a/.github/workflows/build-mac.yml b/.github/workflows/build-mac.yml
new file mode 100644
index 0000000..e8beb46
--- /dev/null
+++ b/.github/workflows/build-mac.yml
@@ -0,0 +1,95 @@
+name: Build macOS
+
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+ inputs:
+ notarize:
+ description: 'Apple Notarization 활성화'
+ required: false
+ default: 'false'
+ type: choice
+ options:
+ - 'false'
+ - 'true'
+
+jobs:
+ build:
+ name: Build & Package (macOS)
+ runs-on: macos-14 # arm64 (Apple Silicon)
+ timeout-minutes: 60
+
+ strategy:
+ fail-fast: false
+ matrix:
+ arch: [arm64, x64]
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: 'npm'
+
+ - name: Setup Python (sidecar 빌드용)
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Install system deps (sox)
+ run: brew install sox
+
+ - name: Install npm dependencies
+ run: npm install
+
+ - name: Bundle SoX into resources
+ working-directory: apps/desktop
+ run: bash scripts/install-sox.sh
+
+ - name: Install Python dependencies
+ working-directory: apps/desktop
+ run: |
+ pip install pyinstaller
+ pip install -r sidecar/requirements.txt
+
+ - name: Build sidecar (PyInstaller)
+ working-directory: apps/desktop
+ run: python scripts/build-sidecar.py
+
+ - name: Rebuild native modules for Electron
+ run: npx --yes @electron/rebuild@3 --version=33.4.11
+
+ - name: Build renderer/preload/main
+ run: npm run build
+
+ - name: electron-builder dist (mac, ${{ matrix.arch }})
+ working-directory: apps/desktop
+ env:
+ # Apple 서명/공증 (notarize=true일 때만 사용)
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ CSC_LINK: ${{ secrets.MAC_CERT_P12_BASE64 }}
+ CSC_KEY_PASSWORD: ${{ secrets.MAC_CERT_P12_PASSWORD }}
+ NOTARIZE: ${{ inputs.notarize || 'false' }}
+ run: |
+ if [ "$NOTARIZE" = "true" ] && [ -n "$APPLE_ID" ]; then
+ npx electron-builder --mac --${{ matrix.arch }} -c.mac.notarize=true
+ else
+ npx electron-builder --mac --${{ matrix.arch }} -c.mac.notarize=false
+ fi
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: d3ro-voice-mac-${{ matrix.arch }}
+ path: |
+ apps/desktop/release/*.dmg
+ apps/desktop/release/*.zip
+ if-no-files-found: error
+ retention-days: 7
diff --git a/.gitignore b/.gitignore
index 3c0587f..a8cb7bf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,8 @@ release/
# Build
*.tsbuildinfo
build/
+!apps/desktop/build/
+!apps/desktop/build/entitlements.mac.plist
sidecar-dist/
# Bundled binaries (download via scripts)
diff --git a/apps/desktop/build/entitlements.mac.plist b/apps/desktop/build/entitlements.mac.plist
new file mode 100644
index 0000000..834d64e
--- /dev/null
+++ b/apps/desktop/build/entitlements.mac.plist
@@ -0,0 +1,39 @@
+
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+ com.apple.security.cs.disable-library-validation
+
+
+
+ com.apple.security.device.audio-input
+
+
+
+ com.apple.security.device.camera
+
+
+
+ com.apple.security.network.client
+
+
+
+ com.apple.security.automation.apple-events
+
+
+
+ com.apple.security.files.user-selected.read-write
+
+
+
+ com.apple.security.cs.allow-dyld-environment-variables
+
+
+
diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml
index 9bbff6c..ca7c520 100644
--- a/apps/desktop/electron-builder.yml
+++ b/apps/desktop/electron-builder.yml
@@ -16,6 +16,9 @@ asarUnpack:
- "node_modules/uiohook-napi/**"
- "node_modules/@nut-tree-fork/**"
+# ────────────────────────────────────────────────────────────────────
+# Windows
+# ────────────────────────────────────────────────────────────────────
win:
target:
- target: nsis
@@ -32,31 +35,80 @@ nsis:
shortcutName: D3RO Voice
deleteAppDataOnUninstall: false
-# 번들 리소스 (앱 외부, resources/ 디렉토리에 배치)
+# ────────────────────────────────────────────────────────────────────
+# macOS
+# ────────────────────────────────────────────────────────────────────
+mac:
+ category: public.app-category.productivity
+ icon: resources/icons/icon.icns
+ target:
+ - target: dmg
+ arch:
+ - arm64
+ - x64
+ - target: zip
+ arch:
+ - arm64
+ - x64
+ hardenedRuntime: true
+ gatekeeperAssess: false
+ entitlements: build/entitlements.mac.plist
+ entitlementsInherit: build/entitlements.mac.plist
+ # notarize: 환경변수 APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / APPLE_TEAM_ID 설정 시 활성화.
+ # CI/dev 환경에서는 NOTARIZE=false로 스킵 가능.
+ notarize: false
+ extendInfo:
+ NSMicrophoneUsageDescription: "D3RO Voice는 음성 인식을 위해 마이크 접근이 필요합니다."
+ NSCameraUsageDescription: "D3RO Voice는 스크린 컨텍스트 캡처를 위해 화면 녹화 권한이 필요합니다."
+ NSAppleEventsUsageDescription: "D3RO Voice는 활성 앱 정보를 가져오기 위해 다른 앱 제어 권한이 필요합니다."
+ NSSystemAdministrationUsageDescription: "D3RO Voice는 전역 핫키 등록을 위해 접근성 권한이 필요합니다."
+
+dmg:
+ writeUpdateInfo: false
+ contents:
+ - x: 130
+ y: 220
+ - x: 410
+ y: 220
+ type: link
+ path: /Applications
+
+# ────────────────────────────────────────────────────────────────────
+# Linux (참고용)
+# ────────────────────────────────────────────────────────────────────
+linux:
+ target:
+ - AppImage
+ category: Utility
+ icon: resources/icons/icon.png
+
+# ────────────────────────────────────────────────────────────────────
+# 공통 리소스 번들
+# ────────────────────────────────────────────────────────────────────
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:
- "**/*"
+ # SoX 바이너리 (번들되어 있으면 번들, 없으면 시스템 PATH)
+ - from: resources/sox/
+ to: sox/
+ filter:
+ - "**/*"
+
+ # Python sidecar (PyInstaller 빌드 결과)
+ # 빌드 전: python scripts/build-sidecar.py (플랫폼에서 개별 실행)
+ - from: sidecar-dist/sidecar/
+ to: sidecar/
+ filter:
+ - "**/*"
+
npmRebuild: true
diff --git a/apps/desktop/resources/icons/README.md b/apps/desktop/resources/icons/README.md
new file mode 100644
index 0000000..77f7d49
--- /dev/null
+++ b/apps/desktop/resources/icons/README.md
@@ -0,0 +1,51 @@
+# App Icons
+
+이 디렉토리에는 플랫폼별 아이콘 파일이 있어야 합니다.
+
+## 필수 파일
+
+| 플랫폼 | 파일 | 크기 |
+|---|---|---|
+| Windows | `icon.ico` | 256x256 (권장: 16/32/48/64/128/256 멀티 해상도) |
+| macOS | `icon.icns` | 1024x1024 base (자동 생성된 멀티 해상도) |
+| Linux / Tray | `icon.png` | 512x512 (또는 1024x1024) |
+
+## 생성 방법
+
+### 원본 PNG 준비
+1024x1024 PNG 파일을 하나 만든 뒤 변환:
+
+### macOS (권장 — 자동화)
+```bash
+# iconutil 사용 (macOS 기본 제공)
+mkdir icon.iconset
+sips -z 16 16 icon.png --out icon.iconset/icon_16x16.png
+sips -z 32 32 icon.png --out icon.iconset/icon_16x16@2x.png
+sips -z 32 32 icon.png --out icon.iconset/icon_32x32.png
+sips -z 64 64 icon.png --out icon.iconset/icon_32x32@2x.png
+sips -z 128 128 icon.png --out icon.iconset/icon_128x128.png
+sips -z 256 256 icon.png --out icon.iconset/icon_128x128@2x.png
+sips -z 256 256 icon.png --out icon.iconset/icon_256x256.png
+sips -z 512 512 icon.png --out icon.iconset/icon_256x256@2x.png
+sips -z 512 512 icon.png --out icon.iconset/icon_512x512.png
+sips -z 1024 1024 icon.png --out icon.iconset/icon_512x512@2x.png
+iconutil -c icns icon.iconset
+rm -r icon.iconset
+```
+
+### Windows (ImageMagick)
+```bash
+magick icon.png -define icon:auto-resize=256,128,64,48,32,16 icon.ico
+```
+
+### electron-icon-builder (자동)
+```bash
+npx electron-icon-builder --input=icon.png --output=resources/icons
+```
+
+## 현재 상태
+
+실제 아이콘 파일은 이 레포에 **커밋되지 않습니다** (디자인 에셋 분리).
+앱을 직접 빌드하려면 위 방식으로 `icon.ico`, `icon.icns`, `icon.png`를 이 디렉토리에 배치하세요.
+
+electron-builder는 아이콘이 없어도 dev 실행은 가능하지만, dist 빌드는 아이콘이 필요합니다.
diff --git a/apps/desktop/scripts/build-sidecar.py b/apps/desktop/scripts/build-sidecar.py
index 9aac52f..3cd39a2 100644
--- a/apps/desktop/scripts/build-sidecar.py
+++ b/apps/desktop/scripts/build-sidecar.py
@@ -1,15 +1,22 @@
"""
scripts/build-sidecar.py
-faster-whisper sidecar를 PyInstaller로 빌드한다.
+faster-whisper sidecar를 PyInstaller로 빌드한다. (크로스 플랫폼)
사용법:
pip install pyinstaller
+ pip install -r sidecar/requirements.txt
python scripts/build-sidecar.py
출력:
- sidecar-dist/sidecar.exe (단일 디렉토리 모드)
+ sidecar-dist/sidecar/sidecar(.exe) (onedir 모드)
+
+플랫폼:
+ Windows → --noconsole (콘솔 창 숨김)
+ macOS → --windowed (.app 번들 형식은 여기서는 미사용, onedir만)
+ Linux → 콘솔 옵션 없음
"""
+import platform
import subprocess
import sys
import shutil
@@ -20,10 +27,13 @@ SIDECAR_DIR = PROJECT_ROOT / "sidecar"
MAIN_PY = SIDECAR_DIR / "main.py"
OUTPUT_DIR = PROJECT_ROOT / "sidecar-dist"
+IS_WINDOWS = platform.system() == "Windows"
+IS_MACOS = platform.system() == "Darwin"
+EXE_SUFFIX = ".exe" if IS_WINDOWS else ""
+
def check_prerequisites():
"""필수 도구가 설치되어 있는지 확인한다."""
- # PyInstaller
try:
import PyInstaller # noqa: F401
except ImportError:
@@ -31,7 +41,6 @@ def check_prerequisites():
print("실행: pip install pyinstaller")
sys.exit(1)
- # faster-whisper
try:
import faster_whisper # noqa: F401
except ImportError:
@@ -47,24 +56,22 @@ def check_prerequisites():
def build():
"""PyInstaller로 sidecar를 빌드한다."""
print("=" * 60)
- print("D3RO-VOICE Sidecar 빌드 시작")
+ print(f"D3RO-VOICE Sidecar 빌드 시작 ({platform.system()} {platform.machine()})")
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 imports
"--hidden-import", "faster_whisper",
"--hidden-import", "ctranslate2",
"--hidden-import", "huggingface_hub",
@@ -78,11 +85,20 @@ def build():
"--hidden-import", "uvicorn.lifespan",
"--hidden-import", "uvicorn.lifespan.on",
"--hidden-import", "uvicorn.lifespan.off",
- # 콘솔 없음 (Windows)
- "--noconsole",
- str(MAIN_PY),
]
+ # 플랫폼별 콘솔 옵션
+ if IS_WINDOWS:
+ cmd.append("--noconsole")
+ elif IS_MACOS:
+ # macOS에서 --windowed는 .app 번들 생성을 의미.
+ # onedir 모드로도 .app이 만들어질 수 있어서 명시적으로는 쓰지 않음.
+ # electron-builder가 extraResources로 Binary만 포함하므로 기본 콘솔 형태 유지.
+ pass
+ # Linux: 옵션 없음
+
+ cmd.append(str(MAIN_PY))
+
print(f"\n실행 명령:\n {' '.join(cmd)}\n")
result = subprocess.run(cmd, cwd=str(PROJECT_ROOT))
@@ -92,27 +108,29 @@ def build():
sys.exit(1)
# 빌드 결과 확인
- exe_path = OUTPUT_DIR / "sidecar" / "sidecar.exe"
+ exe_path = OUTPUT_DIR / "sidecar" / f"sidecar{EXE_SUFFIX}"
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_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}")
+ # onedir 모드에서는 디렉토리 내부에 바이너리가 있음
+ pattern = "*.exe" if IS_WINDOWS else "sidecar"
+ for item in OUTPUT_DIR.rglob(pattern):
+ print(f" 발견: {item}")
print("\n빌드 완료!")
- print(f"electron-builder에서 이 경로를 extraResources로 지정하세요:")
- print(f" from: sidecar-dist/sidecar/")
- print(f" to: sidecar/")
+ print("electron-builder에서 이 경로를 extraResources로 지정하세요:")
+ print(" from: sidecar-dist/sidecar/")
+ print(" to: sidecar/")
if __name__ == "__main__":
diff --git a/apps/desktop/scripts/install-sox.sh b/apps/desktop/scripts/install-sox.sh
new file mode 100644
index 0000000..ad7d3dc
--- /dev/null
+++ b/apps/desktop/scripts/install-sox.sh
@@ -0,0 +1,92 @@
+#!/usr/bin/env bash
+# scripts/install-sox.sh
+#
+# Mac/Linux에서 SoX 바이너리를 apps/desktop/resources/sox/로 복사한다.
+# electron-builder의 extraResources가 이 디렉토리를 번들 대상으로 잡는다.
+#
+# 사용법:
+# # 먼저 시스템에 SoX 설치
+# brew install sox # macOS
+# sudo apt install sox # Ubuntu/Debian
+#
+# # 그 다음 이 스크립트 실행 (apps/desktop 기준)
+# ./scripts/install-sox.sh
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+DESKTOP_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+TARGET_DIR="${DESKTOP_DIR}/resources/sox"
+
+OS="$(uname -s)"
+case "$OS" in
+ Darwin*) PLATFORM="macos" ;;
+ Linux*) PLATFORM="linux" ;;
+ *)
+ echo "Unsupported OS: $OS — Windows는 scripts/download-sox.ps1를 사용하세요."
+ exit 1
+ ;;
+esac
+
+echo "=== D3RO Voice: SoX 바이너리 번들 (${PLATFORM}) ==="
+
+# 시스템 sox/rec 경로 찾기
+SOX_BIN="$(command -v sox || true)"
+REC_BIN="$(command -v rec || true)"
+
+if [ -z "$SOX_BIN" ]; then
+ echo "ERROR: 시스템에 sox가 설치되어 있지 않습니다."
+ if [ "$PLATFORM" = "macos" ]; then
+ echo " macOS: brew install sox"
+ else
+ echo " Linux: sudo apt install sox 또는 sudo yum install sox"
+ fi
+ exit 1
+fi
+
+echo "sox: $SOX_BIN"
+if [ -n "$REC_BIN" ]; then
+ echo "rec: $REC_BIN"
+fi
+
+# 타겟 디렉토리 준비
+mkdir -p "${TARGET_DIR}"
+echo "target: ${TARGET_DIR}"
+
+# 바이너리 복사
+cp "$SOX_BIN" "${TARGET_DIR}/sox"
+chmod +x "${TARGET_DIR}/sox"
+echo "복사: sox → ${TARGET_DIR}/sox"
+
+if [ -n "$REC_BIN" ]; then
+ cp "$REC_BIN" "${TARGET_DIR}/rec"
+ chmod +x "${TARGET_DIR}/rec"
+ echo "복사: rec → ${TARGET_DIR}/rec"
+fi
+
+# 동적 라이브러리 의존성 복사 (macOS만)
+if [ "$PLATFORM" = "macos" ]; then
+ echo "동적 라이브러리 의존성 복사 중..."
+
+ # otool로 의존성 확인 후 SoX와 함께 묶음
+ LIBS=$(otool -L "$SOX_BIN" | awk 'NR>1 && $1 !~ /^\/System\// && $1 !~ /^\/usr\/lib\// {print $1}')
+
+ for lib in $LIBS; do
+ if [ -f "$lib" ]; then
+ lib_name="$(basename "$lib")"
+ cp "$lib" "${TARGET_DIR}/${lib_name}"
+ chmod +w "${TARGET_DIR}/${lib_name}"
+ echo " lib: ${lib_name}"
+
+ # rpath를 @loader_path로 변경 (앱 번들 내부에서도 동작하도록)
+ install_name_tool -change "$lib" "@loader_path/${lib_name}" "${TARGET_DIR}/sox" 2>/dev/null || true
+ if [ -n "$REC_BIN" ]; then
+ install_name_tool -change "$lib" "@loader_path/${lib_name}" "${TARGET_DIR}/rec" 2>/dev/null || true
+ fi
+ fi
+ done
+fi
+
+echo ""
+echo "완료. electron-builder가 이 디렉토리를 extraResources로 번들합니다."
+ls -lh "${TARGET_DIR}/"
diff --git a/apps/desktop/src/main/services/ScreenContextService.ts b/apps/desktop/src/main/services/ScreenContextService.ts
index 4d52f36..3aa25dc 100644
--- a/apps/desktop/src/main/services/ScreenContextService.ts
+++ b/apps/desktop/src/main/services/ScreenContextService.ts
@@ -179,22 +179,38 @@ class ScreenContextService {
// ── 내부 구현 ─────────────────────────────────────────
/**
- * Windows에서 활성 윈도우의 프로세스명과 타이틀을 가져온다.
- * PowerShell을 사용하여 GetForegroundWindow → 프로세스 정보 조회.
+ * 활성 윈도우의 프로세스명과 타이틀을 가져온다.
+ * - Windows: PowerShell + user32.dll
+ * - macOS: osascript (System Events)
+ * - Linux: 미지원 (null 반환)
+ *
+ * 참고: macOS는 Accessibility 권한이 필요하다. 첫 호출 시 시스템이
+ * 권한 요청 다이얼로그를 띄운다. 사용자가 거부하면 { null, null } 반환.
*/
private async _getActiveWindowInfo(): Promise<{
appName: string | null
windowTitle: string | null
}> {
- if (process.platform !== 'win32') {
- return { appName: null, windowTitle: null }
+ if (process.platform === 'win32') {
+ return this._getActiveWindowInfoWin32()
}
+ if (process.platform === 'darwin') {
+ return this._getActiveWindowInfoDarwin()
+ }
+ return { appName: null, windowTitle: null }
+ }
+ /**
+ * Windows: PowerShell + user32.dll로 활성 윈도우 조회.
+ */
+ private async _getActiveWindowInfoWin32(): Promise<{
+ appName: string | null
+ windowTitle: string | null
+ }> {
const { execFile } = await import('child_process')
const { promisify } = await import('util')
const execFileAsync = promisify(execFile)
- // PowerShell 스크립트: GetForegroundWindow의 프로세스명과 윈도우 타이틀
const psScript = `
Add-Type @"
using System;
@@ -238,6 +254,53 @@ $title = $sb.ToString()
}
}
+ /**
+ * macOS: osascript (AppleScript)로 frontmost process와 윈도우 타이틀 조회.
+ * Accessibility 권한 필요.
+ */
+ private async _getActiveWindowInfoDarwin(): Promise<{
+ appName: string | null
+ windowTitle: string | null
+ }> {
+ const { execFile } = await import('child_process')
+ const { promisify } = await import('util')
+ const execFileAsync = promisify(execFile)
+
+ // AppleScript: 프로세스명과 앞 윈도우 타이틀을 2줄로 반환.
+ // 윈도우가 없는 앱도 있으므로 try-fallback.
+ const script = `
+try
+ tell application "System Events"
+ set frontApp to first process whose frontmost is true
+ set appName to name of frontApp
+ try
+ set winTitle to name of front window of frontApp
+ on error
+ set winTitle to ""
+ end try
+ return appName & linefeed & winTitle
+ end tell
+on error errMsg
+ return "" & linefeed & ""
+end try
+`.trim()
+
+ try {
+ const { stdout } = await execFileAsync('/usr/bin/osascript', ['-e', script], {
+ timeout: 3000
+ })
+ const lines = stdout.split('\n')
+ const appName = lines[0]?.trim() || null
+ const windowTitle = lines[1]?.trim() || null
+ return { appName, windowTitle }
+ } catch (error) {
+ logger.warn(
+ `osascript active window query failed: ${error instanceof Error ? error.message : String(error)}`
+ )
+ return { appName: null, windowTitle: null }
+ }
+ }
+
/**
* 선택된 텍스트를 클립보드 방식으로 캡처한다.
* TextInsertService의 역방향: clipboard save → Ctrl+C simulate → clipboard read → clipboard restore
diff --git a/apps/desktop/src/main/services/SoundEffectService.ts b/apps/desktop/src/main/services/SoundEffectService.ts
index 357f843..0d02b34 100644
--- a/apps/desktop/src/main/services/SoundEffectService.ts
+++ b/apps/desktop/src/main/services/SoundEffectService.ts
@@ -85,8 +85,10 @@ class SoundEffectService {
}
/**
- * Windows에서 WAV 파일을 비동기적으로 재생한다.
- * PowerShell의 SoundPlayer를 사용 (fire-and-forget).
+ * 플랫폼별 네이티브 WAV 재생 (비동기, fire-and-forget).
+ * - Windows: PowerShell SoundPlayer
+ * - macOS: /usr/bin/afplay
+ * - Linux: aplay (alsa-utils, 대부분 기본 설치)
*/
private _playWavNative(filePath: string): void {
if (!existsSync(filePath)) return
@@ -95,7 +97,6 @@ class SoundEffectService {
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()"`,
@@ -106,8 +107,23 @@ class SoundEffectService {
}
}
)
+ } else if (process.platform === 'darwin') {
+ // macOS: afplay는 기본 포함, 쉘 인젝션 방지를 위해 execFile 사용
+ const { execFile } = require('child_process') as typeof import('child_process')
+ execFile('/usr/bin/afplay', [filePath], (err: Error | null) => {
+ if (err) {
+ logger.debug(`afplay failed: ${err.message}`)
+ }
+ })
+ } else {
+ // Linux: aplay fallback
+ const { execFile } = require('child_process') as typeof import('child_process')
+ execFile('aplay', ['-q', filePath], (err: Error | null) => {
+ if (err) {
+ logger.debug(`aplay failed: ${err.message}`)
+ }
+ })
}
- // macOS/Linux는 추후 지원 (afplay, aplay)
} catch (err) {
logger.debug(`Sound play error: ${err instanceof Error ? err.message : String(err)}`)
}
diff --git a/apps/desktop/src/main/utils/paths.ts b/apps/desktop/src/main/utils/paths.ts
index 5249b6d..35e7b32 100644
--- a/apps/desktop/src/main/utils/paths.ts
+++ b/apps/desktop/src/main/utils/paths.ts
@@ -1,10 +1,13 @@
// src/main/utils/paths.ts
-// dev vs production 경로 자동 감지 유틸
+// dev vs production 경로 + 플랫폼별 실행파일 확장자 자동 해결
import path from 'path'
import { app } from 'electron'
import { existsSync } from 'fs'
+/** Windows는 .exe 접미사, 그 외는 없음 */
+const EXE_SUFFIX = process.platform === 'win32' ? '.exe' : ''
+
/**
* 앱이 패키징되었는지 여부.
* electron-builder로 빌드 후 실행하면 app.isPackaged = true.
@@ -14,24 +17,15 @@ function isPackaged(): boolean {
}
/**
- * 프로젝트 루트 경로.
- * - 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로 번들)
+ * SoX 실행 파일 경로. 번들된 게 있으면 그것, 없으면 시스템 PATH의 sox.
+ * - Windows: sox.exe
+ * - macOS/Linux: sox (brew install sox / apt install sox 필요)
*/
export function getSoxPath(): string {
- // 번들된 SoX 경로
+ const soxBin = `sox${EXE_SUFFIX}`
const bundledSox = isPackaged()
- ? path.join(process.resourcesPath, 'sox', 'sox.exe')
- : path.join(app.getAppPath(), 'resources', 'sox', 'sox.exe')
+ ? path.join(process.resourcesPath, 'sox', soxBin)
+ : path.join(app.getAppPath(), 'resources', 'sox', soxBin)
if (existsSync(bundledSox)) {
return bundledSox
@@ -46,9 +40,10 @@ export function getSoxPath(): string {
* node-record-lpcm16은 rec를 사용한다.
*/
export function getRecPath(): string {
+ const recBin = `rec${EXE_SUFFIX}`
const bundledRec = isPackaged()
- ? path.join(process.resourcesPath, 'sox', 'rec.exe')
- : path.join(app.getAppPath(), 'resources', 'sox', 'rec.exe')
+ ? path.join(process.resourcesPath, 'sox', recBin)
+ : path.join(app.getAppPath(), 'resources', 'sox', recBin)
if (existsSync(bundledRec)) {
return bundledRec
@@ -59,19 +54,21 @@ export function getRecPath(): string {
/**
* STT sidecar 실행 경로.
- * - dev: python sidecar/main.py
- * - production: sidecar/sidecar.exe (PyInstaller 빌드)
+ * - dev: python/python3 + sidecar/main.py
+ * - production: sidecar/sidecar(.exe) (PyInstaller 빌드)
*/
export function getSidecarCommand(): { command: string; args: string[] } {
+ const sidecarBin = `sidecar${EXE_SUFFIX}`
+
if (isPackaged()) {
- // production: PyInstaller exe
- const exePath = path.join(process.resourcesPath, 'sidecar', 'sidecar.exe')
+ const exePath = path.join(process.resourcesPath, 'sidecar', sidecarBin)
if (existsSync(exePath)) {
return { command: exePath, args: [] }
}
- // exe가 없으면 Python 폴백 (번들 실패 대비)
+ // PyInstaller 번들 실패 대비 폴백
const pyPath = path.join(process.resourcesPath, 'sidecar', 'main.py')
- return { command: 'python', args: [pyPath] }
+ const pythonCmd = process.platform === 'win32' ? 'python' : 'python3'
+ return { command: pythonCmd, args: [pyPath] }
}
// dev: Python 직접 실행
@@ -92,18 +89,20 @@ export function getSoundPath(filename: string): string {
/**
* ffmpeg 실행 파일 경로.
- * - dev: @ffmpeg-installer/ffmpeg의 node_modules 경로
+ * - dev: @ffmpeg-installer/ffmpeg의 node_modules 경로 (플랫폼별 자동)
* - production: extraResources로 번들된 경로
*/
export function getFfmpegPath(): string {
+ const ffmpegBin = `ffmpeg${EXE_SUFFIX}`
+
if (isPackaged()) {
- const bundled = path.join(process.resourcesPath, 'ffmpeg', 'ffmpeg.exe')
+ const bundled = path.join(process.resourcesPath, 'ffmpeg', ffmpegBin)
if (existsSync(bundled)) {
return bundled
}
}
- // dev: @ffmpeg-installer/ffmpeg에서 제공하는 경로
+ // dev: @ffmpeg-installer/ffmpeg에서 제공하는 경로 (플랫폼별 자동 선택)
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const installer = require('@ffmpeg-installer/ffmpeg')
diff --git a/docs/v2/phase-V2-5-mac-guide.md b/docs/v2/phase-V2-5-mac-guide.md
new file mode 100644
index 0000000..34959dd
--- /dev/null
+++ b/docs/v2/phase-V2-5-mac-guide.md
@@ -0,0 +1,235 @@
+# Phase V2-5: macOS 빌드 / 실행 / 배포 가이드
+
+> 본인 Mac에서 D3RO Voice를 dev 실행하거나 dist(.dmg/.zip) 빌드하기 위한 단계별 가이드.
+> Windows에서 작성된 코드는 V2-5에서 모든 플랫폼 분기를 추가했습니다 — Mac에서는 별도 코드 수정 없이 아래 단계만 따르면 됩니다.
+
+---
+
+## 0. 사전 조건
+
+- **macOS 12 (Monterey) 이상** 권장
+- **Apple Silicon(M1/M2/M3) 또는 Intel** 둘 다 지원
+- Xcode Command Line Tools (`xcode-select --install`)
+- Homebrew 설치 (`brew --version`으로 확인)
+
+## 1. 시스템 의존성 설치
+
+```bash
+# 기본 도구
+brew install node@22 python@3.11 git
+
+# 오디오 캡처 (필수)
+brew install sox
+
+# Ollama (로컬 LLM, 선택)
+brew install ollama
+ollama serve & # 백그라운드 실행
+ollama pull qwen3:4b
+```
+
+## 2. 프로젝트 클론 + 설치
+
+```bash
+git clone https://github.com/yunchan8804/d3ro-voice.git
+cd d3ro-voice
+
+# npm workspace 설치 (모든 패키지 한 번에)
+npm install
+
+# native 모듈 재빌드 (electron 33 ABI에 맞게)
+npx @electron/rebuild@3 --version=33.4.11
+```
+
+## 3. SoX 바이너리 번들 (선택, dev에서는 PATH로 충분)
+
+dev 실행만 할 거라면 `brew install sox`만으로 충분합니다. `paths.ts`가 `sox` 시스템 명령을 자동 fallback으로 사용합니다.
+
+dist 빌드를 할 거라면 `resources/sox/`에 바이너리를 미리 복사:
+
+```bash
+cd apps/desktop
+bash scripts/install-sox.sh
+```
+
+이 스크립트는:
+1. `command -v sox`로 시스템 sox 위치 찾기
+2. `apps/desktop/resources/sox/`에 복사
+3. `otool -L`로 dylib 의존성 확인 후 함께 복사
+4. `install_name_tool`로 rpath를 `@loader_path`로 변경 (앱 번들 내부에서도 동작)
+
+## 4. Python sidecar 빌드 (faster-whisper)
+
+dev에서도 sidecar는 Python 직접 실행. dist에서는 PyInstaller로 묶어야 함.
+
+```bash
+cd apps/desktop
+
+# 가상 환경 (선택, 권장)
+python3.11 -m venv .venv-sidecar
+source .venv-sidecar/bin/activate
+
+pip install pyinstaller
+pip install -r sidecar/requirements.txt
+
+# 빌드 (sidecar-dist/sidecar/sidecar 생성)
+python scripts/build-sidecar.py
+```
+
+빌드 결과: `apps/desktop/sidecar-dist/sidecar/sidecar` (실행 파일)
+
+`electron-builder.yml`이 이 경로를 `extraResources`로 번들합니다.
+
+## 5. 아이콘 준비 (dist 빌드 전)
+
+`apps/desktop/resources/icons/`는 placeholder 디렉토리입니다.
+
+```bash
+cd apps/desktop/resources/icons
+
+# 1024x1024 PNG 원본을 icon.png로 저장한 뒤:
+mkdir -p icon.iconset
+sips -z 16 16 icon.png --out icon.iconset/icon_16x16.png
+sips -z 32 32 icon.png --out icon.iconset/icon_16x16@2x.png
+sips -z 32 32 icon.png --out icon.iconset/icon_32x32.png
+sips -z 64 64 icon.png --out icon.iconset/icon_32x32@2x.png
+sips -z 128 128 icon.png --out icon.iconset/icon_128x128.png
+sips -z 256 256 icon.png --out icon.iconset/icon_128x128@2x.png
+sips -z 256 256 icon.png --out icon.iconset/icon_256x256.png
+sips -z 512 512 icon.png --out icon.iconset/icon_256x256@2x.png
+sips -z 512 512 icon.png --out icon.iconset/icon_512x512.png
+sips -z 1024 1024 icon.png --out icon.iconset/icon_512x512@2x.png
+iconutil -c icns icon.iconset
+rm -r icon.iconset
+```
+
+자세한 내용: `apps/desktop/resources/icons/README.md`
+
+## 6. dev 실행
+
+```bash
+# 루트에서 (npm workspace가 자동으로 apps/desktop 호출)
+npm run dev
+```
+
+처음 실행 시 macOS가 다음 권한을 요구합니다:
+
+| 권한 | 사용처 | 거부 시 영향 |
+|---|---|---|
+| 마이크 | 음성 인식 (필수) | 녹음 불가 |
+| Accessibility | 전역 핫키 (uiohook-napi), 활성 윈도우 조회 (AppleScript) | 핫키/스크린 컨텍스트 동작 안 함 |
+| 화면 녹화 | 스크린 컨텍스트 캡처 (선택) | OCR/LLM 컨텍스트 비어있음 |
+| Apple Events | 활성 앱 이름 조회 (osascript) | appName이 null |
+
+권한은 **시스템 설정 → 개인 정보 보호 및 보안**에서 수동으로도 조정 가능합니다.
+
+## 7. dist 빌드 (DMG/ZIP)
+
+```bash
+cd apps/desktop
+
+# Apple Silicon만
+npx electron-builder --mac --arm64
+
+# Intel만
+npx electron-builder --mac --x64
+
+# 둘 다
+npx electron-builder --mac
+```
+
+결과: `apps/desktop/release//`
+- `D3RO Voice-1.0.0-arm64.dmg`
+- `D3RO Voice-1.0.0-arm64.zip`
+- `D3RO Voice-1.0.0.dmg` (intel)
+- `D3RO Voice-1.0.0.zip` (intel)
+
+## 8. Code Signing + Notarization (배포용)
+
+### 8.1 Apple Developer 인증서 준비
+
+1. https://developer.apple.com → Account → Certificates
+2. `+` → `Developer ID Application` (배포용) 생성
+3. 다운로드한 `.cer`을 키체인에 추가
+4. 키체인에서 **인증서 + 개인 키**를 함께 export → `.p12` 파일 (암호 설정)
+
+### 8.2 환경 변수
+
+```bash
+# Code signing
+export CSC_LINK="$(base64 -i path/to/cert.p12)"
+export CSC_KEY_PASSWORD=""
+
+# Notarization (App-specific password 필요)
+export APPLE_ID="your-apple-id@example.com"
+export APPLE_APP_SPECIFIC_PASSWORD="abcd-efgh-ijkl-mnop" # appleid.apple.com에서 생성
+export APPLE_TEAM_ID="ABCDE12345"
+```
+
+App-specific password 생성: https://appleid.apple.com → Sign-In and Security → App-Specific Passwords
+
+### 8.3 서명+공증 빌드
+
+```bash
+cd apps/desktop
+npx electron-builder --mac -c.mac.notarize=true
+```
+
+공증은 보통 5~30분 걸립니다. 완료되면 자동으로 staple됩니다.
+
+검증:
+```bash
+spctl --assess --verbose=4 release//D3RO\ Voice.app
+# accepted source=Notarized Developer ID
+```
+
+## 9. CI 빌드 (GitHub Actions)
+
+`.github/workflows/build-mac.yml` 워크플로우를 사용하여 자동 빌드:
+
+```bash
+# 태그 푸시 시 자동 트리거
+git tag v1.0.0
+git push origin v1.0.0
+```
+
+또는 GitHub UI에서 `Actions → Build macOS → Run workflow`.
+
+### 필요한 GitHub Secrets
+
+| Secret | 설명 |
+|---|---|
+| `APPLE_ID` | Apple 계정 이메일 |
+| `APPLE_APP_SPECIFIC_PASSWORD` | App-specific password |
+| `APPLE_TEAM_ID` | 10자리 팀 ID |
+| `MAC_CERT_P12_BASE64` | `.p12` 인증서를 base64 인코딩한 문자열 |
+| `MAC_CERT_P12_PASSWORD` | `.p12` 암호 |
+
+`workflow_dispatch`로 수동 실행 시 `notarize=true`를 선택하면 공증까지 진행.
+
+## 10. 트러블슈팅
+
+### "App is damaged and can't be opened" (서명 없는 dmg)
+```bash
+xattr -cr /Applications/D3RO\ Voice.app
+```
+또는 시스템 설정 → 보안 → "확인 없이 열기"
+
+### `sox: command not found` (dev 모드)
+```bash
+brew install sox
+```
+
+### `ImportError: dlopen failed` (sidecar 실행 시)
+PyInstaller 빌드가 현재 Mac 아키텍처와 다를 가능성. arm64 Mac에서 `arch -x86_64 python` 같은 cross 빌드는 권장하지 않음.
+
+### Electron 앱이 즉시 종료
+```bash
+# 콘솔 로그 확인
+~/Library/Logs/d3ro-voice/main.log
+# 또는 dev 모드로 직접 실행
+npm run dev
+```
+
+### Accessibility 권한 거부 후 재요청
+시스템 설정 → 개인정보보호 → 손쉬운 사용 → 좌하단 자물쇠 해제 → D3RO Voice 체크
+권한 변경 후 앱 재시작.
diff --git a/memory/project_status.md b/memory/project_status.md
index 735bed2..4ac9d34 100644
--- a/memory/project_status.md
+++ b/memory/project_status.md
@@ -7,11 +7,14 @@
**V1 (Electron) 완료** → **V2 (Monorepo) 진행 중**
- Phase V2-1 ✅ 완료 (Monorepo 전환 — a/b/c/d)
-- Phase V2-2 🟡 로컬 작업 완료, 사용자 액션 대기
- - SQL 마이그레이션 4개 파일 + Edge Functions 2개 스캐폴딩 작성 완료
- - Supabase 프로젝트 생성/OAuth 등록/배포는 사용자가 직접 수행
- - 가이드: `docs/v2/phase-V2-2-setup.md`
-- 다음: Phase V2-3 (Web App MVP — Next.js)
+- Phase V2-2 🟡 로컬 작업 완료, 사용자 Supabase 배포 대기
+- Phase V2-5 🟡 로컬 작업 완료, 사용자 Mac 검증 대기
+ - paths.ts 플랫폼 분기, electron-builder.yml mac 타겟, entitlements,
+ SoundEffectService(afplay)/ScreenContextService(AppleScript) Mac 분기,
+ build-sidecar.py 크로스 플랫폼, install-sox.sh, GitHub Actions Mac CI
+ - 가이드: `docs/v2/phase-V2-5-mac-guide.md`
+- 다음: Phase V2-3 (Web App MVP — Next.js) 또는 V2-4 (데스크톱 동기화)
+- V2-2 사용자 액션이 끝나면 V2-3 진행 가능
## V1 완료 페이즈
@@ -173,8 +176,44 @@
- **Realtime**: `transcripts` 테이블만 `supabase_realtime` publication에 추가 (회의 중 세그먼트 동기화 용).
- **Edge Functions placeholder**: 인증/쿼터 파이프라인은 완성, 실제 AI API 호출은 주석 처리된 실제 구현 코드 포함. V2-3 (Web MVP) 진행 전까지 유지.
-### Phase V2-3~V2-8: 미착수
-Web App MVP, 데스크톱 동기화, Mac 빌드, Mobile App, 팀 기능, 결제/출시
+### Phase V2-5: Mac 빌드 — 로컬 작업 완료
+
+**작성/수정된 파일**
+- `apps/desktop/src/main/utils/paths.ts` — `EXE_SUFFIX` 상수 도입, sox/sidecar/ffmpeg 경로 플랫폼별 분기
+- `apps/desktop/src/main/services/SoundEffectService.ts` — Mac `afplay`, Linux `aplay` 분기 추가 (execFile로 안전하게)
+- `apps/desktop/src/main/services/ScreenContextService.ts` — `_getActiveWindowInfo`를 win32/darwin로 분기. macOS는 `osascript`로 frontmost process + 윈도우 타이틀 조회 (Accessibility 권한 필요)
+- `apps/desktop/scripts/build-sidecar.py` — `IS_WINDOWS`/`IS_MACOS` 분기, `EXE_SUFFIX`, `--noconsole`은 Windows에서만, 출력 경로 플랫폼 통합
+- `apps/desktop/scripts/install-sox.sh` — Mac/Linux에서 시스템 sox를 `resources/sox/`로 복사. macOS는 `otool`로 dylib 의존성 함께 복사 + `install_name_tool`로 rpath 변경
+- `apps/desktop/electron-builder.yml` — `mac:` 타겟 추가 (dmg+zip, arm64+x64), `hardenedRuntime`, `entitlements`, `extendInfo`(권한 메시지), Linux AppImage placeholder
+- `apps/desktop/build/entitlements.mac.plist` — 마이크/카메라/네트워크/Apple Events/JIT/dylib 권한
+- `apps/desktop/resources/icons/README.md` — 플랫폼별 아이콘 생성 가이드 (sips/iconutil/imagemagick)
+- `.github/workflows/build-mac.yml` — macos-14 runner, brew sox, sidecar 빌드, electron-builder dist (arm64/x64 매트릭스), CSC/notarize 환경변수, artifact 업로드
+- `docs/v2/phase-V2-5-mac-guide.md` — Mac 사전 조건/dev 실행/dist 빌드/서명/공증/CI/트러블슈팅
+
+**검증 결과 (Windows에서)**
+- typecheck ✅ — Mac 분기 추가에도 회귀 없음
+- build ✅ — Windows 번들 정상
+- dev 런타임 ✅ — Ollama 자동 실행 + 모든 서비스 정상
+
+**Mac에서 검증 대기 (사용자 액션)**
+- `brew install sox node@22 python@3.11`
+- `npm install && npx @electron/rebuild@3 --version=33.4.11`
+- `npm run dev` → Accessibility/마이크/Apple Events 권한 허용
+- 핫키, 녹음, STT, LLM, 화면 컨텍스트 동작 확인
+- 결과 리포트 받아 미세 조정 필요할 수 있음
+
+**감사 결과 vs 처리**
+- C1 sox.exe/sidecar.exe/ffmpeg.exe 하드코딩 → ✅ 수정
+- C2 아이콘 부재 → ✅ placeholder 디렉토리 + README
+- C3 mac 타겟 부재 → ✅ 추가
+- C4 SoX 바이너리 부재 → ✅ install-sox.sh + 가이드
+- C5 ScreenContextService 미지원 → ✅ AppleScript 분기
+- C6 SoundEffectService 미지원 → ✅ afplay 분기
+- C7 build-sidecar.py Windows 전제 → ✅ 크로스 플랫폼
+- M6/M7/M8/M9 (이미 분기 완료) → 변경 없음
+
+### Phase V2-3/V2-4/V2-6/V2-7/V2-8: 미착수
+Web App MVP, 데스크톱 동기화, Mobile App, 팀 기능, 결제/출시
## 알려진 차단/이슈