Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f554559be9 | |||
| 8c0226d3f9 | |||
| 674b9e7c87 | |||
| 4a76c09f3a | |||
| 48aec4e144 | |||
| 21eadb3b20 | |||
| fa5da6d052 |
@@ -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>'
|
||||
: '') +
|
||||
|
||||
@@ -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()
|
||||
})()
|
||||
|
||||
@@ -181,12 +181,15 @@ main {
|
||||
overflow-y: auto;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
/* 약관 본문은 더 진한 순백으로(제목/굵은글씨/목록). */
|
||||
color: #fff;
|
||||
}
|
||||
.agreementBody h1, .agreementBody h2, .agreementBody h3 { margin: 12px 0 6px; }
|
||||
.agreementBody h1 { font-size: 17px; }
|
||||
.agreementBody h2 { font-size: 15px; }
|
||||
.agreementBody h3 { font-size: 14px; }
|
||||
.agreementBody p { margin: 6px 0; }
|
||||
/* 본문 문단은 기존 회색(--text-muted) 대신 이전 흰색 톤(#e6edf3)으로 올린다. */
|
||||
.agreementBody p { margin: 6px 0; color: #e6edf3; }
|
||||
.agreementBody ul, .agreementBody ol { margin: 6px 0; padding-left: 22px; }
|
||||
.agreementBody li { margin: 2px 0; }
|
||||
.agreementBody code { background: rgba(255,255,255,0.08); padding: 1px 4px; border-radius: 3px; font-family: 'Consolas', monospace; }
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
},
|
||||
"step3": {
|
||||
"heading": "완료",
|
||||
"message": "리소스팩 설치를 완료했습니다."
|
||||
"message": "사용자 동의하에 리소스팩이 설치되었습니다.",
|
||||
"applyNotice": "리소스팩은 직접 적용해주세요."
|
||||
},
|
||||
"install": {
|
||||
"errorMessage": "설치 중 오류가 발생했습니다: {{message}}",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"intro": {
|
||||
"heading": "시작하기 전에",
|
||||
"message": "마인크래프트 런처를 끄고 시작해주세요."
|
||||
},
|
||||
"common": {
|
||||
"back": "이전",
|
||||
"next": "다음",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "minecraft-music-quiz-installer",
|
||||
"version": "0.3.9",
|
||||
"version": "0.3.14",
|
||||
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
|
||||
"main": "dist/installer/main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -39,9 +39,15 @@ async function migrateLegacyExe(target: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** BtbN/FFmpeg-Builds 의 win64-gpl 빌드. zip 내부에 bin/ffmpeg.exe 가 들어 있음. */
|
||||
/**
|
||||
* BtbN/FFmpeg-Builds 의 win64-gpl 빌드. zip 내부에 bin/ffmpeg.exe 가 들어 있음.
|
||||
* `releases/download/latest/` 형태(=항상 최신 자산이 붙어 있는 롤링 `latest` 태그)를
|
||||
* 쓴다. `releases/latest/download/`(GitHub 의 "최신 릴리스" 자동 포인터)는 갓
|
||||
* 만들어진 `autobuild-<날짜>` 릴리스로 리다이렉트되는데, 그 릴리스에 자산이 아직
|
||||
* 업로드되지 않았거나 없으면 HTTP 404 가 나서 ffmpeg 설치가 실패한다.
|
||||
*/
|
||||
const FFMPEG_ZIP_URL =
|
||||
'https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip'
|
||||
'https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip'
|
||||
|
||||
let installPromise: Promise<string> | null = null
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -21,16 +21,18 @@ function aliasListSnbt(aliases: string[]): string {
|
||||
return `[${parts.join(',')}]`
|
||||
}
|
||||
|
||||
/** 한 곡(MusicListEntry) → `{title:"...", author:"...", alias:[...], description:"...", volume:1.0}` SNBT. */
|
||||
/** 한 곡(MusicListEntry) → `{volume:0.5, title:"...", author:"...", alias:[...], description:"...", video:"..."}` SNBT. */
|
||||
function entrySnbt(entry: MusicListEntry): string {
|
||||
const title = escapeSnbtString(entry.title ?? '')
|
||||
// launcher 의 artist → 데이터팩 SNBT 의 author. 빈 값은 빈 문자열로 그대로 둔다.
|
||||
const author = escapeSnbtString(entry.artist ?? '')
|
||||
const alias = aliasListSnbt(entry.aliases ?? [])
|
||||
const description = escapeSnbtString(entry.description ?? '')
|
||||
// launcher 가 생성하는 항목에는 volume 기본값 1.0 을 항상 넣는다.
|
||||
// video = 곡의 유튜브 영상 주소(MusicListEntry.url).
|
||||
const video = escapeSnbtString(entry.url ?? '')
|
||||
// launcher 가 생성하는 항목에는 volume 기본값 0.5 를 항상 넣는다.
|
||||
// 운영자는 생성된 mcfunction 에서 곡별로 직접 값을 바꿔 사용한다.
|
||||
return `{title:"${title}", author:"${author}", alias:${alias}, description:"${description}", volume:1.0}`
|
||||
return `{volume:0.5, title:"${title}", author:"${author}", alias:${alias}, description:"${description}", video:"${video}"}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,11 +43,11 @@ function entrySnbt(entry: MusicListEntry): string {
|
||||
export function buildSongsMcfunction(list: PackList): string {
|
||||
const lines: string[] = []
|
||||
lines.push('# 곡 한 개 = 한 줄.')
|
||||
lines.push('# 필수 — title, author, alias, description')
|
||||
lines.push('# 필수 — title, author, alias, description, video')
|
||||
lines.push('# 선택 — volume (이 곡만의 /playsound 음량. 미지정시 init/config.mcfunction')
|
||||
lines.push('# 의 audio.volume 사용)')
|
||||
lines.push('# 곡 순서가 리소스팩의 track_NN / cover_NN 인덱스와 1:1 매칭된다.')
|
||||
lines.push('# 예) {title:"Quiet Song", author:"...", alias:[...], description:"...", volume:1.0}')
|
||||
lines.push('# 예) {volume:0.5, title:"Quiet Song", author:"...", alias:[...], description:"...", video:"..."}')
|
||||
lines.push('data modify storage mq:main songs set value []')
|
||||
for (const entry of list.music) {
|
||||
lines.push(`data modify storage mq:main songs append value ${entrySnbt(entry)}`)
|
||||
|
||||
Reference in New Issue
Block a user