i18n: 음악퀴즈 설치기 UI 문구를 locales/installer/ko-kr.json 으로 분리
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -21,9 +21,14 @@ import type {
|
||||
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'
|
||||
|
||||
loadEnv()
|
||||
|
||||
const i18n = loadComponentI18n('installer')
|
||||
const t = i18n.t
|
||||
export const localeDict = i18n.dict
|
||||
|
||||
interface InstallerState {
|
||||
manifestUrl: string
|
||||
baseUrl: string
|
||||
@@ -100,7 +105,7 @@ function fetchBuffer(url: string): Promise<Buffer> {
|
||||
response.on('end', () => resolve(Buffer.concat(chunks)))
|
||||
})
|
||||
request.on('error', reject)
|
||||
request.on('timeout', () => request.destroy(new Error('요청 시간 초과')))
|
||||
request.on('timeout', () => request.destroy(new Error(t('errors.requestTimeout'))))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -124,7 +129,7 @@ ipcMain.handle('packs:load', async (_event, manifestUrlInput?: string): Promise<
|
||||
state.manifestUrl = manifestUrlInput
|
||||
state.baseUrl = deriveBaseUrl(manifestUrlInput)
|
||||
}
|
||||
sendLog(`manifest 다운로드: ${state.manifestUrl}`)
|
||||
sendLog(t('log.manifestDownload', { url: state.manifestUrl }))
|
||||
const manifest = await fetchJson<Manifest>(state.manifestUrl)
|
||||
const results: FetchedPack[] = []
|
||||
for (const entry of manifest.packs ?? []) {
|
||||
@@ -135,21 +140,21 @@ ipcMain.handle('packs:load', async (_event, manifestUrlInput?: string): Promise<
|
||||
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}`)
|
||||
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(`로드된 음악퀴즈: ${results.length}개`)
|
||||
sendLog(t('log.packsLoaded', { count: results.length }))
|
||||
return results
|
||||
})
|
||||
|
||||
ipcMain.handle('packs:select', async (_event, packKey: string) => {
|
||||
if (!state.packs.has(packKey)) {
|
||||
throw new Error('선택한 음악퀴즈를 찾을 수 없습니다.')
|
||||
throw new Error(t('errors.packNotFound'))
|
||||
}
|
||||
state.selectedKey = packKey
|
||||
sendLog(`선택: ${packKey}`)
|
||||
sendLog(t('log.selectedPack', { key: packKey }))
|
||||
})
|
||||
|
||||
ipcMain.handle('dialog:pickFolder', async (): Promise<string | null> => {
|
||||
@@ -163,10 +168,10 @@ ipcMain.handle('dialog:pickFolder', async (): Promise<string | null> => {
|
||||
|
||||
ipcMain.handle('install:validatePath', async (_event, target: string) => {
|
||||
if (!target || target.trim().length === 0) {
|
||||
return { ok: false, message: '서버 설치 경로를 입력해 주세요.' }
|
||||
return { ok: false, message: t('errors.installPathRequired') }
|
||||
}
|
||||
if (containsHangul(target)) {
|
||||
return { ok: false, message: '경로에 한글이 포함되면 마인크래프트 서버가 정상 동작하지 않습니다.' }
|
||||
return { ok: false, message: t('errors.installPathHangul') }
|
||||
}
|
||||
const absolute = path.resolve(target)
|
||||
state.installPath = absolute
|
||||
@@ -225,7 +230,7 @@ function downloadStream(
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new Error('취소되었습니다.'))
|
||||
reject(new Error(t('errors.canceled')))
|
||||
return
|
||||
}
|
||||
const u = new URL(url)
|
||||
@@ -233,7 +238,7 @@ function downloadStream(
|
||||
const fileStream = fs.createWriteStream(target)
|
||||
let settled = false
|
||||
const onAbort = () => {
|
||||
try { req.destroy(new Error('취소되었습니다.')) } catch { /* noop */ }
|
||||
try { req.destroy(new Error(t('errors.canceled'))) } catch { /* noop */ }
|
||||
try { fileStream.close() } catch { /* noop */ }
|
||||
}
|
||||
signal.addEventListener('abort', onAbort)
|
||||
@@ -279,13 +284,13 @@ function downloadStream(
|
||||
fileStream.close(() => {})
|
||||
if (!settled) { settled = true; reject(err) }
|
||||
})
|
||||
req.on('timeout', () => req.destroy(new Error('요청 시간 초과')))
|
||||
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: '이미 JDK 설치가 진행 중입니다.' }
|
||||
return { ok: false, message: t('errors.jdkBusy') }
|
||||
}
|
||||
jdkInstall.inProgress = true
|
||||
const controller = new AbortController()
|
||||
@@ -298,20 +303,24 @@ ipcMain.handle('jdk:install', async (): Promise<{ ok: boolean; path?: string; me
|
||||
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('JDK(Temurin 21) 자동 설치 시작 — 다운로드 중...')
|
||||
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(`JDK 다운로드: ${pct}% (${Math.floor(loaded / 1024 / 1024)}MB / ${Math.floor(total / 1024 / 1024)}MB)`)
|
||||
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('취소되었습니다.')
|
||||
if (controller.signal.aborted) throw new Error(t('errors.canceled'))
|
||||
|
||||
sendLog('JDK 압축 해제 중...')
|
||||
sendLog(t('log.jdkExtracting'))
|
||||
await fsp.rm(destDir, { recursive: true, force: true })
|
||||
await fsp.mkdir(destDir, { recursive: true })
|
||||
await extractZip(tempZip, { dir: destDir })
|
||||
@@ -323,19 +332,19 @@ ipcMain.handle('jdk:install', async (): Promise<{ ok: boolean; path?: string; me
|
||||
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(`설치 후 java 실행 파일을 찾지 못했습니다: ${javaExe}`)
|
||||
throw new Error(t('errors.javaExeMissing', { path: javaExe }))
|
||||
}
|
||||
|
||||
sendLog(`JDK 자동 설치 완료: ${javaRoot}`)
|
||||
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('JDK 설치가 취소되었습니다.')
|
||||
sendLog(t('log.jdkCanceled'))
|
||||
try { await fsp.rm(destDir, { recursive: true, force: true }) } catch { /* noop */ }
|
||||
return { ok: false, message: '취소됨' }
|
||||
return { ok: false, message: t('errors.canceledShort') }
|
||||
}
|
||||
sendLog(`JDK 설치 실패: ${msg}`)
|
||||
sendLog(t('log.jdkInstallFailedLog', { message: msg }))
|
||||
return { ok: false, message: msg }
|
||||
} finally {
|
||||
try { await fsp.rm(tempZip, { force: true }) } catch { /* noop */ }
|
||||
@@ -348,7 +357,7 @@ ipcMain.handle('jdk:install', async (): Promise<{ ok: boolean; path?: string; me
|
||||
ipcMain.handle('jdk:cancelInstall', async (): Promise<{ ok: boolean }> => {
|
||||
if (jdkInstall.controller) {
|
||||
jdkInstall.controller.abort()
|
||||
sendLog('JDK 설치 취소 요청을 보냈습니다.')
|
||||
sendLog(t('log.jdkCancelRequested'))
|
||||
}
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -368,9 +377,9 @@ async function downloadAndExtractZip(url: string, label: string, extractDir: str
|
||||
const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'mq-zip-'))
|
||||
const tempZip = path.join(tempDir, 'package.zip')
|
||||
try {
|
||||
sendLog(`${label} 다운로드: ${url}`)
|
||||
sendLog(t('log.labelDownload', { label, url }))
|
||||
await downloadFile(url, tempZip)
|
||||
sendLog(`${label} 압축 해제: ${extractDir}`)
|
||||
sendLog(t('log.labelExtract', { label, dir: extractDir }))
|
||||
await extractZip(tempZip, { dir: extractDir })
|
||||
} finally {
|
||||
await fsp.rm(tempDir, { recursive: true, force: true })
|
||||
@@ -379,36 +388,36 @@ async function downloadAndExtractZip(url: string, label: string, extractDir: str
|
||||
|
||||
async function downloadServerZip(pack: PackDefinition, targetDir: string): Promise<void> {
|
||||
if (!pack.serverPath) {
|
||||
sendLog('서버 파일(serverPath)이 비어 있어 서버 zip 다운로드를 건너뜁니다.')
|
||||
sendLog(t('log.skipServerZip'))
|
||||
return
|
||||
}
|
||||
const url = resolveManifestRelative(pack.serverPath, 'servers')
|
||||
await downloadAndExtractZip(url, '서버 파일', targetDir)
|
||||
await downloadAndExtractZip(url, t('log.labelServerFile'), targetDir)
|
||||
}
|
||||
|
||||
async function downloadMapZip(pack: PackDefinition, customRoot: string): Promise<void> {
|
||||
if (!pack.mapPath) {
|
||||
sendLog('맵 파일(mapPath)이 비어 있어 맵 다운로드를 건너뜁니다.')
|
||||
sendLog(t('log.skipMapZip'))
|
||||
return
|
||||
}
|
||||
const url = resolveManifestRelative(pack.mapPath, 'maps')
|
||||
const savesDir = path.join(customRoot, 'saves')
|
||||
await downloadAndExtractZip(url, '맵', savesDir)
|
||||
await downloadAndExtractZip(url, t('log.labelMap'), savesDir)
|
||||
}
|
||||
|
||||
async function downloadModsFolder(pack: PackDefinition, customRoot: string): Promise<void> {
|
||||
if (!pack.modsFolder) {
|
||||
sendLog('modsFolder가 비어 있어 모드 다운로드를 건너뜁니다.')
|
||||
sendLog(t('log.skipModsFolder'))
|
||||
return
|
||||
}
|
||||
const indexUrl = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/index.json`
|
||||
sendLog(`모드 목록 조회: ${indexUrl}`)
|
||||
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(`/file/mods/${pack.modsFolder}/ 안에 .jar 파일이 없습니다.`)
|
||||
sendLog(t('log.modsFolderEmpty', { folder: pack.modsFolder }))
|
||||
return
|
||||
}
|
||||
const modsDir = path.join(customRoot, 'mods')
|
||||
@@ -416,33 +425,33 @@ async function downloadModsFolder(pack: PackDefinition, customRoot: string): Pro
|
||||
for (const fileName of files) {
|
||||
const url = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/${encodeURIComponent(fileName)}`
|
||||
const target = path.join(modsDir, fileName)
|
||||
sendLog(`모드 다운로드: ${fileName}`)
|
||||
sendLog(t('log.modDownload', { file: fileName }))
|
||||
await downloadFile(url, target)
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadResourcepackZip(pack: PackDefinition, customRoot: string): Promise<void> {
|
||||
if (!pack.resourcepackPath) {
|
||||
sendLog('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(`리소스팩 다운로드: ${url}`)
|
||||
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('음악퀴즈를 찾을 수 없습니다.')
|
||||
if (!pack) throw new Error(t('errors.packNotFound2'))
|
||||
if (containsHangul(payload.installPath)) {
|
||||
throw new Error('경로에 한글이 포함되면 안 됩니다.')
|
||||
throw new Error(t('errors.installPathHangulShort'))
|
||||
}
|
||||
const installPath = path.resolve(payload.installPath)
|
||||
state.installPath = installPath
|
||||
await fsp.mkdir(installPath, { recursive: true })
|
||||
sendLog(`서버 설치 경로: ${installPath}`)
|
||||
sendLog(t('log.serverInstallPath', { path: installPath }))
|
||||
|
||||
await downloadServerZip(pack.pack, installPath)
|
||||
// 다운로드한 zip에 들어있을 수 있는 eula.txt를 그대로 보존한다.
|
||||
@@ -469,20 +478,20 @@ ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) =
|
||||
async function injectUpnpToRunBat(installPath: string): Promise<void> {
|
||||
const runBat = path.join(installPath, 'run.bat')
|
||||
if (!fs.existsSync(runBat)) {
|
||||
sendLog('run.bat 이 없어 UPnP 자동 등록 스크립트 주입을 건너뜁니다.')
|
||||
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('run.bat 에 이미 UPnP 자동 등록 스크립트가 들어 있어 건너뜁니다.')
|
||||
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('run.bat 에서 java 호출 라인을 찾지 못해 UPnP 자동 등록 주입을 건너뜁니다.')
|
||||
sendLog(t('log.runBatNoJava'))
|
||||
return
|
||||
}
|
||||
let pauseIdx = -1
|
||||
@@ -521,7 +530,7 @@ async function injectUpnpToRunBat(installPath: string): Promise<void> {
|
||||
// bat 파일은 CRLF 가 안전.
|
||||
const output = merged.join('\r\n')
|
||||
await fsp.writeFile(runBat, output, 'utf8')
|
||||
sendLog('run.bat 에 서버 기동/종료 시 UPnP 자동 등록·해제 스크립트를 추가했습니다.')
|
||||
sendLog(t('log.runBatInjected'))
|
||||
}
|
||||
|
||||
ipcMain.handle('server:readEula', async (_event, installPath: string): Promise<{ exists: boolean; content: string }> => {
|
||||
@@ -542,7 +551,7 @@ ipcMain.handle('server:fetchMinecraftEula', async (): Promise<{ url: string; htm
|
||||
const buffer = await fetchBuffer(url)
|
||||
return { url, html: buffer.toString('utf8') }
|
||||
} catch (error) {
|
||||
sendLog(`Minecraft EULA 페이지 조회 실패: ${(error as Error).message}`)
|
||||
sendLog(t('log.mojangEulaFetchFail', { message: (error as Error).message }))
|
||||
return { url, html: '' }
|
||||
}
|
||||
})
|
||||
@@ -550,12 +559,12 @@ ipcMain.handle('server:fetchMinecraftEula', async (): Promise<{ url: string; htm
|
||||
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 동의 저장 완료.')
|
||||
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('음악퀴즈를 찾을 수 없습니다.')
|
||||
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 }
|
||||
@@ -578,14 +587,14 @@ ipcMain.handle('server:configEditor', async (_event, installPath: string) => {
|
||||
} catch (error) {
|
||||
res.statusCode = 500
|
||||
res.setHeader('content-type', 'text/plain; charset=utf-8')
|
||||
res.end(`서버 오류: ${(error as Error).message}`)
|
||||
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(`서버 설정 편집기 실행: ${url}`)
|
||||
sendLog(t('log.configEditorOpen', { url }))
|
||||
await shell.openExternal(url)
|
||||
return { url }
|
||||
})
|
||||
@@ -599,7 +608,7 @@ async function pickPort(): Promise<number> {
|
||||
const address = probe.address()
|
||||
probe.close(() => {
|
||||
if (address && typeof address === 'object') resolve(address.port)
|
||||
else reject(new Error('포트를 할당할 수 없습니다.'))
|
||||
else reject(new Error(t('errors.portAllocFail')))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -619,7 +628,7 @@ async function handleConfigEditorRequest(installPath: string, req: http.Incoming
|
||||
const target = url.searchParams.get('name')
|
||||
if (!target || !SERVER_CONFIG_FILES.includes(target)) {
|
||||
res.statusCode = 400
|
||||
res.end('알 수 없는 파일')
|
||||
res.end(t('configEditor.unknownFile'))
|
||||
return
|
||||
}
|
||||
const filePath = path.join(installPath, target)
|
||||
@@ -640,7 +649,7 @@ async function handleConfigEditorRequest(installPath: string, req: http.Incoming
|
||||
const content = params.get('content') ?? ''
|
||||
if (!SERVER_CONFIG_FILES.includes(target)) {
|
||||
res.statusCode = 400
|
||||
res.end('알 수 없는 파일')
|
||||
res.end(t('configEditor.unknownFile'))
|
||||
return
|
||||
}
|
||||
const filePath = path.join(installPath, target)
|
||||
@@ -668,15 +677,17 @@ function renderConfigEditorPage(fileSet: string[]): string {
|
||||
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>서버 설정 편집기</title>
|
||||
<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>서버 설정 편집기</h1>
|
||||
<p><small>아래 파일을 직접 편집한 후 "적용" 버튼으로 저장합니다. 설치기 화면에서 다음 단계로 진행하기 전 마음껏 편집할 수 있습니다.</small></p>
|
||||
<label>대상 파일 <select id="file">${optionMarkup}</select></label>
|
||||
<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">적용</button>
|
||||
<button id="save">${t('configEditor.applyButton')}</button>
|
||||
<p id="status"><small></small></p>
|
||||
<script>
|
||||
const file=document.getElementById('file');
|
||||
@@ -684,7 +695,7 @@ 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?'저장 완료':'저장 실패';});
|
||||
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>`
|
||||
}
|
||||
@@ -700,72 +711,75 @@ function readBody(req: http.IncomingMessage): Promise<string> {
|
||||
|
||||
ipcMain.handle('server:portForward', async (_event, port: number): Promise<PortForwardResult> => {
|
||||
const targetPort = Number.isFinite(port) && port > 0 ? port : 25565
|
||||
sendLog(`포트포워딩 점검 시작: 포트 ${targetPort}`)
|
||||
sendLog(t('log.portCheckStart', { port: targetPort }))
|
||||
|
||||
// 1차 점검 전에 우리가 이전 실행에서 만든 UPnP 매핑이 남아 있으면 먼저 제거한다.
|
||||
// 이렇게 해야 "사용자 라우터 규칙이 활성화돼서 외부 접근이 가능한 상태" 와 "UPnP 매핑 덕분에 접근 가능한 상태" 가 구별된다.
|
||||
// 사용자 규칙이 비활성/없으면 1차 점검은 false 가 되어 UPnP 시도 단계로 자연스럽게 넘어간다.
|
||||
sendLog('이전 실행의 UPnP 매핑이 남아 있으면 제거합니다(중복 방지)...')
|
||||
sendLog(t('log.upnpCleanup'))
|
||||
await removeUpnpMapping(targetPort)
|
||||
|
||||
// 외부 IP 확보: 공용 API → 실패 시 UPnP 게이트웨이의 외부 IP로 폴백.
|
||||
let externalIp = await detectExternalIpHttp()
|
||||
if (externalIp) {
|
||||
sendLog(`외부 IP 확인(HTTP): ${externalIp}`)
|
||||
sendLog(t('log.externalIpHttp', { ip: externalIp }))
|
||||
} else {
|
||||
sendLog('외부 IP 확인 실패(HTTP). UPnP 게이트웨이를 통한 조회 시도...')
|
||||
sendLog(t('log.externalIpHttpFail'))
|
||||
externalIp = await detectExternalIpUpnp()
|
||||
if (externalIp) sendLog(`외부 IP 확인(UPnP): ${externalIp}`)
|
||||
else sendLog('UPnP 게이트웨이에서도 외부 IP를 얻지 못했습니다.')
|
||||
if (externalIp) sendLog(t('log.externalIpUpnp', { ip: externalIp }))
|
||||
else sendLog(t('log.externalIpUpnpFail'))
|
||||
}
|
||||
|
||||
// 1차 점검: 외부에서 이미 접근 가능한지 (서버가 떠 있거나, 우리가 임시 리스너 띄워서 검증).
|
||||
sendLog('외부 포트체크 서비스(ifconfig.co)로 1차 점검합니다...')
|
||||
sendLog(t('log.probeStart'))
|
||||
let probe = await probePortFromOutside(targetPort, externalIp)
|
||||
if (!externalIp && probe.detectedIp) externalIp = probe.detectedIp
|
||||
sendLog(`1차 점검 결과: ${probe.reachable === true ? '성공' : probe.reachable === false ? '실패' : '확인 불가'} (${probe.detail})`)
|
||||
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(`외부에서 ${externalIp || '(IP 미상)'}:${targetPort} 접근 확인됨. 사용자 규칙으로 포워딩 됨.`)
|
||||
sendLog(t('log.probePreForwarded', { addr: externalIp || t('log.ipUnknown'), port: targetPort }))
|
||||
return { status: 'preForwarded', externalIp, port: targetPort }
|
||||
}
|
||||
|
||||
// UPnP 시도.
|
||||
sendLog(`UPnP로 포트 ${targetPort} 자동 개방 시도(TCP)...`)
|
||||
sendLog(t('log.upnpTryOpen', { port: targetPort }))
|
||||
try {
|
||||
await openPortViaUpnp(targetPort)
|
||||
sendLog('UPnP portMapping 요청 성공. 외부 접근을 재확인합니다.')
|
||||
sendLog(t('log.upnpReqOk'))
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message || String(error)
|
||||
sendLog(`UPnP 시도 실패: ${msg}`)
|
||||
sendLog(t('log.upnpTryFail', { message: msg }))
|
||||
return {
|
||||
status: 'upnpFailed',
|
||||
externalIp,
|
||||
port: targetPort,
|
||||
message: `UPnP 실패: ${msg}. 라우터에서 UPnP가 꺼져 있을 수 있습니다. 직접 포트포워딩을 해주세요.`
|
||||
message: t('log.upnpFailDetail', { message: msg })
|
||||
}
|
||||
}
|
||||
|
||||
// NAT 반영 지연을 고려해 최대 3회 재점검.
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
await sleep(1500)
|
||||
sendLog(`UPnP 적용 후 재점검 ${attempt}/3...`)
|
||||
sendLog(t('log.upnpRecheck', { attempt }))
|
||||
probe = await probePortFromOutside(targetPort, externalIp)
|
||||
if (!externalIp && probe.detectedIp) externalIp = probe.detectedIp
|
||||
if (probe.reachable === true) {
|
||||
sendLog(`UPnP로 포트 ${targetPort} 자동 개방 완료. 테스트 매핑을 제거합니다(실제 개방은 run.bat 이 서버 기동 시 자동으로 처리).`)
|
||||
sendLog(t('log.upnpDone', { port: targetPort }))
|
||||
await removeUpnpMapping(targetPort)
|
||||
return { status: 'upnpOk', externalIp, port: targetPort }
|
||||
}
|
||||
}
|
||||
|
||||
// 테스트 목적으로 만든 매핑 정리. 실제 개방은 run.bat 이 담당.
|
||||
sendLog('테스트용 UPnP 매핑을 정리합니다.')
|
||||
sendLog(t('log.upnpCleanupTest'))
|
||||
await removeUpnpMapping(targetPort)
|
||||
|
||||
const reason = probe.reachable === false
|
||||
? 'UPnP 매핑은 등록됐지만 외부 포트체크 서비스에서 연결이 닿지 않았습니다. ISP 차단, 이중 NAT, 또는 방화벽 설정을 확인하세요.'
|
||||
: `외부 포트체크 결과를 받지 못했습니다(${probe.detail}). UPnP 매핑은 등록됐을 수 있습니다.`
|
||||
? t('log.upnpFailReason1')
|
||||
: t('log.upnpFailReason2', { detail: probe.detail })
|
||||
sendLog(reason)
|
||||
return { status: 'upnpFailed', externalIp, port: targetPort, message: reason }
|
||||
})
|
||||
@@ -792,12 +806,12 @@ function detectExternalIpUpnp(): Promise<string> {
|
||||
try {
|
||||
client = natUpnp.createClient()
|
||||
} catch (err) {
|
||||
sendLog(`UPnP 클라이언트 생성 실패: ${(err as Error).message}`)
|
||||
sendLog(t('log.upnpClientFail', { message: (err as Error).message }))
|
||||
finish('')
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
sendLog('UPnP externalIp 조회 타임아웃(8s).')
|
||||
sendLog(t('log.upnpExternalTimeout'))
|
||||
try { client && client.close() } catch {}
|
||||
finish('')
|
||||
}, 8000)
|
||||
@@ -805,7 +819,7 @@ function detectExternalIpUpnp(): Promise<string> {
|
||||
clearTimeout(timer)
|
||||
try { client && client.close() } catch {}
|
||||
if (err || !ip) {
|
||||
if (err) sendLog(`UPnP externalIp 오류: ${err.message}`)
|
||||
if (err) sendLog(t('log.upnpExternalErr', { message: err.message }))
|
||||
finish('')
|
||||
} else {
|
||||
finish(ip)
|
||||
@@ -846,9 +860,9 @@ async function probePortFromOutside(
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code
|
||||
if (code === 'EADDRINUSE') {
|
||||
sendLog(`포트 ${port}이(가) 이미 사용 중. 임시 리스너 없이 외부 서비스 응답만으로 판정합니다.`)
|
||||
sendLog(t('log.portInUse', { port }))
|
||||
} else {
|
||||
sendLog(`임시 리스너 바인딩 실패: ${(err as Error).message}`)
|
||||
sendLog(t('log.listenerBindFail', { message: (err as Error).message }))
|
||||
}
|
||||
try { server && server.close() } catch {}
|
||||
server = null
|
||||
@@ -886,20 +900,20 @@ async function probePortFromOutside(
|
||||
let reachable: boolean | null = null
|
||||
const details: string[] = []
|
||||
if (listenerBound) {
|
||||
details.push(`임시 리스너 도달=${gotInboundConnection ? 'yes' : 'no'}`)
|
||||
details.push(t('log.detailListenerHit', { value: gotInboundConnection ? 'yes' : 'no' }))
|
||||
if (gotInboundConnection) reachable = true
|
||||
} else {
|
||||
details.push('임시 리스너=skip(포트 사용중)')
|
||||
details.push(t('log.detailListenerSkip'))
|
||||
}
|
||||
|
||||
let detectedIp = ''
|
||||
if ('ok' in externalResult && externalResult.ok) {
|
||||
details.push(`ifconfig.co reachable=${externalResult.reachable} ip=${externalResult.ip || '?'}`)
|
||||
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(`ifconfig.co 실패=${(externalResult as { error: string }).error}`)
|
||||
details.push(t('log.detailIfconfigFail', { error: (externalResult as { error: string }).error }))
|
||||
}
|
||||
|
||||
// 임시 리스너가 떴고 외부 서비스도 닿지 않았다면 명확한 false.
|
||||
@@ -907,7 +921,7 @@ async function probePortFromOutside(
|
||||
|
||||
return {
|
||||
reachable,
|
||||
detail: details.join(', ') || '결과 없음',
|
||||
detail: details.join(', ') || t('log.detailNone'),
|
||||
detectedIp: detectedIp || hintIp || ''
|
||||
}
|
||||
}
|
||||
@@ -934,12 +948,12 @@ function fetchIfconfigCoPort(port: number): Promise<{ ok: true; reachable: boole
|
||||
const ip = typeof json.ip === 'string' ? json.ip : ''
|
||||
resolve({ ok: true, reachable, ip })
|
||||
} catch (err) {
|
||||
resolve({ ok: false, error: `응답 파싱 실패: ${text.slice(0, 80)}` })
|
||||
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('요청 시간 초과(15s)')))
|
||||
req.on('timeout', () => req.destroy(new Error(t('errors.requestTimeout15s'))))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -951,20 +965,20 @@ function removeUpnpMapping(port: number): Promise<void> {
|
||||
try {
|
||||
client = natUpnp.createClient()
|
||||
} catch (err) {
|
||||
sendLog(`UPnP 클라이언트 생성 실패(매핑 제거 단계): ${(err as Error).message}`)
|
||||
sendLog(t('log.upnpClientFailRemove', { message: (err as Error).message }))
|
||||
done()
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
try { client && client.close() } catch {}
|
||||
sendLog(`UPnP 매핑 제거 응답 없음(타임아웃 8s). 라우터에 우리가 만든 규칙이 없을 수 있습니다.`)
|
||||
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(`UPnP 매핑 제거 시도 결과: ${err.message} (없으면 정상)`)
|
||||
else sendLog(`UPnP 매핑 제거 완료(포트 ${port}).`)
|
||||
if (err) sendLog(t('log.upnpRemoveAttempt', { message: err.message }))
|
||||
else sendLog(t('log.upnpRemoveDone', { port }))
|
||||
done()
|
||||
})
|
||||
})
|
||||
@@ -988,7 +1002,7 @@ function openPortViaUpnp(port: number): Promise<void> {
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
try { client && client.close() } catch {}
|
||||
done(new Error('UPnP 응답 없음(타임아웃 15s). 라우터의 UPnP가 꺼져 있거나 SSDP 패킷이 차단됐을 수 있습니다.'))
|
||||
done(new Error(t('errors.upnpTimeout')))
|
||||
}, 15000)
|
||||
client.portMapping(
|
||||
{ public: port, private: port, ttl: 0, description: 'MusicQuiz Server', protocol: 'tcp' },
|
||||
@@ -1007,7 +1021,7 @@ function sleep(ms: number): Promise<void> {
|
||||
|
||||
ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) => {
|
||||
const pack = state.packs.get(payload.packKey)
|
||||
if (!pack) throw new Error('음악퀴즈를 찾을 수 없습니다.')
|
||||
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 })
|
||||
@@ -1023,11 +1037,11 @@ ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) =
|
||||
const cacheDir = path.join(customRoot, 'platform-cache')
|
||||
await fsp.mkdir(cacheDir, { recursive: true })
|
||||
const installerPath = path.join(cacheDir, deriveFileName(platformUrl) || 'platform-installer.jar')
|
||||
sendLog(`플랫폼(${pack.pack.platform.type}) 다운로드: ${platformUrl}`)
|
||||
sendLog(t('log.platformDownload', { type: pack.pack.platform.type, url: platformUrl }))
|
||||
await downloadFile(platformUrl, installerPath)
|
||||
sendLog(`플랫폼 설치파일 저장: ${installerPath} (사용자가 직접 실행하거나 마인크래프트 런처에서 인식할 수 있습니다.)`)
|
||||
sendLog(t('log.platformSaved', { path: installerPath }))
|
||||
} else if (!payload.installPlatform) {
|
||||
sendLog('플랫폼 설치 건너뜀. 바닐라로 진행합니다.')
|
||||
sendLog(t('log.platformSkipped'))
|
||||
}
|
||||
|
||||
await downloadModsFolder(pack.pack, customRoot)
|
||||
@@ -1051,17 +1065,17 @@ interface FabricInstallerMeta {
|
||||
async function installFabricLoader(pack: PackDefinition, customRoot: string): Promise<void> {
|
||||
const loaderVersion = pack.platform.loaderVersion
|
||||
if (!loaderVersion) {
|
||||
throw new Error('Fabric 로더 버전이 음악퀴즈에 지정되지 않았습니다. 관리 사이트에서 platform.loaderVersion 을 설정해 주세요.')
|
||||
throw new Error(t('errors.fabricLoaderRequired'))
|
||||
}
|
||||
|
||||
// 1) 최신 fabric-installer 메타데이터 조회.
|
||||
sendLog('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('Fabric installer 목록을 받지 못했습니다.')
|
||||
throw new Error(t('errors.fabricInstallerListEmpty'))
|
||||
}
|
||||
const latest = installerList.find((item) => item.stable) || installerList[0]
|
||||
sendLog(`Fabric installer ${latest.version} 다운로드: ${latest.url}`)
|
||||
sendLog(t('log.fabricInstallerDownload', { version: latest.version, url: latest.url }))
|
||||
|
||||
// 2) installer jar 캐시.
|
||||
const cacheDir = path.join(customRoot, 'platform-cache')
|
||||
@@ -1071,7 +1085,7 @@ async function installFabricLoader(pack: PackDefinition, customRoot: string): Pr
|
||||
|
||||
// 3) Java 실행파일 확보.
|
||||
const javaCmd = await findJavaExecutable()
|
||||
sendLog(`Java 사용: ${javaCmd}`)
|
||||
sendLog(t('log.javaUsed', { path: javaCmd }))
|
||||
|
||||
// 4) fabric-installer CLI 자동 실행.
|
||||
// client 모드 + -noprofile: launcher_profiles.json 은 우리 코드가 직접 갱신하므로 fabric-installer 가 덮어쓰지 않게 한다.
|
||||
@@ -1083,9 +1097,9 @@ async function installFabricLoader(pack: PackDefinition, customRoot: string): Pr
|
||||
'-dir', customRoot,
|
||||
'-noprofile'
|
||||
]
|
||||
sendLog(`Fabric 자동 설치 시작: ${pack.mcVersion} / loader ${loaderVersion} → ${customRoot}`)
|
||||
sendLog(t('log.fabricInstallStart', { mc: pack.mcVersion, loader: loaderVersion, dir: customRoot }))
|
||||
await runJavaProcess(javaCmd, args)
|
||||
sendLog('Fabric 자동 설치 완료.')
|
||||
sendLog(t('log.fabricInstallDone'))
|
||||
}
|
||||
|
||||
async function findJavaExecutable(): Promise<string> {
|
||||
@@ -1157,13 +1171,13 @@ function runJavaProcess(cmd: string, args: string[]): Promise<void> {
|
||||
if (stderrTail.length > 4000) stderrTail = stderrTail.slice(-4000)
|
||||
emitLines(chunk, '[fabric-err]')
|
||||
})
|
||||
child.on('error', (err) => reject(new Error(`Java 실행 실패: ${err.message}`)))
|
||||
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(`fabric-installer 종료 코드 ${code}${detail ? ' — ' + detail : ''}`))
|
||||
reject(new Error(t('errors.fabricInstallerExit', { code: code ?? '', detail: detail ? ' — ' + detail : '' })))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1291,7 +1305,7 @@ function resolveLastVersionId(pack: PackDefinition): string {
|
||||
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}`)
|
||||
sendLog(t('log.launcherProfilesMissing', { path: launcherPath }))
|
||||
return
|
||||
}
|
||||
const raw = await fsp.readFile(launcherPath, 'utf8')
|
||||
@@ -1303,14 +1317,14 @@ async function updateLauncherProfile(pack: PackDefinition, gameDir: string): Pro
|
||||
const ramMerged = mergeRamArgs(existingJavaArgs, pack.serverMaxRam)
|
||||
const javaArgs = mergeJvmTuningFlags(ramMerged, DEFAULT_JVM_TUNING_FLAGS)
|
||||
if (existingJavaArgs !== javaArgs) {
|
||||
sendLog(`JVM 인수 갱신(메모리 + G1 GC 튜닝 추가): "${existingJavaArgs}" → "${javaArgs}"`)
|
||||
sendLog(t('log.javaArgsUpdated', { before: existingJavaArgs, after: javaArgs }))
|
||||
}
|
||||
const lastVersionId = resolveLastVersionId(pack)
|
||||
sendLog(`launcher_profiles 의 lastVersionId = ${lastVersionId}`)
|
||||
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(`경고: .minecraft/versions/${lastVersionId} 가 없습니다. 마인크래프트 런처에서 해당 버전을 한 번 받아주거나, 플랫폼 설치를 먼저 마쳐주세요.`)
|
||||
sendLog(t('log.versionMissingWarn', { id: lastVersionId }))
|
||||
}
|
||||
json.profiles[profileKey] = {
|
||||
...existingProfile,
|
||||
@@ -1321,7 +1335,7 @@ async function updateLauncherProfile(pack: PackDefinition, gameDir: string): Pro
|
||||
javaArgs
|
||||
}
|
||||
await fsp.writeFile(launcherPath, `${JSON.stringify(json, null, 2)}\n`, 'utf8')
|
||||
sendLog(`launcher_profiles.json 갱신: 프로필 "${profileKey}", gameDir=${gameDir}`)
|
||||
sendLog(t('log.launcherProfilesUpdated', { profile: profileKey, dir: gameDir }))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1333,7 +1347,7 @@ async function updateLauncherProfile(pack: PackDefinition, gameDir: string): Pro
|
||||
async function copyMinecraftUserSettings(customRoot: string): Promise<void> {
|
||||
const mcRoot = path.join(getAppDataDir(), '.minecraft')
|
||||
if (!fs.existsSync(mcRoot)) {
|
||||
sendLog('.minecraft 폴더가 없어 기존 설정 복사를 건너뜁니다.')
|
||||
sendLog(t('log.minecraftRootMissing'))
|
||||
return
|
||||
}
|
||||
let copied = 0
|
||||
@@ -1352,12 +1366,12 @@ async function copyMinecraftUserSettings(customRoot: string): Promise<void> {
|
||||
await fsp.copyFile(src, dst)
|
||||
copied += 1
|
||||
} catch (err) {
|
||||
sendLog(`설정 복사 실패 (${entry.name}): ${(err as Error).message}`)
|
||||
sendLog(t('log.settingCopyFail', { name: entry.name, message: (err as Error).message }))
|
||||
}
|
||||
}
|
||||
sendLog(`기존 마인크래프트 설정 복사: 새로 복사 ${copied}개 / 보존(이미 존재) ${skipped}개.`)
|
||||
sendLog(t('log.settingCopySummary', { copied, skipped }))
|
||||
} catch (err) {
|
||||
sendLog(`기존 설정 복사 중 오류: ${(err as Error).message}`)
|
||||
sendLog(t('log.settingCopyError', { message: (err as Error).message }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1375,23 +1389,23 @@ async function linkMinecraftRuntimeDirs(customRoot: string): Promise<void> {
|
||||
const src = path.join(mcRoot, dir)
|
||||
const dst = path.join(customRoot, dir)
|
||||
if (!fs.existsSync(src)) {
|
||||
sendLog(`.minecraft/${dir} 가 없습니다. 마인크래프트 런처를 한 번 실행한 뒤 다시 시도해주세요.`)
|
||||
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(`.mc_custom/${dir} 가 실제 폴더로 이미 존재 — 건너뜀.`)
|
||||
sendLog(t('log.runtimeDirExists', { dir }))
|
||||
continue
|
||||
}
|
||||
try {
|
||||
// 'junction' 은 Windows 에서 권한 없이 만들 수 있는 디렉터리 링크.
|
||||
// 다른 OS 에서는 Node 가 알아서 일반 symlink 로 처리.
|
||||
await fsp.symlink(src, dst, 'junction')
|
||||
sendLog(`링크 생성: .mc_custom/${dir} → .minecraft/${dir}`)
|
||||
sendLog(t('log.runtimeLinkCreated', { dir }))
|
||||
} catch (err) {
|
||||
sendLog(`링크 생성 실패 (${dir}): ${(err as Error).message}`)
|
||||
sendLog(t('log.runtimeLinkFail', { dir, message: (err as Error).message }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1409,20 +1423,20 @@ ipcMain.handle('finish:desktopShortcut', async () => {
|
||||
const ok = require('electron').shell.writeShortcutLink(shortcutPath, 'create', {
|
||||
target: runBat,
|
||||
cwd: state.installPath,
|
||||
description: '음악퀴즈 서버 실행'
|
||||
description: t('log.shortcutDescription')
|
||||
})
|
||||
sendLog(ok ? `바로가기 생성: ${shortcutPath}` : '바로가기 생성 실패')
|
||||
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(`run.bat을 찾을 수 없습니다: ${runBat}`)
|
||||
sendLog(t('log.runBatMissingPath', { path: runBat }))
|
||||
return
|
||||
}
|
||||
spawn('cmd.exe', ['/c', 'start', '', runBat], { cwd: state.installPath, detached: true, stdio: 'ignore' }).unref()
|
||||
sendLog('서버 실행 요청 완료.')
|
||||
sendLog(t('log.serverStartRequested'))
|
||||
})
|
||||
|
||||
ipcMain.handle('finish:startLauncher', async () => {
|
||||
@@ -1435,9 +1449,9 @@ ipcMain.handle('finish:startLauncher', async () => {
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
await shell.openExternal('minecraft://')
|
||||
sendLog('마인크래프트 런처 실행 요청 완료(URL 스킴, 비-Windows).')
|
||||
sendLog(t('log.launcherUrlSchemeNonWin'))
|
||||
} catch (err) {
|
||||
sendLog(`런처 실행 실패: ${(err as Error).message}`)
|
||||
sendLog(t('log.launcherFail', { message: (err as Error).message }))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1449,15 +1463,15 @@ ipcMain.handle('finish:startLauncher', async () => {
|
||||
type LauncherCandidate = { label: string; path: string; viaShell: boolean }
|
||||
const candidates: LauncherCandidate[] = [
|
||||
// Win32 설치판 — 실행 파일 직접 spawn.
|
||||
{ label: 'Win32 설치(Program Files (x86))', path: path.join(programFilesX86, 'Minecraft Launcher', 'MinecraftLauncher.exe'), viaShell: false },
|
||||
{ label: 'Win32 설치(Program Files)', path: path.join(programFiles, 'Minecraft Launcher', 'MinecraftLauncher.exe'), viaShell: false },
|
||||
{ label: 'Win32 설치(legacy Minecraft 폴더)', path: path.join(programFilesX86, 'Minecraft', 'MinecraftLauncher.exe'), viaShell: false },
|
||||
{ label: 'Win32 설치(legacy Minecraft 폴더)', path: path.join(programFiles, 'Minecraft', 'MinecraftLauncher.exe'), viaShell: false },
|
||||
{ label: 'Xbox / Game Pass', path: 'C:\\XboxGames\\Minecraft Launcher\\Content\\Minecraft.exe', viaShell: false },
|
||||
{ label: 'npm/portable', path: path.join(localAppData, 'Programs', 'minecraft-launcher', 'MinecraftLauncher.exe'), viaShell: false },
|
||||
{ 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: 'App Execution Alias(Minecraft.exe)', path: path.join(localAppData, 'Microsoft', 'WindowsApps', 'Minecraft.exe'), viaShell: true },
|
||||
{ label: 'App Execution Alias(MinecraftLauncher.exe)', path: path.join(localAppData, 'Microsoft', 'WindowsApps', 'MinecraftLauncher.exe'), viaShell: true }
|
||||
{ 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) {
|
||||
@@ -1466,15 +1480,15 @@ ipcMain.handle('finish:startLauncher', async () => {
|
||||
if (!exists) continue
|
||||
try {
|
||||
if (cand.viaShell) {
|
||||
sendLog(`마인크래프트 런처 실행(${cand.label}, 셸 경유): ${cand.path}`)
|
||||
sendLog(t('log.launcherExecShell', { label: cand.label, path: cand.path }))
|
||||
spawn('cmd.exe', ['/c', 'start', '', cand.path], { detached: true, stdio: 'ignore' }).unref()
|
||||
} else {
|
||||
sendLog(`마인크래프트 런처 실행(${cand.label}): ${cand.path}`)
|
||||
sendLog(t('log.launcherExec', { label: cand.label, path: cand.path }))
|
||||
spawn(cand.path, [], { detached: true, stdio: 'ignore' }).unref()
|
||||
}
|
||||
return
|
||||
} catch (err) {
|
||||
sendLog(`${cand.path} 실행 실패: ${(err as Error).message}`)
|
||||
sendLog(t('log.launcherCandFail', { path: cand.path, message: (err as Error).message }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1482,24 +1496,26 @@ ipcMain.handle('finish:startLauncher', async () => {
|
||||
// 마인크래프트 런처(Java) PFN: Microsoft.4297127D64EC6_8wekyb3d8bbwe, AppId: Minecraft.
|
||||
try {
|
||||
const aumid = 'shell:AppsFolder\\Microsoft.4297127D64EC6_8wekyb3d8bbwe!Minecraft'
|
||||
sendLog(`AppsFolder 로 MS Store 런처 실행 시도: ${aumid}`)
|
||||
sendLog(t('log.launcherAppsFolderTry', { aumid }))
|
||||
spawn('explorer.exe', [aumid], { detached: true, stdio: 'ignore' }).unref()
|
||||
return
|
||||
} catch (err) {
|
||||
sendLog(`AppsFolder 실행 실패: ${(err as Error).message}`)
|
||||
sendLog(t('log.launcherAppsFolderFail', { message: (err as Error).message }))
|
||||
}
|
||||
|
||||
// 마지막 수단: minecraft:// URL 스킴. 런처가 없으면 MS Store 가 열린다.
|
||||
try {
|
||||
sendLog('마지막 시도: minecraft:// URL 스킴 (런처가 없으면 MS Store 가 열릴 수 있음).')
|
||||
sendLog(t('log.launcherUrlSchemeFallback'))
|
||||
await shell.openExternal('minecraft://')
|
||||
} catch (err) {
|
||||
sendLog(`URL 스킴 실행 실패: ${(err as Error).message}.`)
|
||||
sendLog(t('log.launcherUrlSchemeFail', { message: (err as Error).message }))
|
||||
}
|
||||
|
||||
sendLog('Minecraft Launcher 실행 시도가 모두 실패했습니다. minecraft.net 또는 Microsoft Store 에서 "Minecraft Launcher" 를 설치한 뒤 다시 시도해 주세요.')
|
||||
sendLog(t('log.launcherAllFail'))
|
||||
})
|
||||
|
||||
ipcMain.handle('i18n:dict', () => localeDict)
|
||||
|
||||
ipcMain.handle('app:quit', () => {
|
||||
// 모든 창을 닫고 앱 종료. macOS에서도 종료(설치기는 한 번 쓰고 끝이니 잔류시키지 않음).
|
||||
app.quit()
|
||||
|
||||
@@ -2,6 +2,9 @@ import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type { ClientInstallPayload, FetchedPack, RamCheckResult, ServerInstallPayload, PortForwardResult } from './types.js'
|
||||
|
||||
const api = {
|
||||
// i18n
|
||||
loadLocale: (): Promise<Record<string, unknown>> => ipcRenderer.invoke('i18n:dict'),
|
||||
|
||||
// 1단계
|
||||
loadPacks: (manifestUrl?: string): Promise<FetchedPack[]> =>
|
||||
ipcRenderer.invoke('packs:load', manifestUrl),
|
||||
|
||||
Reference in New Issue
Block a user