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

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