Build music-quiz installer and management site per spec
Implements the full spec described in README.md: Management site (Node + TypeScript + Express + EJS): - Public main page lists packs registered in manifest.json. - /op login (account.json, internal-only), /op/dashboard manages packs with horizontal-scroll cards, add/select-and-delete flow, and the /op/dashboard/:packName editor (Mojang release dropdown, dynamic mods/resourcepacks lists, platform/RAM fields, file rename). - Routes for /manifest.json (public) and /file/* (server pack files). - Middleware blocks /account.json and /manifest/* directory access. Installer (Electron): - Five page renderer driven by IPC (preload contextBridge API): pack pick → single/multi → server install (path no-Korean check, JDK detect, file download, EULA, RAM gating, local web config editor, UPnP/port-forward check) → client install (.mc_custom mods + resourcepacks + launcher_profiles.json gameDir/javaArgs) → finish toggles (server folder, shortcut, server start, launcher start). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
29
installer/index.html
Normal file
29
installer/index.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>마인크래프트 음악퀴즈 간편설치기</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="appHeader">
|
||||
<h1>마인크래프트 음악퀴즈 간편설치기</h1>
|
||||
<ol class="stepIndicator" id="stepIndicator">
|
||||
<li data-step="1">1. 음악퀴즈</li>
|
||||
<li data-step="2">2. 모드</li>
|
||||
<li data-step="3">3. 서버</li>
|
||||
<li data-step="4">4. 클라이언트</li>
|
||||
<li data-step="5">5. 완료</li>
|
||||
</ol>
|
||||
</header>
|
||||
|
||||
<main id="pageHost"></main>
|
||||
|
||||
<aside class="logViewer" id="logViewer" hidden>
|
||||
<header><h2>설치 로그</h2><button type="button" id="logToggle">접기</button></header>
|
||||
<pre id="logBody"></pre>
|
||||
</aside>
|
||||
|
||||
<script src="./renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
513
installer/renderer.js
Normal file
513
installer/renderer.js
Normal file
@@ -0,0 +1,513 @@
|
||||
'use strict'
|
||||
|
||||
const installerApi = window.installer
|
||||
|
||||
const state = {
|
||||
packs: [],
|
||||
selectedPackKey: null,
|
||||
mode: null, // 'single' | 'multi'
|
||||
serverInstall: {
|
||||
path: '',
|
||||
jdk: '',
|
||||
eulaAccepted: false,
|
||||
ram: null,
|
||||
portStatus: null
|
||||
},
|
||||
client: {
|
||||
installPlatform: true
|
||||
},
|
||||
finishToggles: {
|
||||
desktopShortcut: true,
|
||||
startServer: true,
|
||||
startLauncher: true
|
||||
},
|
||||
stepDone: { 1: false, 2: false, 3: false, 4: false }
|
||||
}
|
||||
|
||||
const pageHost = document.getElementById('pageHost')
|
||||
const stepIndicator = document.getElementById('stepIndicator')
|
||||
const logViewer = document.getElementById('logViewer')
|
||||
const logBody = document.getElementById('logBody')
|
||||
const logToggle = document.getElementById('logToggle')
|
||||
|
||||
logToggle.addEventListener('click', function () {
|
||||
logViewer.classList.toggle('collapsed')
|
||||
if (logViewer.classList.contains('collapsed')) {
|
||||
logViewer.style.height = '36px'
|
||||
logToggle.textContent = '펼치기'
|
||||
} else {
|
||||
logViewer.style.height = ''
|
||||
logToggle.textContent = '접기'
|
||||
}
|
||||
})
|
||||
|
||||
installerApi.onLog(function (line) {
|
||||
logViewer.hidden = false
|
||||
logBody.textContent += line + '\n'
|
||||
logBody.scrollTop = logBody.scrollHeight
|
||||
})
|
||||
|
||||
function setActiveStep(step) {
|
||||
stepIndicator.querySelectorAll('li').forEach(function (item) {
|
||||
var index = Number(item.getAttribute('data-step'))
|
||||
item.classList.remove('active', 'done')
|
||||
if (index < step) item.classList.add('done')
|
||||
if (index === step) item.classList.add('active')
|
||||
})
|
||||
}
|
||||
|
||||
function clearPage() {
|
||||
pageHost.innerHTML = ''
|
||||
}
|
||||
|
||||
function renderStep1() {
|
||||
setActiveStep(1)
|
||||
clearPage()
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>1단계. 설치할 음악퀴즈 선택</h2>' +
|
||||
'<p>관리 사이트의 manifest.json에서 음악퀴즈 목록을 가져옵니다.</p>' +
|
||||
'<div class="fieldset"><label>manifest URL <input id="manifestUrl" type="url" value="' +
|
||||
(state.manifestUrl || 'http://127.0.0.1:3000/manifest.json') + '" /></label>' +
|
||||
'<button class="secondaryBtn" id="reload">목록 새로고침</button></div>' +
|
||||
'<div id="packList" class="cardChoice"></div>' +
|
||||
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>다음</button></div>'
|
||||
pageHost.appendChild(section)
|
||||
var listEl = section.querySelector('#packList')
|
||||
var nextBtn = section.querySelector('#next')
|
||||
|
||||
function renderList() {
|
||||
listEl.innerHTML = ''
|
||||
if (state.packs.length === 0) {
|
||||
listEl.innerHTML = '<p class="formMessage">아직 음악퀴즈가 없습니다. "목록 새로고침"을 눌러 주세요.</p>'
|
||||
return
|
||||
}
|
||||
state.packs.forEach(function (pack) {
|
||||
var btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.innerHTML = '<strong>' + pack.name + '</strong><br><small>마인크래프트 ' + pack.pack.mcVersion + ' / ' + pack.pack.platform.type + '</small>'
|
||||
if (state.selectedPackKey === pack.key) btn.classList.add('selected')
|
||||
btn.addEventListener('click', function () {
|
||||
state.selectedPackKey = pack.key
|
||||
nextBtn.disabled = false
|
||||
renderList()
|
||||
})
|
||||
listEl.appendChild(btn)
|
||||
})
|
||||
}
|
||||
|
||||
section.querySelector('#reload').addEventListener('click', async function () {
|
||||
var manifestUrl = section.querySelector('#manifestUrl').value
|
||||
state.manifestUrl = manifestUrl
|
||||
try {
|
||||
var packs = await installerApi.loadPacks(manifestUrl)
|
||||
state.packs = packs
|
||||
renderList()
|
||||
} catch (err) {
|
||||
alert('manifest 다운로드 실패: ' + err.message)
|
||||
}
|
||||
})
|
||||
|
||||
nextBtn.addEventListener('click', async function () {
|
||||
if (!state.selectedPackKey) return
|
||||
await installerApi.setSelectedPack(state.selectedPackKey)
|
||||
state.stepDone[1] = true
|
||||
renderStep2()
|
||||
})
|
||||
|
||||
renderList()
|
||||
}
|
||||
|
||||
function renderStep2() {
|
||||
setActiveStep(2)
|
||||
clearPage()
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>2단계. 싱글 / 멀티 선택</h2>' +
|
||||
'<div class="cardChoice">' +
|
||||
'<button id="single" type="button"><strong>싱글</strong><br><small>혼자 즐기는 모드. 4단계만 진행합니다.</small></button>' +
|
||||
'<button id="multi" type="button"><strong>멀티</strong><br><small>친구들과 함께. 3단계 서버 설치 후 4단계를 진행합니다.</small></button>' +
|
||||
'</div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><span></span></div>'
|
||||
pageHost.appendChild(section)
|
||||
section.querySelector('#single').addEventListener('click', function () {
|
||||
state.mode = 'single'
|
||||
state.stepDone[2] = true
|
||||
renderStep4()
|
||||
})
|
||||
section.querySelector('#multi').addEventListener('click', function () {
|
||||
state.mode = 'multi'
|
||||
state.stepDone[2] = true
|
||||
renderStep3()
|
||||
})
|
||||
section.querySelector('#back').addEventListener('click', renderStep1)
|
||||
}
|
||||
|
||||
function renderStep3() {
|
||||
setActiveStep(3)
|
||||
clearPage()
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>3단계. 서버 관련 설정</h2>' +
|
||||
'<div id="sub31" class="subStep"></div>' +
|
||||
'<div id="sub32" class="subStep" hidden></div>' +
|
||||
'<div id="sub33" class="subStep" hidden></div>' +
|
||||
'<div id="sub34" class="subStep" hidden></div>' +
|
||||
'<div id="sub35" class="subStep" hidden></div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="proceedClient" hidden>4단계로 진행</button></div>'
|
||||
pageHost.appendChild(section)
|
||||
section.querySelector('#back').addEventListener('click', renderStep2)
|
||||
|
||||
renderSubStep31(section.querySelector('#sub31'), function () {
|
||||
section.querySelector('#sub32').hidden = false
|
||||
renderSubStep32(section.querySelector('#sub32'), function () {
|
||||
section.querySelector('#sub33').hidden = false
|
||||
renderSubStep33(section.querySelector('#sub33'), function () {
|
||||
section.querySelector('#sub34').hidden = false
|
||||
renderSubStep34(section.querySelector('#sub34'), function () {
|
||||
section.querySelector('#sub35').hidden = false
|
||||
renderSubStep35(section.querySelector('#sub35'), function () {
|
||||
section.querySelector('#proceedClient').hidden = false
|
||||
section.querySelector('#proceedClient').addEventListener('click', function () {
|
||||
state.stepDone[3] = true
|
||||
renderStep4()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function renderSubStep31(host, done) {
|
||||
host.innerHTML =
|
||||
'<h3>3-1. 서버 설치 경로</h3>' +
|
||||
'<p class="formMessage">서버를 생성할 폴더를 선택하세요. 경로에 한글이 포함되면 안 됩니다.</p>' +
|
||||
'<div class="fieldset"><label><input id="installPath" type="text" placeholder="C:\\MusicQuizServer" value="' + (state.serverInstall.path || '') + '" /></label>' +
|
||||
'<button class="secondaryBtn" id="pickFolder">폴더 선택</button></div>' +
|
||||
'<div class="formMessage" id="msg"></div>' +
|
||||
'<button class="primaryBtn" id="confirm">확인</button>'
|
||||
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 () {
|
||||
var result = await installerApi.validateInstallPath(input.value.trim())
|
||||
if (!result.ok) {
|
||||
msg.textContent = result.message || '경로가 유효하지 않습니다.'
|
||||
msg.classList.add('error')
|
||||
return
|
||||
}
|
||||
msg.textContent = '경로 확정: ' + result.message
|
||||
msg.classList.remove('error')
|
||||
msg.classList.add('success')
|
||||
state.serverInstall.path = input.value.trim()
|
||||
done()
|
||||
})
|
||||
}
|
||||
|
||||
function renderSubStep32(host, done) {
|
||||
host.innerHTML =
|
||||
'<h3>3-2. JDK 확인</h3>' +
|
||||
'<p class="formMessage">JAVA_HOME 또는 C:\\Program Files\\Java 에서 자동 탐색합니다. 직접 폴더를 선택해도 됩니다.</p>' +
|
||||
'<div class="fieldset"><label><input id="jdkPath" type="text" placeholder="C:\\Program Files\\Java\\jdk-17" value="' + (state.serverInstall.jdk || '') + '" /></label>' +
|
||||
'<button class="secondaryBtn" id="pickJdk">폴더 선택</button>' +
|
||||
'<button class="secondaryBtn" id="auto">자동 탐색</button></div>' +
|
||||
'<div class="formMessage" id="msg"></div>' +
|
||||
'<button class="primaryBtn" id="confirm">확인</button>'
|
||||
var input = host.querySelector('#jdkPath')
|
||||
var msg = host.querySelector('#msg')
|
||||
host.querySelector('#auto').addEventListener('click', async function () {
|
||||
var detect = await installerApi.detectJdk()
|
||||
if (detect.found) {
|
||||
input.value = detect.path
|
||||
msg.textContent = 'JDK 발견: ' + detect.path
|
||||
msg.classList.remove('error')
|
||||
msg.classList.add('success')
|
||||
} else {
|
||||
msg.textContent = 'JDK를 자동으로 찾지 못했습니다. 직접 선택해 주세요.'
|
||||
msg.classList.add('error')
|
||||
}
|
||||
})
|
||||
host.querySelector('#pickJdk').addEventListener('click', async function () {
|
||||
var picked = await installerApi.pickFolder()
|
||||
if (picked) input.value = picked
|
||||
})
|
||||
host.querySelector('#confirm').addEventListener('click', function () {
|
||||
if (!input.value.trim()) {
|
||||
msg.textContent = 'JDK 경로를 입력해 주세요.'
|
||||
msg.classList.add('error')
|
||||
return
|
||||
}
|
||||
state.serverInstall.jdk = input.value.trim()
|
||||
done()
|
||||
})
|
||||
;(async function () {
|
||||
var detect = await installerApi.detectJdk()
|
||||
if (detect.found && !input.value) {
|
||||
input.value = detect.path
|
||||
msg.textContent = 'JDK 자동 탐색됨: ' + detect.path
|
||||
msg.classList.add('success')
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
function renderSubStep33(host, done) {
|
||||
host.innerHTML =
|
||||
'<h3>3-3. 서버 다운로드 및 설치</h3>' +
|
||||
'<p class="formMessage">선택한 음악퀴즈의 서버 파일을 다운로드합니다. 진행 상황은 하단 로그 뷰어에 표시됩니다.</p>' +
|
||||
'<div class="formMessage" id="downloadStatus">대기 중</div>' +
|
||||
'<button class="primaryBtn" id="startDownload">다운로드 시작</button>' +
|
||||
'<div id="eulaSection" hidden style="margin-top:14px;">' +
|
||||
'<h3>3-3-3. EULA 동의</h3>' +
|
||||
'<div class="eulaBox">Minecraft EULA: 본 설치는 Minecraft End User License Agreement (https://www.minecraft.net/ko-kr/eula) 동의가 필요합니다. 동의 시 eula.txt가 새로 작성됩니다.</div>' +
|
||||
'<label class="toggleRow"><input id="eulaCheck" type="checkbox" /> Minecraft EULA에 동의합니다.</label>' +
|
||||
'<div class="formMessage" id="eulaMsg"></div>' +
|
||||
'</div>' +
|
||||
'<div id="ramSection" hidden style="margin-top:14px;">' +
|
||||
'<h3>3-3-4. 램 검사</h3>' +
|
||||
'<div class="formMessage" id="ramMsg">검사 중...</div>' +
|
||||
'</div>' +
|
||||
'<div class="actionRow"><span></span><button class="primaryBtn" id="confirm" hidden>다음</button></div>'
|
||||
|
||||
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')
|
||||
|
||||
startBtn.addEventListener('click', async function () {
|
||||
startBtn.disabled = true
|
||||
statusEl.textContent = '다운로드 중...'
|
||||
try {
|
||||
await installerApi.startServerInstall({
|
||||
packKey: state.selectedPackKey,
|
||||
installPath: state.serverInstall.path,
|
||||
jdkPath: state.serverInstall.jdk
|
||||
})
|
||||
statusEl.textContent = '다운로드 완료. EULA 동의가 필요합니다.'
|
||||
eulaSection.hidden = false
|
||||
} catch (err) {
|
||||
statusEl.textContent = '다운로드 실패: ' + err.message
|
||||
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')
|
||||
}
|
||||
})
|
||||
|
||||
confirmBtn.addEventListener('click', function () {
|
||||
state.serverInstall.eulaAccepted = true
|
||||
done()
|
||||
})
|
||||
}
|
||||
|
||||
function renderSubStep34(host, done) {
|
||||
host.innerHTML =
|
||||
'<h3>3-4. 서버 설정 편집</h3>' +
|
||||
'<p class="formMessage">로컬 웹서버를 띄워 server.properties / bukkit.yml 등을 GUI로 편집합니다.</p>' +
|
||||
'<button class="secondaryBtn" id="open">편집기 열기</button>' +
|
||||
'<div class="formMessage" id="editorMsg"></div>' +
|
||||
'<button class="primaryBtn" id="confirm">확인</button>'
|
||||
host.querySelector('#open').addEventListener('click', async function () {
|
||||
var msg = host.querySelector('#editorMsg')
|
||||
try {
|
||||
var result = await installerApi.startServerConfigEditor(state.serverInstall.path)
|
||||
msg.innerHTML = '편집기 주소: <a href="' + result.url + '" target="_blank">' + result.url + '</a>'
|
||||
} catch (err) {
|
||||
msg.textContent = '편집기 실행 실패: ' + err.message
|
||||
msg.classList.add('error')
|
||||
}
|
||||
})
|
||||
host.querySelector('#confirm').addEventListener('click', done)
|
||||
}
|
||||
|
||||
function renderSubStep35(host, done) {
|
||||
host.innerHTML =
|
||||
'<h3>3-5. 포트포워딩 점검</h3>' +
|
||||
'<p class="formMessage">서버의 외부 접근 가능 여부를 확인합니다. UPnP를 시도해도 안 되면 직접 포트포워딩을 안내합니다.</p>' +
|
||||
'<div class="fieldset"><label>포트 <input id="port" type="text" value="25565" /></label></div>' +
|
||||
'<button class="secondaryBtn" id="run">검사 시작</button>' +
|
||||
'<div class="formMessage" id="resultMsg"></div>' +
|
||||
'<button class="primaryBtn" id="confirm" hidden>확인</button>'
|
||||
var resultMsg = host.querySelector('#resultMsg')
|
||||
var confirmBtn = host.querySelector('#confirm')
|
||||
host.querySelector('#run').addEventListener('click', async function () {
|
||||
var port = Number(host.querySelector('#port').value) || 25565
|
||||
resultMsg.textContent = '확인 중...'
|
||||
var result = await installerApi.checkPortForward(port)
|
||||
state.serverInstall.portStatus = result
|
||||
if (result.status === 'preForwarded') {
|
||||
resultMsg.innerHTML = '이미 외부 접속 가능: ' + result.externalIp + ':' + result.port
|
||||
resultMsg.classList.add('success')
|
||||
} else if (result.status === 'upnpOk') {
|
||||
resultMsg.innerHTML = 'UPnP로 자동 개방 완료: ' + result.externalIp + ':' + result.port
|
||||
resultMsg.classList.add('success')
|
||||
} else {
|
||||
resultMsg.innerHTML = (result.message || '직접 포트포워딩을 해주세요.') +
|
||||
'<br><small>외부 IP: ' + (result.externalIp || '확인 불가') + ', 포트: ' + result.port + '</small>'
|
||||
resultMsg.classList.add('warn')
|
||||
}
|
||||
confirmBtn.hidden = false
|
||||
})
|
||||
confirmBtn.addEventListener('click', done)
|
||||
}
|
||||
|
||||
function renderStep4() {
|
||||
setActiveStep(4)
|
||||
clearPage()
|
||||
var pack = state.packs.find(function (p) { return p.key === state.selectedPackKey })
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>4단계. 유저 클라이언트 설정</h2>' +
|
||||
'<div class="subStep" id="sub41"></div>' +
|
||||
'<div class="subStep" id="sub42" hidden></div>' +
|
||||
'<div class="subStep" id="sub43" hidden></div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" hidden>5단계로</button></div>'
|
||||
pageHost.appendChild(section)
|
||||
section.querySelector('#back').addEventListener('click', function () {
|
||||
if (state.mode === 'multi') renderStep3(); else renderStep2()
|
||||
})
|
||||
|
||||
renderSubStep41(section.querySelector('#sub41'), pack, function () {
|
||||
section.querySelector('#sub42').hidden = false
|
||||
renderSubStep42(section.querySelector('#sub42'), function () {
|
||||
section.querySelector('#sub43').hidden = false
|
||||
renderSubStep43(section.querySelector('#sub43'), function () {
|
||||
var nextBtn = section.querySelector('#next')
|
||||
nextBtn.hidden = false
|
||||
nextBtn.addEventListener('click', function () {
|
||||
state.stepDone[4] = true
|
||||
renderStep5()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function renderSubStep41(host, pack, done) {
|
||||
var platformType = pack ? pack.pack.platform.type : 'vanilla'
|
||||
host.innerHTML =
|
||||
'<h3>4-1. 모드 플랫폼</h3>' +
|
||||
'<p class="formMessage">선택한 음악퀴즈의 플랫폼: <strong>' + platformType + '</strong></p>' +
|
||||
(platformType === 'vanilla'
|
||||
? '<p class="formMessage">바닐라이므로 별도 설치는 필요 없습니다.</p><button class="primaryBtn" id="next">다음</button>'
|
||||
: '<div class="actionRow"><button class="primaryBtn" id="install">설치</button><button class="secondaryBtn" id="skip">건너뛰기</button></div>')
|
||||
if (platformType === 'vanilla') {
|
||||
state.client.installPlatform = false
|
||||
host.querySelector('#next').addEventListener('click', done)
|
||||
return
|
||||
}
|
||||
host.querySelector('#install').addEventListener('click', function () {
|
||||
state.client.installPlatform = true
|
||||
done()
|
||||
})
|
||||
host.querySelector('#skip').addEventListener('click', function () {
|
||||
state.client.installPlatform = false
|
||||
done()
|
||||
})
|
||||
}
|
||||
|
||||
function renderSubStep42(host, done) {
|
||||
host.innerHTML =
|
||||
'<h3>4-2. 모드/리소스팩 다운로드 및 launcher_profiles 갱신</h3>' +
|
||||
'<p class="formMessage">%appdata%\\.mc_custom 에 모드와 리소스팩을 설치하고, launcher_profiles.json에 프로필을 등록합니다.</p>' +
|
||||
'<button class="primaryBtn" id="run">설치 시작</button>' +
|
||||
'<div class="formMessage" id="msg"></div>'
|
||||
host.querySelector('#run').addEventListener('click', async function () {
|
||||
var msg = host.querySelector('#msg')
|
||||
msg.textContent = '설치 중...'
|
||||
msg.classList.remove('error', 'success')
|
||||
try {
|
||||
await installerApi.installClient({
|
||||
packKey: state.selectedPackKey,
|
||||
installPlatform: !!state.client.installPlatform
|
||||
})
|
||||
msg.textContent = '클라이언트 설치 완료.'
|
||||
msg.classList.add('success')
|
||||
done()
|
||||
} catch (err) {
|
||||
msg.textContent = '설치 실패: ' + err.message
|
||||
msg.classList.add('error')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function renderSubStep43(host, done) {
|
||||
host.innerHTML = '<h3>4-3. 완료 확인</h3><p class="formMessage">모드와 리소스팩이 .mc_custom에 설치되어 있고, launcher_profiles.json도 갱신되었습니다.</p><button class="primaryBtn" id="confirm">5단계로</button>'
|
||||
host.querySelector('#confirm').addEventListener('click', done)
|
||||
}
|
||||
|
||||
function renderStep5() {
|
||||
setActiveStep(5)
|
||||
clearPage()
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
var multi = state.mode === 'multi'
|
||||
section.innerHTML =
|
||||
'<h2>5단계. 설치 완료</h2>' +
|
||||
'<p>모든 단계가 끝났습니다. 아래 옵션을 선택해 주세요.</p>' +
|
||||
(multi ? '<div class="subStep">' +
|
||||
'<h3>서버</h3>' +
|
||||
'<button class="secondaryBtn" id="openFolder">서버 폴더 열기</button>' +
|
||||
'<label class="toggleRow"><input type="checkbox" id="shortcut" checked /> 바탕화면에 서버 실행 바로가기 만들기</label>' +
|
||||
'<label class="toggleRow"><input type="checkbox" id="startServer" checked /> 서버 바로 실행</label>' +
|
||||
'</div>' : '') +
|
||||
'<div class="subStep">' +
|
||||
'<h3>마인크래프트 런처</h3>' +
|
||||
'<label class="toggleRow"><input type="checkbox" id="startLauncher" checked /> 마인크래프트 런처 실행</label>' +
|
||||
'</div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="finish">완료</button></div>'
|
||||
pageHost.appendChild(section)
|
||||
section.querySelector('#back').addEventListener('click', renderStep4)
|
||||
if (multi) {
|
||||
section.querySelector('#openFolder').addEventListener('click', function () {
|
||||
installerApi.openServerFolder()
|
||||
})
|
||||
}
|
||||
section.querySelector('#finish').addEventListener('click', async function () {
|
||||
if (multi) {
|
||||
if (section.querySelector('#shortcut').checked) await installerApi.createDesktopShortcut()
|
||||
if (section.querySelector('#startServer').checked) await installerApi.startServer()
|
||||
}
|
||||
if (section.querySelector('#startLauncher').checked) await installerApi.startMinecraftLauncher()
|
||||
section.querySelector('#finish').disabled = true
|
||||
section.querySelector('#finish').textContent = '완료됨'
|
||||
})
|
||||
}
|
||||
|
||||
renderStep1()
|
||||
162
installer/styles.css
Normal file
162
installer/styles.css
Normal file
@@ -0,0 +1,162 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0d1117;
|
||||
--bg-alt: #161b22;
|
||||
--bg-card: #1f242c;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--text-muted: #8b949e;
|
||||
--accent: #2f81f7;
|
||||
--danger: #f85149;
|
||||
--success: #3fb950;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Pretendard', -apple-system, 'Segoe UI', sans-serif;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
body {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.appHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background: var(--bg-alt);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.appHeader h1 { margin: 0; font-size: 18px; }
|
||||
|
||||
.stepIndicator {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stepIndicator li {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stepIndicator li.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
background: rgba(47, 129, 247, 0.15);
|
||||
}
|
||||
|
||||
.stepIndicator li.done {
|
||||
border-color: var(--success);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 28px 32px 100px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.page { max-width: 720px; margin: 0 auto; }
|
||||
|
||||
.page h2 { margin: 0 0 16px; }
|
||||
|
||||
.page p { color: var(--text-muted); }
|
||||
|
||||
.cardChoice {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.cardChoice button {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 16px 18px;
|
||||
border-radius: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.cardChoice button:hover { border-color: var(--accent); }
|
||||
.cardChoice button.selected { border-color: var(--accent); background: rgba(47, 129, 247, 0.15); }
|
||||
|
||||
.actionRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 24px;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.primaryBtn,
|
||||
.secondaryBtn,
|
||||
.dangerBtn {
|
||||
font: inherit;
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.primaryBtn { background: var(--accent); color: white; }
|
||||
.primaryBtn:disabled { background: #2c3849; cursor: not-allowed; }
|
||||
|
||||
.secondaryBtn { background: var(--bg-card); border-color: var(--border); color: var(--text); }
|
||||
.secondaryBtn:hover { border-color: var(--accent); }
|
||||
|
||||
.dangerBtn { background: var(--danger); color: white; }
|
||||
|
||||
.fieldset { display: flex; flex-direction: column; gap: 8px; margin: 16px 0; }
|
||||
.fieldset label { display: flex; gap: 8px; align-items: center; }
|
||||
.fieldset input[type="text"], .fieldset input[type="url"] { flex: 1; background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 8px 10px; border-radius: 6px; }
|
||||
|
||||
.formMessage { font-size: 13px; color: var(--text-muted); margin-top: 6px; }
|
||||
.formMessage.error { color: var(--danger); }
|
||||
.formMessage.success { color: var(--success); }
|
||||
|
||||
.subStep { padding: 14px 16px; border: 1px solid var(--border); border-radius: 12px; margin-bottom: 12px; background: var(--bg-card); }
|
||||
.subStep h3 { margin: 0 0 8px; font-size: 16px; }
|
||||
|
||||
.logViewer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 200px;
|
||||
background: #0a0d11;
|
||||
border-top: 1px solid var(--border);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.logViewer header { display: flex; justify-content: space-between; align-items: center; padding: 6px 12px; background: var(--bg-alt); }
|
||||
.logViewer header h2 { margin: 0; font-size: 13px; }
|
||||
.logViewer header button { background: transparent; border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 4px 8px; cursor: pointer; }
|
||||
.logViewer pre { margin: 0; padding: 8px 12px; overflow-y: auto; font-family: 'Consolas', monospace; font-size: 12px; }
|
||||
|
||||
.eulaBox { background: var(--bg-card); border: 1px solid var(--border); padding: 16px; border-radius: 10px; max-height: 200px; overflow-y: auto; font-size: 12px; line-height: 1.6; }
|
||||
|
||||
.toggleRow { display: flex; align-items: center; gap: 10px; margin: 8px 0; }
|
||||
|
||||
.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); }
|
||||
.statusBadge.warn { background: rgba(248, 197, 49, 0.2); color: #f0c244; }
|
||||
.statusBadge.fail { background: rgba(248, 81, 73, 0.2); color: var(--danger); }
|
||||
Reference in New Issue
Block a user