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

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

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

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

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

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

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

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

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

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

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

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

View file

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

View file

@ -16,6 +16,9 @@ asarUnpack:
- "node_modules/uiohook-napi/**"
- "node_modules/@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

View file

@ -0,0 +1,51 @@
# App Icons
이 디렉토리에는 플랫폼별 아이콘 파일이 있어야 합니다.
## 필수 파일
| 플랫폼 | 파일 | 크기 |
|---|---|---|
| Windows | `icon.ico` | 256x256 (권장: 16/32/48/64/128/256 멀티 해상도) |
| macOS | `icon.icns` | 1024x1024 base (자동 생성된 멀티 해상도) |
| Linux / Tray | `icon.png` | 512x512 (또는 1024x1024) |
## 생성 방법
### 원본 PNG 준비
1024x1024 PNG 파일을 하나 만든 뒤 변환:
### macOS (권장 — 자동화)
```bash
# iconutil 사용 (macOS 기본 제공)
mkdir icon.iconset
sips -z 16 16 icon.png --out icon.iconset/icon_16x16.png
sips -z 32 32 icon.png --out icon.iconset/icon_16x16@2x.png
sips -z 32 32 icon.png --out icon.iconset/icon_32x32.png
sips -z 64 64 icon.png --out icon.iconset/icon_32x32@2x.png
sips -z 128 128 icon.png --out icon.iconset/icon_128x128.png
sips -z 256 256 icon.png --out icon.iconset/icon_128x128@2x.png
sips -z 256 256 icon.png --out icon.iconset/icon_256x256.png
sips -z 512 512 icon.png --out icon.iconset/icon_256x256@2x.png
sips -z 512 512 icon.png --out icon.iconset/icon_512x512.png
sips -z 1024 1024 icon.png --out icon.iconset/icon_512x512@2x.png
iconutil -c icns icon.iconset
rm -r icon.iconset
```
### Windows (ImageMagick)
```bash
magick icon.png -define icon:auto-resize=256,128,64,48,32,16 icon.ico
```
### electron-icon-builder (자동)
```bash
npx electron-icon-builder --input=icon.png --output=resources/icons
```
## 현재 상태
실제 아이콘 파일은 이 레포에 **커밋되지 않습니다** (디자인 에셋 분리).
앱을 직접 빌드하려면 위 방식으로 `icon.ico`, `icon.icns`, `icon.png`를 이 디렉토리에 배치하세요.
electron-builder는 아이콘이 없어도 dev 실행은 가능하지만, dist 빌드는 아이콘이 필요합니다.

View file

@ -1,15 +1,22 @@
"""
scripts/build-sidecar.py
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__":

View file

@ -0,0 +1,92 @@
#!/usr/bin/env bash
# scripts/install-sox.sh
#
# Mac/Linux에서 SoX 바이너리를 apps/desktop/resources/sox/로 복사한다.
# electron-builder의 extraResources가 이 디렉토리를 번들 대상으로 잡는다.
#
# 사용법:
# # 먼저 시스템에 SoX 설치
# brew install sox # macOS
# sudo apt install sox # Ubuntu/Debian
#
# # 그 다음 이 스크립트 실행 (apps/desktop 기준)
# ./scripts/install-sox.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DESKTOP_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
TARGET_DIR="${DESKTOP_DIR}/resources/sox"
OS="$(uname -s)"
case "$OS" in
Darwin*) PLATFORM="macos" ;;
Linux*) PLATFORM="linux" ;;
*)
echo "Unsupported OS: $OS — Windows는 scripts/download-sox.ps1를 사용하세요."
exit 1
;;
esac
echo "=== D3RO Voice: SoX 바이너리 번들 (${PLATFORM}) ==="
# 시스템 sox/rec 경로 찾기
SOX_BIN="$(command -v sox || true)"
REC_BIN="$(command -v rec || true)"
if [ -z "$SOX_BIN" ]; then
echo "ERROR: 시스템에 sox가 설치되어 있지 않습니다."
if [ "$PLATFORM" = "macos" ]; then
echo " macOS: brew install sox"
else
echo " Linux: sudo apt install sox 또는 sudo yum install sox"
fi
exit 1
fi
echo "sox: $SOX_BIN"
if [ -n "$REC_BIN" ]; then
echo "rec: $REC_BIN"
fi
# 타겟 디렉토리 준비
mkdir -p "${TARGET_DIR}"
echo "target: ${TARGET_DIR}"
# 바이너리 복사
cp "$SOX_BIN" "${TARGET_DIR}/sox"
chmod +x "${TARGET_DIR}/sox"
echo "복사: sox → ${TARGET_DIR}/sox"
if [ -n "$REC_BIN" ]; then
cp "$REC_BIN" "${TARGET_DIR}/rec"
chmod +x "${TARGET_DIR}/rec"
echo "복사: rec → ${TARGET_DIR}/rec"
fi
# 동적 라이브러리 의존성 복사 (macOS만)
if [ "$PLATFORM" = "macos" ]; then
echo "동적 라이브러리 의존성 복사 중..."
# otool로 의존성 확인 후 SoX와 함께 묶음
LIBS=$(otool -L "$SOX_BIN" | awk 'NR>1 && $1 !~ /^\/System\// && $1 !~ /^\/usr\/lib\// {print $1}')
for lib in $LIBS; do
if [ -f "$lib" ]; then
lib_name="$(basename "$lib")"
cp "$lib" "${TARGET_DIR}/${lib_name}"
chmod +w "${TARGET_DIR}/${lib_name}"
echo " lib: ${lib_name}"
# rpath를 @loader_path로 변경 (앱 번들 내부에서도 동작하도록)
install_name_tool -change "$lib" "@loader_path/${lib_name}" "${TARGET_DIR}/sox" 2>/dev/null || true
if [ -n "$REC_BIN" ]; then
install_name_tool -change "$lib" "@loader_path/${lib_name}" "${TARGET_DIR}/rec" 2>/dev/null || true
fi
fi
done
fi
echo ""
echo "완료. electron-builder가 이 디렉토리를 extraResources로 번들합니다."
ls -lh "${TARGET_DIR}/"

View file

@ -179,22 +179,38 @@ class ScreenContextService {
// ── 내부 구현 ─────────────────────────────────────────
/**
* Windows에서 .
* PowerShell을 GetForegroundWindow .
* .
* - Windows: PowerShell + user32.dll
* - macOS: osascript (System Events)
* - Linux: 미지원 (null )
*
* 참고: macOS는 Accessibility .
* . { null, null } .
*/
private async _getActiveWindowInfo(): Promise<{
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

View file

@ -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)}`)
}

View file

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