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' 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 = process.env.MANIFEST_URL ?? 'http://127.0.0.1:3000/manifest.json' 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 { mainWindow = new BrowserWindow({ width: 980, height: 720, 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('요청 시간 초과'))) }) } 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(`manifest 다운로드: ${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(`pack 로드 실패 (${entry.file}): ${(error as Error).message}`) } } state.packs.clear() for (const item of results) state.packs.set(item.key, item) sendLog(`로드된 음악퀴즈: ${results.length}개`) return results }) ipcMain.handle('packs:select', async (_event, packKey: string) => { if (!state.packs.has(packKey)) { throw new Error('선택한 음악퀴즈를 찾을 수 없습니다.') } state.selectedKey = packKey sendLog(`선택: ${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: '서버 설치 경로를 입력해 주세요.' } } if (containsHangul(target)) { return { ok: false, message: '경로에 한글이 포함되면 마인크래프트 서버가 정상 동작하지 않습니다.' } } 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) 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: '' } }) /** * 입력값이 절대 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(`${label} 다운로드: ${url}`) await downloadFile(url, tempZip) sendLog(`${label} 압축 해제: ${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('서버 파일(serverPath)이 비어 있어 서버 zip 다운로드를 건너뜁니다.') return } const url = resolveManifestRelative(pack.serverPath, 'servers') await downloadAndExtractZip(url, '서버 파일', targetDir) } async function downloadMapZip(pack: PackDefinition, customRoot: string): Promise { if (!pack.mapPath) { sendLog('맵 파일(mapPath)이 비어 있어 맵 다운로드를 건너뜁니다.') return } const url = resolveManifestRelative(pack.mapPath, 'maps') const savesDir = path.join(customRoot, 'saves') await downloadAndExtractZip(url, '맵', savesDir) } async function downloadModsFolder(pack: PackDefinition, customRoot: string): Promise { if (!pack.modsFolder) { sendLog('modsFolder가 비어 있어 모드 다운로드를 건너뜁니다.') return } const indexUrl = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/index.json` sendLog(`모드 목록 조회: ${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(`/file/mods/${pack.modsFolder}/ 안에 .jar 파일이 없습니다.`) return } const modsDir = path.join(customRoot, 'mods') await fsp.mkdir(modsDir, { recursive: true }) for (const fileName of files) { const url = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/${encodeURIComponent(fileName)}` const target = path.join(modsDir, fileName) sendLog(`모드 다운로드: ${fileName}`) await downloadFile(url, target) } } async function downloadResourcepackZip(pack: PackDefinition, customRoot: string): Promise { if (!pack.resourcepackPath) { sendLog('resourcepackPath가 비어 있어 리소스팩 다운로드를 건너뜁니다.') 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(`리소스팩 다운로드: ${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('음악퀴즈를 찾을 수 없습니다.') if (containsHangul(payload.installPath)) { throw new Error('경로에 한글이 포함되면 안 됩니다.') } const installPath = path.resolve(payload.installPath) state.installPath = installPath await fsp.mkdir(installPath, { recursive: true }) sendLog(`서버 설치 경로: ${installPath}`) await downloadServerZip(pack.pack, installPath) // 다운로드한 zip에 들어있을 수 있는 eula.txt를 그대로 보존한다. // 동의 흐름은 renderer가 별도 IPC로 읽고 동의 시 덮어쓴다. }) 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(`Minecraft EULA 페이지 조회 실패: ${(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('EULA 동의 저장 완료.') }) ipcMain.handle('server:checkRam', async (_event, packKey: string): Promise => { const pack = state.packs.get(packKey) if (!pack) throw new Error('음악퀴즈를 찾을 수 없습니다.') 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(`서버 오류: ${(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(`서버 설정 편집기 실행: ${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('포트를 할당할 수 없습니다.')) }) }) }) } 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('알 수 없는 파일') 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('알 수 없는 파일') 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('') return ` 서버 설정 편집기

서버 설정 편집기

아래 파일을 직접 편집한 후 "적용" 버튼으로 저장합니다. 설치기 화면에서 다음 단계로 진행하기 전 마음껏 편집할 수 있습니다.

` } 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(`포트포워딩 점검 시작: 포트 ${targetPort}`) // 외부 IP 확보: 공용 API → 실패 시 UPnP 게이트웨이의 외부 IP로 폴백. let externalIp = await detectExternalIpHttp() if (externalIp) { sendLog(`외부 IP 확인(HTTP): ${externalIp}`) } else { sendLog('외부 IP 확인 실패(HTTP). UPnP 게이트웨이를 통한 조회 시도...') externalIp = await detectExternalIpUpnp() if (externalIp) sendLog(`외부 IP 확인(UPnP): ${externalIp}`) else sendLog('UPnP 게이트웨이에서도 외부 IP를 얻지 못했습니다.') } // 1차 점검: 외부에서 이미 접근 가능한지 (서버가 떠 있거나, 우리가 임시 리스너 띄워서 검증). sendLog('외부 포트체크 서비스(ifconfig.co)로 1차 점검합니다...') let probe = await probePortFromOutside(targetPort, externalIp) if (!externalIp && probe.detectedIp) externalIp = probe.detectedIp sendLog(`1차 점검 결과: ${probe.reachable === true ? '성공' : probe.reachable === false ? '실패' : '확인 불가'} (${probe.detail})`) if (probe.reachable === true) { sendLog(`외부에서 ${externalIp || '(IP 미상)'}:${targetPort} 접근 확인됨. 포트포워딩 됨.`) // 이미 라우터 사용자 규칙으로 포워딩 중이라면 우리가 이전에 만든 UPnP 매핑은 불필요. // 남아 있으면 중복/충돌 소지가 있어 제거 시도. await removeUpnpMapping(targetPort) return { status: 'preForwarded', externalIp, port: targetPort } } // UPnP 시도. sendLog(`UPnP로 포트 ${targetPort} 자동 개방 시도(TCP)...`) try { await openPortViaUpnp(targetPort) sendLog('UPnP portMapping 요청 성공. 외부 접근을 재확인합니다.') } catch (error) { const msg = (error as Error).message || String(error) sendLog(`UPnP 시도 실패: ${msg}`) return { status: 'upnpFailed', externalIp, port: targetPort, message: `UPnP 실패: ${msg}. 라우터에서 UPnP가 꺼져 있을 수 있습니다. 직접 포트포워딩을 해주세요.` } } // NAT 반영 지연을 고려해 최대 3회 재점검. for (let attempt = 1; attempt <= 3; attempt++) { await sleep(1500) sendLog(`UPnP 적용 후 재점검 ${attempt}/3...`) probe = await probePortFromOutside(targetPort, externalIp) if (!externalIp && probe.detectedIp) externalIp = probe.detectedIp if (probe.reachable === true) { sendLog(`UPnP로 포트 ${targetPort} 자동 개방 완료.`) return { status: 'upnpOk', externalIp, port: targetPort } } } const reason = probe.reachable === false ? 'UPnP 매핑은 등록됐지만 외부 포트체크 서비스에서 연결이 닿지 않았습니다. ISP 차단, 이중 NAT, 또는 방화벽 설정을 확인하세요.' : `외부 포트체크 결과를 받지 못했습니다(${probe.detail}). UPnP 매핑은 등록됐을 수 있습니다.` 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(`UPnP 클라이언트 생성 실패: ${(err as Error).message}`) finish('') return } const timer = setTimeout(() => { sendLog('UPnP externalIp 조회 타임아웃(8s).') 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(`UPnP externalIp 오류: ${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(`포트 ${port}이(가) 이미 사용 중. 임시 리스너 없이 외부 서비스 응답만으로 판정합니다.`) } else { sendLog(`임시 리스너 바인딩 실패: ${(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(`임시 리스너 도달=${gotInboundConnection ? 'yes' : 'no'}`) if (gotInboundConnection) reachable = true } else { details.push('임시 리스너=skip(포트 사용중)') } let detectedIp = '' if ('ok' in externalResult && externalResult.ok) { details.push(`ifconfig.co reachable=${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(`ifconfig.co 실패=${(externalResult as { error: string }).error}`) } // 임시 리스너가 떴고 외부 서비스도 닿지 않았다면 명확한 false. if (reachable === null && listenerBound && !gotInboundConnection) reachable = false return { reachable, detail: details.join(', ') || '결과 없음', 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: `응답 파싱 실패: ${text.slice(0, 80)}` }) } }) }) req.on('error', (err) => resolve({ ok: false, error: err.message })) req.on('timeout', () => req.destroy(new Error('요청 시간 초과(15s)'))) }) } 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(`UPnP 클라이언트 생성 실패(매핑 제거 단계): ${(err as Error).message}`) done() return } const timer = setTimeout(() => { try { client && client.close() } catch {} sendLog(`UPnP 매핑 제거 응답 없음(타임아웃 8s). 라우터에 우리가 만든 규칙이 없을 수 있습니다.`) done() }, 8000) client.portUnmapping({ public: port, protocol: 'tcp' }, (err: Error | null) => { clearTimeout(timer) try { client && client.close() } catch {} if (err) sendLog(`UPnP 매핑 제거 시도 결과: ${err.message} (없으면 정상)`) else sendLog(`UPnP 매핑 제거 완료(포트 ${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('UPnP 응답 없음(타임아웃 15s). 라우터의 UPnP가 꺼져 있거나 SSDP 패킷이 차단됐을 수 있습니다.')) }, 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('음악퀴즈를 찾을 수 없습니다.') 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 }) 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(`플랫폼(${pack.pack.platform.type}) 다운로드: ${platformUrl}`) await downloadFile(platformUrl, installerPath) sendLog(`플랫폼 설치파일 저장: ${installerPath} (사용자가 직접 실행하거나 마인크래프트 런처에서 인식할 수 있습니다.)`) } else if (!payload.installPlatform) { sendLog('플랫폼 설치 건너뜀. 바닐라로 진행합니다.') } await downloadModsFolder(pack.pack, customRoot) await downloadResourcepackZip(pack.pack, customRoot) await downloadMapZip(pack.pack, customRoot) // 런처가 .mc_custom 을 gameDir 로 잡아도 assets/libraries/versions 를 // 찾을 수 있도록 .minecraft 의 해당 폴더로 junction 링크. await linkMinecraftRuntimeDirs(customRoot) await updateLauncherProfile(pack.pack, customRoot) }) 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() } async function updateLauncherProfile(pack: PackDefinition, gameDir: string): Promise { const launcherPath = path.join(getAppDataDir(), '.minecraft', 'launcher_profiles.json') if (!fs.existsSync(launcherPath)) { sendLog(`launcher_profiles.json을 찾을 수 없습니다: ${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 javaArgs = mergeRamArgs(existingJavaArgs, pack.serverMaxRam) if (existingJavaArgs && existingJavaArgs !== javaArgs) { sendLog(`기존 JVM 인수 유지, -Xmx 만 갱신: "${existingJavaArgs}" → "${javaArgs}"`) } const lastVersionId = pack.platform.type === 'vanilla' ? pack.mcVersion : `${pack.mcVersion}-${pack.platform.type}` json.profiles[profileKey] = { ...existingProfile, name: profileKey, type: 'custom', gameDir, lastVersionId, javaArgs } await fsp.writeFile(launcherPath, `${JSON.stringify(json, null, 2)}\n`, 'utf8') sendLog(`launcher_profiles.json 갱신: 프로필 "${profileKey}", gameDir=${gameDir}`) } /** * .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(`.minecraft/${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(`.mc_custom/${dir} 가 실제 폴더로 이미 존재 — 건너뜀.`) continue } try { // 'junction' 은 Windows 에서 권한 없이 만들 수 있는 디렉터리 링크. // 다른 OS 에서는 Node 가 알아서 일반 symlink 로 처리. await fsp.symlink(src, dst, 'junction') sendLog(`링크 생성: .mc_custom/${dir} → .minecraft/${dir}`) } catch (err) { sendLog(`링크 생성 실패 (${dir}): ${(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: '음악퀴즈 서버 실행' }) sendLog(ok ? `바로가기 생성: ${shortcutPath}` : '바로가기 생성 실패') }) ipcMain.handle('finish:startServer', async () => { if (!state.installPath) return const runBat = path.join(state.installPath, 'run.bat') if (!fs.existsSync(runBat)) { sendLog(`run.bat을 찾을 수 없습니다: ${runBat}`) return } spawn('cmd.exe', ['/c', 'start', '', runBat], { cwd: state.installPath, detached: true, stdio: 'ignore' }).unref() sendLog('서버 실행 요청 완료.') }) ipcMain.handle('finish:startLauncher', async () => { // 1순위: minecraft:// URL 스킴. UWP(Microsoft Store) / Win32 / Xbox 앱 어떤 형태로 설치돼 // 있어도 OS의 등록된 프로토콜 핸들러가 처리하므로 가장 견고하다. try { sendLog('마인크래프트 런처 실행 요청(URL 스킴 minecraft://)...') await shell.openExternal('minecraft://') sendLog('마인크래프트 런처 실행 요청 완료.') return } catch (err) { sendLog(`URL 스킴 실행 실패: ${(err as Error).message}. 직접 경로 탐색으로 폴백합니다.`) } // 2순위: 알려진 설치 경로 탐색. 구버전 .exe 설치판 / Xbox 앱 설치 위치까지 커버. 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') const candidates = [ path.join(programFilesX86, 'Minecraft Launcher', 'MinecraftLauncher.exe'), path.join(programFiles, 'Minecraft Launcher', 'MinecraftLauncher.exe'), path.join(programFilesX86, 'Minecraft', 'MinecraftLauncher.exe'), path.join(programFiles, 'Minecraft', 'MinecraftLauncher.exe'), 'C:\\XboxGames\\Minecraft Launcher\\Content\\Minecraft.exe', path.join(localAppData, 'Programs', 'minecraft-launcher', 'MinecraftLauncher.exe') ] const target = candidates.find((candidate) => { try { return fs.existsSync(candidate) } catch { return false } }) if (target) { spawn(target, [], { detached: true, stdio: 'ignore' }).unref() sendLog(`마인크래프트 런처 실행: ${target}`) return } sendLog('Minecraft Launcher를 찾을 수 없습니다. Microsoft Store에서 "Minecraft Launcher"를 설치하거나 minecraft.net에서 받은 뒤 직접 실행해 주세요.') }) 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() })