Build music-quiz installer and management site per spec
Implements the full spec described in README.md: Management site (Node + TypeScript + Express + EJS): - Public main page lists packs registered in manifest.json. - /op login (account.json, internal-only), /op/dashboard manages packs with horizontal-scroll cards, add/select-and-delete flow, and the /op/dashboard/:packName editor (Mojang release dropdown, dynamic mods/resourcepacks lists, platform/RAM fields, file rename). - Routes for /manifest.json (public) and /file/* (server pack files). - Middleware blocks /account.json and /manifest/* directory access. Installer (Electron): - Five page renderer driven by IPC (preload contextBridge API): pack pick → single/multi → server install (path no-Korean check, JDK detect, file download, EULA, RAM gating, local web config editor, UPnP/port-forward check) → client install (.mc_custom mods + resourcepacks + launcher_profiles.json gameDir/javaArgs) → finish toggles (server folder, shortcut, server start, launcher start). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
592
src/installer/main.ts
Normal file
592
src/installer/main.ts
Normal file
@@ -0,0 +1,592 @@
|
||||
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'
|
||||
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: '' }
|
||||
})
|
||||
|
||||
async function downloadServerFiles(pack: PackDefinition, targetDir: string): Promise<void> {
|
||||
const indexUrl = `${state.baseUrl}/file/${pack.packPath.replace(/^\/+|\/+$/g, '')}`
|
||||
sendLog(`서버 파일 인덱스: ${indexUrl}`)
|
||||
let listing: string[] = []
|
||||
try {
|
||||
const directoryHtml = (await fetchBuffer(indexUrl)).toString('utf8')
|
||||
listing = Array.from(directoryHtml.matchAll(/href=\"([^\"]+)\"/g))
|
||||
.map((match) => match[1])
|
||||
.filter((href) => !href.startsWith('?') && !href.endsWith('/'))
|
||||
} catch (error) {
|
||||
sendLog(`서버 파일 인덱스 로드 실패: ${(error as Error).message}`)
|
||||
}
|
||||
|
||||
if (listing.length === 0) {
|
||||
sendLog('서버 파일 인덱스를 가져올 수 없습니다. packPath 또는 사이트 디렉토리 인덱스 설정을 확인해 주세요.')
|
||||
return
|
||||
}
|
||||
|
||||
for (const fileName of listing) {
|
||||
const targetUrl = `${indexUrl.replace(/\/$/, '')}/${fileName}`
|
||||
const target = path.join(targetDir, decodeURIComponent(fileName))
|
||||
sendLog(`다운로드: ${fileName}`)
|
||||
await downloadFile(targetUrl, 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 downloadServerFiles(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 cacheDir = path.join(customRoot, 'platform-cache')
|
||||
await fsp.mkdir(cacheDir, { recursive: true })
|
||||
const installerPath = path.join(cacheDir, deriveFileName(pack.pack.platform.downloadUrl) || 'platform-installer.jar')
|
||||
sendLog(`플랫폼(${pack.pack.platform.type}) 다운로드: ${pack.pack.platform.downloadUrl}`)
|
||||
await downloadFile(pack.pack.platform.downloadUrl, 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 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()
|
||||
})
|
||||
59
src/installer/preload.ts
Normal file
59
src/installer/preload.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type { ClientInstallPayload, FetchedPack, RamCheckResult, ServerInstallPayload, PortForwardResult } from './types'
|
||||
|
||||
const api = {
|
||||
// 1단계
|
||||
loadPacks: (manifestUrl?: string): Promise<FetchedPack[]> =>
|
||||
ipcRenderer.invoke('packs:load', manifestUrl),
|
||||
setSelectedPack: (packKey: string): Promise<void> =>
|
||||
ipcRenderer.invoke('packs:select', packKey),
|
||||
|
||||
// 3-1
|
||||
pickFolder: (): Promise<string | null> => ipcRenderer.invoke('dialog:pickFolder'),
|
||||
validateInstallPath: (target: string): Promise<{ ok: boolean; message?: string }> =>
|
||||
ipcRenderer.invoke('install:validatePath', target),
|
||||
|
||||
// 3-2
|
||||
detectJdk: (): Promise<{ found: boolean; path: string }> => ipcRenderer.invoke('jdk:detect'),
|
||||
|
||||
// 3-3
|
||||
startServerInstall: (payload: ServerInstallPayload): Promise<void> =>
|
||||
ipcRenderer.invoke('server:install', payload),
|
||||
acceptEula: (installPath: string): Promise<void> =>
|
||||
ipcRenderer.invoke('server:acceptEula', installPath),
|
||||
checkRam: (packKey: string): Promise<RamCheckResult> =>
|
||||
ipcRenderer.invoke('server:checkRam', packKey),
|
||||
|
||||
// 3-4
|
||||
startServerConfigEditor: (installPath: string): Promise<{ url: string }> =>
|
||||
ipcRenderer.invoke('server:configEditor', installPath),
|
||||
|
||||
// 3-5
|
||||
checkPortForward: (port: number): Promise<PortForwardResult> =>
|
||||
ipcRenderer.invoke('server:portForward', port),
|
||||
|
||||
// 4단계
|
||||
installClient: (payload: ClientInstallPayload): Promise<void> =>
|
||||
ipcRenderer.invoke('client:install', payload),
|
||||
|
||||
// 5단계
|
||||
openServerFolder: (): Promise<void> => ipcRenderer.invoke('finish:openServerFolder'),
|
||||
createDesktopShortcut: (): Promise<void> => ipcRenderer.invoke('finish:desktopShortcut'),
|
||||
startServer: (): Promise<void> => ipcRenderer.invoke('finish:startServer'),
|
||||
startMinecraftLauncher: (): Promise<void> => ipcRenderer.invoke('finish:startLauncher'),
|
||||
|
||||
// log stream
|
||||
onLog: (handler: (line: string) => void): (() => void) => {
|
||||
const listener = (_event: unknown, line: string) => handler(line)
|
||||
ipcRenderer.on('log', listener)
|
||||
return () => ipcRenderer.removeListener('log', listener)
|
||||
}
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('installer', api)
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
installer: typeof api
|
||||
}
|
||||
}
|
||||
41
src/installer/types.ts
Normal file
41
src/installer/types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Manifest, PackDefinition } from '../shared/types'
|
||||
|
||||
export interface InstallerConfig {
|
||||
manifestUrl: string
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
export interface FetchedPack {
|
||||
key: string
|
||||
name: string
|
||||
pack: PackDefinition
|
||||
}
|
||||
|
||||
export interface FetchedManifest {
|
||||
manifest: Manifest
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
export interface ServerInstallPayload {
|
||||
packKey: string
|
||||
installPath: string
|
||||
jdkPath: string
|
||||
}
|
||||
|
||||
export interface ClientInstallPayload {
|
||||
packKey: string
|
||||
installPlatform: boolean
|
||||
}
|
||||
|
||||
export interface RamCheckResult {
|
||||
systemRamMb: number
|
||||
decision: 'maxOk' | 'minOk' | 'tooLow'
|
||||
appliedRamMb: number
|
||||
}
|
||||
|
||||
export interface PortForwardResult {
|
||||
status: 'preForwarded' | 'upnpOk' | 'upnpFailed'
|
||||
externalIp?: string
|
||||
port: number
|
||||
message?: string
|
||||
}
|
||||
61
src/server/app.ts
Normal file
61
src/server/app.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import express from 'express'
|
||||
import session from 'express-session'
|
||||
import path from 'node:path'
|
||||
import { manifestRootPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths'
|
||||
import { indexRouter } from './routes/index'
|
||||
import { opRouter } from './routes/op'
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 3000)
|
||||
const HOST = process.env.HOST ?? '0.0.0.0'
|
||||
|
||||
const app = express()
|
||||
|
||||
app.set('view engine', 'ejs')
|
||||
app.set('views', viewsDirPath)
|
||||
app.set('trust proxy', 1)
|
||||
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
app.use(express.json())
|
||||
|
||||
app.use(session({
|
||||
secret: process.env.SESSION_SECRET ?? 'music-quiz-installer-dev-secret',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 1000 * 60 * 60 * 8
|
||||
}
|
||||
}))
|
||||
|
||||
// 외부에서 account.json, /manifest 폴더 등에 절대 접근 불가하도록 가장 먼저 차단한다.
|
||||
app.use((req, res, next) => {
|
||||
if (/^\/account\.json/i.test(req.path) || /^\/manifest\//i.test(req.path)) {
|
||||
res.status(404).send('Not Found')
|
||||
return
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
app.use('/static', express.static(publicDirPath))
|
||||
|
||||
// 외부 노출이 필요한 정적 자원만 화이트리스트로 라우팅한다.
|
||||
app.get('/manifest.json', (_req, res) => {
|
||||
res.sendFile(manifestRootPath)
|
||||
})
|
||||
|
||||
app.use('/file', express.static(fileDirPath, { fallthrough: true, index: false }))
|
||||
|
||||
app.use('/', indexRouter)
|
||||
app.use('/', opRouter)
|
||||
|
||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error(err)
|
||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||
res.status(500).send(`서버 오류: ${message}`)
|
||||
})
|
||||
|
||||
app.listen(PORT, HOST, () => {
|
||||
console.log(`[server] http://${HOST}:${PORT}`)
|
||||
console.log(`[server] views: ${path.relative(process.cwd(), viewsDirPath)}`)
|
||||
})
|
||||
19
src/server/middleware/auth.ts
Normal file
19
src/server/middleware/auth.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
userId?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
if (req.session?.userId) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
if (req.method === 'GET') {
|
||||
res.redirect('/op')
|
||||
return
|
||||
}
|
||||
res.status(401).send('인증이 필요합니다.')
|
||||
}
|
||||
23
src/server/routes/index.ts
Normal file
23
src/server/routes/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Router } from 'express'
|
||||
import { listPackKeys, loadPackDefinition, readManifest } from '../../shared/store'
|
||||
|
||||
export const indexRouter = Router()
|
||||
|
||||
indexRouter.get('/', async (_req, res, next) => {
|
||||
try {
|
||||
const manifest = await readManifest()
|
||||
const definitionMap = new Map<string, Awaited<ReturnType<typeof loadPackDefinition>>>()
|
||||
const keys = await listPackKeys()
|
||||
for (const key of keys) {
|
||||
definitionMap.set(key, await loadPackDefinition(key))
|
||||
}
|
||||
const packs = manifest.packs.map((entry) => ({
|
||||
name: entry.name,
|
||||
file: entry.file,
|
||||
definition: definitionMap.get(entry.file) ?? null
|
||||
}))
|
||||
res.render('index', { packs })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
163
src/server/routes/op.ts
Normal file
163
src/server/routes/op.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Router } from 'express'
|
||||
import {
|
||||
createPack,
|
||||
deletePackKeys,
|
||||
listPackKeys,
|
||||
loadPackDefinition,
|
||||
normalizePackDefinition,
|
||||
readAccounts,
|
||||
renamePack,
|
||||
sanitizePackKey
|
||||
} from '../../shared/store'
|
||||
import { fetchReleaseVersions } from '../../shared/mojang'
|
||||
import { requireAuth } from '../middleware/auth'
|
||||
import type { PackDefinition } from '../../shared/types'
|
||||
|
||||
export const opRouter = Router()
|
||||
|
||||
function pickFirstValue(value: unknown): string {
|
||||
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : ''
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
function pickStringArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item): item is string => typeof item === 'string')
|
||||
}
|
||||
if (typeof value === 'string') return [value]
|
||||
return []
|
||||
}
|
||||
|
||||
opRouter.get('/op', (req, res) => {
|
||||
if (req.session?.userId) {
|
||||
res.redirect('/op/dashboard')
|
||||
return
|
||||
}
|
||||
res.render('op/login', { error: null })
|
||||
})
|
||||
|
||||
opRouter.post('/op', async (req, res, next) => {
|
||||
try {
|
||||
const id = pickFirstValue(req.body.id).trim()
|
||||
const password = pickFirstValue(req.body.password)
|
||||
const accounts = await readAccounts()
|
||||
const matched = accounts.find((entry) => entry.id === id && entry.password === password)
|
||||
if (!matched) {
|
||||
res.status(401).render('op/login', { error: '아이디 또는 비밀번호가 올바르지 않습니다.' })
|
||||
return
|
||||
}
|
||||
req.session.userId = matched.id
|
||||
res.redirect('/op/dashboard')
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/logout', (req, res) => {
|
||||
req.session.destroy(() => {
|
||||
res.redirect('/op')
|
||||
})
|
||||
})
|
||||
|
||||
opRouter.get('/op/dashboard', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const keys = await listPackKeys()
|
||||
const items = await Promise.all(keys.map(async (key) => ({
|
||||
key,
|
||||
definition: await loadPackDefinition(key)
|
||||
})))
|
||||
res.render('op/dashboard', {
|
||||
userId: req.session.userId,
|
||||
items
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/dashboard/create', requireAuth, async (_req, res, next) => {
|
||||
try {
|
||||
const { key } = await createPack()
|
||||
res.redirect(`/op/dashboard/${key}`)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/dashboard/delete', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const keys = pickStringArray(req.body.targetKey)
|
||||
.map((key) => sanitizePackKey(key))
|
||||
.filter((key) => key.length > 0)
|
||||
if (keys.length > 0) {
|
||||
await deletePackKeys(keys)
|
||||
}
|
||||
res.redirect('/op/dashboard')
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.get('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
const definition = await loadPackDefinition(packKey)
|
||||
if (!definition) {
|
||||
res.status(404).send('해당 음악퀴즈를 찾을 수 없습니다.')
|
||||
return
|
||||
}
|
||||
const releases = await fetchReleaseVersions()
|
||||
res.render('op/editor', {
|
||||
userId: req.session.userId,
|
||||
packKey,
|
||||
pack: definition,
|
||||
releases
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
const requestedKey = sanitizePackKey(pickFirstValue(req.body.fileName)) || packKey
|
||||
|
||||
const modNames = pickStringArray(req.body['modName']).map((value) => value.trim())
|
||||
const modUrls = pickStringArray(req.body['modUrl']).map((value) => value.trim())
|
||||
const mods = modNames.map((name, index) => ({ name, downloadUrl: modUrls[index] ?? '' }))
|
||||
|
||||
const resourceNames = pickStringArray(req.body['resourceName']).map((value) => value.trim())
|
||||
const resourceUrls = pickStringArray(req.body['resourceUrl']).map((value) => value.trim())
|
||||
const resourcepacks = resourceNames.map((name, index) => ({ name, downloadUrl: resourceUrls[index] ?? '' }))
|
||||
|
||||
const platformType = pickFirstValue(req.body.platformType)
|
||||
const platformDownloadUrl = pickFirstValue(req.body.platformDownloadUrl).trim()
|
||||
|
||||
const partial: Partial<PackDefinition> & Record<string, unknown> = {
|
||||
name: pickFirstValue(req.body.displayName),
|
||||
mcVersion: pickFirstValue(req.body.mcVersion),
|
||||
platform: {
|
||||
type: (platformType as PackDefinition['platform']['type']) || 'vanilla',
|
||||
downloadUrl: platformDownloadUrl.length > 0 ? platformDownloadUrl : undefined
|
||||
},
|
||||
mods,
|
||||
resourcepacks,
|
||||
serverMinRam: Number(pickFirstValue(req.body.serverMinRam)),
|
||||
serverMaxRam: Number(pickFirstValue(req.body.serverMaxRam)),
|
||||
clientMinRam: Number(pickFirstValue(req.body.clientMinRam)),
|
||||
clientRecommendedRam: Number(pickFirstValue(req.body.clientRecommendedRam)),
|
||||
packPath: pickFirstValue(req.body.packPath)
|
||||
}
|
||||
|
||||
const normalized = normalizePackDefinition(partial)
|
||||
if (normalized.clientMinRam > normalized.clientRecommendedRam) {
|
||||
res.status(400).send('clientMinRam은 clientRecommendedRam보다 클 수 없습니다.')
|
||||
return
|
||||
}
|
||||
const finalKey = await renamePack(packKey, requestedKey, normalized)
|
||||
res.redirect(`/op/dashboard/${finalKey}`)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
60
src/shared/mojang.ts
Normal file
60
src/shared/mojang.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import https from 'node:https'
|
||||
|
||||
interface MojangVersionEntry {
|
||||
id: string
|
||||
type: string
|
||||
}
|
||||
|
||||
interface MojangVersionManifest {
|
||||
versions: MojangVersionEntry[]
|
||||
}
|
||||
|
||||
const MANIFEST_URL = 'https://piston-meta.mojang.com/mc/game/version_manifest_v2.json'
|
||||
|
||||
let cachedReleases: string[] | null = null
|
||||
let cachedAt = 0
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000
|
||||
|
||||
export async function fetchReleaseVersions(): Promise<string[]> {
|
||||
if (cachedReleases && Date.now() - cachedAt < CACHE_TTL_MS) {
|
||||
return cachedReleases
|
||||
}
|
||||
try {
|
||||
const data = await fetchJson<MojangVersionManifest>(MANIFEST_URL)
|
||||
const releases = data.versions.filter((entry) => entry.type === 'release').map((entry) => entry.id)
|
||||
cachedReleases = releases
|
||||
cachedAt = Date.now()
|
||||
return releases
|
||||
} catch {
|
||||
return cachedReleases ?? FALLBACK_RELEASES
|
||||
}
|
||||
}
|
||||
|
||||
function fetchJson<T>(url: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = https.get(url, { timeout: 8000 }, (response) => {
|
||||
if (response.statusCode !== 200) {
|
||||
response.resume()
|
||||
reject(new Error(`Mojang manifest HTTP ${response.statusCode}`))
|
||||
return
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
response.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
response.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')) as T)
|
||||
} catch (error) {
|
||||
reject(error as Error)
|
||||
}
|
||||
})
|
||||
})
|
||||
request.on('error', reject)
|
||||
request.on('timeout', () => {
|
||||
request.destroy(new Error('Mojang manifest timeout'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const FALLBACK_RELEASES = [
|
||||
'1.21', '1.20.6', '1.20.4', '1.20.2', '1.20.1', '1.19.4', '1.19.2', '1.18.2', '1.17.1', '1.16.5'
|
||||
]
|
||||
18
src/shared/nat-upnp.d.ts
vendored
Normal file
18
src/shared/nat-upnp.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
declare module 'nat-upnp' {
|
||||
interface PortMappingOptions {
|
||||
public: number | { host?: string; port: number }
|
||||
private: number | { host?: string; port: number }
|
||||
ttl?: number
|
||||
description?: string
|
||||
protocol?: 'tcp' | 'udp'
|
||||
}
|
||||
|
||||
interface UpnpClient {
|
||||
portMapping(options: PortMappingOptions, callback: (err: Error | null) => void): void
|
||||
portUnmapping(options: { public: number; protocol?: 'tcp' | 'udp' }, callback: (err: Error | null) => void): void
|
||||
externalIp(callback: (err: Error | null, ip: string) => void): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
export function createClient(): UpnpClient
|
||||
}
|
||||
10
src/shared/paths.ts
Normal file
10
src/shared/paths.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import path from 'node:path'
|
||||
|
||||
// 컴파일 후 dist/shared/paths.js → 2단계 상위가 프로젝트 루트.
|
||||
export const projectRoot = path.resolve(__dirname, '..', '..')
|
||||
export const manifestRootPath = path.join(projectRoot, 'manifest.json')
|
||||
export const manifestDirPath = path.join(projectRoot, 'manifest')
|
||||
export const accountFilePath = path.join(projectRoot, 'account.json')
|
||||
export const fileDirPath = path.join(projectRoot, 'file')
|
||||
export const viewsDirPath = path.join(projectRoot, 'views')
|
||||
export const publicDirPath = path.join(projectRoot, 'public')
|
||||
219
src/shared/store.ts
Normal file
219
src/shared/store.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import fs from 'node:fs'
|
||||
import fsp from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { manifestRootPath, manifestDirPath, accountFilePath } from './paths'
|
||||
import type { Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType } from './types'
|
||||
|
||||
export async function readManifest(): Promise<Manifest> {
|
||||
try {
|
||||
const raw = await fsp.readFile(manifestRootPath, 'utf8')
|
||||
const parsed = JSON.parse(raw) as Partial<Manifest>
|
||||
if (!parsed || !Array.isArray(parsed.packs)) {
|
||||
return { packs: [] }
|
||||
}
|
||||
return {
|
||||
packs: parsed.packs.filter((entry): entry is ManifestEntry =>
|
||||
typeof entry?.name === 'string' && typeof entry?.file === 'string')
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { packs: [] }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeManifest(manifest: Manifest): Promise<void> {
|
||||
await fsp.writeFile(manifestRootPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
export function defaultPackDefinition(name: string): PackDefinition {
|
||||
return {
|
||||
name,
|
||||
mcVersion: '1.20.1',
|
||||
platform: { type: 'vanilla' },
|
||||
mods: [],
|
||||
resourcepacks: [],
|
||||
serverMinRam: 2048,
|
||||
serverMaxRam: 4096,
|
||||
clientMinRam: 2048,
|
||||
clientRecommendedRam: 4096,
|
||||
packPath: ''
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_LOADERS: LoaderType[] = ['vanilla', 'forge', 'fabric', 'neoforge']
|
||||
|
||||
export function normalizePackDefinition(input: Partial<PackDefinition> & Record<string, unknown>): PackDefinition {
|
||||
const fallback = defaultPackDefinition(typeof input.name === 'string' ? input.name : 'new')
|
||||
const platform = (input.platform ?? {}) as Partial<PackDefinition['platform']>
|
||||
const platformType = ALLOWED_LOADERS.includes(platform.type as LoaderType)
|
||||
? (platform.type as LoaderType)
|
||||
: 'vanilla'
|
||||
|
||||
const modsSource = Array.isArray(input.mods) ? input.mods : []
|
||||
const mods = modsSource
|
||||
.map((entry) => {
|
||||
const value = entry as Partial<PackDefinition['mods'][number]>
|
||||
return {
|
||||
name: typeof value?.name === 'string' ? value.name.trim() : '',
|
||||
downloadUrl: typeof value?.downloadUrl === 'string' ? value.downloadUrl.trim() : ''
|
||||
}
|
||||
})
|
||||
.filter((entry) => entry.name.length > 0 || entry.downloadUrl.length > 0)
|
||||
|
||||
const resourcePacksSource = Array.isArray(input.resourcepacks) ? input.resourcepacks : []
|
||||
const resourcepacks = resourcePacksSource
|
||||
.map((entry) => {
|
||||
const value = entry as Partial<PackDefinition['resourcepacks'][number]>
|
||||
return {
|
||||
name: typeof value?.name === 'string' ? value.name.trim() : '',
|
||||
downloadUrl: typeof value?.downloadUrl === 'string' ? value.downloadUrl.trim() : ''
|
||||
}
|
||||
})
|
||||
.filter((entry) => entry.name.length > 0 || entry.downloadUrl.length > 0)
|
||||
|
||||
return {
|
||||
name: typeof input.name === 'string' && input.name.trim().length > 0 ? input.name.trim() : fallback.name,
|
||||
mcVersion: typeof input.mcVersion === 'string' && input.mcVersion.trim().length > 0
|
||||
? input.mcVersion.trim()
|
||||
: fallback.mcVersion,
|
||||
platform: {
|
||||
type: platformType,
|
||||
downloadUrl: typeof platform.downloadUrl === 'string' && platform.downloadUrl.trim().length > 0
|
||||
? platform.downloadUrl.trim()
|
||||
: undefined
|
||||
},
|
||||
mods,
|
||||
resourcepacks,
|
||||
serverMinRam: clampNumber(input.serverMinRam, fallback.serverMinRam),
|
||||
serverMaxRam: clampNumber(input.serverMaxRam, fallback.serverMaxRam),
|
||||
clientMinRam: clampNumber(input.clientMinRam, fallback.clientMinRam),
|
||||
clientRecommendedRam: clampNumber(input.clientRecommendedRam, fallback.clientRecommendedRam),
|
||||
packPath: typeof input.packPath === 'string' ? input.packPath.trim() : ''
|
||||
}
|
||||
}
|
||||
|
||||
function clampNumber(input: unknown, fallback: number): number {
|
||||
const value = typeof input === 'number' ? input : Number(input)
|
||||
if (!Number.isFinite(value) || value <= 0) return fallback
|
||||
return Math.floor(value)
|
||||
}
|
||||
|
||||
export function packKeyFromFile(fileName: string): string {
|
||||
return fileName.replace(/\.json$/i, '')
|
||||
}
|
||||
|
||||
export async function loadPackDefinition(packKey: string): Promise<PackDefinition | null> {
|
||||
const filePath = path.join(manifestDirPath, `${packKey}.json`)
|
||||
try {
|
||||
const raw = await fsp.readFile(filePath, 'utf8')
|
||||
const parsed = JSON.parse(raw) as Partial<PackDefinition>
|
||||
return normalizePackDefinition(parsed)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePackDefinition(packKey: string, pack: PackDefinition): Promise<void> {
|
||||
await fsp.mkdir(manifestDirPath, { recursive: true })
|
||||
const filePath = path.join(manifestDirPath, `${packKey}.json`)
|
||||
await fsp.writeFile(filePath, `${JSON.stringify(pack, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
export async function listPackKeys(): Promise<string[]> {
|
||||
try {
|
||||
const entries = await fsp.readdir(manifestDirPath)
|
||||
return entries
|
||||
.filter((name) => name.toLowerCase().endsWith('.json'))
|
||||
.map(packKeyFromFile)
|
||||
.sort((a, b) => a.localeCompare(b, 'ko'))
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function nextNewKey(): Promise<string> {
|
||||
const used = new Set(await listPackKeys())
|
||||
if (!used.has('new')) return 'new'
|
||||
for (let i = 2; i < 1000; i += 1) {
|
||||
const candidate = `new${i}`
|
||||
if (!used.has(candidate)) return candidate
|
||||
}
|
||||
return `new-${Date.now()}`
|
||||
}
|
||||
|
||||
export async function createPack(): Promise<{ key: string; pack: PackDefinition }> {
|
||||
const key = await nextNewKey()
|
||||
const pack = defaultPackDefinition(key)
|
||||
await savePackDefinition(key, pack)
|
||||
await syncManifestWith(key, pack.name, 'add')
|
||||
return { key, pack }
|
||||
}
|
||||
|
||||
export async function deletePackKeys(keys: string[]): Promise<void> {
|
||||
for (const key of keys) {
|
||||
if (!key) continue
|
||||
const filePath = path.join(manifestDirPath, `${key}.json`)
|
||||
try {
|
||||
await fsp.unlink(filePath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
await syncManifestWith(key, '', 'remove')
|
||||
}
|
||||
}
|
||||
|
||||
export async function renamePack(oldKey: string, newKey: string, pack: PackDefinition): Promise<string> {
|
||||
const safeNew = sanitizePackKey(newKey) || oldKey
|
||||
const targetPath = path.join(manifestDirPath, `${safeNew}.json`)
|
||||
const sourcePath = path.join(manifestDirPath, `${oldKey}.json`)
|
||||
|
||||
if (safeNew !== oldKey && fs.existsSync(targetPath)) {
|
||||
throw new Error(`이미 ${safeNew}.json 이름의 음악퀴즈가 있습니다.`)
|
||||
}
|
||||
|
||||
await savePackDefinition(safeNew, pack)
|
||||
if (safeNew !== oldKey) {
|
||||
try {
|
||||
await fsp.unlink(sourcePath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
await syncManifestWith(oldKey, '', 'remove')
|
||||
}
|
||||
await syncManifestWith(safeNew, pack.name, 'upsert')
|
||||
return safeNew
|
||||
}
|
||||
|
||||
export function sanitizePackKey(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9_\-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
type ManifestSyncAction = 'add' | 'remove' | 'upsert'
|
||||
|
||||
async function syncManifestWith(key: string, name: string, action: ManifestSyncAction): Promise<void> {
|
||||
const manifest = await readManifest()
|
||||
const filtered = manifest.packs.filter((entry) => entry.file !== key)
|
||||
if (action === 'remove') {
|
||||
await writeManifest({ packs: filtered })
|
||||
return
|
||||
}
|
||||
filtered.push({ name: name || key, file: key })
|
||||
await writeManifest({ packs: filtered })
|
||||
}
|
||||
|
||||
export async function readAccounts(): Promise<AccountEntry[]> {
|
||||
try {
|
||||
const raw = await fsp.readFile(accountFilePath, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter((entry): entry is AccountEntry =>
|
||||
typeof entry?.id === 'string' && typeof entry?.password === 'string')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
||||
throw error
|
||||
}
|
||||
}
|
||||
38
src/shared/types.ts
Normal file
38
src/shared/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export type LoaderType = 'vanilla' | 'forge' | 'fabric' | 'neoforge'
|
||||
|
||||
export interface PackPlatform {
|
||||
type: LoaderType
|
||||
downloadUrl?: string
|
||||
}
|
||||
|
||||
export interface PackAsset {
|
||||
name: string
|
||||
downloadUrl: string
|
||||
}
|
||||
|
||||
export interface PackDefinition {
|
||||
name: string
|
||||
mcVersion: string
|
||||
platform: PackPlatform
|
||||
mods: PackAsset[]
|
||||
resourcepacks: PackAsset[]
|
||||
serverMinRam: number
|
||||
serverMaxRam: number
|
||||
clientMinRam: number
|
||||
clientRecommendedRam: number
|
||||
packPath: string
|
||||
}
|
||||
|
||||
export interface ManifestEntry {
|
||||
name: string
|
||||
file: string
|
||||
}
|
||||
|
||||
export interface Manifest {
|
||||
packs: ManifestEntry[]
|
||||
}
|
||||
|
||||
export interface AccountEntry {
|
||||
id: string
|
||||
password: string
|
||||
}
|
||||
Reference in New Issue
Block a user