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:
parent
97eb886ec3
commit
77d514222e
12 changed files with 780 additions and 79 deletions
95
.github/workflows/build-mac.yml
vendored
Normal file
95
.github/workflows/build-mac.yml
vendored
Normal 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
2
.gitignore
vendored
|
|
@ -15,6 +15,8 @@ release/
|
|||
# Build
|
||||
*.tsbuildinfo
|
||||
build/
|
||||
!apps/desktop/build/
|
||||
!apps/desktop/build/entitlements.mac.plist
|
||||
sidecar-dist/
|
||||
|
||||
# Bundled binaries (download via scripts)
|
||||
|
|
|
|||
39
apps/desktop/build/entitlements.mac.plist
Normal file
39
apps/desktop/build/entitlements.mac.plist
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
51
apps/desktop/resources/icons/README.md
Normal file
51
apps/desktop/resources/icons/README.md
Normal 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 빌드는 아이콘이 필요합니다.
|
||||
|
|
@ -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__":
|
||||
|
|
|
|||
92
apps/desktop/scripts/install-sox.sh
Normal file
92
apps/desktop/scripts/install-sox.sh
Normal 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}/"
|
||||
|
|
@ -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') {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
235
docs/v2/phase-V2-5-mac-guide.md
Normal file
235
docs/v2/phase-V2-5-mac-guide.md
Normal 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 체크
|
||||
권한 변경 후 앱 재시작.
|
||||
|
|
@ -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, 팀 기능, 결제/출시
|
||||
|
||||
## 알려진 차단/이슈
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue