feat(V2-5): macOS 빌드 지원 — 플랫폼 분기 + electron-builder mac 타겟 + CI

플랫폼 분기 (paths.ts):
- EXE_SUFFIX 상수로 sox/sidecar/ffmpeg 실행파일 확장자 통합
- Windows에선 .exe 자동 부착, Mac/Linux에선 빈 문자열
- 미사용 getProjectRoot 헬퍼 제거

런타임 서비스 Mac 분기:
- SoundEffectService: darwin → /usr/bin/afplay,
  linux → aplay 분기 추가 (execFile로 안전하게)
- ScreenContextService._getActiveWindowInfo:
  win32 → PowerShell + user32.dll (기존),
  darwin → osascript (System Events frontmost process + 윈도우 타이틀)
  Linux는 미지원 (null)

electron-builder.yml:
- mac 타겟 추가 (dmg + zip, arm64 + x64 매트릭스)
- hardenedRuntime, gatekeeperAssess, entitlements 설정
- extendInfo로 NSMicrophoneUsage / NSCameraUsage / NSAppleEvents /
  NSSystemAdministration 권한 메시지
- dmg 레이아웃 (드래그 to /Applications)
- linux AppImage placeholder
- notarize: false 기본, NOTARIZE 환경변수로 활성화

build/entitlements.mac.plist:
- allow-jit, allow-unsigned-executable-memory (Electron 필수)
- audio-input, camera, network.client
- automation.apple-events (활성 윈도우 조회용)
- files.user-selected.read-write
- allow-dyld-environment-variables (sox/ffmpeg 라이브러리 로드)

scripts/build-sidecar.py:
- IS_WINDOWS / IS_MACOS / EXE_SUFFIX 도입
- Windows에서만 --noconsole 플래그
- 빌드 결과 경로 + size 출력 플랫폼 통합

scripts/install-sox.sh (신규):
- Mac/Linux용 SoX 번들 스크립트
- macOS는 otool로 dylib 의존성 식별 후 함께 복사,
  install_name_tool로 rpath를 @loader_path로 변경
- electron-builder의 extraResources 대상 디렉토리에 배치

resources/icons/ (신규):
- README.md만 커밋, 실제 아이콘 파일은 분리
- sips/iconutil/imagemagick으로 .icns/.ico/.png 생성 가이드

.github/workflows/build-mac.yml (신규):
- macos-14 runner (Apple Silicon), arm64/x64 matrix
- brew sox, npm install, @electron/rebuild,
  install-sox.sh, build-sidecar.py, electron-builder dist
- CSC/NOTARIZE 환경변수 자동 처리
- artifact 업로드 (dmg + zip, retention 7일)

docs/v2/phase-V2-5-mac-guide.md (신규):
- 사전 조건, 시스템 의존성, dev 실행, dist 빌드,
  Code signing + Notarization, CI 트리거, 트러블슈팅

검증 (Windows에서):
- typecheck 통과 (Mac 분기 추가에도 회귀 없음)
- build 통과
- dev 런타임 정상

Mac 검증은 사용자 본인 Mac에서 수행 (V2-5 사용자 액션).
This commit is contained in:
yunchan8804 2026-04-08 15:57:16 +09:00
parent 97eb886ec3
commit 77d514222e
12 changed files with 780 additions and 79 deletions

95
.github/workflows/build-mac.yml vendored Normal file
View file

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

2
.gitignore vendored
View file

@ -15,6 +15,8 @@ release/
# Build # Build
*.tsbuildinfo *.tsbuildinfo
build/ build/
!apps/desktop/build/
!apps/desktop/build/entitlements.mac.plist
sidecar-dist/ sidecar-dist/
# Bundled binaries (download via scripts) # Bundled binaries (download via scripts)

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Electron 앱은 JIT 실행을 필요로 함 -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<!-- faster-whisper 등 네이티브 sidecar 실행 허용 -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- 마이크 -->
<key>com.apple.security.device.audio-input</key>
<true/>
<!-- 카메라/스크린 녹화 (ScreenContextService) -->
<key>com.apple.security.device.camera</key>
<true/>
<!-- 네트워크 클라이언트 (Ollama, Supabase 등) -->
<key>com.apple.security.network.client</key>
<true/>
<!-- AppleScript로 다른 앱 이벤트 전송 (활성 윈도우 조회) -->
<key>com.apple.security.automation.apple-events</key>
<true/>
<!-- 파일 시스템 사용자 선택 파일 -->
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<!-- DYLIB injection 허용 (ffmpeg, SoX 등) -->
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>

View file

@ -16,6 +16,9 @@ asarUnpack:
- "node_modules/uiohook-napi/**" - "node_modules/uiohook-napi/**"
- "node_modules/@nut-tree-fork/**" - "node_modules/@nut-tree-fork/**"
# ────────────────────────────────────────────────────────────────────
# Windows
# ────────────────────────────────────────────────────────────────────
win: win:
target: target:
- target: nsis - target: nsis
@ -32,31 +35,80 @@ nsis:
shortcutName: D3RO Voice shortcutName: D3RO Voice
deleteAppDataOnUninstall: false 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: 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/ - from: resources/sounds/
to: sounds/ to: sounds/
filter: filter:
- "*.wav" - "*.wav"
# 앱 아이콘 # 앱 아이콘 (트레이 등 런타임에서 참조)
- from: resources/icons/ - from: resources/icons/
to: icons/ to: icons/
filter: 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 npmRebuild: true

View file

@ -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 빌드는 아이콘이 필요합니다.

View file

@ -1,15 +1,22 @@
""" """
scripts/build-sidecar.py scripts/build-sidecar.py
faster-whisper sidecar를 PyInstaller로 빌드한다. faster-whisper sidecar를 PyInstaller로 빌드한다. (크로스 플랫폼)
사용법: 사용법:
pip install pyinstaller pip install pyinstaller
pip install -r sidecar/requirements.txt
python scripts/build-sidecar.py 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 subprocess
import sys import sys
import shutil import shutil
@ -20,10 +27,13 @@ SIDECAR_DIR = PROJECT_ROOT / "sidecar"
MAIN_PY = SIDECAR_DIR / "main.py" MAIN_PY = SIDECAR_DIR / "main.py"
OUTPUT_DIR = PROJECT_ROOT / "sidecar-dist" 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(): def check_prerequisites():
"""필수 도구가 설치되어 있는지 확인한다.""" """필수 도구가 설치되어 있는지 확인한다."""
# PyInstaller
try: try:
import PyInstaller # noqa: F401 import PyInstaller # noqa: F401
except ImportError: except ImportError:
@ -31,7 +41,6 @@ def check_prerequisites():
print("실행: pip install pyinstaller") print("실행: pip install pyinstaller")
sys.exit(1) sys.exit(1)
# faster-whisper
try: try:
import faster_whisper # noqa: F401 import faster_whisper # noqa: F401
except ImportError: except ImportError:
@ -47,24 +56,22 @@ def check_prerequisites():
def build(): def build():
"""PyInstaller로 sidecar를 빌드한다.""" """PyInstaller로 sidecar를 빌드한다."""
print("=" * 60) print("=" * 60)
print("D3RO-VOICE Sidecar 빌드 시작") print(f"D3RO-VOICE Sidecar 빌드 시작 ({platform.system()} {platform.machine()})")
print("=" * 60) print("=" * 60)
# 기존 빌드 정리 # 기존 빌드 정리
if OUTPUT_DIR.exists(): if OUTPUT_DIR.exists():
shutil.rmtree(OUTPUT_DIR) shutil.rmtree(OUTPUT_DIR)
# PyInstaller 실행 — onedir 모드 (onefile보다 시작이 빠름)
cmd = [ cmd = [
sys.executable, "-m", "PyInstaller", sys.executable, "-m", "PyInstaller",
"--name", "sidecar", "--name", "sidecar",
"--distpath", str(OUTPUT_DIR), "--distpath", str(OUTPUT_DIR),
"--workpath", str(PROJECT_ROOT / "build" / "sidecar-build"), "--workpath", str(PROJECT_ROOT / "build" / "sidecar-build"),
"--specpath", str(PROJECT_ROOT / "build"), "--specpath", str(PROJECT_ROOT / "build"),
# onedir 모드 (단일 디렉토리)
"--noconfirm", "--noconfirm",
"--clean", "--clean",
# hidden imports (PyInstaller가 자동 감지 못하는 것) # hidden imports
"--hidden-import", "faster_whisper", "--hidden-import", "faster_whisper",
"--hidden-import", "ctranslate2", "--hidden-import", "ctranslate2",
"--hidden-import", "huggingface_hub", "--hidden-import", "huggingface_hub",
@ -78,11 +85,20 @@ def build():
"--hidden-import", "uvicorn.lifespan", "--hidden-import", "uvicorn.lifespan",
"--hidden-import", "uvicorn.lifespan.on", "--hidden-import", "uvicorn.lifespan.on",
"--hidden-import", "uvicorn.lifespan.off", "--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") print(f"\n실행 명령:\n {' '.join(cmd)}\n")
result = subprocess.run(cmd, cwd=str(PROJECT_ROOT)) result = subprocess.run(cmd, cwd=str(PROJECT_ROOT))
@ -92,27 +108,29 @@ def build():
sys.exit(1) sys.exit(1)
# 빌드 결과 확인 # 빌드 결과 확인
exe_path = OUTPUT_DIR / "sidecar" / "sidecar.exe" exe_path = OUTPUT_DIR / "sidecar" / f"sidecar{EXE_SUFFIX}"
if exe_path.exists(): if exe_path.exists():
size_mb = exe_path.stat().st_size / (1024 * 1024) size_mb = exe_path.stat().st_size / (1024 * 1024)
print(f"\n빌드 성공!") print(f"\n빌드 성공!")
print(f" 경로: {exe_path}") print(f" 경로: {exe_path}")
print(f" 크기: {size_mb:.1f} MB") print(f" 크기: {size_mb:.1f} MB")
# 전체 디렉토리 크기 total_size = sum(
total_size = sum(f.stat().st_size for f in (OUTPUT_DIR / "sidecar").rglob("*") if f.is_file()) f.stat().st_size for f in (OUTPUT_DIR / "sidecar").rglob("*") if f.is_file()
)
total_mb = total_size / (1024 * 1024) total_mb = total_size / (1024 * 1024)
print(f" 전체 디렉토리: {total_mb:.1f} MB") print(f" 전체 디렉토리: {total_mb:.1f} MB")
else: else:
print(f"\n빌드 출력을 찾을 수 없습니다: {exe_path}") print(f"\n빌드 출력을 찾을 수 없습니다: {exe_path}")
# onedir 모드에서는 디렉토리 내부에 exe가 있음 # onedir 모드에서는 디렉토리 내부에 바이너리가 있음
for exe in OUTPUT_DIR.rglob("*.exe"): pattern = "*.exe" if IS_WINDOWS else "sidecar"
print(f" 발견: {exe}") for item in OUTPUT_DIR.rglob(pattern):
print(f" 발견: {item}")
print("\n빌드 완료!") print("\n빌드 완료!")
print(f"electron-builder에서 이 경로를 extraResources로 지정하세요:") print("electron-builder에서 이 경로를 extraResources로 지정하세요:")
print(f" from: sidecar-dist/sidecar/") print(" from: sidecar-dist/sidecar/")
print(f" to: sidecar/") print(" to: sidecar/")
if __name__ == "__main__": if __name__ == "__main__":

View file

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

View file

@ -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<{ private async _getActiveWindowInfo(): Promise<{
appName: string | null appName: string | null
windowTitle: string | null windowTitle: string | null
}> { }> {
if (process.platform !== 'win32') { if (process.platform === 'win32') {
return { appName: null, windowTitle: null } 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 { execFile } = await import('child_process')
const { promisify } = await import('util') const { promisify } = await import('util')
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
// PowerShell 스크립트: GetForegroundWindow의 프로세스명과 윈도우 타이틀
const psScript = ` const psScript = `
Add-Type @" Add-Type @"
using System; 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 * TextInsertService의 역방향: clipboard save Ctrl+C simulate clipboard read clipboard restore

View file

@ -85,8 +85,10 @@ class SoundEffectService {
} }
/** /**
* Windows에서 WAV . * WAV (, fire-and-forget).
* PowerShell의 SoundPlayer를 (fire-and-forget). * - Windows: PowerShell SoundPlayer
* - macOS: /usr/bin/afplay
* - Linux: aplay (alsa-utils, )
*/ */
private _playWavNative(filePath: string): void { private _playWavNative(filePath: string): void {
if (!existsSync(filePath)) return if (!existsSync(filePath)) return
@ -95,7 +97,6 @@ class SoundEffectService {
const { exec } = require('child_process') as typeof import('child_process') const { exec } = require('child_process') as typeof import('child_process')
if (process.platform === 'win32') { if (process.platform === 'win32') {
// Windows: PowerShell SoundPlayer (비동기, 프로세스 분리)
const escapedPath = filePath.replace(/'/g, "''") const escapedPath = filePath.replace(/'/g, "''")
exec( exec(
`powershell -NoProfile -Command "(New-Object Media.SoundPlayer '${escapedPath}').PlaySync()"`, `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) { } catch (err) {
logger.debug(`Sound play error: ${err instanceof Error ? err.message : String(err)}`) logger.debug(`Sound play error: ${err instanceof Error ? err.message : String(err)}`)
} }

View file

@ -1,10 +1,13 @@
// src/main/utils/paths.ts // src/main/utils/paths.ts
// dev vs production 경로 자동 감지 유틸 // dev vs production 경로 + 플랫폼별 실행파일 확장자 자동 해결
import path from 'path' import path from 'path'
import { app } from 'electron' import { app } from 'electron'
import { existsSync } from 'fs' import { existsSync } from 'fs'
/** Windows는 .exe 접미사, 그 외는 없음 */
const EXE_SUFFIX = process.platform === 'win32' ? '.exe' : ''
/** /**
* . * .
* electron-builder로 app.isPackaged = true. * electron-builder로 app.isPackaged = true.
@ -14,24 +17,15 @@ function isPackaged(): boolean {
} }
/** /**
* . * SoX . , PATH의 sox.
* - dev: 프로젝트 (D:/workspace/D3ROVoice) * - Windows: sox.exe
* - production: process.resourcesPath (app.asar.unpacked ) * - macOS/Linux: sox (brew install sox / apt install sox )
*/
function getProjectRoot(): string {
return isPackaged() ? process.resourcesPath : app.getAppPath()
}
/**
* SoX .
* - dev: resources/sox/sox.exe () PATH의 sox
* - production: resources/sox/sox.exe (extraResources로 )
*/ */
export function getSoxPath(): string { export function getSoxPath(): string {
// 번들된 SoX 경로 const soxBin = `sox${EXE_SUFFIX}`
const bundledSox = isPackaged() const bundledSox = isPackaged()
? path.join(process.resourcesPath, 'sox', 'sox.exe') ? path.join(process.resourcesPath, 'sox', soxBin)
: path.join(app.getAppPath(), 'resources', 'sox', 'sox.exe') : path.join(app.getAppPath(), 'resources', 'sox', soxBin)
if (existsSync(bundledSox)) { if (existsSync(bundledSox)) {
return bundledSox return bundledSox
@ -46,9 +40,10 @@ export function getSoxPath(): string {
* node-record-lpcm16은 rec를 . * node-record-lpcm16은 rec를 .
*/ */
export function getRecPath(): string { export function getRecPath(): string {
const recBin = `rec${EXE_SUFFIX}`
const bundledRec = isPackaged() const bundledRec = isPackaged()
? path.join(process.resourcesPath, 'sox', 'rec.exe') ? path.join(process.resourcesPath, 'sox', recBin)
: path.join(app.getAppPath(), 'resources', 'sox', 'rec.exe') : path.join(app.getAppPath(), 'resources', 'sox', recBin)
if (existsSync(bundledRec)) { if (existsSync(bundledRec)) {
return bundledRec return bundledRec
@ -59,19 +54,21 @@ export function getRecPath(): string {
/** /**
* STT sidecar . * STT sidecar .
* - dev: python sidecar/main.py * - dev: python/python3 + sidecar/main.py
* - production: sidecar/sidecar.exe (PyInstaller ) * - production: sidecar/sidecar(.exe) (PyInstaller )
*/ */
export function getSidecarCommand(): { command: string; args: string[] } { export function getSidecarCommand(): { command: string; args: string[] } {
const sidecarBin = `sidecar${EXE_SUFFIX}`
if (isPackaged()) { if (isPackaged()) {
// production: PyInstaller exe const exePath = path.join(process.resourcesPath, 'sidecar', sidecarBin)
const exePath = path.join(process.resourcesPath, 'sidecar', 'sidecar.exe')
if (existsSync(exePath)) { if (existsSync(exePath)) {
return { command: exePath, args: [] } return { command: exePath, args: [] }
} }
// exe가 없으면 Python 폴백 (번들 실패 대비) // PyInstaller 번들 실패 대비 폴백
const pyPath = path.join(process.resourcesPath, 'sidecar', 'main.py') 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 직접 실행 // dev: Python 직접 실행
@ -92,18 +89,20 @@ export function getSoundPath(filename: string): string {
/** /**
* ffmpeg . * ffmpeg .
* - dev: @ffmpeg-installer/ffmpeg의 node_modules * - dev: @ffmpeg-installer/ffmpeg의 node_modules ( )
* - production: extraResources로 * - production: extraResources로
*/ */
export function getFfmpegPath(): string { export function getFfmpegPath(): string {
const ffmpegBin = `ffmpeg${EXE_SUFFIX}`
if (isPackaged()) { if (isPackaged()) {
const bundled = path.join(process.resourcesPath, 'ffmpeg', 'ffmpeg.exe') const bundled = path.join(process.resourcesPath, 'ffmpeg', ffmpegBin)
if (existsSync(bundled)) { if (existsSync(bundled)) {
return bundled return bundled
} }
} }
// dev: @ffmpeg-installer/ffmpeg에서 제공하는 경로 // dev: @ffmpeg-installer/ffmpeg에서 제공하는 경로 (플랫폼별 자동 선택)
try { try {
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
const installer = require('@ffmpeg-installer/ffmpeg') const installer = require('@ffmpeg-installer/ffmpeg')

View file

@ -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/<version>/`
- `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="<p12 암호>"
# 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/<version>/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 체크
권한 변경 후 앱 재시작.

View file

@ -7,11 +7,14 @@
**V1 (Electron) 완료** → **V2 (Monorepo) 진행 중** **V1 (Electron) 완료** → **V2 (Monorepo) 진행 중**
- Phase V2-1 ✅ 완료 (Monorepo 전환 — a/b/c/d) - Phase V2-1 ✅ 완료 (Monorepo 전환 — a/b/c/d)
- Phase V2-2 🟡 로컬 작업 완료, 사용자 액션 대기 - Phase V2-2 🟡 로컬 작업 완료, 사용자 Supabase 배포 대기
- SQL 마이그레이션 4개 파일 + Edge Functions 2개 스캐폴딩 작성 완료 - Phase V2-5 🟡 로컬 작업 완료, 사용자 Mac 검증 대기
- Supabase 프로젝트 생성/OAuth 등록/배포는 사용자가 직접 수행 - paths.ts 플랫폼 분기, electron-builder.yml mac 타겟, entitlements,
- 가이드: `docs/v2/phase-V2-2-setup.md` SoundEffectService(afplay)/ScreenContextService(AppleScript) Mac 분기,
- 다음: Phase V2-3 (Web App MVP — Next.js) 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 완료 페이즈 ## V1 완료 페이즈
@ -173,8 +176,44 @@
- **Realtime**: `transcripts` 테이블만 `supabase_realtime` publication에 추가 (회의 중 세그먼트 동기화 용). - **Realtime**: `transcripts` 테이블만 `supabase_realtime` publication에 추가 (회의 중 세그먼트 동기화 용).
- **Edge Functions placeholder**: 인증/쿼터 파이프라인은 완성, 실제 AI API 호출은 주석 처리된 실제 구현 코드 포함. V2-3 (Web MVP) 진행 전까지 유지. - **Edge Functions placeholder**: 인증/쿼터 파이프라인은 완성, 실제 AI API 호출은 주석 처리된 실제 구현 코드 포함. V2-3 (Web MVP) 진행 전까지 유지.
### Phase V2-3~V2-8: 미착수 ### Phase V2-5: Mac 빌드 — 로컬 작업 완료
Web App MVP, 데스크톱 동기화, Mac 빌드, Mobile App, 팀 기능, 결제/출시
**작성/수정된 파일**
- `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, 팀 기능, 결제/출시
## 알려진 차단/이슈 ## 알려진 차단/이슈