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