installer: 3-3 자동 다운로드 및 UPnP 안정화

3-3 서버 다운로드도 진입 즉시 자동 시작하도록 변경. "다운로드 시작" 버튼을 제거하고 4-2와 같은 자동 실행 패턴을 적용했다(EULA 모달은 그대로 유지). 실패 시 이전→다음으로 재시도.

UPnP 점검 안정화:
- openPortViaUpnp에 15초 타임아웃 추가. SSDP 응답 없을 때 영구 hang을 방지한다.
- detectExternalIp: ipify 단일 실패 시 ifconfig.me/icanhazip 폴백 후, 최종 폴백으로 UPnP 게이트웨이의 externalIp를 사용. 기존에는 IP를 못 얻으면 UPnP 시도조차 안 했음.
- portMapping 성공 후 NAT 상태 전파 지연을 고려해 testPortReachable을 1.5초 간격 3회 재시도.
- 각 단계마다 로그를 남겨 라우터 UPnP 비활성/SSDP 차단/이중 NAT 등의 원인을 구분할 수 있게 함.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 01:01:00 +09:00
parent c2fb7d03a6
commit e31c6ed55b
2 changed files with 119 additions and 33 deletions

View File

@@ -260,20 +260,22 @@ function renderSubStep33(host, back, done) {
'<h3>3-3. 서버 다운로드 및 설치</h3>' +
'<p class="formMessage">선택한 음악퀴즈의 서버 파일을 다운로드합니다. 진행 상황은 하단 로그 뷰어에 표시됩니다.</p>' +
'<div class="formMessage" id="downloadStatus">대기 중</div>' +
'<button class="primaryBtn" id="startDownload">다운로드 시작</button>' +
'<div id="ramSection" hidden style="margin-top:14px;">' +
'<h4>램 검사</h4>' +
'<div class="formMessage" id="ramMsg">검사 중...</div>' +
'</div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
var startBtn = host.querySelector('#startDownload')
var statusEl = host.querySelector('#downloadStatus')
var ramSection = host.querySelector('#ramSection')
var ramMsg = host.querySelector('#ramMsg')
var nextBtn = host.querySelector('#next')
host.querySelector('#back').addEventListener('click', back)
nextBtn.addEventListener('click', function () {
if (!state.serverInstall.eulaAccepted) return
done()
})
// 이미 통과했던 상태 복원: 사용자가 다음→이전으로 돌아왔을 때 재다운로드 강요하지 않는다.
if (state.serverInstall.eulaAccepted && state.serverInstall.ram) {
@@ -281,10 +283,11 @@ function renderSubStep33(host, back, done) {
statusEl.classList.add('success')
showRamResult(state.serverInstall.ram)
nextBtn.disabled = false
return
}
startBtn.addEventListener('click', async function () {
startBtn.disabled = true
// 페이지 진입 즉시 자동 다운로드
;(async function () {
state.serverInstall.eulaAccepted = false
nextBtn.disabled = true
statusEl.classList.remove('success', 'error')
@@ -298,9 +301,8 @@ function renderSubStep33(host, back, done) {
statusEl.textContent = 'EULA 동의가 필요합니다. 팝업을 확인해 주세요.'
var accepted = await openEulaPopup(state.serverInstall.path)
if (!accepted) {
statusEl.textContent = 'EULA 동의 실패. 다운로드를 취소니다. "다운로드 시작"으로 다시 시도하세요.'
statusEl.textContent = 'EULA 동의 실패. 다운로드를 취소했습니다. 이전→다음으로 다시 시도하세요.'
statusEl.classList.add('error')
startBtn.disabled = false
return
}
try {
@@ -308,7 +310,6 @@ function renderSubStep33(host, back, done) {
} catch (err) {
statusEl.textContent = 'EULA 저장 실패: ' + err.message
statusEl.classList.add('error')
startBtn.disabled = false
return
}
state.serverInstall.eulaAccepted = true
@@ -320,16 +321,10 @@ function renderSubStep33(host, back, done) {
if (ram.decision === 'tooLow') return
nextBtn.disabled = false
} catch (err) {
statusEl.textContent = '다운로드 실패: ' + err.message
statusEl.textContent = '다운로드 실패: ' + (err && err.message ? err.message : err)
statusEl.classList.add('error')
startBtn.disabled = false
}
})
nextBtn.addEventListener('click', function () {
if (!state.serverInstall.eulaAccepted) return
done()
})
})()
function showRamResult(result) {
ramSection.hidden = false