- _meta.json: customLabels -> terms.{label,showInInstaller,showInInstallerRp}
- Drop builtin protection; any term kind can be deleted/added/toggled
- New public route /manifest/terms/<pack>/index.json for installer term lists
- Installers fetch terms:list dynamically; skip agreement step if list empty
- Term editor: 2 visibility checkboxes (설치기 / 리소스팩 설치기), multi-select
- Migration from old schema preserves custom labels (default: visible in both)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1736 lines
70 KiB
TypeScript
1736 lines
70 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'
|
|
import { loadEnv, getManifestUrl } from '../shared/env.js'
|
|
import { loadComponentI18n } from '../shared/i18n.js'
|
|
import { LAUNCHER_PROFILE_ICON } from './launcherIcon.js'
|
|
|
|
loadEnv()
|
|
|
|
const i18n = loadComponentI18n('installer')
|
|
const t = i18n.t
|
|
export const localeDict = i18n.dict
|
|
|
|
interface InstallerState {
|
|
manifestUrl: string
|
|
baseUrl: string
|
|
packs: Map<string, FetchedPack>
|
|
selectedKey: string | null
|
|
installPath: string | null
|
|
configEditorServer: http.Server | null
|
|
configEditorPort: number | null
|
|
}
|
|
|
|
const DEFAULT_MANIFEST_URL = getManifestUrl()
|
|
|
|
const state: InstallerState = {
|
|
manifestUrl: DEFAULT_MANIFEST_URL,
|
|
baseUrl: deriveBaseUrl(DEFAULT_MANIFEST_URL),
|
|
packs: new Map(),
|
|
selectedKey: null,
|
|
installPath: null,
|
|
configEditorServer: null,
|
|
configEditorPort: null
|
|
}
|
|
|
|
let mainWindow: BrowserWindow | null = null
|
|
|
|
function deriveBaseUrl(manifestUrl: string): string {
|
|
try {
|
|
const parsed = new URL(manifestUrl)
|
|
return `${parsed.protocol}//${parsed.host}`
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
function createMainWindow(): void {
|
|
// 패키징 시 build/icon.ico, dev 실행 시 build/icon.png 모두 동일 경로에서 발견되도록
|
|
// 프로젝트 루트의 build/ 를 가리킨다. 파일이 없으면 Electron 이 기본 아이콘으로 fallback.
|
|
const iconPath = path.join(__dirname, '..', '..', 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png')
|
|
mainWindow = new BrowserWindow({
|
|
width: 980,
|
|
height: 720,
|
|
icon: iconPath,
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.js'),
|
|
contextIsolation: true,
|
|
nodeIntegration: false
|
|
}
|
|
})
|
|
mainWindow.removeMenu()
|
|
void mainWindow.loadFile(path.join(__dirname, '..', '..', 'installer', 'index.html'))
|
|
}
|
|
|
|
function sendLog(line: string): void {
|
|
if (!mainWindow || mainWindow.isDestroyed()) return
|
|
const stamped = `[${new Date().toLocaleTimeString('ko-KR', { hour12: false })}] ${line}`
|
|
mainWindow.webContents.send('log', stamped)
|
|
}
|
|
|
|
function fetchBuffer(url: string): Promise<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(t('errors.requestTimeout'))))
|
|
})
|
|
}
|
|
|
|
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(t('log.manifestDownload', { url: 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(t('log.packLoadFail', { file: entry.file, message: (error as Error).message }))
|
|
}
|
|
}
|
|
state.packs.clear()
|
|
for (const item of results) state.packs.set(item.key, item)
|
|
sendLog(t('log.packsLoaded', { count: results.length }))
|
|
return results
|
|
})
|
|
|
|
// 약관(Markdown) 을 사이트(/manifest/terms/<packKey>/<kind>.md) 에서 받아와 그대로 돌려준다.
|
|
// v0.3.4~ : 사이트에서 임의 kind 등록 가능 → 하드코딩 5종 화이트리스트 대신 kind 형식만 검증.
|
|
const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
|
|
ipcMain.handle('terms:get', async (_event, kind: string): Promise<{ ok: boolean; content?: string; message?: string }> => {
|
|
if (typeof kind !== 'string' || !TERM_KIND_RE.test(kind)) {
|
|
return { ok: false, message: 'invalid term kind' }
|
|
}
|
|
if (!state.selectedKey) {
|
|
return { ok: false, message: 'pack not selected' }
|
|
}
|
|
try {
|
|
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/${kind}.md`
|
|
const buf = await fetchBuffer(url)
|
|
return { ok: true, content: buf.toString('utf8') }
|
|
} catch (error) {
|
|
return { ok: false, message: (error as Error).message }
|
|
}
|
|
})
|
|
|
|
// 메인 인스톨러용 약관 목록. /manifest/terms/<packKey>/index.json 을 받아
|
|
// showInInstaller=true 인 항목만 추려 반환. 비어 있으면 렌더러가 약관 단계를 건너뛴다.
|
|
ipcMain.handle('terms:list', async (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> => {
|
|
if (!state.selectedKey) return { ok: false, message: 'pack not selected' }
|
|
try {
|
|
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/index.json`
|
|
const buf = await fetchBuffer(url)
|
|
const parsed = JSON.parse(buf.toString('utf8')) as { terms?: unknown }
|
|
const items = Array.isArray(parsed.terms) ? parsed.terms : []
|
|
const terms: Array<{ kind: string; label: string }> = []
|
|
for (const it of items) {
|
|
if (!it || typeof it !== 'object') continue
|
|
const entry = it as Record<string, unknown>
|
|
if (entry.showInInstaller !== true) continue
|
|
const kind = typeof entry.kind === 'string' ? entry.kind : ''
|
|
const label = typeof entry.label === 'string' ? entry.label : ''
|
|
if (!TERM_KIND_RE.test(kind) || label.length === 0) continue
|
|
terms.push({ kind, label })
|
|
}
|
|
return { ok: true, terms }
|
|
} catch (error) {
|
|
return { ok: false, message: (error as Error).message }
|
|
}
|
|
})
|
|
|
|
ipcMain.handle('packs:select', async (_event, packKey: string) => {
|
|
if (!state.packs.has(packKey)) {
|
|
throw new Error(t('errors.packNotFound'))
|
|
}
|
|
state.selectedKey = packKey
|
|
sendLog(t('log.selectedPack', { key: packKey }))
|
|
})
|
|
|
|
ipcMain.handle('dialog:pickFolder', async (): Promise<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: t('errors.installPathRequired') }
|
|
}
|
|
if (containsHangul(target)) {
|
|
return { ok: false, message: t('errors.installPathHangul') }
|
|
}
|
|
const absolute = path.resolve(target)
|
|
state.installPath = absolute
|
|
return { ok: true, message: absolute }
|
|
})
|
|
|
|
ipcMain.handle('jdk:detect', async () => {
|
|
const candidates: string[] = []
|
|
if (process.env.JAVA_HOME) candidates.push(process.env.JAVA_HOME)
|
|
if (process.env.JDK_HOME) candidates.push(process.env.JDK_HOME)
|
|
// 자동 설치 위치(우리 설치기가 만든 JDK)도 후보에 포함.
|
|
candidates.push(path.join(getAppDataDir(), 'jdk', 'temurin-21'))
|
|
candidates.push('C:\\Program Files\\Java')
|
|
|
|
for (const candidate of candidates) {
|
|
if (!candidate) continue
|
|
try {
|
|
const stat = await fsp.stat(candidate)
|
|
if (stat.isFile()) {
|
|
return { found: true, path: candidate }
|
|
}
|
|
if (stat.isDirectory()) {
|
|
const javaExe = path.join(candidate, 'bin', process.platform === 'win32' ? 'java.exe' : 'java')
|
|
if (fs.existsSync(javaExe)) {
|
|
return { found: true, path: candidate }
|
|
}
|
|
const entries = await fsp.readdir(candidate)
|
|
for (const entry of entries) {
|
|
const child = path.join(candidate, entry)
|
|
const childJava = path.join(child, 'bin', process.platform === 'win32' ? 'java.exe' : 'java')
|
|
if (fs.existsSync(childJava)) {
|
|
return { found: true, path: child }
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
continue
|
|
}
|
|
}
|
|
return { found: false, path: '' }
|
|
})
|
|
|
|
// ── JDK 자동 설치(Temurin 21, 취소 가능) ──────────────────────────────
|
|
interface JdkInstallState {
|
|
controller: AbortController | null
|
|
destDir: string | null
|
|
inProgress: boolean
|
|
}
|
|
const jdkInstall: JdkInstallState = { controller: null, destDir: null, inProgress: false }
|
|
|
|
function downloadStream(
|
|
url: string,
|
|
target: string,
|
|
signal: AbortSignal,
|
|
onProgress?: (loaded: number, total: number) => void
|
|
): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
if (signal.aborted) {
|
|
reject(new Error(t('errors.canceled')))
|
|
return
|
|
}
|
|
const u = new URL(url)
|
|
const transport = u.protocol === 'https:' ? https : http
|
|
const fileStream = fs.createWriteStream(target)
|
|
let settled = false
|
|
const onAbort = () => {
|
|
try { req.destroy(new Error(t('errors.canceled'))) } catch { /* noop */ }
|
|
try { fileStream.close() } catch { /* noop */ }
|
|
}
|
|
signal.addEventListener('abort', onAbort)
|
|
const req = transport.get(u, { timeout: 120000 }, (res) => {
|
|
const sc = res.statusCode ?? 0
|
|
if (sc === 301 || sc === 302 || sc === 307 || sc === 308) {
|
|
const redirect = res.headers.location
|
|
if (redirect) {
|
|
res.resume()
|
|
fileStream.close(() => {
|
|
signal.removeEventListener('abort', onAbort)
|
|
downloadStream(new URL(redirect, u).toString(), target, signal, onProgress).then(resolve, reject)
|
|
})
|
|
return
|
|
}
|
|
}
|
|
if (sc >= 400) {
|
|
res.resume()
|
|
fileStream.close(() => {
|
|
signal.removeEventListener('abort', onAbort)
|
|
if (!settled) { settled = true; reject(new Error(`HTTP ${sc}`)) }
|
|
})
|
|
return
|
|
}
|
|
const total = Number(res.headers['content-length'] ?? 0)
|
|
let loaded = 0
|
|
res.on('data', (chunk: Buffer) => {
|
|
loaded += chunk.length
|
|
if (onProgress) onProgress(loaded, total)
|
|
})
|
|
res.pipe(fileStream)
|
|
fileStream.on('finish', () => fileStream.close(() => {
|
|
signal.removeEventListener('abort', onAbort)
|
|
if (!settled) { settled = true; resolve() }
|
|
}))
|
|
res.on('error', (err) => {
|
|
signal.removeEventListener('abort', onAbort)
|
|
if (!settled) { settled = true; reject(err) }
|
|
})
|
|
})
|
|
req.on('error', (err) => {
|
|
signal.removeEventListener('abort', onAbort)
|
|
fileStream.close(() => {})
|
|
if (!settled) { settled = true; reject(err) }
|
|
})
|
|
req.on('timeout', () => req.destroy(new Error(t('errors.requestTimeout'))))
|
|
})
|
|
}
|
|
|
|
ipcMain.handle('jdk:install', async (): Promise<{ ok: boolean; path?: string; message?: string }> => {
|
|
if (jdkInstall.inProgress) {
|
|
return { ok: false, message: t('errors.jdkBusy') }
|
|
}
|
|
jdkInstall.inProgress = true
|
|
const controller = new AbortController()
|
|
jdkInstall.controller = controller
|
|
const tmpRoot = path.join(getAppDataDir(), 'jdk-cache')
|
|
await fsp.mkdir(tmpRoot, { recursive: true })
|
|
const tempZip = path.join(tmpRoot, `temurin-21-${Date.now()}.zip`)
|
|
const destDir = path.join(getAppDataDir(), 'jdk', 'temurin-21')
|
|
jdkInstall.destDir = destDir
|
|
try {
|
|
// Adoptium API v3: latest GA JDK 21 Windows x64. 본문은 307 로 GitHub 릴리즈로 리다이렉트.
|
|
const url = 'https://api.adoptium.net/v3/binary/latest/21/ga/windows/x64/jdk/hotspot/normal/eclipse?project=jdk'
|
|
sendLog(t('log.jdkInstallStart'))
|
|
let lastPctReported = -1
|
|
await downloadStream(url, tempZip, controller.signal, (loaded, total) => {
|
|
if (total > 0) {
|
|
const pct = Math.floor((loaded / total) * 100)
|
|
if (pct >= lastPctReported + 5) {
|
|
lastPctReported = pct
|
|
sendLog(t('log.jdkDownloadProgress', {
|
|
percent: pct,
|
|
loaded: Math.floor(loaded / 1024 / 1024),
|
|
total: Math.floor(total / 1024 / 1024)
|
|
}))
|
|
}
|
|
}
|
|
})
|
|
if (controller.signal.aborted) throw new Error(t('errors.canceled'))
|
|
|
|
sendLog(t('log.jdkExtracting'))
|
|
await fsp.rm(destDir, { recursive: true, force: true })
|
|
await fsp.mkdir(destDir, { recursive: true })
|
|
await extractZip(tempZip, { dir: destDir })
|
|
|
|
// Adoptium ZIP 은 jdk-21.x.x+... 하위 폴더로 감싸져 있다. 그 폴더의 bin/java.exe 가 실제 자바.
|
|
let javaRoot = destDir
|
|
const inner = await fsp.readdir(destDir, { withFileTypes: true })
|
|
const innerJdk = inner.find((entry) => entry.isDirectory() && /^jdk-/i.test(entry.name))
|
|
if (innerJdk) javaRoot = path.join(destDir, innerJdk.name)
|
|
const javaExe = path.join(javaRoot, 'bin', process.platform === 'win32' ? 'java.exe' : 'java')
|
|
if (!fs.existsSync(javaExe)) {
|
|
throw new Error(t('errors.javaExeMissing', { path: javaExe }))
|
|
}
|
|
|
|
sendLog(t('log.jdkDoneRoot', { path: javaRoot }))
|
|
return { ok: true, path: javaRoot }
|
|
} catch (err) {
|
|
const msg = (err as Error).message || String(err)
|
|
if (controller.signal.aborted || /취소/.test(msg)) {
|
|
sendLog(t('log.jdkCanceled'))
|
|
try { await fsp.rm(destDir, { recursive: true, force: true }) } catch { /* noop */ }
|
|
return { ok: false, message: t('errors.canceledShort') }
|
|
}
|
|
sendLog(t('log.jdkInstallFailedLog', { message: msg }))
|
|
return { ok: false, message: msg }
|
|
} finally {
|
|
try { await fsp.rm(tempZip, { force: true }) } catch { /* noop */ }
|
|
jdkInstall.inProgress = false
|
|
jdkInstall.controller = null
|
|
jdkInstall.destDir = null
|
|
}
|
|
})
|
|
|
|
ipcMain.handle('jdk:cancelInstall', async (): Promise<{ ok: boolean }> => {
|
|
if (jdkInstall.controller) {
|
|
jdkInstall.controller.abort()
|
|
sendLog(t('log.jdkCancelRequested'))
|
|
}
|
|
return { ok: true }
|
|
})
|
|
|
|
/**
|
|
* 입력값이 절대 URL이면 그대로, 상대값이면 manifest 도메인의 /file/<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(t('log.labelDownload', { label, url }))
|
|
await downloadFile(url, tempZip)
|
|
sendLog(t('log.labelExtract', { label, dir: extractDir }))
|
|
await extractZip(tempZip, { dir: extractDir })
|
|
} finally {
|
|
await fsp.rm(tempDir, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
async function downloadServerZip(pack: PackDefinition, targetDir: string): Promise<void> {
|
|
if (!pack.serverPath) {
|
|
sendLog(t('log.skipServerZip'))
|
|
return
|
|
}
|
|
const url = resolveManifestRelative(pack.serverPath, 'servers')
|
|
await downloadAndExtractZip(url, t('log.labelServerFile'), targetDir)
|
|
}
|
|
|
|
/**
|
|
* 설치러가 saves/ 에 풀어놓은 최상위 폴더(또는 파일) 목록을 기록하는 마커 파일.
|
|
* 재설치 시 잔여물을 안전하게 정리하고, 싱글→참가자 전환 시에도
|
|
* 사용자가 직접 만든 월드는 보존한 채 설치러가 만든 맵만 제거하기 위함이다.
|
|
*/
|
|
const INSTALLER_MAP_MARKER = '.musicquiz-installer-map.json'
|
|
|
|
async function readInstallerMapMarker(customRoot: string): Promise<string[]> {
|
|
const markerPath = path.join(customRoot, 'saves', INSTALLER_MAP_MARKER)
|
|
try {
|
|
const raw = await fsp.readFile(markerPath, 'utf8')
|
|
const data = JSON.parse(raw) as { entries?: unknown }
|
|
if (Array.isArray(data.entries)) {
|
|
return data.entries.filter((s): s is string => typeof s === 'string')
|
|
}
|
|
} catch {
|
|
// 마커가 없거나 파싱 실패 — 빈 목록 반환
|
|
}
|
|
return []
|
|
}
|
|
|
|
async function writeInstallerMapMarker(customRoot: string, entries: string[]): Promise<void> {
|
|
const savesDir = path.join(customRoot, 'saves')
|
|
await fsp.mkdir(savesDir, { recursive: true })
|
|
const markerPath = path.join(savesDir, INSTALLER_MAP_MARKER)
|
|
await fsp.writeFile(markerPath, JSON.stringify({ entries }, null, 2), 'utf8')
|
|
}
|
|
|
|
async function cleanupInstallerMap(customRoot: string): Promise<void> {
|
|
const savesDir = path.join(customRoot, 'saves')
|
|
const entries = await readInstallerMapMarker(customRoot)
|
|
if (entries.length === 0) return
|
|
sendLog(t('log.cleanupInstallerMap', { count: entries.length }))
|
|
for (const name of entries) {
|
|
// 안전장치: 경로 구분자/상대경로 토큰이 섞인 항목은 무시
|
|
if (!name || name.includes('/') || name.includes('\\') || name === '.' || name === '..') continue
|
|
const target = path.join(savesDir, name)
|
|
await fsp.rm(target, { recursive: true, force: true })
|
|
}
|
|
await fsp.rm(path.join(savesDir, INSTALLER_MAP_MARKER), { force: true })
|
|
}
|
|
|
|
/**
|
|
* Windows 폴더 이름으로 쓸 수 없는 문자를 모두 `_` 로 치환.
|
|
* 금지 문자: `<>:"/\|?*` 와 제어 문자(0x00~0x1f)
|
|
* 추가 제한: 끝의 공백/마침표 제거, 빈 문자열 fallback, 예약 이름(CON, NUL 등) 회피.
|
|
* 참고: https://learn.microsoft.com/windows/win32/fileio/naming-a-file
|
|
*/
|
|
function sanitizeMapFolderName(name: string): string {
|
|
let cleaned = (name || '').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
|
|
cleaned = cleaned.replace(/[ .]+$/, '')
|
|
if (!cleaned) cleaned = 'map'
|
|
if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(cleaned)) cleaned = '_' + cleaned
|
|
return cleaned
|
|
}
|
|
|
|
async function downloadMapZip(pack: PackDefinition, customRoot: string): Promise<void> {
|
|
if (!pack.mapPath) {
|
|
sendLog(t('log.skipMapZip'))
|
|
return
|
|
}
|
|
// 이전 설치러가 풀어놓은 맵이 남아 있으면 먼저 제거 (다른 팩/재설치 시 잔여물 방지).
|
|
await cleanupInstallerMap(customRoot)
|
|
const url = resolveManifestRelative(pack.mapPath, 'maps')
|
|
const savesDir = path.join(customRoot, 'saves')
|
|
await fsp.mkdir(savesDir, { recursive: true })
|
|
|
|
// zip 의 최상위 구조(단일 폴더 / 루트에 level.dat) 와 관계없이 최종 폴더 이름이
|
|
// 항상 퀴즈 이름이 되도록, 우선 saves/ 안의 임시 폴더에 풀고 적절히 옮긴다.
|
|
// saves 와 같은 디렉터리에서 만들기 때문에 rename 이 cross-device 실패 없이 동작.
|
|
const tempExtractDir = await fsp.mkdtemp(path.join(savesDir, '.mq-map-extract-'))
|
|
try {
|
|
await downloadAndExtractZip(url, t('log.labelMap'), tempExtractDir)
|
|
|
|
// zip 이 단일 최상위 폴더면 그 안을 월드 콘텐츠로, 아니면 임시 디렉터리 자체가
|
|
// 월드 콘텐츠(level.dat 등이 루트). 어느 쪽이든 결과적으로 saves/<퀴즈이름>/ 로.
|
|
const entries = await fsp.readdir(tempExtractDir)
|
|
let sourceDir = tempExtractDir
|
|
if (entries.length === 1) {
|
|
const candidate = path.join(tempExtractDir, entries[0])
|
|
const stat = await fsp.stat(candidate).catch(() => null)
|
|
if (stat?.isDirectory()) sourceDir = candidate
|
|
}
|
|
|
|
const desired = sanitizeMapFolderName(pack.name)
|
|
// 사용자가 직접 만든 동명 월드와 겹치면 덮어쓰지 않고 _2, _3 … 으로 회피.
|
|
let target = desired
|
|
let suffix = 2
|
|
while (fs.existsSync(path.join(savesDir, target))) {
|
|
target = `${desired}_${suffix}`
|
|
suffix++
|
|
}
|
|
const targetDir = path.join(savesDir, target)
|
|
await fsp.rename(sourceDir, targetDir)
|
|
sendLog(t('log.mapInstalledAs', { name: target }))
|
|
await writeInstallerMapMarker(customRoot, [target])
|
|
} finally {
|
|
// sourceDir 가 tempExtractDir 자체였다면 rename 으로 사라졌고, 단일 하위 폴더였다면
|
|
// 비어 있는 껍데기만 남아 있다. 어느 경우든 안전하게 정리.
|
|
await fsp.rm(tempExtractDir, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
async function downloadModsFolder(pack: PackDefinition, customRoot: string): Promise<void> {
|
|
// 바닐라 팩(modsFolder 비어 있음)은 모드 자체와 무관하므로 기존 mods/ 를 건드리지
|
|
// 않는다 — 사용자가 다른 곳에서 받아 둔 모드까지 지워버리는 부작용 방지.
|
|
if (!pack.modsFolder) {
|
|
sendLog(t('log.skipModsFolder'))
|
|
return
|
|
}
|
|
const modsDir = path.join(customRoot, 'mods')
|
|
// 모드팩인 경우엔 이전 버전/이전 팩 모드가 섞이면 로딩이 실패하므로 매번 비우고 받는다.
|
|
sendLog(t('log.clearMods', { dir: modsDir }))
|
|
await fsp.rm(modsDir, { recursive: true, force: true })
|
|
await fsp.mkdir(modsDir, { recursive: true })
|
|
const indexUrl = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/index.json`
|
|
sendLog(t('log.modsIndexFetch', { url: indexUrl }))
|
|
const listing = await fetchJson<{ files?: unknown }>(indexUrl)
|
|
const files = Array.isArray(listing.files)
|
|
? listing.files.filter((name): name is string => typeof name === 'string' && /\.jar$/i.test(name))
|
|
: []
|
|
if (files.length === 0) {
|
|
sendLog(t('log.modsFolderEmpty', { folder: pack.modsFolder }))
|
|
return
|
|
}
|
|
for (const fileName of files) {
|
|
const url = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/${encodeURIComponent(fileName)}`
|
|
const target = path.join(modsDir, fileName)
|
|
sendLog(t('log.modDownload', { file: fileName }))
|
|
await downloadFile(url, target)
|
|
}
|
|
}
|
|
|
|
async function downloadResourcepackZip(pack: PackDefinition, customRoot: string): Promise<void> {
|
|
if (!pack.resourcepackPath) {
|
|
sendLog(t('log.skipResourcepack'))
|
|
return
|
|
}
|
|
const url = `${state.baseUrl}/file/resourcepacks/${pack.resourcepackPath.replace(/^\/+/, '')}`
|
|
const target = path.join(customRoot, 'resourcepacks', pack.resourcepackPath.replace(/^\/+/, ''))
|
|
await fsp.mkdir(path.dirname(target), { recursive: true })
|
|
sendLog(t('log.resourcepackDownload', { url }))
|
|
await downloadFile(url, target)
|
|
}
|
|
|
|
ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) => {
|
|
const pack = state.packs.get(payload.packKey)
|
|
if (!pack) throw new Error(t('errors.packNotFound2'))
|
|
if (containsHangul(payload.installPath)) {
|
|
throw new Error(t('errors.installPathHangulShort'))
|
|
}
|
|
const installPath = path.resolve(payload.installPath)
|
|
state.installPath = installPath
|
|
await fsp.mkdir(installPath, { recursive: true })
|
|
sendLog(t('log.serverInstallPath', { path: installPath }))
|
|
|
|
await downloadServerZip(pack.pack, installPath)
|
|
// 다운로드한 zip에 들어있을 수 있는 eula.txt를 그대로 보존한다.
|
|
// 동의 흐름은 renderer가 별도 IPC로 읽고 동의 시 덮어쓴다.
|
|
|
|
// run.bat 에 서버 기동/종료시 UPnP 자동 등록/해제 로직 주입.
|
|
// 이렇게 해야 서버가 안 떠 있는 동안에는 포트가 닫혀 있게 된다.
|
|
await injectUpnpToRunBat(installPath)
|
|
})
|
|
|
|
/**
|
|
* 추출된 서버 zip 의 run.bat 에 UPnP 자동 등록(서버 시작 시) / 자동 해제(서버 종료 후)
|
|
* 스크립트를 끼워 넣는다. 이미 우리가 주입했던 마커가 있으면 다시 건드리지 않는다.
|
|
*
|
|
* 동작:
|
|
* 1) 서버 시작 직전: server.properties 의 server-port 값(없으면 25565) 으로 PowerShell
|
|
* 을 통해 HNetCfg.NATUPnP.1 COM 객체를 이용해 정적 포트 매핑 추가.
|
|
* 2) 서버 프로세스 종료 후(=pause 직전 또는 파일 끝): 동일한 포트의 매핑 제거.
|
|
*
|
|
* 제한: 사용자가 콘솔 창을 X 버튼으로 강제 종료하면 teardown 이 실행되지 않는다.
|
|
* 이 경우 라우터의 UPnP TTL 에 의해 자동 만료되며, 다음 실행 시 Add 전에 Remove 를
|
|
* 시도하므로 idempotent.
|
|
*/
|
|
async function injectUpnpToRunBat(installPath: string): Promise<void> {
|
|
const runBat = path.join(installPath, 'run.bat')
|
|
if (!fs.existsSync(runBat)) {
|
|
sendLog(t('log.runBatMissing'))
|
|
return
|
|
}
|
|
const MARKER = 'REM === UPNP MANAGED BY MUSICQUIZ INSTALLER ==='
|
|
const original = await fsp.readFile(runBat, 'utf8')
|
|
if (original.includes(MARKER)) {
|
|
sendLog(t('log.runBatAlreadyInjected'))
|
|
return
|
|
}
|
|
|
|
const lines = original.split(/\r?\n/)
|
|
const javaIdx = lines.findIndex((line) => /^\s*java(\.exe)?[\s"]/i.test(line))
|
|
if (javaIdx === -1) {
|
|
sendLog(t('log.runBatNoJava'))
|
|
return
|
|
}
|
|
let pauseIdx = -1
|
|
for (let i = javaIdx + 1; i < lines.length; i++) {
|
|
if (/^\s*pause\b/i.test(lines[i])) { pauseIdx = i; break }
|
|
}
|
|
if (pauseIdx === -1) pauseIdx = lines.length
|
|
|
|
|
|
// PowerShell 한 줄로 처리: server.properties 의 server-port 우선, 없으면 25565.
|
|
// Add 전에 같은 포트의 매핑이 남아 있으면 먼저 Remove 하여 idempotent 하게 만든다.
|
|
const addBlock = [
|
|
MARKER,
|
|
'REM 서버 시작 직전: server-port 추출 후 UPnP 매핑 등록.',
|
|
'set "_MQ_PORT=25565"',
|
|
'for /f "tokens=2 delims==" %%a in (\'findstr /b /c:"server-port=" server.properties 2^>nul\') do set "_MQ_PORT=%%a"',
|
|
'set "_MQ_PORT=%_MQ_PORT: =%"',
|
|
'echo [MusicQuiz] UPnP 등록 시도: TCP %_MQ_PORT%',
|
|
'powershell -NoProfile -Command "$port=[int]$env:_MQ_PORT; $ip=(Get-NetIPAddress -AddressFamily IPv4 -PrefixOrigin Dhcp,Manual -ErrorAction SilentlyContinue ^| Where-Object {$_.IPAddress -notlike \'169.254.*\' -and $_.IPAddress -ne \'127.0.0.1\'} ^| Select-Object -First 1).IPAddress; if (-not $ip) { Write-Host \'[MusicQuiz] 로컬 IPv4 검색 실패\'; exit 1 }; try { $u = New-Object -ComObject HNetCfg.NATUPnP.1; $c=$u.StaticPortMappingCollection; if ($c) { try { $c.Remove($port,\'TCP\') ^| Out-Null } catch {}; $c.Add($port,\'TCP\',$port,$ip,$true,\'MusicQuiz Minecraft Server\') ^| Out-Null; Write-Host (\'[MusicQuiz] UPnP 등록 성공: \' + $ip + \':\' + $port + \' TCP\') } else { Write-Host \'[MusicQuiz] UPnP 컬렉션 사용 불가(라우터 UPnP 꺼짐?)\' } } catch { Write-Host (\'[MusicQuiz] UPnP 등록 실패: \' + $_.Exception.Message) }"'
|
|
]
|
|
|
|
const removeBlock = [
|
|
'REM 서버 종료 후: UPnP 매핑 해제.',
|
|
'echo [MusicQuiz] UPnP 해제 시도: TCP %_MQ_PORT%',
|
|
'powershell -NoProfile -Command "$port=[int]$env:_MQ_PORT; try { $u = New-Object -ComObject HNetCfg.NATUPnP.1; $c=$u.StaticPortMappingCollection; if ($c) { $c.Remove($port,\'TCP\') ^| Out-Null; Write-Host (\'[MusicQuiz] UPnP 해제 완료: TCP \' + $port) } } catch { Write-Host (\'[MusicQuiz] UPnP 해제 실패: \' + $_.Exception.Message) }"'
|
|
]
|
|
|
|
const merged: string[] = []
|
|
merged.push(...lines.slice(0, javaIdx))
|
|
merged.push(...addBlock)
|
|
merged.push(lines[javaIdx])
|
|
merged.push(...lines.slice(javaIdx + 1, pauseIdx))
|
|
merged.push(...removeBlock)
|
|
merged.push(...lines.slice(pauseIdx))
|
|
|
|
// bat 파일은 CRLF 가 안전.
|
|
const output = merged.join('\r\n')
|
|
await fsp.writeFile(runBat, output, 'utf8')
|
|
sendLog(t('log.runBatInjected'))
|
|
}
|
|
|
|
ipcMain.handle('server:readEula', async (_event, installPath: string): Promise<{ exists: boolean; content: string }> => {
|
|
if (!installPath) return { exists: false, content: '' }
|
|
const target = path.join(path.resolve(installPath), 'eula.txt')
|
|
try {
|
|
const content = await fsp.readFile(target, 'utf8')
|
|
return { exists: true, content }
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { exists: false, content: '' }
|
|
throw error
|
|
}
|
|
})
|
|
|
|
ipcMain.handle('server:fetchMinecraftEula', async (): Promise<{ url: string; html: string }> => {
|
|
const url = 'https://www.minecraft.net/en-us/eula'
|
|
try {
|
|
const buffer = await fetchBuffer(url)
|
|
return { url, html: buffer.toString('utf8') }
|
|
} catch (error) {
|
|
sendLog(t('log.mojangEulaFetchFail', { message: (error as Error).message }))
|
|
return { url, html: '' }
|
|
}
|
|
})
|
|
|
|
ipcMain.handle('server:acceptEula', async (_event, installPath: string) => {
|
|
const target = path.join(installPath, 'eula.txt')
|
|
await fsp.writeFile(target, `# Generated by music quiz installer\neula=true\n`, 'utf8')
|
|
sendLog(t('log.eulaAccepted'))
|
|
})
|
|
|
|
ipcMain.handle('server:checkRam', async (_event, packKey: string): Promise<RamCheckResult> => {
|
|
const pack = state.packs.get(packKey)
|
|
if (!pack) throw new Error(t('errors.packNotFound2'))
|
|
const systemRamMb = Math.floor(os.totalmem() / (1024 * 1024))
|
|
if (systemRamMb >= pack.pack.serverMaxRam) {
|
|
return { systemRamMb, decision: 'maxOk', appliedRamMb: pack.pack.serverMaxRam }
|
|
}
|
|
if (systemRamMb >= pack.pack.serverMinRam) {
|
|
return { systemRamMb, decision: 'minOk', appliedRamMb: pack.pack.serverMinRam }
|
|
}
|
|
return { systemRamMb, decision: 'tooLow', appliedRamMb: 0 }
|
|
})
|
|
|
|
ipcMain.handle('server:configEditor', async (_event, installPath: string) => {
|
|
if (state.configEditorServer) {
|
|
state.configEditorServer.close()
|
|
state.configEditorServer = null
|
|
}
|
|
const port = await pickPort()
|
|
const server = http.createServer(async (req, res) => {
|
|
try {
|
|
await handleConfigEditorRequest(installPath, req, res)
|
|
} catch (error) {
|
|
res.statusCode = 500
|
|
res.setHeader('content-type', 'text/plain; charset=utf-8')
|
|
res.end(t('configEditor.serverError', { message: (error as Error).message }))
|
|
}
|
|
})
|
|
await new Promise<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(t('log.configEditorOpen', { 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(t('errors.portAllocFail')))
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
const SERVER_CONFIG_FILES = ['server.properties', 'bukkit.yml', 'spigot.yml', 'paper-global.yml']
|
|
|
|
async function handleConfigEditorRequest(installPath: string, req: http.IncomingMessage, res: http.ServerResponse): Promise<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(t('configEditor.unknownFile'))
|
|
return
|
|
}
|
|
const filePath = path.join(installPath, target)
|
|
if (!fs.existsSync(filePath)) {
|
|
res.setHeader('content-type', 'text/plain; charset=utf-8')
|
|
res.end('')
|
|
return
|
|
}
|
|
const content = await fsp.readFile(filePath, 'utf8')
|
|
res.setHeader('content-type', 'text/plain; charset=utf-8')
|
|
res.end(content)
|
|
return
|
|
}
|
|
if (req.method === 'POST' && url.pathname === '/save') {
|
|
const body = await readBody(req)
|
|
const params = new URLSearchParams(body)
|
|
const target = params.get('name') ?? ''
|
|
const content = params.get('content') ?? ''
|
|
if (!SERVER_CONFIG_FILES.includes(target)) {
|
|
res.statusCode = 400
|
|
res.end(t('configEditor.unknownFile'))
|
|
return
|
|
}
|
|
const filePath = path.join(installPath, target)
|
|
await fsp.writeFile(filePath, content, 'utf8')
|
|
res.statusCode = 200
|
|
res.setHeader('content-type', 'application/json')
|
|
res.end(JSON.stringify({ ok: true }))
|
|
return
|
|
}
|
|
res.statusCode = 404
|
|
res.end('Not found')
|
|
}
|
|
|
|
async function collectConfigFiles(installPath: string): Promise<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('')
|
|
const savedText = JSON.stringify(t('configEditor.saved'))
|
|
const saveFailedText = JSON.stringify(t('configEditor.saveFailed'))
|
|
return `<!doctype html>
|
|
<html lang="ko"><head><meta charset="utf-8"/><title>${t('configEditor.pageTitle')}</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>${t('configEditor.heading')}</h1>
|
|
<p><small>${t('configEditor.intro')}</small></p>
|
|
<label>${t('configEditor.targetLabel')} <select id="file">${optionMarkup}</select></label>
|
|
<textarea id="content"></textarea>
|
|
<button id="save">${t('configEditor.applyButton')}</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?${savedText}:${saveFailedText};});
|
|
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(t('log.portCheckStart', { port: targetPort }))
|
|
|
|
// 1차 점검 전에 우리가 이전 실행에서 만든 UPnP 매핑이 남아 있으면 먼저 제거한다.
|
|
// 이렇게 해야 "사용자 라우터 규칙이 활성화돼서 외부 접근이 가능한 상태" 와 "UPnP 매핑 덕분에 접근 가능한 상태" 가 구별된다.
|
|
// 사용자 규칙이 비활성/없으면 1차 점검은 false 가 되어 UPnP 시도 단계로 자연스럽게 넘어간다.
|
|
sendLog(t('log.upnpCleanup'))
|
|
await removeUpnpMapping(targetPort)
|
|
|
|
// 외부 IP 확보: 공용 API → 실패 시 UPnP 게이트웨이의 외부 IP로 폴백.
|
|
let externalIp = await detectExternalIpHttp()
|
|
if (externalIp) {
|
|
sendLog(t('log.externalIpHttp', { ip: externalIp }))
|
|
} else {
|
|
sendLog(t('log.externalIpHttpFail'))
|
|
externalIp = await detectExternalIpUpnp()
|
|
if (externalIp) sendLog(t('log.externalIpUpnp', { ip: externalIp }))
|
|
else sendLog(t('log.externalIpUpnpFail'))
|
|
}
|
|
|
|
// 1차 점검: 외부에서 이미 접근 가능한지 (서버가 떠 있거나, 우리가 임시 리스너 띄워서 검증).
|
|
sendLog(t('log.probeStart'))
|
|
let probe = await probePortFromOutside(targetPort, externalIp)
|
|
if (!externalIp && probe.detectedIp) externalIp = probe.detectedIp
|
|
const verdict = probe.reachable === true
|
|
? t('log.probeVerdictSuccess')
|
|
: probe.reachable === false ? t('log.probeVerdictFail') : t('log.probeVerdictUnknown')
|
|
sendLog(t('log.probeResult', { verdict, detail: probe.detail }))
|
|
|
|
if (probe.reachable === true) {
|
|
sendLog(t('log.probePreForwarded', { addr: externalIp || t('log.ipUnknown'), port: targetPort }))
|
|
return { status: 'preForwarded', externalIp, port: targetPort }
|
|
}
|
|
|
|
// UPnP 시도.
|
|
sendLog(t('log.upnpTryOpen', { port: targetPort }))
|
|
try {
|
|
await openPortViaUpnp(targetPort)
|
|
sendLog(t('log.upnpReqOk'))
|
|
} catch (error) {
|
|
const msg = (error as Error).message || String(error)
|
|
sendLog(t('log.upnpTryFail', { message: msg }))
|
|
return {
|
|
status: 'upnpFailed',
|
|
externalIp,
|
|
port: targetPort,
|
|
message: t('log.upnpFailDetail', { message: msg })
|
|
}
|
|
}
|
|
|
|
// NAT 반영 지연을 고려해 최대 3회 재점검.
|
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
await sleep(1500)
|
|
sendLog(t('log.upnpRecheck', { attempt }))
|
|
probe = await probePortFromOutside(targetPort, externalIp)
|
|
if (!externalIp && probe.detectedIp) externalIp = probe.detectedIp
|
|
if (probe.reachable === true) {
|
|
sendLog(t('log.upnpDone', { port: targetPort }))
|
|
await removeUpnpMapping(targetPort)
|
|
return { status: 'upnpOk', externalIp, port: targetPort }
|
|
}
|
|
}
|
|
|
|
// 테스트 목적으로 만든 매핑 정리. 실제 개방은 run.bat 이 담당.
|
|
sendLog(t('log.upnpCleanupTest'))
|
|
await removeUpnpMapping(targetPort)
|
|
|
|
const reason = probe.reachable === false
|
|
? t('log.upnpFailReason1')
|
|
: t('log.upnpFailReason2', { detail: probe.detail })
|
|
sendLog(reason)
|
|
return { status: 'upnpFailed', externalIp, port: targetPort, message: reason }
|
|
})
|
|
|
|
async function detectExternalIpHttp(): Promise<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(t('log.upnpClientFail', { message: (err as Error).message }))
|
|
finish('')
|
|
return
|
|
}
|
|
const timer = setTimeout(() => {
|
|
sendLog(t('log.upnpExternalTimeout'))
|
|
try { client && client.close() } catch {}
|
|
finish('')
|
|
}, 8000)
|
|
client.externalIp((err: Error | null, ip?: string) => {
|
|
clearTimeout(timer)
|
|
try { client && client.close() } catch {}
|
|
if (err || !ip) {
|
|
if (err) sendLog(t('log.upnpExternalErr', { message: err.message }))
|
|
finish('')
|
|
} else {
|
|
finish(ip)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 외부에서 우리 PC의 지정 포트가 닿는지 확인한다.
|
|
*
|
|
* 헤어핀(hairpin) NAT 미지원 가정용 라우터에서는 내부에서 자기 외부 IP로 직접 TCP 연결을
|
|
* 시도해도 실패하므로, 외부 포트체크 서비스(ifconfig.co)에게 검사를 위임한다.
|
|
*
|
|
* 1) 가능하면 임시 TCP 리스너를 해당 포트에 띄운다(서버가 아직 안 떠 있는 상태도 검증 가능).
|
|
* 포트가 이미 사용 중이면 외부 서비스 응답만으로 판정한다.
|
|
* 2) ifconfig.co/port/PORT를 호출해 외부에서 TCP 연결을 시도하게 한다.
|
|
* 3) 임시 리스너에 연결이 도달했거나 ifconfig.co가 reachable=true를 반환하면 성공.
|
|
*/
|
|
async function probePortFromOutside(
|
|
port: number,
|
|
hintIp: string
|
|
): Promise<{ reachable: boolean | null; detail: string; detectedIp: string }> {
|
|
// 1) 임시 리스너 바인딩 시도.
|
|
let server: net.Server | null = null
|
|
let listenerBound = false
|
|
try {
|
|
server = net.createServer()
|
|
await new Promise<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(t('log.portInUse', { port }))
|
|
} else {
|
|
sendLog(t('log.listenerBindFail', { message: (err as Error).message }))
|
|
}
|
|
try { server && server.close() } catch {}
|
|
server = null
|
|
}
|
|
|
|
let gotInboundConnection = false
|
|
const inboundPromise = new Promise<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(t('log.detailListenerHit', { value: gotInboundConnection ? 'yes' : 'no' }))
|
|
if (gotInboundConnection) reachable = true
|
|
} else {
|
|
details.push(t('log.detailListenerSkip'))
|
|
}
|
|
|
|
let detectedIp = ''
|
|
if ('ok' in externalResult && externalResult.ok) {
|
|
details.push(t('log.detailIfconfig', { reachable: String(externalResult.reachable), ip: externalResult.ip || '?' }))
|
|
detectedIp = externalResult.ip || ''
|
|
if (externalResult.reachable === true) reachable = true
|
|
else if (reachable !== true && externalResult.reachable === false) reachable = false
|
|
} else if ('ok' in externalResult && !externalResult.ok) {
|
|
details.push(t('log.detailIfconfigFail', { error: (externalResult as { error: string }).error }))
|
|
}
|
|
|
|
// 임시 리스너가 떴고 외부 서비스도 닿지 않았다면 명확한 false.
|
|
if (reachable === null && listenerBound && !gotInboundConnection) reachable = false
|
|
|
|
return {
|
|
reachable,
|
|
detail: details.join(', ') || t('log.detailNone'),
|
|
detectedIp: detectedIp || hintIp || ''
|
|
}
|
|
}
|
|
|
|
function fetchIfconfigCoPort(port: number): Promise<{ ok: true; reachable: boolean | null; ip: string } | { ok: false; error: string }> {
|
|
return new Promise((resolve) => {
|
|
const target = new URL(`https://ifconfig.co/port/${port}`)
|
|
const req = https.get(target, {
|
|
timeout: 15000,
|
|
headers: { 'Accept': 'application/json', 'User-Agent': 'MusicQuiz-Installer' }
|
|
}, (res) => {
|
|
if ((res.statusCode ?? 0) >= 400) {
|
|
res.resume()
|
|
resolve({ ok: false, error: `HTTP ${res.statusCode}` })
|
|
return
|
|
}
|
|
const chunks: Buffer[] = []
|
|
res.on('data', (c: Buffer) => chunks.push(c))
|
|
res.on('end', () => {
|
|
const text = Buffer.concat(chunks).toString('utf8').trim()
|
|
try {
|
|
const json = JSON.parse(text)
|
|
const reachable = typeof json.reachable === 'boolean' ? json.reachable : null
|
|
const ip = typeof json.ip === 'string' ? json.ip : ''
|
|
resolve({ ok: true, reachable, ip })
|
|
} catch (err) {
|
|
resolve({ ok: false, error: t('errors.parseResponseFailed', { snippet: text.slice(0, 80) }) })
|
|
}
|
|
})
|
|
})
|
|
req.on('error', (err) => resolve({ ok: false, error: err.message }))
|
|
req.on('timeout', () => req.destroy(new Error(t('errors.requestTimeout15s'))))
|
|
})
|
|
}
|
|
|
|
function removeUpnpMapping(port: number): Promise<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(t('log.upnpClientFailRemove', { message: (err as Error).message }))
|
|
done()
|
|
return
|
|
}
|
|
const timer = setTimeout(() => {
|
|
try { client && client.close() } catch {}
|
|
sendLog(t('log.upnpRemoveTimeout'))
|
|
done()
|
|
}, 8000)
|
|
client.portUnmapping({ public: port, protocol: 'tcp' }, (err: Error | null) => {
|
|
clearTimeout(timer)
|
|
try { client && client.close() } catch {}
|
|
if (err) sendLog(t('log.upnpRemoveAttempt', { message: err.message }))
|
|
else sendLog(t('log.upnpRemoveDone', { port }))
|
|
done()
|
|
})
|
|
})
|
|
}
|
|
|
|
function openPortViaUpnp(port: number): Promise<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(t('errors.upnpTimeout')))
|
|
}, 15000)
|
|
client.portMapping(
|
|
{ public: port, private: port, ttl: 0, description: 'MusicQuiz Server', protocol: 'tcp' },
|
|
(error: Error | null) => {
|
|
clearTimeout(timer)
|
|
try { client && client.close() } catch {}
|
|
done(error || undefined)
|
|
}
|
|
)
|
|
})
|
|
}
|
|
|
|
function sleep(ms: number): Promise<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(t('errors.packNotFound2'))
|
|
const customRoot = path.join(getAppDataDir(), '.mc_custom')
|
|
await fsp.mkdir(path.join(customRoot, 'mods'), { recursive: true })
|
|
await fsp.mkdir(path.join(customRoot, 'resourcepacks'), { recursive: true })
|
|
|
|
try {
|
|
// 사용자가 기존 .minecraft 에 저장해둔 설정(options.txt, servers.dat 등)을
|
|
// .mc_custom 으로 가져온다. 이미 있는 파일은 보존.
|
|
await copyMinecraftUserSettings(customRoot)
|
|
|
|
if (payload.installPlatform && pack.pack.platform.type === 'fabric') {
|
|
await installFabricLoader(pack.pack, customRoot)
|
|
} else if (payload.installPlatform && pack.pack.platform.type !== 'vanilla' && pack.pack.platform.downloadUrl) {
|
|
const platformUrl = resolveManifestRelative(pack.pack.platform.downloadUrl, 'platforms')
|
|
const cacheDir = path.join(customRoot, 'platform-cache')
|
|
await fsp.mkdir(cacheDir, { recursive: true })
|
|
const installerPath = path.join(cacheDir, deriveFileName(platformUrl) || 'platform-installer.jar')
|
|
sendLog(t('log.platformDownload', { type: pack.pack.platform.type, url: platformUrl }))
|
|
await downloadFile(platformUrl, installerPath)
|
|
sendLog(t('log.platformSaved', { path: installerPath }))
|
|
} else if (!payload.installPlatform) {
|
|
sendLog(t('log.platformSkipped'))
|
|
}
|
|
|
|
await downloadModsFolder(pack.pack, customRoot)
|
|
await downloadResourcepackZip(pack.pack, customRoot)
|
|
|
|
if (payload.skipMap) {
|
|
// 참가자 모드: 이전 설치 흐름에서 설치러가 풀어둔 맵이 있다면 제거한다.
|
|
// 사용자가 직접 만든 월드는 마커에 포함되지 않으므로 그대로 보존된다.
|
|
await cleanupInstallerMap(customRoot)
|
|
sendLog(t('log.skipMapZip'))
|
|
} else {
|
|
await downloadMapZip(pack.pack, customRoot)
|
|
}
|
|
|
|
// 런처가 .mc_custom 을 gameDir 로 잡아도 assets/libraries/versions 를
|
|
// 찾을 수 있도록 .minecraft 의 해당 폴더로 junction 링크.
|
|
await linkMinecraftRuntimeDirs(customRoot)
|
|
|
|
await updateLauncherProfile(pack.pack, customRoot)
|
|
} finally {
|
|
// 설치가 끝나면(또는 실패해도) 더 이상 필요 없는 platform-cache(다운받은
|
|
// fabric/forge/neoforge installer jar 캐시)를 삭제한다. 다음 실행에서 다시
|
|
// 받으면 되고, 남겨두면 사용자 .mc_custom 폴더만 차지한다. 실패 경로에서도
|
|
// 정리되도록 finally 에 둔다.
|
|
await fsp.rm(path.join(customRoot, 'platform-cache'), { recursive: true, force: true }).catch(() => {})
|
|
}
|
|
})
|
|
|
|
interface FabricInstallerMeta {
|
|
url: string
|
|
version: string
|
|
stable: boolean
|
|
}
|
|
|
|
async function installFabricLoader(pack: PackDefinition, customRoot: string): Promise<void> {
|
|
const loaderVersion = pack.platform.loaderVersion
|
|
if (!loaderVersion) {
|
|
throw new Error(t('errors.fabricLoaderRequired'))
|
|
}
|
|
|
|
// 0) 이미 설치돼 있으면 건너뛴다. fabric-installer 는 매번 jar 를 지우고
|
|
// 다시 쓰려고 시도해서, 마인크래프트나 다른 프로세스가 그 파일을 잡고
|
|
// 있으면 FileSystemException 으로 실패한다. 결과 파일이 그대로 있으면
|
|
// 재실행할 필요가 없으므로 그냥 통과.
|
|
const versionId = `fabric-loader-${loaderVersion}-${pack.mcVersion}`
|
|
const versionDir = path.join(customRoot, 'versions', versionId)
|
|
const versionJar = path.join(versionDir, `${versionId}.jar`)
|
|
const versionJson = path.join(versionDir, `${versionId}.json`)
|
|
if (fs.existsSync(versionJar) && fs.existsSync(versionJson)) {
|
|
sendLog(t('log.fabricAlreadyInstalled', { id: versionId, dir: versionDir }))
|
|
return
|
|
}
|
|
|
|
// 1) 최신 fabric-installer 메타데이터 조회.
|
|
sendLog(t('log.fabricFetchInstallerList'))
|
|
const installerList = await fetchJson<FabricInstallerMeta[]>('https://meta.fabricmc.net/v2/versions/installer')
|
|
if (!installerList || installerList.length === 0) {
|
|
throw new Error(t('errors.fabricInstallerListEmpty'))
|
|
}
|
|
const latest = installerList.find((item) => item.stable) || installerList[0]
|
|
sendLog(t('log.fabricInstallerDownload', { version: latest.version, url: latest.url }))
|
|
|
|
// 2) installer jar 캐시.
|
|
const cacheDir = path.join(customRoot, 'platform-cache')
|
|
await fsp.mkdir(cacheDir, { recursive: true })
|
|
const installerJar = path.join(cacheDir, `fabric-installer-${latest.version}.jar`)
|
|
await downloadFile(latest.url, installerJar)
|
|
|
|
// 3) Java 실행파일 확보.
|
|
const javaCmd = await findJavaExecutable()
|
|
sendLog(t('log.javaUsed', { path: javaCmd }))
|
|
|
|
// 4) fabric-installer CLI 자동 실행.
|
|
// client 모드 + -noprofile: launcher_profiles.json 은 우리 코드가 직접 갱신하므로 fabric-installer 가 덮어쓰지 않게 한다.
|
|
// JVM stdout 인코딩 강제 UTF-8:
|
|
// 한국 윈도우의 시스템 codepage 는 cp949(MS949) 라서 fabric-installer 가
|
|
// 한글을 cp949 로 stdout 에 쓰면 우리가 utf-8 로 디코드해서 깨져 보인다.
|
|
// `file.encoding` 은 default Charset, `stdout/stderr.encoding` 은
|
|
// System.out/err 의 PrintStream 인코딩(Java 18+). 둘 다 지정하면
|
|
// 구버전·신버전 JDK 모두에서 안전.
|
|
const args = [
|
|
'-Dfile.encoding=UTF-8',
|
|
'-Dstdout.encoding=UTF-8',
|
|
'-Dstderr.encoding=UTF-8',
|
|
'-jar', installerJar,
|
|
'client',
|
|
'-mcversion', pack.mcVersion,
|
|
'-loader', loaderVersion,
|
|
'-dir', customRoot,
|
|
'-noprofile'
|
|
]
|
|
sendLog(t('log.fabricInstallStart', { mc: pack.mcVersion, loader: loaderVersion, dir: customRoot }))
|
|
await runJavaProcess(javaCmd, args)
|
|
sendLog(t('log.fabricInstallDone'))
|
|
}
|
|
|
|
async function findJavaExecutable(): Promise<string> {
|
|
const javaName = process.platform === 'win32' ? 'java.exe' : 'java'
|
|
|
|
// 1) JAVA_HOME 우선.
|
|
const javaHome = process.env.JAVA_HOME
|
|
if (javaHome) {
|
|
const exe = path.join(javaHome, 'bin', javaName)
|
|
if (fs.existsSync(exe)) return exe
|
|
}
|
|
|
|
// 2) 마인크래프트 런처가 번들한 자바 런타임. .minecraft\runtime\<name>\<os>\<name>\bin\java.exe 구조.
|
|
try {
|
|
const runtimeBase = path.join(getAppDataDir(), '.minecraft', 'runtime')
|
|
if (fs.existsSync(runtimeBase)) {
|
|
const priority = [
|
|
'java-runtime-delta',
|
|
'java-runtime-gamma',
|
|
'java-runtime-beta',
|
|
'java-runtime-alpha',
|
|
'java-runtime-legacy',
|
|
'jre-legacy'
|
|
]
|
|
const names = await fsp.readdir(runtimeBase)
|
|
const sorted = names.slice().sort((a, b) => {
|
|
const ia = priority.indexOf(a)
|
|
const ib = priority.indexOf(b)
|
|
if (ia === -1 && ib === -1) return 0
|
|
if (ia === -1) return 1
|
|
if (ib === -1) return -1
|
|
return ia - ib
|
|
})
|
|
for (const name of sorted) {
|
|
const dir = path.join(runtimeBase, name)
|
|
try {
|
|
const osDirs = await fsp.readdir(dir)
|
|
for (const osDir of osDirs) {
|
|
const exe = path.join(dir, osDir, name, 'bin', javaName)
|
|
if (fs.existsSync(exe)) return exe
|
|
}
|
|
} catch {
|
|
// skip
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// skip
|
|
}
|
|
|
|
// 3) PATH 폴백.
|
|
return javaName
|
|
}
|
|
|
|
function runJavaProcess(cmd: string, args: string[]): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
|
|
let stderrTail = ''
|
|
const emitLines = (chunk: Buffer, prefix: string) => {
|
|
const text = chunk.toString('utf8')
|
|
text.split(/\r?\n/).forEach((line) => {
|
|
if (line.trim().length === 0) return
|
|
sendLog(` ${prefix} ${line}`)
|
|
})
|
|
}
|
|
child.stdout?.on('data', (chunk: Buffer) => emitLines(chunk, '[fabric]'))
|
|
child.stderr?.on('data', (chunk: Buffer) => {
|
|
stderrTail += chunk.toString('utf8')
|
|
if (stderrTail.length > 4000) stderrTail = stderrTail.slice(-4000)
|
|
emitLines(chunk, '[fabric-err]')
|
|
})
|
|
child.on('error', (err) => reject(new Error(t('errors.javaSpawnFailed', { message: err.message }))))
|
|
child.on('close', (code) => {
|
|
if (code === 0) {
|
|
resolve()
|
|
} else {
|
|
const detail = stderrTail.trim().split(/\r?\n/).slice(-3).join(' | ')
|
|
reject(new Error(t('errors.fabricInstallerExit', { code: code ?? '', detail: detail ? ' — ' + detail : '' })))
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
function deriveFileName(url: string): string {
|
|
try {
|
|
const parsed = new URL(url)
|
|
const last = parsed.pathname.split('/').filter(Boolean).pop() ?? ''
|
|
return decodeURIComponent(last)
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
function getAppDataDir(): string {
|
|
if (process.platform === 'win32' && process.env.APPDATA) return process.env.APPDATA
|
|
return app.getPath('appData')
|
|
}
|
|
|
|
/**
|
|
* 기존 javaArgs 에서 RAM 토큰만 새 값으로 교체하고 나머지 args 는 보존한다.
|
|
* - -Xmx: 항상 추천 RAM 으로 설정 (없으면 추가).
|
|
* - -Xms: 기존에 있을 때만 교체. 없으면 추가하지 않음.
|
|
* (clientMinRam 은 "유저 PC 사양 최소 요구치" 의미이지 JVM 초기 힙이 아님)
|
|
*/
|
|
function mergeRamArgs(existing: string, recommendedMb: number): string {
|
|
const newXmx = `-Xmx${recommendedMb}M`
|
|
const tokens = (existing || '').split(/\s+/).filter(Boolean)
|
|
let foundXmx = false
|
|
const merged = tokens.map((t) => {
|
|
if (t.startsWith('-Xmx')) { foundXmx = true; return newXmx }
|
|
return t
|
|
})
|
|
if (!foundXmx) merged.unshift(newXmx)
|
|
return merged.join(' ').trim()
|
|
}
|
|
|
|
// Aikar 권장 G1 GC 튜닝 셋의 기본형. 메모리(-Xms/-Xmx) 는 mergeRamArgs 에서 별도 처리.
|
|
const DEFAULT_JVM_TUNING_FLAGS = [
|
|
'-XX:+UnlockExperimentalVMOptions',
|
|
'-XX:+UseG1GC',
|
|
'-XX:G1NewSizePercent=20',
|
|
'-XX:G1ReservePercent=20',
|
|
'-XX:MaxGCPauseMillis=50',
|
|
'-XX:G1HeapRegionSize=32M'
|
|
]
|
|
|
|
/**
|
|
* 기존 javaArgs 에 JVM 튜닝 플래그를 병합. 사용자가 이미 동일 key 를 지정했으면
|
|
* 그 값을 존중하고(덮어쓰지 않음), 없는 항목만 끝에 덧붙인다.
|
|
* -XX:+UseG1GC, -XX:-UseG1GC → key = "-XX:UseG1GC"
|
|
* -XX:G1NewSizePercent=20 → key = "-XX:G1NewSizePercent"
|
|
* -Xmx2G → key = "-Xmx"
|
|
*/
|
|
function mergeJvmTuningFlags(existing: string, flags: string[]): string {
|
|
function keyOf(token: string): string {
|
|
if (token.startsWith('-XX:')) {
|
|
const body = token.slice(4)
|
|
const stripped = body.startsWith('+') || body.startsWith('-') ? body.slice(1) : body
|
|
const eqIdx = stripped.indexOf('=')
|
|
return `-XX:${eqIdx >= 0 ? stripped.slice(0, eqIdx) : stripped}`
|
|
}
|
|
const eqIdx = token.indexOf('=')
|
|
if (eqIdx >= 0) return token.slice(0, eqIdx)
|
|
if (token.startsWith('-Xmx')) return '-Xmx'
|
|
if (token.startsWith('-Xms')) return '-Xms'
|
|
if (token.startsWith('-Xmn')) return '-Xmn'
|
|
return token
|
|
}
|
|
const tokens = (existing || '').split(/\s+/).filter(Boolean)
|
|
const haveKeys = new Set(tokens.map(keyOf))
|
|
const additions: string[] = []
|
|
for (const f of flags) {
|
|
if (!haveKeys.has(keyOf(f))) additions.push(f)
|
|
}
|
|
if (additions.length === 0) return existing
|
|
return [...tokens, ...additions].join(' ').trim()
|
|
}
|
|
|
|
/**
|
|
* launcher_profiles 의 lastVersionId 를 마인크래프트 런처가 실제로 가지고 있는 폴더 이름과 맞춘다.
|
|
* - vanilla: mcVersion 그대로 (예: "1.21.4")
|
|
* - fabric: fabric-installer 가 만드는 폴더 명명 규칙은 `fabric-loader-<loaderVer>-<mcVer>`.
|
|
* platform.loaderVersion 이 비어 있으면 .minecraft/versions 에서 같은 mcVersion 의 폴더를 탐색.
|
|
* - forge / neoforge: 사용자 환경마다 폴더 명명이 다를 수 있어 일단 mcVersion 으로 폴백.
|
|
* 추후 정밀하게 잡으려면 mods loader installer 가 만든 실제 폴더명을 탐색해야 한다.
|
|
*/
|
|
function resolveLastVersionId(pack: PackDefinition): string {
|
|
if (pack.platform.type === 'vanilla') return pack.mcVersion
|
|
if (pack.platform.type === 'fabric') {
|
|
const loader = pack.platform.loaderVersion
|
|
if (loader) return `fabric-loader-${loader}-${pack.mcVersion}`
|
|
// loaderVersion 미지정: 실제 설치된 폴더 탐색.
|
|
try {
|
|
const versionsRoot = path.join(getAppDataDir(), '.minecraft', 'versions')
|
|
if (fs.existsSync(versionsRoot)) {
|
|
const entries = fs.readdirSync(versionsRoot)
|
|
const match = entries.find((entry) =>
|
|
entry.startsWith('fabric-loader-') && entry.endsWith(`-${pack.mcVersion}`)
|
|
)
|
|
if (match) return match
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
return pack.mcVersion // 폴백: vanilla 로 실행 시도
|
|
}
|
|
// forge / neoforge: 가능한 후보 탐색.
|
|
try {
|
|
const versionsRoot = path.join(getAppDataDir(), '.minecraft', 'versions')
|
|
if (fs.existsSync(versionsRoot)) {
|
|
const entries = fs.readdirSync(versionsRoot)
|
|
const match = entries.find((entry) =>
|
|
entry.toLowerCase().includes(pack.platform.type) && entry.includes(pack.mcVersion)
|
|
)
|
|
if (match) return match
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
return pack.mcVersion
|
|
}
|
|
|
|
async function updateLauncherProfile(pack: PackDefinition, gameDir: string): Promise<void> {
|
|
const launcherPath = path.join(getAppDataDir(), '.minecraft', 'launcher_profiles.json')
|
|
if (!fs.existsSync(launcherPath)) {
|
|
sendLog(t('log.launcherProfilesMissing', { path: launcherPath }))
|
|
return
|
|
}
|
|
const raw = await fsp.readFile(launcherPath, 'utf8')
|
|
const json = JSON.parse(raw) as { profiles?: Record<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 ramMerged = mergeRamArgs(existingJavaArgs, pack.serverMaxRam)
|
|
const javaArgs = mergeJvmTuningFlags(ramMerged, DEFAULT_JVM_TUNING_FLAGS)
|
|
if (existingJavaArgs !== javaArgs) {
|
|
sendLog(t('log.javaArgsUpdated', { before: existingJavaArgs, after: javaArgs }))
|
|
}
|
|
const lastVersionId = resolveLastVersionId(pack)
|
|
sendLog(t('log.lastVersionId', { id: lastVersionId }))
|
|
// 해당 version 폴더 존재 확인. 없으면 런처가 "Unable to prepare assets for download" 로 실패한다.
|
|
const versionDir = path.join(getAppDataDir(), '.minecraft', 'versions', lastVersionId)
|
|
if (!fs.existsSync(versionDir)) {
|
|
sendLog(t('log.versionMissingWarn', { id: lastVersionId }))
|
|
}
|
|
json.profiles[profileKey] = {
|
|
...existingProfile,
|
|
name: profileKey,
|
|
type: 'custom',
|
|
icon: LAUNCHER_PROFILE_ICON,
|
|
gameDir,
|
|
lastVersionId,
|
|
javaArgs
|
|
}
|
|
await fsp.writeFile(launcherPath, `${JSON.stringify(json, null, 2)}\n`, 'utf8')
|
|
sendLog(t('log.launcherProfilesUpdated', { profile: profileKey, dir: gameDir }))
|
|
}
|
|
|
|
/**
|
|
* 사용자가 기존에 .minecraft 에 만들어둔 설정 파일들(options.txt, optionsof.txt,
|
|
* servers.dat, usercache.json 등 최상위 파일 전부)을 .mc_custom 으로 복사한다.
|
|
* 기본 규칙은 "이미 .mc_custom 에 같은 이름의 파일이 있으면 보존" 이지만,
|
|
* ALWAYS_SYNC_FILES 목록에 든 파일(=사용자가 원래 .minecraft 에서 쓰던
|
|
* 설정을 그대로 이어 쓰고 싶은 옵션 파일들)은 매번 .minecraft 쪽으로
|
|
* 덮어써서 동기화한다.
|
|
* 디렉터리(mods/saves/versions/assets 등)는 각자 별도 처리하므로 여기서는 건드리지 않는다.
|
|
*/
|
|
const ALWAYS_SYNC_FILES = new Set([
|
|
'options.txt',
|
|
'optionsof.txt',
|
|
'optionsshaders.txt'
|
|
])
|
|
|
|
async function copyMinecraftUserSettings(customRoot: string): Promise<void> {
|
|
const mcRoot = path.join(getAppDataDir(), '.minecraft')
|
|
if (!fs.existsSync(mcRoot)) {
|
|
sendLog(t('log.minecraftRootMissing'))
|
|
return
|
|
}
|
|
let copied = 0
|
|
let skipped = 0
|
|
let synced = 0
|
|
try {
|
|
const entries = await fsp.readdir(mcRoot, { withFileTypes: true })
|
|
for (const entry of entries) {
|
|
if (!entry.isFile()) continue
|
|
const src = path.join(mcRoot, entry.name)
|
|
const dst = path.join(customRoot, entry.name)
|
|
const dstExists = fs.existsSync(dst)
|
|
const alwaysSync = ALWAYS_SYNC_FILES.has(entry.name)
|
|
if (dstExists && !alwaysSync) {
|
|
skipped += 1
|
|
continue
|
|
}
|
|
try {
|
|
await fsp.copyFile(src, dst)
|
|
if (dstExists) synced += 1
|
|
else copied += 1
|
|
} catch (err) {
|
|
sendLog(t('log.settingCopyFail', { name: entry.name, message: (err as Error).message }))
|
|
}
|
|
}
|
|
sendLog(t('log.settingCopySummary', { copied, skipped, synced }))
|
|
} catch (err) {
|
|
sendLog(t('log.settingCopyError', { message: (err as Error).message }))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* .mc_custom 에서 마인크래프트 런처가 찾는 assets/libraries/versions 를
|
|
* .minecraft 의 같은 폴더로 junction(Windows) / symlink(POSIX) 한다.
|
|
* 이미 같은 자리에 무언가 있으면 손대지 않는다.
|
|
*
|
|
* 이걸 안 하면 런처가 .mc_custom/assets 가 없다며 "Unable to prepare assets
|
|
* for download" 에러로 실행에 실패한다.
|
|
*/
|
|
async function linkMinecraftRuntimeDirs(customRoot: string): Promise<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(t('log.runtimeDirMissing', { dir }))
|
|
continue
|
|
}
|
|
let existing: import('node:fs').Stats | null = null
|
|
try { existing = await fsp.lstat(dst) } catch { existing = null }
|
|
if (existing) {
|
|
if (existing.isSymbolicLink()) continue // 이미 링크됨
|
|
sendLog(t('log.runtimeDirExists', { dir }))
|
|
continue
|
|
}
|
|
try {
|
|
// 'junction' 은 Windows 에서 권한 없이 만들 수 있는 디렉터리 링크.
|
|
// 다른 OS 에서는 Node 가 알아서 일반 symlink 로 처리.
|
|
await fsp.symlink(src, dst, 'junction')
|
|
sendLog(t('log.runtimeLinkCreated', { dir }))
|
|
} catch (err) {
|
|
sendLog(t('log.runtimeLinkFail', { dir, message: (err as Error).message }))
|
|
}
|
|
}
|
|
}
|
|
|
|
ipcMain.handle('finish:openServerFolder', async () => {
|
|
if (!state.installPath) return
|
|
await shell.openPath(state.installPath)
|
|
})
|
|
|
|
ipcMain.handle('finish:desktopShortcut', async () => {
|
|
if (process.platform !== 'win32' || !state.installPath) return
|
|
const desktopDir = app.getPath('desktop')
|
|
const shortcutPath = path.join(desktopDir, 'MusicQuiz Server.lnk')
|
|
const runBat = path.join(state.installPath, 'run.bat')
|
|
const ok = require('electron').shell.writeShortcutLink(shortcutPath, 'create', {
|
|
target: runBat,
|
|
cwd: state.installPath,
|
|
description: t('log.shortcutDescription')
|
|
})
|
|
sendLog(ok ? t('log.shortcutCreated', { path: shortcutPath }) : t('log.shortcutFailed'))
|
|
})
|
|
|
|
ipcMain.handle('finish:startServer', async () => {
|
|
if (!state.installPath) return
|
|
const runBat = path.join(state.installPath, 'run.bat')
|
|
if (!fs.existsSync(runBat)) {
|
|
sendLog(t('log.runBatMissingPath', { path: runBat }))
|
|
return
|
|
}
|
|
spawn('cmd.exe', ['/c', 'start', '', runBat], { cwd: state.installPath, detached: true, stdio: 'ignore' }).unref()
|
|
sendLog(t('log.serverStartRequested'))
|
|
})
|
|
|
|
ipcMain.handle('finish:startLauncher', async () => {
|
|
// 마인크래프트 런처는 두 가지 형태로 배포된다:
|
|
// 1) Win32 설치판: C:\Program Files (x86)\Minecraft Launcher\MinecraftLauncher.exe 등
|
|
// 2) MSIX(Microsoft Store) 앱: PackageFamilyName=Microsoft.4297127D64EC6_8wekyb3d8bbwe, AppId=Minecraft
|
|
// - 일반 .exe 가 아니라 shell:AppsFolder\<PFN>!<AppId> 또는 App Execution Alias 로만 띄울 수 있음.
|
|
// minecraft:// URL 스킴은 런처가 핸들러로 등록되어 있어야만 동작하고, 등록이 깨지거나 비어 있으면
|
|
// MS Store 로 폴백되므로 가장 마지막 시도로 미룬다.
|
|
if (process.platform !== 'win32') {
|
|
try {
|
|
await shell.openExternal('minecraft://')
|
|
sendLog(t('log.launcherUrlSchemeNonWin'))
|
|
} catch (err) {
|
|
sendLog(t('log.launcherFail', { message: (err as Error).message }))
|
|
}
|
|
return
|
|
}
|
|
|
|
const programFilesX86 = process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)'
|
|
const programFiles = process.env['ProgramFiles'] ?? 'C:\\Program Files'
|
|
const localAppData = process.env['LOCALAPPDATA'] ?? path.join(os.homedir(), 'AppData', 'Local')
|
|
|
|
type LauncherCandidate = { label: string; path: string; viaShell: boolean }
|
|
const candidates: LauncherCandidate[] = [
|
|
// Win32 설치판 — 실행 파일 직접 spawn.
|
|
{ label: t('candidates.winProgramFiles86'), path: path.join(programFilesX86, 'Minecraft Launcher', 'MinecraftLauncher.exe'), viaShell: false },
|
|
{ label: t('candidates.winProgramFiles'), path: path.join(programFiles, 'Minecraft Launcher', 'MinecraftLauncher.exe'), viaShell: false },
|
|
{ label: t('candidates.winLegacy86'), path: path.join(programFilesX86, 'Minecraft', 'MinecraftLauncher.exe'), viaShell: false },
|
|
{ label: t('candidates.winLegacy'), path: path.join(programFiles, 'Minecraft', 'MinecraftLauncher.exe'), viaShell: false },
|
|
{ label: t('candidates.xboxGamePass'), path: 'C:\\XboxGames\\Minecraft Launcher\\Content\\Minecraft.exe', viaShell: false },
|
|
{ label: t('candidates.npmPortable'), path: path.join(localAppData, 'Programs', 'minecraft-launcher', 'MinecraftLauncher.exe'), viaShell: false },
|
|
// App Execution Alias(MS Store 설치 시 자동 생성, reparse point 라 cmd /c start 로 띄워야 안정적).
|
|
{ label: t('candidates.appAliasMinecraft'), path: path.join(localAppData, 'Microsoft', 'WindowsApps', 'Minecraft.exe'), viaShell: true },
|
|
{ label: t('candidates.appAliasLauncher'), path: path.join(localAppData, 'Microsoft', 'WindowsApps', 'MinecraftLauncher.exe'), viaShell: true }
|
|
]
|
|
|
|
for (const cand of candidates) {
|
|
let exists = false
|
|
try { exists = fs.existsSync(cand.path) } catch { exists = false }
|
|
if (!exists) continue
|
|
try {
|
|
if (cand.viaShell) {
|
|
sendLog(t('log.launcherExecShell', { label: cand.label, path: cand.path }))
|
|
spawn('cmd.exe', ['/c', 'start', '', cand.path], { detached: true, stdio: 'ignore' }).unref()
|
|
} else {
|
|
sendLog(t('log.launcherExec', { label: cand.label, path: cand.path }))
|
|
spawn(cand.path, [], { detached: true, stdio: 'ignore' }).unref()
|
|
}
|
|
return
|
|
} catch (err) {
|
|
sendLog(t('log.launcherCandFail', { path: cand.path, message: (err as Error).message }))
|
|
}
|
|
}
|
|
|
|
// MSIX 앱 직접 실행: shell:AppsFolder\<PackageFamilyName>!<AppId>.
|
|
// 마인크래프트 런처(Java) PFN: Microsoft.4297127D64EC6_8wekyb3d8bbwe, AppId: Minecraft.
|
|
try {
|
|
const aumid = 'shell:AppsFolder\\Microsoft.4297127D64EC6_8wekyb3d8bbwe!Minecraft'
|
|
sendLog(t('log.launcherAppsFolderTry', { aumid }))
|
|
spawn('explorer.exe', [aumid], { detached: true, stdio: 'ignore' }).unref()
|
|
return
|
|
} catch (err) {
|
|
sendLog(t('log.launcherAppsFolderFail', { message: (err as Error).message }))
|
|
}
|
|
|
|
// 마지막 수단: minecraft:// URL 스킴. 런처가 없으면 MS Store 가 열린다.
|
|
try {
|
|
sendLog(t('log.launcherUrlSchemeFallback'))
|
|
await shell.openExternal('minecraft://')
|
|
} catch (err) {
|
|
sendLog(t('log.launcherUrlSchemeFail', { message: (err as Error).message }))
|
|
}
|
|
|
|
sendLog(t('log.launcherAllFail'))
|
|
})
|
|
|
|
ipcMain.handle('i18n:dict', () => localeDict)
|
|
|
|
ipcMain.handle('app:quit', () => {
|
|
// 모든 창을 닫고 앱 종료. macOS에서도 종료(설치기는 한 번 쓰고 끝이니 잔류시키지 않음).
|
|
app.quit()
|
|
})
|
|
|
|
app.whenReady().then(() => {
|
|
createMainWindow()
|
|
app.on('activate', () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
|
|
})
|
|
})
|
|
|
|
app.on('window-all-closed', () => {
|
|
if (state.configEditorServer) {
|
|
state.configEditorServer.close()
|
|
state.configEditorServer = null
|
|
}
|
|
if (process.platform !== 'darwin') app.quit()
|
|
})
|