Files
minecraft_launcher/src/installer/main.ts
claude-bot c527efc42f installer: address vanilla mods preservation, robust map rename, new app icon
Reviewer follow-ups:

1) Preserve mods/ for vanilla packs. `downloadModsFolder` now checks
   `!pack.modsFolder` BEFORE wiping — vanilla packs (no modsFolder) no
   longer clobber a user's hand-curated mods directory. Wipe still runs
   for modded packs to keep different MC versions from colliding.

2) Always rename the extracted map to `saves/<퀴즈이름>/`, regardless of
   the zip's top-level layout. The zip is now extracted into a temp
   directory under saves/, and:
   - if the temp has a single subdirectory, that subdirectory's content
     becomes the world;
   - otherwise the temp dir itself (e.g. level.dat + region/ at root) is
     the world.
   In either case, it is renamed atomically to `saves/<sanitized name>`
   (or `<name>_2` etc. if a user world collides). Marker tracks the
   final folder name for participant cleanup.

User request: replace both .exe icons.

- Added build/icon.ico (multi-size 16/32/48/64/128/256) and build/icon.png
  generated from the new music-note artwork.
- electron-builder.yml: set win.icon, nsis installer/uninstaller icons,
  buildResources=build, include build/icon.* in files for runtime use.
- New electron-builder-rp.yml + dist:win:rp script so the resourcepack
  installer also packages with the same icon.
- BrowserWindow({ icon }) wired in both installer and installer-rp main
  processes so the running window's titlebar/taskbar icon matches.

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

1673 lines
66 KiB
TypeScript

import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'
import http from 'node:http'
import https from 'node:https'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import { spawn } from 'node:child_process'
import { URL } from 'node:url'
import natUpnp from 'nat-upnp'
// extract-zip은 CommonJS 기본 export.
const extractZip: (source: string, options: { dir: string }) => Promise<void> = require('extract-zip')
import type {
ClientInstallPayload,
FetchedPack,
PortForwardResult,
RamCheckResult,
ServerInstallPayload
} from './types.js'
import type { Manifest, PackDefinition } from '../shared/types.js'
import { normalizePackDefinition } from '../shared/store.js'
import { loadEnv, getManifestUrl } from '../shared/env.js'
import { loadComponentI18n } from '../shared/i18n.js'
loadEnv()
const i18n = loadComponentI18n('installer')
const t = i18n.t
export const localeDict = i18n.dict
interface InstallerState {
manifestUrl: string
baseUrl: string
packs: Map<string, FetchedPack>
selectedKey: string | null
installPath: string | null
configEditorServer: http.Server | null
configEditorPort: number | null
}
const DEFAULT_MANIFEST_URL = getManifestUrl()
const state: InstallerState = {
manifestUrl: DEFAULT_MANIFEST_URL,
baseUrl: deriveBaseUrl(DEFAULT_MANIFEST_URL),
packs: new Map(),
selectedKey: null,
installPath: null,
configEditorServer: null,
configEditorPort: null
}
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 {
// 패키징 시 build/icon.ico, dev 실행 시 build/icon.png 모두 동일 경로에서 발견되도록
// 프로젝트 루트의 build/ 를 가리킨다. 파일이 없으면 Electron 이 기본 아이콘으로 fallback.
const iconPath = path.join(__dirname, '..', '..', 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png')
mainWindow = new BrowserWindow({
width: 980,
height: 720,
icon: iconPath,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
})
mainWindow.removeMenu()
void mainWindow.loadFile(path.join(__dirname, '..', '..', 'installer', '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)
}
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('errors.requestTimeout'))))
})
}
async function fetchJson<T>(url: string): Promise<T> {
const buffer = await fetchBuffer(url)
return JSON.parse(buffer.toString('utf8')) as T
}
async function downloadFile(url: string, target: string): Promise<void> {
await fsp.mkdir(path.dirname(target), { recursive: true })
const buffer = await fetchBuffer(url)
await fsp.writeFile(target, buffer)
}
function containsHangul(text: string): boolean {
return /[\u3131-\u318E\uAC00-\uD7A3\u1100-\u11FF]/.test(text)
}
ipcMain.handle('packs:load', async (_event, manifestUrlInput?: string): Promise<FetchedPack[]> => {
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: FetchedPack[] = []
for (const entry of manifest.packs ?? []) {
if (typeof entry?.file !== 'string') continue
const packUrl = `${state.baseUrl}/manifest.json`.replace(/manifest\.json$/, `manifest/${entry.file}.json`)
try {
const raw = await fetchJson<Partial<PackDefinition>>(packUrl)
const pack = normalizePackDefinition(raw)
results.push({ key: entry.file, name: entry.name || pack.name, pack })
} catch (error) {
sendLog(t('log.packLoadFail', { 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 }))
return results
})
ipcMain.handle('packs:select', async (_event, packKey: string) => {
if (!state.packs.has(packKey)) {
throw new Error(t('errors.packNotFound'))
}
state.selectedKey = packKey
sendLog(t('log.selectedPack', { key: packKey }))
})
ipcMain.handle('dialog:pickFolder', async (): Promise<string | null> => {
if (!mainWindow) return null
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory', 'createDirectory']
})
if (result.canceled || result.filePaths.length === 0) return null
return result.filePaths[0]
})
ipcMain.handle('install:validatePath', async (_event, target: string) => {
if (!target || target.trim().length === 0) {
return { ok: false, message: t('errors.installPathRequired') }
}
if (containsHangul(target)) {
return { ok: false, message: t('errors.installPathHangul') }
}
const absolute = path.resolve(target)
state.installPath = absolute
return { ok: true, message: absolute }
})
ipcMain.handle('jdk:detect', async () => {
const candidates: string[] = []
if (process.env.JAVA_HOME) candidates.push(process.env.JAVA_HOME)
if (process.env.JDK_HOME) candidates.push(process.env.JDK_HOME)
// 자동 설치 위치(우리 설치기가 만든 JDK)도 후보에 포함.
candidates.push(path.join(getAppDataDir(), 'jdk', 'temurin-21'))
candidates.push('C:\\Program Files\\Java')
for (const candidate of candidates) {
if (!candidate) continue
try {
const stat = await fsp.stat(candidate)
if (stat.isFile()) {
return { found: true, path: candidate }
}
if (stat.isDirectory()) {
const javaExe = path.join(candidate, 'bin', process.platform === 'win32' ? 'java.exe' : 'java')
if (fs.existsSync(javaExe)) {
return { found: true, path: candidate }
}
const entries = await fsp.readdir(candidate)
for (const entry of entries) {
const child = path.join(candidate, entry)
const childJava = path.join(child, 'bin', process.platform === 'win32' ? 'java.exe' : 'java')
if (fs.existsSync(childJava)) {
return { found: true, path: child }
}
}
}
} catch {
continue
}
}
return { found: false, path: '' }
})
// ── JDK 자동 설치(Temurin 21, 취소 가능) ──────────────────────────────
interface JdkInstallState {
controller: AbortController | null
destDir: string | null
inProgress: boolean
}
const jdkInstall: JdkInstallState = { controller: null, destDir: null, inProgress: false }
function downloadStream(
url: string,
target: string,
signal: AbortSignal,
onProgress?: (loaded: number, total: number) => void
): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new Error(t('errors.canceled')))
return
}
const u = new URL(url)
const transport = u.protocol === 'https:' ? https : http
const fileStream = fs.createWriteStream(target)
let settled = false
const onAbort = () => {
try { req.destroy(new Error(t('errors.canceled'))) } catch { /* noop */ }
try { fileStream.close() } catch { /* noop */ }
}
signal.addEventListener('abort', onAbort)
const req = transport.get(u, { timeout: 120000 }, (res) => {
const sc = res.statusCode ?? 0
if (sc === 301 || sc === 302 || sc === 307 || sc === 308) {
const redirect = res.headers.location
if (redirect) {
res.resume()
fileStream.close(() => {
signal.removeEventListener('abort', onAbort)
downloadStream(new URL(redirect, u).toString(), target, signal, onProgress).then(resolve, reject)
})
return
}
}
if (sc >= 400) {
res.resume()
fileStream.close(() => {
signal.removeEventListener('abort', onAbort)
if (!settled) { settled = true; reject(new Error(`HTTP ${sc}`)) }
})
return
}
const total = Number(res.headers['content-length'] ?? 0)
let loaded = 0
res.on('data', (chunk: Buffer) => {
loaded += chunk.length
if (onProgress) onProgress(loaded, total)
})
res.pipe(fileStream)
fileStream.on('finish', () => fileStream.close(() => {
signal.removeEventListener('abort', onAbort)
if (!settled) { settled = true; resolve() }
}))
res.on('error', (err) => {
signal.removeEventListener('abort', onAbort)
if (!settled) { settled = true; reject(err) }
})
})
req.on('error', (err) => {
signal.removeEventListener('abort', onAbort)
fileStream.close(() => {})
if (!settled) { settled = true; reject(err) }
})
req.on('timeout', () => req.destroy(new Error(t('errors.requestTimeout'))))
})
}
ipcMain.handle('jdk:install', async (): Promise<{ ok: boolean; path?: string; message?: string }> => {
if (jdkInstall.inProgress) {
return { ok: false, message: t('errors.jdkBusy') }
}
jdkInstall.inProgress = true
const controller = new AbortController()
jdkInstall.controller = controller
const tmpRoot = path.join(getAppDataDir(), 'jdk-cache')
await fsp.mkdir(tmpRoot, { recursive: true })
const tempZip = path.join(tmpRoot, `temurin-21-${Date.now()}.zip`)
const destDir = path.join(getAppDataDir(), 'jdk', 'temurin-21')
jdkInstall.destDir = destDir
try {
// Adoptium API v3: latest GA JDK 21 Windows x64. 본문은 307 로 GitHub 릴리즈로 리다이렉트.
const url = 'https://api.adoptium.net/v3/binary/latest/21/ga/windows/x64/jdk/hotspot/normal/eclipse?project=jdk'
sendLog(t('log.jdkInstallStart'))
let lastPctReported = -1
await downloadStream(url, tempZip, controller.signal, (loaded, total) => {
if (total > 0) {
const pct = Math.floor((loaded / total) * 100)
if (pct >= lastPctReported + 5) {
lastPctReported = pct
sendLog(t('log.jdkDownloadProgress', {
percent: pct,
loaded: Math.floor(loaded / 1024 / 1024),
total: Math.floor(total / 1024 / 1024)
}))
}
}
})
if (controller.signal.aborted) throw new Error(t('errors.canceled'))
sendLog(t('log.jdkExtracting'))
await fsp.rm(destDir, { recursive: true, force: true })
await fsp.mkdir(destDir, { recursive: true })
await extractZip(tempZip, { dir: destDir })
// Adoptium ZIP 은 jdk-21.x.x+... 하위 폴더로 감싸져 있다. 그 폴더의 bin/java.exe 가 실제 자바.
let javaRoot = destDir
const inner = await fsp.readdir(destDir, { withFileTypes: true })
const innerJdk = inner.find((entry) => entry.isDirectory() && /^jdk-/i.test(entry.name))
if (innerJdk) javaRoot = path.join(destDir, innerJdk.name)
const javaExe = path.join(javaRoot, 'bin', process.platform === 'win32' ? 'java.exe' : 'java')
if (!fs.existsSync(javaExe)) {
throw new Error(t('errors.javaExeMissing', { path: javaExe }))
}
sendLog(t('log.jdkDoneRoot', { path: javaRoot }))
return { ok: true, path: javaRoot }
} catch (err) {
const msg = (err as Error).message || String(err)
if (controller.signal.aborted || /취소/.test(msg)) {
sendLog(t('log.jdkCanceled'))
try { await fsp.rm(destDir, { recursive: true, force: true }) } catch { /* noop */ }
return { ok: false, message: t('errors.canceledShort') }
}
sendLog(t('log.jdkInstallFailedLog', { message: msg }))
return { ok: false, message: msg }
} finally {
try { await fsp.rm(tempZip, { force: true }) } catch { /* noop */ }
jdkInstall.inProgress = false
jdkInstall.controller = null
jdkInstall.destDir = null
}
})
ipcMain.handle('jdk:cancelInstall', async (): Promise<{ ok: boolean }> => {
if (jdkInstall.controller) {
jdkInstall.controller.abort()
sendLog(t('log.jdkCancelRequested'))
}
return { ok: true }
})
/**
* 입력값이 절대 URL이면 그대로, 상대값이면 manifest 도메인의 /file/<subDir>/<file> 로 해석.
*/
function resolveManifestRelative(input: string, subDir: string): string {
if (!input) return ''
if (/^https?:\/\//i.test(input)) return input
const fileName = input.replace(/^\/+/, '')
return `${state.baseUrl}/file/${subDir}/${fileName}`
}
async function downloadAndExtractZip(url: string, label: string, extractDir: string): Promise<void> {
await fsp.mkdir(extractDir, { recursive: true })
const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'mq-zip-'))
const tempZip = path.join(tempDir, 'package.zip')
try {
sendLog(t('log.labelDownload', { label, url }))
await downloadFile(url, tempZip)
sendLog(t('log.labelExtract', { label, dir: extractDir }))
await extractZip(tempZip, { dir: extractDir })
} finally {
await fsp.rm(tempDir, { recursive: true, force: true })
}
}
async function downloadServerZip(pack: PackDefinition, targetDir: string): Promise<void> {
if (!pack.serverPath) {
sendLog(t('log.skipServerZip'))
return
}
const url = resolveManifestRelative(pack.serverPath, 'servers')
await downloadAndExtractZip(url, t('log.labelServerFile'), targetDir)
}
/**
* 설치러가 saves/ 에 풀어놓은 최상위 폴더(또는 파일) 목록을 기록하는 마커 파일.
* 재설치 시 잔여물을 안전하게 정리하고, 싱글→참가자 전환 시에도
* 사용자가 직접 만든 월드는 보존한 채 설치러가 만든 맵만 제거하기 위함이다.
*/
const INSTALLER_MAP_MARKER = '.musicquiz-installer-map.json'
async function readInstallerMapMarker(customRoot: string): Promise<string[]> {
const markerPath = path.join(customRoot, 'saves', INSTALLER_MAP_MARKER)
try {
const raw = await fsp.readFile(markerPath, 'utf8')
const data = JSON.parse(raw) as { entries?: unknown }
if (Array.isArray(data.entries)) {
return data.entries.filter((s): s is string => typeof s === 'string')
}
} catch {
// 마커가 없거나 파싱 실패 — 빈 목록 반환
}
return []
}
async function writeInstallerMapMarker(customRoot: string, entries: string[]): Promise<void> {
const savesDir = path.join(customRoot, 'saves')
await fsp.mkdir(savesDir, { recursive: true })
const markerPath = path.join(savesDir, INSTALLER_MAP_MARKER)
await fsp.writeFile(markerPath, JSON.stringify({ entries }, null, 2), 'utf8')
}
async function cleanupInstallerMap(customRoot: string): Promise<void> {
const savesDir = path.join(customRoot, 'saves')
const entries = await readInstallerMapMarker(customRoot)
if (entries.length === 0) return
sendLog(t('log.cleanupInstallerMap', { count: entries.length }))
for (const name of entries) {
// 안전장치: 경로 구분자/상대경로 토큰이 섞인 항목은 무시
if (!name || name.includes('/') || name.includes('\\') || name === '.' || name === '..') continue
const target = path.join(savesDir, name)
await fsp.rm(target, { recursive: true, force: true })
}
await fsp.rm(path.join(savesDir, INSTALLER_MAP_MARKER), { force: true })
}
/**
* Windows 폴더 이름으로 쓸 수 없는 문자를 모두 `_` 로 치환.
* 금지 문자: `<>:"/\|?*` 와 제어 문자(0x00~0x1f)
* 추가 제한: 끝의 공백/마침표 제거, 빈 문자열 fallback, 예약 이름(CON, NUL 등) 회피.
* 참고: https://learn.microsoft.com/windows/win32/fileio/naming-a-file
*/
function sanitizeMapFolderName(name: string): string {
let cleaned = (name || '').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
cleaned = cleaned.replace(/[ .]+$/, '')
if (!cleaned) cleaned = 'map'
if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(cleaned)) cleaned = '_' + cleaned
return cleaned
}
async function downloadMapZip(pack: PackDefinition, customRoot: string): Promise<void> {
if (!pack.mapPath) {
sendLog(t('log.skipMapZip'))
return
}
// 이전 설치러가 풀어놓은 맵이 남아 있으면 먼저 제거 (다른 팩/재설치 시 잔여물 방지).
await cleanupInstallerMap(customRoot)
const url = resolveManifestRelative(pack.mapPath, 'maps')
const savesDir = path.join(customRoot, 'saves')
await fsp.mkdir(savesDir, { recursive: true })
// zip 의 최상위 구조(단일 폴더 / 루트에 level.dat) 와 관계없이 최종 폴더 이름이
// 항상 퀴즈 이름이 되도록, 우선 saves/ 안의 임시 폴더에 풀고 적절히 옮긴다.
// saves 와 같은 디렉터리에서 만들기 때문에 rename 이 cross-device 실패 없이 동작.
const tempExtractDir = await fsp.mkdtemp(path.join(savesDir, '.mq-map-extract-'))
try {
await downloadAndExtractZip(url, t('log.labelMap'), tempExtractDir)
// zip 이 단일 최상위 폴더면 그 안을 월드 콘텐츠로, 아니면 임시 디렉터리 자체가
// 월드 콘텐츠(level.dat 등이 루트). 어느 쪽이든 결과적으로 saves/<퀴즈이름>/ 로.
const entries = await fsp.readdir(tempExtractDir)
let sourceDir = tempExtractDir
if (entries.length === 1) {
const candidate = path.join(tempExtractDir, entries[0])
const stat = await fsp.stat(candidate).catch(() => null)
if (stat?.isDirectory()) sourceDir = candidate
}
const desired = sanitizeMapFolderName(pack.name)
// 사용자가 직접 만든 동명 월드와 겹치면 덮어쓰지 않고 _2, _3 … 으로 회피.
let target = desired
let suffix = 2
while (fs.existsSync(path.join(savesDir, target))) {
target = `${desired}_${suffix}`
suffix++
}
const targetDir = path.join(savesDir, target)
await fsp.rename(sourceDir, targetDir)
sendLog(t('log.mapInstalledAs', { name: target }))
await writeInstallerMapMarker(customRoot, [target])
} finally {
// sourceDir 가 tempExtractDir 자체였다면 rename 으로 사라졌고, 단일 하위 폴더였다면
// 비어 있는 껍데기만 남아 있다. 어느 경우든 안전하게 정리.
await fsp.rm(tempExtractDir, { recursive: true, force: true })
}
}
async function downloadModsFolder(pack: PackDefinition, customRoot: string): Promise<void> {
// 바닐라 팩(modsFolder 비어 있음)은 모드 자체와 무관하므로 기존 mods/ 를 건드리지
// 않는다 — 사용자가 다른 곳에서 받아 둔 모드까지 지워버리는 부작용 방지.
if (!pack.modsFolder) {
sendLog(t('log.skipModsFolder'))
return
}
const modsDir = path.join(customRoot, 'mods')
// 모드팩인 경우엔 이전 버전/이전 팩 모드가 섞이면 로딩이 실패하므로 매번 비우고 받는다.
sendLog(t('log.clearMods', { dir: modsDir }))
await fsp.rm(modsDir, { recursive: true, force: true })
await fsp.mkdir(modsDir, { recursive: true })
const indexUrl = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/index.json`
sendLog(t('log.modsIndexFetch', { url: indexUrl }))
const listing = await fetchJson<{ files?: unknown }>(indexUrl)
const files = Array.isArray(listing.files)
? listing.files.filter((name): name is string => typeof name === 'string' && /\.jar$/i.test(name))
: []
if (files.length === 0) {
sendLog(t('log.modsFolderEmpty', { folder: pack.modsFolder }))
return
}
for (const fileName of files) {
const url = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/${encodeURIComponent(fileName)}`
const target = path.join(modsDir, fileName)
sendLog(t('log.modDownload', { file: fileName }))
await downloadFile(url, target)
}
}
async function downloadResourcepackZip(pack: PackDefinition, customRoot: string): Promise<void> {
if (!pack.resourcepackPath) {
sendLog(t('log.skipResourcepack'))
return
}
const url = `${state.baseUrl}/file/resourcepacks/${pack.resourcepackPath.replace(/^\/+/, '')}`
const target = path.join(customRoot, 'resourcepacks', pack.resourcepackPath.replace(/^\/+/, ''))
await fsp.mkdir(path.dirname(target), { recursive: true })
sendLog(t('log.resourcepackDownload', { url }))
await downloadFile(url, target)
}
ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) => {
const pack = state.packs.get(payload.packKey)
if (!pack) throw new Error(t('errors.packNotFound2'))
if (containsHangul(payload.installPath)) {
throw new Error(t('errors.installPathHangulShort'))
}
const installPath = path.resolve(payload.installPath)
state.installPath = installPath
await fsp.mkdir(installPath, { recursive: true })
sendLog(t('log.serverInstallPath', { path: installPath }))
await downloadServerZip(pack.pack, installPath)
// 다운로드한 zip에 들어있을 수 있는 eula.txt를 그대로 보존한다.
// 동의 흐름은 renderer가 별도 IPC로 읽고 동의 시 덮어쓴다.
// run.bat 에 서버 기동/종료시 UPnP 자동 등록/해제 로직 주입.
// 이렇게 해야 서버가 안 떠 있는 동안에는 포트가 닫혀 있게 된다.
await injectUpnpToRunBat(installPath)
})
/**
* 추출된 서버 zip 의 run.bat 에 UPnP 자동 등록(서버 시작 시) / 자동 해제(서버 종료 후)
* 스크립트를 끼워 넣는다. 이미 우리가 주입했던 마커가 있으면 다시 건드리지 않는다.
*
* 동작:
* 1) 서버 시작 직전: server.properties 의 server-port 값(없으면 25565) 으로 PowerShell
* 을 통해 HNetCfg.NATUPnP.1 COM 객체를 이용해 정적 포트 매핑 추가.
* 2) 서버 프로세스 종료 후(=pause 직전 또는 파일 끝): 동일한 포트의 매핑 제거.
*
* 제한: 사용자가 콘솔 창을 X 버튼으로 강제 종료하면 teardown 이 실행되지 않는다.
* 이 경우 라우터의 UPnP TTL 에 의해 자동 만료되며, 다음 실행 시 Add 전에 Remove 를
* 시도하므로 idempotent.
*/
async function injectUpnpToRunBat(installPath: string): Promise<void> {
const runBat = path.join(installPath, 'run.bat')
if (!fs.existsSync(runBat)) {
sendLog(t('log.runBatMissing'))
return
}
const MARKER = 'REM === UPNP MANAGED BY MUSICQUIZ INSTALLER ==='
const original = await fsp.readFile(runBat, 'utf8')
if (original.includes(MARKER)) {
sendLog(t('log.runBatAlreadyInjected'))
return
}
const lines = original.split(/\r?\n/)
const javaIdx = lines.findIndex((line) => /^\s*java(\.exe)?[\s"]/i.test(line))
if (javaIdx === -1) {
sendLog(t('log.runBatNoJava'))
return
}
let pauseIdx = -1
for (let i = javaIdx + 1; i < lines.length; i++) {
if (/^\s*pause\b/i.test(lines[i])) { pauseIdx = i; break }
}
if (pauseIdx === -1) pauseIdx = lines.length
// PowerShell 한 줄로 처리: server.properties 의 server-port 우선, 없으면 25565.
// Add 전에 같은 포트의 매핑이 남아 있으면 먼저 Remove 하여 idempotent 하게 만든다.
const addBlock = [
MARKER,
'REM 서버 시작 직전: server-port 추출 후 UPnP 매핑 등록.',
'set "_MQ_PORT=25565"',
'for /f "tokens=2 delims==" %%a in (\'findstr /b /c:"server-port=" server.properties 2^>nul\') do set "_MQ_PORT=%%a"',
'set "_MQ_PORT=%_MQ_PORT: =%"',
'echo [MusicQuiz] UPnP 등록 시도: TCP %_MQ_PORT%',
'powershell -NoProfile -Command "$port=[int]$env:_MQ_PORT; $ip=(Get-NetIPAddress -AddressFamily IPv4 -PrefixOrigin Dhcp,Manual -ErrorAction SilentlyContinue ^| Where-Object {$_.IPAddress -notlike \'169.254.*\' -and $_.IPAddress -ne \'127.0.0.1\'} ^| Select-Object -First 1).IPAddress; if (-not $ip) { Write-Host \'[MusicQuiz] 로컬 IPv4 검색 실패\'; exit 1 }; try { $u = New-Object -ComObject HNetCfg.NATUPnP.1; $c=$u.StaticPortMappingCollection; if ($c) { try { $c.Remove($port,\'TCP\') ^| Out-Null } catch {}; $c.Add($port,\'TCP\',$port,$ip,$true,\'MusicQuiz Minecraft Server\') ^| Out-Null; Write-Host (\'[MusicQuiz] UPnP 등록 성공: \' + $ip + \':\' + $port + \' TCP\') } else { Write-Host \'[MusicQuiz] UPnP 컬렉션 사용 불가(라우터 UPnP 꺼짐?)\' } } catch { Write-Host (\'[MusicQuiz] UPnP 등록 실패: \' + $_.Exception.Message) }"'
]
const removeBlock = [
'REM 서버 종료 후: UPnP 매핑 해제.',
'echo [MusicQuiz] UPnP 해제 시도: TCP %_MQ_PORT%',
'powershell -NoProfile -Command "$port=[int]$env:_MQ_PORT; try { $u = New-Object -ComObject HNetCfg.NATUPnP.1; $c=$u.StaticPortMappingCollection; if ($c) { $c.Remove($port,\'TCP\') ^| Out-Null; Write-Host (\'[MusicQuiz] UPnP 해제 완료: TCP \' + $port) } } catch { Write-Host (\'[MusicQuiz] UPnP 해제 실패: \' + $_.Exception.Message) }"'
]
const merged: string[] = []
merged.push(...lines.slice(0, javaIdx))
merged.push(...addBlock)
merged.push(lines[javaIdx])
merged.push(...lines.slice(javaIdx + 1, pauseIdx))
merged.push(...removeBlock)
merged.push(...lines.slice(pauseIdx))
// bat 파일은 CRLF 가 안전.
const output = merged.join('\r\n')
await fsp.writeFile(runBat, output, 'utf8')
sendLog(t('log.runBatInjected'))
}
ipcMain.handle('server:readEula', async (_event, installPath: string): Promise<{ exists: boolean; content: string }> => {
if (!installPath) return { exists: false, content: '' }
const target = path.join(path.resolve(installPath), 'eula.txt')
try {
const content = await fsp.readFile(target, 'utf8')
return { exists: true, content }
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { exists: false, content: '' }
throw error
}
})
ipcMain.handle('server:fetchMinecraftEula', async (): Promise<{ url: string; html: string }> => {
const url = 'https://www.minecraft.net/en-us/eula'
try {
const buffer = await fetchBuffer(url)
return { url, html: buffer.toString('utf8') }
} catch (error) {
sendLog(t('log.mojangEulaFetchFail', { message: (error as Error).message }))
return { url, html: '' }
}
})
ipcMain.handle('server:acceptEula', async (_event, installPath: string) => {
const target = path.join(installPath, 'eula.txt')
await fsp.writeFile(target, `# Generated by music quiz installer\neula=true\n`, 'utf8')
sendLog(t('log.eulaAccepted'))
})
ipcMain.handle('server:checkRam', async (_event, packKey: string): Promise<RamCheckResult> => {
const pack = state.packs.get(packKey)
if (!pack) throw new Error(t('errors.packNotFound2'))
const systemRamMb = Math.floor(os.totalmem() / (1024 * 1024))
if (systemRamMb >= pack.pack.serverMaxRam) {
return { systemRamMb, decision: 'maxOk', appliedRamMb: pack.pack.serverMaxRam }
}
if (systemRamMb >= pack.pack.serverMinRam) {
return { systemRamMb, decision: 'minOk', appliedRamMb: pack.pack.serverMinRam }
}
return { systemRamMb, decision: 'tooLow', appliedRamMb: 0 }
})
ipcMain.handle('server:configEditor', async (_event, installPath: string) => {
if (state.configEditorServer) {
state.configEditorServer.close()
state.configEditorServer = null
}
const port = await pickPort()
const server = http.createServer(async (req, res) => {
try {
await handleConfigEditorRequest(installPath, req, res)
} catch (error) {
res.statusCode = 500
res.setHeader('content-type', 'text/plain; charset=utf-8')
res.end(t('configEditor.serverError', { message: (error as Error).message }))
}
})
await new Promise<void>((resolve) => server.listen(port, '127.0.0.1', resolve))
state.configEditorServer = server
state.configEditorPort = port
const url = `http://127.0.0.1:${port}/`
sendLog(t('log.configEditorOpen', { url }))
await shell.openExternal(url)
return { url }
})
async function pickPort(): Promise<number> {
return new Promise((resolve, reject) => {
const probe = http.createServer()
probe.unref()
probe.on('error', reject)
probe.listen(0, '127.0.0.1', () => {
const address = probe.address()
probe.close(() => {
if (address && typeof address === 'object') resolve(address.port)
else reject(new Error(t('errors.portAllocFail')))
})
})
})
}
const SERVER_CONFIG_FILES = ['server.properties', 'bukkit.yml', 'spigot.yml', 'paper-global.yml']
async function handleConfigEditorRequest(installPath: string, req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
if (req.method === 'GET' && url.pathname === '/') {
const fileSet = await collectConfigFiles(installPath)
res.setHeader('content-type', 'text/html; charset=utf-8')
res.end(renderConfigEditorPage(fileSet))
return
}
if (req.method === 'GET' && url.pathname === '/file') {
const target = url.searchParams.get('name')
if (!target || !SERVER_CONFIG_FILES.includes(target)) {
res.statusCode = 400
res.end(t('configEditor.unknownFile'))
return
}
const filePath = path.join(installPath, target)
if (!fs.existsSync(filePath)) {
res.setHeader('content-type', 'text/plain; charset=utf-8')
res.end('')
return
}
const content = await fsp.readFile(filePath, 'utf8')
res.setHeader('content-type', 'text/plain; charset=utf-8')
res.end(content)
return
}
if (req.method === 'POST' && url.pathname === '/save') {
const body = await readBody(req)
const params = new URLSearchParams(body)
const target = params.get('name') ?? ''
const content = params.get('content') ?? ''
if (!SERVER_CONFIG_FILES.includes(target)) {
res.statusCode = 400
res.end(t('configEditor.unknownFile'))
return
}
const filePath = path.join(installPath, target)
await fsp.writeFile(filePath, content, 'utf8')
res.statusCode = 200
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify({ ok: true }))
return
}
res.statusCode = 404
res.end('Not found')
}
async function collectConfigFiles(installPath: string): Promise<string[]> {
const result: string[] = []
for (const fileName of SERVER_CONFIG_FILES) {
const filePath = path.join(installPath, fileName)
if (fs.existsSync(filePath)) result.push(fileName)
}
return result
}
function renderConfigEditorPage(fileSet: string[]): string {
const safeList = fileSet.length > 0 ? fileSet : SERVER_CONFIG_FILES.slice(0, 2)
const optionMarkup = safeList
.map((file, index) => `<option value="${file}" ${index === 0 ? 'selected' : ''}>${file}</option>`)
.join('')
const savedText = JSON.stringify(t('configEditor.saved'))
const saveFailedText = JSON.stringify(t('configEditor.saveFailed'))
return `<!doctype html>
<html lang="ko"><head><meta charset="utf-8"/><title>${t('configEditor.pageTitle')}</title>
<style>body{font-family:sans-serif;background:#0d1117;color:#e6edf3;padding:24px;}select,textarea,button{font:inherit;}textarea{width:100%;height:60vh;background:#161b22;color:#e6edf3;border:1px solid #30363d;padding:12px;border-radius:8px;}button{background:#2f81f7;color:#fff;border:none;padding:10px 16px;border-radius:8px;cursor:pointer;margin-top:12px;}small{color:#8b949e;}</style>
</head><body>
<h1>${t('configEditor.heading')}</h1>
<p><small>${t('configEditor.intro')}</small></p>
<label>${t('configEditor.targetLabel')} <select id="file">${optionMarkup}</select></label>
<textarea id="content"></textarea>
<button id="save">${t('configEditor.applyButton')}</button>
<p id="status"><small></small></p>
<script>
const file=document.getElementById('file');
const content=document.getElementById('content');
const status=document.querySelector('#status small');
async function load(){const r=await fetch('/file?name='+encodeURIComponent(file.value));content.value=await r.text();}
file.addEventListener('change',load);
document.getElementById('save').addEventListener('click',async()=>{const body=new URLSearchParams();body.set('name',file.value);body.set('content',content.value);const r=await fetch('/save',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:body.toString()});status.textContent=r.ok?${savedText}:${saveFailedText};});
load();
</script></body></html>`
}
function readBody(req: http.IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
req.on('data', (chunk: Buffer) => chunks.push(chunk))
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
req.on('error', reject)
})
}
ipcMain.handle('server:portForward', async (_event, port: number): Promise<PortForwardResult> => {
const targetPort = Number.isFinite(port) && port > 0 ? port : 25565
sendLog(t('log.portCheckStart', { port: targetPort }))
// 1차 점검 전에 우리가 이전 실행에서 만든 UPnP 매핑이 남아 있으면 먼저 제거한다.
// 이렇게 해야 "사용자 라우터 규칙이 활성화돼서 외부 접근이 가능한 상태" 와 "UPnP 매핑 덕분에 접근 가능한 상태" 가 구별된다.
// 사용자 규칙이 비활성/없으면 1차 점검은 false 가 되어 UPnP 시도 단계로 자연스럽게 넘어간다.
sendLog(t('log.upnpCleanup'))
await removeUpnpMapping(targetPort)
// 외부 IP 확보: 공용 API → 실패 시 UPnP 게이트웨이의 외부 IP로 폴백.
let externalIp = await detectExternalIpHttp()
if (externalIp) {
sendLog(t('log.externalIpHttp', { ip: externalIp }))
} else {
sendLog(t('log.externalIpHttpFail'))
externalIp = await detectExternalIpUpnp()
if (externalIp) sendLog(t('log.externalIpUpnp', { ip: externalIp }))
else sendLog(t('log.externalIpUpnpFail'))
}
// 1차 점검: 외부에서 이미 접근 가능한지 (서버가 떠 있거나, 우리가 임시 리스너 띄워서 검증).
sendLog(t('log.probeStart'))
let probe = await probePortFromOutside(targetPort, externalIp)
if (!externalIp && probe.detectedIp) externalIp = probe.detectedIp
const verdict = probe.reachable === true
? t('log.probeVerdictSuccess')
: probe.reachable === false ? t('log.probeVerdictFail') : t('log.probeVerdictUnknown')
sendLog(t('log.probeResult', { verdict, detail: probe.detail }))
if (probe.reachable === true) {
sendLog(t('log.probePreForwarded', { addr: externalIp || t('log.ipUnknown'), port: targetPort }))
return { status: 'preForwarded', externalIp, port: targetPort }
}
// UPnP 시도.
sendLog(t('log.upnpTryOpen', { port: targetPort }))
try {
await openPortViaUpnp(targetPort)
sendLog(t('log.upnpReqOk'))
} catch (error) {
const msg = (error as Error).message || String(error)
sendLog(t('log.upnpTryFail', { message: msg }))
return {
status: 'upnpFailed',
externalIp,
port: targetPort,
message: t('log.upnpFailDetail', { message: msg })
}
}
// NAT 반영 지연을 고려해 최대 3회 재점검.
for (let attempt = 1; attempt <= 3; attempt++) {
await sleep(1500)
sendLog(t('log.upnpRecheck', { attempt }))
probe = await probePortFromOutside(targetPort, externalIp)
if (!externalIp && probe.detectedIp) externalIp = probe.detectedIp
if (probe.reachable === true) {
sendLog(t('log.upnpDone', { port: targetPort }))
await removeUpnpMapping(targetPort)
return { status: 'upnpOk', externalIp, port: targetPort }
}
}
// 테스트 목적으로 만든 매핑 정리. 실제 개방은 run.bat 이 담당.
sendLog(t('log.upnpCleanupTest'))
await removeUpnpMapping(targetPort)
const reason = probe.reachable === false
? t('log.upnpFailReason1')
: t('log.upnpFailReason2', { detail: probe.detail })
sendLog(reason)
return { status: 'upnpFailed', externalIp, port: targetPort, message: reason }
})
async function detectExternalIpHttp(): Promise<string> {
const endpoints = ['https://api.ipify.org', 'https://ifconfig.me/ip', 'https://icanhazip.com']
for (const url of endpoints) {
try {
const buffer = await fetchBuffer(url)
const ip = buffer.toString('utf8').trim()
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) return ip
} catch {
// try next
}
}
return ''
}
function detectExternalIpUpnp(): Promise<string> {
return new Promise((resolve) => {
let settled = false
const finish = (ip: string) => { if (!settled) { settled = true; resolve(ip) } }
let client: ReturnType<typeof natUpnp.createClient> | null = null
try {
client = natUpnp.createClient()
} catch (err) {
sendLog(t('log.upnpClientFail', { message: (err as Error).message }))
finish('')
return
}
const timer = setTimeout(() => {
sendLog(t('log.upnpExternalTimeout'))
try { client && client.close() } catch {}
finish('')
}, 8000)
client.externalIp((err: Error | null, ip?: string) => {
clearTimeout(timer)
try { client && client.close() } catch {}
if (err || !ip) {
if (err) sendLog(t('log.upnpExternalErr', { message: err.message }))
finish('')
} else {
finish(ip)
}
})
})
}
/**
* 외부에서 우리 PC의 지정 포트가 닿는지 확인한다.
*
* 헤어핀(hairpin) NAT 미지원 가정용 라우터에서는 내부에서 자기 외부 IP로 직접 TCP 연결을
* 시도해도 실패하므로, 외부 포트체크 서비스(ifconfig.co)에게 검사를 위임한다.
*
* 1) 가능하면 임시 TCP 리스너를 해당 포트에 띄운다(서버가 아직 안 떠 있는 상태도 검증 가능).
* 포트가 이미 사용 중이면 외부 서비스 응답만으로 판정한다.
* 2) ifconfig.co/port/PORT를 호출해 외부에서 TCP 연결을 시도하게 한다.
* 3) 임시 리스너에 연결이 도달했거나 ifconfig.co가 reachable=true를 반환하면 성공.
*/
async function probePortFromOutside(
port: number,
hintIp: string
): Promise<{ reachable: boolean | null; detail: string; detectedIp: string }> {
// 1) 임시 리스너 바인딩 시도.
let server: net.Server | null = null
let listenerBound = false
try {
server = net.createServer()
await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => { server!.removeListener('error', onError); reject(err) }
server!.once('error', onError)
server!.listen(port, '0.0.0.0', () => {
server!.removeListener('error', onError)
listenerBound = true
resolve()
})
})
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (code === 'EADDRINUSE') {
sendLog(t('log.portInUse', { port }))
} else {
sendLog(t('log.listenerBindFail', { message: (err as Error).message }))
}
try { server && server.close() } catch {}
server = null
}
let gotInboundConnection = false
const inboundPromise = new Promise<void>((resolve) => {
if (!server) { resolve(); return }
const onConn = (sock: net.Socket) => {
gotInboundConnection = true
try { sock.end() } catch {}
try { sock.destroy() } catch {}
resolve()
}
server.on('connection', onConn)
})
// 2) 외부 서비스 트리거.
const externalProbe = fetchIfconfigCoPort(port).catch((err) => ({
ok: false as const,
error: (err as Error).message
}))
// 외부 연결 도달 또는 12초 타임아웃 중 빠른 것을 기다린다.
await Promise.race([
inboundPromise,
sleep(12000)
])
const externalResult = await externalProbe
try { server && server.close() } catch {}
// 3) 판정.
let reachable: boolean | null = null
const details: string[] = []
if (listenerBound) {
details.push(t('log.detailListenerHit', { value: gotInboundConnection ? 'yes' : 'no' }))
if (gotInboundConnection) reachable = true
} else {
details.push(t('log.detailListenerSkip'))
}
let detectedIp = ''
if ('ok' in externalResult && externalResult.ok) {
details.push(t('log.detailIfconfig', { reachable: String(externalResult.reachable), ip: externalResult.ip || '?' }))
detectedIp = externalResult.ip || ''
if (externalResult.reachable === true) reachable = true
else if (reachable !== true && externalResult.reachable === false) reachable = false
} else if ('ok' in externalResult && !externalResult.ok) {
details.push(t('log.detailIfconfigFail', { error: (externalResult as { error: string }).error }))
}
// 임시 리스너가 떴고 외부 서비스도 닿지 않았다면 명확한 false.
if (reachable === null && listenerBound && !gotInboundConnection) reachable = false
return {
reachable,
detail: details.join(', ') || t('log.detailNone'),
detectedIp: detectedIp || hintIp || ''
}
}
function fetchIfconfigCoPort(port: number): Promise<{ ok: true; reachable: boolean | null; ip: string } | { ok: false; error: string }> {
return new Promise((resolve) => {
const target = new URL(`https://ifconfig.co/port/${port}`)
const req = https.get(target, {
timeout: 15000,
headers: { 'Accept': 'application/json', 'User-Agent': 'MusicQuiz-Installer' }
}, (res) => {
if ((res.statusCode ?? 0) >= 400) {
res.resume()
resolve({ ok: false, error: `HTTP ${res.statusCode}` })
return
}
const chunks: Buffer[] = []
res.on('data', (c: Buffer) => chunks.push(c))
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8').trim()
try {
const json = JSON.parse(text)
const reachable = typeof json.reachable === 'boolean' ? json.reachable : null
const ip = typeof json.ip === 'string' ? json.ip : ''
resolve({ ok: true, reachable, ip })
} catch (err) {
resolve({ ok: false, error: t('errors.parseResponseFailed', { snippet: text.slice(0, 80) }) })
}
})
})
req.on('error', (err) => resolve({ ok: false, error: err.message }))
req.on('timeout', () => req.destroy(new Error(t('errors.requestTimeout15s'))))
})
}
function removeUpnpMapping(port: number): Promise<void> {
return new Promise((resolve) => {
let settled = false
const done = () => { if (!settled) { settled = true; resolve() } }
let client: ReturnType<typeof natUpnp.createClient> | null = null
try {
client = natUpnp.createClient()
} catch (err) {
sendLog(t('log.upnpClientFailRemove', { message: (err as Error).message }))
done()
return
}
const timer = setTimeout(() => {
try { client && client.close() } catch {}
sendLog(t('log.upnpRemoveTimeout'))
done()
}, 8000)
client.portUnmapping({ public: port, protocol: 'tcp' }, (err: Error | null) => {
clearTimeout(timer)
try { client && client.close() } catch {}
if (err) sendLog(t('log.upnpRemoveAttempt', { message: err.message }))
else sendLog(t('log.upnpRemoveDone', { port }))
done()
})
})
}
function openPortViaUpnp(port: number): Promise<void> {
return new Promise((resolve, reject) => {
let settled = false
const done = (err?: Error) => {
if (settled) return
settled = true
if (err) reject(err)
else resolve()
}
let client: ReturnType<typeof natUpnp.createClient> | null = null
try {
client = natUpnp.createClient()
} catch (err) {
done(err as Error)
return
}
const timer = setTimeout(() => {
try { client && client.close() } catch {}
done(new Error(t('errors.upnpTimeout')))
}, 15000)
client.portMapping(
{ public: port, private: port, ttl: 0, description: 'MusicQuiz Server', protocol: 'tcp' },
(error: Error | null) => {
clearTimeout(timer)
try { client && client.close() } catch {}
done(error || undefined)
}
)
})
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) => {
const pack = state.packs.get(payload.packKey)
if (!pack) throw new Error(t('errors.packNotFound2'))
const customRoot = path.join(getAppDataDir(), '.mc_custom')
await fsp.mkdir(path.join(customRoot, 'mods'), { recursive: true })
await fsp.mkdir(path.join(customRoot, 'resourcepacks'), { recursive: true })
// 사용자가 기존 .minecraft 에 저장해둔 설정(options.txt, servers.dat 등)을
// .mc_custom 으로 가져온다. 이미 있는 파일은 보존.
await copyMinecraftUserSettings(customRoot)
if (payload.installPlatform && pack.pack.platform.type === 'fabric') {
await installFabricLoader(pack.pack, customRoot)
} else if (payload.installPlatform && pack.pack.platform.type !== 'vanilla' && pack.pack.platform.downloadUrl) {
const platformUrl = resolveManifestRelative(pack.pack.platform.downloadUrl, 'platforms')
const cacheDir = path.join(customRoot, 'platform-cache')
await fsp.mkdir(cacheDir, { recursive: true })
const installerPath = path.join(cacheDir, deriveFileName(platformUrl) || 'platform-installer.jar')
sendLog(t('log.platformDownload', { type: pack.pack.platform.type, url: platformUrl }))
await downloadFile(platformUrl, installerPath)
sendLog(t('log.platformSaved', { path: installerPath }))
} else if (!payload.installPlatform) {
sendLog(t('log.platformSkipped'))
}
await downloadModsFolder(pack.pack, customRoot)
await downloadResourcepackZip(pack.pack, customRoot)
if (payload.skipMap) {
// 참가자 모드: 이전 설치 흐름에서 설치러가 풀어둔 맵이 있다면 제거한다.
// 사용자가 직접 만든 월드는 마커에 포함되지 않으므로 그대로 보존된다.
await cleanupInstallerMap(customRoot)
sendLog(t('log.skipMapZip'))
} else {
await downloadMapZip(pack.pack, customRoot)
}
// 런처가 .mc_custom 을 gameDir 로 잡아도 assets/libraries/versions 를
// 찾을 수 있도록 .minecraft 의 해당 폴더로 junction 링크.
await linkMinecraftRuntimeDirs(customRoot)
await updateLauncherProfile(pack.pack, customRoot)
})
interface FabricInstallerMeta {
url: string
version: string
stable: boolean
}
async function installFabricLoader(pack: PackDefinition, customRoot: string): Promise<void> {
const loaderVersion = pack.platform.loaderVersion
if (!loaderVersion) {
throw new Error(t('errors.fabricLoaderRequired'))
}
// 0) 이미 설치돼 있으면 건너뛴다. fabric-installer 는 매번 jar 를 지우고
// 다시 쓰려고 시도해서, 마인크래프트나 다른 프로세스가 그 파일을 잡고
// 있으면 FileSystemException 으로 실패한다. 결과 파일이 그대로 있으면
// 재실행할 필요가 없으므로 그냥 통과.
const versionId = `fabric-loader-${loaderVersion}-${pack.mcVersion}`
const versionDir = path.join(customRoot, 'versions', versionId)
const versionJar = path.join(versionDir, `${versionId}.jar`)
const versionJson = path.join(versionDir, `${versionId}.json`)
if (fs.existsSync(versionJar) && fs.existsSync(versionJson)) {
sendLog(t('log.fabricAlreadyInstalled', { id: versionId, dir: versionDir }))
return
}
// 1) 최신 fabric-installer 메타데이터 조회.
sendLog(t('log.fabricFetchInstallerList'))
const installerList = await fetchJson<FabricInstallerMeta[]>('https://meta.fabricmc.net/v2/versions/installer')
if (!installerList || installerList.length === 0) {
throw new Error(t('errors.fabricInstallerListEmpty'))
}
const latest = installerList.find((item) => item.stable) || installerList[0]
sendLog(t('log.fabricInstallerDownload', { version: latest.version, url: latest.url }))
// 2) installer jar 캐시.
const cacheDir = path.join(customRoot, 'platform-cache')
await fsp.mkdir(cacheDir, { recursive: true })
const installerJar = path.join(cacheDir, `fabric-installer-${latest.version}.jar`)
await downloadFile(latest.url, installerJar)
// 3) Java 실행파일 확보.
const javaCmd = await findJavaExecutable()
sendLog(t('log.javaUsed', { path: javaCmd }))
// 4) fabric-installer CLI 자동 실행.
// client 모드 + -noprofile: launcher_profiles.json 은 우리 코드가 직접 갱신하므로 fabric-installer 가 덮어쓰지 않게 한다.
const args = [
'-jar', installerJar,
'client',
'-mcversion', pack.mcVersion,
'-loader', loaderVersion,
'-dir', customRoot,
'-noprofile'
]
sendLog(t('log.fabricInstallStart', { mc: pack.mcVersion, loader: loaderVersion, dir: customRoot }))
await runJavaProcess(javaCmd, args)
sendLog(t('log.fabricInstallDone'))
}
async function findJavaExecutable(): Promise<string> {
const javaName = process.platform === 'win32' ? 'java.exe' : 'java'
// 1) JAVA_HOME 우선.
const javaHome = process.env.JAVA_HOME
if (javaHome) {
const exe = path.join(javaHome, 'bin', javaName)
if (fs.existsSync(exe)) return exe
}
// 2) 마인크래프트 런처가 번들한 자바 런타임. .minecraft\runtime\<name>\<os>\<name>\bin\java.exe 구조.
try {
const runtimeBase = path.join(getAppDataDir(), '.minecraft', 'runtime')
if (fs.existsSync(runtimeBase)) {
const priority = [
'java-runtime-delta',
'java-runtime-gamma',
'java-runtime-beta',
'java-runtime-alpha',
'java-runtime-legacy',
'jre-legacy'
]
const names = await fsp.readdir(runtimeBase)
const sorted = names.slice().sort((a, b) => {
const ia = priority.indexOf(a)
const ib = priority.indexOf(b)
if (ia === -1 && ib === -1) return 0
if (ia === -1) return 1
if (ib === -1) return -1
return ia - ib
})
for (const name of sorted) {
const dir = path.join(runtimeBase, name)
try {
const osDirs = await fsp.readdir(dir)
for (const osDir of osDirs) {
const exe = path.join(dir, osDir, name, 'bin', javaName)
if (fs.existsSync(exe)) return exe
}
} catch {
// skip
}
}
}
} catch {
// skip
}
// 3) PATH 폴백.
return javaName
}
function runJavaProcess(cmd: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
let stderrTail = ''
const emitLines = (chunk: Buffer, prefix: string) => {
const text = chunk.toString('utf8')
text.split(/\r?\n/).forEach((line) => {
if (line.trim().length === 0) return
sendLog(` ${prefix} ${line}`)
})
}
child.stdout?.on('data', (chunk: Buffer) => emitLines(chunk, '[fabric]'))
child.stderr?.on('data', (chunk: Buffer) => {
stderrTail += chunk.toString('utf8')
if (stderrTail.length > 4000) stderrTail = stderrTail.slice(-4000)
emitLines(chunk, '[fabric-err]')
})
child.on('error', (err) => reject(new Error(t('errors.javaSpawnFailed', { message: err.message }))))
child.on('close', (code) => {
if (code === 0) {
resolve()
} else {
const detail = stderrTail.trim().split(/\r?\n/).slice(-3).join(' | ')
reject(new Error(t('errors.fabricInstallerExit', { code: code ?? '', detail: detail ? ' — ' + detail : '' })))
}
})
})
}
function deriveFileName(url: string): string {
try {
const parsed = new URL(url)
const last = parsed.pathname.split('/').filter(Boolean).pop() ?? ''
return decodeURIComponent(last)
} catch {
return ''
}
}
function getAppDataDir(): string {
if (process.platform === 'win32' && process.env.APPDATA) return process.env.APPDATA
return app.getPath('appData')
}
/**
* 기존 javaArgs 에서 RAM 토큰만 새 값으로 교체하고 나머지 args 는 보존한다.
* - -Xmx: 항상 추천 RAM 으로 설정 (없으면 추가).
* - -Xms: 기존에 있을 때만 교체. 없으면 추가하지 않음.
* (clientMinRam 은 "유저 PC 사양 최소 요구치" 의미이지 JVM 초기 힙이 아님)
*/
function mergeRamArgs(existing: string, recommendedMb: number): string {
const newXmx = `-Xmx${recommendedMb}M`
const tokens = (existing || '').split(/\s+/).filter(Boolean)
let foundXmx = false
const merged = tokens.map((t) => {
if (t.startsWith('-Xmx')) { foundXmx = true; return newXmx }
return t
})
if (!foundXmx) merged.unshift(newXmx)
return merged.join(' ').trim()
}
// Aikar 권장 G1 GC 튜닝 셋의 기본형. 메모리(-Xms/-Xmx) 는 mergeRamArgs 에서 별도 처리.
const DEFAULT_JVM_TUNING_FLAGS = [
'-XX:+UnlockExperimentalVMOptions',
'-XX:+UseG1GC',
'-XX:G1NewSizePercent=20',
'-XX:G1ReservePercent=20',
'-XX:MaxGCPauseMillis=50',
'-XX:G1HeapRegionSize=32M'
]
/**
* 기존 javaArgs 에 JVM 튜닝 플래그를 병합. 사용자가 이미 동일 key 를 지정했으면
* 그 값을 존중하고(덮어쓰지 않음), 없는 항목만 끝에 덧붙인다.
* -XX:+UseG1GC, -XX:-UseG1GC → key = "-XX:UseG1GC"
* -XX:G1NewSizePercent=20 → key = "-XX:G1NewSizePercent"
* -Xmx2G → key = "-Xmx"
*/
function mergeJvmTuningFlags(existing: string, flags: string[]): string {
function keyOf(token: string): string {
if (token.startsWith('-XX:')) {
const body = token.slice(4)
const stripped = body.startsWith('+') || body.startsWith('-') ? body.slice(1) : body
const eqIdx = stripped.indexOf('=')
return `-XX:${eqIdx >= 0 ? stripped.slice(0, eqIdx) : stripped}`
}
const eqIdx = token.indexOf('=')
if (eqIdx >= 0) return token.slice(0, eqIdx)
if (token.startsWith('-Xmx')) return '-Xmx'
if (token.startsWith('-Xms')) return '-Xms'
if (token.startsWith('-Xmn')) return '-Xmn'
return token
}
const tokens = (existing || '').split(/\s+/).filter(Boolean)
const haveKeys = new Set(tokens.map(keyOf))
const additions: string[] = []
for (const f of flags) {
if (!haveKeys.has(keyOf(f))) additions.push(f)
}
if (additions.length === 0) return existing
return [...tokens, ...additions].join(' ').trim()
}
/**
* launcher_profiles 의 lastVersionId 를 마인크래프트 런처가 실제로 가지고 있는 폴더 이름과 맞춘다.
* - vanilla: mcVersion 그대로 (예: "1.21.4")
* - fabric: fabric-installer 가 만드는 폴더 명명 규칙은 `fabric-loader-<loaderVer>-<mcVer>`.
* platform.loaderVersion 이 비어 있으면 .minecraft/versions 에서 같은 mcVersion 의 폴더를 탐색.
* - forge / neoforge: 사용자 환경마다 폴더 명명이 다를 수 있어 일단 mcVersion 으로 폴백.
* 추후 정밀하게 잡으려면 mods loader installer 가 만든 실제 폴더명을 탐색해야 한다.
*/
function resolveLastVersionId(pack: PackDefinition): string {
if (pack.platform.type === 'vanilla') return pack.mcVersion
if (pack.platform.type === 'fabric') {
const loader = pack.platform.loaderVersion
if (loader) return `fabric-loader-${loader}-${pack.mcVersion}`
// loaderVersion 미지정: 실제 설치된 폴더 탐색.
try {
const versionsRoot = path.join(getAppDataDir(), '.minecraft', 'versions')
if (fs.existsSync(versionsRoot)) {
const entries = fs.readdirSync(versionsRoot)
const match = entries.find((entry) =>
entry.startsWith('fabric-loader-') && entry.endsWith(`-${pack.mcVersion}`)
)
if (match) return match
}
} catch {
// fall through
}
return pack.mcVersion // 폴백: vanilla 로 실행 시도
}
// forge / neoforge: 가능한 후보 탐색.
try {
const versionsRoot = path.join(getAppDataDir(), '.minecraft', 'versions')
if (fs.existsSync(versionsRoot)) {
const entries = fs.readdirSync(versionsRoot)
const match = entries.find((entry) =>
entry.toLowerCase().includes(pack.platform.type) && entry.includes(pack.mcVersion)
)
if (match) return match
}
} catch {
// fall through
}
return pack.mcVersion
}
async function updateLauncherProfile(pack: PackDefinition, gameDir: string): Promise<void> {
const launcherPath = path.join(getAppDataDir(), '.minecraft', 'launcher_profiles.json')
if (!fs.existsSync(launcherPath)) {
sendLog(t('log.launcherProfilesMissing', { path: launcherPath }))
return
}
const raw = await fsp.readFile(launcherPath, 'utf8')
const json = JSON.parse(raw) as { profiles?: Record<string, Record<string, unknown>> }
json.profiles = json.profiles ?? {}
const profileKey = pack.name
const existingProfile = json.profiles[profileKey] ?? {}
const existingJavaArgs = typeof existingProfile.javaArgs === 'string' ? (existingProfile.javaArgs as string) : ''
const ramMerged = mergeRamArgs(existingJavaArgs, pack.serverMaxRam)
const javaArgs = mergeJvmTuningFlags(ramMerged, DEFAULT_JVM_TUNING_FLAGS)
if (existingJavaArgs !== javaArgs) {
sendLog(t('log.javaArgsUpdated', { before: existingJavaArgs, after: javaArgs }))
}
const lastVersionId = resolveLastVersionId(pack)
sendLog(t('log.lastVersionId', { id: lastVersionId }))
// 해당 version 폴더 존재 확인. 없으면 런처가 "Unable to prepare assets for download" 로 실패한다.
const versionDir = path.join(getAppDataDir(), '.minecraft', 'versions', lastVersionId)
if (!fs.existsSync(versionDir)) {
sendLog(t('log.versionMissingWarn', { id: lastVersionId }))
}
json.profiles[profileKey] = {
...existingProfile,
name: profileKey,
type: 'custom',
gameDir,
lastVersionId,
javaArgs
}
await fsp.writeFile(launcherPath, `${JSON.stringify(json, null, 2)}\n`, 'utf8')
sendLog(t('log.launcherProfilesUpdated', { profile: profileKey, dir: gameDir }))
}
/**
* 사용자가 기존에 .minecraft 에 만들어둔 설정 파일들(options.txt, optionsof.txt,
* servers.dat, usercache.json 등 최상위 파일 전부)을 .mc_custom 으로 복사한다.
* 기본 규칙은 "이미 .mc_custom 에 같은 이름의 파일이 있으면 보존" 이지만,
* ALWAYS_SYNC_FILES 목록에 든 파일(=사용자가 원래 .minecraft 에서 쓰던
* 설정을 그대로 이어 쓰고 싶은 옵션 파일들)은 매번 .minecraft 쪽으로
* 덮어써서 동기화한다.
* 디렉터리(mods/saves/versions/assets 등)는 각자 별도 처리하므로 여기서는 건드리지 않는다.
*/
const ALWAYS_SYNC_FILES = new Set([
'options.txt',
'optionsof.txt',
'optionsshaders.txt'
])
async function copyMinecraftUserSettings(customRoot: string): Promise<void> {
const mcRoot = path.join(getAppDataDir(), '.minecraft')
if (!fs.existsSync(mcRoot)) {
sendLog(t('log.minecraftRootMissing'))
return
}
let copied = 0
let skipped = 0
let synced = 0
try {
const entries = await fsp.readdir(mcRoot, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isFile()) continue
const src = path.join(mcRoot, entry.name)
const dst = path.join(customRoot, entry.name)
const dstExists = fs.existsSync(dst)
const alwaysSync = ALWAYS_SYNC_FILES.has(entry.name)
if (dstExists && !alwaysSync) {
skipped += 1
continue
}
try {
await fsp.copyFile(src, dst)
if (dstExists) synced += 1
else copied += 1
} catch (err) {
sendLog(t('log.settingCopyFail', { name: entry.name, message: (err as Error).message }))
}
}
sendLog(t('log.settingCopySummary', { copied, skipped, synced }))
} catch (err) {
sendLog(t('log.settingCopyError', { message: (err as Error).message }))
}
}
/**
* .mc_custom 에서 마인크래프트 런처가 찾는 assets/libraries/versions 를
* .minecraft 의 같은 폴더로 junction(Windows) / symlink(POSIX) 한다.
* 이미 같은 자리에 무언가 있으면 손대지 않는다.
*
* 이걸 안 하면 런처가 .mc_custom/assets 가 없다며 "Unable to prepare assets
* for download" 에러로 실행에 실패한다.
*/
async function linkMinecraftRuntimeDirs(customRoot: string): Promise<void> {
const mcRoot = path.join(getAppDataDir(), '.minecraft')
for (const dir of ['assets', 'libraries', 'versions']) {
const src = path.join(mcRoot, dir)
const dst = path.join(customRoot, dir)
if (!fs.existsSync(src)) {
sendLog(t('log.runtimeDirMissing', { dir }))
continue
}
let existing: import('node:fs').Stats | null = null
try { existing = await fsp.lstat(dst) } catch { existing = null }
if (existing) {
if (existing.isSymbolicLink()) continue // 이미 링크됨
sendLog(t('log.runtimeDirExists', { dir }))
continue
}
try {
// 'junction' 은 Windows 에서 권한 없이 만들 수 있는 디렉터리 링크.
// 다른 OS 에서는 Node 가 알아서 일반 symlink 로 처리.
await fsp.symlink(src, dst, 'junction')
sendLog(t('log.runtimeLinkCreated', { dir }))
} catch (err) {
sendLog(t('log.runtimeLinkFail', { dir, message: (err as Error).message }))
}
}
}
ipcMain.handle('finish:openServerFolder', async () => {
if (!state.installPath) return
await shell.openPath(state.installPath)
})
ipcMain.handle('finish:desktopShortcut', async () => {
if (process.platform !== 'win32' || !state.installPath) return
const desktopDir = app.getPath('desktop')
const shortcutPath = path.join(desktopDir, 'MusicQuiz Server.lnk')
const runBat = path.join(state.installPath, 'run.bat')
const ok = require('electron').shell.writeShortcutLink(shortcutPath, 'create', {
target: runBat,
cwd: state.installPath,
description: t('log.shortcutDescription')
})
sendLog(ok ? t('log.shortcutCreated', { path: shortcutPath }) : t('log.shortcutFailed'))
})
ipcMain.handle('finish:startServer', async () => {
if (!state.installPath) return
const runBat = path.join(state.installPath, 'run.bat')
if (!fs.existsSync(runBat)) {
sendLog(t('log.runBatMissingPath', { path: runBat }))
return
}
spawn('cmd.exe', ['/c', 'start', '', runBat], { cwd: state.installPath, detached: true, stdio: 'ignore' }).unref()
sendLog(t('log.serverStartRequested'))
})
ipcMain.handle('finish:startLauncher', async () => {
// 마인크래프트 런처는 두 가지 형태로 배포된다:
// 1) Win32 설치판: C:\Program Files (x86)\Minecraft Launcher\MinecraftLauncher.exe 등
// 2) MSIX(Microsoft Store) 앱: PackageFamilyName=Microsoft.4297127D64EC6_8wekyb3d8bbwe, AppId=Minecraft
// - 일반 .exe 가 아니라 shell:AppsFolder\<PFN>!<AppId> 또는 App Execution Alias 로만 띄울 수 있음.
// minecraft:// URL 스킴은 런처가 핸들러로 등록되어 있어야만 동작하고, 등록이 깨지거나 비어 있으면
// MS Store 로 폴백되므로 가장 마지막 시도로 미룬다.
if (process.platform !== 'win32') {
try {
await shell.openExternal('minecraft://')
sendLog(t('log.launcherUrlSchemeNonWin'))
} catch (err) {
sendLog(t('log.launcherFail', { message: (err as Error).message }))
}
return
}
const programFilesX86 = process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)'
const programFiles = process.env['ProgramFiles'] ?? 'C:\\Program Files'
const localAppData = process.env['LOCALAPPDATA'] ?? path.join(os.homedir(), 'AppData', 'Local')
type LauncherCandidate = { label: string; path: string; viaShell: boolean }
const candidates: LauncherCandidate[] = [
// Win32 설치판 — 실행 파일 직접 spawn.
{ label: t('candidates.winProgramFiles86'), path: path.join(programFilesX86, 'Minecraft Launcher', 'MinecraftLauncher.exe'), viaShell: false },
{ label: t('candidates.winProgramFiles'), path: path.join(programFiles, 'Minecraft Launcher', 'MinecraftLauncher.exe'), viaShell: false },
{ label: t('candidates.winLegacy86'), path: path.join(programFilesX86, 'Minecraft', 'MinecraftLauncher.exe'), viaShell: false },
{ label: t('candidates.winLegacy'), path: path.join(programFiles, 'Minecraft', 'MinecraftLauncher.exe'), viaShell: false },
{ label: t('candidates.xboxGamePass'), path: 'C:\\XboxGames\\Minecraft Launcher\\Content\\Minecraft.exe', viaShell: false },
{ label: t('candidates.npmPortable'), path: path.join(localAppData, 'Programs', 'minecraft-launcher', 'MinecraftLauncher.exe'), viaShell: false },
// App Execution Alias(MS Store 설치 시 자동 생성, reparse point 라 cmd /c start 로 띄워야 안정적).
{ label: t('candidates.appAliasMinecraft'), path: path.join(localAppData, 'Microsoft', 'WindowsApps', 'Minecraft.exe'), viaShell: true },
{ label: t('candidates.appAliasLauncher'), path: path.join(localAppData, 'Microsoft', 'WindowsApps', 'MinecraftLauncher.exe'), viaShell: true }
]
for (const cand of candidates) {
let exists = false
try { exists = fs.existsSync(cand.path) } catch { exists = false }
if (!exists) continue
try {
if (cand.viaShell) {
sendLog(t('log.launcherExecShell', { label: cand.label, path: cand.path }))
spawn('cmd.exe', ['/c', 'start', '', cand.path], { detached: true, stdio: 'ignore' }).unref()
} else {
sendLog(t('log.launcherExec', { label: cand.label, path: cand.path }))
spawn(cand.path, [], { detached: true, stdio: 'ignore' }).unref()
}
return
} catch (err) {
sendLog(t('log.launcherCandFail', { path: cand.path, message: (err as Error).message }))
}
}
// MSIX 앱 직접 실행: shell:AppsFolder\<PackageFamilyName>!<AppId>.
// 마인크래프트 런처(Java) PFN: Microsoft.4297127D64EC6_8wekyb3d8bbwe, AppId: Minecraft.
try {
const aumid = 'shell:AppsFolder\\Microsoft.4297127D64EC6_8wekyb3d8bbwe!Minecraft'
sendLog(t('log.launcherAppsFolderTry', { aumid }))
spawn('explorer.exe', [aumid], { detached: true, stdio: 'ignore' }).unref()
return
} catch (err) {
sendLog(t('log.launcherAppsFolderFail', { message: (err as Error).message }))
}
// 마지막 수단: minecraft:// URL 스킴. 런처가 없으면 MS Store 가 열린다.
try {
sendLog(t('log.launcherUrlSchemeFallback'))
await shell.openExternal('minecraft://')
} catch (err) {
sendLog(t('log.launcherUrlSchemeFail', { message: (err as Error).message }))
}
sendLog(t('log.launcherAllFail'))
})
ipcMain.handle('i18n:dict', () => localeDict)
ipcMain.handle('app:quit', () => {
// 모든 창을 닫고 앱 종료. macOS에서도 종료(설치기는 한 번 쓰고 끝이니 잔류시키지 않음).
app.quit()
})
app.whenReady().then(() => {
createMainWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
})
})
app.on('window-all-closed', () => {
if (state.configEditorServer) {
state.configEditorServer.close()
state.configEditorServer = null
}
if (process.platform !== 'darwin') app.quit()
})