- main/preload/ytdlp/ffmpeg/music/images/pack/renderer 전반에서 로그·에러·진행 메시지 문자열을 locales/installer-rp/ko-kr.json 사전 키로 교체 - preload 에 loadLocale 추가, main 에 rp:i18n:dict IPC 핸들러 추가 - 패키징된 .exe 에서도 한국어 사전이 적용되도록 electron-builder.yml 의 extraResources 에 locales/ 폴더 추가 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
148 lines
5.1 KiB
TypeScript
148 lines
5.1 KiB
TypeScript
import { spawn } from 'node:child_process'
|
|
import { promises as fs, createWriteStream, constants as fsConst } from 'node:fs'
|
|
import path from 'node:path'
|
|
import https from 'node:https'
|
|
import http from 'node:http'
|
|
import { getMcCustomDir } from '../shared/paths.js'
|
|
import { loadComponentI18n } from '../shared/i18n.js'
|
|
|
|
const { t } = loadComponentI18n('installer-rp')
|
|
|
|
// extract-zip 은 CommonJS 기본 export 라 require 로 받음.
|
|
const extractZip: (source: string, options: { dir: string }) => Promise<void> = require('extract-zip')
|
|
|
|
/**
|
|
* 리소스팩 간편설치기는 Windows .exe 로 배포되므로 ffmpeg.exe 한 종류만 사용.
|
|
* 경로: %appdata%/.mc_custom/ffmpeg.exe
|
|
*/
|
|
export function getFfmpegExePath(): string {
|
|
return path.join(getMcCustomDir(), 'ffmpeg.exe')
|
|
}
|
|
|
|
/** BtbN/FFmpeg-Builds 의 win64-gpl 빌드. zip 내부에 bin/ffmpeg.exe 가 들어 있음. */
|
|
const FFMPEG_ZIP_URL =
|
|
'https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip'
|
|
|
|
let installPromise: Promise<string> | null = null
|
|
|
|
/**
|
|
* %appdata%/.mc_custom/ffmpeg.exe 가 없거나 실행 불가하면 BtbN 빌드 zip 에서
|
|
* ffmpeg.exe 만 추출해 설치하고 절대경로를 돌려준다.
|
|
*/
|
|
export async function ensureFfmpegExe(
|
|
log?: (line: string) => void
|
|
): Promise<string> {
|
|
const target = getFfmpegExePath()
|
|
if (await canExecute(target)) {
|
|
log?.(t('log.ffmpegExists', { path: target }))
|
|
return target
|
|
}
|
|
if (installPromise) return installPromise
|
|
|
|
installPromise = (async () => {
|
|
const dir = getMcCustomDir()
|
|
const zipPath = path.join(dir, '.tmp_ffmpeg.zip')
|
|
const extractDir = path.join(dir, '.tmp_ffmpeg')
|
|
try {
|
|
await fs.mkdir(dir, { recursive: true })
|
|
// 이전 시도의 임시 파일/폴더 정리
|
|
await fs.rm(zipPath, { force: true })
|
|
await fs.rm(extractDir, { recursive: true, force: true })
|
|
|
|
log?.(t('log.ffmpegDownloading', { url: FFMPEG_ZIP_URL }))
|
|
await downloadToFile(FFMPEG_ZIP_URL, zipPath)
|
|
log?.(t('log.ffmpegExtracting'))
|
|
await extractZip(zipPath, { dir: extractDir })
|
|
|
|
const found = await findFile(extractDir, 'ffmpeg.exe')
|
|
if (!found) {
|
|
throw new Error(t('errors.ffmpegNotInZip'))
|
|
}
|
|
// 같은 파일시스템(=같은 드라이브) 일 가능성이 높아 rename 시도, 실패 시 copyFile fallback.
|
|
try {
|
|
await fs.rename(found, target)
|
|
} catch {
|
|
await fs.copyFile(found, target)
|
|
}
|
|
|
|
const ok = await probeVersion(target)
|
|
if (!ok) throw new Error(t('errors.ffmpegVerifyFailed'))
|
|
log?.(t('log.ffmpegReady', { path: target }))
|
|
return target
|
|
} catch (err) {
|
|
try { await fs.unlink(target) } catch { /* noop */ }
|
|
throw new Error(
|
|
t('errors.ffmpegInstallFailed', {
|
|
message: err instanceof Error ? err.message : String(err)
|
|
})
|
|
)
|
|
} finally {
|
|
// 임시 파일/폴더 정리
|
|
await fs.rm(zipPath, { force: true }).catch(() => {})
|
|
await fs.rm(extractDir, { recursive: true, force: true }).catch(() => {})
|
|
installPromise = null
|
|
}
|
|
})()
|
|
return installPromise
|
|
}
|
|
|
|
async function canExecute(filePath: string): Promise<boolean> {
|
|
try { await fs.access(filePath, fsConst.F_OK) } catch { return false }
|
|
return probeVersion(filePath)
|
|
}
|
|
|
|
function probeVersion(bin: string): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
const child = spawn(bin, ['-version'], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
let ok = false
|
|
child.stdout.on('data', () => { ok = true })
|
|
child.on('error', () => resolve(false))
|
|
child.on('close', (code) => resolve(ok && code === 0))
|
|
})
|
|
}
|
|
|
|
async function findFile(root: string, name: string): Promise<string | null> {
|
|
const entries = await fs.readdir(root, { withFileTypes: true })
|
|
for (const e of entries) {
|
|
const full = path.join(root, e.name)
|
|
if (e.isFile() && e.name.toLowerCase() === name.toLowerCase()) return full
|
|
if (e.isDirectory()) {
|
|
const inner = await findFile(full, name)
|
|
if (inner) return inner
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/** GitHub Releases latest URL 은 302 리다이렉트를 사용하므로 따라가며 받음. */
|
|
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
if (redirects > 8) {
|
|
reject(new Error(t('common.tooManyRedirects')))
|
|
return
|
|
}
|
|
const lib = url.startsWith('https://') ? https : http
|
|
const req = lib.get(url, {
|
|
headers: { 'user-agent': 'mc-music-quiz-rp-installer' }
|
|
}, (res) => {
|
|
const code = res.statusCode || 0
|
|
if (code >= 300 && code < 400 && res.headers.location) {
|
|
res.resume()
|
|
downloadToFile(res.headers.location, dest, redirects + 1).then(resolve, reject)
|
|
return
|
|
}
|
|
if (code !== 200) {
|
|
res.resume()
|
|
reject(new Error(`HTTP ${code} (${url})`))
|
|
return
|
|
}
|
|
const out = createWriteStream(dest)
|
|
res.pipe(out)
|
|
out.on('finish', () => out.close((err) => err ? reject(err) : resolve()))
|
|
out.on('error', reject)
|
|
res.on('error', reject)
|
|
})
|
|
req.on('error', reject)
|
|
})
|
|
}
|