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 = 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' import { LAUNCHER_PROFILE_ICON } from './launcherIcon.js' loadEnv() const i18n = loadComponentI18n('installer') const t = i18n.t export const localeDict = i18n.dict interface InstallerState { manifestUrl: string baseUrl: string packs: Map 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 { 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(url: string): Promise { const buffer = await fetchBuffer(url) return JSON.parse(buffer.toString('utf8')) as T } async function downloadFile(url: string, target: string): Promise { 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 => { 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(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>(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 }) // 약관(Markdown) 을 사이트(/manifest/terms//.md) 에서 받아와 그대로 돌려준다. // v0.3.4~ : 사이트에서 임의 kind 등록 가능 → 하드코딩 5종 화이트리스트 대신 kind 형식만 검증. const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/ ipcMain.handle('terms:get', async (_event, kind: string): Promise<{ ok: boolean; content?: string; message?: 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)}/${kind}.md` const buf = await fetchBuffer(url) return { ok: true, content: buf.toString('utf8') } } catch (error) { return { ok: false, message: (error as Error).message } } }) // 메인 인스톨러용 약관 목록. /manifest/terms//index.json 을 받아 // showInInstaller=true 인 항목만 추려 반환. 비어 있으면 렌더러가 약관 단계를 건너뛴다. ipcMain.handle('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 if (entry.showInInstaller !== 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 } } }) 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 => { 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 { 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// 로 해석. */ 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 { 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 { 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 { 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 { 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 { 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 { 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 { // 바닐라 팩(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 { 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 { 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 => { 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((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 { 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 { 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 { 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) => ``) .join('') const savedText = JSON.stringify(t('configEditor.saved')) const saveFailedText = JSON.stringify(t('configEditor.saveFailed')) return ` ${t('configEditor.pageTitle')}

${t('configEditor.heading')}

${t('configEditor.intro')}

` } function readBody(req: http.IncomingMessage): Promise { 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 => { 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 { 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 { return new Promise((resolve) => { let settled = false const finish = (ip: string) => { if (!settled) { settled = true; resolve(ip) } } let client: ReturnType | 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((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((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 { return new Promise((resolve) => { let settled = false const done = () => { if (!settled) { settled = true; resolve() } } let client: ReturnType | 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 { 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 | 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 { 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 }) try { // 사용자가 기존 .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) } finally { // 설치가 끝나면(또는 실패해도) 더 이상 필요 없는 platform-cache(다운받은 // fabric/forge/neoforge installer jar 캐시)를 삭제한다. 다음 실행에서 다시 // 받으면 되고, 남겨두면 사용자 .mc_custom 폴더만 차지한다. 실패 경로에서도 // 정리되도록 finally 에 둔다. await fsp.rm(path.join(customRoot, 'platform-cache'), { recursive: true, force: true }).catch(() => {}) } }) interface FabricInstallerMeta { url: string version: string stable: boolean } async function installFabricLoader(pack: PackDefinition, customRoot: string): Promise { 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('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 가 덮어쓰지 않게 한다. // JVM stdout 인코딩 강제 UTF-8: // 한국 윈도우의 시스템 codepage 는 cp949(MS949) 라서 fabric-installer 가 // 한글을 cp949 로 stdout 에 쓰면 우리가 utf-8 로 디코드해서 깨져 보인다. // `file.encoding` 은 default Charset, `stdout/stderr.encoding` 은 // System.out/err 의 PrintStream 인코딩(Java 18+). 둘 다 지정하면 // 구버전·신버전 JDK 모두에서 안전. const args = [ '-Dfile.encoding=UTF-8', '-Dstdout.encoding=UTF-8', '-Dstderr.encoding=UTF-8', '-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 { 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\\\\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 { 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--`. * 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 { 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> } 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', icon: LAUNCHER_PROFILE_ICON, 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 { 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 { 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\! 또는 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\!. // 마인크래프트 런처(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() })