- Login form/route accepts password only; matched account row provides session userId
- PackDefinition: replace packPath with mapPath (.mc_custom/saves) and serverPath (server install dir); editor exposes two .zip fields
- Installer resolves relative platform/map/server URLs against manifest origin under /file/{platforms,maps,servers}/<name>; downloads and extracts the zips
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
615 lines
23 KiB
TypeScript
615 lines
23 KiB
TypeScript
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'
|
|
import http from 'node:http'
|
|
import https from 'node:https'
|
|
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'
|
|
import type { Manifest, PackDefinition } from '../shared/types'
|
|
import { normalizePackDefinition } from '../shared/store'
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
|
|
const eulaPath = path.join(installPath, 'eula.txt')
|
|
if (fs.existsSync(eulaPath)) {
|
|
await fsp.unlink(eulaPath)
|
|
sendLog('기존 eula.txt 삭제, 사용자 동의를 다시 받습니다.')
|
|
}
|
|
})
|
|
|
|
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
|
|
const externalIp = await detectExternalIp()
|
|
if (await testPortReachable(externalIp, targetPort)) {
|
|
sendLog(`외부에서 ${externalIp}:${targetPort} 접근 확인됨. 포트포워딩 됨.`)
|
|
return { status: 'preForwarded', externalIp, port: targetPort }
|
|
}
|
|
try {
|
|
await openPortViaUpnp(targetPort)
|
|
if (await testPortReachable(externalIp, targetPort)) {
|
|
sendLog(`UPnP로 포트 ${targetPort} 자동 개방 완료.`)
|
|
return { status: 'upnpOk', externalIp, port: targetPort }
|
|
}
|
|
sendLog('UPnP 개방은 시도했지만 외부 접근이 확인되지 않았습니다.')
|
|
return { status: 'upnpFailed', externalIp, port: targetPort, message: '직접 포트포워딩을 해주세요.' }
|
|
} catch (error) {
|
|
sendLog(`UPnP 시도 실패: ${(error as Error).message}`)
|
|
return { status: 'upnpFailed', externalIp, port: targetPort, message: '직접 포트포워딩을 해주세요.' }
|
|
}
|
|
})
|
|
|
|
async function detectExternalIp(): Promise<string> {
|
|
try {
|
|
const buffer = await fetchBuffer('https://api.ipify.org')
|
|
return buffer.toString('utf8').trim()
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
function testPortReachable(host: string, port: number): Promise<boolean> {
|
|
if (!host) return Promise.resolve(false)
|
|
return new Promise((resolve) => {
|
|
import('node:net').then((net) => {
|
|
const socket = net.createConnection({ host, port })
|
|
socket.setTimeout(3000)
|
|
socket.once('connect', () => {
|
|
socket.end()
|
|
resolve(true)
|
|
})
|
|
socket.once('error', () => resolve(false))
|
|
socket.once('timeout', () => {
|
|
socket.destroy()
|
|
resolve(false)
|
|
})
|
|
}).catch(() => resolve(false))
|
|
})
|
|
}
|
|
|
|
function openPortViaUpnp(port: number): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const client = natUpnp.createClient()
|
|
client.portMapping({ public: port, private: port, ttl: 0, description: 'MusicQuiz Server', protocol: 'tcp' }, (error) => {
|
|
client.close()
|
|
if (error) reject(error)
|
|
else resolve()
|
|
})
|
|
})
|
|
}
|
|
|
|
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('플랫폼 설치 건너뜀. 바닐라로 진행합니다.')
|
|
}
|
|
|
|
for (const mod of pack.pack.mods) {
|
|
if (!mod.downloadUrl) continue
|
|
const target = path.join(customRoot, 'mods', deriveFileName(mod.downloadUrl) || `${mod.name}.jar`)
|
|
sendLog(`모드 다운로드: ${mod.name}`)
|
|
await downloadFile(mod.downloadUrl, target)
|
|
}
|
|
for (const resourcePack of pack.pack.resourcepacks) {
|
|
if (!resourcePack.downloadUrl) continue
|
|
const target = path.join(customRoot, 'resourcepacks', deriveFileName(resourcePack.downloadUrl) || `${resourcePack.name}.zip`)
|
|
sendLog(`리소스팩 다운로드: ${resourcePack.name}`)
|
|
await downloadFile(resourcePack.downloadUrl, target)
|
|
}
|
|
|
|
await downloadMapZip(pack.pack, 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')
|
|
}
|
|
|
|
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 javaArgs = `-Xmx${pack.serverMaxRam}M -Xms${pack.serverMinRam}M`
|
|
const lastVersionId = pack.platform.type === 'vanilla'
|
|
? pack.mcVersion
|
|
: `${pack.mcVersion}-${pack.platform.type}`
|
|
json.profiles[profileKey] = {
|
|
...(json.profiles[profileKey] ?? {}),
|
|
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}`)
|
|
}
|
|
|
|
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 () => {
|
|
const candidates = [
|
|
path.join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Minecraft Launcher', 'MinecraftLauncher.exe'),
|
|
path.join(process.env['ProgramFiles'] ?? 'C:\\Program Files', 'Minecraft Launcher', 'MinecraftLauncher.exe')
|
|
]
|
|
const target = candidates.find((candidate) => fs.existsSync(candidate))
|
|
if (!target) {
|
|
sendLog('Minecraft Launcher를 찾을 수 없습니다. 직접 실행해 주세요.')
|
|
return
|
|
}
|
|
spawn(target, [], { detached: true, stdio: 'ignore' }).unref()
|
|
sendLog('마인크래프트 런처 실행 요청 완료.')
|
|
})
|
|
|
|
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()
|
|
})
|