3 Commits

Author SHA1 Message Date
f554559be9 installer: stop treating ifconfig.co failure as 'port closed'
포트 1차 점검(probePortFromOutside) 오탐 수정.
- 임시 리스너에 인바운드가 안 왔다는 이유만으로 false(닫힘) 단정하던 로직 제거.
  ifconfig.co 가 타임아웃/레이트리밋으로 실패하면 외부에서 연결 시도 자체가
  없었던 것이라 '리스너 미도달'은 무의미하고, 인바운드 수신 주체가 마크 서버가
  아니라 설치기 프로세스라 Windows 방화벽이 설치기만 막아도 미도달이 된다.
  외부 판정이 없으면 reachable=null(확인 불가)로 남겨 UPnP 시도/안내로 넘긴다.
- ifconfig.co 일시 실패 대비 1회 재시도 추가(fetchIfconfigCoPortOnce + 래퍼).
v0.3.14.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-24 21:24:12 +09:00
8c0226d3f9 installer: fix UPnP PowerShell parse error (^| -> | inside quotes)
run.bat 의 UPnP 등록/해제 PowerShell 명령에서 파이프를 cmd식 ^| 로
이스케이프했는데, 이 파이프는 powershell -Command "..." 의 큰따옴표 안이라
cmd 가 이미 리터럴로 넘긴다. 그래서 PowerShell 이 ^ 를 "예기치 않은 토큰"
으로 파싱 실패(서버는 정상 기동하나 콘솔에 에러 출력, UPnP 매핑도 실패).
^| -> | 로 교정. for/f 의 2^>nul(=cmd 레벨 리다이렉트)은 그대로 유지. v0.3.13.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-24 17:09:39 +09:00
674b9e7c87 installers: add intro notice (main) and finish notice (rp)
- 메인 설치기: 첫 페이지로 "마인크래프트 런처를 끄고 시작해주세요" 안내(renderIntro)
  추가 후 다음 버튼으로 step1 진입.
- 리소스팩 설치기: 완료(step3) 페이지 문구를 "사용자 동의하에 리소스팩이
  설치되었습니다." + "리소스팩은 직접 적용해주세요." 로 변경.
v0.3.12.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-23 23:02:38 +09:00
6 changed files with 48 additions and 8 deletions

View File

@@ -534,6 +534,7 @@ function renderStep3() {
section.innerHTML =
'<h2>' + escapeHtml(tt('step3.heading')) + '</h2>' +
'<p class="formMessage">' + escapeHtml(tt('step3.message')) + '</p>' +
'<p class="formMessage">' + escapeHtml(tt('step3.applyNotice')) + '</p>' +
(state.resourcepackPath
? '<p class="formMessage"><code>' + escapeHtml(state.resourcepackPath) + '</code></p>'
: '') +

View File

@@ -96,6 +96,20 @@ function clearPage() {
pageHost.innerHTML = ''
}
// 첫 진입 안내 페이지: 마인크래프트 런처를 끄고 시작하도록 안내.
function renderIntro() {
setActiveStep(1)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + tt('intro.heading') + '</h2>' +
'<p class="formMessage">' + tt('intro.message') + '</p>' +
'<div class="actionRow"><button class="primaryBtn" id="introNext">' + tt('common.next') + '</button></div>'
pageHost.appendChild(section)
section.querySelector('#introNext').addEventListener('click', renderStep1)
}
function renderStep1() {
setActiveStep(1)
clearPage()
@@ -972,5 +986,5 @@ function escapeHtml(s) {
I18N = (await installerApi.loadLocale()) || {}
} catch (_) { I18N = {} }
applyStaticI18n()
renderStep1()
renderIntro()
})()

View File

@@ -63,7 +63,8 @@
},
"step3": {
"heading": "완료",
"message": "리소스팩 설치를 완료했습니다."
"message": "사용자 동의하에 리소스팩 설치되었습니다.",
"applyNotice": "리소스팩은 직접 적용해주세요."
},
"install": {
"errorMessage": "설치 중 오류가 발생했습니다: {{message}}",

View File

@@ -1,4 +1,8 @@
{
"intro": {
"heading": "시작하기 전에",
"message": "마인크래프트 런처를 끄고 시작해주세요."
},
"common": {
"back": "이전",
"next": "다음",

View File

@@ -1,6 +1,6 @@
{
"name": "minecraft-music-quiz-installer",
"version": "0.3.11",
"version": "0.3.14",
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
"main": "dist/installer/main.js",
"scripts": {

View File

@@ -657,13 +657,13 @@ async function injectUpnpToRunBat(installPath: string): Promise<void> {
'for /f "tokens=2 delims==" %%a in (\'findstr /b /c:"server-port=" server.properties 2^>nul\') do set "_MQ_PORT=%%a"',
'set "_MQ_PORT=%_MQ_PORT: =%"',
'echo [MusicQuiz] UPnP 등록 시도: TCP %_MQ_PORT%',
'powershell -NoProfile -Command "$port=[int]$env:_MQ_PORT; $ip=(Get-NetIPAddress -AddressFamily IPv4 -PrefixOrigin Dhcp,Manual -ErrorAction SilentlyContinue ^| Where-Object {$_.IPAddress -notlike \'169.254.*\' -and $_.IPAddress -ne \'127.0.0.1\'} ^| Select-Object -First 1).IPAddress; if (-not $ip) { Write-Host \'[MusicQuiz] 로컬 IPv4 검색 실패\'; exit 1 }; try { $u = New-Object -ComObject HNetCfg.NATUPnP.1; $c=$u.StaticPortMappingCollection; if ($c) { try { $c.Remove($port,\'TCP\') ^| Out-Null } catch {}; $c.Add($port,\'TCP\',$port,$ip,$true,\'MusicQuiz Minecraft Server\') ^| Out-Null; Write-Host (\'[MusicQuiz] UPnP 등록 성공: \' + $ip + \':\' + $port + \' TCP\') } else { Write-Host \'[MusicQuiz] UPnP 컬렉션 사용 불가(라우터 UPnP 꺼짐?)\' } } catch { Write-Host (\'[MusicQuiz] UPnP 등록 실패: \' + $_.Exception.Message) }"'
'powershell -NoProfile -Command "$port=[int]$env:_MQ_PORT; $ip=(Get-NetIPAddress -AddressFamily IPv4 -PrefixOrigin Dhcp,Manual -ErrorAction SilentlyContinue | Where-Object {$_.IPAddress -notlike \'169.254.*\' -and $_.IPAddress -ne \'127.0.0.1\'} | Select-Object -First 1).IPAddress; if (-not $ip) { Write-Host \'[MusicQuiz] 로컬 IPv4 검색 실패\'; exit 1 }; try { $u = New-Object -ComObject HNetCfg.NATUPnP.1; $c=$u.StaticPortMappingCollection; if ($c) { try { $c.Remove($port,\'TCP\') | Out-Null } catch {}; $c.Add($port,\'TCP\',$port,$ip,$true,\'MusicQuiz Minecraft Server\') | Out-Null; Write-Host (\'[MusicQuiz] UPnP 등록 성공: \' + $ip + \':\' + $port + \' TCP\') } else { Write-Host \'[MusicQuiz] UPnP 컬렉션 사용 불가(라우터 UPnP 꺼짐?)\' } } catch { Write-Host (\'[MusicQuiz] UPnP 등록 실패: \' + $_.Exception.Message) }"'
]
const removeBlock = [
'REM 서버 종료 후: UPnP 매핑 해제.',
'echo [MusicQuiz] UPnP 해제 시도: TCP %_MQ_PORT%',
'powershell -NoProfile -Command "$port=[int]$env:_MQ_PORT; try { $u = New-Object -ComObject HNetCfg.NATUPnP.1; $c=$u.StaticPortMappingCollection; if ($c) { $c.Remove($port,\'TCP\') ^| Out-Null; Write-Host (\'[MusicQuiz] UPnP 해제 완료: TCP \' + $port) } } catch { Write-Host (\'[MusicQuiz] UPnP 해제 실패: \' + $_.Exception.Message) }"'
'powershell -NoProfile -Command "$port=[int]$env:_MQ_PORT; try { $u = New-Object -ComObject HNetCfg.NATUPnP.1; $c=$u.StaticPortMappingCollection; if ($c) { $c.Remove($port,\'TCP\') | Out-Null; Write-Host (\'[MusicQuiz] UPnP 해제 완료: TCP \' + $port) } } catch { Write-Host (\'[MusicQuiz] UPnP 해제 실패: \' + $_.Exception.Message) }"'
]
const merged: string[] = []
@@ -1063,8 +1063,14 @@ async function probePortFromOutside(
details.push(t('log.detailIfconfigFail', { error: (externalResult as { error: string }).error }))
}
// 임시 리스너가 떴고 외부 서비스도 닿지 않았다면 명확한 false.
if (reachable === null && listenerBound && !gotInboundConnection) reachable = false
// '닫힘(false)' 판정은 외부 점검 서비스(ifconfig.co)가 명시적으로 reachable=false
// 돌려준 경우에만 인정한다(위 분기에서 처리). 임시 리스너에 인바운드가 안 왔다는 사실만으로는
// false 로 단정하지 않는다:
// - ifconfig.co 가 타임아웃/레이트리밋으로 실패하면 애초에 외부에서 연결을 시도한 적이 없으므로
// '리스너 미도달'은 아무 의미가 없다(과거엔 이 경우를 false 로 오탐했다).
// - 인바운드를 받는 주체가 마크 서버가 아니라 설치기(node/electron) 프로세스라, Windows 방화벽이
// 설치기 인바운드만 막아도 포워딩이 정상이어도 '리스너 미도달'이 된다.
// 따라서 외부 판정이 없으면 reachable=null(확인 불가)로 남겨 UPnP 시도/안내 단계로 넘긴다.
return {
reachable,
@@ -1073,7 +1079,21 @@ async function probePortFromOutside(
}
}
function fetchIfconfigCoPort(port: number): Promise<{ ok: true; reachable: boolean | null; ip: string } | { ok: false; error: string }> {
type IfconfigPortResult = { ok: true; reachable: boolean | null; ip: string } | { ok: false; error: string }
// ifconfig.co 는 간헐적으로 타임아웃/레이트리밋(429)을 낸다. 실패를 곧장 '확인 불가'로
// 넘기기 전에 한 번 더 시도해서 일시적 실패로 인한 오탐/미확인을 줄인다.
async function fetchIfconfigCoPort(port: number): Promise<IfconfigPortResult> {
let last: IfconfigPortResult = { ok: false, error: 'no attempt' }
for (let attempt = 0; attempt < 2; attempt++) {
last = await fetchIfconfigCoPortOnce(port)
if (last.ok) return last
if (attempt === 0) await sleep(1500)
}
return last
}
function fetchIfconfigCoPortOnce(port: number): Promise<IfconfigPortResult> {
return new Promise((resolve) => {
const target = new URL(`https://ifconfig.co/port/${port}`)
const req = https.get(target, {