Files
minecraft_launcher/src/installer-rp/main.ts
claude-bot 9ba5dc6b7b terms: per-term installer visibility toggles + universal delete (v0.3.4)
- _meta.json: customLabels -> terms.{label,showInInstaller,showInInstallerRp}
- Drop builtin protection; any term kind can be deleted/added/toggled
- New public route /manifest/terms/<pack>/index.json for installer term lists
- Installers fetch terms:list dynamically; skip agreement step if list empty
- Term editor: 2 visibility checkboxes (설치기 / 리소스팩 설치기), multi-select
- Migration from old schema preserves custom labels (default: visible in both)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 10:14:42 +09:00

534 lines
22 KiB
TypeScript

import { app, BrowserWindow, ipcMain, shell } from 'electron'
import http from 'node:http'
import https from 'node:https'
import path from 'node:path'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import os from 'node:os'
import { URL } from 'node:url'
import type { ChildProcess } from 'node:child_process'
import type { Manifest, PackDefinition, PackList } from '../shared/types.js'
import { normalizePackDefinition } from '../shared/store.js'
import { getAppDataDir, getMcCustomDir } from '../shared/paths.js'
import { loadEnv, getManifestUrl } from '../shared/env.js'
import { loadComponentI18n } from '../shared/i18n.js'
import type { RpFetchedPack } from './types.js'
import { ensureYtDlpExe } from './ytdlp.js'
import { ensureFfmpegExe } from './ffmpeg.js'
import { downloadMusicTrack } from './music.js'
import { downloadImage, normalizeToCover, coverFileName } from './images.js'
import { buildResourcepackZip } from './pack.js'
loadEnv()
const i18n = loadComponentI18n('installer-rp')
const t = i18n.t
export const localeDict = i18n.dict
interface RpInstallerState {
manifestUrl: string
baseUrl: string
packs: Map<string, RpFetchedPack>
selectedKey: string | null
/** 현재 설치 진행 중인지 여부. 취소 신호로 사용. */
cancelRequested: boolean
/** 현재 실행 중인 외부 프로세스들(yt-dlp/ffmpeg). 취소 시 모두 kill. */
activeChildren: Set<ChildProcess>
}
/**
* 사용자가 사이트에서 지정한 "생성되는 리소스팩 이름" 을 Windows 파일명으로 쓸 수
* 있게 정리한다. 금지 문자(\<\>:"/\\|?*\x00-\x1f) 는 `_` 로, 끝의 공백/마침표는
* 제거, 예약어(CON/PRN/...)는 앞에 `_` 를 붙인다. 빈 입력은 빈 문자열 반환 →
* 호출 측에서 폴백을 결정한다.
*/
function sanitizeOutputPackName(name: string): string {
let cleaned = (name || '').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
cleaned = cleaned.replace(/[ .]+$/, '')
if (!cleaned) return ''
if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(cleaned)) cleaned = '_' + cleaned
return cleaned
}
/**
* 동시 yt-dlp 프로세스 수를 CPU 코어 수로 자동 결정.
* - yt-dlp + ffmpeg 변환이 CPU 바운드라 코어 수가 가장 좋은 프록시.
* - 유튜브가 IP 단위로 throttle 걸기 때문에 5 이상은 효과 없음 → 상한 5.
* - 환경변수 MUSIC_CONCURRENCY 로 강제 오버라이드 가능.
*/
function pickMusicConcurrency(): number {
const override = Number(process.env.MUSIC_CONCURRENCY)
if (Number.isFinite(override) && override >= 1) {
return Math.min(8, Math.floor(override))
}
const cores = os.cpus()?.length ?? 4
if (cores <= 2) return 2
if (cores <= 4) return 3
if (cores <= 8) return 4
return 5
}
/**
* 새 다운로드 시작 사이의 최소 간격(ms).
* - 동시 N개를 모두 t=0 에 시작하면 카드들이 0% 에서 같이 정지된 듯 보임.
* - 시차를 두고 시작하면 "1번 끝남 → 4번 시작 → 2번 끝남 → 5번 시작" 식으로
* 유저 입장에서 항상 뭔가 새로 시작/완료되는 흐름이 보임.
* - 너무 길면 동시성 이득을 깎아먹음. 2s 가 체감/속도 균형점.
*/
const MUSIC_START_STAGGER_MS = 2000
/** start-gate. 여러 worker 가 동시에 acquire 해도 직렬화되어 순차 통과. */
let musicStartChain: Promise<void> = Promise.resolve()
let nextMusicStartAt = 0
function acquireMusicStartSlot(): Promise<void> {
const slot = musicStartChain.then(async () => {
const wait = Math.max(0, nextMusicStartAt - Date.now())
if (wait > 0) await new Promise<void>((r) => setTimeout(r, wait))
nextMusicStartAt = Date.now() + MUSIC_START_STAGGER_MS
})
musicStartChain = slot.catch(() => {})
return slot
}
const DEFAULT_MANIFEST_URL = getManifestUrl()
const state: RpInstallerState = {
manifestUrl: DEFAULT_MANIFEST_URL,
baseUrl: deriveBaseUrl(DEFAULT_MANIFEST_URL),
packs: new Map(),
selectedKey: null,
cancelRequested: false,
activeChildren: new Set()
}
let mainWindow: BrowserWindow | null = null
function deriveBaseUrl(manifestUrl: string): string {
try {
const parsed = new URL(manifestUrl)
return `${parsed.protocol}//${parsed.host}`
} catch {
return ''
}
}
function createMainWindow(): void {
// 메인 설치기와 동일한 아이콘 사용. dev/prod, Windows/기타 분기까지 같은 규칙.
const iconPath = path.join(__dirname, '..', '..', 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png')
mainWindow = new BrowserWindow({
width: 900,
height: 680,
icon: iconPath,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
})
mainWindow.removeMenu()
void mainWindow.loadFile(path.join(__dirname, '..', '..', 'installer-rp', 'index.html'))
}
function sendLog(line: string): void {
if (!mainWindow || mainWindow.isDestroyed()) return
const stamped = `[${new Date().toLocaleTimeString('ko-KR', { hour12: false })}] ${line}`
mainWindow.webContents.send('log', stamped)
}
type ProgressEvent =
| { phase: 'prep'; message: string; done?: boolean }
| {
phase: 'item'
kind: 'music' | 'image'
index: number
total: number
percent: number
status: 'running' | 'done' | 'error'
message?: string
}
| { phase: 'package'; message: string; done?: boolean }
function sendProgress(payload: ProgressEvent): void {
if (!mainWindow || mainWindow.isDestroyed()) return
mainWindow.webContents.send('progress', payload)
}
function fetchBuffer(url: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
const target = new URL(url)
const transport = target.protocol === 'https:' ? https : http
const request = transport.get(target, { timeout: 30000 }, (response) => {
if (response.statusCode === 301 || response.statusCode === 302) {
const redirect = response.headers.location
if (redirect) {
response.resume()
fetchBuffer(new URL(redirect, target).toString()).then(resolve, reject)
return
}
}
if ((response.statusCode ?? 0) >= 400) {
response.resume()
reject(new Error(`HTTP ${response.statusCode}`))
return
}
const chunks: Buffer[] = []
response.on('data', (chunk: Buffer) => chunks.push(chunk))
response.on('end', () => resolve(Buffer.concat(chunks)))
})
request.on('error', reject)
request.on('timeout', () => request.destroy(new Error(t('common.requestTimeout'))))
})
}
async function fetchJson<T>(url: string): Promise<T> {
const buffer = await fetchBuffer(url)
return JSON.parse(buffer.toString('utf8')) as T
}
// ── IPC: 1단계 manifest 로드 ─────────────────────────
ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promise<RpFetchedPack[]> => {
if (typeof manifestUrlInput === 'string' && manifestUrlInput.length > 0) {
state.manifestUrl = manifestUrlInput
state.baseUrl = deriveBaseUrl(manifestUrlInput)
}
sendLog(t('log.manifestDownload', { url: state.manifestUrl }))
const manifest = await fetchJson<Manifest>(state.manifestUrl)
const results: RpFetchedPack[] = []
for (const entry of manifest.packs ?? []) {
if (typeof entry?.file !== 'string') continue
const listUrl = `${state.baseUrl}/file/list/${encodeURIComponent(entry.file)}.json`
const packUrl = `${state.baseUrl}/manifest/${encodeURIComponent(entry.file)}.json`
try {
// 목록(필수) + 팩 정의(mcVersion 용, 실패해도 폴백) 동시 로드.
const [listRaw, packRaw] = await Promise.all([
fetchJson<Partial<PackList>>(listUrl),
fetchJson<Partial<PackDefinition>>(packUrl).catch((err) => {
sendLog(t('log.packDefFailed', { file: entry.file, message: (err as Error).message }))
return null
})
])
const list: PackList = {
musicPlaylistUrl: typeof listRaw.musicPlaylistUrl === 'string' ? listRaw.musicPlaylistUrl : '',
imagePlaylistUrl: typeof listRaw.imagePlaylistUrl === 'string' ? listRaw.imagePlaylistUrl : '',
music: Array.isArray(listRaw.music) ? listRaw.music : [],
images: Array.isArray(listRaw.images) ? listRaw.images : []
}
const normalized = packRaw ? normalizePackDefinition(packRaw as Partial<PackDefinition>) : null
const mcVersion = normalized?.mcVersion ?? ''
const resourcepackPath = normalized?.resourcepackPath ?? ''
const outputPackName = normalized?.outputPackName ?? ''
results.push({
key: entry.file,
name: entry.name || entry.file,
mcVersion,
resourcepackPath,
outputPackName,
list
})
} catch (error) {
sendLog(t('log.listLoadFailed', { file: entry.file, message: (error as Error).message }))
}
}
state.packs.clear()
for (const item of results) state.packs.set(item.key, item)
sendLog(t('log.packsLoaded', { count: results.length }))
for (const item of results) {
sendLog(t('log.packEntry', {
key: item.key,
mc: item.mcVersion || t('log.packEntryUnknownVersion'),
base: item.resourcepackPath || t('log.packEntryNoBase')
}))
}
return results
})
ipcMain.handle('rp:packs:select', async (_event, packKey: string) => {
if (!state.packs.has(packKey)) {
throw new Error(t('errors.selectedPackNotFound'))
}
state.selectedKey = packKey
sendLog(t('log.selectedPack', { key: packKey }))
})
ipcMain.handle('rp:i18n:dict', () => localeDict)
// ── IPC: 약관 다운로드 ──────────────────────────────
// v0.3.4~ : 사이트에서 임의 kind 가 만들어질 수 있으니 5종 화이트리스트 대신
// kind 형식만 검증한다. 어떤 약관을 rp 인스톨러에 보여줄지는 사이트의 visibility 토글이 결정.
const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
ipcMain.handle('rp:terms:get', async (_event, kind: string) => {
if (typeof kind !== 'string' || !TERM_KIND_RE.test(kind)) {
return { ok: false, message: 'invalid term kind' }
}
if (!state.selectedKey) return { ok: false, message: 'pack not selected' }
try {
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/${encodeURIComponent(kind)}.md`
const buf = await fetchBuffer(url)
return { ok: true, content: buf.toString('utf8') }
} catch (error) {
return { ok: false, message: (error as Error).message }
}
})
// rp 인스톨러용 약관 목록. /manifest/terms/<packKey>/index.json 을 받아
// showInInstallerRp=true 인 항목만 추려 반환. 비어 있으면 렌더러가 약관 단계를 건너뛴다.
ipcMain.handle('rp:terms:list', async (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> => {
if (!state.selectedKey) return { ok: false, message: 'pack not selected' }
try {
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/index.json`
const buf = await fetchBuffer(url)
const parsed = JSON.parse(buf.toString('utf8')) as { terms?: unknown }
const items = Array.isArray(parsed.terms) ? parsed.terms : []
const terms: Array<{ kind: string; label: string }> = []
for (const it of items) {
if (!it || typeof it !== 'object') continue
const entry = it as Record<string, unknown>
if (entry.showInInstallerRp !== true) continue
const kind = typeof entry.kind === 'string' ? entry.kind : ''
const label = typeof entry.label === 'string' ? entry.label : ''
if (!TERM_KIND_RE.test(kind) || label.length === 0) continue
terms.push({ kind, label })
}
return { ok: true, terms }
} catch (error) {
return { ok: false, message: (error as Error).message }
}
})
// ── IPC: 2단계 설치 ──────────────────────────────────
ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string }> => {
if (!state.selectedKey) throw new Error(t('errors.selectPackFirst'))
const pack = state.packs.get(state.selectedKey)
if (!pack) throw new Error(t('errors.currentPackNotFound'))
state.cancelRequested = false
const tempRoot = path.join(getMcCustomDir(), '.temp')
await fsp.mkdir(tempRoot, { recursive: true })
const musicTotal = pack.list.music.length
const imageTotal = pack.list.images.length
try {
// 2-1. yt-dlp / ffmpeg 준비 (%appdata%/.mc_custom/{yt-dlp,ffmpeg}.exe)
sendLog(t('log.ytdlpPreparing'))
sendProgress({ phase: 'prep', message: t('progress.ytdlpPreparing') })
const ytDlpBin = await ensureYtDlpExe(sendLog)
sendLog(t('log.ytdlpPath', { path: ytDlpBin }))
throwIfCancelled()
sendLog(t('log.ffmpegPreparing'))
sendProgress({ phase: 'prep', message: t('progress.ffmpegPreparing') })
const ffmpegBin = await ensureFfmpegExe(sendLog)
sendLog(t('log.ffmpegPath', { path: ffmpegBin }))
sendProgress({ phase: 'prep', message: t('progress.ready'), done: true })
throwIfCancelled()
// 2-2. 음악 다운로드 (CPU 코어 수 기반 자동 동시 다운로드, 시차 출발, ogg 변환)
const musicDir = path.join(tempRoot, 'music')
await fsp.mkdir(musicDir, { recursive: true })
const concurrency = pickMusicConcurrency()
const cpuCount = os.cpus()?.length ?? 0
// 첫 음악은 즉시 시작 가능하도록 base 를 현재 시각으로.
nextMusicStartAt = Date.now()
sendLog(t('log.cpuDetected', { cores: cpuCount, concurrency }))
sendLog(t('log.musicStart', { total: musicTotal, concurrency, stagger: MUSIC_START_STAGGER_MS }))
// 클로저 안에서 narrowing 이 풀리지 않도록 로컬 alias.
const musicList = pack.list.music
let nextIndex = 0
async function musicWorker(): Promise<void> {
while (true) {
if (state.cancelRequested) return
const i = nextIndex++
if (i >= musicTotal) return
// 시차 게이트: 새 다운로드 시작은 직전 시작과 최소 MUSIC_START_STAGGER_MS 간격을 둠.
await acquireMusicStartSlot()
if (state.cancelRequested) return
const entry = musicList[i]
const idx = i + 1
sendLog(t('log.musicTrackStart', { idx }))
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
let child: ChildProcess | null = null
try {
const outPath = await downloadMusicTrack({
ytdlpExe: ytDlpBin,
ffmpegExe: ffmpegBin,
tempDir: musicDir,
index: idx,
url: entry.url,
log: sendLog,
onChild: (c) => {
child = c
state.activeChildren.add(c)
},
onProgress: (pct) => {
// 다운로드(0~90%) + 변환(90~100%) 으로 매핑.
sendProgress({
phase: 'item', kind: 'music', index: idx, total: musicTotal,
percent: Math.min(90, pct * 0.9), status: 'running'
})
}
})
if (child) state.activeChildren.delete(child)
sendLog(t('log.musicTrackDone', { idx, name: path.basename(outPath) }))
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
} catch (err) {
if (child) state.activeChildren.delete(child)
if (state.cancelRequested) {
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: t('progress.cancelled') })
return
}
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: (err as Error).message })
throw new Error(t('errors.musicDownloadFailed', { idx, message: (err as Error).message }))
}
}
}
const workerCount = Math.min(concurrency, musicTotal)
const workers: Promise<void>[] = []
for (let w = 0; w < workerCount; w++) workers.push(musicWorker())
await Promise.all(workers)
throwIfCancelled()
// 2-3. 사진 다운로드 + painting variant 정규화
const paintingDir = path.join(tempRoot, 'painting')
await fsp.mkdir(paintingDir, { recursive: true })
sendLog(t('log.imageStart', { total: imageTotal }))
for (let i = 0; i < imageTotal; i++) {
throwIfCancelled()
const entry = pack.list.images[i]
const idx = i + 1
sendLog(t('log.imageDownloading', { idx }))
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running' })
let buf: Buffer
try {
buf = await downloadImage(entry.url)
} catch (err) {
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
throw new Error(t('errors.imageDownloadFailed', { idx, message: (err as Error).message }))
}
throwIfCancelled()
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 60, status: 'running' })
const outPath = path.join(paintingDir, coverFileName(idx))
try {
await normalizeToCover(buf, outPath)
} catch (err) {
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
throw new Error(t('errors.imageNormalizeFailed', { idx, message: (err as Error).message }))
}
sendLog(t('log.imageDone', { idx, name: path.basename(outPath) }))
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' })
}
// 2-4. 베이스 리소스팩 다운로드 (있을 때만)
throwIfCancelled()
let baseZipPath: string | undefined
if (pack.resourcepackPath) {
// 파일명에 공백·괄호가 있을 수 있어 encodeURIComponent 로 인코딩.
const cleaned = pack.resourcepackPath.replace(/^\/+/, '')
const baseUrl = `${state.baseUrl}/file/resourcepacks/${encodeURIComponent(cleaned)}`
baseZipPath = path.join(tempRoot, 'base.zip')
sendLog(t('log.baseDownload', { path: cleaned }))
sendLog(t('log.baseUrl', { url: baseUrl }))
sendProgress({ phase: 'package', message: t('progress.baseDownloading') })
try {
const buf = await fetchBuffer(baseUrl)
await fsp.writeFile(baseZipPath, buf)
sendLog(t('log.baseReceived', { kb: (buf.length / 1024).toFixed(1) }))
} catch (err) {
throw new Error(t('errors.baseDownloadFailed', { message: (err as Error).message }))
}
} else {
sendLog(t('log.baseAbsent'))
}
// 2-5. 리소스팩 zip 빌드 (pack.mcmeta + sounds.json + 음악·이미지, 베이스 위에 얹기)
throwIfCancelled()
// 사이트에서 지정한 "생성되는 리소스팩 이름" 을 우선 사용. 비어있거나 sanitize
// 결과가 빈 문자열이면 `<packKey>_resourcepack` 로 폴백.
const sanitizedOutputName = sanitizeOutputPackName(pack.outputPackName)
const resourcepackBaseName = sanitizedOutputName || `${state.selectedKey}_resourcepack`
const resourcepackName = `${resourcepackBaseName}.zip`
const resourcepackDir = path.join(getMcCustomDir(), 'resourcepacks')
const resourcepackPath = path.join(resourcepackDir, resourcepackName)
sendLog(t('log.buildingZip', { name: resourcepackName }))
sendProgress({ phase: 'package', message: baseZipPath ? t('progress.buildingWithBase') : t('progress.buildingZip') })
await buildResourcepackZip({
musicDir,
paintingDir,
packName: pack.name,
mcVersion: pack.mcVersion,
workDir: tempRoot,
outZipPath: resourcepackPath,
baseZipPath,
log: sendLog,
// build 내부에서도 단계 사이/zip 도중에 폴링해서 취소를 빠르게 반영한다.
cancelChecker: () => state.cancelRequested
})
throwIfCancelled()
// 2-6. %appdata%/.mc_custom/resourcepacks/ 에 배치 (위 빌드가 직접 outZipPath 에 저장)
sendLog(t('log.installComplete', { path: resourcepackPath }))
// 2-7. 베이스 리소스팩은 우리가 임시폴더에 받아서 빌드에 이미 얹었으므로,
// 메인 설치기가 `.mc_custom/resourcepacks/<resourcepackPath>` 에 받아둔
// 원본 zip 은 MC 리소스팩 목록에 굳이 남길 필요 없다. 삭제하되, 사용자가
// outputPackName 을 base 파일명과 똑같이 둬서 우리가 방금 쓴 최종 zip 과
// 같은 경로면 그대로 둔다(우리 산출물을 지우면 안 되므로).
if (pack.resourcepackPath) {
const basePackPath = path.join(resourcepackDir, pack.resourcepackPath)
if (path.resolve(basePackPath) !== path.resolve(resourcepackPath)) {
try {
await fsp.rm(basePackPath, { force: true })
sendLog(t('log.baseRemoved', { path: basePackPath }))
} catch { /* 없으면 무시 */ }
}
}
sendProgress({ phase: 'package', message: t('progress.installComplete'), done: true })
return { resourcepackPath }
} finally {
// 임시 파일 정리
await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => {})
}
})
ipcMain.handle('rp:install:cancel', async () => {
state.cancelRequested = true
sendLog(t('log.cancelRequested', { count: state.activeChildren.size }))
for (const child of state.activeChildren) {
if (!child.killed) child.kill()
}
})
function throwIfCancelled(): void {
if (state.cancelRequested) {
throw new Error(t('errors.cancelledByUser'))
}
}
// ── IPC: 3단계 완료 ──────────────────────────────────
ipcMain.handle('rp:finish:openFolder', async () => {
const dir = path.join(getMcCustomDir(), 'resourcepacks')
if (!fs.existsSync(dir)) {
await fsp.mkdir(dir, { recursive: true })
}
await shell.openPath(dir)
})
ipcMain.handle('rp:quit', async () => {
app.quit()
})
// ── 앱 라이프사이클 ───────────────────────────────
app.whenReady().then(() => {
createMainWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
})
})
app.on('window-all-closed', () => {
// 강제 종료 시에도 임시 파일은 정리.
fsp.rm(path.join(getMcCustomDir(), '.temp'), { recursive: true, force: true }).catch(() => {})
if (process.platform !== 'darwin') app.quit()
})