From e31c6ed55baf3ee50b31c3dd3d6ad9492561c9f1 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 13 May 2026 01:01:00 +0900 Subject: [PATCH] =?UTF-8?q?installer:=203-3=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EB=B0=8F=20UPnP=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- installer/renderer.js | 25 ++++----- src/installer/main.ts | 127 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 119 insertions(+), 33 deletions(-) diff --git a/installer/renderer.js b/installer/renderer.js index 7fa53e7..6cc4c2b 100644 --- a/installer/renderer.js +++ b/installer/renderer.js @@ -260,20 +260,22 @@ function renderSubStep33(host, back, done) { '

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

' + '

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

' + '
대기 중
' + - '' + '' + '
' - 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 diff --git a/src/installer/main.ts b/src/installer/main.ts index a57db53..bea014a 100644 --- a/src/installer/main.ts +++ b/src/installer/main.ts @@ -475,32 +475,99 @@ function readBody(req: http.IncomingMessage): Promise { ipcMain.handle('server:portForward', async (_event, port: number): Promise => { const targetPort = Number.isFinite(port) && port > 0 ? port : 25565 - const externalIp = await detectExternalIp() - if (await testPortReachable(externalIp, targetPort)) { + sendLog(`포트포워딩 점검 시작: 포트 ${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} 접근 확인됨. 포트포워딩 됨.`) return { status: 'preForwarded', externalIp, port: targetPort } } + + sendLog(`UPnP로 포트 ${targetPort} 자동 개방 시도(TCP)...`) try { 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} 자동 개방 완료.`) return { status: 'upnpOk', externalIp, port: targetPort } } - sendLog('UPnP 개방은 시도했지만 외부 접근이 확인되지 않았습니다.') - return { status: 'upnpFailed', externalIp, port: targetPort, message: '직접 포트포워딩을 해주세요.' } + const reason = externalIp + ? 'UPnP 매핑은 등록됐지만 외부 접근이 확인되지 않았습니다(ISP 차단 또는 이중 NAT 가능).' + : '외부 IP를 확인할 수 없어 접근 검증을 건너뛰었습니다. UPnP 매핑은 등록됐을 수 있습니다.' + sendLog(reason) + return { status: 'upnpFailed', externalIp, port: targetPort, message: reason + ' 직접 포트포워딩을 해주세요.' } } catch (error) { - sendLog(`UPnP 시도 실패: ${(error as Error).message}`) - return { status: 'upnpFailed', externalIp, port: targetPort, message: '직접 포트포워딩을 해주세요.' } + const msg = (error as Error).message || String(error) + sendLog(`UPnP 시도 실패: ${msg}`) + return { + status: 'upnpFailed', + externalIp, + port: targetPort, + message: `UPnP 실패: ${msg}. 라우터에서 UPnP가 꺼져 있을 수 있습니다. 직접 포트포워딩을 해주세요.` + } } }) -async function detectExternalIp(): Promise { - try { - const buffer = await fetchBuffer('https://api.ipify.org') - return buffer.toString('utf8').trim() - } catch { - return '' +async function detectExternalIpHttp(): Promise { + const endpoints = ['https://api.ipify.org', 'https://ifconfig.me/ip', 'https://icanhazip.com'] + for (const url of endpoints) { + try { + const buffer = await fetchBuffer(url) + const ip = buffer.toString('utf8').trim() + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) return ip + } catch { + // try next + } } + return '' +} + +function detectExternalIpUpnp(): Promise { + return new Promise((resolve) => { + let settled = false + const finish = (ip: string) => { if (!settled) { settled = true; resolve(ip) } } + let client: ReturnType | 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 { @@ -524,15 +591,39 @@ function testPortReachable(host: string, port: number): Promise { function openPortViaUpnp(port: number): Promise { return new Promise((resolve, reject) => { - const client = natUpnp.createClient() - client.portMapping({ public: port, private: port, ttl: 0, description: 'MusicQuiz Server', protocol: 'tcp' }, (error) => { - client.close() - if (error) reject(error) + let settled = false + const done = (err?: Error) => { + if (settled) return + settled = true + if (err) reject(err) else resolve() - }) + } + let client: ReturnType | 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 { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) => { const pack = state.packs.get(payload.packKey) if (!pack) throw new Error('음악퀴즈를 찾을 수 없습니다.')