diff --git a/installer/renderer.js b/installer/renderer.js index 333d362..72bd6d2 100644 --- a/installer/renderer.js +++ b/installer/renderer.js @@ -160,19 +160,17 @@ function renderStep3() { section.className = 'page' section.innerHTML = '

3단계. 서버 관련 설정

' + - '
' + - '
' + '
' pageHost.appendChild(section) var subHost = section.querySelector('#subHost') - section.querySelector('#back').addEventListener('click', renderStep2) - function show31() { subHost.innerHTML = ''; renderSubStep31(subHost, show32) } - function show32() { subHost.innerHTML = ''; renderSubStep32(subHost, show33) } - function show33() { subHost.innerHTML = ''; renderSubStep33(subHost, show34) } - function show34() { subHost.innerHTML = ''; renderSubStep34(subHost, show35) } + function show31() { subHost.innerHTML = ''; renderSubStep31(subHost, renderStep2, show32) } + function show32() { subHost.innerHTML = ''; renderSubStep32(subHost, show31, show33) } + function show33() { subHost.innerHTML = ''; renderSubStep33(subHost, show32, show34) } + function show34() { subHost.innerHTML = ''; renderSubStep34(subHost, show33, show35) } function show35() { subHost.innerHTML = '' - renderSubStep35(subHost, function () { + renderSubStep35(subHost, show34, function () { state.stepDone[3] = true renderStep4() }) @@ -180,21 +178,22 @@ function renderStep3() { show31() } -function renderSubStep31(host, done) { +function renderSubStep31(host, back, done) { host.innerHTML = '

3-1. 서버 설치 경로

' + '

서버를 생성할 폴더를 선택하세요. 경로에 한글이 포함되면 안 됩니다.

' + '
' + '
' + '
' + - '' + '
' var input = host.querySelector('#installPath') var msg = host.querySelector('#msg') host.querySelector('#pickFolder').addEventListener('click', async function () { var picked = await installerApi.pickFolder() if (picked) input.value = picked }) - host.querySelector('#confirm').addEventListener('click', async function () { + host.querySelector('#back').addEventListener('click', back) + host.querySelector('#next').addEventListener('click', async function () { var result = await installerApi.validateInstallPath(input.value.trim()) if (!result.ok) { msg.textContent = result.message || '경로가 유효하지 않습니다.' @@ -209,7 +208,7 @@ function renderSubStep31(host, done) { }) } -function renderSubStep32(host, done) { +function renderSubStep32(host, back, done) { host.innerHTML = '

3-2. JDK 확인

' + '

JAVA_HOME 또는 C:\\Program Files\\Java 에서 자동 탐색합니다. 직접 폴더를 선택해도 됩니다.

' + @@ -217,7 +216,7 @@ function renderSubStep32(host, done) { '' + '' + '
' + - '' + '
' var input = host.querySelector('#jdkPath') var msg = host.querySelector('#msg') host.querySelector('#auto').addEventListener('click', async function () { @@ -236,7 +235,8 @@ function renderSubStep32(host, done) { var picked = await installerApi.pickFolder() if (picked) input.value = picked }) - host.querySelector('#confirm').addEventListener('click', function () { + host.querySelector('#back').addEventListener('click', back) + host.querySelector('#next').addEventListener('click', function () { if (!input.value.trim()) { msg.textContent = 'JDK 경로를 입력해 주세요.' msg.classList.add('error') @@ -255,35 +255,39 @@ function renderSubStep32(host, done) { })() } -function renderSubStep33(host, done) { +function renderSubStep33(host, back, done) { host.innerHTML = '

3-3. 서버 다운로드 및 설치

' + '

선택한 음악퀴즈의 서버 파일을 다운로드합니다. 진행 상황은 하단 로그 뷰어에 표시됩니다.

' + '
대기 중
' + '' + - '' + '' + - '
' + '
' var startBtn = host.querySelector('#startDownload') var statusEl = host.querySelector('#downloadStatus') - var eulaSection = host.querySelector('#eulaSection') var ramSection = host.querySelector('#ramSection') var ramMsg = host.querySelector('#ramMsg') - var confirmBtn = host.querySelector('#confirm') - var eulaCheck = host.querySelector('#eulaCheck') - var eulaMsg = host.querySelector('#eulaMsg') + var nextBtn = host.querySelector('#next') + + host.querySelector('#back').addEventListener('click', back) + + // 이미 통과했던 상태 복원: 사용자가 다음→이전으로 돌아왔을 때 재다운로드 강요하지 않는다. + if (state.serverInstall.eulaAccepted && state.serverInstall.ram) { + statusEl.textContent = '다운로드 및 EULA 동의 완료.' + statusEl.classList.add('success') + showRamResult(state.serverInstall.ram) + nextBtn.disabled = false + } startBtn.addEventListener('click', async function () { startBtn.disabled = true + state.serverInstall.eulaAccepted = false + nextBtn.disabled = true + statusEl.classList.remove('success', 'error') statusEl.textContent = '다운로드 중...' try { await installerApi.startServerInstall({ @@ -291,55 +295,123 @@ function renderSubStep33(host, done) { installPath: state.serverInstall.path, jdkPath: state.serverInstall.jdk }) - statusEl.textContent = '다운로드 완료. EULA 동의가 필요합니다.' - eulaSection.hidden = false + statusEl.textContent = 'EULA 동의가 필요합니다. 팝업을 확인해 주세요.' + var accepted = await openEulaPopup(state.serverInstall.path) + if (!accepted) { + statusEl.textContent = 'EULA 동의 실패. 다운로드를 취소합니다. "다운로드 시작"으로 다시 시도하세요.' + statusEl.classList.add('error') + startBtn.disabled = false + return + } + try { + await installerApi.acceptEula(state.serverInstall.path) + } catch (err) { + statusEl.textContent = 'EULA 저장 실패: ' + err.message + statusEl.classList.add('error') + startBtn.disabled = false + return + } + state.serverInstall.eulaAccepted = true + statusEl.textContent = '다운로드 및 EULA 동의 완료.' + statusEl.classList.add('success') + var ram = await installerApi.checkRam(state.selectedPackKey) + state.serverInstall.ram = ram + showRamResult(ram) + if (ram.decision === 'tooLow') return + nextBtn.disabled = false } catch (err) { statusEl.textContent = '다운로드 실패: ' + err.message + statusEl.classList.add('error') startBtn.disabled = false } }) - eulaCheck.addEventListener('change', async function () { - if (!eulaCheck.checked) return - try { - await installerApi.acceptEula(state.serverInstall.path) - eulaMsg.textContent = 'EULA 동의 저장됨.' - eulaMsg.classList.add('success') - ramSection.hidden = false - var result = await installerApi.checkRam(state.selectedPackKey) - state.serverInstall.ram = result - if (result.decision === 'tooLow') { - ramMsg.innerHTML = '시스템 램(' + result.systemRamMb + 'MB)이 음악퀴즈 최소 요구치(' + (state.packs.find(function (p) { return p.key === state.selectedPackKey }).pack.serverMinRam) + 'MB)에 미치지 못합니다. 설치를 중단합니다.' - ramMsg.classList.add('error') - return - } - if (result.decision === 'minOk') { - ramMsg.innerHTML = '시스템 램(' + result.systemRamMb + 'MB)이 권장치보다 부족합니다. 최소치(' + result.appliedRamMb + 'MB)로 진행합니다.' - ramMsg.classList.add('warn') - } else { - ramMsg.textContent = '시스템 램(' + result.systemRamMb + 'MB) 충분. ' + result.appliedRamMb + 'MB로 설정.' - ramMsg.classList.add('success') - } - confirmBtn.hidden = false - } catch (err) { - eulaMsg.textContent = 'EULA 저장 실패: ' + err.message - eulaMsg.classList.add('error') - } + nextBtn.addEventListener('click', function () { + if (!state.serverInstall.eulaAccepted) return + done() }) - confirmBtn.addEventListener('click', function () { - state.serverInstall.eulaAccepted = true - done() + function showRamResult(result) { + ramSection.hidden = false + ramMsg.classList.remove('error', 'warn', 'success') + if (result.decision === 'tooLow') { + var pack = state.packs.find(function (p) { return p.key === state.selectedPackKey }) + var minRam = pack ? pack.pack.serverMinRam : 0 + ramMsg.innerHTML = '시스템 램(' + result.systemRamMb + 'MB)이 음악퀴즈 최소 요구치(' + minRam + 'MB)에 미치지 못합니다. 설치를 중단합니다.' + ramMsg.classList.add('error') + } else if (result.decision === 'minOk') { + ramMsg.innerHTML = '시스템 램(' + result.systemRamMb + 'MB)이 권장치보다 부족합니다. 최소치(' + result.appliedRamMb + 'MB)로 진행합니다.' + ramMsg.classList.add('warn') + } else { + ramMsg.textContent = '시스템 램(' + result.systemRamMb + 'MB) 충분. ' + result.appliedRamMb + 'MB로 설정.' + ramMsg.classList.add('success') + } + } +} + +// EULA 동의 팝업. resolve(true) = 동의, resolve(false) = 비동의/창 닫힘. +async function openEulaPopup(installPath) { + var read = await installerApi.readEula(installPath) + var bodyHtml = '' + if (read.exists) { + bodyHtml = '

서버 파일에 포함된 eula.txt 내용입니다.

' + + '
' + escapeHtml(read.content) + '
' + } else { + var fetched = await installerApi.fetchMinecraftEula() + if (fetched.html) { + bodyHtml = '

서버 파일에 eula.txt가 없어 minecraft.net의 EULA를 표시합니다 (' + fetched.url + ').

' + + '' + } else { + bodyHtml = '

EULA 페이지를 불러올 수 없습니다. 직접 확인해 주세요: https://www.minecraft.net/en-us/eula

' + } + } + return new Promise(function (resolve) { + var overlay = document.createElement('div') + overlay.className = 'modalOverlay' + overlay.innerHTML = + '' + document.body.appendChild(overlay) + + var settled = false + function close(result) { + if (settled) return + settled = true + overlay.remove() + resolve(result) + } + overlay.querySelector('[data-action="accept"]').addEventListener('click', function () { close(true) }) + overlay.querySelector('[data-action="reject"]').addEventListener('click', function () { close(false) }) + overlay.querySelector('.modalClose').addEventListener('click', function () { close(false) }) + overlay.addEventListener('click', function (event) { + if (event.target === overlay) close(false) + }) }) } -function renderSubStep34(host, done) { +function escapeHtml(text) { + return String(text).replace(/[&<>"']/g, function (ch) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch] + }) +} + +function escapeAttr(text) { + return String(text).replace(/&/g, '&').replace(/"/g, '"') +} + +function renderSubStep34(host, back, done) { host.innerHTML = '

3-4. 서버 설정 편집

' + '

로컬 웹서버를 띄워 server.properties / bukkit.yml 등을 GUI로 편집합니다.

' + '' + '
' + - '' + '
' host.querySelector('#open').addEventListener('click', async function () { var msg = host.querySelector('#editorMsg') try { @@ -350,19 +422,22 @@ function renderSubStep34(host, done) { msg.classList.add('error') } }) - host.querySelector('#confirm').addEventListener('click', done) + host.querySelector('#back').addEventListener('click', back) + host.querySelector('#next').addEventListener('click', done) } -function renderSubStep35(host, done) { +function renderSubStep35(host, back, done) { host.innerHTML = '

3-5. 포트포워딩 점검

' + '

서버의 외부 접근 가능 여부를 확인합니다. UPnP를 시도해도 안 되면 직접 포트포워딩을 안내합니다.

' + '
' + '' + '
' + - '' + '
' var resultMsg = host.querySelector('#resultMsg') - var confirmBtn = host.querySelector('#confirm') + var nextBtn = host.querySelector('#next') + if (state.serverInstall.portStatus) nextBtn.disabled = false + host.querySelector('#back').addEventListener('click', back) host.querySelector('#run').addEventListener('click', async function () { var port = Number(host.querySelector('#port').value) || 25565 resultMsg.textContent = '확인 중...' @@ -379,9 +454,9 @@ function renderSubStep35(host, done) { '
외부 IP: ' + (result.externalIp || '확인 불가') + ', 포트: ' + result.port + '' resultMsg.classList.add('warn') } - confirmBtn.hidden = false + nextBtn.disabled = false }) - confirmBtn.addEventListener('click', done) + nextBtn.addEventListener('click', done) } function renderStep4() { @@ -392,25 +467,16 @@ function renderStep4() { section.className = 'page' section.innerHTML = '

4단계. 유저 클라이언트 설정

' + - '
' + - '
' + '
' pageHost.appendChild(section) var subHost = section.querySelector('#subHost') - section.querySelector('#back').addEventListener('click', function () { - if (state.mode === 'multi') renderStep3(); else renderStep2() - }) + function backToPrevStep() { if (state.mode === 'multi') renderStep3(); else renderStep2() } - function show41() { - subHost.innerHTML = '' - renderSubStep41(subHost, pack, show42) - } - function show42() { - subHost.innerHTML = '' - renderSubStep42(subHost, show43) - } + function show41() { subHost.innerHTML = ''; renderSubStep41(subHost, pack, backToPrevStep, show42) } + function show42() { subHost.innerHTML = ''; renderSubStep42(subHost, show41, show43) } function show43() { subHost.innerHTML = '' - renderSubStep43(subHost, function () { + renderSubStep43(subHost, show42, function () { state.stepDone[4] = true renderStep5() }) @@ -418,7 +484,7 @@ function renderStep4() { show41() } -function renderSubStep41(host, pack, done) { +function renderSubStep41(host, pack, back, done) { var platformType = pack ? pack.pack.platform.type : 'vanilla' if (platformType === 'vanilla') { state.client.installPlatform = false @@ -426,7 +492,8 @@ function renderSubStep41(host, pack, done) { '

4-1. 모드 플랫폼

' + '

선택한 음악퀴즈의 플랫폼: vanilla

' + '

바닐라이므로 별도 설치는 필요 없습니다.

' + - '
' + '
' + host.querySelector('#back').addEventListener('click', back) host.querySelector('#next').addEventListener('click', done) return } @@ -438,7 +505,7 @@ function renderSubStep41(host, pack, done) { '' + '' + '' + - '
' + '
' var nextBtn = host.querySelector('#next') var choiceButtons = host.querySelectorAll('[data-choice]') @@ -462,19 +529,22 @@ function renderSubStep41(host, pack, done) { applyChoice(state.client.installPlatform ? 'install' : 'skip') } + host.querySelector('#back').addEventListener('click', back) nextBtn.addEventListener('click', done) } -function renderSubStep42(host, done) { +function renderSubStep42(host, back, done) { host.innerHTML = '

4-2. 모드/리소스팩 다운로드 및 launcher_profiles 갱신

' + '

%appdata%\\.mc_custom 에 모드와 리소스팩을 설치하고, launcher_profiles.json에 프로필을 등록합니다.

' + '' + '
' + - '
' + '
' var runBtn = host.querySelector('#run') var msg = host.querySelector('#msg') var nextBtn = host.querySelector('#next') + if (state.client.clientInstalled) nextBtn.disabled = false + host.querySelector('#back').addEventListener('click', back) runBtn.addEventListener('click', async function () { runBtn.disabled = true msg.textContent = '설치 중...' @@ -486,7 +556,8 @@ function renderSubStep42(host, done) { }) msg.textContent = '클라이언트 설치 완료.' msg.classList.add('success') - nextBtn.hidden = false + state.client.clientInstalled = true + nextBtn.disabled = false } catch (err) { msg.textContent = '설치 실패: ' + err.message msg.classList.add('error') @@ -496,9 +567,13 @@ function renderSubStep42(host, done) { nextBtn.addEventListener('click', done) } -function renderSubStep43(host, done) { - host.innerHTML = '

4-3. 완료 확인

모드와 리소스팩이 .mc_custom에 설치되어 있고, launcher_profiles.json도 갱신되었습니다.

' - host.querySelector('#confirm').addEventListener('click', done) +function renderSubStep43(host, back, done) { + host.innerHTML = + '

4-3. 완료 확인

' + + '

모드와 리소스팩이 .mc_custom에 설치되어 있고, launcher_profiles.json도 갱신되었습니다.

' + + '
' + host.querySelector('#back').addEventListener('click', back) + host.querySelector('#next').addEventListener('click', done) } function renderStep5() { diff --git a/installer/styles.css b/installer/styles.css index f686907..848eba9 100644 --- a/installer/styles.css +++ b/installer/styles.css @@ -155,6 +155,67 @@ main { .toggleRow { display: flex; align-items: center; gap: 10px; margin: 8px 0; } +.modalOverlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modalCard { + background: var(--bg-alt); + border: 1px solid var(--border); + border-radius: 12px; + width: min(720px, 92vw); + max-height: 86vh; + display: grid; + grid-template-rows: auto 1fr auto; + overflow: hidden; +} + +.modalCard > header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} + +.modalCard > header h3 { margin: 0; font-size: 16px; } +.modalClose { background: transparent; border: none; color: var(--text-muted); font-size: 22px; cursor: pointer; } +.modalClose:hover { color: var(--text); } + +.modalBody { + padding: 16px; + overflow-y: auto; +} + +.modalCard > footer { padding: 12px 16px; border-top: 1px solid var(--border); margin: 0; } + +.eulaPre { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + font-family: 'Consolas', monospace; + font-size: 12px; + white-space: pre-wrap; + word-break: break-word; + max-height: 50vh; + overflow-y: auto; +} + +.eulaFrame { + width: 100%; + height: 50vh; + background: white; + border: 1px solid var(--border); + border-radius: 8px; +} + .statusBadge { display: inline-flex; padding: 3px 10px; border-radius: 999px; font-size: 12px; } .statusBadge.pending { background: #2c3849; color: var(--text-muted); } .statusBadge.ok { background: rgba(63, 185, 80, 0.2); color: var(--success); } diff --git a/src/installer/main.ts b/src/installer/main.ts index b7ef68a..9845ed9 100644 --- a/src/installer/main.ts +++ b/src/installer/main.ts @@ -295,11 +295,30 @@ ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) = sendLog(`서버 설치 경로: ${installPath}`) await downloadServerZip(pack.pack, installPath) + // 다운로드한 zip에 들어있을 수 있는 eula.txt를 그대로 보존한다. + // 동의 흐름은 renderer가 별도 IPC로 읽고 동의 시 덮어쓴다. +}) - const eulaPath = path.join(installPath, 'eula.txt') - if (fs.existsSync(eulaPath)) { - await fsp.unlink(eulaPath) - sendLog('기존 eula.txt 삭제, 사용자 동의를 다시 받습니다.') +ipcMain.handle('server:readEula', async (_event, installPath: string): Promise<{ exists: boolean; content: string }> => { + if (!installPath) return { exists: false, content: '' } + const target = path.join(path.resolve(installPath), 'eula.txt') + try { + const content = await fsp.readFile(target, 'utf8') + return { exists: true, content } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { exists: false, content: '' } + throw error + } +}) + +ipcMain.handle('server:fetchMinecraftEula', async (): Promise<{ url: string; html: string }> => { + const url = 'https://www.minecraft.net/en-us/eula' + try { + const buffer = await fetchBuffer(url) + return { url, html: buffer.toString('utf8') } + } catch (error) { + sendLog(`Minecraft EULA 페이지 조회 실패: ${(error as Error).message}`) + return { url, html: '' } } }) diff --git a/src/installer/preload.ts b/src/installer/preload.ts index f4a9110..58112a2 100644 --- a/src/installer/preload.ts +++ b/src/installer/preload.ts @@ -19,6 +19,10 @@ const api = { // 3-3 startServerInstall: (payload: ServerInstallPayload): Promise => ipcRenderer.invoke('server:install', payload), + readEula: (installPath: string): Promise<{ exists: boolean; content: string }> => + ipcRenderer.invoke('server:readEula', installPath), + fetchMinecraftEula: (): Promise<{ url: string; html: string }> => + ipcRenderer.invoke('server:fetchMinecraftEula'), acceptEula: (installPath: string): Promise => ipcRenderer.invoke('server:acceptEula', installPath), checkRam: (packKey: string): Promise =>