9 Commits

Author SHA1 Message Date
ffb2048627 terms: agreement pages + site Notion-style editor + rp cancel fix
- 5종 약관(map/resourcepack/mod/installer/installer-rp) markdown 시드 + manifest/terms/ 노출
- 사이트 /op/agreement 목록 + Notion 스타일 markdown 에디터 (슬래시 명령어, 미리보기)
- 메인 installer: 음악퀴즈 선택 직후 약관 동의 페이지(맵·모드·설치기) 추가
- rp installer: 음악퀴즈 선택 직후 약관 동의 페이지(리소스팩·설치기) 추가
- rp installer 취소 버그 수정: buildResourcepackZip 단계간 + archive.abort() 폴링
- rp installer 취소 UX: 즉시 "취소 중…" 표시, 취소 시 installFailed 알림 생략
- 0.2.6 → 0.3.0 (큰 기능)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 00:55:36 +09:00
bc3841147f installer: write custom icon into Minecraft launcher profile
Minecraft launcher's "설치 설정" screen reads `profile.icon` from
launcher_profiles.json. We were leaving it unset, so the launcher fell
back to the default Furnace icon. Inline build/icon.png as a base64
data URL at build time (scripts/build-launcher-icon.cjs generates
src/installer/launcherIcon.ts) and set it on the profile we write.

The build/ directory isn't included in the electron-builder asar (it's
only used to point at the .ico for the exe), so a runtime read isn't
possible — the icon ships compiled into the bundle. To refresh after
changing icon.png, run `npm run build:launcher-icon` (it's wired into
`dist:win` so a fresh exe build always regenerates it).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 23:56:02 +09:00
40986bee11 installer-rp: delete base resourcepack zip after composing final pack
The RP installer downloads a fresh copy of the base zip into its temp
dir and composes the final pack on top of it. The base zip the main
installer placed in .mc_custom/resourcepacks/ has nothing to do after
that — but it stays in the Minecraft resource-pack list as a second
entry. Delete it after the final zip is written.

Guard against the case where the user set outputPackName equal to the
base filename, which would make base path == final path; in that case
we leave it alone so we don't wipe the file we just wrote.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 21:28:36 +09:00
bf225f51e1 installer: force fabric-installer JVM stdout to UTF-8
Korean Windows defaults the JVM's stdout to cp949 (MS949), so the
fabric-installer's Korean status lines came through as mojibake when
Node decoded them as UTF-8 (e.g. "���가져오는중 (org.ow2.asm:asm:9.9)").

Pass -Dfile.encoding/-Dstdout.encoding/-Dstderr.encoding=UTF-8 before
-jar so the JVM writes UTF-8 and our existing utf-8 decode matches.
stdout/stderr.encoding properties take effect on Java 18+;
file.encoding covers older JDKs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 21:22:44 +09:00
2371af4411 installer: clean platform-cache in finally so failures don't leak
Previous version only deleted platform-cache at the very end of the
success path. If anything between platform install and launcher
profile update failed, the cache jar stuck around. Move the rm into
a finally block so the directory is always cleaned up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 19:10:01 +09:00
1f59f6a98b installer: move yt-dlp/ffmpeg under .mc_custom/installer/, clean platform-cache
- yt-dlp.exe, ffmpeg.exe now live in %appdata%/.mc_custom/installer/ so
  the .mc_custom root stays a clean Minecraft game folder. Existing
  binaries at the old location are migrated on first run.
- After a successful install, the platform-cache (downloaded fabric /
  forge / neoforge installer jars) is deleted — it's regenerable and
  was just wasting disk space.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 19:02:01 +09:00
794ad9b778 installer-rp: rename fallback pack name musicquiz → resourcepack
Per user request: when outputPackName is empty, fall back to
`<packKey>_resourcepack` instead of `<packKey>_musicquiz`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:54:31 +09:00
f810719d92 installer-rp: site-configured outputPackName for built zip
Adds a new "생성되는 리소스팩 이름" admin field saved to the pack
manifest and consumed by the rp installer when naming the final zip.
Empty value falls back to <packKey>_musicquiz; Windows-invalid chars
are sanitized to '_'. Bumps version 0.1.1 → 0.2.0 (new feature).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:34:46 +09:00
ae771668de installer-rp: ship installer/styles.css so packaged UI renders
The rp installer's `index.html` references `../installer/styles.css`,
which works in dev because both source directories sit side by side.
The packaged exe's `files` list only included `installer-rp/**`, so
inside the asar the stylesheet path resolved to nothing and the UI
rendered completely unstyled (per user screenshot).

Add the single shared file `installer/styles.css` to the rp build's
file list. The cross-directory `<link>` reference now resolves inside
the asar, and we avoid duplicating the stylesheet.

Bump to 0.1.1 — small patch-level fix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:14:42 +09:00
34 changed files with 1546 additions and 62 deletions

View File

@@ -11,6 +11,10 @@ files:
- dist/installer-rp/** - dist/installer-rp/**
- dist/shared/** - dist/shared/**
- installer-rp/** - installer-rp/**
# rp 의 index.html 은 메인 설치기와 동일한 styles.css 를 공유함
# (`<link href="../installer/styles.css">`). asar 안에 해당 파일이 없으면
# UI 가 무스타일로 렌더링되므로 그 한 파일만 명시적으로 포함.
- installer/styles.css
- build/icon.* - build/icon.*
- package.json - package.json
# sharp 는 플랫폼별 prebuilt 가 분리 패키지로 배포됨. Windows 빌드에서는 # sharp 는 플랫폼별 prebuilt 가 분리 패키지로 배포됨. Windows 빌드에서는

View File

@@ -124,7 +124,7 @@ function renderStep1() {
nextBtn.addEventListener('click', function () { nextBtn.addEventListener('click', function () {
if (!state.selectedKey) return if (!state.selectedKey) return
api.selectPack(state.selectedKey).then(function () { api.selectPack(state.selectedKey).then(function () {
renderStep2() renderAgreement()
}).catch(function (err) { }).catch(function (err) {
alert(err.message || tt('common.selectFailed')) alert(err.message || tt('common.selectFailed'))
}) })
@@ -140,6 +140,170 @@ function renderStep1() {
}) })
} }
// 약관 동의 페이지: 1단계 직후, 2단계 설치 진입 전에 노출.
// rp 인스톨러는 리소스팩·설치기 두 약관만 확인·동의하면 된다.
function renderAgreement() {
setActiveStep(1)
clearPage()
var KINDS = [
{ id: 'resourcepack', tab: tt('agreement.tabResourcepack') },
{ id: 'installer-rp', tab: tt('agreement.tabInstaller') }
]
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + escapeHtml(tt('agreement.heading')) + '</h2>' +
'<p class="formMessage">' + escapeHtml(tt('agreement.intro')) + '</p>' +
'<div class="tabBar" id="agTabs">' +
KINDS.map(function (k, i) {
return '<button type="button" class="tabBtn' + (i === 0 ? ' active' : '') + '" data-ag="' + k.id + '">' + escapeHtml(k.tab) + '</button>'
}).join('') +
'</div>' +
'<div class="agreementBody" id="agBody">' + escapeHtml(tt('agreement.loading')) + '</div>' +
'<label class="toggleRow" style="margin-top:12px;"><input type="checkbox" id="agAccept" /> ' +
escapeHtml(tt('agreement.agreeAll')) + '</label>' +
'<div class="formMessage" id="agMsg"></div>' +
'<div class="actionRow">' +
' <button class="secondaryBtn" id="back">' + escapeHtml(tt('common.back')) + '</button>' +
' <button class="primaryBtn" id="next" disabled>' + escapeHtml(tt('common.next')) + '</button>' +
'</div>'
pageHost.appendChild(section)
var body = section.querySelector('#agBody')
var tabs = section.querySelectorAll('[data-ag]')
var nextBtn = section.querySelector('#next')
var accept = section.querySelector('#agAccept')
var msg = section.querySelector('#agMsg')
// 본문 캐시. 탭 전환 시 재요청하지 않음.
var cache = {}
function showKind(kind) {
if (cache[kind]) { body.innerHTML = cache[kind]; return }
body.textContent = tt('agreement.loading')
api.getTerm(kind).then(function (res) {
if (!res.ok) {
body.innerHTML = '<p class="formMessage error">' + escapeHtml(tt('agreement.loadFailed', { message: res.message || '' })) + '</p>'
return
}
var html = renderTermsMarkdown(res.content || '')
cache[kind] = html
body.innerHTML = html
}).catch(function (err) {
body.innerHTML = '<p class="formMessage error">' + escapeHtml(tt('agreement.loadFailed', { message: err.message })) + '</p>'
})
}
tabs.forEach(function (b) {
b.addEventListener('click', function () {
tabs.forEach(function (x) { x.classList.remove('active') })
b.classList.add('active')
showKind(b.getAttribute('data-ag'))
})
})
accept.addEventListener('change', function () {
nextBtn.disabled = !accept.checked
if (accept.checked) msg.textContent = ''
})
nextBtn.addEventListener('click', function () {
if (!accept.checked) {
msg.textContent = tt('agreement.agreeRequired')
msg.classList.add('error')
return
}
renderStep2()
})
section.querySelector('#back').addEventListener('click', renderStep1)
showKind(KINDS[0].id)
}
// 인스톨러용 미니 markdown 렌더러. 사이트 termsEditor 와 같은 규칙을 처리한다.
function renderTermsMarkdown(src) {
function escHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function inline(s) {
s = escHtml(s)
s = s.replace(/`([^`]+)`/g, '<code>$1</code>')
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
s = s.replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, '$1<em>$2</em>')
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
s = s.replace(/(^|[\s(])(https?:\/\/[^\s)]+)/g, function (m, p, u) {
return p + '<a href="' + u + '" target="_blank" rel="noopener">' + u + '</a>'
})
return s
}
var lines = src.replace(/\r\n/g, '\n').split('\n')
var out = []
var i = 0
var stack = null
function closeList() { if (stack) { out.push('</' + stack + '>'); stack = null } }
while (i < lines.length) {
var line = lines[i]
var fence = /^```(\w*)\s*$/.exec(line)
if (fence) {
closeList()
var code = []; i += 1
while (i < lines.length && !/^```\s*$/.test(lines[i])) { code.push(lines[i]); i += 1 }
if (i < lines.length) i += 1
out.push('<pre><code>' + escHtml(code.join('\n')) + '</code></pre>')
continue
}
var togStart = /^:::toggle\s+(.+)$/.exec(line)
if (togStart) {
closeList()
var summary = togStart[1]; var body2 = []; i += 1
while (i < lines.length && !/^:::\s*$/.test(lines[i])) { body2.push(lines[i]); i += 1 }
if (i < lines.length) i += 1
out.push('<details><summary>' + inline(summary) + '</summary>' + renderTermsMarkdown(body2.join('\n')) + '</details>')
continue
}
var h = /^(#{1,6})\s+(.*)$/.exec(line)
if (h) {
closeList()
out.push('<h' + h[1].length + '>' + inline(h[2]) + '</h' + h[1].length + '>')
i += 1; continue
}
if (/^---+\s*$/.test(line)) { closeList(); out.push('<hr/>'); i += 1; continue }
if (/^>\s?/.test(line)) {
closeList()
var q = []
while (i < lines.length && /^>\s?/.test(lines[i])) { q.push(lines[i].replace(/^>\s?/, '')); i += 1 }
out.push('<blockquote>' + renderTermsMarkdown(q.join('\n')) + '</blockquote>')
continue
}
var ol = /^\s*\d+\.\s+(.*)$/.exec(line)
if (ol) {
if (stack !== 'ol') { closeList(); out.push('<ol>'); stack = 'ol' }
out.push('<li>' + inline(ol[1]) + '</li>'); i += 1; continue
}
var ul = /^\s*[-*]\s+(.*)$/.exec(line)
if (ul) {
if (stack !== 'ul') { closeList(); out.push('<ul>'); stack = 'ul' }
out.push('<li>' + inline(ul[1]) + '</li>'); i += 1; continue
}
if (/^\s*$/.test(line)) { closeList(); i += 1; continue }
closeList()
var para = [line]; i += 1
while (i < lines.length && !/^\s*$/.test(lines[i])
&& !/^(#{1,6})\s+/.test(lines[i])
&& !/^\s*[-*]\s+/.test(lines[i])
&& !/^\s*\d+\.\s+/.test(lines[i])
&& !/^>/.test(lines[i])
&& !/^---+\s*$/.test(lines[i])
&& !/^```/.test(lines[i])
&& !/^:::/.test(lines[i])) {
para.push(lines[i]); i += 1
}
out.push('<p>' + inline(para.join('\n').replace(/\n/g, '<br/>')) + '</p>')
}
closeList()
return out.join('\n')
}
// ── 2단계: 설치 진행 ──────────────────────────────── // ── 2단계: 설치 진행 ────────────────────────────────
function renderStep2() { function renderStep2() {
setActiveStep(2) setActiveStep(2)
@@ -255,9 +419,16 @@ function renderStep2() {
} }
}) })
// 사용자가 취소를 눌렀는지 추적. 취소 흐름에서는 installFailed 알림을 띄우지 않고
// 조용히 step1 로 돌아간다.
var cancelInitiated = false
cancelBtn.addEventListener('click', function () { cancelBtn.addEventListener('click', function () {
if (!state.installing) return if (!state.installing || cancelInitiated) return
cancelInitiated = true
cancelBtn.disabled = true cancelBtn.disabled = true
cancelBtn.textContent = tt('agreement.cancelling')
// 사용자에게 어느 단계든 즉시 "취소 중" 신호가 보이도록 패키지 섹션 상태 갱신.
pkgSub.textContent = tt('agreement.cancelling')
api.cancelInstall() api.cancelInstall()
}) })
@@ -273,7 +444,9 @@ function renderStep2() {
}).catch(function (err) { }).catch(function (err) {
state.installing = false state.installing = false
if (stopProgress) stopProgress() if (stopProgress) stopProgress()
alert(tt('common.installFailed', { message: (err && err.message) || err })) if (!cancelInitiated) {
alert(tt('common.installFailed', { message: (err && err.message) || err }))
}
renderStep1() renderStep1()
}) })
} }

View File

@@ -134,7 +134,7 @@ function renderStep1() {
if (!state.selectedPackKey) return if (!state.selectedPackKey) return
await installerApi.setSelectedPack(state.selectedPackKey) await installerApi.setSelectedPack(state.selectedPackKey)
state.stepDone[1] = true state.stepDone[1] = true
renderStep2() renderAgreement()
}) })
;(async function () { ;(async function () {
@@ -148,6 +148,171 @@ function renderStep1() {
})() })()
} }
// 약관 동의 페이지: 음악퀴즈 선택 직후, 싱글/멀티 선택(step2) 진입 전에 노출.
// 메인 설치기는 맵·모드·설치기 세 약관을 모두 확인·동의해야 다음 단계로 갈 수 있다.
function renderAgreement() {
setActiveStep(1)
clearPage()
var KINDS = [
{ id: 'map', tab: tt('agreement.tabMap') },
{ id: 'mod', tab: tt('agreement.tabMod') },
{ id: 'installer', tab: tt('agreement.tabInstaller') }
]
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + tt('agreement.heading') + '</h2>' +
'<p class="formMessage">' + tt('agreement.intro') + '</p>' +
'<div class="tabBar" id="agTabs">' +
KINDS.map(function (k, i) {
return '<button type="button" class="tabBtn' + (i === 0 ? ' active' : '') + '" data-ag="' + k.id + '">' + k.tab + '</button>'
}).join('') +
'</div>' +
'<div class="agreementBody" id="agBody">' + tt('agreement.loading') + '</div>' +
'<label class="toggleRow" style="margin-top:12px;"><input type="checkbox" id="agAccept" /> ' +
tt('agreement.agreeAll') + '</label>' +
'<div class="formMessage" id="agMsg"></div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
pageHost.appendChild(section)
var body = section.querySelector('#agBody')
var tabs = section.querySelectorAll('[data-ag]')
var nextBtn = section.querySelector('#next')
var accept = section.querySelector('#agAccept')
var msg = section.querySelector('#agMsg')
// 약관 본문은 한 번 받으면 캐시. 탭 전환 시 재요청하지 않는다.
var cache = {}
function showKind(kind) {
if (cache[kind]) {
body.innerHTML = cache[kind]
return
}
body.textContent = tt('agreement.loading')
installerApi.getTerm(kind).then(function (res) {
if (!res.ok) {
body.innerHTML = '<p class="formMessage error">' + tt('agreement.loadFailed', { message: res.message || '' }) + '</p>'
return
}
var html = renderTermsMarkdown(res.content || '')
cache[kind] = html
body.innerHTML = html
}).catch(function (err) {
body.innerHTML = '<p class="formMessage error">' + tt('agreement.loadFailed', { message: err.message }) + '</p>'
})
}
tabs.forEach(function (b) {
b.addEventListener('click', function () {
tabs.forEach(function (x) { x.classList.remove('active') })
b.classList.add('active')
showKind(b.getAttribute('data-ag'))
})
})
accept.addEventListener('change', function () {
nextBtn.disabled = !accept.checked
if (accept.checked) msg.textContent = ''
})
nextBtn.addEventListener('click', function () {
if (!accept.checked) {
msg.textContent = tt('agreement.agreeRequired')
msg.classList.add('error')
return
}
renderStep2()
})
section.querySelector('#back').addEventListener('click', renderStep1)
showKind(KINDS[0].id)
}
// 인스톨러용 미니 markdown 렌더러. 사이트 termsEditor 와 동일한 규칙을 처리한다.
function renderTermsMarkdown(src) {
function escHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function inline(s) {
s = escHtml(s)
s = s.replace(/`([^`]+)`/g, '<code>$1</code>')
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
s = s.replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, '$1<em>$2</em>')
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
s = s.replace(/(^|[\s(])(https?:\/\/[^\s)]+)/g, function (m, p, u) {
return p + '<a href="' + u + '" target="_blank" rel="noopener">' + u + '</a>'
})
return s
}
var lines = src.replace(/\r\n/g, '\n').split('\n')
var out = []
var i = 0
var stack = null
function closeList() { if (stack) { out.push('</' + stack + '>'); stack = null } }
while (i < lines.length) {
var line = lines[i]
var fence = /^```(\w*)\s*$/.exec(line)
if (fence) {
closeList()
var code = []; i += 1
while (i < lines.length && !/^```\s*$/.test(lines[i])) { code.push(lines[i]); i += 1 }
if (i < lines.length) i += 1
out.push('<pre><code>' + escHtml(code.join('\n')) + '</code></pre>')
continue
}
var togStart = /^:::toggle\s+(.+)$/.exec(line)
if (togStart) {
closeList()
var summary = togStart[1]; var body2 = []; i += 1
while (i < lines.length && !/^:::\s*$/.test(lines[i])) { body2.push(lines[i]); i += 1 }
if (i < lines.length) i += 1
out.push('<details><summary>' + inline(summary) + '</summary>' + renderTermsMarkdown(body2.join('\n')) + '</details>')
continue
}
var h = /^(#{1,6})\s+(.*)$/.exec(line)
if (h) {
closeList()
out.push('<h' + h[1].length + '>' + inline(h[2]) + '</h' + h[1].length + '>')
i += 1; continue
}
if (/^---+\s*$/.test(line)) { closeList(); out.push('<hr/>'); i += 1; continue }
if (/^>\s?/.test(line)) {
closeList()
var q = []
while (i < lines.length && /^>\s?/.test(lines[i])) { q.push(lines[i].replace(/^>\s?/, '')); i += 1 }
out.push('<blockquote>' + renderTermsMarkdown(q.join('\n')) + '</blockquote>')
continue
}
var ol = /^\s*\d+\.\s+(.*)$/.exec(line)
if (ol) {
if (stack !== 'ol') { closeList(); out.push('<ol>'); stack = 'ol' }
out.push('<li>' + inline(ol[1]) + '</li>'); i += 1; continue
}
var ul = /^\s*[-*]\s+(.*)$/.exec(line)
if (ul) {
if (stack !== 'ul') { closeList(); out.push('<ul>'); stack = 'ul' }
out.push('<li>' + inline(ul[1]) + '</li>'); i += 1; continue
}
if (/^\s*$/.test(line)) { closeList(); i += 1; continue }
closeList()
var para = [line]; i += 1
while (i < lines.length && !/^\s*$/.test(lines[i])
&& !/^(#{1,6})\s+/.test(lines[i])
&& !/^\s*[-*]\s+/.test(lines[i])
&& !/^\s*\d+\.\s+/.test(lines[i])
&& !/^>/.test(lines[i])
&& !/^---+\s*$/.test(lines[i])
&& !/^```/.test(lines[i])
&& !/^:::/.test(lines[i])) {
para.push(lines[i]); i += 1
}
out.push('<p>' + inline(para.join('\n').replace(/\n/g, '<br/>')) + '</p>')
}
closeList()
return out.join('\n')
}
function renderStep2() { function renderStep2() {
setActiveStep(2) setActiveStep(2)
clearPage() clearPage()
@@ -193,7 +358,7 @@ function renderStep2() {
if (state.mode === 'multi') renderStep2Role() if (state.mode === 'multi') renderStep2Role()
else renderStep4() else renderStep4()
}) })
section.querySelector('#back').addEventListener('click', renderStep1) section.querySelector('#back').addEventListener('click', renderAgreement)
} }
function renderStep2Role() { function renderStep2Role() {

View File

@@ -155,6 +155,49 @@ main {
.toggleRow { display: flex; align-items: center; gap: 10px; margin: 8px 0; } .toggleRow { display: flex; align-items: center; gap: 10px; margin: 8px 0; }
/* 약관 동의 페이지 — 탭 + 약관 본문 박스. */
.tabBar { display: flex; gap: 6px; margin: 12px 0 0; flex-wrap: wrap; }
.tabBtn {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
padding: 6px 14px;
border-radius: 8px 8px 0 0;
cursor: pointer;
font-size: 13px;
}
.tabBtn.active {
background: var(--bg-card);
border-bottom-color: var(--bg-card);
color: var(--accent, #6cf);
font-weight: 600;
}
.agreementBody {
background: var(--bg-card);
border: 1px solid var(--border);
padding: 14px 18px;
border-radius: 0 10px 10px 10px;
max-height: 320px;
overflow-y: auto;
font-size: 13px;
line-height: 1.65;
}
.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; }
.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; }
.agreementBody pre { background: rgba(0,0,0,0.3); padding: 8px 10px; border-radius: 6px; overflow-x: auto; }
.agreementBody pre code { background: none; padding: 0; }
.agreementBody blockquote { margin: 6px 0; padding-left: 10px; border-left: 3px solid var(--border); color: #aab; }
.agreementBody details { margin: 6px 0; }
.agreementBody details > summary { cursor: pointer; padding: 4px 0; }
.agreementBody hr { border: none; border-top: 1px solid var(--border); margin: 10px 0; }
.agreementBody a { color: var(--accent, #6cf); }
.modalOverlay { .modalOverlay {
position: fixed; position: fixed;
inset: 0; inset: 0;

View File

@@ -14,6 +14,7 @@
}, },
"common": { "common": {
"next": "다음", "next": "다음",
"back": "이전",
"cancel": "취소", "cancel": "취소",
"confirm": "확인", "confirm": "확인",
"openFolder": "리소스팩 폴더 열기", "openFolder": "리소스팩 폴더 열기",
@@ -30,6 +31,17 @@
"step1": { "step1": {
"heading": "음악퀴즈 선택" "heading": "음악퀴즈 선택"
}, },
"agreement": {
"heading": "약관 동의",
"intro": "리소스팩을 설치하기 전에 아래 약관을 모두 확인하고 동의해 주세요.",
"tabResourcepack": "리소스팩 약관",
"tabInstaller": "리소스팩 설치기 약관",
"loading": "약관을 불러오는 중...",
"loadFailed": "약관 로드 실패: {{message}}",
"agreeAll": "위 모든 약관(리소스팩·설치기)에 동의합니다.",
"agreeRequired": "약관에 동의해야 다음 단계로 진행할 수 있습니다.",
"cancelling": "취소 중…"
},
"step2": { "step2": {
"heading": "리소스팩 설치", "heading": "리소스팩 설치",
"description": "음악·사진을 받아 리소스팩을 만들고 <code>%appdata%/.mc_custom/resourcepacks/</code> 에 자동 설치합니다.", "description": "음악·사진을 받아 리소스팩을 만들고 <code>%appdata%/.mc_custom/resourcepacks/</code> 에 자동 설치합니다.",
@@ -75,6 +87,7 @@
"baseUrl": " URL: {{url}}", "baseUrl": " URL: {{url}}",
"baseReceived": "베이스 리소스팩 받음 ({{kb}} KB)", "baseReceived": "베이스 리소스팩 받음 ({{kb}} KB)",
"baseAbsent": "베이스 리소스팩 없음(resourcepackPath 빈 값) — 새 리소스팩으로 생성", "baseAbsent": "베이스 리소스팩 없음(resourcepackPath 빈 값) — 새 리소스팩으로 생성",
"baseRemoved": "베이스 리소스팩 삭제: {{path}}",
"buildingZip": "리소스팩 zip 빌드 중… ({{name}})", "buildingZip": "리소스팩 zip 빌드 중… ({{name}})",
"installComplete": "설치 완료: {{path}}", "installComplete": "설치 완료: {{path}}",
"cancelRequested": "취소 요청됨. 실행 중 프로세스 {{count}}개 중단…", "cancelRequested": "취소 요청됨. 실행 중 프로세스 {{count}}개 중단…",

View File

@@ -30,6 +30,17 @@
"logViewer": { "logViewer": {
"title": "설치 로그" "title": "설치 로그"
}, },
"agreement": {
"heading": "약관 동의",
"intro": "설치 전에 아래 약관을 모두 확인하고 동의해 주세요.",
"tabMap": "맵 약관",
"tabMod": "모드 약관",
"tabInstaller": "설치기 약관",
"loading": "약관을 불러오는 중...",
"loadFailed": "약관 로드 실패: {{message}}",
"agreeAll": "위 모든 약관(맵·모드·설치기)에 동의합니다.",
"agreeRequired": "약관에 동의해야 다음 단계로 진행할 수 있습니다."
},
"step1": { "step1": {
"heading": "설치할 음악퀴즈 선택", "heading": "설치할 음악퀴즈 선택",
"loading": "목록을 불러오는 중...", "loading": "목록을 불러오는 중...",

View File

@@ -37,6 +37,7 @@
"browserTitle": "관리자 대시보드", "browserTitle": "관리자 대시보드",
"editList": "음악목록 수정", "editList": "음악목록 수정",
"editDatapack": "데이터팩 수정", "editDatapack": "데이터팩 수정",
"editTerms": "약관 수정",
"addPack": "음악퀴즈 추가", "addPack": "음악퀴즈 추가",
"deletePack": "음악퀴즈 삭제", "deletePack": "음악퀴즈 삭제",
"emptyHint": "등록된 음악퀴즈가 없습니다. \"음악퀴즈 추가\" 버튼으로 새로 만들어 보세요.", "emptyHint": "등록된 음악퀴즈가 없습니다. \"음악퀴즈 추가\" 버튼으로 새로 만들어 보세요.",
@@ -124,11 +125,39 @@
"serverPathHint": "/file/servers/ 아래 zip 파일 이름. 멀티 모드 전용.", "serverPathHint": "/file/servers/ 아래 zip 파일 이름. 멀티 모드 전용.",
"modsFolder": "모드 폴더 이름", "modsFolder": "모드 폴더 이름",
"modsFolderHint": "/file/mods/&lt;폴더이름&gt;/ 안의 모든 .jar을 자동으로 받습니다. 비워두면 모드를 받지 않습니다.", "modsFolderHint": "/file/mods/&lt;폴더이름&gt;/ 안의 모든 .jar을 자동으로 받습니다. 비워두면 모드를 받지 않습니다.",
"resourcepackPath": "리소스팩 (.zip)", "resourcepackPath": "베이스 리소스팩 (.zip)",
"resourcepackHint": "/file/resourcepacks/ 아래 .zip 파일 이름. 비워두면 리소스팩을 받지 않습니다.", "resourcepackHint": "/file/resourcepacks/ 아래 .zip 파일 이름. 리소스팩 설치기가 이 zip 위에 음악·사진을 얹어 최종 리소스팩을 만듭니다. 비워두면 처음부터 새로 만듭니다.",
"outputPackName": "생성되는 리소스팩 이름",
"outputPackNamePlaceholder": "예: 음악퀴즈 테스트팩",
"outputPackNameHint": "리소스팩 설치기가 만들어 내는 zip 파일 이름이자, 마인크래프트 리소스팩 목록의 제목이 됩니다. 비워두면 파일이름_resourcepack 형태로 자동 지정됩니다. Windows 파일명 금지 문자(\\ / : * ? \" &lt; &gt; |)는 자동으로 _ 로 바뀝니다.",
"ramOrderInvalid": "클라이언트 최소 램은 권장 램보다 클 수 없습니다.", "ramOrderInvalid": "클라이언트 최소 램은 권장 램보다 클 수 없습니다.",
"fabricLoaderRequired": "Fabric 로더 버전을 선택해 주세요." "fabricLoaderRequired": "Fabric 로더 버전을 선택해 주세요."
}, },
"terms": {
"browserTitle": "약관 수정",
"title": "약관 수정",
"hint": "수정할 약관을 선택하세요. 사이트에서 저장한 내용은 인스톨러가 약관 동의 화면에서 사용합니다.",
"editorBrowserTitle": "{{label}} 편집",
"editorTitle": "{{label}}",
"save": "약관 저장",
"saving": "저장 중…",
"saved": "저장 완료",
"saveFailed": "저장 실패: {{message}}",
"preview": "미리보기",
"edit": "편집",
"slashHint": "/ 를 입력해 블록 종류를 선택하거나 #, - 를 직접 입력할 수 있습니다.",
"slashHeading1": "큰 제목",
"slashHeading2": "중간 제목",
"slashHeading3": "작은 제목",
"slashText": "내용",
"slashBullet": "글머리 기호",
"slashNumbered": "번호 매기기",
"slashToggle": "토글",
"slashDivider": "구분선",
"slashQuote": "인용",
"slashCode": "코드",
"leaveConfirm": "저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?"
},
"datapack": { "datapack": {
"browserTitle": "데이터팩 수정", "browserTitle": "데이터팩 수정",
"title": "데이터팩 수정", "title": "데이터팩 수정",

View File

@@ -0,0 +1,27 @@
# 리소스팩 설치기(exe) 안내 및 약관
**1.** 이 설치기는 리소스팩(음악·사진)의 간편한 빌드 및 설치를 위한 프로그램입니다.
- 설치기를 통해 설치되는 리소스팩은 리소스팩 약관을 따릅니다.
- 설치기 사용 전 리소스팩 약관을 반드시 확인하세요.
**2.** 이 설치기는 반드시 개인이 사용하여야 하며 비영리적인 목적으로 사용하여야 합니다.
- 어떠한 경우에도 영리적인 목적으로 사용할 수 없으며, 허가를 받을 수도 없습니다.
**3.** 설치기에 대한 2차 창작 및 2차 배포는 금지됩니다.
- 소스코드의 무단 복제, 수정, 재배포는 엄격히 금지됩니다.
- 설치기를 타 플랫폼 또는 제3자에게 재배포하는 행위는 금지됩니다.
**4.** 설치기의 소스코드는 GitHub를 통해 공개되어 있습니다.
- 소스코드 열람은 허용되나, 이를 기반으로 한 파생 프로그램 제작 및 배포는 금지됩니다.
- 버그 제보 및 기여는 허용됩니다.
**5.** 설치기는 음악·이미지 다운로드를 위해 외부 도구(yt-dlp, ffmpeg)를 자동으로 받아 사용합니다. 각 도구는 해당 프로젝트의 라이선스를 따릅니다.
- yt-dlp: https://github.com/yt-dlp/yt-dlp
- ffmpeg: https://ffmpeg.org/
**6.** 이 설치기의 저작권은 제작자에게 있으며, 무단 사용 시 저작권법에 의해 처벌받을 수 있습니다.
Copyright (c) 2026. All rights reserved.
This software is protected under a Custom License.
Unauthorized reproduction, modification, or distribution of this software is strictly prohibited and may result in legal action under applicable copyright laws.

View File

@@ -0,0 +1,29 @@
# 설치기(exe) 안내 및 약관
**1.** 이 설치기는 맵, 모드, 리소스팩의 간편한 설치를 위한 프로그램입니다.
- 설치기를 통해 설치되는 각 콘텐츠(맵, 모드, 리소스팩)는 각각의 약관을 따릅니다.
- 설치기 사용 전 각 콘텐츠의 약관을 반드시 확인하세요.
**2.** 이 설치기는 반드시 개인이 사용하여야 하며 비영리적인 목적으로 사용하여야 합니다.
- 어떠한 경우에도 영리적인 목적으로 사용할 수 없으며, 허가를 받을 수도 없습니다.
**3.** 설치기에 대한 2차 창작 및 2차 배포는 금지됩니다.
- 소스코드의 무단 복제, 수정, 재배포는 엄격히 금지됩니다.
- 설치기를 타 플랫폼 또는 제3자에게 재배포하는 행위는 금지됩니다.
**4.** 설치기의 소스코드는 GitHub를 통해 공개되어 있습니다.
- 소스코드 열람은 허용되나, 이를 기반으로 한 파생 프로그램 제작 및 배포는 금지됩니다.
- 버그 제보 및 기여는 허용됩니다.
**5.** 설치기에 포함된 외부 모드(Fabric API, Modmenu)는 각 모드의 라이선스를 따르며, 설치기는 해당 모드들을 공식 배포처에서 다운로드합니다.
- Fabric API: https://www.curseforge.com/minecraft/mc-mods/fabric-api
- Modmenu: https://www.curseforge.com/minecraft/mc-mods/modmenu
**6.** 이 설치기의 저작권은 제작자에게 있으며, 무단 사용 시 저작권법에 의해 처벌받을 수 있습니다.
Copyright (c) 2026. All rights reserved.
This software is protected under a Custom License.
Unauthorized reproduction, modification, or distribution of this software is strictly prohibited and may result in legal action under applicable copyright laws.
All rights reserved (ARR). No part of this software may be reproduced, distributed, or transmitted in any form or by any means without the prior written permission of the copyright holder.

22
manifest/terms/map.md Normal file
View File

@@ -0,0 +1,22 @@
# 맵(Map) 안내 및 약관
**1.** 이 맵은 마인크래프트 인게임에서 시스템에 따라 재생되는 노래를 듣고 제목을 맞추는 Windows PC 기반 JE 26.1.2 전용 맵입니다.
- 이번 노래퀴즈 주제는 "게임"이며, 임의의 게임 OST/BGM을 듣고 게임의 이름을 맞추어야 합니다.
- JE 버전이 다를 경우 플레이가 불가능할 수도 있습니다. 버전을 반드시 확인하세요.
**2.** 이 맵은 반드시 개인이 사용하여야 하며 비영리적인 목적으로 사용하여야 합니다.
- 어떠한 경우에도 영리적인 목적으로 사용할 수 없으며, 허가를 받을 수도 없습니다.
**3.** 맵에 대한 2차 창작은 금지합니다. 2차 배포는 이 글을 통하여 배포하되 허가가 필요합니다.
- 맵에는 배경용 타 맵 제작자의 라이선스도 포함되어 있습니다. 무단 배포는 엄격히 금지합니다.
**4.** 맵 플레이에는 50Mbps 이상의 기본적인 인터넷 속도를 요구합니다.
- 또한 8코어 이상의 CPU와 16GB 이상의 램 용량을 권장합니다.
- 위 사양을 충족하지 못할 경우 원활한 플레이가 어려울 수 있습니다.
**5.** 맵에는 배경용 제3자의 맵이 사용되었습니다.
- 출처: https://www.planetminecraft.com/project/liyue-harbour-from-genshin-impact-in-minecraft-1-1-scale/
- 저작자: SkyBlock Squad
- 해당 맵은 저작자의 허가를 받아 사용하였습니다.
This work is licensed under CC BY-NC-ND 4.0

19
manifest/terms/mod.md Normal file
View File

@@ -0,0 +1,19 @@
# 모드(Mod) 안내 및 약관
**1.** 더 향상된 플레이를 위하여 모드가 포함되어 있습니다.
- 모드는 설치기를 통하여 자동 설치됩니다.
- 자동 설치가 제대로 되지 않을 경우 수동 설치를 권장합니다.
**2.** Fabric 기반 26.1.2 모드를 사용하였습니다.
- 저희가 제작한 chat_answer, video_player 모드는 제작자의 소유입니다.
- 두 모드에 대한 2차 창작 및 2차 배포는 금지됩니다.
**3.** 모드는 반드시 개인이 사용하여야 하며 비영리적인 목적으로 사용하여야 합니다.
- 어떠한 경우에도 영리적인 목적으로 사용할 수 없습니다.
**4.** 원활한 플레이를 위해 Sodium, Iris Shaders 모드를 함께 사용하는 것을 권장합니다.
- 최적화 및 쉐이더 적용을 하기 위한 의도이며, 필수 사항은 아닙니다.
- Sodium: https://www.curseforge.com/minecraft/mc-mods/sodium
- Iris Shaders: https://www.curseforge.com/minecraft/mc-mods/irisshaders
This work is licensed under CC BY-NC-ND 4.0

View File

@@ -0,0 +1,13 @@
# 리소스팩(ResourcePack) 안내 및 약관
**1.** 리소스팩은 맵 플레이를 위한 필수 요소입니다.
- 노래가 나오지 않는 경우 리소스팩의 적용 여부를 반드시 확인하세요.
**2.** 리소스팩은 절대 2차 창작하거나 2차 배포해서는 안 되며, 어느 누구에게도 전달해서는 안 됩니다.
- 영리적인 목적으로 사용할 수 없습니다.
- 리소스팩에 포함된 음악의 저작권은 각 원저작자에게 있습니다.
- 리소스팩은 이 맵 플레이 전용으로만 사용하여야 합니다.
Copyright (c) 2026. All rights reserved.
All music and audio files included in this resource pack are excluded from this license.
The copyright of all such content belongs to their respective original copyright holders.

View File

@@ -1,6 +1,6 @@
{ {
"name": "minecraft-music-quiz-installer", "name": "minecraft-music-quiz-installer",
"version": "0.1.0", "version": "0.3.0",
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트", "description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
"main": "dist/installer/main.js", "main": "dist/installer/main.js",
"scripts": { "scripts": {
@@ -10,7 +10,8 @@
"installer": "tsc -p tsconfig.installer.json && electron .", "installer": "tsc -p tsconfig.installer.json && electron .",
"installer:rp": "tsc -p tsconfig.installer-rp.json && electron dist/installer-rp/main.js", "installer:rp": "tsc -p tsconfig.installer-rp.json && electron dist/installer-rp/main.js",
"preinstall:sharp-win32": "npm install --no-save --force @img/sharp-win32-x64@0.34.5", "preinstall:sharp-win32": "npm install --no-save --force @img/sharp-win32-x64@0.34.5",
"dist:win": "npm run preinstall:sharp-win32 && tsc -p tsconfig.installer.json && electron-builder --win --config electron-builder.yml", "build:launcher-icon": "node scripts/build-launcher-icon.cjs",
"dist:win": "npm run preinstall:sharp-win32 && npm run build:launcher-icon && tsc -p tsconfig.installer.json && electron-builder --win --config electron-builder.yml",
"dist:win:rp": "npm run preinstall:sharp-win32 && tsc -p tsconfig.installer-rp.json && electron-builder --win --config electron-builder-rp.yml" "dist:win:rp": "npm run preinstall:sharp-win32 && tsc -p tsconfig.installer-rp.json && electron-builder --win --config electron-builder-rp.yml"
}, },
"dependencies": { "dependencies": {

95
public/termsEditor.css Normal file
View File

@@ -0,0 +1,95 @@
/* Notion 스타일 약관 편집기 전용 스타일.
* 텍스트영역과 미리보기 영역을 동일한 폭/타이포로 보여 주어 입력 ↔ 미리보기
* 전환 시 시각적 점프가 최소화되도록 한다. 슬래시 메뉴는 caret 좌표 위에
* 절대 위치로 띄운다. */
.termsEditorWrap {
position: relative;
margin-top: 12px;
}
.termsEditor {
width: 100%;
min-height: 60vh;
padding: 16px 18px;
border: 1px solid #d5d5d5;
border-radius: 8px;
background: #fff;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 14px;
line-height: 1.7;
resize: vertical;
box-sizing: border-box;
outline: none;
white-space: pre-wrap;
}
.termsEditor:focus {
border-color: #5b8def;
box-shadow: 0 0 0 2px rgba(91, 141, 239, 0.2);
}
.termsPreview {
min-height: 60vh;
padding: 16px 18px;
border: 1px solid #d5d5d5;
border-radius: 8px;
background: #fafafa;
font-size: 14px;
line-height: 1.7;
box-sizing: border-box;
}
.termsPreview h1 { font-size: 22px; margin: 12px 0 8px; }
.termsPreview h2 { font-size: 18px; margin: 10px 0 6px; }
.termsPreview h3 { font-size: 15px; margin: 8px 0 4px; }
.termsPreview p { margin: 6px 0; }
.termsPreview ul, .termsPreview ol { margin: 6px 0; padding-left: 22px; }
.termsPreview li { margin: 2px 0; }
.termsPreview hr { border: none; border-top: 1px solid #e0e0e0; margin: 12px 0; }
.termsPreview blockquote {
margin: 8px 0; padding: 4px 12px; border-left: 3px solid #ddd; color: #555;
}
.termsPreview code {
background: #eee; padding: 1px 5px; border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
}
.termsPreview pre {
background: #f0f0f0; padding: 10px 12px; border-radius: 6px; overflow: auto;
}
.termsPreview pre code { background: transparent; padding: 0; }
.termsPreview a { color: #2664d8; text-decoration: underline; word-break: break-all; }
.termsPreview details {
margin: 6px 0; border: 1px solid #e0e0e0; border-radius: 6px;
background: #fff; padding: 4px 10px;
}
.termsPreview details > summary { cursor: pointer; font-weight: 600; padding: 4px 0; }
/* 슬래시 자동완성 메뉴 — 노션 느낌으로 caret 좌표 위에 띄움. */
.slashMenu {
position: absolute;
z-index: 50;
min-width: 220px;
max-height: 280px;
overflow-y: auto;
background: #fff;
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
padding: 4px;
font-size: 13px;
}
.slashMenu .slashItem {
display: flex; flex-direction: column;
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
}
.slashMenu .slashItem:hover,
.slashMenu .slashItem.active {
background: #eef2ff;
}
.slashMenu .slashItem strong { font-size: 13px; }
.slashMenu .slashItem span { color: #888; font-size: 11px; }

385
public/termsEditor.js Normal file
View File

@@ -0,0 +1,385 @@
/* 약관(Markdown) 편집기.
* - 기본은 textarea: 사용자가 직접 #, - 등을 입력할 수 있다.
* - "/" 를 줄 맨 앞 또는 빈 공간 다음에 입력하면 슬래시 메뉴를 띄워
* 제목/내용/글머리/번호/토글/구분선/인용/코드 블록을 선택해 자동 삽입한다.
* (사용자가 #, - 같은 기호를 외울 필요 없이 명령어로 입력 가능)
* - 미리보기 탭에서 작은 markdown → HTML 렌더러로 결과를 보여 준다.
*/
(function () {
'use strict'
var editor = document.getElementById('editor')
var preview = document.getElementById('preview')
var slashMenu = document.getElementById('slashMenu')
var status = document.getElementById('status')
var dirtyMark = document.getElementById('dirty-mark')
var saveBtn = document.getElementById('saveBtn')
var tabBtns = document.querySelectorAll('.tabBar .tabBtn')
editor.value = INITIAL || ''
var dirty = false
function setDirty(v) {
dirty = v
dirtyMark.hidden = !v
}
// ─── markdown 미리 보기용 미니 렌더러 ────────────────────────────────
// 정식 markdown 파서는 아니지만, 본 편집기가 만들어 내는 형태(#, ##, ###,
// - , 1. , > , ---, ``` , 토글 details) 정도는 충실히 처리한다.
function escHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function inline(s) {
s = escHtml(s)
// code `x`
s = s.replace(/`([^`]+)`/g, '<code>$1</code>')
// bold **x**
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
// italic *x*
s = s.replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, '$1<em>$2</em>')
// links [text](url) — also auto-link bare http(s)
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
s = s.replace(/(^|[\s(])(https?:\/\/[^\s)]+)/g, function (m, p, u) {
return p + '<a href="' + u + '" target="_blank" rel="noopener">' + u + '</a>'
})
return s
}
function renderMd(src) {
var lines = src.replace(/\r\n/g, '\n').split('\n')
var out = []
var i = 0
var stackList = null // 'ul' | 'ol' | null
function closeList() { if (stackList) { out.push('</' + stackList + '>'); stackList = null } }
while (i < lines.length) {
var line = lines[i]
// 코드 블록 ```lang
var fence = /^```(\w*)\s*$/.exec(line)
if (fence) {
closeList()
var code = []
i += 1
while (i < lines.length && !/^```\s*$/.test(lines[i])) {
code.push(lines[i]); i += 1
}
if (i < lines.length) i += 1
out.push('<pre><code>' + escHtml(code.join('\n')) + '</code></pre>')
continue
}
// 토글 (자체 구문) :::toggle 제목 ... :::
var togStart = /^:::toggle\s+(.+)$/.exec(line)
if (togStart) {
closeList()
var summary = togStart[1]
var body = []
i += 1
while (i < lines.length && !/^:::\s*$/.test(lines[i])) {
body.push(lines[i]); i += 1
}
if (i < lines.length) i += 1
out.push('<details><summary>' + inline(summary) + '</summary>' + renderMd(body.join('\n')) + '</details>')
continue
}
// 헤딩
var h = /^(#{1,6})\s+(.*)$/.exec(line)
if (h) {
closeList()
var level = h[1].length
out.push('<h' + level + '>' + inline(h[2]) + '</h' + level + '>')
i += 1; continue
}
// hr
if (/^---+\s*$/.test(line)) {
closeList()
out.push('<hr />'); i += 1; continue
}
// 인용 >
if (/^>\s?/.test(line)) {
closeList()
var q = []
while (i < lines.length && /^>\s?/.test(lines[i])) {
q.push(lines[i].replace(/^>\s?/, '')); i += 1
}
out.push('<blockquote>' + renderMd(q.join('\n')) + '</blockquote>')
continue
}
// 번호 목록
var ol = /^\s*\d+\.\s+(.*)$/.exec(line)
if (ol) {
if (stackList !== 'ol') { closeList(); out.push('<ol>'); stackList = 'ol' }
out.push('<li>' + inline(ol[1]) + '</li>')
i += 1; continue
}
// 불릿
var ul = /^\s*[-*]\s+(.*)$/.exec(line)
if (ul) {
if (stackList !== 'ul') { closeList(); out.push('<ul>'); stackList = 'ul' }
out.push('<li>' + inline(ul[1]) + '</li>')
i += 1; continue
}
// 빈 줄
if (/^\s*$/.test(line)) { closeList(); i += 1; continue }
// 일반 문단
closeList()
var para = [line]; i += 1
while (i < lines.length && !/^\s*$/.test(lines[i])
&& !/^(#{1,6})\s+/.test(lines[i])
&& !/^\s*[-*]\s+/.test(lines[i])
&& !/^\s*\d+\.\s+/.test(lines[i])
&& !/^>/.test(lines[i])
&& !/^---+\s*$/.test(lines[i])
&& !/^```/.test(lines[i])
&& !/^:::/.test(lines[i])) {
para.push(lines[i]); i += 1
}
out.push('<p>' + inline(para.join('\n').replace(/\n/g, '<br/>')) + '</p>')
}
closeList()
return out.join('\n')
}
function refreshPreview() {
preview.innerHTML = renderMd(editor.value)
}
// ─── 탭 전환 (edit / preview) ────────────────────────────────────────
tabBtns.forEach(function (b) {
b.addEventListener('click', function () {
tabBtns.forEach(function (x) { x.classList.remove('active') })
b.classList.add('active')
var mode = b.getAttribute('data-mode')
if (mode === 'preview') {
refreshPreview()
editor.hidden = true
preview.hidden = false
} else {
editor.hidden = false
preview.hidden = true
}
})
})
// ─── 저장 ───────────────────────────────────────────────────────────
function save() {
status.classList.remove('error')
status.textContent = I18N.saving
fetch('/op/agreement/' + encodeURIComponent(TERM_KIND), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: editor.value })
}).then(function (r) {
return r.json().then(function (j) { return { ok: r.ok && j && j.ok !== false, body: j } })
}).then(function (res) {
if (!res.ok) throw new Error((res.body && res.body.message) || 'failed')
setDirty(false)
status.textContent = I18N.saved
}).catch(function (err) {
status.classList.add('error')
status.textContent = I18N.saveFailed.replace('{{message}}', err.message)
})
}
saveBtn.addEventListener('click', save)
// Ctrl+S 저장
document.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
e.preventDefault(); save()
}
})
// 페이지 떠나기 가드
window.addEventListener('beforeunload', function (e) {
if (!dirty) return
e.preventDefault()
e.returnValue = I18N.leaveConfirm
return I18N.leaveConfirm
})
editor.addEventListener('input', function () {
setDirty(true)
})
// ─── 슬래시 자동완성 ─────────────────────────────────────────────────
// 정의: { label, hint, insert: 줄 시작에 들어갈 텍스트 (커서 위치는 |로 표시) }
var SLASH_ITEMS = [
{ label: I18N.slashHeading1, hint: '# ', insert: '# |' },
{ label: I18N.slashHeading2, hint: '## ', insert: '## |' },
{ label: I18N.slashHeading3, hint: '### ', insert: '### |' },
{ label: I18N.slashText, hint: '', insert: '|' },
{ label: I18N.slashBullet, hint: '- ', insert: '- |' },
{ label: I18N.slashNumbered, hint: '1. ', insert: '1. |' },
{ label: I18N.slashToggle, hint: ':::toggle 제목 ... :::', insert: ':::toggle 제목\n|\n:::' },
{ label: I18N.slashDivider, hint: '---', insert: '---\n|' },
{ label: I18N.slashQuote, hint: '> ', insert: '> |' },
{ label: I18N.slashCode, hint: '```', insert: '```\n|\n```' }
]
var slashState = null // { startPos: number, query: string, activeIndex: number, filtered: [] }
function renderSlashItems(filtered) {
slashMenu.innerHTML = ''
filtered.forEach(function (item, idx) {
var el = document.createElement('div')
el.className = 'slashItem' + (idx === slashState.activeIndex ? ' active' : '')
var strong = document.createElement('strong')
strong.textContent = item.label
var span = document.createElement('span')
span.textContent = item.hint || ''
el.appendChild(strong); el.appendChild(span)
el.addEventListener('mousedown', function (e) {
e.preventDefault()
applySlash(item)
})
slashMenu.appendChild(el)
})
}
function positionSlash() {
// textarea caret 좌표 근사: 보이지 않는 mirror div 를 만들어 caret 위치를 추정한다.
var rect = editor.getBoundingClientRect()
var wrapRect = editor.parentElement.getBoundingClientRect()
var caret = getCaretCoords(editor)
var top = caret.top + 22 + (rect.top - wrapRect.top) - editor.scrollTop
var left = caret.left + (rect.left - wrapRect.left)
slashMenu.style.top = top + 'px'
slashMenu.style.left = left + 'px'
}
function openSlash() {
slashState = {
startPos: editor.selectionStart - 1, // '/' 위치
query: '',
activeIndex: 0,
filtered: SLASH_ITEMS.slice()
}
renderSlashItems(slashState.filtered)
slashMenu.hidden = false
positionSlash()
}
function closeSlash() {
slashState = null
slashMenu.hidden = true
}
function applySlash(item) {
if (!slashState) return
var value = editor.value
var start = slashState.startPos
var end = editor.selectionStart
// 줄의 시작 위치 계산 (이미 '#', '- ' 같은 prefix 가 있어도 무시하고 새 prefix 로 교체)
var lineStart = value.lastIndexOf('\n', start - 1) + 1
var lineEnd = value.indexOf('\n', end)
if (lineEnd === -1) lineEnd = value.length
var beforeLine = value.slice(0, lineStart)
var afterLine = value.slice(lineEnd)
var currentLine = value.slice(lineStart, lineEnd)
// 줄 안에서 '/검색어' 부분을 제거하고, 나머지 텍스트를 prefix 뒤에 이어 붙인다.
var rest = currentLine.slice(0, start - lineStart) + currentLine.slice(end - lineStart)
var insert = item.insert
var caretMarker = insert.indexOf('|')
var inserted = insert.replace('|', rest)
editor.value = beforeLine + inserted + afterLine
var caretPos = (beforeLine + insert.slice(0, caretMarker)).length
editor.selectionStart = editor.selectionEnd = caretPos
closeSlash()
setDirty(true)
editor.focus()
}
editor.addEventListener('keydown', function (e) {
if (slashState) {
if (e.key === 'ArrowDown') {
e.preventDefault()
slashState.activeIndex = (slashState.activeIndex + 1) % slashState.filtered.length
renderSlashItems(slashState.filtered)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
slashState.activeIndex = (slashState.activeIndex - 1 + slashState.filtered.length) % slashState.filtered.length
renderSlashItems(slashState.filtered)
return
}
if (e.key === 'Enter' || e.key === 'Tab') {
if (slashState.filtered.length > 0) {
e.preventDefault()
applySlash(slashState.filtered[slashState.activeIndex])
return
}
}
if (e.key === 'Escape') {
e.preventDefault()
closeSlash()
return
}
}
})
editor.addEventListener('input', function (e) {
var pos = editor.selectionStart
var ch = editor.value.slice(pos - 1, pos)
if (!slashState && ch === '/') {
// 줄 시작 또는 공백 다음에서만 슬래시 메뉴 활성화
var prev = pos >= 2 ? editor.value.slice(pos - 2, pos - 1) : '\n'
if (prev === '\n' || prev === ' ' || pos === 1) {
openSlash()
return
}
}
if (slashState) {
var startPos = slashState.startPos
if (pos < startPos || editor.value[startPos] !== '/') {
closeSlash()
return
}
var q = editor.value.slice(startPos + 1, pos).toLowerCase()
slashState.query = q
slashState.filtered = SLASH_ITEMS.filter(function (it) {
if (!q) return true
return it.label.toLowerCase().indexOf(q) !== -1
|| (it.hint && it.hint.toLowerCase().indexOf(q) !== -1)
})
slashState.activeIndex = 0
renderSlashItems(slashState.filtered)
positionSlash()
}
})
editor.addEventListener('blur', function () {
// mousedown on menu uses e.preventDefault → blur 시에도 안전하게 닫는다.
setTimeout(closeSlash, 100)
})
// ─── caret 좌표 계산 (mirror div 기법) ───────────────────────────────
function getCaretCoords(el) {
var div = document.createElement('div')
var s = getComputedStyle(el)
var props = [
'boxSizing','width','height','overflowX','overflowY',
'borderTopWidth','borderRightWidth','borderBottomWidth','borderLeftWidth',
'paddingTop','paddingRight','paddingBottom','paddingLeft',
'fontStyle','fontVariant','fontWeight','fontStretch','fontSize','fontSizeAdjust',
'lineHeight','fontFamily','textAlign','textTransform','textIndent','textDecoration',
'letterSpacing','wordSpacing','tabSize','MozTabSize','whiteSpace'
]
div.style.position = 'absolute'
div.style.visibility = 'hidden'
div.style.whiteSpace = 'pre-wrap'
div.style.wordWrap = 'break-word'
props.forEach(function (p) { div.style[p] = s[p] })
div.style.position = 'absolute'
div.style.top = '0'
div.style.left = '0'
var rect = el.getBoundingClientRect()
document.body.appendChild(div)
var pos = el.selectionStart
var before = el.value.substring(0, pos)
div.textContent = before
var span = document.createElement('span')
span.textContent = el.value.substring(pos) || '.'
div.appendChild(span)
var top = span.offsetTop + parseInt(s.borderTopWidth, 10)
var left = span.offsetLeft + parseInt(s.borderLeftWidth, 10)
document.body.removeChild(div)
return { top: top, left: left }
}
})()

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env node
// build/icon.png 을 읽어 base64 data URL 로 변환해
// src/installer/launcherIcon.ts 에 상수로 박는다.
//
// 마인크래프트 런처의 "설치 설정" 화면 프로필 아이콘은
// launcher_profiles.json 의 profile.icon 필드에서 오는데,
// `data:image/png;base64,...` 형태의 data URL 을 받는다.
// build/ 폴더는 electron-builder 가 exe 아이콘으로만 쓰고 asar 에
// 포함되지 않아서, 런타임에 그 파일을 읽을 수 없다. 대신 빌드(개발) 시점에
// 이 스크립트를 돌려 PNG 를 소스 코드에 인라인한다.
'use strict'
const fs = require('node:fs')
const path = require('node:path')
const repoRoot = path.resolve(__dirname, '..')
const pngPath = path.join(repoRoot, 'build', 'icon.png')
const tsPath = path.join(repoRoot, 'src', 'installer', 'launcherIcon.ts')
const buf = fs.readFileSync(pngPath)
const b64 = buf.toString('base64')
const ts = `// AUTO-GENERATED by scripts/build-launcher-icon.cjs from build/icon.png.
// 마인크래프트 런처의 "설치 설정" 화면에서 보이는 프로필 아이콘. exe 와 같은
// 이미지를 쓰기 위해 빌드 시점에 PNG 를 data URL 로 인라인한다. 변경하려면
// build/icon.png 교체 후 \`node scripts/build-launcher-icon.cjs\` 재실행.
export const LAUNCHER_PROFILE_ICON =
'data:image/png;base64,${b64}'
`
fs.writeFileSync(tsPath, ts, 'utf8')
console.log(`wrote ${tsPath} (${buf.length} bytes PNG → ${b64.length} chars base64)`)

View File

@@ -3,7 +3,7 @@ import { promises as fs, createWriteStream, constants as fsConst } from 'node:fs
import path from 'node:path' 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, getMcCustomInstallerDir } from '../shared/paths.js'
import { loadComponentI18n } from '../shared/i18n.js' import { loadComponentI18n } from '../shared/i18n.js'
const { t } = loadComponentI18n('installer-rp') const { t } = loadComponentI18n('installer-rp')
@@ -13,10 +13,30 @@ const extractZip: (source: string, options: { dir: string }) => Promise<void> =
/** /**
* 리소스팩 간편설치기는 Windows .exe 로 배포되므로 ffmpeg.exe 한 종류만 사용. * 리소스팩 간편설치기는 Windows .exe 로 배포되므로 ffmpeg.exe 한 종류만 사용.
* 경로: %appdata%/.mc_custom/ffmpeg.exe * 경로: %appdata%/.mc_custom/installer/ffmpeg.exe
*/ */
export function getFfmpegExePath(): string { export function getFfmpegExePath(): string {
return path.join(getMcCustomDir(), 'ffmpeg.exe') return path.join(getMcCustomInstallerDir(), 'ffmpeg.exe')
}
/**
* 0.2.1 이전 버전이 `.mc_custom/ffmpeg.exe` 에 받아둔 파일이 있으면 새 위치로
* 옮긴다.
*/
async function migrateLegacyExe(target: string): Promise<void> {
const legacy = path.join(getMcCustomDir(), 'ffmpeg.exe')
if (legacy === target) return
try {
await fs.access(legacy, fsConst.F_OK)
} catch {
return
}
try {
await fs.mkdir(path.dirname(target), { recursive: true })
await fs.rename(legacy, target)
} catch {
try { await fs.unlink(legacy) } catch { /* noop */ }
}
} }
/** BtbN/FFmpeg-Builds 의 win64-gpl 빌드. zip 내부에 bin/ffmpeg.exe 가 들어 있음. */ /** BtbN/FFmpeg-Builds 의 win64-gpl 빌드. zip 내부에 bin/ffmpeg.exe 가 들어 있음. */
@@ -33,6 +53,7 @@ export async function ensureFfmpegExe(
log?: (line: string) => void log?: (line: string) => void
): Promise<string> { ): Promise<string> {
const target = getFfmpegExePath() const target = getFfmpegExePath()
await migrateLegacyExe(target)
if (await canExecute(target)) { if (await canExecute(target)) {
log?.(t('log.ffmpegExists', { path: target })) log?.(t('log.ffmpegExists', { path: target }))
return target return target
@@ -40,7 +61,7 @@ export async function ensureFfmpegExe(
if (installPromise) return installPromise if (installPromise) return installPromise
installPromise = (async () => { installPromise = (async () => {
const dir = getMcCustomDir() const dir = getMcCustomInstallerDir()
const zipPath = path.join(dir, '.tmp_ffmpeg.zip') const zipPath = path.join(dir, '.tmp_ffmpeg.zip')
const extractDir = path.join(dir, '.tmp_ffmpeg') const extractDir = path.join(dir, '.tmp_ffmpeg')
try { try {

View File

@@ -35,6 +35,20 @@ interface RpInstallerState {
activeChildren: Set<ChildProcess> activeChildren: Set<ChildProcess>
} }
/**
* 사용자가 사이트에서 지정한 "생성되는 리소스팩 이름" 을 Windows 파일명으로 쓸 수
* 있게 정리한다. 금지 문자(\<\>:"/\\|?*\x00-\x1f) 는 `_` 로, 끝의 공백/마침표는
* 제거, 예약어(CON/PRN/...)는 앞에 `_` 를 붙인다. 빈 입력은 빈 문자열 반환 →
* 호출 측에서 폴백을 결정한다.
*/
function sanitizeOutputPackName(name: string): string {
let cleaned = (name || '').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
cleaned = cleaned.replace(/[ .]+$/, '')
if (!cleaned) return ''
if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(cleaned)) cleaned = '_' + cleaned
return cleaned
}
/** /**
* 동시 yt-dlp 프로세스 수를 CPU 코어 수로 자동 결정. * 동시 yt-dlp 프로세스 수를 CPU 코어 수로 자동 결정.
* - yt-dlp + ffmpeg 변환이 CPU 바운드라 코어 수가 가장 좋은 프록시. * - yt-dlp + ffmpeg 변환이 CPU 바운드라 코어 수가 가장 좋은 프록시.
@@ -201,11 +215,13 @@ ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promi
const normalized = packRaw ? normalizePackDefinition(packRaw as Partial<PackDefinition>) : null const normalized = packRaw ? normalizePackDefinition(packRaw as Partial<PackDefinition>) : null
const mcVersion = normalized?.mcVersion ?? '' const mcVersion = normalized?.mcVersion ?? ''
const resourcepackPath = normalized?.resourcepackPath ?? '' const resourcepackPath = normalized?.resourcepackPath ?? ''
const outputPackName = normalized?.outputPackName ?? ''
results.push({ results.push({
key: entry.file, key: entry.file,
name: entry.name || entry.file, name: entry.name || entry.file,
mcVersion, mcVersion,
resourcepackPath, resourcepackPath,
outputPackName,
list list
}) })
} catch (error) { } catch (error) {
@@ -235,6 +251,22 @@ ipcMain.handle('rp:packs:select', async (_event, packKey: string) => {
ipcMain.handle('rp:i18n:dict', () => localeDict) ipcMain.handle('rp:i18n:dict', () => localeDict)
// ── IPC: 약관 다운로드 ──────────────────────────────
// 사이트가 /manifest/terms/<kind>.md 로 노출하는 md 파일을 그대로 받아 본문(string) 으로 반환.
// rp 인스톨러에서는 'resourcepack' 과 'installer-rp' 두 종류만 실제로 사용하지만, 메인
// 인스톨러와 동일한 화이트리스트를 둬서 사이트 컴포넌트 분류와 1:1 로 매칭되게 한다.
const TERM_KIND_WHITELIST = new Set(['map', 'resourcepack', 'mod', 'installer', 'installer-rp'])
ipcMain.handle('rp:terms:get', async (_event, kind: string) => {
if (!TERM_KIND_WHITELIST.has(kind)) return { ok: false, message: 'unknown term kind' }
try {
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(kind)}.md`
const buf = await fetchBuffer(url)
return { ok: true, content: buf.toString('utf8') }
} catch (error) {
return { ok: false, message: (error as Error).message }
}
})
// ── 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(t('errors.selectPackFirst')) if (!state.selectedKey) throw new Error(t('errors.selectPackFirst'))
@@ -383,7 +415,11 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
// 2-5. 리소스팩 zip 빌드 (pack.mcmeta + sounds.json + 음악·이미지, 베이스 위에 얹기) // 2-5. 리소스팩 zip 빌드 (pack.mcmeta + sounds.json + 음악·이미지, 베이스 위에 얹기)
throwIfCancelled() throwIfCancelled()
const resourcepackName = `${state.selectedKey}_musicquiz.zip` // 사이트에서 지정한 "생성되는 리소스팩 이름" 을 우선 사용. 비어있거나 sanitize
// 결과가 빈 문자열이면 `<packKey>_resourcepack` 로 폴백.
const sanitizedOutputName = sanitizeOutputPackName(pack.outputPackName)
const resourcepackBaseName = sanitizedOutputName || `${state.selectedKey}_resourcepack`
const resourcepackName = `${resourcepackBaseName}.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(t('log.buildingZip', { name: resourcepackName })) sendLog(t('log.buildingZip', { name: resourcepackName }))
@@ -396,11 +432,30 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
workDir: tempRoot, workDir: tempRoot,
outZipPath: resourcepackPath, outZipPath: resourcepackPath,
baseZipPath, baseZipPath,
log: sendLog log: sendLog,
// build 내부에서도 단계 사이/zip 도중에 폴링해서 취소를 빠르게 반영한다.
cancelChecker: () => state.cancelRequested
}) })
throwIfCancelled()
// 2-6. %appdata%/.mc_custom/resourcepacks/ 에 배치 (위 빌드가 직접 outZipPath 에 저장) // 2-6. %appdata%/.mc_custom/resourcepacks/ 에 배치 (위 빌드가 직접 outZipPath 에 저장)
sendLog(t('log.installComplete', { path: resourcepackPath })) sendLog(t('log.installComplete', { path: resourcepackPath }))
// 2-7. 베이스 리소스팩은 우리가 임시폴더에 받아서 빌드에 이미 얹었으므로,
// 메인 설치기가 `.mc_custom/resourcepacks/<resourcepackPath>` 에 받아둔
// 원본 zip 은 MC 리소스팩 목록에 굳이 남길 필요 없다. 삭제하되, 사용자가
// outputPackName 을 base 파일명과 똑같이 둬서 우리가 방금 쓴 최종 zip 과
// 같은 경로면 그대로 둔다(우리 산출물을 지우면 안 되므로).
if (pack.resourcepackPath) {
const basePackPath = path.join(resourcepackDir, pack.resourcepackPath)
if (path.resolve(basePackPath) !== path.resolve(resourcepackPath)) {
try {
await fsp.rm(basePackPath, { force: true })
sendLog(t('log.baseRemoved', { path: basePackPath }))
} catch { /* 없으면 무시 */ }
}
}
sendProgress({ phase: 'package', message: t('progress.installComplete'), done: true }) sendProgress({ phase: 'package', message: t('progress.installComplete'), done: true })
return { resourcepackPath } return { resourcepackPath }
} finally { } finally {

View File

@@ -29,6 +29,26 @@ export interface BuildResourcepackOptions {
baseZipPath?: string baseZipPath?: string
/** 진단용 로그 콜백 (선택). */ /** 진단용 로그 콜백 (선택). */
log?: (line: string) => void log?: (line: string) => void
/**
* 사용자 취소 신호. true 가 되면 가능한 시점에 build 를 중단한다.
* - 단계 사이 (extract → meta → 음악 복사 → painting 복사 → zip) 폴링.
* - zip 생성 중에도 폴링해서 archive.abort() 로 끊는다.
* 호출자는 후속 처리에서 임시 폴더와 부분 zip 파일을 정리해야 한다.
*/
cancelChecker?: () => boolean
}
/** cancelChecker 가 true 를 반환하면 던지는 에러. main 쪽 에러 처리와 동일한 메시지를 쓰지 않고,
* 명시적인 클래스 마커로 식별하기 쉽게 한다. 메시지는 i18n 의 errors.cancelledByUser 와 1:1. */
class CancelledError extends Error {
constructor() {
super(t('errors.cancelledByUser'))
this.name = 'CancelledError'
}
}
function throwIfCancelled(checker?: () => boolean): void {
if (checker && checker()) throw new CancelledError()
} }
/** /**
@@ -41,6 +61,8 @@ export interface BuildResourcepackOptions {
* assets/musicquiz/textures/painting/cover_NN.png ← paintingDir/cover_NN.png 에서 옮김 * assets/musicquiz/textures/painting/cover_NN.png ← paintingDir/cover_NN.png 에서 옮김
*/ */
export async function buildResourcepackZip(opts: BuildResourcepackOptions): Promise<void> { export async function buildResourcepackZip(opts: BuildResourcepackOptions): Promise<void> {
const cancel = opts.cancelChecker
throwIfCancelled(cancel)
const root = path.join(opts.workDir, 'resourcepack') const root = path.join(opts.workDir, 'resourcepack')
// 베이스가 있건 없건 작업 트리는 항상 처음부터 다시 만든다. // 베이스가 있건 없건 작업 트리는 항상 처음부터 다시 만든다.
await fs.rm(root, { recursive: true, force: true }) await fs.rm(root, { recursive: true, force: true })
@@ -50,6 +72,7 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
if (opts.baseZipPath) { if (opts.baseZipPath) {
opts.log?.(t('log.baseExtract', { name: 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 })
throwIfCancelled(cancel)
} }
const soundsDir = path.join(root, 'assets', NAMESPACE, 'sounds') const soundsDir = path.join(root, 'assets', NAMESPACE, 'sounds')
@@ -125,6 +148,7 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
// 없으면 새로 생성. // 없으면 새로 생성.
} }
for (const fname of musicFiles) { for (const fname of musicFiles) {
throwIfCancelled(cancel)
// NN.ogg → track_NN.ogg 로 리네임해 패키지. // NN.ogg → track_NN.ogg 로 리네임해 패키지.
const stem = path.basename(fname, path.extname(fname)) // "01" const stem = path.basename(fname, path.extname(fname)) // "01"
const trackId = `track_${stem}` const trackId = `track_${stem}`
@@ -136,36 +160,64 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
} }
} }
await fs.writeFile(soundsJsonPath, JSON.stringify(soundsJson, null, 2) + '\n') await fs.writeFile(soundsJsonPath, JSON.stringify(soundsJson, null, 2) + '\n')
throwIfCancelled(cancel)
// 3) painting 텍스처 복사 (이미 cover_NN.png 형태). 같은 파일명은 덮어씀. // 3) painting 텍스처 복사 (이미 cover_NN.png 형태). 같은 파일명은 덮어씀.
const paintingFiles = (await fs.readdir(opts.paintingDir)) const paintingFiles = (await fs.readdir(opts.paintingDir))
.filter((n) => n.toLowerCase().endsWith('.png')) .filter((n) => n.toLowerCase().endsWith('.png'))
.sort() .sort()
for (const fname of paintingFiles) { for (const fname of paintingFiles) {
throwIfCancelled(cancel)
await fs.copyFile(path.join(opts.paintingDir, fname), path.join(paintingOutDir, fname)) await fs.copyFile(path.join(opts.paintingDir, fname), path.join(paintingOutDir, fname))
} }
throwIfCancelled(cancel)
// 4) zip 으로 묶기 // 4) zip 으로 묶기. 이 단계가 가장 길어서 별도로 cancel 폴링이 들어간다.
await fs.mkdir(path.dirname(opts.outZipPath), { recursive: true }) await fs.mkdir(path.dirname(opts.outZipPath), { recursive: true })
await zipDirectory(root, opts.outZipPath) await zipDirectory(root, opts.outZipPath, cancel)
// zip 빌드가 끝난 직후에도 한 번 더 확인: 마지막 순간 취소가 들어왔을 수 있다.
if (cancel && cancel()) {
// 부분 zip 파일이 디스크에 남아있을 수 있으니 삭제.
await fs.rm(opts.outZipPath, { force: true })
throw new CancelledError()
}
// 임시 트리는 호출자가 tempRoot 통째 정리하므로 여기서 별도 삭제 불필요. // 임시 트리는 호출자가 tempRoot 통째 정리하므로 여기서 별도 삭제 불필요.
} }
function zipDirectory(srcDir: string, outZipPath: string): Promise<void> { function zipDirectory(srcDir: string, outZipPath: string, cancelChecker?: () => boolean): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const output = createWriteStream(outZipPath) const output = createWriteStream(outZipPath)
const archive = archiver('zip', { zlib: { level: 9 } }) const archive = archiver('zip', { zlib: { level: 9 } })
output.on('close', () => resolve()) // 취소 폴링: archiver 자체는 abort() 후 'error' 이벤트로 ABORT 코드를 던진다.
output.on('error', reject) // 200ms 간격이면 사용자 체감으로는 즉각적이면서 CPU 부담은 없다.
let interval: NodeJS.Timeout | null = null
let aborted = false
if (cancelChecker) {
interval = setInterval(() => {
if (cancelChecker() && !aborted) {
aborted = true
try { archive.abort() } catch { /* 이미 끝났거나 abort 불가 상태 */ }
}
}, 200)
}
function cleanup() {
if (interval) { clearInterval(interval); interval = null }
}
output.on('close', () => { cleanup(); if (aborted) reject(new CancelledError()); else resolve() })
output.on('error', (err) => { cleanup(); reject(err) })
archive.on('warning', (err: Error & { code?: string }) => { archive.on('warning', (err: Error & { code?: string }) => {
// ENOENT 정도면 무시, 그 외는 reject. // ENOENT 정도면 무시, 그 외는 reject.
if (err.code === 'ENOENT') return if (err.code === 'ENOENT') return
reject(err) cleanup(); reject(err)
})
archive.on('error', (err: Error & { code?: string }) => {
cleanup()
if (err.code === 'ABORT' || aborted) reject(new CancelledError())
else reject(err)
}) })
archive.on('error', reject)
archive.pipe(output) archive.pipe(output)
archive.directory(srcDir, false) archive.directory(srcDir, false)
archive.finalize().catch(reject) archive.finalize().catch((err) => { cleanup(); reject(err) })
}) })
} }

View File

@@ -12,6 +12,10 @@ const api = {
selectPack: (packKey: string): Promise<void> => selectPack: (packKey: string): Promise<void> =>
ipcRenderer.invoke('rp:packs:select', packKey), ipcRenderer.invoke('rp:packs:select', packKey),
/** 약관(Markdown) 다운로드. kind: 'resourcepack' | 'installer-rp'. */
getTerm: (kind: string): Promise<{ ok: boolean; content?: string; message?: string }> =>
ipcRenderer.invoke('rp:terms:get', kind),
/** 리소스팩 빌드/설치 시작. 완료 또는 취소될 때까지 resolve 되지 않을 수 있음. */ /** 리소스팩 빌드/설치 시작. 완료 또는 취소될 때까지 resolve 되지 않을 수 있음. */
startInstall: (): Promise<{ resourcepackPath: string }> => startInstall: (): Promise<{ resourcepackPath: string }> =>
ipcRenderer.invoke('rp:install:start'), ipcRenderer.invoke('rp:install:start'),

View File

@@ -10,6 +10,12 @@ export interface RpFetchedPack {
* 빈 문자열이면 새 리소스팩을 처음부터 생성. * 빈 문자열이면 새 리소스팩을 처음부터 생성.
*/ */
resourcepackPath: string resourcepackPath: string
/**
* /manifest/<key>.json 의 outputPackName. 관리 사이트에서 설정한 "생성되는
* 리소스팩 이름". 비어 있으면 설치기가 `<key>_resourcepack` 형식으로 폴백.
* 파일명으로 쓰기 전에 Windows 금지 문자(\<\>:"/\\|?*) 는 `_` 로 치환.
*/
outputPackName: string
/** /file/list/<key>.json 의 음악·사진 목록. */ /** /file/list/<key>.json 의 음악·사진 목록. */
list: PackList list: PackList
} }

View File

@@ -3,17 +3,38 @@ import { promises as fs, createWriteStream, constants as fsConst } from 'node:fs
import path from 'node:path' 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, getMcCustomInstallerDir } from '../shared/paths.js'
import { loadComponentI18n } from '../shared/i18n.js' import { loadComponentI18n } from '../shared/i18n.js'
const { t } = loadComponentI18n('installer-rp') const { t } = loadComponentI18n('installer-rp')
/** /**
* 리소스팩 간편설치기는 Windows .exe 로 배포되므로 yt-dlp.exe 한 종류만 사용. * 리소스팩 간편설치기는 Windows .exe 로 배포되므로 yt-dlp.exe 한 종류만 사용.
* 경로: %appdata%/.mc_custom/yt-dlp.exe * 경로: %appdata%/.mc_custom/installer/yt-dlp.exe
*/ */
export function getYtDlpExePath(): string { export function getYtDlpExePath(): string {
return path.join(getMcCustomDir(), 'yt-dlp.exe') return path.join(getMcCustomInstallerDir(), 'yt-dlp.exe')
}
/**
* 0.2.1 이전 버전이 `.mc_custom/yt-dlp.exe` 에 받아둔 파일이 있으면 새 위치로
* 옮긴다. 마인크래프트 게임 폴더 루트가 외부 도구 파일로 더럽혀지지 않도록.
*/
async function migrateLegacyExe(target: string): Promise<void> {
const legacy = path.join(getMcCustomDir(), 'yt-dlp.exe')
if (legacy === target) return
try {
await fs.access(legacy, fsConst.F_OK)
} catch {
return
}
try {
await fs.mkdir(path.dirname(target), { recursive: true })
await fs.rename(legacy, target)
} catch {
// 권한·드라이브 문제 등으로 실패하면 그냥 새로 받으면 되므로 무시.
try { await fs.unlink(legacy) } catch { /* noop */ }
}
} }
const YT_DLP_DOWNLOAD_URL = const YT_DLP_DOWNLOAD_URL =
@@ -29,6 +50,7 @@ export async function ensureYtDlpExe(
log?: (line: string) => void log?: (line: string) => void
): Promise<string> { ): Promise<string> {
const target = getYtDlpExePath() const target = getYtDlpExePath()
await migrateLegacyExe(target)
if (await canExecute(target)) { if (await canExecute(target)) {
log?.(t('log.ytdlpExists', { path: target })) log?.(t('log.ytdlpExists', { path: target }))
return target return target

File diff suppressed because one or more lines are too long

View File

@@ -22,6 +22,7 @@ import type { Manifest, PackDefinition } from '../shared/types.js'
import { normalizePackDefinition } from '../shared/store.js' import { normalizePackDefinition } from '../shared/store.js'
import { loadEnv, getManifestUrl } from '../shared/env.js' import { loadEnv, getManifestUrl } from '../shared/env.js'
import { loadComponentI18n } from '../shared/i18n.js' import { loadComponentI18n } from '../shared/i18n.js'
import { LAUNCHER_PROFILE_ICON } from './launcherIcon.js'
loadEnv() loadEnv()
@@ -153,6 +154,22 @@ ipcMain.handle('packs:load', async (_event, manifestUrlInput?: string): Promise<
return results return results
}) })
// 약관(Markdown) 을 사이트(/manifest/terms/<kind>.md) 에서 받아와 그대로 돌려준다.
// 화이트리스트로 5종 제한. 네트워크 실패 시 에러 메시지가 그대로 화면에 노출된다.
const TERM_KIND_WHITELIST = new Set(['map', 'resourcepack', 'mod', 'installer', 'installer-rp'])
ipcMain.handle('terms:get', async (_event, kind: string): Promise<{ ok: boolean; content?: string; message?: string }> => {
if (!TERM_KIND_WHITELIST.has(kind)) {
return { ok: false, message: 'unknown term kind' }
}
try {
const url = `${state.baseUrl}/manifest/terms/${kind}.md`
const buf = await fetchBuffer(url)
return { ok: true, content: buf.toString('utf8') }
} catch (error) {
return { ok: false, message: (error as Error).message }
}
})
ipcMain.handle('packs:select', async (_event, packKey: string) => { ipcMain.handle('packs:select', async (_event, packKey: string) => {
if (!state.packs.has(packKey)) { if (!state.packs.has(packKey)) {
throw new Error(t('errors.packNotFound')) throw new Error(t('errors.packNotFound'))
@@ -1128,41 +1145,49 @@ ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) =
await fsp.mkdir(path.join(customRoot, 'mods'), { recursive: true }) await fsp.mkdir(path.join(customRoot, 'mods'), { recursive: true })
await fsp.mkdir(path.join(customRoot, 'resourcepacks'), { recursive: true }) await fsp.mkdir(path.join(customRoot, 'resourcepacks'), { recursive: true })
// 사용자가 기존 .minecraft 에 저장해둔 설정(options.txt, servers.dat 등)을 try {
// .mc_custom 으로 가져온다. 이미 있는 파일은 보존. // 사용자가 기존 .minecraft 에 저장해둔 설정(options.txt, servers.dat 등)을
await copyMinecraftUserSettings(customRoot) // .mc_custom 으로 가져온다. 이미 있는 파일은 보존.
await copyMinecraftUserSettings(customRoot)
if (payload.installPlatform && pack.pack.platform.type === 'fabric') { if (payload.installPlatform && pack.pack.platform.type === 'fabric') {
await installFabricLoader(pack.pack, customRoot) await installFabricLoader(pack.pack, customRoot)
} else if (payload.installPlatform && pack.pack.platform.type !== 'vanilla' && pack.pack.platform.downloadUrl) { } else if (payload.installPlatform && pack.pack.platform.type !== 'vanilla' && pack.pack.platform.downloadUrl) {
const platformUrl = resolveManifestRelative(pack.pack.platform.downloadUrl, 'platforms') const platformUrl = resolveManifestRelative(pack.pack.platform.downloadUrl, 'platforms')
const cacheDir = path.join(customRoot, 'platform-cache') const cacheDir = path.join(customRoot, 'platform-cache')
await fsp.mkdir(cacheDir, { recursive: true }) await fsp.mkdir(cacheDir, { recursive: true })
const installerPath = path.join(cacheDir, deriveFileName(platformUrl) || 'platform-installer.jar') const installerPath = path.join(cacheDir, deriveFileName(platformUrl) || 'platform-installer.jar')
sendLog(t('log.platformDownload', { type: pack.pack.platform.type, url: platformUrl })) sendLog(t('log.platformDownload', { type: pack.pack.platform.type, url: platformUrl }))
await downloadFile(platformUrl, installerPath) await downloadFile(platformUrl, installerPath)
sendLog(t('log.platformSaved', { path: installerPath })) sendLog(t('log.platformSaved', { path: installerPath }))
} else if (!payload.installPlatform) { } else if (!payload.installPlatform) {
sendLog(t('log.platformSkipped')) sendLog(t('log.platformSkipped'))
}
await downloadModsFolder(pack.pack, customRoot)
await downloadResourcepackZip(pack.pack, customRoot)
if (payload.skipMap) {
// 참가자 모드: 이전 설치 흐름에서 설치러가 풀어둔 맵이 있다면 제거한다.
// 사용자가 직접 만든 월드는 마커에 포함되지 않으므로 그대로 보존된다.
await cleanupInstallerMap(customRoot)
sendLog(t('log.skipMapZip'))
} else {
await downloadMapZip(pack.pack, customRoot)
}
// 런처가 .mc_custom 을 gameDir 로 잡아도 assets/libraries/versions 를
// 찾을 수 있도록 .minecraft 의 해당 폴더로 junction 링크.
await linkMinecraftRuntimeDirs(customRoot)
await updateLauncherProfile(pack.pack, customRoot)
} finally {
// 설치가 끝나면(또는 실패해도) 더 이상 필요 없는 platform-cache(다운받은
// fabric/forge/neoforge installer jar 캐시)를 삭제한다. 다음 실행에서 다시
// 받으면 되고, 남겨두면 사용자 .mc_custom 폴더만 차지한다. 실패 경로에서도
// 정리되도록 finally 에 둔다.
await fsp.rm(path.join(customRoot, 'platform-cache'), { recursive: true, force: true }).catch(() => {})
} }
await downloadModsFolder(pack.pack, customRoot)
await downloadResourcepackZip(pack.pack, customRoot)
if (payload.skipMap) {
// 참가자 모드: 이전 설치 흐름에서 설치러가 풀어둔 맵이 있다면 제거한다.
// 사용자가 직접 만든 월드는 마커에 포함되지 않으므로 그대로 보존된다.
await cleanupInstallerMap(customRoot)
sendLog(t('log.skipMapZip'))
} else {
await downloadMapZip(pack.pack, customRoot)
}
// 런처가 .mc_custom 을 gameDir 로 잡아도 assets/libraries/versions 를
// 찾을 수 있도록 .minecraft 의 해당 폴더로 junction 링크.
await linkMinecraftRuntimeDirs(customRoot)
await updateLauncherProfile(pack.pack, customRoot)
}) })
interface FabricInstallerMeta { interface FabricInstallerMeta {
@@ -1211,7 +1236,16 @@ async function installFabricLoader(pack: PackDefinition, customRoot: string): Pr
// 4) fabric-installer CLI 자동 실행. // 4) fabric-installer CLI 자동 실행.
// client 모드 + -noprofile: launcher_profiles.json 은 우리 코드가 직접 갱신하므로 fabric-installer 가 덮어쓰지 않게 한다. // client 모드 + -noprofile: launcher_profiles.json 은 우리 코드가 직접 갱신하므로 fabric-installer 가 덮어쓰지 않게 한다.
// JVM stdout 인코딩 강제 UTF-8:
// 한국 윈도우의 시스템 codepage 는 cp949(MS949) 라서 fabric-installer 가
// 한글을 cp949 로 stdout 에 쓰면 우리가 utf-8 로 디코드해서 깨져 보인다.
// `file.encoding` 은 default Charset, `stdout/stderr.encoding` 은
// System.out/err 의 PrintStream 인코딩(Java 18+). 둘 다 지정하면
// 구버전·신버전 JDK 모두에서 안전.
const args = [ const args = [
'-Dfile.encoding=UTF-8',
'-Dstdout.encoding=UTF-8',
'-Dstderr.encoding=UTF-8',
'-jar', installerJar, '-jar', installerJar,
'client', 'client',
'-mcversion', pack.mcVersion, '-mcversion', pack.mcVersion,
@@ -1452,6 +1486,7 @@ async function updateLauncherProfile(pack: PackDefinition, gameDir: string): Pro
...existingProfile, ...existingProfile,
name: profileKey, name: profileKey,
type: 'custom', type: 'custom',
icon: LAUNCHER_PROFILE_ICON,
gameDir, gameDir,
lastVersionId, lastVersionId,
javaArgs javaArgs

View File

@@ -11,6 +11,10 @@ const api = {
setSelectedPack: (packKey: string): Promise<void> => setSelectedPack: (packKey: string): Promise<void> =>
ipcRenderer.invoke('packs:select', packKey), ipcRenderer.invoke('packs:select', packKey),
// 약관(Markdown) 다운로드
getTerm: (kind: string): Promise<{ ok: boolean; content?: string; message?: string }> =>
ipcRenderer.invoke('terms:get', kind),
// 3-1 // 3-1
pickFolder: (): Promise<string | null> => ipcRenderer.invoke('dialog:pickFolder'), pickFolder: (): Promise<string | null> => ipcRenderer.invoke('dialog:pickFolder'),
validateInstallPath: (target: string): Promise<{ ok: boolean; message?: string }> => validateInstallPath: (target: string): Promise<{ ok: boolean; message?: string }> =>

View File

@@ -2,7 +2,10 @@ import express from 'express'
import session from 'express-session' import session from 'express-session'
import path from 'node:path' import path from 'node:path'
import fsp from 'node:fs/promises' import fsp from 'node:fs/promises'
import { manifestRootPath, manifestDirPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths.js' import {
manifestRootPath, manifestDirPath, manifestTermsDirPath,
fileDirPath, viewsDirPath, publicDirPath
} from '../shared/paths.js'
import { loadEnv } from '../shared/env.js' import { loadEnv } from '../shared/env.js'
import { t, localeDict } from './i18n.js' import { t, localeDict } from './i18n.js'
import { indexRouter } from './routes/index.js' import { indexRouter } from './routes/index.js'
@@ -59,6 +62,21 @@ app.get('/manifest.json', (_req, res) => {
res.sendFile(manifestRootPath) res.sendFile(manifestRootPath)
}) })
// 설치기에서 약관(markdown) 을 가져갈 수 있도록 화이트리스트 파일명만 허용.
app.get('/manifest/terms/:fileName', (req, res) => {
const fileName = req.params.fileName
// 화이트리스트: map.md, resourcepack.md, mod.md, installer.md, installer-rp.md
if (!/^(map|resourcepack|mod|installer|installer-rp)\.md$/.test(fileName)) {
res.status(404).send('Not Found')
return
}
res.type('text/markdown; charset=utf-8')
res.sendFile(path.join(manifestTermsDirPath, fileName), (err) => {
if (!err || res.headersSent) return
res.status(404).send('Not Found')
})
})
// 설치기에서 개별 음악퀴즈 JSON을 가져갈 수 있도록 파일 단위로만 허용. // 설치기에서 개별 음악퀴즈 JSON을 가져갈 수 있도록 파일 단위로만 허용.
// 디렉토리 리스팅, 다른 확장자, 경로 탈출은 차단. // 디렉토리 리스팅, 다른 확장자, 경로 탈출은 차단.
app.get('/manifest/:fileName', (req, res) => { app.get('/manifest/:fileName', (req, res) => {

View File

@@ -6,13 +6,18 @@ import {
listPackKeys, listPackKeys,
loadPackDefinition, loadPackDefinition,
loadPackList, loadPackList,
loadTerm,
normalizePackDefinition, normalizePackDefinition,
normalizePackList, normalizePackList,
readAccounts, readAccounts,
renamePack, renamePack,
sanitizePackKey, sanitizePackKey,
savePackList saveTerm,
savePackList,
isTermKind,
TERM_KINDS
} from '../../shared/store.js' } from '../../shared/store.js'
import type { TermKind } from '../../shared/store.js'
import { fetchReleaseVersions } from '../../shared/mojang.js' import { fetchReleaseVersions } from '../../shared/mojang.js'
import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js' import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js'
import { requireAuth } from '../middleware/auth.js' import { requireAuth } from '../middleware/auth.js'
@@ -295,6 +300,56 @@ opRouter.get('/op/datapack/:packName/images-zip', requireAuth, async (req, res,
} }
}) })
// ─── /op/agreement ─────────────────────────────────────────────────────
// 약관(Markdown) 5종 편집기. 사이트에서는 노션 스타일의 슬래시 명령으로
// 마크다운을 작성하고, 인스톨러는 /manifest/terms/<kind>.md 로 받아 표시한다.
const TERM_LABELS: Record<TermKind, string> = {
'map': '맵 약관',
'resourcepack': '리소스팩 약관',
'mod': '모드 약관',
'installer': '설치기 약관',
'installer-rp': '리소스팩 설치기 약관'
}
opRouter.get('/op/agreement', requireAuth, (req, res) => {
const items = TERM_KINDS.map((kind) => ({ kind, label: TERM_LABELS[kind] }))
res.render('op/terms', { userId: req.session.userId, items })
})
opRouter.get('/op/agreement/:kind', requireAuth, async (req, res, next) => {
try {
const kind = pickFirstValue(req.params.kind)
if (!isTermKind(kind)) {
res.status(404).send(t('errors.unknown'))
return
}
const content = await loadTerm(kind)
res.render('op/termsEditor', {
userId: req.session.userId,
kind,
label: TERM_LABELS[kind],
content
})
} catch (error) {
next(error)
}
})
opRouter.post('/op/agreement/:kind', requireAuth, async (req, res, next) => {
try {
const kind = pickFirstValue(req.params.kind)
if (!isTermKind(kind)) {
res.status(404).json({ ok: false, message: t('errors.unknown') })
return
}
const content = typeof req.body?.content === 'string' ? req.body.content : ''
await saveTerm(kind, content)
res.json({ ok: true })
} catch (error) {
next(error)
}
})
opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) => { opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
try { try {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName)) const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
@@ -314,6 +369,7 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
} as PackDefinition['platform'] & { loaderVersion?: string }, } as PackDefinition['platform'] & { loaderVersion?: string },
modsFolder: pickFirstValue(req.body.modsFolder), modsFolder: pickFirstValue(req.body.modsFolder),
resourcepackPath: pickFirstValue(req.body.resourcepackPath), resourcepackPath: pickFirstValue(req.body.resourcepackPath),
outputPackName: pickFirstValue(req.body.outputPackName),
serverMinRam: Number(pickFirstValue(req.body.serverMinRam)), serverMinRam: Number(pickFirstValue(req.body.serverMinRam)),
serverMaxRam: Number(pickFirstValue(req.body.serverMaxRam)), serverMaxRam: Number(pickFirstValue(req.body.serverMaxRam)),
clientMinRam: Number(pickFirstValue(req.body.clientMinRam)), clientMinRam: Number(pickFirstValue(req.body.clientMinRam)),

View File

@@ -5,6 +5,7 @@ import os from 'node:os'
export const projectRoot = path.resolve(__dirname, '..', '..') export const projectRoot = path.resolve(__dirname, '..', '..')
export const manifestRootPath = path.join(projectRoot, 'manifest.json') export const manifestRootPath = path.join(projectRoot, 'manifest.json')
export const manifestDirPath = path.join(projectRoot, 'manifest') export const manifestDirPath = path.join(projectRoot, 'manifest')
export const manifestTermsDirPath = path.join(manifestDirPath, 'terms')
export const accountFilePath = path.join(projectRoot, 'account.json') export const accountFilePath = path.join(projectRoot, 'account.json')
export const fileDirPath = path.join(projectRoot, 'file') export const fileDirPath = path.join(projectRoot, 'file')
export const fileListDirPath = path.join(fileDirPath, 'list') export const fileListDirPath = path.join(fileDirPath, 'list')
@@ -32,3 +33,13 @@ export function getAppDataDir(): string {
export function getMcCustomDir(): string { export function getMcCustomDir(): string {
return path.join(getAppDataDir(), '.mc_custom') return path.join(getAppDataDir(), '.mc_custom')
} }
/**
* %appdata%/.mc_custom/installer — 설치기가 자체적으로 다운로드해 사용하는
* 외부 바이너리(yt-dlp.exe, ffmpeg.exe 등) 보관 위치. .mc_custom 루트가
* 마인크래프트 게임 폴더(`mods/`, `resourcepacks/`, `saves/` 등)와 섞이지
* 않도록 별도 하위 폴더에 둔다.
*/
export function getMcCustomInstallerDir(): string {
return path.join(getMcCustomDir(), 'installer')
}

View File

@@ -1,7 +1,10 @@
import fs from 'node:fs' import fs from 'node:fs'
import fsp from 'node:fs/promises' import fsp from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import { manifestRootPath, manifestDirPath, accountFilePath, fileListDirPath } from './paths.js' import {
manifestRootPath, manifestDirPath, manifestTermsDirPath,
accountFilePath, fileListDirPath
} from './paths.js'
import type { import type {
Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType, Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType,
PackList, MusicListEntry, ImageListEntry PackList, MusicListEntry, ImageListEntry
@@ -37,6 +40,7 @@ export function defaultPackDefinition(name: string): PackDefinition {
platform: { type: 'vanilla' }, platform: { type: 'vanilla' },
modsFolder: '', modsFolder: '',
resourcepackPath: '', resourcepackPath: '',
outputPackName: '',
serverMinRam: 2048, serverMinRam: 2048,
serverMaxRam: 4096, serverMaxRam: 4096,
clientMinRam: 2048, clientMinRam: 2048,
@@ -95,6 +99,8 @@ export function normalizePackDefinition(input: Partial<PackDefinition> & Record<
}, },
modsFolder: sanitizeFolderName(input.modsFolder), modsFolder: sanitizeFolderName(input.modsFolder),
resourcepackPath: sanitizeZipFileName(input.resourcepackPath), resourcepackPath: sanitizeZipFileName(input.resourcepackPath),
// 표시명은 사용자 입력을 보존(공백/마침표 trim 만). 파일명 안전 처리는 설치기 측에서.
outputPackName: typeof input.outputPackName === 'string' ? input.outputPackName.trim() : '',
serverMinRam: clampNumber(input.serverMinRam, fallback.serverMinRam), serverMinRam: clampNumber(input.serverMinRam, fallback.serverMinRam),
serverMaxRam: clampNumber(input.serverMaxRam, fallback.serverMaxRam), serverMaxRam: clampNumber(input.serverMaxRam, fallback.serverMaxRam),
clientMinRam: clampNumber(input.clientMinRam, fallback.clientMinRam), clientMinRam: clampNumber(input.clientMinRam, fallback.clientMinRam),
@@ -288,6 +294,35 @@ export async function savePackList(packKey: string, list: PackList): Promise<voi
await fsp.writeFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8') await fsp.writeFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
} }
// ─── Terms (Markdown 약관) ─────────────────────────────────────────────
// 사이트와 인스톨러가 약관을 보여주기 위해 사용하는 markdown 파일.
// 화이트리스트로 5종만 허용한다.
export type TermKind = 'map' | 'resourcepack' | 'mod' | 'installer' | 'installer-rp'
export const TERM_KINDS: readonly TermKind[] = [
'map', 'resourcepack', 'mod', 'installer', 'installer-rp'
]
export function isTermKind(value: unknown): value is TermKind {
return typeof value === 'string' && (TERM_KINDS as readonly string[]).includes(value)
}
export async function loadTerm(kind: TermKind): Promise<string> {
const filePath = path.join(manifestTermsDirPath, `${kind}.md`)
try {
return await fsp.readFile(filePath, 'utf8')
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''
throw error
}
}
export async function saveTerm(kind: TermKind, markdown: string): Promise<void> {
await fsp.mkdir(manifestTermsDirPath, { recursive: true })
const filePath = path.join(manifestTermsDirPath, `${kind}.md`)
const normalized = (markdown ?? '').replace(/\r\n/g, '\n')
await fsp.writeFile(filePath, normalized.endsWith('\n') ? normalized : `${normalized}\n`, 'utf8')
}
export async function readAccounts(): Promise<AccountEntry[]> { export async function readAccounts(): Promise<AccountEntry[]> {
try { try {
const raw = await fsp.readFile(accountFilePath, 'utf8') const raw = await fsp.readFile(accountFilePath, 'utf8')

View File

@@ -16,6 +16,14 @@ export interface PackDefinition {
modsFolder: string modsFolder: string
/** /file/resourcepacks/<resourcepackPath> 의 단일 .zip을 그대로 다운로드. */ /** /file/resourcepacks/<resourcepackPath> 의 단일 .zip을 그대로 다운로드. */
resourcepackPath: string resourcepackPath: string
/**
* 리소스팩 설치기가 만들어 내는 최종 zip 파일의 이름(확장자 제외).
* 빈 문자열이면 설치기가 `<packKey>_resourcepack` 형식으로 기본 이름을 만든다.
* 마인크래프트 리소스팩 목록에서 사용자에게 제목처럼 보이는 값이므로
* 한글 등 자유 입력을 그대로 보존하고, 파일 시스템에서 사용할 때 금지 문자만
* `_` 로 치환한다(치환 책임은 설치기 측에 있음).
*/
outputPackName: string
serverMinRam: number serverMinRam: number
serverMaxRam: number serverMaxRam: number
clientMinRam: number clientMinRam: number

View File

@@ -15,6 +15,7 @@
<div class="dashboardActions"> <div class="dashboardActions">
<a class="secondaryButton" href="/op/list"><%= t('dashboard.editList') %></a> <a class="secondaryButton" href="/op/list"><%= t('dashboard.editList') %></a>
<a class="secondaryButton" href="/op/datapack"><%= t('dashboard.editDatapack') %></a> <a class="secondaryButton" href="/op/datapack"><%= t('dashboard.editDatapack') %></a>
<a class="secondaryButton" href="/op/agreement"><%= t('dashboard.editTerms') %></a>
<form method="post" action="/op/dashboard/create" class="inlineForm"> <form method="post" action="/op/dashboard/create" class="inlineForm">
<button type="submit" class="primaryButton"><%= t('dashboard.addPack') %></button> <button type="submit" class="primaryButton"><%= t('dashboard.addPack') %></button>
</form> </form>

View File

@@ -98,6 +98,11 @@
<input name="resourcepackPath" value="<%= pack.resourcepackPath %>" placeholder="my-pack.zip" pattern=".*\.zip|" /> <input name="resourcepackPath" value="<%= pack.resourcepackPath %>" placeholder="my-pack.zip" pattern=".*\.zip|" />
<small class="muted"><%= t('editor.resourcepackHint') %></small> <small class="muted"><%= t('editor.resourcepackHint') %></small>
</label> </label>
<label class="fullSpan">
<span><%= t('editor.outputPackName') %></span>
<input name="outputPackName" value="<%= pack.outputPackName %>" placeholder="<%= t('editor.outputPackNamePlaceholder') %>" />
<small class="muted"><%= t('editor.outputPackNameHint') %></small>
</label>
</div> </div>
<button class="primaryButton" type="submit"><%= t('common.save') %></button> <button class="primaryButton" type="submit"><%= t('common.save') %></button>

34
views/op/terms.ejs Normal file
View File

@@ -0,0 +1,34 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= t('terms.browserTitle') %></title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody">
<%- include('../partials/navbar', { userId }) %>
<main class="pageWrap">
<section class="dashboardHeader">
<div>
<a class="ghostLink" href="/op/dashboard"><%= t('common.back') %></a>
<h1 style="margin-top:20px;"><%= t('terms.title') %></h1>
</div>
</section>
<p class="muted"><%= t('terms.hint') %></p>
<section class="cardRow horizontalScroll">
<% items.forEach(function (item) { %>
<article class="packCard">
<a class="cardLink" href="/op/agreement/<%= item.kind %>">
<h2><%= item.label %></h2>
<p class="muted"><%= item.kind %>.md</p>
</a>
</article>
<% }) %>
</section>
</main>
</body>
</html>

49
views/op/termsEditor.ejs Normal file
View File

@@ -0,0 +1,49 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= t('terms.editorBrowserTitle', { label: label }) %></title>
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="/static/termsEditor.css" />
</head>
<body class="siteBody">
<%- include('../partials/navbar', { userId }) %>
<main class="pageWrap">
<section class="dashboardHeader">
<div>
<a class="ghostLink" href="/op/agreement"><%= t('common.back') %></a>
<h1 style="margin-top:20px;"><%= t('terms.editorTitle', { label: label }) %></h1>
<p class="muted"><%= kind %>.md</p>
</div>
<div class="dirtyMark" id="dirty-mark" hidden>*</div>
</section>
<div class="listActionsRow" style="align-items:center;">
<button type="button" class="primaryButton" id="saveBtn"><%= t('terms.save') %></button>
<div class="tabBar" style="margin:0 0 0 12px;">
<button type="button" class="tabBtn active" data-mode="edit"><%= t('terms.edit') %></button>
<button type="button" class="tabBtn" data-mode="preview"><%= t('terms.preview') %></button>
</div>
<span class="statusText" id="status"></span>
</div>
<p class="muted" style="font-size:12px;"><%= t('terms.slashHint') %></p>
<div id="editorWrap" class="termsEditorWrap">
<textarea id="editor" class="termsEditor" spellcheck="false"></textarea>
<div id="preview" class="termsPreview" hidden></div>
<div id="slashMenu" class="slashMenu" hidden></div>
</div>
</main>
<script>
var TERM_KIND = <%- JSON.stringify(kind) %>;
var INITIAL = <%- JSON.stringify(content) %>;
var I18N = <%- JSON.stringify(localeDict.terms) %>;
I18N.common = <%- JSON.stringify(localeDict.common) %>;
</script>
<script src="/static/termsEditor.js"></script>
</body>
</html>