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
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') {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue