Files
minecraft_launcher/src/installer/main.ts
claude-bot 536e94474f installer: 런처 실행을 URL 스킴으로 보강, 완료 후 자동 종료
Minecraft Launcher 실행 핸들러가 옛 Program Files 경로 두 곳만 보고 있어서 Microsoft Store/UWP/Xbox 앱 설치 등 최근 설치 형태에서 거의 못 찾았다.

- 1순위로 shell.openExternal('minecraft://') 사용. OS에 등록된 프로토콜 핸들러가 설치 형태(UWP/Win32/Xbox)에 무관하게 처리.
- 폴백 경로 후보 확장: Program Files / Program Files (x86) 양쪽의 Minecraft, Minecraft Launcher, XboxGames 경로, LOCALAPPDATA\Programs\minecraft-launcher까지 검사.
- 못 찾았을 때 메시지에 설치처(Microsoft Store/minecraft.net) 안내 추가.

5단계 완료 버튼: 모든 단계가 끝난 뒤이므로 마무리 액션(바로가기/서버 실행/런처 실행)을 처리한 다음 app.quit으로 설치기를 자동 종료한다. 'app:quit' IPC 핸들러와 preload 노출(quitApp) 추가.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 01:13:30 +09:00

990 lines
38 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'
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 = 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<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('요청 시간 초과')))
})
}
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(`manifest 다운로드: ${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(`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<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: '서버 설치 경로를 입력해 주세요.' }
}
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/<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(`${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<void> {
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<void> {
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<void> {
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<void> {
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<RamCheckResult> => {
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<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(`서버 설정 편집기 실행: ${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('포트를 할당할 수 없습니다.'))
})
})
})
}
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('알 수 없는 파일')
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<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('')
return `<!doctype html>
<html lang="ko"><head><meta charset="utf-8"/><title>서버 설정 편집기</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>서버 설정 편집기</h1>
<p><small>아래 파일을 직접 편집한 후 "적용" 버튼으로 저장합니다. 설치기 화면에서 다음 단계로 진행하기 전 마음껏 편집할 수 있습니다.</small></p>
<label>대상 파일 <select id="file">${optionMarkup}</select></label>
<textarea id="content"></textarea>
<button id="save">적용</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?'저장 완료':'저장 실패';});
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(`포트포워딩 점검 시작: 포트 ${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<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(`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<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(`포트 ${port}이(가) 이미 사용 중. 임시 리스너 없이 외부 서비스 응답만으로 판정합니다.`)
} else {
sendLog(`임시 리스너 바인딩 실패: ${(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(`임시 리스너 도달=${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<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(`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<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('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<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('음악퀴즈를 찾을 수 없습니다.')
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<void> {
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<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 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<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(`.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()
})