From 77d514222e6a4c7614bf9425430223fc0897be14 Mon Sep 17 00:00:00 2001 From: yunchan8804 Date: Wed, 8 Apr 2026 15:57:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(V2-5):=20macOS=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20=E2=80=94=20=ED=94=8C=EB=9E=AB=ED=8F=BC=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20+=20electron-builder=20mac=20=ED=83=80?= =?UTF-8?q?=EA=B2=9F=20+=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 플랫폼 분기 (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 사용자 액션). --- .github/workflows/build-mac.yml | 95 +++++++ .gitignore | 2 + apps/desktop/build/entitlements.mac.plist | 39 +++ apps/desktop/electron-builder.yml | 84 +++++-- apps/desktop/resources/icons/README.md | 51 ++++ apps/desktop/scripts/build-sidecar.py | 58 +++-- apps/desktop/scripts/install-sox.sh | 92 +++++++ .../src/main/services/ScreenContextService.ts | 73 +++++- .../src/main/services/SoundEffectService.ts | 24 +- apps/desktop/src/main/utils/paths.ts | 53 ++-- docs/v2/phase-V2-5-mac-guide.md | 235 ++++++++++++++++++ memory/project_status.md | 53 +++- 12 files changed, 780 insertions(+), 79 deletions(-) create mode 100644 .github/workflows/build-mac.yml create mode 100644 apps/desktop/build/entitlements.mac.plist create mode 100644 apps/desktop/resources/icons/README.md create mode 100644 apps/desktop/scripts/install-sox.sh create mode 100644 docs/v2/phase-V2-5-mac-guide.md 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, 팀 기능, 결제/출시 ## 알려진 차단/이슈