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('음악퀴즈를 찾을 수 없습니다.')