installer: multi-role host/participant split + auto-platform + EULA + port UX

step2:
- 멀티 선택 시 호스트 / 참가자 sub-choice 추가. 호스트 는 기존 멀티 흐름 그대로,
  참가자 는 step3 (서버 설치) 를 건너뛰고 step4 client install 만 진행.

step4 client install:
- 플랫폼 설치/생략 선택 화면(sub41) 제거. 음악퀴즈 platform.type 이 vanilla 가
  아니면 무조건 자동 설치, vanilla 면 자동 건너뜀. 사용자 결정 없음.
- 참가자 모드에서는 ClientInstallPayload.skipMap=true 로 보내 client 측
  saves/ 에 맵을 풀지 않는다 (서버에 이미 있음).
- types.ts 에 skipMap 필드 추가. main.ts client:install 핸들러에서 분기.

step3 EULA modal:
- eula.txt 의 내용과 무관하게 항상 minecraft.net 의 공식 서버 EULA 페이지를
  받아 iframe 에 표시. readEula() 분기 제거.

step3 포트포워딩 결과:
- 성공(preForwarded/upnpOk) 시 "친구는 <address> 주소로 서버에 접속할 수
  있습니다" 처럼 외부 주소를 강조해 표시.
- 포트가 25565 면 :포트 를 생략하고 ip 만 보여줌 (마인크래프트 자바판
  기본 포트라 클라이언트에서도 생략 가능).

step5:
- 서버 마무리 액션 (바로가기/서버 실행 토글) 은 호스트 만 노출. 참가자는
  서버를 띄우지 않으므로 런처 토글만 보인다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 22:06:48 +09:00
parent 848fac500e
commit 49f320fa71
4 changed files with 109 additions and 103 deletions

View File

@@ -24,6 +24,11 @@ const state = {
packs: [],
selectedPackKey: null,
mode: null, // 'single' | 'multi'
// mode==='multi' 일 때만 의미가 있다.
// 'host' → 서버를 직접 연다. 기존 멀티 흐름 (step3 + step4) 그대로.
// 'participant' → 친구 서버에 접속만 한다. step3 (서버 설치) 를 건너뛰고
// client 측에서도 맵은 받지 않는다 (참가자라 서버에 이미 있음).
role: null, // 'host' | 'participant' | null
serverInstall: {
path: '',
jdk: '',
@@ -154,33 +159,71 @@ function renderStep2() {
'<button id="single" type="button" data-mode="single"><strong>' + tt('step2.singleTitle') + '</strong><br><small>' + tt('step2.singleHint') + '</small></button>' +
'<button id="multi" type="button" data-mode="multi"><strong>' + tt('step2.multiTitle') + '</strong><br><small>' + tt('step2.multiHint') + '</small></button>' +
'</div>' +
'<div id="roleSection" hidden style="margin-top:16px;">' +
'<h3>' + tt('step2.roleHeading') + '</h3>' +
'<div class="cardChoice">' +
'<button type="button" data-role="host"><strong>' + tt('step2.hostTitle') + '</strong><br><small>' + tt('step2.hostHint') + '</small></button>' +
'<button type="button" data-role="participant"><strong>' + tt('step2.participantTitle') + '</strong><br><small>' + tt('step2.participantHint') + '</small></button>' +
'</div>' +
'</div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
pageHost.appendChild(section)
var nextBtn = section.querySelector('#next')
var modeButtons = section.querySelectorAll('[data-mode]')
var roleSection = section.querySelector('#roleSection')
var roleButtons = section.querySelectorAll('[data-role]')
function applySelection(mode) {
function applyMode(mode) {
state.mode = mode
modeButtons.forEach(function (btn) {
if (btn.getAttribute('data-mode') === mode) btn.classList.add('selected')
else btn.classList.remove('selected')
})
if (mode === 'multi') {
roleSection.hidden = false
// 역할이 이미 골라져 있으면 그대로, 아니면 사용자가 골라야 next 활성화.
nextBtn.disabled = !state.role
} else {
roleSection.hidden = true
state.role = null
roleButtons.forEach(function (btn) { btn.classList.remove('selected') })
nextBtn.disabled = false
}
}
function applyRole(role) {
state.role = role
roleButtons.forEach(function (btn) {
if (btn.getAttribute('data-role') === role) btn.classList.add('selected')
else btn.classList.remove('selected')
})
nextBtn.disabled = false
}
modeButtons.forEach(function (btn) {
btn.addEventListener('click', function () {
applySelection(btn.getAttribute('data-mode'))
applyMode(btn.getAttribute('data-mode'))
})
})
roleButtons.forEach(function (btn) {
btn.addEventListener('click', function () {
applyRole(btn.getAttribute('data-role'))
})
})
if (state.mode === 'single' || state.mode === 'multi') applySelection(state.mode)
if (state.mode === 'single' || state.mode === 'multi') {
applyMode(state.mode)
if (state.mode === 'multi' && state.role) applyRole(state.role)
}
nextBtn.addEventListener('click', function () {
if (!state.mode) return
if (state.mode === 'multi' && !state.role) return
state.stepDone[2] = true
if (state.mode === 'single') renderStep4()
else renderStep3()
// 멀티+호스트 만 서버 설치(step3) 를 거친다.
// 싱글, 멀티+참가자 는 곧장 클라이언트(step4) 로.
if (state.mode === 'multi' && state.role === 'host') renderStep3()
else renderStep4()
})
section.querySelector('#back').addEventListener('click', renderStep1)
}
@@ -446,13 +489,10 @@ function renderSubStep33(host, back, done) {
}
// EULA 동의 팝업. resolve(true) = 동의, resolve(false) = 비동의/창 닫힘.
async function openEulaPopup(installPath) {
var read = await installerApi.readEula(installPath)
// eula.txt 의 내용과 무관하게 항상 minecraft.net 의 공식 EULA 페이지를 받아서
// 표시한다 — 사용자가 실제 서버 약관을 보고 동의하도록.
async function openEulaPopup(_installPath) {
var bodyHtml = ''
if (read.exists) {
bodyHtml = '<p class="formMessage">' + tt('step3.eulaModal.fromFile') + '</p>' +
'<pre class="eulaPre">' + escapeHtml(read.content) + '</pre>'
} else {
var fetched = await installerApi.fetchMinecraftEula()
if (fetched.html) {
bodyHtml = '<p class="formMessage">' + tt('step3.eulaModal.fromMojang', { url: fetched.url }) + '</p>' +
@@ -460,7 +500,6 @@ async function openEulaPopup(installPath) {
} else {
bodyHtml = '<p class="formMessage error">' + tt('step3.eulaModal.loadFailed') + '</p>'
}
}
return new Promise(function (resolve) {
var overlay = document.createElement('div')
overlay.className = 'modalOverlay'
@@ -491,12 +530,6 @@ async function openEulaPopup(installPath) {
})
}
function escapeHtml(text) {
return String(text).replace(/[&<>"']/g, function (ch) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]
})
}
function escapeAttr(text) {
return String(text).replace(/&/g, '&amp;').replace(/"/g, '&quot;')
}
@@ -535,6 +568,14 @@ function renderSubStep35(host, back, done) {
var runBtn = host.querySelector('#run')
host.querySelector('#back').addEventListener('click', back)
// 25565 는 마인크래프트 자바판 기본 포트라 클라이언트에서 생략 가능 →
// 사용자에게도 ip 만 보여주는 게 깔끔하다.
function formatServerAddress(ip, port) {
var safeIp = ip || tt('step3.sub35.ipUnknown')
if (Number(port) === 25565) return safeIp
return safeIp + ':' + port
}
async function runCheck() {
runBtn.disabled = true
resultMsg.classList.remove('success', 'warn', 'error')
@@ -543,16 +584,16 @@ function renderSubStep35(host, back, done) {
try {
var result = await installerApi.checkPortForward(port)
state.serverInstall.portStatus = result
var address = formatServerAddress(result.externalIp, result.port)
if (result.status === 'preForwarded') {
resultMsg.innerHTML = tt('step3.sub35.preForwarded', { ip: result.externalIp, port: result.port })
resultMsg.innerHTML = tt('step3.sub35.preForwarded', { address: address })
resultMsg.classList.add('success')
} else if (result.status === 'upnpOk') {
resultMsg.innerHTML = tt('step3.sub35.upnpOk', { ip: result.externalIp, port: result.port })
resultMsg.innerHTML = tt('step3.sub35.upnpOk', { address: address })
resultMsg.classList.add('success')
} else {
var ip = result.externalIp || tt('step3.sub35.ipUnknown')
resultMsg.innerHTML = (result.message || tt('step3.sub35.manualHint')) +
tt('step3.sub35.manualDetail', { ip: ip, port: result.port })
tt('step3.sub35.manualDetail', { address: address })
resultMsg.classList.add('warn')
}
nextBtn.disabled = false
@@ -581,64 +622,25 @@ function renderStep4() {
'<div class="subStep" id="subHost"></div>'
pageHost.appendChild(section)
var subHost = section.querySelector('#subHost')
function backToPrevStep() { if (state.mode === 'multi') renderStep3(); else renderStep2() }
function show41() { subHost.innerHTML = ''; renderSubStep41(subHost, pack, backToPrevStep, show42) }
function show42() { subHost.innerHTML = ''; renderSubStep42(subHost, show41, goStep5) }
// 플랫폼 선택 UI 는 더 이상 보여주지 않는다. 음악퀴즈에 지정된 플랫폼이
// 바닐라가 아니면 자동으로 설치하고, 바닐라면 건너뛴다 — 사용자가 고를 일이 없다.
var platformType = pack ? pack.pack.platform.type : 'vanilla'
state.client.installPlatform = platformType !== 'vanilla'
// 멀티+호스트 만 step3 (서버 설치) 를 거쳤으므로 거기로 돌아간다.
// 싱글 / 멀티+참가자 는 step2 로 되돌아간다.
function backToPrevStep() {
if (state.mode === 'multi' && state.role === 'host') renderStep3()
else renderStep2()
}
function show42() { subHost.innerHTML = ''; renderSubStep42(subHost, backToPrevStep, goStep5) }
function goStep5() {
state.stepDone[4] = true
renderStep5()
}
show41()
}
function renderSubStep41(host, pack, back, done) {
var platformType = pack ? pack.pack.platform.type : 'vanilla'
if (platformType === 'vanilla') {
state.client.installPlatform = false
host.innerHTML =
'<h3>' + tt('step4.sub41.heading') + '</h3>' +
'<p class="formMessage">' + tt('step4.sub41.vanillaInfo') + '</p>' +
'<p class="formMessage">' + tt('step4.sub41.vanillaNoInstall') + '</p>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next">' + tt('common.next') + '</button></div>'
host.querySelector('#back').addEventListener('click', back)
host.querySelector('#next').addEventListener('click', done)
return
}
host.innerHTML =
'<h3>' + tt('step4.sub41.heading') + '</h3>' +
'<p class="formMessage">' + tt('step4.sub41.info', { platform: platformType }) + '</p>' +
'<div class="cardChoice">' +
'<button type="button" data-choice="install"><strong>' + tt('step4.sub41.installTitle') + '</strong><br><small>' + tt('step4.sub41.installHint', { platform: platformType }) + '</small></button>' +
'<button type="button" data-choice="skip"><strong>' + tt('step4.sub41.skipTitle') + '</strong><br><small>' + tt('step4.sub41.skipHint') + '</small></button>' +
'</div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
var nextBtn = host.querySelector('#next')
var choiceButtons = host.querySelectorAll('[data-choice]')
function applyChoice(choice) {
state.client.installPlatform = choice === 'install'
choiceButtons.forEach(function (btn) {
if (btn.getAttribute('data-choice') === choice) btn.classList.add('selected')
else btn.classList.remove('selected')
})
nextBtn.disabled = false
}
choiceButtons.forEach(function (btn) {
btn.addEventListener('click', function () {
applyChoice(btn.getAttribute('data-choice'))
})
})
if (typeof state.client.installPlatform === 'boolean') {
applyChoice(state.client.installPlatform ? 'install' : 'skip')
}
host.querySelector('#back').addEventListener('click', back)
nextBtn.addEventListener('click', done)
show42()
}
function renderSubStep42(host, back, done) {
@@ -665,7 +667,9 @@ function renderSubStep42(host, back, done) {
try {
await installerApi.installClient({
packKey: state.selectedPackKey,
installPlatform: !!state.client.installPlatform
installPlatform: !!state.client.installPlatform,
// 참가자는 친구 서버에 접속만 하므로 클라이언트에 맵을 풀지 않는다.
skipMap: state.mode === 'multi' && state.role === 'participant'
})
msg.textContent = tt('step4.sub42.done')
msg.classList.add('success')
@@ -683,11 +687,13 @@ function renderStep5() {
clearPage()
var section = document.createElement('section')
section.className = 'page'
var multi = state.mode === 'multi'
// 서버 마무리 액션 (바로가기/서버 실행) 은 step3 를 거친 호스트 만 노출한다.
// 싱글, 멀티+참가자 는 서버를 직접 띄우지 않으므로 런처만 보여준다.
var showServerActions = state.mode === 'multi' && state.role === 'host'
section.innerHTML =
'<h2>' + tt('step5.heading') + '</h2>' +
'<p>' + tt('step5.summary') + '</p>' +
(multi ? '<div class="subStep">' +
(showServerActions ? '<div class="subStep">' +
'<h3>' + tt('step5.serverHeading') + '</h3>' +
'<button class="secondaryBtn" id="openFolder">' + tt('step5.openServerFolder') + '</button>' +
'<label class="toggleRow"><input type="checkbox" id="shortcut" checked /> ' + tt('step5.shortcut') + '</label>' +
@@ -700,7 +706,7 @@ function renderStep5() {
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="finish">' + tt('step5.finish') + '</button></div>'
pageHost.appendChild(section)
section.querySelector('#back').addEventListener('click', renderStep4)
if (multi) {
if (showServerActions) {
section.querySelector('#openFolder').addEventListener('click', function () {
installerApi.openServerFolder()
})
@@ -710,7 +716,7 @@ function renderStep5() {
finishBtn.disabled = true
finishBtn.textContent = tt('step5.finishing')
try {
if (multi) {
if (showServerActions) {
if (section.querySelector('#shortcut').checked) await installerApi.createDesktopShortcut()
if (section.querySelector('#startServer').checked) await installerApi.startServer()
}

View File

@@ -42,7 +42,12 @@
"singleTitle": "싱글",
"singleHint": "싱글 맵으로 혼자 플레이할때",
"multiTitle": "멀티",
"multiHint": "버킷 서버로 친구들과 같이 플레이할때"
"multiHint": "버킷 서버로 친구들과 같이 플레이할때",
"roleHeading": "호스트 / 참가자",
"hostTitle": "호스트",
"hostHint": "내가 서버를 직접 열고 친구들을 초대할 때",
"participantTitle": "참가자",
"participantHint": "친구가 연 서버에 접속만 할 때 (맵은 받지 않음)"
},
"step3": {
"heading": "서버 관련 설정",
@@ -90,8 +95,7 @@
},
"eulaModal": {
"title": "Minecraft EULA 동의",
"fromFile": "서버 파일에 포함된 eula.txt 내용입니다.",
"fromMojang": "서버 파일에 eula.txt가 없어 minecraft.net의 EULA를 표시합니다 (<a href=\"{{url}}\" target=\"_blank\">{{url}}</a>).",
"fromMojang": "마인크래프트 서버를 실행하려면 아래 EULA에 동의해야 합니다 (<a href=\"{{url}}\" target=\"_blank\">{{url}}</a>).",
"loadFailed": "EULA 페이지를 불러올 수 없습니다. 직접 확인해 주세요: <a href=\"https://www.minecraft.net/en-us/eula\" target=\"_blank\">https://www.minecraft.net/en-us/eula</a>"
},
"sub34": {
@@ -107,26 +111,16 @@
"portLabel": "포트",
"recheck": "재점검",
"checking": "확인 중...",
"preForwarded": "이미 외부 접속 가능: {{ip}}:{{port}}",
"upnpOk": "UPnP로 자동 개방 완료: {{ip}}:{{port}}",
"preForwarded": "포트포워딩 성공! 친구는 <strong>{{address}}</strong> 주소로 서버에 접속할 수 있습니다. (이미 외부 개방되어 있음)",
"upnpOk": "포트포워딩 성공! 친구는 <strong>{{address}}</strong> 주소로 서버에 접속할 수 있습니다. (UPnP로 자동 개방 완료)",
"manualHint": "직접 포트포워딩을 해주세요.",
"manualDetail": "<br><small>외부 IP: {{ip}}, 포트: {{port}}</small>",
"manualDetail": "<br><small>외부 주소: {{address}}</small>",
"checkFailed": "점검 실패: {{message}}",
"ipUnknown": "확인 불가"
}
},
"step4": {
"heading": "클라이언트 설정",
"sub41": {
"heading": "플랫폼",
"vanillaInfo": "선택한 음악퀴즈의 플랫폼: <strong>vanilla</strong>",
"vanillaNoInstall": "바닐라이므로 별도 설치는 필요 없습니다.",
"info": "선택한 음악퀴즈의 플랫폼: <strong>{{platform}}</strong>",
"installTitle": "권장 플랫폼 설치",
"installHint": "{{platform}} 설치",
"skipTitle": "기본 마인크래프트로 설치",
"skipHint": "설치하지 않고 바닐라로 진행합니다."
},
"sub42": {
"heading": "다운로드 및 적용",
"description": "클라이언트 설정",
@@ -196,7 +190,7 @@
"labelServerFile": "서버 파일",
"labelMap": "맵",
"skipServerZip": "서버 파일(serverPath)이 비어 있어 서버 zip 다운로드를 건너뜁니다.",
"skipMapZip": "맵 파일(mapPath)이 비어 있어 맵 다운로드를 건너뜁니다.",
"skipMapZip": "맵 다운로드를 건너뜁니다 (mapPath 비어 있음 또는 참가자 모드).",
"skipModsFolder": "modsFolder가 비어 있어 모드 다운로드를 건너뜁니다.",
"modsIndexFetch": "모드 목록 조회: {{url}}",
"modsFolderEmpty": "/file/mods/{{folder}}/ 안에 .jar 파일이 없습니다.",

View File

@@ -1047,7 +1047,11 @@ ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) =
await downloadModsFolder(pack.pack, customRoot)
await downloadResourcepackZip(pack.pack, customRoot)
if (payload.skipMap) {
sendLog(t('log.skipMapZip'))
} else {
await downloadMapZip(pack.pack, customRoot)
}
// 런처가 .mc_custom 을 gameDir 로 잡아도 assets/libraries/versions 를
// 찾을 수 있도록 .minecraft 의 해당 폴더로 junction 링크.

View File

@@ -25,6 +25,8 @@ export interface ServerInstallPayload {
export interface ClientInstallPayload {
packKey: string
installPlatform: boolean
/** true 면 client 측 saves/ 에 맵을 풀지 않는다 (참가자 모드). */
skipMap?: boolean
}
export interface RamCheckResult {