i18n: 리소스팩 설치기 UI 문구를 locales/installer-rp/ko-kr.json 으로 분리
- main/preload/ytdlp/ffmpeg/music/images/pack/renderer 전반에서 로그·에러·진행 메시지 문자열을 locales/installer-rp/ko-kr.json 사전 키로 교체 - preload 에 loadLocale 추가, main 에 rp:i18n:dict IPC 핸들러 추가 - 패키징된 .exe 에서도 한국어 사전이 적용되도록 electron-builder.yml 의 extraResources 에 locales/ 폴더 추가 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -9,11 +9,17 @@ files:
|
|||||||
- package.json
|
- package.json
|
||||||
# 빌드 시점의 .env 를 설치기 옆에 함께 배포(없으면 조용히 패스).
|
# 빌드 시점의 .env 를 설치기 옆에 함께 배포(없으면 조용히 패스).
|
||||||
# 패키징 후 운영자가 resources/.env 만 교체해서 도메인을 바꿀 수도 있음.
|
# 패키징 후 운영자가 resources/.env 만 교체해서 도메인을 바꿀 수도 있음.
|
||||||
|
# locales/ 폴더는 i18n.ts 가 process.resourcesPath/locales/<component>/ko-kr.json
|
||||||
|
# 을 찾아 로드하므로, 빌드된 .exe 에서도 한국어 사전이 적용되도록 함께 배포.
|
||||||
extraResources:
|
extraResources:
|
||||||
- from: .
|
- from: .
|
||||||
to: .
|
to: .
|
||||||
filter:
|
filter:
|
||||||
- .env
|
- .env
|
||||||
|
- from: locales
|
||||||
|
to: locales
|
||||||
|
filter:
|
||||||
|
- "**/*"
|
||||||
win:
|
win:
|
||||||
target: nsis
|
target: nsis
|
||||||
artifactName: ${productName}-${version}-Setup.${ext}
|
artifactName: ${productName}-${version}-Setup.${ext}
|
||||||
|
|||||||
@@ -10,6 +10,25 @@ const state = {
|
|||||||
resourcepackPath: ''
|
resourcepackPath: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let I18N = {}
|
||||||
|
|
||||||
|
function tt(key, params) {
|
||||||
|
var parts = String(key).split('.')
|
||||||
|
var cur = I18N
|
||||||
|
for (var i = 0; i < parts.length; i++) {
|
||||||
|
if (cur && typeof cur === 'object' && parts[i] in cur) {
|
||||||
|
cur = cur[parts[i]]
|
||||||
|
} else {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof cur !== 'string') return key
|
||||||
|
if (!params) return cur
|
||||||
|
return cur.replace(/\{\{\s*(\w+)\s*\}\}/g, function (_m, name) {
|
||||||
|
return name in params ? String(params[name]) : '{{' + name + '}}'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const pageHost = document.getElementById('pageHost')
|
const pageHost = document.getElementById('pageHost')
|
||||||
const stepIndicator = document.getElementById('stepIndicator')
|
const stepIndicator = document.getElementById('stepIndicator')
|
||||||
const logViewer = document.getElementById('logViewer')
|
const logViewer = document.getElementById('logViewer')
|
||||||
@@ -20,10 +39,10 @@ logToggle.addEventListener('click', function () {
|
|||||||
logViewer.classList.toggle('collapsed')
|
logViewer.classList.toggle('collapsed')
|
||||||
if (logViewer.classList.contains('collapsed')) {
|
if (logViewer.classList.contains('collapsed')) {
|
||||||
logViewer.style.height = '36px'
|
logViewer.style.height = '36px'
|
||||||
logToggle.textContent = '펼치기'
|
logToggle.textContent = tt('logViewer.expand')
|
||||||
} else {
|
} else {
|
||||||
logViewer.style.height = ''
|
logViewer.style.height = ''
|
||||||
logToggle.textContent = '접기'
|
logToggle.textContent = tt('logViewer.collapse')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -33,6 +52,22 @@ api.onLog(function (line) {
|
|||||||
logBody.scrollTop = logBody.scrollHeight
|
logBody.scrollTop = logBody.scrollHeight
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function applyStaticI18n() {
|
||||||
|
document.title = tt('app.title')
|
||||||
|
var h1 = document.querySelector('.appHeader h1')
|
||||||
|
if (h1) h1.textContent = tt('app.title')
|
||||||
|
var stepLis = stepIndicator.querySelectorAll('li')
|
||||||
|
stepLis.forEach(function (item) {
|
||||||
|
var idx = item.getAttribute('data-step')
|
||||||
|
if (idx === '1') item.textContent = tt('stepIndicator.step1')
|
||||||
|
else if (idx === '2') item.textContent = tt('stepIndicator.step2')
|
||||||
|
else if (idx === '3') item.textContent = tt('stepIndicator.step3')
|
||||||
|
})
|
||||||
|
var logH2 = logViewer.querySelector('header h2')
|
||||||
|
if (logH2) logH2.textContent = tt('logViewer.heading')
|
||||||
|
logToggle.textContent = tt('logViewer.collapse')
|
||||||
|
}
|
||||||
|
|
||||||
function setActiveStep(step) {
|
function setActiveStep(step) {
|
||||||
stepIndicator.querySelectorAll('li').forEach(function (item) {
|
stepIndicator.querySelectorAll('li').forEach(function (item) {
|
||||||
var index = Number(item.getAttribute('data-step'))
|
var index = Number(item.getAttribute('data-step'))
|
||||||
@@ -51,9 +86,9 @@ function renderStep1() {
|
|||||||
var section = document.createElement('section')
|
var section = document.createElement('section')
|
||||||
section.className = 'page'
|
section.className = 'page'
|
||||||
section.innerHTML =
|
section.innerHTML =
|
||||||
'<h2>1단계. 음악퀴즈 선택</h2>' +
|
'<h2>' + escapeHtml(tt('step1.heading')) + '</h2>' +
|
||||||
'<div id="packList" class="cardChoice"><p class="formMessage">목록을 불러오는 중...</p></div>' +
|
'<div id="packList" class="cardChoice"><p class="formMessage">' + escapeHtml(tt('common.loading')) + '</p></div>' +
|
||||||
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>다음</button></div>'
|
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>' + escapeHtml(tt('common.next')) + '</button></div>'
|
||||||
pageHost.appendChild(section)
|
pageHost.appendChild(section)
|
||||||
var listEl = section.querySelector('#packList')
|
var listEl = section.querySelector('#packList')
|
||||||
var nextBtn = section.querySelector('#next')
|
var nextBtn = section.querySelector('#next')
|
||||||
@@ -61,7 +96,7 @@ function renderStep1() {
|
|||||||
function renderList() {
|
function renderList() {
|
||||||
listEl.innerHTML = ''
|
listEl.innerHTML = ''
|
||||||
if (state.packs.length === 0) {
|
if (state.packs.length === 0) {
|
||||||
listEl.innerHTML = '<p class="formMessage error">등록된 음악퀴즈가 없습니다.</p>'
|
listEl.innerHTML = '<p class="formMessage error">' + escapeHtml(tt('common.noPacks')) + '</p>'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
state.packs.forEach(function (pack) {
|
state.packs.forEach(function (pack) {
|
||||||
@@ -69,11 +104,14 @@ function renderStep1() {
|
|||||||
card.type = 'button'
|
card.type = 'button'
|
||||||
card.className = 'choiceCard'
|
card.className = 'choiceCard'
|
||||||
if (state.selectedKey === pack.key) card.classList.add('selected')
|
if (state.selectedKey === pack.key) card.classList.add('selected')
|
||||||
var verLabel = pack.mcVersion ? '마인크래프트 ' + escapeHtml(pack.mcVersion) + ' · ' : ''
|
var verLabel = pack.mcVersion
|
||||||
|
? escapeHtml(tt('common.mcVersionLabel', { version: pack.mcVersion }))
|
||||||
|
: ''
|
||||||
card.innerHTML =
|
card.innerHTML =
|
||||||
'<strong>' + escapeHtml(pack.name) + '</strong>' +
|
'<strong>' + escapeHtml(pack.name) + '</strong>' +
|
||||||
'<small>' + verLabel +
|
'<small>' + verLabel +
|
||||||
'음악 ' + pack.list.music.length + '곡 · 사진 ' + pack.list.images.length + '장</small>'
|
escapeHtml(tt('common.trackImageCount', { music: pack.list.music.length, image: pack.list.images.length })) +
|
||||||
|
'</small>'
|
||||||
card.addEventListener('click', function () {
|
card.addEventListener('click', function () {
|
||||||
state.selectedKey = pack.key
|
state.selectedKey = pack.key
|
||||||
nextBtn.disabled = false
|
nextBtn.disabled = false
|
||||||
@@ -88,7 +126,7 @@ function renderStep1() {
|
|||||||
api.selectPack(state.selectedKey).then(function () {
|
api.selectPack(state.selectedKey).then(function () {
|
||||||
renderStep2()
|
renderStep2()
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
alert(err.message || '선택 실패')
|
alert(err.message || tt('common.selectFailed'))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -96,7 +134,9 @@ function renderStep1() {
|
|||||||
state.packs = packs || []
|
state.packs = packs || []
|
||||||
renderList()
|
renderList()
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
listEl.innerHTML = '<p class="formMessage error">목록 로드 실패: ' + escapeHtml(err.message || '') + '</p>'
|
listEl.innerHTML = '<p class="formMessage error">' +
|
||||||
|
escapeHtml(tt('common.listLoadFailed', { message: err.message || '' })) +
|
||||||
|
'</p>'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,30 +155,29 @@ function renderStep2() {
|
|||||||
var section = document.createElement('section')
|
var section = document.createElement('section')
|
||||||
section.className = 'page'
|
section.className = 'page'
|
||||||
section.innerHTML =
|
section.innerHTML =
|
||||||
'<h2>2단계. 리소스팩 설치</h2>' +
|
'<h2>' + escapeHtml(tt('step2.heading')) + '</h2>' +
|
||||||
'<p class="formMessage">음악·사진을 받아 리소스팩을 만들고 ' +
|
'<p class="formMessage">' + tt('step2.description') + '</p>' +
|
||||||
'<code>%appdata%/.mc_custom/resourcepacks/</code> 에 자동 설치합니다.</p>' +
|
|
||||||
'<div class="prepRow">' +
|
'<div class="prepRow">' +
|
||||||
' <span class="prepChip" id="chip-ytdlp">yt-dlp 준비</span>' +
|
' <span class="prepChip" id="chip-ytdlp">' + escapeHtml(tt('step2.chipYtdlp')) + '</span>' +
|
||||||
' <span class="prepChip" id="chip-ffmpeg">ffmpeg 준비</span>' +
|
' <span class="prepChip" id="chip-ffmpeg">' + escapeHtml(tt('step2.chipFfmpeg')) + '</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="progressSection">' +
|
'<div class="progressSection">' +
|
||||||
' <h3>음악 다운로드</h3>' +
|
' <h3>' + escapeHtml(tt('step2.musicHeading')) + '</h3>' +
|
||||||
' <div class="sectionSub" id="music-sub">' + musicTotal + '곡</div>' +
|
' <div class="sectionSub" id="music-sub">' + escapeHtml(tt('step2.musicSub', { count: musicTotal })) + '</div>' +
|
||||||
' <div class="progressGrid" id="musicGrid"></div>' +
|
' <div class="progressGrid" id="musicGrid"></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="progressSection">' +
|
'<div class="progressSection">' +
|
||||||
' <h3>사진 다운로드</h3>' +
|
' <h3>' + escapeHtml(tt('step2.imageHeading')) + '</h3>' +
|
||||||
' <div class="sectionSub" id="image-sub">' + imageTotal + '장</div>' +
|
' <div class="sectionSub" id="image-sub">' + escapeHtml(tt('step2.imageSub', { count: imageTotal })) + '</div>' +
|
||||||
' <div class="progressGrid" id="imageGrid"></div>' +
|
' <div class="progressGrid" id="imageGrid"></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="progressSection">' +
|
'<div class="progressSection">' +
|
||||||
' <h3>리소스팩 빌드</h3>' +
|
' <h3>' + escapeHtml(tt('step2.packageHeading')) + '</h3>' +
|
||||||
' <div class="sectionSub" id="pkg-sub">대기 중…</div>' +
|
' <div class="sectionSub" id="pkg-sub">' + escapeHtml(tt('step2.packageWaiting')) + '</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="actionRow">' +
|
'<div class="actionRow">' +
|
||||||
' <span></span>' +
|
' <span></span>' +
|
||||||
' <button class="dangerBtn" id="cancel">취소</button>' +
|
' <button class="dangerBtn" id="cancel">' + escapeHtml(tt('common.cancel')) + '</button>' +
|
||||||
'</div>'
|
'</div>'
|
||||||
pageHost.appendChild(section)
|
pageHost.appendChild(section)
|
||||||
|
|
||||||
@@ -156,7 +195,7 @@ function renderStep2() {
|
|||||||
card.innerHTML =
|
card.innerHTML =
|
||||||
'<div class="cardTop"><span class="label">' + idx + '</span><span class="icon">○</span></div>' +
|
'<div class="cardTop"><span class="label">' + idx + '</span><span class="icon">○</span></div>' +
|
||||||
'<div class="bar"><span></span></div>' +
|
'<div class="bar"><span></span></div>' +
|
||||||
'<div class="pct">대기</div>'
|
'<div class="pct">' + escapeHtml(tt('step2.cardWaiting')) + '</div>'
|
||||||
return card
|
return card
|
||||||
}
|
}
|
||||||
for (var m = 1; m <= musicTotal; m++) musicGrid.appendChild(buildCard(m))
|
for (var m = 1; m <= musicTotal; m++) musicGrid.appendChild(buildCard(m))
|
||||||
@@ -172,17 +211,17 @@ function renderStep2() {
|
|||||||
var pct = card.querySelector('.pct')
|
var pct = card.querySelector('.pct')
|
||||||
var icon = card.querySelector('.icon')
|
var icon = card.querySelector('.icon')
|
||||||
if (status === 'done') {
|
if (status === 'done') {
|
||||||
if (pct) pct.textContent = '완료'
|
if (pct) pct.textContent = tt('step2.cardDone')
|
||||||
if (icon) icon.textContent = '✓'
|
if (icon) icon.textContent = '✓'
|
||||||
if (bar) bar.style.width = '100%'
|
if (bar) bar.style.width = '100%'
|
||||||
} else if (status === 'error') {
|
} else if (status === 'error') {
|
||||||
if (pct) pct.textContent = '실패'
|
if (pct) pct.textContent = tt('step2.cardError')
|
||||||
if (icon) icon.textContent = '✕'
|
if (icon) icon.textContent = '✕'
|
||||||
} else if (status === 'running') {
|
} else if (status === 'running') {
|
||||||
if (pct) pct.textContent = Math.round(percent) + '%'
|
if (pct) pct.textContent = Math.round(percent) + '%'
|
||||||
if (icon) icon.textContent = '⏳'
|
if (icon) icon.textContent = '⏳'
|
||||||
} else {
|
} else {
|
||||||
if (pct) pct.textContent = '대기'
|
if (pct) pct.textContent = tt('step2.cardWaiting')
|
||||||
if (icon) icon.textContent = '○'
|
if (icon) icon.textContent = '○'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,7 +248,9 @@ function renderStep2() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (payload.phase === 'package') {
|
if (payload.phase === 'package') {
|
||||||
pkgSub.textContent = payload.done ? '설치 완료' : (payload.message || '빌드 중…')
|
pkgSub.textContent = payload.done
|
||||||
|
? tt('step2.packageDone')
|
||||||
|
: (payload.message || tt('step2.packageBuilding'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -232,7 +273,7 @@ function renderStep2() {
|
|||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
state.installing = false
|
state.installing = false
|
||||||
if (stopProgress) stopProgress()
|
if (stopProgress) stopProgress()
|
||||||
alert('설치 실패: ' + ((err && err.message) || err))
|
alert(tt('common.installFailed', { message: (err && err.message) || err }))
|
||||||
renderStep1()
|
renderStep1()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -244,14 +285,14 @@ function renderStep3() {
|
|||||||
var section = document.createElement('section')
|
var section = document.createElement('section')
|
||||||
section.className = 'page'
|
section.className = 'page'
|
||||||
section.innerHTML =
|
section.innerHTML =
|
||||||
'<h2>3단계. 완료</h2>' +
|
'<h2>' + escapeHtml(tt('step3.heading')) + '</h2>' +
|
||||||
'<p class="formMessage">리소스팩 설치를 완료했습니다.</p>' +
|
'<p class="formMessage">' + escapeHtml(tt('step3.message')) + '</p>' +
|
||||||
(state.resourcepackPath
|
(state.resourcepackPath
|
||||||
? '<p class="formMessage"><code>' + escapeHtml(state.resourcepackPath) + '</code></p>'
|
? '<p class="formMessage"><code>' + escapeHtml(state.resourcepackPath) + '</code></p>'
|
||||||
: '') +
|
: '') +
|
||||||
'<div class="actionRow">' +
|
'<div class="actionRow">' +
|
||||||
' <button class="secondaryBtn" id="openFolder">리소스팩 폴더 열기</button>' +
|
' <button class="secondaryBtn" id="openFolder">' + escapeHtml(tt('common.openFolder')) + '</button>' +
|
||||||
' <button class="primaryBtn" id="finish">확인</button>' +
|
' <button class="primaryBtn" id="finish">' + escapeHtml(tt('common.confirm')) + '</button>' +
|
||||||
'</div>'
|
'</div>'
|
||||||
pageHost.appendChild(section)
|
pageHost.appendChild(section)
|
||||||
section.querySelector('#openFolder').addEventListener('click', function () {
|
section.querySelector('#openFolder').addEventListener('click', function () {
|
||||||
@@ -268,4 +309,8 @@ function escapeHtml(s) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
renderStep1()
|
;(async function () {
|
||||||
|
try { I18N = (await api.loadLocale()) || {} } catch (_) { I18N = {} }
|
||||||
|
applyStaticI18n()
|
||||||
|
renderStep1()
|
||||||
|
})()
|
||||||
|
|||||||
127
locales/installer-rp/ko-kr.json
Normal file
127
locales/installer-rp/ko-kr.json
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"title": "마인크래프트 음악퀴즈 리소스팩 간편설치기"
|
||||||
|
},
|
||||||
|
"stepIndicator": {
|
||||||
|
"step1": "1. 음악퀴즈",
|
||||||
|
"step2": "2. 설치",
|
||||||
|
"step3": "3. 완료"
|
||||||
|
},
|
||||||
|
"logViewer": {
|
||||||
|
"heading": "설치 로그",
|
||||||
|
"collapse": "접기",
|
||||||
|
"expand": "펼치기"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"next": "다음",
|
||||||
|
"cancel": "취소",
|
||||||
|
"confirm": "확인",
|
||||||
|
"openFolder": "리소스팩 폴더 열기",
|
||||||
|
"loading": "목록을 불러오는 중...",
|
||||||
|
"selectFailed": "선택 실패",
|
||||||
|
"listLoadFailed": "목록 로드 실패: {{message}}",
|
||||||
|
"installFailed": "설치 실패: {{message}}",
|
||||||
|
"noPacks": "등록된 음악퀴즈가 없습니다.",
|
||||||
|
"mcVersionLabel": "마인크래프트 {{version}} · ",
|
||||||
|
"trackImageCount": "음악 {{music}}곡 · 사진 {{image}}장",
|
||||||
|
"requestTimeout": "요청 시간 초과",
|
||||||
|
"tooManyRedirects": "redirect 가 너무 많습니다."
|
||||||
|
},
|
||||||
|
"step1": {
|
||||||
|
"heading": "1단계. 음악퀴즈 선택"
|
||||||
|
},
|
||||||
|
"step2": {
|
||||||
|
"heading": "2단계. 리소스팩 설치",
|
||||||
|
"description": "음악·사진을 받아 리소스팩을 만들고 <code>%appdata%/.mc_custom/resourcepacks/</code> 에 자동 설치합니다.",
|
||||||
|
"chipYtdlp": "yt-dlp 준비",
|
||||||
|
"chipFfmpeg": "ffmpeg 준비",
|
||||||
|
"musicHeading": "음악 다운로드",
|
||||||
|
"musicSub": "{{count}}곡",
|
||||||
|
"imageHeading": "사진 다운로드",
|
||||||
|
"imageSub": "{{count}}장",
|
||||||
|
"packageHeading": "리소스팩 빌드",
|
||||||
|
"packageWaiting": "대기 중…",
|
||||||
|
"packageBuilding": "빌드 중…",
|
||||||
|
"packageDone": "설치 완료",
|
||||||
|
"cardWaiting": "대기",
|
||||||
|
"cardDone": "완료",
|
||||||
|
"cardError": "실패"
|
||||||
|
},
|
||||||
|
"step3": {
|
||||||
|
"heading": "3단계. 완료",
|
||||||
|
"message": "리소스팩 설치를 완료했습니다."
|
||||||
|
},
|
||||||
|
"log": {
|
||||||
|
"manifestDownload": "manifest 다운로드: {{url}}",
|
||||||
|
"packDefFailed": "팩 정의 로드 실패 ({{file}}): {{message}} — mcVersion 폴백",
|
||||||
|
"listLoadFailed": "목록 로드 실패 ({{file}}): {{message}}",
|
||||||
|
"packsLoaded": "로드된 음악퀴즈: {{count}}개",
|
||||||
|
"packEntry": " - {{key}}: mc={{mc}} 베이스={{base}}",
|
||||||
|
"packEntryUnknownVersion": "?",
|
||||||
|
"packEntryNoBase": "(없음)",
|
||||||
|
"selectedPack": "선택: {{key}}",
|
||||||
|
"ytdlpPreparing": "yt-dlp 준비 중…",
|
||||||
|
"ytdlpPath": "yt-dlp 경로: {{path}}",
|
||||||
|
"ffmpegPreparing": "ffmpeg 준비 중…",
|
||||||
|
"ffmpegPath": "ffmpeg 경로: {{path}}",
|
||||||
|
"cpuDetected": "CPU 코어 {{cores}}개 감지 → 동시 다운로드 {{concurrency}}개",
|
||||||
|
"musicStart": "음악 다운로드 시작 ({{total}}곡, 동시 {{concurrency}}개, 시차 {{stagger}}ms)",
|
||||||
|
"musicTrackStart": "{{idx}}번 노래 다운로드 시작",
|
||||||
|
"musicTrackDone": "{{idx}}번 노래 완료: {{name}}",
|
||||||
|
"imageStart": "사진 다운로드 시작 ({{total}}장)",
|
||||||
|
"imageDownloading": "{{idx}}번 사진 다운로드 중…",
|
||||||
|
"imageDone": "{{idx}}번 사진 완료: {{name}}",
|
||||||
|
"baseDownload": "베이스 리소스팩 다운로드: {{path}}",
|
||||||
|
"baseUrl": " URL: {{url}}",
|
||||||
|
"baseReceived": "베이스 리소스팩 받음 ({{kb}} KB)",
|
||||||
|
"baseAbsent": "베이스 리소스팩 없음(resourcepackPath 빈 값) — 새 리소스팩으로 생성",
|
||||||
|
"buildingZip": "리소스팩 zip 빌드 중… ({{name}})",
|
||||||
|
"installComplete": "설치 완료: {{path}}",
|
||||||
|
"cancelRequested": "취소 요청됨. 실행 중 프로세스 {{count}}개 중단…",
|
||||||
|
"ytdlpExists": "yt-dlp.exe 이미 있음: {{path}}",
|
||||||
|
"ytdlpDownloading": "yt-dlp.exe 다운로드 중: {{url}}",
|
||||||
|
"ytdlpReady": "yt-dlp.exe 준비 완료: {{path}}",
|
||||||
|
"ffmpegExists": "ffmpeg.exe 이미 있음: {{path}}",
|
||||||
|
"ffmpegDownloading": "ffmpeg.exe 다운로드 중: {{url}}",
|
||||||
|
"ffmpegExtracting": "ffmpeg zip 압축 해제 중…",
|
||||||
|
"ffmpegReady": "ffmpeg.exe 준비 완료: {{path}}",
|
||||||
|
"baseExtract": "베이스 리소스팩 압축 해제: {{name}}",
|
||||||
|
"packFormatMatched": "pack_format = {{format}} (mcVersion {{matched}})",
|
||||||
|
"packFormatFallback": "pack_format = {{format}} (mcVersion \"{{version}}\" 매칭 실패, 최신 폴백)",
|
||||||
|
"soundsMerged": "기존 sounds.json 병합 ({{count}}개 항목)",
|
||||||
|
"ytdlpLine": "yt-dlp> {{line}}"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"ytdlpPreparing": "yt-dlp 준비 중",
|
||||||
|
"ffmpegPreparing": "ffmpeg 준비 중",
|
||||||
|
"ready": "준비 완료",
|
||||||
|
"cancelled": "취소됨",
|
||||||
|
"baseDownloading": "베이스 리소스팩 다운로드 중",
|
||||||
|
"buildingWithBase": "베이스에 음악·사진 추가 중",
|
||||||
|
"buildingZip": "zip 빌드 중",
|
||||||
|
"installComplete": "설치 완료"
|
||||||
|
},
|
||||||
|
"pack": {
|
||||||
|
"description": "음악퀴즈 리소스팩 - {{name}}"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"selectedPackNotFound": "선택한 음악퀴즈를 찾을 수 없습니다.",
|
||||||
|
"selectPackFirst": "음악퀴즈를 먼저 선택해주세요.",
|
||||||
|
"currentPackNotFound": "선택된 음악퀴즈를 찾을 수 없습니다.",
|
||||||
|
"cancelledByUser": "사용자가 설치를 취소했습니다.",
|
||||||
|
"musicDownloadFailed": "{{idx}}번 노래 다운로드 실패: {{message}}",
|
||||||
|
"imageDownloadFailed": "{{idx}}번 사진 다운로드 실패: {{message}}",
|
||||||
|
"imageNormalizeFailed": "{{idx}}번 사진 정규화 실패: {{message}}",
|
||||||
|
"baseDownloadFailed": "베이스 리소스팩 다운로드 실패: {{message}}",
|
||||||
|
"ytdlpSignal": "yt-dlp 가 신호 {{signal}} 로 종료됨",
|
||||||
|
"ytdlpExit": "yt-dlp 종료 코드 {{code}}: {{stderr}}",
|
||||||
|
"ytdlpNoStderr": "(stderr 없음)",
|
||||||
|
"ytdlpMissingOutput": "예상 출력파일이 없음: {{path}}",
|
||||||
|
"imageMetaUnknown": "이미지 크기를 읽지 못함",
|
||||||
|
"ytdlpVerifyFailed": "yt-dlp.exe 다운로드는 됐지만 실행 검증에 실패했습니다.",
|
||||||
|
"ytdlpInstallFailed": "yt-dlp.exe 자동 설치 실패: {{message}}",
|
||||||
|
"ffmpegNotInZip": "zip 내부에서 ffmpeg.exe 를 찾을 수 없습니다.",
|
||||||
|
"ffmpegVerifyFailed": "ffmpeg.exe 다운로드는 됐지만 실행 검증에 실패했습니다.",
|
||||||
|
"ffmpegInstallFailed": "ffmpeg.exe 자동 설치 실패: {{message}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import path from 'node:path'
|
|||||||
import https from 'node:https'
|
import https from 'node:https'
|
||||||
import http from 'node:http'
|
import http from 'node:http'
|
||||||
import { getMcCustomDir } from '../shared/paths.js'
|
import { getMcCustomDir } from '../shared/paths.js'
|
||||||
|
import { loadComponentI18n } from '../shared/i18n.js'
|
||||||
|
|
||||||
|
const { t } = loadComponentI18n('installer-rp')
|
||||||
|
|
||||||
// extract-zip 은 CommonJS 기본 export 라 require 로 받음.
|
// extract-zip 은 CommonJS 기본 export 라 require 로 받음.
|
||||||
const extractZip: (source: string, options: { dir: string }) => Promise<void> = require('extract-zip')
|
const extractZip: (source: string, options: { dir: string }) => Promise<void> = require('extract-zip')
|
||||||
@@ -31,7 +34,7 @@ export async function ensureFfmpegExe(
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const target = getFfmpegExePath()
|
const target = getFfmpegExePath()
|
||||||
if (await canExecute(target)) {
|
if (await canExecute(target)) {
|
||||||
log?.(`ffmpeg.exe 이미 있음: ${target}`)
|
log?.(t('log.ffmpegExists', { path: target }))
|
||||||
return target
|
return target
|
||||||
}
|
}
|
||||||
if (installPromise) return installPromise
|
if (installPromise) return installPromise
|
||||||
@@ -46,14 +49,14 @@ export async function ensureFfmpegExe(
|
|||||||
await fs.rm(zipPath, { force: true })
|
await fs.rm(zipPath, { force: true })
|
||||||
await fs.rm(extractDir, { recursive: true, force: true })
|
await fs.rm(extractDir, { recursive: true, force: true })
|
||||||
|
|
||||||
log?.(`ffmpeg.exe 다운로드 중: ${FFMPEG_ZIP_URL}`)
|
log?.(t('log.ffmpegDownloading', { url: FFMPEG_ZIP_URL }))
|
||||||
await downloadToFile(FFMPEG_ZIP_URL, zipPath)
|
await downloadToFile(FFMPEG_ZIP_URL, zipPath)
|
||||||
log?.('ffmpeg zip 압축 해제 중…')
|
log?.(t('log.ffmpegExtracting'))
|
||||||
await extractZip(zipPath, { dir: extractDir })
|
await extractZip(zipPath, { dir: extractDir })
|
||||||
|
|
||||||
const found = await findFile(extractDir, 'ffmpeg.exe')
|
const found = await findFile(extractDir, 'ffmpeg.exe')
|
||||||
if (!found) {
|
if (!found) {
|
||||||
throw new Error('zip 내부에서 ffmpeg.exe 를 찾을 수 없습니다.')
|
throw new Error(t('errors.ffmpegNotInZip'))
|
||||||
}
|
}
|
||||||
// 같은 파일시스템(=같은 드라이브) 일 가능성이 높아 rename 시도, 실패 시 copyFile fallback.
|
// 같은 파일시스템(=같은 드라이브) 일 가능성이 높아 rename 시도, 실패 시 copyFile fallback.
|
||||||
try {
|
try {
|
||||||
@@ -63,14 +66,15 @@ export async function ensureFfmpegExe(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ok = await probeVersion(target)
|
const ok = await probeVersion(target)
|
||||||
if (!ok) throw new Error('ffmpeg.exe 다운로드는 됐지만 실행 검증에 실패했습니다.')
|
if (!ok) throw new Error(t('errors.ffmpegVerifyFailed'))
|
||||||
log?.(`ffmpeg.exe 준비 완료: ${target}`)
|
log?.(t('log.ffmpegReady', { path: target }))
|
||||||
return target
|
return target
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
try { await fs.unlink(target) } catch { /* noop */ }
|
try { await fs.unlink(target) } catch { /* noop */ }
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'ffmpeg.exe 자동 설치 실패: ' +
|
t('errors.ffmpegInstallFailed', {
|
||||||
(err instanceof Error ? err.message : String(err))
|
message: err instanceof Error ? err.message : String(err)
|
||||||
|
})
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
// 임시 파일/폴더 정리
|
// 임시 파일/폴더 정리
|
||||||
@@ -114,7 +118,7 @@ async function findFile(root: string, name: string): Promise<string | null> {
|
|||||||
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
|
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (redirects > 8) {
|
if (redirects > 8) {
|
||||||
reject(new Error('redirect 가 너무 많습니다.'))
|
reject(new Error(t('common.tooManyRedirects')))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const lib = url.startsWith('https://') ? https : http
|
const lib = url.startsWith('https://') ? https : http
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import http from 'node:http'
|
|||||||
import https from 'node:https'
|
import https from 'node:https'
|
||||||
import { URL } from 'node:url'
|
import { URL } from 'node:url'
|
||||||
import sharp from 'sharp'
|
import sharp from 'sharp'
|
||||||
|
import { loadComponentI18n } from '../shared/i18n.js'
|
||||||
|
|
||||||
|
const { t } = loadComponentI18n('installer-rp')
|
||||||
|
|
||||||
/** painting variant 텍스처의 최대 변 길이(px). 슬롯 4x4 × 256px. */
|
/** painting variant 텍스처의 최대 변 길이(px). 슬롯 4x4 × 256px. */
|
||||||
const MAX_SIDE = 1024
|
const MAX_SIDE = 1024
|
||||||
@@ -30,7 +33,7 @@ export function ytIdFromUrl(url: string): string {
|
|||||||
function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
|
function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (redirects > 8) {
|
if (redirects > 8) {
|
||||||
reject(new Error('redirect 가 너무 많습니다.'))
|
reject(new Error(t('common.tooManyRedirects')))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const target = new URL(url)
|
const target = new URL(url)
|
||||||
@@ -56,7 +59,7 @@ function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
|
|||||||
res.on('end', () => resolve(Buffer.concat(chunks)))
|
res.on('end', () => resolve(Buffer.concat(chunks)))
|
||||||
})
|
})
|
||||||
req.on('error', reject)
|
req.on('error', reject)
|
||||||
req.on('timeout', () => req.destroy(new Error('요청 시간 초과')))
|
req.on('timeout', () => req.destroy(new Error(t('common.requestTimeout'))))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +94,7 @@ export async function normalizeToCover(buffer: Buffer, outPath: string): Promise
|
|||||||
const meta = await img.metadata()
|
const meta = await img.metadata()
|
||||||
const w = meta.width ?? 0
|
const w = meta.width ?? 0
|
||||||
const h = meta.height ?? 0
|
const h = meta.height ?? 0
|
||||||
if (w <= 0 || h <= 0) throw new Error('이미지 크기를 읽지 못함')
|
if (w <= 0 || h <= 0) throw new Error(t('errors.imageMetaUnknown'))
|
||||||
const s = Math.min(w, h)
|
const s = Math.min(w, h)
|
||||||
const left = Math.floor((w - s) / 2)
|
const left = Math.floor((w - s) / 2)
|
||||||
const top = Math.floor((h - s) / 2)
|
const top = Math.floor((h - s) / 2)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { Manifest, PackDefinition, PackList } from '../shared/types.js'
|
|||||||
import { normalizePackDefinition } from '../shared/store.js'
|
import { normalizePackDefinition } from '../shared/store.js'
|
||||||
import { getAppDataDir, getMcCustomDir } from '../shared/paths.js'
|
import { getAppDataDir, getMcCustomDir } from '../shared/paths.js'
|
||||||
import { loadEnv, getManifestUrl } from '../shared/env.js'
|
import { loadEnv, getManifestUrl } from '../shared/env.js'
|
||||||
|
import { loadComponentI18n } from '../shared/i18n.js'
|
||||||
import type { RpFetchedPack } from './types.js'
|
import type { RpFetchedPack } from './types.js'
|
||||||
import { ensureYtDlpExe } from './ytdlp.js'
|
import { ensureYtDlpExe } from './ytdlp.js'
|
||||||
import { ensureFfmpegExe } from './ffmpeg.js'
|
import { ensureFfmpegExe } from './ffmpeg.js'
|
||||||
@@ -19,6 +20,9 @@ import { downloadImage, normalizeToCover, coverFileName } from './images.js'
|
|||||||
import { buildResourcepackZip } from './pack.js'
|
import { buildResourcepackZip } from './pack.js'
|
||||||
|
|
||||||
loadEnv()
|
loadEnv()
|
||||||
|
const i18n = loadComponentI18n('installer-rp')
|
||||||
|
const t = i18n.t
|
||||||
|
export const localeDict = i18n.dict
|
||||||
|
|
||||||
interface RpInstallerState {
|
interface RpInstallerState {
|
||||||
manifestUrl: string
|
manifestUrl: string
|
||||||
@@ -154,7 +158,7 @@ function fetchBuffer(url: string): Promise<Buffer> {
|
|||||||
response.on('end', () => resolve(Buffer.concat(chunks)))
|
response.on('end', () => resolve(Buffer.concat(chunks)))
|
||||||
})
|
})
|
||||||
request.on('error', reject)
|
request.on('error', reject)
|
||||||
request.on('timeout', () => request.destroy(new Error('요청 시간 초과')))
|
request.on('timeout', () => request.destroy(new Error(t('common.requestTimeout'))))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +173,7 @@ ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promi
|
|||||||
state.manifestUrl = manifestUrlInput
|
state.manifestUrl = manifestUrlInput
|
||||||
state.baseUrl = deriveBaseUrl(manifestUrlInput)
|
state.baseUrl = deriveBaseUrl(manifestUrlInput)
|
||||||
}
|
}
|
||||||
sendLog(`manifest 다운로드: ${state.manifestUrl}`)
|
sendLog(t('log.manifestDownload', { url: state.manifestUrl }))
|
||||||
const manifest = await fetchJson<Manifest>(state.manifestUrl)
|
const manifest = await fetchJson<Manifest>(state.manifestUrl)
|
||||||
const results: RpFetchedPack[] = []
|
const results: RpFetchedPack[] = []
|
||||||
for (const entry of manifest.packs ?? []) {
|
for (const entry of manifest.packs ?? []) {
|
||||||
@@ -181,7 +185,7 @@ ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promi
|
|||||||
const [listRaw, packRaw] = await Promise.all([
|
const [listRaw, packRaw] = await Promise.all([
|
||||||
fetchJson<Partial<PackList>>(listUrl),
|
fetchJson<Partial<PackList>>(listUrl),
|
||||||
fetchJson<Partial<PackDefinition>>(packUrl).catch((err) => {
|
fetchJson<Partial<PackDefinition>>(packUrl).catch((err) => {
|
||||||
sendLog(`팩 정의 로드 실패 (${entry.file}): ${(err as Error).message} — mcVersion 폴백`)
|
sendLog(t('log.packDefFailed', { file: entry.file, message: (err as Error).message }))
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
@@ -202,31 +206,37 @@ ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promi
|
|||||||
list
|
list
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendLog(`목록 로드 실패 (${entry.file}): ${(error as Error).message}`)
|
sendLog(t('log.listLoadFailed', { file: entry.file, message: (error as Error).message }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state.packs.clear()
|
state.packs.clear()
|
||||||
for (const item of results) state.packs.set(item.key, item)
|
for (const item of results) state.packs.set(item.key, item)
|
||||||
sendLog(`로드된 음악퀴즈: ${results.length}개`)
|
sendLog(t('log.packsLoaded', { count: results.length }))
|
||||||
for (const item of results) {
|
for (const item of results) {
|
||||||
sendLog(` - ${item.key}: mc=${item.mcVersion || '?'} 베이스=${item.resourcepackPath || '(없음)'}`)
|
sendLog(t('log.packEntry', {
|
||||||
|
key: item.key,
|
||||||
|
mc: item.mcVersion || t('log.packEntryUnknownVersion'),
|
||||||
|
base: item.resourcepackPath || t('log.packEntryNoBase')
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
return results
|
return results
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('rp:packs:select', async (_event, packKey: string) => {
|
ipcMain.handle('rp:packs:select', async (_event, packKey: string) => {
|
||||||
if (!state.packs.has(packKey)) {
|
if (!state.packs.has(packKey)) {
|
||||||
throw new Error('선택한 음악퀴즈를 찾을 수 없습니다.')
|
throw new Error(t('errors.selectedPackNotFound'))
|
||||||
}
|
}
|
||||||
state.selectedKey = packKey
|
state.selectedKey = packKey
|
||||||
sendLog(`선택: ${packKey}`)
|
sendLog(t('log.selectedPack', { key: packKey }))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('rp:i18n:dict', () => localeDict)
|
||||||
|
|
||||||
// ── IPC: 2단계 설치 ──────────────────────────────────
|
// ── IPC: 2단계 설치 ──────────────────────────────────
|
||||||
ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string }> => {
|
ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string }> => {
|
||||||
if (!state.selectedKey) throw new Error('음악퀴즈를 먼저 선택해주세요.')
|
if (!state.selectedKey) throw new Error(t('errors.selectPackFirst'))
|
||||||
const pack = state.packs.get(state.selectedKey)
|
const pack = state.packs.get(state.selectedKey)
|
||||||
if (!pack) throw new Error('선택된 음악퀴즈를 찾을 수 없습니다.')
|
if (!pack) throw new Error(t('errors.currentPackNotFound'))
|
||||||
state.cancelRequested = false
|
state.cancelRequested = false
|
||||||
|
|
||||||
const tempRoot = path.join(getMcCustomDir(), '.temp')
|
const tempRoot = path.join(getMcCustomDir(), '.temp')
|
||||||
@@ -237,16 +247,16 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 2-1. yt-dlp / ffmpeg 준비 (%appdata%/.mc_custom/{yt-dlp,ffmpeg}.exe)
|
// 2-1. yt-dlp / ffmpeg 준비 (%appdata%/.mc_custom/{yt-dlp,ffmpeg}.exe)
|
||||||
sendLog('yt-dlp 준비 중…')
|
sendLog(t('log.ytdlpPreparing'))
|
||||||
sendProgress({ phase: 'prep', message: 'yt-dlp 준비 중' })
|
sendProgress({ phase: 'prep', message: t('progress.ytdlpPreparing') })
|
||||||
const ytDlpBin = await ensureYtDlpExe(sendLog)
|
const ytDlpBin = await ensureYtDlpExe(sendLog)
|
||||||
sendLog(`yt-dlp 경로: ${ytDlpBin}`)
|
sendLog(t('log.ytdlpPath', { path: ytDlpBin }))
|
||||||
throwIfCancelled()
|
throwIfCancelled()
|
||||||
sendLog('ffmpeg 준비 중…')
|
sendLog(t('log.ffmpegPreparing'))
|
||||||
sendProgress({ phase: 'prep', message: 'ffmpeg 준비 중' })
|
sendProgress({ phase: 'prep', message: t('progress.ffmpegPreparing') })
|
||||||
const ffmpegBin = await ensureFfmpegExe(sendLog)
|
const ffmpegBin = await ensureFfmpegExe(sendLog)
|
||||||
sendLog(`ffmpeg 경로: ${ffmpegBin}`)
|
sendLog(t('log.ffmpegPath', { path: ffmpegBin }))
|
||||||
sendProgress({ phase: 'prep', message: '준비 완료', done: true })
|
sendProgress({ phase: 'prep', message: t('progress.ready'), done: true })
|
||||||
throwIfCancelled()
|
throwIfCancelled()
|
||||||
|
|
||||||
// 2-2. 음악 다운로드 (CPU 코어 수 기반 자동 동시 다운로드, 시차 출발, ogg 변환)
|
// 2-2. 음악 다운로드 (CPU 코어 수 기반 자동 동시 다운로드, 시차 출발, ogg 변환)
|
||||||
@@ -256,8 +266,8 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
|||||||
const cpuCount = os.cpus()?.length ?? 0
|
const cpuCount = os.cpus()?.length ?? 0
|
||||||
// 첫 음악은 즉시 시작 가능하도록 base 를 현재 시각으로.
|
// 첫 음악은 즉시 시작 가능하도록 base 를 현재 시각으로.
|
||||||
nextMusicStartAt = Date.now()
|
nextMusicStartAt = Date.now()
|
||||||
sendLog(`CPU 코어 ${cpuCount}개 감지 → 동시 다운로드 ${concurrency}개`)
|
sendLog(t('log.cpuDetected', { cores: cpuCount, concurrency }))
|
||||||
sendLog(`음악 다운로드 시작 (${musicTotal}곡, 동시 ${concurrency}개, 시차 ${MUSIC_START_STAGGER_MS}ms)`)
|
sendLog(t('log.musicStart', { total: musicTotal, concurrency, stagger: MUSIC_START_STAGGER_MS }))
|
||||||
|
|
||||||
// 클로저 안에서 narrowing 이 풀리지 않도록 로컬 alias.
|
// 클로저 안에서 narrowing 이 풀리지 않도록 로컬 alias.
|
||||||
const musicList = pack.list.music
|
const musicList = pack.list.music
|
||||||
@@ -272,7 +282,7 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
|||||||
if (state.cancelRequested) return
|
if (state.cancelRequested) return
|
||||||
const entry = musicList[i]
|
const entry = musicList[i]
|
||||||
const idx = i + 1
|
const idx = i + 1
|
||||||
sendLog(`${idx}번 노래 다운로드 시작`)
|
sendLog(t('log.musicTrackStart', { idx }))
|
||||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
|
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
|
||||||
let child: ChildProcess | null = null
|
let child: ChildProcess | null = null
|
||||||
try {
|
try {
|
||||||
@@ -296,16 +306,16 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (child) state.activeChildren.delete(child)
|
if (child) state.activeChildren.delete(child)
|
||||||
sendLog(`${idx}번 노래 완료: ${path.basename(outPath)}`)
|
sendLog(t('log.musicTrackDone', { idx, name: path.basename(outPath) }))
|
||||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
|
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (child) state.activeChildren.delete(child)
|
if (child) state.activeChildren.delete(child)
|
||||||
if (state.cancelRequested) {
|
if (state.cancelRequested) {
|
||||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: '취소됨' })
|
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: t('progress.cancelled') })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: (err as Error).message })
|
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: (err as Error).message })
|
||||||
throw new Error(`${idx}번 노래 다운로드 실패: ${(err as Error).message}`)
|
throw new Error(t('errors.musicDownloadFailed', { idx, message: (err as Error).message }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,19 +329,19 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
|||||||
// 2-3. 사진 다운로드 + painting variant 정규화
|
// 2-3. 사진 다운로드 + painting variant 정규화
|
||||||
const paintingDir = path.join(tempRoot, 'painting')
|
const paintingDir = path.join(tempRoot, 'painting')
|
||||||
await fsp.mkdir(paintingDir, { recursive: true })
|
await fsp.mkdir(paintingDir, { recursive: true })
|
||||||
sendLog(`사진 다운로드 시작 (${imageTotal}장)`)
|
sendLog(t('log.imageStart', { total: imageTotal }))
|
||||||
for (let i = 0; i < imageTotal; i++) {
|
for (let i = 0; i < imageTotal; i++) {
|
||||||
throwIfCancelled()
|
throwIfCancelled()
|
||||||
const entry = pack.list.images[i]
|
const entry = pack.list.images[i]
|
||||||
const idx = i + 1
|
const idx = i + 1
|
||||||
sendLog(`${idx}번 사진 다운로드 중…`)
|
sendLog(t('log.imageDownloading', { idx }))
|
||||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running' })
|
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running' })
|
||||||
let buf: Buffer
|
let buf: Buffer
|
||||||
try {
|
try {
|
||||||
buf = await downloadImage(entry.url)
|
buf = await downloadImage(entry.url)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
|
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
|
||||||
throw new Error(`${idx}번 사진 다운로드 실패: ${(err as Error).message}`)
|
throw new Error(t('errors.imageDownloadFailed', { idx, message: (err as Error).message }))
|
||||||
}
|
}
|
||||||
throwIfCancelled()
|
throwIfCancelled()
|
||||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 60, status: 'running' })
|
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 60, status: 'running' })
|
||||||
@@ -340,9 +350,9 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
|||||||
await normalizeToCover(buf, outPath)
|
await normalizeToCover(buf, outPath)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
|
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
|
||||||
throw new Error(`${idx}번 사진 정규화 실패: ${(err as Error).message}`)
|
throw new Error(t('errors.imageNormalizeFailed', { idx, message: (err as Error).message }))
|
||||||
}
|
}
|
||||||
sendLog(`${idx}번 사진 완료: ${path.basename(outPath)}`)
|
sendLog(t('log.imageDone', { idx, name: path.basename(outPath) }))
|
||||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' })
|
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,18 +364,18 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
|||||||
const cleaned = pack.resourcepackPath.replace(/^\/+/, '')
|
const cleaned = pack.resourcepackPath.replace(/^\/+/, '')
|
||||||
const baseUrl = `${state.baseUrl}/file/resourcepacks/${encodeURIComponent(cleaned)}`
|
const baseUrl = `${state.baseUrl}/file/resourcepacks/${encodeURIComponent(cleaned)}`
|
||||||
baseZipPath = path.join(tempRoot, 'base.zip')
|
baseZipPath = path.join(tempRoot, 'base.zip')
|
||||||
sendLog(`베이스 리소스팩 다운로드: ${cleaned}`)
|
sendLog(t('log.baseDownload', { path: cleaned }))
|
||||||
sendLog(` URL: ${baseUrl}`)
|
sendLog(t('log.baseUrl', { url: baseUrl }))
|
||||||
sendProgress({ phase: 'package', message: '베이스 리소스팩 다운로드 중' })
|
sendProgress({ phase: 'package', message: t('progress.baseDownloading') })
|
||||||
try {
|
try {
|
||||||
const buf = await fetchBuffer(baseUrl)
|
const buf = await fetchBuffer(baseUrl)
|
||||||
await fsp.writeFile(baseZipPath, buf)
|
await fsp.writeFile(baseZipPath, buf)
|
||||||
sendLog(`베이스 리소스팩 받음 (${(buf.length / 1024).toFixed(1)} KB)`)
|
sendLog(t('log.baseReceived', { kb: (buf.length / 1024).toFixed(1) }))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`베이스 리소스팩 다운로드 실패: ${(err as Error).message}`)
|
throw new Error(t('errors.baseDownloadFailed', { message: (err as Error).message }))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sendLog('베이스 리소스팩 없음(resourcepackPath 빈 값) — 새 리소스팩으로 생성')
|
sendLog(t('log.baseAbsent'))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2-5. 리소스팩 zip 빌드 (pack.mcmeta + sounds.json + 음악·이미지, 베이스 위에 얹기)
|
// 2-5. 리소스팩 zip 빌드 (pack.mcmeta + sounds.json + 음악·이미지, 베이스 위에 얹기)
|
||||||
@@ -373,8 +383,8 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
|||||||
const resourcepackName = `${state.selectedKey}_musicquiz.zip`
|
const resourcepackName = `${state.selectedKey}_musicquiz.zip`
|
||||||
const resourcepackDir = path.join(getMcCustomDir(), 'resourcepacks')
|
const resourcepackDir = path.join(getMcCustomDir(), 'resourcepacks')
|
||||||
const resourcepackPath = path.join(resourcepackDir, resourcepackName)
|
const resourcepackPath = path.join(resourcepackDir, resourcepackName)
|
||||||
sendLog(`리소스팩 zip 빌드 중… (${resourcepackName})`)
|
sendLog(t('log.buildingZip', { name: resourcepackName }))
|
||||||
sendProgress({ phase: 'package', message: baseZipPath ? '베이스에 음악·사진 추가 중' : 'zip 빌드 중' })
|
sendProgress({ phase: 'package', message: baseZipPath ? t('progress.buildingWithBase') : t('progress.buildingZip') })
|
||||||
await buildResourcepackZip({
|
await buildResourcepackZip({
|
||||||
musicDir,
|
musicDir,
|
||||||
paintingDir,
|
paintingDir,
|
||||||
@@ -387,8 +397,8 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 2-6. %appdata%/.mc_custom/resourcepacks/ 에 배치 (위 빌드가 직접 outZipPath 에 저장)
|
// 2-6. %appdata%/.mc_custom/resourcepacks/ 에 배치 (위 빌드가 직접 outZipPath 에 저장)
|
||||||
sendLog(`설치 완료: ${resourcepackPath}`)
|
sendLog(t('log.installComplete', { path: resourcepackPath }))
|
||||||
sendProgress({ phase: 'package', message: '설치 완료', done: true })
|
sendProgress({ phase: 'package', message: t('progress.installComplete'), done: true })
|
||||||
return { resourcepackPath }
|
return { resourcepackPath }
|
||||||
} finally {
|
} finally {
|
||||||
// 임시 파일 정리
|
// 임시 파일 정리
|
||||||
@@ -398,7 +408,7 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
|||||||
|
|
||||||
ipcMain.handle('rp:install:cancel', async () => {
|
ipcMain.handle('rp:install:cancel', async () => {
|
||||||
state.cancelRequested = true
|
state.cancelRequested = true
|
||||||
sendLog(`취소 요청됨. 실행 중 프로세스 ${state.activeChildren.size}개 중단…`)
|
sendLog(t('log.cancelRequested', { count: state.activeChildren.size }))
|
||||||
for (const child of state.activeChildren) {
|
for (const child of state.activeChildren) {
|
||||||
if (!child.killed) child.kill()
|
if (!child.killed) child.kill()
|
||||||
}
|
}
|
||||||
@@ -406,7 +416,7 @@ ipcMain.handle('rp:install:cancel', async () => {
|
|||||||
|
|
||||||
function throwIfCancelled(): void {
|
function throwIfCancelled(): void {
|
||||||
if (state.cancelRequested) {
|
if (state.cancelRequested) {
|
||||||
throw new Error('사용자가 설치를 취소했습니다.')
|
throw new Error(t('errors.cancelledByUser'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { spawn, type ChildProcess } from 'node:child_process'
|
import { spawn, type ChildProcess } from 'node:child_process'
|
||||||
import { promises as fs } from 'node:fs'
|
import { promises as fs } from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import { loadComponentI18n } from '../shared/i18n.js'
|
||||||
|
|
||||||
|
const { t } = loadComponentI18n('installer-rp')
|
||||||
|
|
||||||
export interface DownloadMusicOptions {
|
export interface DownloadMusicOptions {
|
||||||
ytdlpExe: string
|
ytdlpExe: string
|
||||||
@@ -58,7 +61,7 @@ export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string>
|
|||||||
for (const raw of lines) {
|
for (const raw of lines) {
|
||||||
const line = raw.trimEnd()
|
const line = raw.trimEnd()
|
||||||
if (!line) continue
|
if (!line) continue
|
||||||
opts.log?.(`yt-dlp> ${line}`)
|
opts.log?.(t('log.ytdlpLine', { line }))
|
||||||
const m = line.match(/\[download\]\s+([\d.]+)%/)
|
const m = line.match(/\[download\]\s+([\d.]+)%/)
|
||||||
if (m) {
|
if (m) {
|
||||||
const pct = Math.min(100, Math.max(0, parseFloat(m[1])))
|
const pct = Math.min(100, Math.max(0, parseFloat(m[1])))
|
||||||
@@ -76,11 +79,16 @@ export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string>
|
|||||||
child.on('error', (err) => reject(err))
|
child.on('error', (err) => reject(err))
|
||||||
child.on('close', async (code, signal) => {
|
child.on('close', async (code, signal) => {
|
||||||
if (signal) {
|
if (signal) {
|
||||||
reject(new Error(`yt-dlp 가 신호 ${signal} 로 종료됨`))
|
reject(new Error(t('errors.ytdlpSignal', { signal: String(signal) })))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
reject(new Error(`yt-dlp 종료 코드 ${code}: ${stderr.trim() || '(stderr 없음)'}`))
|
reject(new Error(
|
||||||
|
t('errors.ytdlpExit', {
|
||||||
|
code: code ?? '',
|
||||||
|
stderr: stderr.trim() || t('errors.ytdlpNoStderr')
|
||||||
|
})
|
||||||
|
))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// .ogg 가 실제로 생성됐는지 확인
|
// .ogg 가 실제로 생성됐는지 확인
|
||||||
@@ -88,7 +96,7 @@ export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string>
|
|||||||
await fs.access(outPath)
|
await fs.access(outPath)
|
||||||
resolve(outPath)
|
resolve(outPath)
|
||||||
} catch {
|
} catch {
|
||||||
reject(new Error(`예상 출력파일이 없음: ${outPath}`))
|
reject(new Error(t('errors.ytdlpMissingOutput', { path: outPath })))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import path from 'node:path'
|
|||||||
import archiver from 'archiver'
|
import archiver from 'archiver'
|
||||||
import extract from 'extract-zip'
|
import extract from 'extract-zip'
|
||||||
import { resolveResourcePackFormat } from './packFormat.js'
|
import { resolveResourcePackFormat } from './packFormat.js'
|
||||||
|
import { loadComponentI18n } from '../shared/i18n.js'
|
||||||
|
|
||||||
|
const { t } = loadComponentI18n('installer-rp')
|
||||||
|
|
||||||
const NAMESPACE = 'musicquiz'
|
const NAMESPACE = 'musicquiz'
|
||||||
|
|
||||||
@@ -45,7 +48,7 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
|
|||||||
|
|
||||||
// 0) 베이스 리소스팩이 지정되면 먼저 풀어둔다. 그 위에 우리 파일을 얹는다.
|
// 0) 베이스 리소스팩이 지정되면 먼저 풀어둔다. 그 위에 우리 파일을 얹는다.
|
||||||
if (opts.baseZipPath) {
|
if (opts.baseZipPath) {
|
||||||
opts.log?.(`베이스 리소스팩 압축 해제: ${path.basename(opts.baseZipPath)}`)
|
opts.log?.(t('log.baseExtract', { name: path.basename(opts.baseZipPath) }))
|
||||||
await extract(opts.baseZipPath, { dir: root })
|
await extract(opts.baseZipPath, { dir: root })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,13 +60,13 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
|
|||||||
// 1) pack.mcmeta 는 mcVersion 에 맞춰 항상 덮어쓴다 (베이스가 다른 버전일 수 있으니).
|
// 1) pack.mcmeta 는 mcVersion 에 맞춰 항상 덮어쓴다 (베이스가 다른 버전일 수 있으니).
|
||||||
const resolved = resolveResourcePackFormat(opts.mcVersion)
|
const resolved = resolveResourcePackFormat(opts.mcVersion)
|
||||||
if (resolved.matched) {
|
if (resolved.matched) {
|
||||||
opts.log?.(`pack_format = ${resolved.format} (mcVersion ${resolved.matched})`)
|
opts.log?.(t('log.packFormatMatched', { format: resolved.format, matched: resolved.matched }))
|
||||||
} else {
|
} else {
|
||||||
opts.log?.(`pack_format = ${resolved.format} (mcVersion "${opts.mcVersion}" 매칭 실패, 최신 폴백)`)
|
opts.log?.(t('log.packFormatFallback', { format: resolved.format, version: opts.mcVersion }))
|
||||||
}
|
}
|
||||||
const mcmeta = {
|
const mcmeta = {
|
||||||
pack: {
|
pack: {
|
||||||
description: `음악퀴즈 리소스팩 - ${opts.packName}`,
|
description: t('pack.description', { name: opts.packName }),
|
||||||
pack_format: resolved.format,
|
pack_format: resolved.format,
|
||||||
supported_formats: { min_inclusive: resolved.format, max_inclusive: resolved.format }
|
supported_formats: { min_inclusive: resolved.format, max_inclusive: resolved.format }
|
||||||
}
|
}
|
||||||
@@ -82,7 +85,7 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
|
|||||||
const parsed = JSON.parse(existing)
|
const parsed = JSON.parse(existing)
|
||||||
if (parsed && typeof parsed === 'object') {
|
if (parsed && typeof parsed === 'object') {
|
||||||
soundsJson = parsed as Record<string, unknown>
|
soundsJson = parsed as Record<string, unknown>
|
||||||
opts.log?.(`기존 sounds.json 병합 (${Object.keys(soundsJson).length}개 항목)`)
|
opts.log?.(t('log.soundsMerged', { count: Object.keys(soundsJson).length }))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 없으면 새로 생성.
|
// 없으면 새로 생성.
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { contextBridge, ipcRenderer } from 'electron'
|
|||||||
import type { RpFetchedPack } from './types.js'
|
import type { RpFetchedPack } from './types.js'
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
|
/** i18n 사전을 렌더러에 전달. */
|
||||||
|
loadLocale: (): Promise<Record<string, unknown>> => ipcRenderer.invoke('rp:i18n:dict'),
|
||||||
|
|
||||||
/** manifest 와 각 음악퀴즈의 file/list/<key>.json 까지 한 번에 로드. */
|
/** manifest 와 각 음악퀴즈의 file/list/<key>.json 까지 한 번에 로드. */
|
||||||
loadPacks: (manifestUrl?: string): Promise<RpFetchedPack[]> =>
|
loadPacks: (manifestUrl?: string): Promise<RpFetchedPack[]> =>
|
||||||
ipcRenderer.invoke('rp:packs:load', manifestUrl),
|
ipcRenderer.invoke('rp:packs:load', manifestUrl),
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import path from 'node:path'
|
|||||||
import https from 'node:https'
|
import https from 'node:https'
|
||||||
import http from 'node:http'
|
import http from 'node:http'
|
||||||
import { getMcCustomDir } from '../shared/paths.js'
|
import { getMcCustomDir } from '../shared/paths.js'
|
||||||
|
import { loadComponentI18n } from '../shared/i18n.js'
|
||||||
|
|
||||||
|
const { t } = loadComponentI18n('installer-rp')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 리소스팩 간편설치기는 Windows .exe 로 배포되므로 yt-dlp.exe 한 종류만 사용.
|
* 리소스팩 간편설치기는 Windows .exe 로 배포되므로 yt-dlp.exe 한 종류만 사용.
|
||||||
@@ -27,7 +30,7 @@ export async function ensureYtDlpExe(
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const target = getYtDlpExePath()
|
const target = getYtDlpExePath()
|
||||||
if (await canExecute(target)) {
|
if (await canExecute(target)) {
|
||||||
log?.(`yt-dlp.exe 이미 있음: ${target}`)
|
log?.(t('log.ytdlpExists', { path: target }))
|
||||||
return target
|
return target
|
||||||
}
|
}
|
||||||
if (installPromise) return installPromise
|
if (installPromise) return installPromise
|
||||||
@@ -35,20 +38,21 @@ export async function ensureYtDlpExe(
|
|||||||
installPromise = (async () => {
|
installPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(path.dirname(target), { recursive: true })
|
await fs.mkdir(path.dirname(target), { recursive: true })
|
||||||
log?.(`yt-dlp.exe 다운로드 중: ${YT_DLP_DOWNLOAD_URL}`)
|
log?.(t('log.ytdlpDownloading', { url: YT_DLP_DOWNLOAD_URL }))
|
||||||
await downloadToFile(YT_DLP_DOWNLOAD_URL, target)
|
await downloadToFile(YT_DLP_DOWNLOAD_URL, target)
|
||||||
const okVersion = await probeVersion(target)
|
const okVersion = await probeVersion(target)
|
||||||
if (!okVersion) {
|
if (!okVersion) {
|
||||||
throw new Error('yt-dlp.exe 다운로드는 됐지만 실행 검증에 실패했습니다.')
|
throw new Error(t('errors.ytdlpVerifyFailed'))
|
||||||
}
|
}
|
||||||
log?.(`yt-dlp.exe 준비 완료: ${target}`)
|
log?.(t('log.ytdlpReady', { path: target }))
|
||||||
return target
|
return target
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 부분 다운로드 흔적 정리
|
// 부분 다운로드 흔적 정리
|
||||||
try { await fs.unlink(target) } catch { /* noop */ }
|
try { await fs.unlink(target) } catch { /* noop */ }
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'yt-dlp.exe 자동 설치 실패: ' +
|
t('errors.ytdlpInstallFailed', {
|
||||||
(err instanceof Error ? err.message : String(err))
|
message: err instanceof Error ? err.message : String(err)
|
||||||
|
})
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
installPromise = null
|
installPromise = null
|
||||||
@@ -80,7 +84,7 @@ function probeVersion(bin: string): Promise<boolean> {
|
|||||||
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
|
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (redirects > 8) {
|
if (redirects > 8) {
|
||||||
reject(new Error('redirect 가 너무 많습니다.'))
|
reject(new Error(t('common.tooManyRedirects')))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const lib = url.startsWith('https://') ? https : http
|
const lib = url.startsWith('https://') ? https : http
|
||||||
|
|||||||
Reference in New Issue
Block a user