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:
@@ -260,20 +260,22 @@ function renderSubStep33(host, back, done) {
|
|||||||
'<h3>3-3. 서버 다운로드 및 설치</h3>' +
|
'<h3>3-3. 서버 다운로드 및 설치</h3>' +
|
||||||
'<p class="formMessage">선택한 음악퀴즈의 서버 파일을 다운로드합니다. 진행 상황은 하단 로그 뷰어에 표시됩니다.</p>' +
|
'<p class="formMessage">선택한 음악퀴즈의 서버 파일을 다운로드합니다. 진행 상황은 하단 로그 뷰어에 표시됩니다.</p>' +
|
||||||
'<div class="formMessage" id="downloadStatus">대기 중</div>' +
|
'<div class="formMessage" id="downloadStatus">대기 중</div>' +
|
||||||
'<button class="primaryBtn" id="startDownload">다운로드 시작</button>' +
|
|
||||||
'<div id="ramSection" hidden style="margin-top:14px;">' +
|
'<div id="ramSection" hidden style="margin-top:14px;">' +
|
||||||
'<h4>램 검사</h4>' +
|
'<h4>램 검사</h4>' +
|
||||||
'<div class="formMessage" id="ramMsg">검사 중...</div>' +
|
'<div class="formMessage" id="ramMsg">검사 중...</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></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 statusEl = host.querySelector('#downloadStatus')
|
||||||
var ramSection = host.querySelector('#ramSection')
|
var ramSection = host.querySelector('#ramSection')
|
||||||
var ramMsg = host.querySelector('#ramMsg')
|
var ramMsg = host.querySelector('#ramMsg')
|
||||||
var nextBtn = host.querySelector('#next')
|
var nextBtn = host.querySelector('#next')
|
||||||
|
|
||||||
host.querySelector('#back').addEventListener('click', back)
|
host.querySelector('#back').addEventListener('click', back)
|
||||||
|
nextBtn.addEventListener('click', function () {
|
||||||
|
if (!state.serverInstall.eulaAccepted) return
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
|
||||||
// 이미 통과했던 상태 복원: 사용자가 다음→이전으로 돌아왔을 때 재다운로드 강요하지 않는다.
|
// 이미 통과했던 상태 복원: 사용자가 다음→이전으로 돌아왔을 때 재다운로드 강요하지 않는다.
|
||||||
if (state.serverInstall.eulaAccepted && state.serverInstall.ram) {
|
if (state.serverInstall.eulaAccepted && state.serverInstall.ram) {
|
||||||
@@ -281,10 +283,11 @@ function renderSubStep33(host, back, done) {
|
|||||||
statusEl.classList.add('success')
|
statusEl.classList.add('success')
|
||||||
showRamResult(state.serverInstall.ram)
|
showRamResult(state.serverInstall.ram)
|
||||||
nextBtn.disabled = false
|
nextBtn.disabled = false
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
startBtn.addEventListener('click', async function () {
|
// 페이지 진입 즉시 자동 다운로드
|
||||||
startBtn.disabled = true
|
;(async function () {
|
||||||
state.serverInstall.eulaAccepted = false
|
state.serverInstall.eulaAccepted = false
|
||||||
nextBtn.disabled = true
|
nextBtn.disabled = true
|
||||||
statusEl.classList.remove('success', 'error')
|
statusEl.classList.remove('success', 'error')
|
||||||
@@ -298,9 +301,8 @@ function renderSubStep33(host, back, done) {
|
|||||||
statusEl.textContent = 'EULA 동의가 필요합니다. 팝업을 확인해 주세요.'
|
statusEl.textContent = 'EULA 동의가 필요합니다. 팝업을 확인해 주세요.'
|
||||||
var accepted = await openEulaPopup(state.serverInstall.path)
|
var accepted = await openEulaPopup(state.serverInstall.path)
|
||||||
if (!accepted) {
|
if (!accepted) {
|
||||||
statusEl.textContent = 'EULA 동의 실패. 다운로드를 취소합니다. "다운로드 시작"으로 다시 시도하세요.'
|
statusEl.textContent = 'EULA 동의 실패. 다운로드를 취소했습니다. 이전→다음으로 다시 시도하세요.'
|
||||||
statusEl.classList.add('error')
|
statusEl.classList.add('error')
|
||||||
startBtn.disabled = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -308,7 +310,6 @@ function renderSubStep33(host, back, done) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
statusEl.textContent = 'EULA 저장 실패: ' + err.message
|
statusEl.textContent = 'EULA 저장 실패: ' + err.message
|
||||||
statusEl.classList.add('error')
|
statusEl.classList.add('error')
|
||||||
startBtn.disabled = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
state.serverInstall.eulaAccepted = true
|
state.serverInstall.eulaAccepted = true
|
||||||
@@ -320,16 +321,10 @@ function renderSubStep33(host, back, done) {
|
|||||||
if (ram.decision === 'tooLow') return
|
if (ram.decision === 'tooLow') return
|
||||||
nextBtn.disabled = false
|
nextBtn.disabled = false
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
statusEl.textContent = '다운로드 실패: ' + err.message
|
statusEl.textContent = '다운로드 실패: ' + (err && err.message ? err.message : err)
|
||||||
statusEl.classList.add('error')
|
statusEl.classList.add('error')
|
||||||
startBtn.disabled = false
|
|
||||||
}
|
}
|
||||||
})
|
})()
|
||||||
|
|
||||||
nextBtn.addEventListener('click', function () {
|
|
||||||
if (!state.serverInstall.eulaAccepted) return
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
|
|
||||||
function showRamResult(result) {
|
function showRamResult(result) {
|
||||||
ramSection.hidden = false
|
ramSection.hidden = false
|
||||||
|
|||||||
@@ -475,32 +475,99 @@ function readBody(req: http.IncomingMessage): Promise<string> {
|
|||||||
|
|
||||||
ipcMain.handle('server:portForward', async (_event, port: number): Promise<PortForwardResult> => {
|
ipcMain.handle('server:portForward', async (_event, port: number): Promise<PortForwardResult> => {
|
||||||
const targetPort = Number.isFinite(port) && port > 0 ? port : 25565
|
const targetPort = Number.isFinite(port) && port > 0 ? port : 25565
|
||||||
const externalIp = await detectExternalIp()
|
sendLog(`포트포워딩 점검 시작: 포트 ${targetPort}`)
|
||||||
if (await testPortReachable(externalIp, targetPort)) {
|
|
||||||
|
// 외부 IP 확보: 공용 API → 실패 시 UPnP 게이트웨이의 외부 IP로 폴백
|
||||||
|
let externalIp = await detectExternalIpHttp()
|
||||||
|
if (externalIp) {
|
||||||
|
sendLog(`외부 IP 확인(HTTP): ${externalIp}`)
|
||||||
|
} else {
|
||||||
|
sendLog('외부 IP 확인 실패(HTTP). UPnP 게이트웨이를 통한 조회 시도...')
|
||||||
|
externalIp = await detectExternalIpUpnp()
|
||||||
|
if (externalIp) sendLog(`외부 IP 확인(UPnP): ${externalIp}`)
|
||||||
|
else sendLog('UPnP 게이트웨이에서도 외부 IP를 얻지 못했습니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (externalIp && (await testPortReachable(externalIp, targetPort))) {
|
||||||
sendLog(`외부에서 ${externalIp}:${targetPort} 접근 확인됨. 포트포워딩 됨.`)
|
sendLog(`외부에서 ${externalIp}:${targetPort} 접근 확인됨. 포트포워딩 됨.`)
|
||||||
return { status: 'preForwarded', externalIp, port: targetPort }
|
return { status: 'preForwarded', externalIp, port: targetPort }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendLog(`UPnP로 포트 ${targetPort} 자동 개방 시도(TCP)...`)
|
||||||
try {
|
try {
|
||||||
await openPortViaUpnp(targetPort)
|
await openPortViaUpnp(targetPort)
|
||||||
if (await testPortReachable(externalIp, targetPort)) {
|
sendLog('UPnP portMapping 요청 성공. 외부 접근을 재확인합니다.')
|
||||||
|
// 라우터의 NAT 상태 반영에 약간의 지연이 있을 수 있어 짧게 재시도
|
||||||
|
let reachable = false
|
||||||
|
if (externalIp) {
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
|
if (await testPortReachable(externalIp, targetPort)) { reachable = true; break }
|
||||||
|
await sleep(1500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reachable) {
|
||||||
sendLog(`UPnP로 포트 ${targetPort} 자동 개방 완료.`)
|
sendLog(`UPnP로 포트 ${targetPort} 자동 개방 완료.`)
|
||||||
return { status: 'upnpOk', externalIp, port: targetPort }
|
return { status: 'upnpOk', externalIp, port: targetPort }
|
||||||
}
|
}
|
||||||
sendLog('UPnP 개방은 시도했지만 외부 접근이 확인되지 않았습니다.')
|
const reason = externalIp
|
||||||
return { status: 'upnpFailed', externalIp, port: targetPort, message: '직접 포트포워딩을 해주세요.' }
|
? 'UPnP 매핑은 등록됐지만 외부 접근이 확인되지 않았습니다(ISP 차단 또는 이중 NAT 가능).'
|
||||||
|
: '외부 IP를 확인할 수 없어 접근 검증을 건너뛰었습니다. UPnP 매핑은 등록됐을 수 있습니다.'
|
||||||
|
sendLog(reason)
|
||||||
|
return { status: 'upnpFailed', externalIp, port: targetPort, message: reason + ' 직접 포트포워딩을 해주세요.' }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendLog(`UPnP 시도 실패: ${(error as Error).message}`)
|
const msg = (error as Error).message || String(error)
|
||||||
return { status: 'upnpFailed', externalIp, port: targetPort, message: '직접 포트포워딩을 해주세요.' }
|
sendLog(`UPnP 시도 실패: ${msg}`)
|
||||||
|
return {
|
||||||
|
status: 'upnpFailed',
|
||||||
|
externalIp,
|
||||||
|
port: targetPort,
|
||||||
|
message: `UPnP 실패: ${msg}. 라우터에서 UPnP가 꺼져 있을 수 있습니다. 직접 포트포워딩을 해주세요.`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function detectExternalIp(): Promise<string> {
|
async function detectExternalIpHttp(): Promise<string> {
|
||||||
|
const endpoints = ['https://api.ipify.org', 'https://ifconfig.me/ip', 'https://icanhazip.com']
|
||||||
|
for (const url of endpoints) {
|
||||||
try {
|
try {
|
||||||
const buffer = await fetchBuffer('https://api.ipify.org')
|
const buffer = await fetchBuffer(url)
|
||||||
return buffer.toString('utf8').trim()
|
const ip = buffer.toString('utf8').trim()
|
||||||
|
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) return ip
|
||||||
} catch {
|
} catch {
|
||||||
return ''
|
// try next
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectExternalIpUpnp(): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false
|
||||||
|
const finish = (ip: string) => { if (!settled) { settled = true; resolve(ip) } }
|
||||||
|
let client: ReturnType<typeof natUpnp.createClient> | null = null
|
||||||
|
try {
|
||||||
|
client = natUpnp.createClient()
|
||||||
|
} catch (err) {
|
||||||
|
sendLog(`UPnP 클라이언트 생성 실패: ${(err as Error).message}`)
|
||||||
|
finish('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
sendLog('UPnP externalIp 조회 타임아웃(8s).')
|
||||||
|
try { client && client.close() } catch {}
|
||||||
|
finish('')
|
||||||
|
}, 8000)
|
||||||
|
client.externalIp((err: Error | null, ip?: string) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
try { client && client.close() } catch {}
|
||||||
|
if (err || !ip) {
|
||||||
|
if (err) sendLog(`UPnP externalIp 오류: ${err.message}`)
|
||||||
|
finish('')
|
||||||
|
} else {
|
||||||
|
finish(ip)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function testPortReachable(host: string, port: number): Promise<boolean> {
|
function testPortReachable(host: string, port: number): Promise<boolean> {
|
||||||
@@ -524,13 +591,37 @@ function testPortReachable(host: string, port: number): Promise<boolean> {
|
|||||||
|
|
||||||
function openPortViaUpnp(port: number): Promise<void> {
|
function openPortViaUpnp(port: number): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const client = natUpnp.createClient()
|
let settled = false
|
||||||
client.portMapping({ public: port, private: port, ttl: 0, description: 'MusicQuiz Server', protocol: 'tcp' }, (error) => {
|
const done = (err?: Error) => {
|
||||||
client.close()
|
if (settled) return
|
||||||
if (error) reject(error)
|
settled = true
|
||||||
|
if (err) reject(err)
|
||||||
else resolve()
|
else resolve()
|
||||||
|
}
|
||||||
|
let client: ReturnType<typeof natUpnp.createClient> | null = null
|
||||||
|
try {
|
||||||
|
client = natUpnp.createClient()
|
||||||
|
} catch (err) {
|
||||||
|
done(err as Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
try { client && client.close() } catch {}
|
||||||
|
done(new Error('UPnP 응답 없음(타임아웃 15s). 라우터의 UPnP가 꺼져 있거나 SSDP 패킷이 차단됐을 수 있습니다.'))
|
||||||
|
}, 15000)
|
||||||
|
client.portMapping(
|
||||||
|
{ public: port, private: port, ttl: 0, description: 'MusicQuiz Server', protocol: 'tcp' },
|
||||||
|
(error: Error | null) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
try { client && client.close() } catch {}
|
||||||
|
done(error || undefined)
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
}
|
}
|
||||||
|
|
||||||
ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) => {
|
ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user