37 Commits

Author SHA1 Message Date
fa5da6d052 installer-rp: fix ffmpeg 404 by using rolling 'latest' tag URL
BtbN/FFmpeg-Builds 다운로드를 releases/latest/download/ (GitHub 최신 릴리스
자동 포인터)에서 releases/download/latest/ (항상 최신 자산이 붙은 롤링 latest
태그)로 변경. 전자는 갓 생성된 autobuild-<날짜> 릴리스로 리다이렉트되는데
자산이 아직/없으면 HTTP 404 로 ffmpeg 설치가 실패한다. v0.3.10.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-07 23:54:18 +09:00
f6df5f936c installer-rp: retry image download on HTTP 429/5xx with backoff
i.ytimg.com 썸네일 서버가 연속 요청을 속도제한(HTTP 429)하면 사진
다운로드가 즉시 실패해 전체 설치가 중단됐다. 일시적 상태코드
(408/425/429/5xx)와 네트워크 오류를 Retry-After 우선 + 지수 백오프(jitter)로
최대 5회 재시도하도록 fetchBuffer 를 보강. v0.3.9.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-07 23:36:30 +09:00
dfb7acba2f installer: use 128x128 launcher profile icon (correct spec)
마인크래프트 런처 사용자 지정 설치 아이콘 규격은 128x128 PNG 고정이다
(minecraft.wiki/w/Launcher). 64x64/256x256 등 규격과 다른 크기는 런처가
무시하고 기본 아이콘(화로)으로 폴백한다. ICON_SIZE 를 128 로 맞춰 음악
아이콘이 실제로 표시되게 한다. v0.3.8.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-07 23:04:34 +09:00
f4c9504c1a installer: shrink launcher profile icon to 64x64 data URL
256x256(~44KB base64) 아이콘은 일부 마인크래프트 런처에서 렌더링되지 않고
기본 아이콘(화로)으로 폴백한다. 프로필 아이콘은 작은 타일로 표시되므로
sharp 로 64x64(~8KB base64) 로 다운스케일해 안정적으로 표시되게 한다.
exe 아이콘(build/icon.*)은 256x256 그대로 유지. v0.3.7.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-07 22:54:48 +09:00
60a52a9bec installer-rp: delete partial artifacts on failure; bump to 0.3.6
Resume previously skipped any track/cover whose file merely existed, so a
partially written NN.ogg or cover_NN.png from a failed download/convert
could be mistaken for a finished file on the next attempt. Now the
failure path removes the expected output before bailing, so only fully
completed artifacts are skipped on resume.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-05 16:30:45 +09:00
fe0d2f75e3 installer-rp: add resume-on-retry and discard-on-quit for failed installs
On install failure the temp folder is now preserved instead of wiped, so
already-downloaded songs/images are skipped on the next attempt. The
error screen offers 재시도 (resume from the failed item) and 처음으로
(discard the partial download and restart). Closing the program without
retrying still wipes the partial download via window-all-closed, and an
explicit cancel also clears it.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-05 16:23:34 +09:00
399f4af808 installer-rp: defer yt-dlp/ffmpeg reinstall until all music workers finish
Avoid the Windows file-lock race where one worker deletes/overwrites
yt-dlp.exe/ffmpeg.exe while sibling workers still run those processes.
Now pass 1 downloads all tracks and collects failures without any
mid-flight refresh; after Promise.all (no live child processes), the
binaries are force-reinstalled once and only the failed tracks retry.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-05 16:18:12 +09:00
d5f88e0e76 yt-dlp/ffmpeg: reinstall latest on failure, retry once
오래된 yt-dlp/ffmpeg 가 유튜브 변경을 못 따라가 다운로드가 실패할 때
최신 버전으로 강제 재설치 후 한 번 더 시도한다.

- server youtube.ts: ensureYtDlp(force) 추가(캐시·zipapp 삭제 후 최신 재다운로드).
  fetchVideoMeta/fetchPlaylistEntries 를 runYtDlp 로 묶어 1차 실패 시
  강제 재설치 후 재시도.
- installer ytdlp.ts/ffmpeg.ts: ensure*Exe(log, force) 추가.
- installer main.ts: 음악 워커가 곡 다운로드 실패 시 전역 1회 강제 재설치
  (refreshBinariesOnce) 후 해당 곡을 1회 재시도.
2026-06-05 16:08:26 +09:00
d9ba2b0f35 installer-rp: decode data: URL images instead of crashing
사진 URL 에 data: URI 가 들어오면 http/https 만 처리하는 다운로더가
'Protocol "data:" not supported' 로 설치 전체를 중단시키던 문제 수정.
data: URL 은 이미지 바이트를 직접 품고 있으므로 base64/percent-encoding
을 디코드해 Buffer 로 바로 반환한다. 잘못된 형식은 명확한 메시지로 거절.
2026-06-05 15:58:22 +09:00
3baf84cfd1 op: emit painting_variant author/title as plain strings
이미지 zip 의 cover_NN.json 이 title/author 를 {text:...} 객체로 내보내
일부 환경에서 인식되지 않던 문제. 요청 형식대로 author:"musicquiz",
title:"cover_NN" 평문 문자열로 바꿔 asset_id/width/height 뒤에 배치한다.
2026-06-05 15:51:59 +09:00
d22c6f17a3 store: rename file/list JSON when pack key changes
renamePack 가 manifest 정의와 약관 폴더는 새 키로 옮기면서 정작 음악·사진
목록(file/list/<key>.json)은 옛 키 파일에 남겨, 이름 변경 후 목록이 비어
보이던 버그 수정. 약관 폴더와 동일하게 fsp.rename 으로 옮기고 옛 파일이
없으면(ENOENT) 무시한다.
2026-06-05 01:57:30 +09:00
0629aa54aa datapack: emit volume:1.0 default on every SNBT entry
운영자가 곡별로 /playsound 음량을 빠르게 조정할 수 있도록
launcher 가 생성하는 모든 SNBT 항목에 volume:1.0 기본값을 항상 넣는다.
주석의 예시도 volume:1.0 으로 통일.
2026-05-28 00:37:37 +09:00
201043e289 datapack: include description in SNBT entry output
entrySnbt() now emits {title, author, alias, description} so the
mcfunction export carries the operator-entered song description
into the data modify storage command. Multiline descriptions are
flattened via newline/tab escapes inside the SNBT string literal
(escapeSnbtString extended to handle \r, \n, \t alongside the
existing backslash + quote escapes) so each `data modify` stays a
single line.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:28:37 +09:00
acd3dd995d list-editor: preserve aliases + description across URL edit
The url-edit modal's save handler was rebuilding state.music[idx]
from scratch using only meta-lookup fields, silently dropping aliases
and (newly added) description. Carry them over from prev so editing
a track's URL no longer wipes operator-entered metadata.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:48:55 +09:00
b4160aefc1 list-editor: add per-track description button + modal
Each music list row now shows a 설명 button immediately to the left of
the 별칭 button. Click opens a modal with a multi-line textarea; on
close the value is persisted into MusicListEntry.description and saved
to the same pack list JSON. The button gets a hasDesc visual indicator
when filled. Description is stored but intentionally not consumed by
datapack export or alias matching — purely informational metadata.

- types.ts: add description: string to MusicListEntry
- store.ts: normalize entry.description via sanitizeStr (defaults to '')
- listEditor.ejs: new #descModal alongside aliasModal
- listEditor.js: render descBtn left of aliasBtn, attach handlers,
  also set description: '' on playlist-fetched entries
- styles.css: extend trackRow grid to 6 cols, reuse aliasBtn styling
  for descBtn, add descTextarea sizing
- locale (ko-kr): descBtn / descModalTitle / descBack / descPlaceholder
  / descHint

Backwards-compatible: existing list JSON files without description
field normalize to ''.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:44:24 +09:00
1ac13a03ff server-youtube: fast-path reuse for cached zipapp
ensureYtDlp() and prepareYtDlp() now check the on-disk yt-dlp_zipapp
before re-running the native-fail -> network-download path. On Linux
servers where the native binary always fails verification (glibc/musl/
arch mismatch), every previous request was re-downloading both the
native (~33MB) and the zipapp (~3MB). With the fast path, after the
first successful zipapp install all subsequent requests short-circuit
to the cached zipapp.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:39:10 +09:00
542f759585 chore: remove stray 0-byte garbage file from repo root
Accidentally tracked by the previous commit's git add -A. Untracked
artifact from shell-command mangling in an earlier smoke test, not
part of any feature.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:35:30 +09:00
3248d096e4 server-youtube: add POSIX zipapp fallback when native bundled binary won't run
If the native yt-dlp_linux/yt-dlp_macos binary fails to execute (glibc
mismatch, musl libc, wrong arch) AND no system yt-dlp is on PATH, fall
back to downloading the universal Python zipapp ('yt-dlp', ~3MB) and
running it via shebang. Requires python3 on PATH, which is standard on
modern Linux servers. Also: rewrite stale Windows-flavored install-path
comments to reflect actual cross-platform behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:35:11 +09:00
8c9dc88e8b server-youtube: strip Zone.Identifier ADS on Windows after download
NTFS marks files downloaded over HTTP with a Zone.Identifier alternate
data stream, which SmartScreen/Attachment Manager can use to block
execution of yt-dlp.exe. Remove the ADS best-effort after each
download to reduce one likely cause of "execution verification failed"
in the user-reported failure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:29:27 +09:00
b769f453a3 server-youtube: diagnostic detail + PATH fallback when bundled yt-dlp won't run
probeVersion() now captures stderr/exit-code/signal/spawn-error instead of
returning a bare boolean, and ensureYtDlp() tries the bundled binary first,
falls back to `yt-dlp(.exe)` on PATH if the bundled one won't execute (AV
block, missing libc symbol, broken download), and only then re-downloads.
The final user-facing error includes the per-attempt diagnostics so we can
actually see WHY verification failed instead of the opaque
"yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다." message.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:24:54 +09:00
5c13648f63 rp-pack: fail-fast on base track/painting collision (was: silent skip)
Reviewer correctly flagged that the previous skip-on-collision
behavior silently drops new quiz tracks when the base resourcepack
already has the same track_NN key. That makes the install LOOK
successful but breaks the quiz at runtime (datapack references the
missing track).

The new behavior throws a clear error explaining which key collided
and what the user must do (remove the conflicting base entry, or
use a different base). The base assets are still preserved (we
never overwrite); we just refuse to build a broken pack.

Removed the now-unused skip-summary log keys.
2026-05-23 17:26:41 +09:00
9efd4a696a rp-pack: never overwrite base resourcepack sounds/paintings (v0.3.5)
If the base resourcepack already has audio files under
assets/musicquiz/sounds/ or entries in assets/musicquiz/sounds.json,
the build now PRESERVES them and skips any new track that would
collide. Same policy for painting textures: existing cover_*.png
in the base are not overwritten by new ones.

Per-track collision is logged so the user can see exactly what was
preserved and what was skipped. Summary counts (added / skipped)
are also logged.

Requested by 사금향: "기존에 있는걸 삭제하거나 이상하게 엎어쓰지
말것" — preserve base assets unconditionally.
2026-05-23 17:18:46 +09:00
c580a50fd4 installer: escape agreement tab labels (XSS hardening)
RP installer already escapes k.tab; main installer was injecting it raw.
Add escapeHtml helper and apply to tab id/label so admin-supplied
agreement labels can't break the HTML.
2026-05-20 10:29:06 +09:00
38df72e4f6 terms: phrase agreement-list failure as install failure (per request)
User asked for "약관 표시 실패시 설치 실패로 처리". The block-on-failure path
is already in place; this just sharpens the message so users see "설치를
진행할 수 없습니다" rather than a soft retry prompt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 10:22:02 +09:00
6447b1cb78 terms: block install on terms list fetch failure (retry UI)
Reviewer caught that v0.3.4 was bypassing the agreement step entirely on
network/server errors, letting users install without ever seeing terms.
Now only the explicit empty-list response (terms:[]) skips the step.
Network errors, 404s, and IPC failures render an error page with Back/Retry
buttons; no next button is exposed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 10:20:49 +09:00
9ba5dc6b7b terms: per-term installer visibility toggles + universal delete (v0.3.4)
- _meta.json: customLabels -> terms.{label,showInInstaller,showInInstallerRp}
- Drop builtin protection; any term kind can be deleted/added/toggled
- New public route /manifest/terms/<pack>/index.json for installer term lists
- Installers fetch terms:list dynamically; skip agreement step if list empty
- Term editor: 2 visibility checkboxes (설치기 / 리소스팩 설치기), multi-select
- Migration from old schema preserves custom labels (default: visible in both)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 10:14:42 +09:00
05dc9d7166 terms: seed-on-fetch + rename/delete sync (v0.3.3)
- public route `/manifest/terms/:packKey/:fileName` 가 sendFile 전에
  `ensurePackTermsDir(packKey)` 를 호출하도록 수정. 관리자가 사이트 약관
  페이지를 한 번도 열지 않은 fresh 배포에서도 설치기가 정상적으로 약관을
  받을 수 있다. `loadPackDefinition` 으로 실제 pack 만 허용해 임의 키로
  빈 폴더가 생성되는 것을 차단.
- `renamePack`: pack JSON 이름이 바뀌면 `manifest/terms/<oldKey>/` 도
  `<newKey>/` 로 함께 rename.
- `deletePackKeys`: pack 삭제 시 약관 폴더도 `fs.rm` 으로 정리 — 동일 key
  재생성 시 옛 약관 부활 방지.
- `ensurePackTermsDir` export.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:39:28 +09:00
25977d894b terms: per-pack storage + import from another pack (v0.3.2)
- store.ts: 약관을 manifest/terms/<packKey>/ 폴더별로 저장. 첫 접근 시
  legacy 전역 .md 파일을 시드로 자동 복사한다.
- importTerms() 추가: 다른 음악퀴즈의 .md + _meta.json 을 현재 pack 으로
  복사한다. 동일 kind 는 source 값으로 덮어쓴다.
- /op/agreement 라우트를 세 단계로 분리:
  · /op/agreement → 음악퀴즈 카드 선택 페이지
  · /op/agreement/:packName → 해당 pack 의 약관 목록 + 추가 + 불러오기
  · /op/agreement/:packName/:kind → 에디터
- 공개 라우트도 /manifest/terms/:packKey/:fileName 으로 변경.
- 설치기 main.ts: state.selectedKey 를 약관 URL 에 포함하도록 수정 (메인 +
  rp 양쪽). pack 미선택 상태에서는 에러 반환.
- termsEditor.js: PACK_KEY 를 받아 저장 URL 에 포함.
- 다른 음악퀴즈 후보 select + 확인 모달 + locale 추가.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:29:04 +09:00
c14b0507c7 terms: dark editor BG, vertical row layout, add/delete custom kinds
- 약관 편집기 배경/슬래시 메뉴를 사이트 다크 팔레트로 통일 (흰 배경 + 흰 글씨 가시성 문제 해결)
- 약관 목록을 가로 풀폭 1줄씩 세로로 쌓이는 레이아웃으로 변경
- 사용자 정의 약관 추가/삭제 지원
  - manifest/terms/_meta.json 에 라벨 저장
  - builtin 5종(map/resourcepack/mod/installer/installer-rp)은 삭제 불가, "기본" 배지 표시
  - kind 식별자 규칙: 소문자/숫자/하이픈 32자 이내
  - 공개 라우트 /manifest/terms/<file>.md 는 isPublicTermsFile() 로 _meta.json 차단
- 0.3.0 → 0.3.1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:12:10 +09:00
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
40 changed files with 3235 additions and 265 deletions

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,211 @@ function renderStep1() {
}) })
} }
// 약관 동의 페이지: 1단계 직후, 2단계 설치 진입 전에 노출.
// v0.3.4~ : 사이트의 visibility 토글에 따라 표시할 약관이 결정된다. 명시적으로 빈 목록(terms:[])
// 정상 응답일 때만 단계를 건너뛰고, 네트워크/서버 오류는 차단 후 다시 시도 UI를 보여준다.
function renderAgreement() {
setActiveStep(1)
clearPage()
var loadingSection = document.createElement('section')
loadingSection.className = 'page'
loadingSection.innerHTML = '<h2>' + escapeHtml(tt('agreement.heading')) + '</h2>' +
'<p class="formMessage">' + escapeHtml(tt('agreement.loading')) + '</p>'
pageHost.appendChild(loadingSection)
api.getTermsList().then(function (res) {
if (!res || !res.ok) {
showAgreementError((res && res.message) || 'unknown')
return
}
var terms = (res.terms || []).map(function (t) {
return { id: t.kind, tab: t.label }
})
if (terms.length === 0) {
renderStep2()
return
}
clearPage()
renderAgreementWithKinds(terms)
}).catch(function (err) {
showAgreementError(err && err.message ? err.message : 'unknown')
})
}
// 약관 목록을 못 받아왔을 때: 사용자에게 오류 + 다시 시도 옵션. 동의 없이 설치 단계로
// 자동 진입하지 않도록 next 버튼을 두지 않는다.
function showAgreementError(message) {
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + escapeHtml(tt('agreement.heading')) + '</h2>' +
'<p class="formMessage error">' + escapeHtml(tt('agreement.listLoadFailed', { message: message })) + '</p>' +
'<div class="actionRow">' +
'<button class="secondaryBtn" id="back">' + escapeHtml(tt('common.back')) + '</button>' +
'<button class="primaryBtn" id="retry">' + escapeHtml(tt('agreement.retry')) + '</button>' +
'</div>'
pageHost.appendChild(section)
section.querySelector('#back').addEventListener('click', renderStep1)
section.querySelector('#retry').addEventListener('click', renderAgreement)
}
function renderAgreementWithKinds(KINDS) {
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 +460,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,8 +485,43 @@ 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) {
renderStep1() // 취소: backend 가 임시 파일을 이미 정리했음. 조용히 처음 단계로.
renderStep1()
return
}
// 그 외 오류: 받아둔 음악·사진은 보존되어 있으므로 '재시도' 로 이어받을 수 있다.
showInstallError((err && err.message) || String(err))
})
}
// 설치 실패 화면: 이어받기('재시도')와 처음으로('처음으로') 선택지를 제공한다.
// 재시도 시 이미 받아둔 곡·사진은 건너뛰고 실패한 지점부터 이어서 설치한다.
function showInstallError(message) {
setActiveStep(2)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + escapeHtml(tt('step2.heading')) + '</h2>' +
'<p class="formMessage error">' + escapeHtml(tt('install.errorMessage', { message: message })) + '</p>' +
'<p class="formMessage">' + escapeHtml(tt('install.resumeHint')) + '</p>' +
'<div class="actionRow">' +
' <button class="secondaryBtn" id="startOver">' + escapeHtml(tt('install.startOver')) + '</button>' +
' <button class="primaryBtn" id="retry">' + escapeHtml(tt('install.retry')) + '</button>' +
'</div>'
pageHost.appendChild(section)
section.querySelector('#retry').addEventListener('click', function () {
// 같은 음악퀴즈로 설치를 다시 시작. backend 가 받아둔 산출물을 건너뛴다.
renderStep2()
})
section.querySelector('#startOver').addEventListener('click', function () {
// 이어받지 않고 처음으로: 받아둔 임시 파일을 정리한 뒤 1단계로.
api.discardInstall().then(function () {
renderStep1()
}).catch(function () {
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,214 @@ function renderStep1() {
})() })()
} }
// 약관 동의 페이지: 음악퀴즈 선택 직후, 싱글/멀티 선택(step2) 진입 전에 노출.
// v0.3.4~ : 어떤 약관을 표시할지는 사이트(/manifest/terms/<pack>/index.json) 가
// 결정. 메인 인스톨러용으로 표시 토글된 항목만 받아 탭을 만든다. 목록이 비어 있는 (terms:[])
// 정상 응답일 때만 단계 자체를 건너뛴다. 네트워크 오류/404/서버 오류는 사용자가 약관 동의
// 없이 설치로 넘어가는 것을 막기 위해 오류 화면 + 다시 시도 버튼으로 차단한다.
function renderAgreement() {
setActiveStep(1)
clearPage()
var loadingSection = document.createElement('section')
loadingSection.className = 'page'
loadingSection.innerHTML = '<h2>' + tt('agreement.heading') + '</h2>' +
'<p class="formMessage">' + tt('agreement.loading') + '</p>'
pageHost.appendChild(loadingSection)
installerApi.getTermsList().then(function (res) {
if (!res || !res.ok) {
showAgreementError((res && res.message) || 'unknown')
return
}
var terms = (res.terms || []).map(function (t) {
return { id: t.kind, tab: t.label }
})
if (terms.length === 0) {
// 명시적으로 표시 대상이 0개라고 서버가 알려준 정상 응답 → 약관 단계 스킵.
renderStep2()
return
}
clearPage()
renderAgreementWithKinds(terms)
}).catch(function (err) {
showAgreementError(err && err.message ? err.message : 'unknown')
})
}
// 약관 목록을 못 받아왔을 때: 사용자에게 오류 + 다시 시도/뒤로 가기 옵션을 보여준다.
// 동의 없이 설치 단계로 넘어가지 않도록 next 버튼을 두지 않는다.
function showAgreementError(message) {
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + tt('agreement.heading') + '</h2>' +
'<p class="formMessage error">' + tt('agreement.listLoadFailed', { message: message }) + '</p>' +
'<div class="actionRow">' +
'<button class="secondaryBtn" id="back">' + tt('common.back') + '</button>' +
'<button class="primaryBtn" id="retry">' + tt('agreement.retry') + '</button>' +
'</div>'
pageHost.appendChild(section)
section.querySelector('#back').addEventListener('click', renderStep1)
section.querySelector('#retry').addEventListener('click', renderAgreement)
}
function renderAgreementWithKinds(KINDS) {
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="' + escapeHtml(k.id) + '">' + escapeHtml(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 +401,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() {
@@ -752,6 +960,12 @@ function renderStep5() {
}) })
} }
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : c === '"' ? '&quot;' : '&#39;'
})
}
// 시작 진입점: 사전을 먼저 받아서 정적 텍스트 갱신 후 첫 페이지 렌더. // 시작 진입점: 사전을 먼저 받아서 정적 텍스트 갱신 후 첫 페이지 렌더.
;(async function () { ;(async function () {
try { try {

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,19 @@
"step1": { "step1": {
"heading": "음악퀴즈 선택" "heading": "음악퀴즈 선택"
}, },
"agreement": {
"heading": "약관 동의",
"intro": "리소스팩을 설치하기 전에 아래 약관을 모두 확인하고 동의해 주세요.",
"tabResourcepack": "리소스팩 약관",
"tabInstaller": "리소스팩 설치기 약관",
"loading": "약관을 불러오는 중...",
"loadFailed": "약관 로드 실패: {{message}}",
"listLoadFailed": "약관 표시에 실패하여 설치를 진행할 수 없습니다.\n사유: {{message}}\n네트워크 상태를 확인하고 다시 시도하거나, 처음 단계로 돌아가 주세요.",
"retry": "다시 시도",
"agreeAll": "위 모든 약관에 동의합니다.",
"agreeRequired": "약관에 동의해야 다음 단계로 진행할 수 있습니다.",
"cancelling": "취소 중…"
},
"step2": { "step2": {
"heading": "리소스팩 설치", "heading": "리소스팩 설치",
"description": "음악·사진을 받아 리소스팩을 만들고 <code>%appdata%/.mc_custom/resourcepacks/</code> 에 자동 설치합니다.", "description": "음악·사진을 받아 리소스팩을 만들고 <code>%appdata%/.mc_custom/resourcepacks/</code> 에 자동 설치합니다.",
@@ -51,6 +65,12 @@
"heading": "완료", "heading": "완료",
"message": "리소스팩 설치를 완료했습니다." "message": "리소스팩 설치를 완료했습니다."
}, },
"install": {
"errorMessage": "설치 중 오류가 발생했습니다: {{message}}",
"resumeHint": "재시도를 누르면 이미 받아둔 음악·사진은 건너뛰고 실패한 지점부터 이어서 설치합니다. 처음으로를 누르거나 프로그램을 닫으면 지금까지 받아둔 파일은 삭제됩니다.",
"retry": "재시도",
"startOver": "처음으로"
},
"log": { "log": {
"manifestDownload": "manifest 다운로드: {{url}}", "manifestDownload": "manifest 다운로드: {{url}}",
"packDefFailed": "팩 정의 로드 실패 ({{file}}): {{message}} — mcVersion 폴백", "packDefFailed": "팩 정의 로드 실패 ({{file}}): {{message}} — mcVersion 폴백",
@@ -68,13 +88,19 @@
"musicStart": "음악 다운로드 시작 ({{total}}곡, 동시 {{concurrency}}개, 시차 {{stagger}}ms)", "musicStart": "음악 다운로드 시작 ({{total}}곡, 동시 {{concurrency}}개, 시차 {{stagger}}ms)",
"musicTrackStart": "{{idx}}번 노래 다운로드 시작", "musicTrackStart": "{{idx}}번 노래 다운로드 시작",
"musicTrackDone": "{{idx}}번 노래 완료: {{name}}", "musicTrackDone": "{{idx}}번 노래 완료: {{name}}",
"musicTrackSkip": "{{idx}}번 노래는 이전에 받아둠 → 건너뜀(이어받기)",
"musicRefreshRetry": "{{count}}곡 다운로드 실패 → yt-dlp/ffmpeg 최신 버전으로 재설치 후 실패한 곡만 재시도",
"ytdlpReinstall": "yt-dlp.exe 최신 버전으로 강제 재설치 중…",
"ffmpegReinstall": "ffmpeg.exe 최신 버전으로 강제 재설치 중…",
"imageStart": "사진 다운로드 시작 ({{total}}장)", "imageStart": "사진 다운로드 시작 ({{total}}장)",
"imageDownloading": "{{idx}}번 사진 다운로드 중…", "imageDownloading": "{{idx}}번 사진 다운로드 중…",
"imageDone": "{{idx}}번 사진 완료: {{name}}", "imageDone": "{{idx}}번 사진 완료: {{name}}",
"imageSkip": "{{idx}}번 사진은 이전에 받아둠 → 건너뜀(이어받기)",
"baseDownload": "베이스 리소스팩 다운로드: {{path}}", "baseDownload": "베이스 리소스팩 다운로드: {{path}}",
"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}}개 중단…",
@@ -91,6 +117,8 @@
"packFormatFallback": "pack_format = {{format}} (mcVersion \"{{version}}\" 매칭 실패, 최신 폴백)", "packFormatFallback": "pack_format = {{format}} (mcVersion \"{{version}}\" 매칭 실패, 최신 폴백)",
"packFormatRange": "호환 범위 선언: pack_format {{min}} ~ {{max}} (supported_formats / min_format / max_format 모두 기록)", "packFormatRange": "호환 범위 선언: pack_format {{min}} ~ {{max}} (supported_formats / min_format / max_format 모두 기록)",
"soundsMerged": "기존 sounds.json 병합 ({{count}}개 항목)", "soundsMerged": "기존 sounds.json 병합 ({{count}}개 항목)",
"tracksAdded": "음악 트랙 추가됨: {{count}}곡",
"paintingsAdded": "사진 텍스처 추가됨: {{count}}장",
"ytdlpLine": "yt-dlp> {{line}}" "ytdlpLine": "yt-dlp> {{line}}"
}, },
"progress": { "progress": {
@@ -120,10 +148,13 @@
"ytdlpNoStderr": "(stderr 없음)", "ytdlpNoStderr": "(stderr 없음)",
"ytdlpMissingOutput": "예상 출력파일이 없음: {{path}}", "ytdlpMissingOutput": "예상 출력파일이 없음: {{path}}",
"imageMetaUnknown": "이미지 크기를 읽지 못함", "imageMetaUnknown": "이미지 크기를 읽지 못함",
"imageDataUrlInvalid": "data: URL 형식이 올바르지 않아 이미지를 디코드하지 못했습니다.",
"ytdlpVerifyFailed": "yt-dlp.exe 다운로드는 됐지만 실행 검증에 실패했습니다.", "ytdlpVerifyFailed": "yt-dlp.exe 다운로드는 됐지만 실행 검증에 실패했습니다.",
"ytdlpInstallFailed": "yt-dlp.exe 자동 설치 실패: {{message}}", "ytdlpInstallFailed": "yt-dlp.exe 자동 설치 실패: {{message}}",
"ffmpegNotInZip": "zip 내부에서 ffmpeg.exe 를 찾을 수 없습니다.", "ffmpegNotInZip": "zip 내부에서 ffmpeg.exe 를 찾을 수 없습니다.",
"ffmpegVerifyFailed": "ffmpeg.exe 다운로드는 됐지만 실행 검증에 실패했습니다.", "ffmpegVerifyFailed": "ffmpeg.exe 다운로드는 됐지만 실행 검증에 실패했습니다.",
"ffmpegInstallFailed": "ffmpeg.exe 자동 설치 실패: {{message}}" "ffmpegInstallFailed": "ffmpeg.exe 자동 설치 실패: {{message}}",
"baseTrackCollision": "베이스 리소스팩에 같은 트랙 ID 가 이미 있어 설치를 중단합니다: {{trackId}}\n베이스 자산을 보존하면서 새 트랙을 같은 ID 로 추가할 수 없습니다. 베이스의 sounds.json 엔트리/sounds 폴더에서 충돌하는 항목을 제거하거나 다른 베이스를 사용하세요.",
"basePaintingCollision": "베이스 리소스팩에 같은 사진 파일이 이미 있어 설치를 중단합니다: {{name}}\n베이스의 painting 텍스처를 보존하면서 같은 파일명을 추가할 수 없습니다. 베이스에서 충돌하는 파일을 제거하거나 다른 베이스를 사용하세요."
} }
} }

View File

@@ -30,6 +30,19 @@
"logViewer": { "logViewer": {
"title": "설치 로그" "title": "설치 로그"
}, },
"agreement": {
"heading": "약관 동의",
"intro": "설치 전에 아래 약관을 모두 확인하고 동의해 주세요.",
"tabMap": "맵 약관",
"tabMod": "모드 약관",
"tabInstaller": "설치기 약관",
"loading": "약관을 불러오는 중...",
"loadFailed": "약관 로드 실패: {{message}}",
"listLoadFailed": "약관 표시에 실패하여 설치를 진행할 수 없습니다.\n사유: {{message}}\n네트워크 상태를 확인하고 다시 시도하거나, 처음 단계로 돌아가 주세요.",
"retry": "다시 시도",
"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": "등록된 음악퀴즈가 없습니다. \"음악퀴즈 추가\" 버튼으로 새로 만들어 보세요.",
@@ -77,6 +78,11 @@
"aliasPlaceholder": "별칭 입력", "aliasPlaceholder": "별칭 입력",
"aliasRemove": "삭제", "aliasRemove": "삭제",
"aliasHint": "정답으로 인정할 다른 표기·번역·약칭을 추가할 수 있습니다.", "aliasHint": "정답으로 인정할 다른 표기·번역·약칭을 추가할 수 있습니다.",
"descBtn": "설명",
"descModalTitle": "설명 - {{title}}",
"descBack": "← 돌아가기",
"descPlaceholder": "이 곡에 대한 설명을 입력하세요",
"descHint": "곡 소개·트리비아 등 자유 메모. 정답 채점이나 데이터팩에는 사용되지 않습니다.",
"metaLoading": "메타데이터 가져오는 중…", "metaLoading": "메타데이터 가져오는 중…",
"metaFailedShort": "메타 조회 실패", "metaFailedShort": "메타 조회 실패",
"metaFailedTitle": "메타데이터 조회 실패", "metaFailedTitle": "메타데이터 조회 실패",
@@ -124,11 +130,68 @@
"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": "약관 수정",
"pickPackHint": "약관을 수정할 음악퀴즈를 선택하세요. 각 음악퀴즈마다 약관을 따로 보관합니다.",
"packBrowserTitle": "{{name}} — 약관 수정",
"packTitle": "{{name}} 약관 수정",
"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저장 없이 이 페이지를 떠나시겠습니까?",
"visibilityHeading": "표시 대상 (중복 선택 가능)",
"visibilityInstaller": "설치기에 표시",
"visibilityInstallerRp": "리소스팩 설치기에 표시",
"visibilityInstallerShort": "설치기",
"visibilityInstallerRpShort": "리소스팩",
"addHeading": "약관 추가",
"kindLabel": "식별자",
"kindPlaceholder": "예: privacy",
"kindHint": "소문자/숫자/하이픈만 사용, 32자 이내. 파일명과 URL 에 그대로 쓰입니다.",
"labelLabel": "표시 이름",
"labelPlaceholder": "예: 개인정보 처리방침",
"addButton": "추가",
"deleteButton": "삭제",
"deleteConfirm": "정말 \"{{label}}\" 약관을 삭제할까요? 이 동작은 되돌릴 수 없습니다.",
"invalidKind": "식별자는 소문자/숫자/하이픈만, 32자 이내여야 합니다.",
"createFailed": "약관 추가 실패",
"cannotDeleteBuiltin": "기본 약관은 삭제할 수 없습니다.",
"importHeading": "다른 음악퀴즈에서 불러오기",
"importSourceLabel": "가져올 음악퀴즈",
"importSourcePlaceholder": "음악퀴즈를 선택하세요",
"importHint": "선택한 음악퀴즈의 모든 약관(.md + 라벨)을 현재 음악퀴즈로 복사합니다. 같은 식별자의 약관이 있으면 덮어씁니다.",
"importButton": "불러오기",
"importEmpty": "불러올 수 있는 다른 음악퀴즈가 없습니다.",
"importConfirm": "선택한 음악퀴즈의 약관을 현재 음악퀴즈로 복사합니다. 같은 식별자의 약관은 덮어쓰여집니다. 진행할까요?",
"importFailed": "약관 불러오기 실패",
"invalidImportSource": "올바르지 않은 음악퀴즈입니다."
},
"datapack": { "datapack": {
"browserTitle": "데이터팩 수정", "browserTitle": "데이터팩 수정",
"title": "데이터팩 수정", "title": "데이터팩 수정",
@@ -162,6 +225,7 @@
"youtube": { "youtube": {
"ytdlpUnavailable": "yt-dlp 를 준비하지 못했습니다. (수동 입력으로 진행)", "ytdlpUnavailable": "yt-dlp 를 준비하지 못했습니다. (수동 입력으로 진행)",
"ytdlpVerifyFailed": "yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다.", "ytdlpVerifyFailed": "yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다.",
"ytdlpVerifyFailedDetail": "yt-dlp 를 사용할 수 없습니다. 시도한 경로 진단: {{detail}}",
"ytdlpInstallFailed": "yt-dlp 자동 설치에 실패했습니다: {{message}}", "ytdlpInstallFailed": "yt-dlp 자동 설치에 실패했습니다: {{message}}",
"ytdlpVideoFailed": "yt-dlp 영상 조회 실패 (code={{code}}): {{detail}}", "ytdlpVideoFailed": "yt-dlp 영상 조회 실패 (code={{code}}): {{detail}}",
"ytdlpPlaylistFailed": "yt-dlp 플레이리스트 조회 실패 (code={{code}}): {{detail}}", "ytdlpPlaylistFailed": "yt-dlp 플레이리스트 조회 실패 (code={{code}}): {{detail}}",

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.1", "version": "0.3.10",
"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": {

View File

@@ -103,6 +103,7 @@
var aliasLabel = aliasCount > 0 var aliasLabel = aliasCount > 0
? tt('aliasBtnWithCount', { count: aliasCount }) ? tt('aliasBtnWithCount', { count: aliasCount })
: tt('aliasBtn') : tt('aliasBtn')
var hasDesc = typeof entry.description === 'string' && entry.description.trim().length > 0
li.innerHTML = li.innerHTML =
'<span class="rowNum">' + (idx + 1) + '</span>' + '<span class="rowNum">' + (idx + 1) + '</span>' +
'<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy" draggable="false"/>' + '<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy" draggable="false"/>' +
@@ -114,12 +115,16 @@
escapeHtml(entry.artist || '') + escapeHtml(entry.artist || '') +
'</div>' + '</div>' +
'</div>' + '</div>' +
'<button type="button" class="descBtn' + (hasDesc ? ' hasDesc' : '') + '" data-desc-open="' + idx + '" draggable="false">' +
escapeHtml(tt('descBtn')) +
'</button>' +
'<button type="button" class="aliasBtn' + (aliasCount > 0 ? ' hasAliases' : '') + '" data-alias-open="' + idx + '" draggable="false">' + '<button type="button" class="aliasBtn' + (aliasCount > 0 ? ' hasAliases' : '') + '" data-alias-open="' + idx + '" draggable="false">' +
escapeHtml(aliasLabel) + escapeHtml(aliasLabel) +
'</button>' + '</button>' +
'<span class="rowDur">' + fmtTime(entry.durationSec) + '</span>' '<span class="rowDur">' + fmtTime(entry.durationSec) + '</span>'
attachDraggable(li, 'music', idx) attachDraggable(li, 'music', idx)
attachInlineEdit(li, idx) attachInlineEdit(li, idx)
attachDescBtn(li, idx)
attachAliasBtn(li, idx) attachAliasBtn(li, idx)
ol.appendChild(li) ol.appendChild(li)
}) })
@@ -391,7 +396,10 @@
url: meta.url || url, url: meta.url || url,
title: meta.title || prev.title || '', title: meta.title || prev.title || '',
artist: meta.channel || prev.artist || '', artist: meta.channel || prev.artist || '',
durationSec: typeof meta.durationSec === 'number' ? meta.durationSec : (prev.durationSec || 0) durationSec: typeof meta.durationSec === 'number' ? meta.durationSec : (prev.durationSec || 0),
// URL 만 바뀌었다고 운영자가 손으로 입력한 메타(별칭/설명)까지 날려선 안 된다.
aliases: Array.isArray(prev.aliases) ? prev.aliases : [],
description: typeof prev.description === 'string' ? prev.description : ''
} }
markDirty() markDirty()
closeAllModals() closeAllModals()
@@ -527,6 +535,57 @@
if (e.target === aliasModal) closeAliasModalSaving() if (e.target === aliasModal) closeAliasModalSaving()
}) })
// ── 설명 모달 (음악) ─────────────────────────────────
// 별칭 모달과 같은 패턴: 모달 닫힐 때 textarea 값을 state.music[idx].description 에 저장.
var descModal = document.getElementById('descModal')
var descTextarea = document.getElementById('desc-textarea')
var descModalTitleEl = document.getElementById('desc-modal-title')
var descBackBtn = document.getElementById('desc-back')
var descEditingIdx = -1
function attachDescBtn(li, idx) {
var btn = li.querySelector('[data-desc-open]')
if (!btn) return
btn.addEventListener('mousedown', function (e) { e.stopPropagation() })
btn.addEventListener('click', function (e) {
e.stopPropagation()
openDescModal(idx)
})
}
function openDescModal(idx) {
if (!state.music[idx]) return
descEditingIdx = idx
var entry = state.music[idx]
descModalTitleEl.textContent = tt('descModalTitle', { title: entry.title || tt('titleFallback') })
descTextarea.value = typeof entry.description === 'string' ? entry.description : ''
descModal.hidden = false
setTimeout(function () { descTextarea.focus() }, 0)
}
function closeDescModalSaving() {
if (descEditingIdx < 0 || !state.music[descEditingIdx]) {
descModal.hidden = true
descEditingIdx = -1
return
}
// textarea 값을 그대로 저장하되, 줄바꿈은 보존하고 양끝 공백만 다듬는다.
var nextDesc = (descTextarea.value || '').replace(/\r\n/g, '\n').trim()
var prev = state.music[descEditingIdx].description || ''
if (nextDesc !== prev) {
state.music[descEditingIdx].description = nextDesc
markDirty()
renderMusic()
}
descModal.hidden = true
descEditingIdx = -1
}
descBackBtn.addEventListener('click', closeDescModalSaving)
descModal.addEventListener('click', function (e) {
if (e.target === descModal) closeDescModalSaving()
})
// ── 사진목록: 음악목록 그대로 복사 ───────────────── // ── 사진목록: 음악목록 그대로 복사 ─────────────────
document.getElementById('image-from-music').addEventListener('click', function () { document.getElementById('image-from-music').addEventListener('click', function () {
if (state.music.length === 0) { if (state.music.length === 0) {
@@ -637,7 +696,7 @@
var entries = result.body.entries || [] var entries = result.body.entries || []
if (target === 'music') { if (target === 'music') {
state.music = entries.map(function (e) { state.music = entries.map(function (e) {
return { url: e.url, title: e.title || '', artist: e.channel || '', durationSec: e.durationSec || 0 } return { url: e.url, title: e.title || '', artist: e.channel || '', durationSec: e.durationSec || 0, aliases: [], description: '' }
}) })
renderMusic() renderMusic()
} else { } else {

View File

@@ -407,19 +407,24 @@ body.siteBody.centerLayout {
.trackList { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; } .trackList { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
.trackRow { .trackRow {
display: grid; display: grid;
grid-template-columns: 36px 80px 1fr auto auto; grid-template-columns: 36px 80px 1fr auto auto auto;
gap: 12px; align-items: center; gap: 12px; align-items: center;
padding: 8px 12px; background: var(--bg-card); padding: 8px 12px; background: var(--bg-card);
border: 1px solid var(--border); border-radius: 8px; border: 1px solid var(--border); border-radius: 8px;
cursor: grab; user-select: none; cursor: grab; user-select: none;
} }
.aliasBtn { .aliasBtn, .descBtn {
background: var(--bg); border: 1px solid var(--border); color: var(--text); background: var(--bg); border: 1px solid var(--border); color: var(--text);
padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 12px; padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 12px;
white-space: nowrap; white-space: nowrap;
} }
.aliasBtn:hover { border-color: var(--accent); } .aliasBtn:hover, .descBtn:hover { border-color: var(--accent); }
.aliasBtn.hasAliases { border-color: var(--accent); color: var(--accent); } .aliasBtn.hasAliases, .descBtn.hasDesc { border-color: var(--accent); color: var(--accent); }
.descTextarea {
width: 100%; min-height: 140px; resize: vertical;
font-family: inherit; font-size: 13px; line-height: 1.5;
padding: 8px 10px;
}
/* 별칭 모달 */ /* 별칭 모달 */
.aliasModalHeader { .aliasModalHeader {

106
public/termsEditor.css Normal file
View File

@@ -0,0 +1,106 @@
/* Notion 스타일 약관 편집기 전용 스타일.
* 텍스트영역과 미리보기 영역을 동일한 폭/타이포로 보여 주어 입력 ↔ 미리보기
* 전환 시 시각적 점프가 최소화되도록 한다. 슬래시 메뉴는 caret 좌표 위에
* 절대 위치로 띄운다. 색은 사이트 다크 팔레트(var(--bg-card) 등)에 맞춘다. */
.termsEditorWrap {
position: relative;
margin-top: 12px;
}
.termsEditor {
width: 100%;
min-height: 60vh;
padding: 16px 18px;
border: 1px solid var(--border, #30363d);
border-radius: 8px;
background: var(--bg-card, #1f242c);
color: var(--text, #e6edf3);
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;
caret-color: var(--accent, #58a6ff);
}
.termsEditor:focus {
border-color: var(--accent, #58a6ff);
box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.25);
}
.termsPreview {
min-height: 60vh;
padding: 16px 18px;
border: 1px solid var(--border, #30363d);
border-radius: 8px;
background: var(--bg-alt, #161b22);
color: var(--text, #e6edf3);
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 var(--border, #30363d); margin: 12px 0; }
.termsPreview blockquote {
margin: 8px 0; padding: 4px 12px;
border-left: 3px solid var(--border, #30363d);
color: var(--text-muted, #8b949e);
}
.termsPreview code {
background: rgba(255, 255, 255, 0.08);
padding: 1px 5px; border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
}
.termsPreview pre {
background: rgba(0, 0, 0, 0.4);
padding: 10px 12px; border-radius: 6px; overflow: auto;
}
.termsPreview pre code { background: transparent; padding: 0; }
.termsPreview a { color: var(--accent, #58a6ff); text-decoration: underline; word-break: break-all; }
.termsPreview details {
margin: 6px 0;
border: 1px solid var(--border, #30363d);
border-radius: 6px;
background: var(--bg-card, #1f242c);
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: var(--bg-alt, #161b22);
color: var(--text, #e6edf3);
border: 1px solid var(--border, #30363d);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
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: var(--bg-card, #1f242c);
}
.slashMenu .slashItem strong { font-size: 13px; color: var(--text, #e6edf3); }
.slashMenu .slashItem span { color: var(--text-muted, #8b949e); font-size: 11px; }

394
public/termsEditor.js Normal file
View File

@@ -0,0 +1,394 @@
/* 약관(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')
var visInstaller = document.getElementById('visInstaller')
var visInstallerRp = document.getElementById('visInstallerRp')
editor.value = INITIAL || ''
var dirty = false
function setDirty(v) {
dirty = v
dirtyMark.hidden = !v
}
// 토글이 바뀌어도 dirty 표시. 저장 시 함께 전송된다.
if (visInstaller) visInstaller.addEventListener('change', function () { setDirty(true) })
if (visInstallerRp) visInstallerRp.addEventListener('change', function () { setDirty(true) })
// ─── 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
var payload = { content: editor.value }
if (visInstaller) payload.showInInstaller = !!visInstaller.checked
if (visInstallerRp) payload.showInInstallerRp = !!visInstallerRp.checked
fetch('/op/agreement/' + encodeURIComponent(PACK_KEY) + '/' + encodeURIComponent(TERM_KIND), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).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,52 @@
#!/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 를 소스 코드에 인라인한다.
//
// 마인크래프트 런처의 사용자 지정 설치 아이콘 규격은 "128x128 PNG" 로
// 고정돼 있다(https://minecraft.wiki/w/Launcher). 이 규격과 다른 크기
// (예: 원본 256x256)를 주면 런처가 아이콘을 무시하고 기본 아이콘(화로)으로
// 폴백한다. 그래서 build/icon.png 를 정확히 128x128 로 리사이즈해서 박는다.
// exe 아이콘(build/icon.ico, build/icon.png)은 256x256 그대로 둔다.
'use strict'
const fs = require('node:fs')
const path = require('node:path')
const sharp = require('sharp')
const repoRoot = path.resolve(__dirname, '..')
const pngPath = path.join(repoRoot, 'build', 'icon.png')
const tsPath = path.join(repoRoot, 'src', 'installer', 'launcherIcon.ts')
const ICON_SIZE = 128
async function main() {
const buf = await sharp(pngPath)
.resize(ICON_SIZE, ICON_SIZE, { fit: 'cover' })
.png({ compressionLevel: 9 })
.toBuffer()
const b64 = buf.toString('base64')
const ts = `// AUTO-GENERATED by scripts/build-launcher-icon.cjs from build/icon.png.
// 마인크래프트 런처의 "설치 설정" 화면에서 보이는 프로필 아이콘. exe 와 같은
// 이미지를 ${ICON_SIZE}x${ICON_SIZE} 로 줄여 빌드 시점에 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} (${ICON_SIZE}x${ICON_SIZE}, ${buf.length} bytes PNG → ${b64.length} chars base64)`)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})

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,15 +13,41 @@ 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')
} }
/** BtbN/FFmpeg-Builds 의 win64-gpl 빌드. zip 내부에 bin/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 가 들어 있음.
* `releases/download/latest/` 형태(=항상 최신 자산이 붙어 있는 롤링 `latest` 태그)를
* 쓴다. `releases/latest/download/`(GitHub 의 "최신 릴리스" 자동 포인터)는 갓
* 만들어진 `autobuild-<날짜>` 릴리스로 리다이렉트되는데, 그 릴리스에 자산이 아직
* 업로드되지 않았거나 없으면 HTTP 404 가 나서 ffmpeg 설치가 실패한다.
*/
const FFMPEG_ZIP_URL = const FFMPEG_ZIP_URL =
'https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip' 'https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip'
let installPromise: Promise<string> | null = null let installPromise: Promise<string> | null = null
@@ -30,17 +56,24 @@ let installPromise: Promise<string> | null = null
* ffmpeg.exe 만 추출해 설치하고 절대경로를 돌려준다. * ffmpeg.exe 만 추출해 설치하고 절대경로를 돌려준다.
*/ */
export async function ensureFfmpegExe( export async function ensureFfmpegExe(
log?: (line: string) => void log?: (line: string) => void,
force = false
): Promise<string> { ): Promise<string> {
const target = getFfmpegExePath() const target = getFfmpegExePath()
if (await canExecute(target)) { await migrateLegacyExe(target)
if (!force && await canExecute(target)) {
log?.(t('log.ffmpegExists', { path: target })) log?.(t('log.ffmpegExists', { path: target }))
return target return target
} }
if (force) {
// 강제 재설치: 오래됐을 수 있는 캐시본을 지워 최신 버전을 받게 한다.
log?.(t('log.ffmpegReinstall'))
try { await fs.unlink(target) } catch { /* noop */ }
}
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

@@ -29,8 +29,35 @@ export function ytIdFromUrl(url: string): string {
} }
} }
/** 단순 HTTP/HTTPS GET (302 따라감, 4xx/5xx 는 reject). */ /**
function fetchBuffer(url: string, redirects = 0): Promise<Buffer> { * 일시적(transient) 으로 보고 재시도할 HTTP 상태코드.
* 429 = Too Many Requests (i.ytimg.com 썸네일 서버가 연속 요청을 속도제한).
* 5xx 게이트웨이 계열도 잠깐 뒤 다시 받으면 성공하는 경우가 많다.
*/
const TRANSIENT_CODES = new Set([408, 425, 429, 500, 502, 503, 504])
const MAX_RETRIES = 5
/** 백오프 상한(ms). Retry-After 헤더가 비정상적으로 커도 이 이상은 기다리지 않는다. */
const MAX_BACKOFF_MS = 60000
/** Retry-After 헤더(초 또는 HTTP-date) → 대기 ms. 못 읽으면 null. */
function parseRetryAfter(h: string | string[] | undefined): number | null {
if (!h) return null
const v = Array.isArray(h) ? h[0] : h
const secs = Number(v)
if (Number.isFinite(secs)) return Math.min(MAX_BACKOFF_MS, Math.max(0, secs * 1000))
const date = Date.parse(v)
if (!Number.isNaN(date)) return Math.min(MAX_BACKOFF_MS, Math.max(0, date - Date.now()))
return null
}
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
/**
* 단순 HTTP/HTTPS GET (302 따라감).
* 429/5xx 등 일시적 오류는 지수 백오프(+jitter, Retry-After 우선)로 최대
* MAX_RETRIES 회 재시도한다. 그 외 4xx 나 재시도 소진 시 reject.
*/
function fetchBuffer(url: string, redirects = 0, attempt = 0): Promise<Buffer> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (redirects > 8) { if (redirects > 8) {
reject(new Error(t('common.tooManyRedirects'))) reject(new Error(t('common.tooManyRedirects')))
@@ -38,6 +65,11 @@ function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
} }
const target = new URL(url) const target = new URL(url)
const lib = target.protocol === 'https:' ? https : http const lib = target.protocol === 'https:' ? https : http
const retryLater = (headerDelay: number | null): void => {
const backoff = Math.min(MAX_BACKOFF_MS, 1000 * 2 ** attempt) + Math.floor(Math.random() * 500)
const delay = headerDelay ?? backoff
sleep(delay).then(() => fetchBuffer(url, redirects, attempt + 1).then(resolve, reject))
}
const req = lib.get(target, { const req = lib.get(target, {
timeout: 30000, timeout: 30000,
headers: { 'user-agent': 'mc-music-quiz-rp-installer' } headers: { 'user-agent': 'mc-music-quiz-rp-installer' }
@@ -45,10 +77,15 @@ function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
const code = res.statusCode || 0 const code = res.statusCode || 0
if (code >= 300 && code < 400 && res.headers.location) { if (code >= 300 && code < 400 && res.headers.location) {
res.resume() res.resume()
fetchBuffer(new URL(res.headers.location, target).toString(), redirects + 1) fetchBuffer(new URL(res.headers.location, target).toString(), redirects + 1, attempt)
.then(resolve, reject) .then(resolve, reject)
return return
} }
if (TRANSIENT_CODES.has(code) && attempt < MAX_RETRIES) {
res.resume()
retryLater(parseRetryAfter(res.headers['retry-after']))
return
}
if (code !== 200) { if (code !== 200) {
res.resume() res.resume()
reject(new Error(`HTTP ${code}`)) reject(new Error(`HTTP ${code}`))
@@ -58,18 +95,48 @@ function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
res.on('data', (c: Buffer) => chunks.push(c)) res.on('data', (c: Buffer) => chunks.push(c))
res.on('end', () => resolve(Buffer.concat(chunks))) res.on('end', () => resolve(Buffer.concat(chunks)))
}) })
req.on('error', reject) req.on('error', (err) => {
// 연결 끊김/리셋 등 네트워크 오류도 몇 번은 재시도.
if (attempt < MAX_RETRIES) {
retryLater(null)
return
}
reject(err)
})
req.on('timeout', () => req.destroy(new Error(t('common.requestTimeout')))) req.on('timeout', () => req.destroy(new Error(t('common.requestTimeout'))))
}) })
} }
/**
* data: URL 이면 그 안에 들어 있는 바이트를 바로 Buffer 로 디코드한다.
* data: URL 은 이미지 데이터 자체를 품고 있어 네트워크 요청이 필요 없으며,
* http/https 만 다루는 fetchBuffer 에 넘기면 `Protocol "data:" not supported`
* 로 터지므로 여기서 가로챈다. data: URL 이 아니면 null.
*/
function decodeDataUrl(url: string): Buffer | null {
if (!/^data:/i.test(url)) return null
const comma = url.indexOf(',')
if (comma < 0) throw new Error(t('errors.imageDataUrlInvalid'))
const meta = url.slice(5, comma)
const data = url.slice(comma + 1)
// `;base64` 가 있으면 base64, 없으면 percent-encoding 된 텍스트.
const buf = /;base64/i.test(meta)
? Buffer.from(data, 'base64')
: Buffer.from(decodeURIComponent(data), 'utf8')
if (buf.length === 0) throw new Error(t('errors.imageDataUrlInvalid'))
return buf
}
/** /**
* 이미지 URL 을 다운로드해 Buffer 로 돌려준다. * 이미지 URL 을 다운로드해 Buffer 로 돌려준다.
* - data: URL 이면 내장 바이트를 바로 디코드 (네트워크 없음).
* - 유튜브 영상 URL 이면 `i.ytimg.com/vi/<id>/maxresdefault.jpg` 1차 → * - 유튜브 영상 URL 이면 `i.ytimg.com/vi/<id>/maxresdefault.jpg` 1차 →
* 실패하면 `hqdefault.jpg` 로 폴백. * 실패하면 `hqdefault.jpg` 로 폴백.
* - 그 외 URL 은 HTTP GET 으로 그대로 받음. * - 그 외 URL 은 HTTP GET 으로 그대로 받음.
*/ */
export async function downloadImage(rawUrl: string): Promise<Buffer> { export async function downloadImage(rawUrl: string): Promise<Buffer> {
const dataBuf = decodeDataUrl(rawUrl)
if (dataBuf) return dataBuf
const ytId = ytIdFromUrl(rawUrl) const ytId = ytIdFromUrl(rawUrl)
if (ytId) { if (ytId) {
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 바운드라 코어 수가 가장 좋은 프록시.
@@ -75,6 +89,16 @@ function acquireMusicStartSlot(): Promise<void> {
return slot return slot
} }
/** 파일이 존재하면 true. 이어받기(재시도) 시 이미 받아둔 산출물 감지에 사용. */
async function fileExists(p: string): Promise<boolean> {
try {
await fsp.access(p)
return true
} catch {
return false
}
}
const DEFAULT_MANIFEST_URL = getManifestUrl() const DEFAULT_MANIFEST_URL = getManifestUrl()
const state: RpInstallerState = { const state: RpInstallerState = {
@@ -201,11 +225,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 +261,49 @@ ipcMain.handle('rp:packs:select', async (_event, packKey: string) => {
ipcMain.handle('rp:i18n:dict', () => localeDict) ipcMain.handle('rp:i18n:dict', () => localeDict)
// ── IPC: 약관 다운로드 ──────────────────────────────
// v0.3.4~ : 사이트에서 임의 kind 가 만들어질 수 있으니 5종 화이트리스트 대신
// kind 형식만 검증한다. 어떤 약관을 rp 인스톨러에 보여줄지는 사이트의 visibility 토글이 결정.
const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
ipcMain.handle('rp:terms:get', async (_event, kind: string) => {
if (typeof kind !== 'string' || !TERM_KIND_RE.test(kind)) {
return { ok: false, message: 'invalid term kind' }
}
if (!state.selectedKey) return { ok: false, message: 'pack not selected' }
try {
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/${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 }
}
})
// rp 인스톨러용 약관 목록. /manifest/terms/<packKey>/index.json 을 받아
// showInInstallerRp=true 인 항목만 추려 반환. 비어 있으면 렌더러가 약관 단계를 건너뛴다.
ipcMain.handle('rp:terms:list', async (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> => {
if (!state.selectedKey) return { ok: false, message: 'pack not selected' }
try {
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/index.json`
const buf = await fetchBuffer(url)
const parsed = JSON.parse(buf.toString('utf8')) as { terms?: unknown }
const items = Array.isArray(parsed.terms) ? parsed.terms : []
const terms: Array<{ kind: string; label: string }> = []
for (const it of items) {
if (!it || typeof it !== 'object') continue
const entry = it as Record<string, unknown>
if (entry.showInInstallerRp !== true) continue
const kind = typeof entry.kind === 'string' ? entry.kind : ''
const label = typeof entry.label === 'string' ? entry.label : ''
if (!TERM_KIND_RE.test(kind) || label.length === 0) continue
terms.push({ kind, label })
}
return { ok: true, terms }
} 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'))
@@ -252,16 +321,30 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
// 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(t('log.ytdlpPreparing')) sendLog(t('log.ytdlpPreparing'))
sendProgress({ phase: 'prep', message: t('progress.ytdlpPreparing') }) sendProgress({ phase: 'prep', message: t('progress.ytdlpPreparing') })
const ytDlpBin = await ensureYtDlpExe(sendLog) let ytDlpBin = await ensureYtDlpExe(sendLog)
sendLog(t('log.ytdlpPath', { path: ytDlpBin })) sendLog(t('log.ytdlpPath', { path: ytDlpBin }))
throwIfCancelled() throwIfCancelled()
sendLog(t('log.ffmpegPreparing')) sendLog(t('log.ffmpegPreparing'))
sendProgress({ phase: 'prep', message: t('progress.ffmpegPreparing') }) sendProgress({ phase: 'prep', message: t('progress.ffmpegPreparing') })
const ffmpegBin = await ensureFfmpegExe(sendLog) let ffmpegBin = await ensureFfmpegExe(sendLog)
sendLog(t('log.ffmpegPath', { path: ffmpegBin })) sendLog(t('log.ffmpegPath', { path: ffmpegBin }))
sendProgress({ phase: 'prep', message: t('progress.ready'), done: true }) sendProgress({ phase: 'prep', message: t('progress.ready'), done: true })
throwIfCancelled() throwIfCancelled()
// 음악 다운로드가 실패하면 yt-dlp/ffmpeg 가 너무 오래된 버전이라 유튜브 변경을
// 못 따라가는 경우일 수 있다. 그때 최신 버전으로 한 번만 강제 재설치한다.
// 워커 여러 개가 동시에 실패해도 재설치는 단 한 번만 일어나도록 락으로 직렬화.
let binRefreshPromise: Promise<void> | null = null
async function refreshBinariesOnce(): Promise<void> {
if (!binRefreshPromise) {
binRefreshPromise = (async () => {
ytDlpBin = await ensureYtDlpExe(sendLog, true)
ffmpegBin = await ensureFfmpegExe(sendLog, true)
})()
}
await binRefreshPromise
}
// 2-2. 음악 다운로드 (CPU 코어 수 기반 자동 동시 다운로드, 시차 출발, ogg 변환) // 2-2. 음악 다운로드 (CPU 코어 수 기반 자동 동시 다운로드, 시차 출발, ogg 변환)
const musicDir = path.join(tempRoot, 'music') const musicDir = path.join(tempRoot, 'music')
await fsp.mkdir(musicDir, { recursive: true }) await fsp.mkdir(musicDir, { recursive: true })
@@ -274,52 +357,84 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
// 클로저 안에서 narrowing 이 풀리지 않도록 로컬 alias. // 클로저 안에서 narrowing 이 풀리지 않도록 로컬 alias.
const musicList = pack.list.music const musicList = pack.list.music
// 곡별 마지막 실패 메시지(재시도 단계에서 최종 에러 메시지로 사용).
const failedMessages = new Map<number, string>()
// 한 곡을 한 번 받아본다. 성공 true / 실패 false.
// emitErrorProgress=false 면 실패해도 UI 에 'error' 상태를 보내지 않는다(재시도 예정).
async function tryDownloadTrack(i: number, emitErrorProgress: boolean): Promise<boolean> {
const entry = musicList[i]
const idx = i + 1
// 최종 산출물 경로. 실패 시 부분 생성된 파일을 지워, 다음 재시도(이어받기)에서
// 완성본으로 오인해 건너뛰는 일을 막는다.
const expectedOut = path.join(musicDir, String(idx).padStart(2, '0') + '.ogg')
sendLog(t('log.musicTrackStart', { idx }))
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
let child: ChildProcess | null = null
try {
const outPath = await downloadMusicTrack({
ytdlpExe: ytDlpBin,
ffmpegExe: ffmpegBin,
tempDir: musicDir,
index: idx,
url: entry.url,
log: sendLog,
onChild: (c) => {
child = c
state.activeChildren.add(c)
},
onProgress: (pct) => {
// 다운로드(0~90%) + 변환(90~100%) 으로 매핑.
sendProgress({
phase: 'item', kind: 'music', index: idx, total: musicTotal,
percent: Math.min(90, pct * 0.9), status: 'running'
})
}
})
if (child) state.activeChildren.delete(child)
sendLog(t('log.musicTrackDone', { idx, name: path.basename(outPath) }))
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
return true
} catch (err) {
if (child) state.activeChildren.delete(child)
// 부분 생성된 .ogg 를 제거(이어받기 시 완성본 오인 방지).
await fsp.rm(expectedOut, { force: true }).catch(() => {})
if (state.cancelRequested) {
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: t('progress.cancelled') })
return false
}
failedMessages.set(i, (err as Error).message)
if (emitErrorProgress) {
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: (err as Error).message })
}
return false
}
}
// 1차 다운로드: 동시 워커로 전부 받아보고, 실패한 곡 인덱스만 모은다.
// 여기서는 yt-dlp/ffmpeg 재설치를 하지 않는다(다른 워커가 같은 exe 를 실행 중일 수
// 있어 Windows 파일 잠금으로 삭제/덮어쓰기가 실패할 수 있기 때문).
const failed: number[] = []
let nextIndex = 0 let nextIndex = 0
async function musicWorker(): Promise<void> { async function musicWorker(): Promise<void> {
while (true) { while (true) {
if (state.cancelRequested) return if (state.cancelRequested) return
const i = nextIndex++ const i = nextIndex++
if (i >= musicTotal) return if (i >= musicTotal) return
const idx = i + 1
// 이전 시도에서 이미 받아둔 곡(.ogg 존재)은 시차 게이트 없이 즉시 완료 처리
// 한다. '재시도' 로 이어받을 때 받았던 곡을 다시 받지 않기 위함.
const outPath = path.join(musicDir, String(idx).padStart(2, '0') + '.ogg')
if (await fileExists(outPath)) {
sendLog(t('log.musicTrackSkip', { idx }))
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
continue
}
// 시차 게이트: 새 다운로드 시작은 직전 시작과 최소 MUSIC_START_STAGGER_MS 간격을 둠. // 시차 게이트: 새 다운로드 시작은 직전 시작과 최소 MUSIC_START_STAGGER_MS 간격을 둠.
await acquireMusicStartSlot() await acquireMusicStartSlot()
if (state.cancelRequested) return if (state.cancelRequested) return
const entry = musicList[i] const ok = await tryDownloadTrack(i, false)
const idx = i + 1 if (!ok && !state.cancelRequested) failed.push(i)
sendLog(t('log.musicTrackStart', { idx }))
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
let child: ChildProcess | null = null
try {
const outPath = await downloadMusicTrack({
ytdlpExe: ytDlpBin,
ffmpegExe: ffmpegBin,
tempDir: musicDir,
index: idx,
url: entry.url,
log: sendLog,
onChild: (c) => {
child = c
state.activeChildren.add(c)
},
onProgress: (pct) => {
// 다운로드(0~90%) + 변환(90~100%) 으로 매핑.
sendProgress({
phase: 'item', kind: 'music', index: idx, total: musicTotal,
percent: Math.min(90, pct * 0.9), status: 'running'
})
}
})
if (child) state.activeChildren.delete(child)
sendLog(t('log.musicTrackDone', { idx, name: path.basename(outPath) }))
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
} catch (err) {
if (child) state.activeChildren.delete(child)
if (state.cancelRequested) {
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: t('progress.cancelled') })
return
}
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: (err as Error).message })
throw new Error(t('errors.musicDownloadFailed', { idx, message: (err as Error).message }))
}
} }
} }
@@ -329,6 +444,29 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
await Promise.all(workers) await Promise.all(workers)
throwIfCancelled() throwIfCancelled()
// 1차에서 실패한 곡이 있으면, 모든 워커가 끝나 실행 중인 yt-dlp/ffmpeg 자식
// 프로세스가 하나도 없는 지금 시점에 단 한 번 최신 버전으로 강제 재설치한다.
// (각 워커 promise 는 자식 프로세스 close 후 resolve 되므로 여기선 exe 가 잠겨
// 있지 않다 → Windows 파일 잠금 문제 없음.) 그런 다음 실패한 곡만 순차 재시도.
if (failed.length > 0) {
failed.sort((a, b) => a - b)
sendLog(t('log.musicRefreshRetry', { count: failed.length }))
await refreshBinariesOnce()
throwIfCancelled()
nextMusicStartAt = Date.now()
for (const i of failed) {
throwIfCancelled()
await acquireMusicStartSlot()
throwIfCancelled()
const ok = await tryDownloadTrack(i, true)
if (!ok) {
throwIfCancelled()
const idx = i + 1
throw new Error(t('errors.musicDownloadFailed', { idx, message: failedMessages.get(i) ?? '' }))
}
}
}
// 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 })
@@ -337,21 +475,32 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
throwIfCancelled() throwIfCancelled()
const entry = pack.list.images[i] const entry = pack.list.images[i]
const idx = i + 1 const idx = i + 1
// 이전 시도에서 이미 정규화해둔 사진은 건너뛴다(이어받기).
const coverPath = path.join(paintingDir, coverFileName(idx))
if (await fileExists(coverPath)) {
sendLog(t('log.imageSkip', { idx }))
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' })
continue
}
sendLog(t('log.imageDownloading', { 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) {
// 부분 생성됐을 수 있는 커버 파일 제거(이어받기 시 완성본 오인 방지).
await fsp.rm(coverPath, { force: true }).catch(() => {})
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(t('errors.imageDownloadFailed', { idx, message: (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' })
const outPath = path.join(paintingDir, coverFileName(idx)) const outPath = coverPath
try { try {
await normalizeToCover(buf, outPath) await normalizeToCover(buf, outPath)
} catch (err) { } catch (err) {
// 변환 중 부분 생성된 PNG 제거(이어받기 시 완성본 오인 방지).
await fsp.rm(coverPath, { force: true }).catch(() => {})
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(t('errors.imageNormalizeFailed', { idx, message: (err as Error).message })) throw new Error(t('errors.imageNormalizeFailed', { idx, message: (err as Error).message }))
} }
@@ -383,7 +532,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,19 +549,50 @@ 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 } // 성공: 임시 파일 정리
} finally {
// 임시 파일 정리
await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => {}) await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => {})
return { resourcepackPath }
} catch (err) {
// 사용자가 취소한 경우에만 임시 파일을 지운다(처음부터 새로 시작).
// 그 외 오류는 받아둔 음악·사진을 보존해 '재시도' 시 실패 지점부터 이어받게 한다.
// (재시도 없이 프로그램을 닫으면 window-all-closed 에서 .temp 를 정리한다.)
if (state.cancelRequested) {
await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => {})
}
throw err
} }
}) })
// '처음으로' 버튼: 재시도하지 않고 처음 단계로 돌아갈 때 받아둔 임시 파일을 정리한다.
ipcMain.handle('rp:install:discard', async () => {
await fsp.rm(path.join(getMcCustomDir(), '.temp'), { recursive: true, force: true }).catch(() => {})
})
ipcMain.handle('rp:install:cancel', async () => { ipcMain.handle('rp:install:cancel', async () => {
state.cancelRequested = true state.cancelRequested = true
sendLog(t('log.cancelRequested', { count: state.activeChildren.size })) sendLog(t('log.cancelRequested', { count: state.activeChildren.size }))

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')
@@ -108,6 +131,10 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
opts.log?.(t('log.packFormatRange', { min: minFmt, max: maxFmt })) opts.log?.(t('log.packFormatRange', { min: minFmt, max: maxFmt }))
// 2) 음악 파일 복사 + sounds.json 생성/병합 // 2) 음악 파일 복사 + sounds.json 생성/병합
// 핵심 정책: 베이스 리소스팩에 이미 있는 자산은 절대 덮어쓰지 않는다.
// - 베이스 sounds.json 의 엔트리는 그대로 보존하고, 우리 트랙은 그 위에 "추가" 만 한다.
// - 베이스 sounds/track_NN.ogg 가 이미 있으면 덮어쓰지 않고 건너뛴다.
// - 키나 파일명이 충돌하면 우리 트랙을 스킵하고 로그로 알린다.
const musicFiles = (await fs.readdir(opts.musicDir)) const musicFiles = (await fs.readdir(opts.musicDir))
.filter((n) => n.toLowerCase().endsWith('.ogg')) .filter((n) => n.toLowerCase().endsWith('.ogg'))
.sort() .sort()
@@ -125,10 +152,23 @@ 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}`
await fs.copyFile(path.join(opts.musicDir, fname), path.join(soundsDir, `${trackId}.ogg`)) const destFile = path.join(soundsDir, `${trackId}.ogg`)
// 베이스에 같은 trackId 의 엔트리/파일이 있으면 두 선택지 다 깨진다:
// (a) 덮어쓰면 베이스의 기존 곡이 사라지고,
// (b) 새 곡을 스킵하면 데이터팩이 가리키는 곡이 빠진 채로 설치된다.
// 안전하게 설치를 즉시 실패시키고 어떤 키가 충돌했는지 알린다.
let collides = soundsJson[trackId] !== undefined
if (!collides) {
try { await fs.access(destFile); collides = true } catch { /* 없음 → OK */ }
}
if (collides) {
throw new Error(t('errors.baseTrackCollision', { trackId }))
}
await fs.copyFile(path.join(opts.musicDir, fname), destFile)
soundsJson[trackId] = { soundsJson[trackId] = {
sounds: [ sounds: [
{ name: `${NAMESPACE}:${trackId}`, stream: true } { name: `${NAMESPACE}:${trackId}`, stream: true }
@@ -136,36 +176,73 @@ 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')
opts.log?.(t('log.tracksAdded', { count: musicFiles.length }))
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) {
await fs.copyFile(path.join(opts.paintingDir, fname), path.join(paintingOutDir, fname)) throwIfCancelled(cancel)
const destFile = path.join(paintingOutDir, fname)
let collides = false
try { await fs.access(destFile); collides = true } catch { /* 없음 → OK */ }
if (collides) {
throw new Error(t('errors.basePaintingCollision', { name: fname }))
}
await fs.copyFile(path.join(opts.paintingDir, fname), destFile)
} }
opts.log?.(t('log.paintingsAdded', { count: paintingFiles.length }))
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,14 @@ 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) 다운로드. v0.3.4~ : 임의 kind 허용 (사이트에서 설정). */
getTerm: (kind: string): Promise<{ ok: boolean; content?: string; message?: string }> =>
ipcRenderer.invoke('rp:terms:get', kind),
/** rp 인스톨러에 표시할 약관 목록 (사이트의 visibility 토글로 필터링). */
getTermsList: (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> =>
ipcRenderer.invoke('rp:terms:list'),
/** 리소스팩 빌드/설치 시작. 완료 또는 취소될 때까지 resolve 되지 않을 수 있음. */ /** 리소스팩 빌드/설치 시작. 완료 또는 취소될 때까지 resolve 되지 않을 수 있음. */
startInstall: (): Promise<{ resourcepackPath: string }> => startInstall: (): Promise<{ resourcepackPath: string }> =>
ipcRenderer.invoke('rp:install:start'), ipcRenderer.invoke('rp:install:start'),
@@ -19,6 +27,10 @@ const api = {
cancelInstall: (): Promise<void> => cancelInstall: (): Promise<void> =>
ipcRenderer.invoke('rp:install:cancel'), ipcRenderer.invoke('rp:install:cancel'),
/** 재시도하지 않고 처음으로 돌아갈 때 받아둔 임시 파일을 정리한다. */
discardInstall: (): Promise<void> =>
ipcRenderer.invoke('rp:install:discard'),
/** %appdata%/.mc_custom/resourcepacks/ 폴더를 OS 파일 탐색기로 연다. */ /** %appdata%/.mc_custom/resourcepacks/ 폴더를 OS 파일 탐색기로 연다. */
openResourcepackFolder: (): Promise<void> => openResourcepackFolder: (): Promise<void> =>
ipcRenderer.invoke('rp:finish:openFolder'), ipcRenderer.invoke('rp:finish:openFolder'),

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 =
@@ -26,13 +47,20 @@ let installPromise: Promise<string> | null = null
* 의 최신 yt-dlp.exe 를 받아 설치하고, 그 절대경로를 돌려준다. * 의 최신 yt-dlp.exe 를 받아 설치하고, 그 절대경로를 돌려준다.
*/ */
export async function ensureYtDlpExe( export async function ensureYtDlpExe(
log?: (line: string) => void log?: (line: string) => void,
force = false
): Promise<string> { ): Promise<string> {
const target = getYtDlpExePath() const target = getYtDlpExePath()
if (await canExecute(target)) { await migrateLegacyExe(target)
if (!force && await canExecute(target)) {
log?.(t('log.ytdlpExists', { path: target })) log?.(t('log.ytdlpExists', { path: target }))
return target return target
} }
if (force) {
// 강제 재설치: 오래됐을 수 있는 캐시본을 지워 최신 버전을 받게 한다.
log?.(t('log.ytdlpReinstall'))
try { await fs.unlink(target) } catch { /* noop */ }
}
if (installPromise) return installPromise if (installPromise) return installPromise
installPromise = (async () => { installPromise = (async () => {

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,50 @@ ipcMain.handle('packs:load', async (_event, manifestUrlInput?: string): Promise<
return results return results
}) })
// 약관(Markdown) 을 사이트(/manifest/terms/<packKey>/<kind>.md) 에서 받아와 그대로 돌려준다.
// v0.3.4~ : 사이트에서 임의 kind 등록 가능 → 하드코딩 5종 화이트리스트 대신 kind 형식만 검증.
const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
ipcMain.handle('terms:get', async (_event, kind: string): Promise<{ ok: boolean; content?: string; message?: string }> => {
if (typeof kind !== 'string' || !TERM_KIND_RE.test(kind)) {
return { ok: false, message: 'invalid term kind' }
}
if (!state.selectedKey) {
return { ok: false, message: 'pack not selected' }
}
try {
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/${kind}.md`
const buf = await fetchBuffer(url)
return { ok: true, content: buf.toString('utf8') }
} catch (error) {
return { ok: false, message: (error as Error).message }
}
})
// 메인 인스톨러용 약관 목록. /manifest/terms/<packKey>/index.json 을 받아
// showInInstaller=true 인 항목만 추려 반환. 비어 있으면 렌더러가 약관 단계를 건너뛴다.
ipcMain.handle('terms:list', async (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> => {
if (!state.selectedKey) return { ok: false, message: 'pack not selected' }
try {
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/index.json`
const buf = await fetchBuffer(url)
const parsed = JSON.parse(buf.toString('utf8')) as { terms?: unknown }
const items = Array.isArray(parsed.terms) ? parsed.terms : []
const terms: Array<{ kind: string; label: string }> = []
for (const it of items) {
if (!it || typeof it !== 'object') continue
const entry = it as Record<string, unknown>
if (entry.showInInstaller !== true) continue
const kind = typeof entry.kind === 'string' ? entry.kind : ''
const label = typeof entry.label === 'string' ? entry.label : ''
if (!TERM_KIND_RE.test(kind) || label.length === 0) continue
terms.push({ kind, label })
}
return { ok: true, terms }
} 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 +1173,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 +1264,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 +1514,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,13 @@ 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),
// 메인 인스톨러용 약관 목록 (사이트의 visibility 토글에 따라 필터링됨)
getTermsList: (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> =>
ipcRenderer.invoke('terms:list'),
// 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,13 @@ 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 {
ensurePackTermsDir, isPublicTermsFile, listTermsWithLabels, loadPackDefinition
} from '../shared/store.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 +65,60 @@ app.get('/manifest.json', (_req, res) => {
res.sendFile(manifestRootPath) res.sendFile(manifestRootPath)
}) })
// 설치기 + 사이트가 약관(markdown) 을 가져갈 수 있도록 .md 만 허용한다.
// 음악퀴즈(pack) 별로 manifest/terms/<packKey>/<file>.md 에서 노출한다.
// _meta.json 같은 시스템 파일이나 경로 탈출은 isPublicTermsFile 에서 차단.
//
// fresh 배포에서 관리자가 약관 페이지를 한 번도 열지 않은 상태로 설치기가 약관을
// 요청하는 경우에도 작동하도록, 실제 pack 이면 ensurePackTermsDir 로 v0.3.1
// 전역 .md 들을 시드 복사한 뒤 sendFile 한다. 임의 packKey 로 빈 폴더가
// 생성되는 것은 loadPackDefinition 으로 차단.
// 설치기가 자기에게 표시할 약관 목록을 받아갈 수 있도록 packKey 별 index.json.
// 응답: [{ kind, label, showInInstaller, showInInstallerRp }]. v0.3.4~ builtin 개념이
// 없어졌으므로 인스톨러는 이 목록을 받아 자기 인스톨러용(`showInInstaller` / `showInInstallerRp`)
// 으로 필터링해서 탭을 만든다.
app.get('/manifest/terms/:packKey/index.json', async (req, res, next) => {
try {
const { packKey } = req.params
if (!/^[a-zA-Z0-9_\-]+$/.test(packKey)) {
res.status(404).json({ terms: [] })
return
}
const pack = await loadPackDefinition(packKey)
if (!pack) {
res.status(404).json({ terms: [] })
return
}
const terms = await listTermsWithLabels(packKey)
res.json({ terms })
} catch (error) {
next(error)
}
})
app.get('/manifest/terms/:packKey/:fileName', async (req, res, next) => {
try {
const { packKey, fileName } = req.params
if (!isPublicTermsFile(packKey, fileName)) {
res.status(404).send('Not Found')
return
}
const pack = await loadPackDefinition(packKey)
if (!pack) {
res.status(404).send('Not Found')
return
}
await ensurePackTermsDir(packKey)
res.type('text/markdown; charset=utf-8')
res.sendFile(path.join(manifestTermsDirPath, packKey, fileName), (err) => {
if (!err || res.headersSent) return
res.status(404).send('Not Found')
})
} catch (error) {
next(error)
}
})
// 설치기에서 개별 음악퀴즈 JSON을 가져갈 수 있도록 파일 단위로만 허용. // 설치기에서 개별 음악퀴즈 JSON을 가져갈 수 있도록 파일 단위로만 허용.
// 디렉토리 리스팅, 다른 확장자, 경로 탈출은 차단. // 디렉토리 리스팅, 다른 확장자, 경로 탈출은 차단.
app.get('/manifest/:fileName', (req, res) => { app.get('/manifest/:fileName', (req, res) => {

View File

@@ -1,8 +1,17 @@
import type { MusicListEntry, PackList } from '../shared/types.js' import type { MusicListEntry, PackList } from '../shared/types.js'
/** SNBT 문자열 리터럴 안에 들어갈 문자열을 escape. */ /**
* SNBT 문자열 리터럴 안에 들어갈 문자열을 escape.
* 백슬래시·따옴표 외에도 줄바꿈·탭을 이스케이프해서 `data modify` 한 줄 명령이
* description 같은 멀티라인 입력 때문에 깨지지 않게 한다.
*/
function escapeSnbtString(input: string): string { function escapeSnbtString(input: string): string {
return input.replace(/\\/g, '\\\\').replace(/"/g, '\\"') return input
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\r/g, '\\r')
.replace(/\n/g, '\\n')
.replace(/\t/g, '\\t')
} }
/** alias 배열을 SNBT 리스트 리터럴로 변환. 빈 배열도 `[]` 로 출력. */ /** alias 배열을 SNBT 리스트 리터럴로 변환. 빈 배열도 `[]` 로 출력. */
@@ -12,13 +21,16 @@ function aliasListSnbt(aliases: string[]): string {
return `[${parts.join(',')}]` return `[${parts.join(',')}]`
} }
/** 한 곡(MusicListEntry) → `{title:"...", author:"...", alias:[...]}` SNBT. */ /** 한 곡(MusicListEntry) → `{title:"...", author:"...", alias:[...], description:"...", volume:1.0}` SNBT. */
function entrySnbt(entry: MusicListEntry): string { function entrySnbt(entry: MusicListEntry): string {
const title = escapeSnbtString(entry.title ?? '') const title = escapeSnbtString(entry.title ?? '')
// launcher 의 artist → 데이터팩 SNBT 의 author. 빈 값은 빈 문자열로 그대로 둔다. // launcher 의 artist → 데이터팩 SNBT 의 author. 빈 값은 빈 문자열로 그대로 둔다.
const author = escapeSnbtString(entry.artist ?? '') const author = escapeSnbtString(entry.artist ?? '')
const alias = aliasListSnbt(entry.aliases ?? []) const alias = aliasListSnbt(entry.aliases ?? [])
return `{title:"${title}", author:"${author}", alias:${alias}}` const description = escapeSnbtString(entry.description ?? '')
// launcher 가 생성하는 항목에는 volume 기본값 1.0 을 항상 넣는다.
// 운영자는 생성된 mcfunction 에서 곡별로 직접 값을 바꿔 사용한다.
return `{title:"${title}", author:"${author}", alias:${alias}, description:"${description}", volume:1.0}`
} }
/** /**
@@ -29,11 +41,11 @@ function entrySnbt(entry: MusicListEntry): string {
export function buildSongsMcfunction(list: PackList): string { export function buildSongsMcfunction(list: PackList): string {
const lines: string[] = [] const lines: string[] = []
lines.push('# 곡 한 개 = 한 줄.') lines.push('# 곡 한 개 = 한 줄.')
lines.push('# 필수 — title, author, alias') lines.push('# 필수 — title, author, alias, description')
lines.push('# 선택 — volume (이 곡만의 /playsound 음량. 미지정시 init/config.mcfunction') lines.push('# 선택 — volume (이 곡만의 /playsound 음량. 미지정시 init/config.mcfunction')
lines.push('# 의 audio.volume 사용)') lines.push('# 의 audio.volume 사용)')
lines.push('# 곡 순서가 리소스팩의 track_NN / cover_NN 인덱스와 1:1 매칭된다.') lines.push('# 곡 순서가 리소스팩의 track_NN / cover_NN 인덱스와 1:1 매칭된다.')
lines.push('# 예) {title:"Quiet Song", author:"...", alias:[...], volume:2.0}') lines.push('# 예) {title:"Quiet Song", author:"...", alias:[...], description:"...", volume:1.0}')
lines.push('data modify storage mq:main songs set value []') lines.push('data modify storage mq:main songs set value []')
for (const entry of list.music) { for (const entry of list.music) {
lines.push(`data modify storage mq:main songs append value ${entrySnbt(entry)}`) lines.push(`data modify storage mq:main songs append value ${entrySnbt(entry)}`)

View File

@@ -2,16 +2,25 @@ import { Router } from 'express'
import archiver from 'archiver' import archiver from 'archiver'
import { import {
createPack, createPack,
createTerm,
deletePackKeys, deletePackKeys,
deleteTerm,
getTermEntry,
importTerms,
isTermKind,
listPackKeys, listPackKeys,
listTermsWithLabels,
loadPackDefinition, loadPackDefinition,
loadPackList, loadPackList,
loadTerm,
normalizePackDefinition, normalizePackDefinition,
normalizePackList, normalizePackList,
readAccounts, readAccounts,
renamePack, renamePack,
sanitizePackKey, sanitizePackKey,
savePackList saveTerm,
savePackList,
setTermVisibility
} from '../../shared/store.js' } 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'
@@ -284,8 +293,8 @@ opRouter.get('/op/datapack/:packName/images-zip', requireAuth, async (req, res,
asset_id: `musicquiz:cover_${nn}`, asset_id: `musicquiz:cover_${nn}`,
width: size, width: size,
height: size, height: size,
title: { text: `Cover ${nn}` }, author: 'musicquiz',
author: { text: 'music quiz' } title: `cover_${nn}`
} }
archive.append(JSON.stringify(json, null, 2) + '\n', { name: `cover_${nn}.json` }) archive.append(JSON.stringify(json, null, 2) + '\n', { name: `cover_${nn}.json` })
} }
@@ -295,6 +304,187 @@ opRouter.get('/op/datapack/:packName/images-zip', requireAuth, async (req, res,
} }
}) })
// ─── /op/agreement ─────────────────────────────────────────────────────
// 약관(Markdown) 편집기. 음악퀴즈(pack) 단위로 따로 저장한다.
// 5종 기본 약관(map/mod/installer/resourcepack/installer-rp) 은 첫 접근 시 시드되지만
// 사용자가 자유롭게 삭제/추가/표시 대상 변경할 수 있다 (v0.3.4~). 인스톨러는
// /manifest/terms/<packKey>/index.json 으로 자신에게 표시할 약관 목록을 받는다.
// /op/agreement → 음악퀴즈 선택(/op/list 와 동일한 카드 형식).
opRouter.get('/op/agreement', requireAuth, async (req, res, next) => {
try {
const keys = await listPackKeys()
const items = await Promise.all(keys.map(async (key) => ({
key,
definition: await loadPackDefinition(key)
})))
res.render('op/terms', { userId: req.session.userId, items })
} catch (error) {
next(error)
}
})
// /op/agreement/:packName → 해당 pack 의 약관 목록 + 추가/불러오기/삭제.
opRouter.get('/op/agreement/:packName', requireAuth, async (req, res, next) => {
try {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).send(t('errors.packNotFound'))
return
}
const items = await listTermsWithLabels(packKey)
// 불러오기 source 후보: 현재 pack 을 제외한 나머지.
const allKeys = await listPackKeys()
const sourceCandidates = await Promise.all(
allKeys
.filter((k) => k !== packKey)
.map(async (k) => ({ key: k, definition: await loadPackDefinition(k) }))
)
res.render('op/terms-pack', {
userId: req.session.userId,
packKey,
pack: definition,
items,
sourceCandidates
})
} catch (error) {
next(error)
}
})
opRouter.post('/op/agreement/:packName/create', requireAuth, async (req, res, next) => {
try {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).send(t('errors.packNotFound'))
return
}
const kindInput = pickFirstValue(req.body.kind).trim().toLowerCase()
const label = pickFirstValue(req.body.label)
if (!isTermKind(kindInput)) {
res.status(400).send(t('terms.invalidKind'))
return
}
await createTerm(packKey, kindInput, label)
res.redirect(`/op/agreement/${packKey}/${kindInput}`)
} catch (error) {
res.status(400).send((error as Error).message || t('terms.createFailed'))
}
})
opRouter.post('/op/agreement/:packName/import', requireAuth, async (req, res, next) => {
try {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).send(t('errors.packNotFound'))
return
}
const sourceKey = sanitizePackKey(pickFirstValue(req.body.source))
if (!sourceKey || sourceKey === packKey) {
res.status(400).send(t('terms.invalidImportSource'))
return
}
const sourceDefinition = await loadPackDefinition(sourceKey)
if (!sourceDefinition) {
res.status(404).send(t('terms.invalidImportSource'))
return
}
await importTerms(packKey, sourceKey)
res.redirect(`/op/agreement/${packKey}`)
} catch (error) {
res.status(400).send((error as Error).message || t('terms.importFailed'))
}
})
opRouter.post('/op/agreement/:packName/:kind/delete', requireAuth, async (req, res, next) => {
try {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).send(t('errors.packNotFound'))
return
}
const kind = pickFirstValue(req.params.kind)
if (!isTermKind(kind)) {
res.status(400).send(t('terms.invalidKind'))
return
}
await deleteTerm(packKey, kind)
res.redirect(`/op/agreement/${packKey}`)
} catch (error) {
next(error)
}
})
opRouter.get('/op/agreement/:packName/:kind', requireAuth, async (req, res, next) => {
try {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).send(t('errors.packNotFound'))
return
}
const kind = pickFirstValue(req.params.kind)
if (!isTermKind(kind)) {
res.status(404).send(t('errors.unknown'))
return
}
const entry = await getTermEntry(packKey, kind)
if (!entry) {
res.status(404).send(t('errors.unknown'))
return
}
const content = await loadTerm(packKey, kind)
res.render('op/termsEditor', {
userId: req.session.userId,
packKey,
pack: definition,
kind,
label: entry.label,
showInInstaller: entry.showInInstaller,
showInInstallerRp: entry.showInInstallerRp,
content
})
} catch (error) {
next(error)
}
})
opRouter.post('/op/agreement/:packName/:kind', requireAuth, async (req, res, next) => {
try {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).json({ ok: false, message: t('errors.packNotFoundJson') })
return
}
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(packKey, kind, content)
// visibility 토글이 함께 전송되면 동시에 갱신. 두 값이 모두 false 면 어디에도
// 표시되지 않지만 사용자가 의도적으로 선택한 결과이므로 그대로 저장한다.
if (
typeof req.body?.showInInstaller === 'boolean'
|| typeof req.body?.showInInstallerRp === 'boolean'
) {
await setTermVisibility(packKey, kind, {
showInInstaller: req.body.showInInstaller === true,
showInInstallerRp: req.body.showInInstallerRp === true
})
}
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 +504,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

@@ -32,48 +32,63 @@ function getYtDlpAssetName(): string {
return 'yt-dlp' // 그 외 OS: 순수 파이썬 zipapp. python3 가 PATH 에 있어야 동작 return 'yt-dlp' // 그 외 OS: 순수 파이썬 zipapp. python3 가 PATH 에 있어야 동작
} }
/** 로컬 설치 경로: %appdata%/.mc_custom/<asset> */ /**
* 로컬 설치 경로: OS별 사용자 데이터 디렉터리 안의 .mc_custom/<asset>.
* - Windows: %APPDATA%/.mc_custom/yt-dlp.exe
* - macOS : ~/Library/Application Support/.mc_custom/yt-dlp_macos
* - Linux 등: $XDG_CONFIG_HOME 또는 ~/.config/.mc_custom/yt-dlp_linux (arch 따라 다름)
*/
export function getYtDlpInstallPath(): string { export function getYtDlpInstallPath(): string {
return path.join(getMcCustomDir(), getYtDlpAssetName()) return path.join(getMcCustomDir(), getYtDlpAssetName())
} }
/** 순수 파이썬 zipapp(`yt-dlp`) 의 로컬 설치 경로. python3 가 PATH 에 있어야 동작. */
function getYtDlpZipappPath(): string {
return path.join(getMcCustomDir(), 'yt-dlp_zipapp')
}
/** 한 번에 한 다운로드만 진행하도록 락 (서버 동시 요청 보호). */ /** 한 번에 한 다운로드만 진행하도록 락 (서버 동시 요청 보호). */
let installPromise: Promise<string> | null = null let installPromise: Promise<string> | null = null
type ProbeResult = { ok: true } | { ok: false; detail: string }
/** /**
* %appdata%/.mc_custom/ 에 yt-dlp 가 준비됐는지 확인하고, 없으면 GitHub Releases 에서 * .mc_custom/ 디렉터리에 yt-dlp 가 준비됐는지 확인하고, 없으면 GitHub Releases 에서
* 현재 OS/아키텍처용 바이너리를 자동으로 받아 설치한다. 성공 시 실행 경로 반환. * 현재 OS/아키텍처용 네이티브 바이너리를 자동으로 받아 설치한다. 성공 시 실행 경로 반환.
*
* 네이티브 바이너리가 실행되지 않는 환경(glibc 미스매치, musl libc, antivirus 차단 등)
* 이면 다음 순서로 폴백한다:
* 1) PATH 의 `yt-dlp(.exe)` (시스템에 따로 깐 거)
* 2) (POSIX 한정) 범용 파이썬 zipapp `yt-dlp` 를 다운로드 후 shebang 실행 — python3 필요
* 전부 실패하면 각 시도의 진단정보가 포함된 에러를 던진다.
*/ */
export async function ensureYtDlp(): Promise<string> { export async function ensureYtDlp(force = false): Promise<string> {
const target = getYtDlpInstallPath() const target = getYtDlpInstallPath()
// 이미 설치돼 있고 실행 가능하면 그대로 사용 if (!force) {
if (await canExecute(target)) return target // Fast path: 이미 설치돼 있고 실행도 잘 되면 그대로 사용
if (await fileExists(target)) {
const probe = await probeVersion(target)
if (probe.ok) return target
}
// Fast path: 네이티브가 안 도는 환경에서 이전에 받아둔 zipapp 이 살아있으면 그걸 재사용
if (process.platform !== 'win32') {
const zipappPath = getYtDlpZipappPath()
if (await fileExists(zipappPath)) {
const probe = await probeVersion(zipappPath)
if (probe.ok) return zipappPath
}
}
} else {
// 강제 재설치: 캐시된(=오래됐을 수 있는) 바이너리를 지워 최신으로 다시 받게 한다.
try { await fs.unlink(target) } catch { /* noop */ }
if (process.platform !== 'win32') {
try { await fs.unlink(getYtDlpZipappPath()) } catch { /* noop */ }
}
}
if (installPromise) return installPromise if (installPromise) return installPromise
installPromise = (async () => { installPromise = (async () => {
try { try {
const dir = getMcCustomDir() return await prepareYtDlp(target, force)
await fs.mkdir(dir, { recursive: true })
const asset = getYtDlpAssetName()
const url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/${asset}`
await downloadToFile(url, target)
// POSIX 계열은 실행 권한 부여
if (process.platform !== 'win32') {
await fs.chmod(target, 0o755)
}
// 검증
const okVersion = await probeVersion(target)
if (!okVersion) {
throw new YtDlpUnavailableError(t('youtube.ytdlpVerifyFailed'))
}
return target
} catch (err) {
// 실패 흔적(부분 다운로드) 삭제
try { await fs.unlink(target) } catch { /* noop */ }
throw err instanceof YtDlpUnavailableError
? err
: new YtDlpUnavailableError(
t('youtube.ytdlpInstallFailed', { message: err instanceof Error ? err.message : String(err) })
)
} finally { } finally {
installPromise = null installPromise = null
} }
@@ -81,31 +96,121 @@ export async function ensureYtDlp(): Promise<string> {
return installPromise return installPromise
} }
async function canExecute(filePath: string): Promise<boolean> { async function prepareYtDlp(target: string, force = false): Promise<string> {
const diagnostics: string[] = []
// 강제 재설치(force)면 기존 캐시·PATH 시도를 건너뛰고 곧장 최신 버전을 받는다.
if (!force) {
// 1a. 기존 네이티브 파일이 있으면 우선 그걸로 시도
if (await fileExists(target)) {
const probe = await probeVersion(target)
if (probe.ok) return target
diagnostics.push(`기존 ${path.basename(target)} 검증 실패: ${probe.detail}`)
}
// 1b. (POSIX) 기존 zipapp 이 있으면 재다운로드 전에 먼저 시도
if (process.platform !== 'win32') {
const existingZipapp = getYtDlpZipappPath()
if (await fileExists(existingZipapp)) {
const probe = await probeVersion(existingZipapp)
if (probe.ok) return existingZipapp
diagnostics.push(`기존 yt-dlp_zipapp 검증 실패: ${probe.detail}`)
}
}
// 2. PATH 에 yt-dlp(.exe) 가 시스템 전역으로 설치돼 있으면 그걸 사용
const pathCmd = process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp'
const pathProbe = await probeVersion(pathCmd)
if (pathProbe.ok) return pathCmd
diagnostics.push(`PATH 의 ${pathCmd} 사용 불가: ${pathProbe.detail}`)
}
// 3. 최후 수단: 새로 다운로드해서 시도
try {
await fs.mkdir(getMcCustomDir(), { recursive: true })
const asset = getYtDlpAssetName()
const url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/${asset}`
try { await fs.unlink(target) } catch { /* noop */ }
await downloadToFile(url, target)
if (process.platform !== 'win32') {
await fs.chmod(target, 0o755)
} else {
// Windows: 인터넷에서 받은 파일에는 NTFS ADS 'Zone.Identifier' 가 붙어
// SmartScreen/Attachment Manager 가 실행을 막을 수 있다. 베스트에포트로 제거.
try { await fs.unlink(`${target}:Zone.Identifier`) } catch { /* noop */ }
}
const probe = await probeVersion(target)
if (probe.ok) return target
diagnostics.push(`새로 받은 ${asset} 검증 실패: ${probe.detail}`)
try { await fs.unlink(target) } catch { /* noop */ }
} catch (err) {
diagnostics.push(`다운로드 실패: ${err instanceof Error ? err.message : String(err)}`)
try { await fs.unlink(target) } catch { /* noop */ }
}
// 4. POSIX 한정 최후 폴백: 범용 파이썬 zipapp `yt-dlp` 다운로드 후 shebang 실행.
// 네이티브 바이너리가 glibc/musl/arch 문제로 못 도는 리눅스 환경이라도
// python3 가 PATH 에 있으면 동작한다. ~ 3MB 짜리 스크립트.
if (process.platform !== 'win32') {
const zipappPath = getYtDlpZipappPath()
try {
try { await fs.unlink(zipappPath) } catch { /* noop */ }
await downloadToFile('https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp', zipappPath)
await fs.chmod(zipappPath, 0o755)
const probe = await probeVersion(zipappPath)
if (probe.ok) return zipappPath
diagnostics.push(`zipapp yt-dlp 검증 실패: ${probe.detail} (python3 누락이거나 PATH 에 없음)`)
try { await fs.unlink(zipappPath) } catch { /* noop */ }
} catch (err) {
diagnostics.push(`zipapp 다운로드 실패: ${err instanceof Error ? err.message : String(err)}`)
try { await fs.unlink(zipappPath) } catch { /* noop */ }
}
}
throw new YtDlpUnavailableError(
t('youtube.ytdlpVerifyFailedDetail', { detail: diagnostics.join(' | ') })
)
}
async function fileExists(filePath: string): Promise<boolean> {
try { try {
await fs.access(filePath, fsConst.F_OK) await fs.access(filePath, fsConst.F_OK)
return true
} catch { } catch {
return false return false
} }
// POSIX 면 X 비트도 확인
if (process.platform !== 'win32') {
try {
await fs.access(filePath, fsConst.X_OK)
} catch {
return false
}
}
// 실제로 --version 으로 한 번 더 확인
return probeVersion(filePath)
} }
function probeVersion(bin: string): Promise<boolean> { function probeVersion(bin: string): Promise<ProbeResult> {
return new Promise((resolve) => { return new Promise((resolve) => {
const child = spawn(bin, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] }) let child: ReturnType<typeof spawn>
let ok = false try {
child.stdout.on('data', () => { ok = true }) child = spawn(bin, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
child.on('error', () => resolve(false)) } catch (err) {
child.on('close', (code) => resolve(ok && code === 0)) resolve({ ok: false, detail: `spawn throw: ${err instanceof Error ? err.message : String(err)}` })
return
}
let stdout = ''
let stderr = ''
child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
child.on('error', (err: NodeJS.ErrnoException) => {
const code = err.code ? `${err.code} ` : ''
resolve({ ok: false, detail: `spawn error: ${code}${err.message}` })
})
child.on('close', (code, signal) => {
const out = stdout.trim()
if (out && code === 0) {
resolve({ ok: true })
return
}
const parts: string[] = []
parts.push(`exit=${code === null ? `signal:${signal}` : code}`)
if (!out) parts.push('stdout=(empty)')
const errLine = stderr.trim().split('\n')[0]
if (errLine) parts.push(`stderr="${errLine.slice(0, 200)}"`)
resolve({ ok: false, detail: parts.join(', ') })
})
}) })
} }
@@ -141,52 +246,80 @@ function downloadToFile(url: string, dest: string, redirects = 0): Promise<void>
}) })
} }
/** yt-dlp 를 한 번 실행하고 종료코드·stdout·stderr 를 모은다. reject 하지 않는다. */
function spawnYtDlp(bin: string, args: string[]): Promise<{ code: number | null; stdout: string; stderr: string }> {
return new Promise((resolve) => {
let child: ReturnType<typeof spawn>
try {
child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] })
} catch (err) {
resolve({ code: null, stdout: '', stderr: err instanceof Error ? err.message : String(err) })
return
}
let stdout = ''
let stderr = ''
let settled = false
const done = (r: { code: number | null; stdout: string; stderr: string }) => {
if (settled) return
settled = true
resolve(r)
}
child.stdout?.on('data', (chunk: Buffer) => (stdout += chunk.toString('utf8')))
child.stderr?.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
child.on('error', (err) => done({ code: null, stdout, stderr: stderr || (err as Error).message }))
child.on('close', (code) => done({ code, stdout, stderr }))
})
}
/**
* yt-dlp 를 실행하고 stdout 을 돌려준다. 첫 시도가 실패(0 이 아닌 종료코드/실행 불가)하면
* yt-dlp 가 오래돼 유튜브 변경을 못 따라가는 상황일 수 있으므로, 최신 버전으로 강제
* 재설치한 뒤 한 번 더 시도한다. 그래도 실패하면 makeError 로 만든 에러를 던진다.
*/
async function runYtDlp(args: string[], makeError: (code: string, detail: string) => Error): Promise<string> {
let bin = await ensureYtDlp()
let res = await spawnYtDlp(bin, args)
if (res.code !== 0) {
let refreshed = false
try {
bin = await ensureYtDlp(true)
refreshed = true
} catch { /* 재설치 실패 시 아래에서 원래 실패로 보고 */ }
if (refreshed) {
res = await spawnYtDlp(bin, args)
}
if (res.code !== 0) {
throw makeError(String(res.code), res.stderr.trim() || res.stdout.trim())
}
}
return res.stdout
}
/** /**
* 단일 영상 URL 의 메타데이터를 가져온다. * 단일 영상 URL 의 메타데이터를 가져온다.
* `--no-playlist` 로 플레이리스트 URL 이 들어와도 단일 영상 정보만 뽑음. * `--no-playlist` 로 플레이리스트 URL 이 들어와도 단일 영상 정보만 뽑음.
*/ */
export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | null> { export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | null> {
const bin = await ensureYtDlp() const stdout = await runYtDlp(
return new Promise((resolve, reject) => { ['--dump-json', '--no-warnings', '--no-playlist', '--skip-download', url],
const child = spawn(bin, [ (code, detail) => new Error(t('youtube.ytdlpVideoFailed', { code, detail }))
'--dump-json', )
'--no-warnings', const line = stdout.trim().split('\n').find((l) => l.trim().length > 0)
'--no-playlist', if (!line) return null
'--skip-download', const obj = JSON.parse(line) as Record<string, unknown>
url const id = typeof obj.id === 'string' ? obj.id : ''
], { stdio: ['ignore', 'pipe', 'pipe'] }) if (!id) return null
let stdout = '' return {
let stderr = '' id,
child.stdout.on('data', (chunk: Buffer) => (stdout += chunk.toString('utf8'))) title: typeof obj.title === 'string' ? obj.title : '',
child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8'))) channel: typeof obj.channel === 'string'
child.on('error', (err) => reject(err)) ? obj.channel
child.on('close', (code) => { : (typeof obj.uploader === 'string' ? obj.uploader : ''),
if (code !== 0) { durationSec: typeof obj.duration === 'number' ? Math.floor(obj.duration) : 0,
reject(new Error(t('youtube.ytdlpVideoFailed', { code: String(code), detail: stderr.trim() || stdout.trim() }))) url: typeof obj.webpage_url === 'string' && obj.webpage_url.length > 0
return ? obj.webpage_url
} : `https://www.youtube.com/watch?v=${id}`
const line = stdout.trim().split('\n').find((l) => l.trim().length > 0) }
if (!line) { resolve(null); return }
try {
const obj = JSON.parse(line) as Record<string, unknown>
const id = typeof obj.id === 'string' ? obj.id : ''
if (!id) { resolve(null); return }
resolve({
id,
title: typeof obj.title === 'string' ? obj.title : '',
channel: typeof obj.channel === 'string'
? obj.channel
: (typeof obj.uploader === 'string' ? obj.uploader : ''),
durationSec: typeof obj.duration === 'number' ? Math.floor(obj.duration) : 0,
url: typeof obj.webpage_url === 'string' && obj.webpage_url.length > 0
? obj.webpage_url
: `https://www.youtube.com/watch?v=${id}`
})
} catch (err) {
reject(err)
}
})
})
} }
/** /**
@@ -194,47 +327,31 @@ export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | nul
* `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON. * `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON.
*/ */
export async function fetchPlaylistEntries(url: string): Promise<YtPlaylistEntry[]> { export async function fetchPlaylistEntries(url: string): Promise<YtPlaylistEntry[]> {
const bin = await ensureYtDlp() const stdout = await runYtDlp(
return new Promise((resolve, reject) => { ['--flat-playlist', '--dump-json', '--no-warnings', url],
const child = spawn(bin, [ (code, detail) => new Error(t('youtube.ytdlpPlaylistFailed', { code, detail }))
'--flat-playlist', )
'--dump-json', const lines = stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
'--no-warnings', const parsed: YtPlaylistEntry[] = []
url for (const line of lines) {
], { stdio: ['ignore', 'pipe', 'pipe'] }) try {
let stdout = '' const obj = JSON.parse(line) as Record<string, unknown>
let stderr = '' const id = typeof obj.id === 'string' ? obj.id : ''
child.stdout.on('data', (chunk: Buffer) => (stdout += chunk.toString('utf8'))) if (!id) continue
child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8'))) parsed.push({
child.on('error', (err) => reject(err)) id,
child.on('close', (code) => { title: typeof obj.title === 'string' ? obj.title : '',
if (code !== 0) { channel: typeof obj.channel === 'string'
reject(new Error(t('youtube.ytdlpPlaylistFailed', { code: String(code), detail: stderr.trim() || stdout.trim() }))) ? obj.channel
return : (typeof obj.uploader === 'string' ? obj.uploader : ''),
} durationSec: typeof obj.duration === 'number' ? Math.floor(obj.duration) : 0,
const lines = stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0) url: typeof obj.url === 'string' && obj.url.length > 0
const parsed: YtPlaylistEntry[] = [] ? obj.url
for (const line of lines) { : `https://www.youtube.com/watch?v=${id}`
try { })
const obj = JSON.parse(line) as Record<string, unknown> } catch {
const id = typeof obj.id === 'string' ? obj.id : '' // 한 줄이 깨져도 나머지는 살림
if (!id) continue }
parsed.push({ }
id, return parsed
title: typeof obj.title === 'string' ? obj.title : '',
channel: typeof obj.channel === 'string'
? obj.channel
: (typeof obj.uploader === 'string' ? obj.uploader : ''),
durationSec: typeof obj.duration === 'number' ? Math.floor(obj.duration) : 0,
url: typeof obj.url === 'string' && obj.url.length > 0
? obj.url
: `https://www.youtube.com/watch?v=${id}`
})
} catch {
// 한 줄이 깨져도 나머지는 살림
}
}
resolve(parsed)
})
})
} }

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),
@@ -172,6 +178,14 @@ export async function deletePackKeys(keys: string[]): Promise<void> {
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
} }
// pack 이 삭제되면 약관 폴더도 함께 정리한다. 동일 packKey 로 재생성될 때
// 옛 약관이 부활하는 것을 막기 위함.
const termsDir = path.join(manifestTermsDirPath, key)
try {
await fsp.rm(termsDir, { recursive: true, force: true })
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
await syncManifestWith(key, '', 'remove') await syncManifestWith(key, '', 'remove')
} }
} }
@@ -192,6 +206,31 @@ export async function renamePack(oldKey: string, newKey: string, pack: PackDefin
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
} }
// 음악·사진 목록 JSON(file/list/<key>.json)도 함께 이름을 바꾼다. 이걸 빼먹으면
// manifest 정의는 새 키로 옮겨졌는데 정작 목록 데이터는 옛 키 파일에 남아,
// 새 packKey 로는 빈 목록만 보이고 인스톨러도 곡/사진을 받지 못한다.
const oldListFile = path.join(fileListDirPath, `${oldKey}.json`)
const newListFile = path.join(fileListDirPath, `${safeNew}.json`)
try {
await fsp.mkdir(fileListDirPath, { recursive: true })
await fsp.rename(oldListFile, newListFile)
} catch (error) {
// 옛 목록 파일이 없으면(한 번도 저장 안 한 새 pack) 그냥 둔다.
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
// 약관 폴더도 함께 이름을 바꾼다 (있는 경우만). pack 이름이 바뀌었는데 약관이
// 옛 폴더에 남아 있으면 인스톨러가 새 packKey 로 약관을 받지 못한다.
const oldTermsDir = path.join(manifestTermsDirPath, oldKey)
const newTermsDir = path.join(manifestTermsDirPath, safeNew)
try {
await fsp.rename(oldTermsDir, newTermsDir)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
// 옛 약관 폴더가 없으면 그대로 둔다. 새 폴더가 이미 있어 충돌하면 그것도 그냥 둔다
// (renamePack 단계에서 사용자에게 보낼 마땅한 UX 가 없고, 다음 약관 접근 때
// 새 폴더 내용이 정상적으로 사용된다).
if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST') throw error
}
await syncManifestWith(oldKey, '', 'remove') await syncManifestWith(oldKey, '', 'remove')
} }
await syncManifestWith(safeNew, pack.name, 'upsert') await syncManifestWith(safeNew, pack.name, 'upsert')
@@ -260,7 +299,8 @@ export function normalizePackList(input: unknown): PackList {
title: sanitizeStr(entry.title), title: sanitizeStr(entry.title),
artist: sanitizeStr(entry.artist), artist: sanitizeStr(entry.artist),
durationSec: sanitizeNumber(entry.durationSec), durationSec: sanitizeNumber(entry.durationSec),
aliases: sanitizeAliases(entry.aliases) aliases: sanitizeAliases(entry.aliases),
description: sanitizeStr(entry.description)
})) }))
.filter((entry) => entry.url.length > 0), .filter((entry) => entry.url.length > 0),
images: images images: images
@@ -288,6 +328,431 @@ 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 파일.
// - 음악퀴즈(pack)별로 독립 폴더(`manifest/terms/<packKey>/`) 에 저장한다.
// - 각 약관(.md) 은 `_meta.json` 의 `terms.<kind>` 엔트리로 라벨/표시 대상이 관리된다.
// 엔트리: { label, showInInstaller, showInInstallerRp }
// - 모든 약관은 추가/삭제 가능. builtin 같은 보호 개념은 더 이상 없음 (v0.3.4~).
// 인스톨러는 하드코딩 5종 대신 `index.json` 에서 자기 인스톨러용 약관 목록을 받는다.
// - 첫 접근 시 5개 기본 약관(map/mod/installer + resourcepack/installer-rp) 을 시드.
// - 파일명 규칙: `[a-z0-9][a-z0-9-]{0,31}\.md` (소문자/숫자/하이픈, 32자 이내).
// - 레거시(전역) `manifest/terms/*.md` 파일이 남아 있으면 packKey 폴더 첫 접근 시 자동 시드.
export type TermKind = string
/**
* 처음 pack 폴더를 만들 때 시드되는 기본 약관 5종 + 기본 표시 대상.
* 사용자는 이후 자유롭게 삭제하거나 표시 대상을 바꿀 수 있다.
*/
const DEFAULT_TERM_SEEDS: Array<{
kind: string
label: string
showInInstaller: boolean
showInInstallerRp: boolean
}> = [
{ kind: 'map', label: '맵 약관', showInInstaller: true, showInInstallerRp: false },
{ kind: 'mod', label: '모드 약관', showInInstaller: true, showInInstallerRp: false },
{ kind: 'installer', label: '설치기 약관', showInInstaller: true, showInInstallerRp: false },
{ kind: 'resourcepack', label: '리소스팩 약관', showInInstaller: false, showInInstallerRp: true },
{ kind: 'installer-rp', label: '리소스팩 설치기 약관', showInInstaller: false, showInInstallerRp: true }
]
const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
export function isTermKind(value: unknown): value is TermKind {
return typeof value === 'string' && TERM_KIND_RE.test(value)
}
export interface TermEntry {
label: string
showInInstaller: boolean
showInInstallerRp: boolean
}
interface TermsMeta {
terms: Record<string, TermEntry>
}
const TERMS_META_FILE = '_meta.json'
function termsDirForPack(packKey: string): string {
return path.join(manifestTermsDirPath, packKey)
}
function isValidPackKey(packKey: string): boolean {
return typeof packKey === 'string'
&& packKey.length > 0
&& /^[a-zA-Z0-9_\-]+$/.test(packKey)
}
/**
* 해당 pack 폴더가 없으면 만든다. 이전 버전(v0.3.1) 의 전역 `manifest/terms/*.md`
* 파일이 남아 있는 경우 첫 접근 시 그 내용을 그대로 새 폴더에 복사해 시드한다.
* 시드는 한 번만 발생: 폴더가 이미 있으면 아무것도 안 한다.
*
* 공개 라우트(`/manifest/terms/<packKey>/<file>`) 에서도 호출되므로 export 한다.
* 라우트 측은 packKey 가 실제 존재하는 pack 인지 확인한 다음에 호출해야 한다
* (그렇지 않으면 임의 키로 빈 폴더가 생성될 수 있다).
*/
export async function ensurePackTermsDir(packKey: string): Promise<string> {
const dir = termsDirForPack(packKey)
let isNew = false
try {
await fsp.access(dir)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
isNew = true
await fsp.mkdir(dir, { recursive: true })
// 레거시(전역) .md 파일이 남아 있으면 그대로 복사 (.md 만, _meta.json 은 새 스키마로 새로 씀).
try {
const legacyEntries = await fsp.readdir(manifestTermsDirPath, { withFileTypes: true })
for (const ent of legacyEntries) {
if (!ent.isFile()) continue
const name = ent.name
if (!name.toLowerCase().endsWith('.md')) continue
const kind = name.slice(0, -3)
if (!TERM_KIND_RE.test(kind)) continue
try {
await fsp.copyFile(
path.join(manifestTermsDirPath, name),
path.join(dir, name)
)
} catch { /* ignore */ }
}
} catch (error2) {
if ((error2 as NodeJS.ErrnoException).code !== 'ENOENT') throw error2
}
}
// 폴더가 새로 만들어졌든 기존이든, _meta.json 이 없거나 구 스키마면 5종 기본 + .md 매칭으로 보완.
await ensureMetaInitialized(dir, isNew)
return dir
}
/**
* `_meta.json` 이 없으면 5종 기본 + 디스크 .md 매칭으로 새로 작성한다.
* 구 스키마(`customLabels`) 가 있으면 새 스키마(`terms`) 로 변환한다.
* 이미 새 스키마면 그대로 둔다 (사용자가 끈 visibility 가 다시 켜지지 않도록).
*/
async function ensureMetaInitialized(dir: string, dirWasJustCreated: boolean): Promise<void> {
const metaPath = path.join(dir, TERMS_META_FILE)
let parsed: unknown = null
try {
const raw = await fsp.readFile(metaPath, 'utf8')
parsed = JSON.parse(raw)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
// 이미 새 스키마면 종료. 빠진 default kind 가 디스크에 있다면 그것만 보충.
if (parsed && typeof parsed === 'object' && (parsed as Record<string, unknown>).terms) {
const meta = parsed as { terms: Record<string, unknown> }
let changed = false
for (const seed of DEFAULT_TERM_SEEDS) {
if (meta.terms[seed.kind]) continue
// .md 가 실제로 디스크에 있을 때만 보충 (없는 약관까지 자동 부활시키지 않음).
try {
await fsp.access(path.join(dir, `${seed.kind}.md`))
} catch {
continue
}
meta.terms[seed.kind] = {
label: seed.label,
showInInstaller: seed.showInInstaller,
showInInstallerRp: seed.showInInstallerRp
}
changed = true
}
if (changed) {
await fsp.writeFile(metaPath, `${JSON.stringify(meta, null, 2)}\n`, 'utf8')
}
return
}
// 구 스키마 customLabels 만 있던 경우 → 새 스키마로 변환.
const oldCustomLabels: Record<string, string> = {}
if (parsed && typeof parsed === 'object' && (parsed as Record<string, unknown>).customLabels
&& typeof (parsed as Record<string, unknown>).customLabels === 'object') {
for (const [k, v] of Object.entries((parsed as { customLabels: Record<string, unknown> }).customLabels)) {
if (typeof v === 'string' && TERM_KIND_RE.test(k)) oldCustomLabels[k] = v
}
}
const terms: Record<string, TermEntry> = {}
// 5종 기본: 디스크에 .md 가 있을 때만 추가 (없는 건 사용자가 의도적으로 지운 것일 수 있음).
// 다만 폴더가 막 생성된 경우는 5종을 무조건 시드 (legacy 시드가 비어 있어도).
for (const seed of DEFAULT_TERM_SEEDS) {
if (!dirWasJustCreated) {
try {
await fsp.access(path.join(dir, `${seed.kind}.md`))
} catch {
continue
}
} else {
// 폴더 새로 생성 케이스: .md 가 없으면 빈 파일 만들어 줌.
const filePath = path.join(dir, `${seed.kind}.md`)
try {
await fsp.access(filePath)
} catch {
await fsp.writeFile(filePath, `# ${seed.label}\n\n`, 'utf8')
}
}
terms[seed.kind] = {
label: seed.label,
showInInstaller: seed.showInInstaller,
showInInstallerRp: seed.showInInstallerRp
}
}
// 구 스키마의 사용자 정의 약관은 양쪽 인스톨러에 보이도록 기본값으로.
for (const [k, label] of Object.entries(oldCustomLabels)) {
if (terms[k]) continue
try {
await fsp.access(path.join(dir, `${k}.md`))
} catch {
continue
}
terms[k] = { label, showInInstaller: true, showInInstallerRp: true }
}
await fsp.writeFile(metaPath, `${JSON.stringify({ terms }, null, 2)}\n`, 'utf8')
}
async function loadTermsMeta(packKey: string): Promise<TermsMeta> {
const dir = await ensurePackTermsDir(packKey)
try {
const raw = await fsp.readFile(path.join(dir, TERMS_META_FILE), 'utf8')
const parsed = JSON.parse(raw) as unknown
const result: TermsMeta = { terms: {} }
if (parsed && typeof parsed === 'object' && (parsed as Record<string, unknown>).terms
&& typeof (parsed as Record<string, unknown>).terms === 'object') {
for (const [k, v] of Object.entries((parsed as { terms: Record<string, unknown> }).terms)) {
if (!TERM_KIND_RE.test(k)) continue
if (!v || typeof v !== 'object') continue
const entry = v as Record<string, unknown>
const label = typeof entry.label === 'string' ? entry.label : k
result.terms[k] = {
label,
showInInstaller: entry.showInInstaller === true,
showInInstallerRp: entry.showInInstallerRp === true
}
}
}
return result
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { terms: {} }
throw error
}
}
async function saveTermsMeta(packKey: string, meta: TermsMeta): Promise<void> {
const dir = await ensurePackTermsDir(packKey)
await fsp.writeFile(
path.join(dir, TERMS_META_FILE),
`${JSON.stringify(meta, null, 2)}\n`,
'utf8'
)
}
export interface TermItem {
kind: string
label: string
showInInstaller: boolean
showInInstallerRp: boolean
}
/**
* 디스크의 .md 파일과 매칭되면서 `_meta.json` 의 `terms` 에 등록된 약관 목록을 반환.
* 정렬: 5종 기본(DEFAULT_TERM_SEEDS 순서) → 그 외 사용자 정의 (kind 사전순).
*/
export async function listTermsWithLabels(packKey: string): Promise<TermItem[]> {
const dir = await ensurePackTermsDir(packKey)
const meta = await loadTermsMeta(packKey)
let onDisk: string[] = []
try {
onDisk = await fsp.readdir(dir)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
const mdKinds = new Set<string>()
for (const fname of onDisk) {
if (!fname.toLowerCase().endsWith('.md')) continue
const kind = fname.slice(0, -3)
if (!TERM_KIND_RE.test(kind)) continue
mdKinds.add(kind)
}
const items: TermItem[] = []
const seen = new Set<string>()
// 1) 기본 시드 순서 우선.
for (const seed of DEFAULT_TERM_SEEDS) {
const entry = meta.terms[seed.kind]
if (!entry) continue
if (!mdKinds.has(seed.kind)) continue
items.push({
kind: seed.kind,
label: entry.label,
showInInstaller: entry.showInInstaller,
showInInstallerRp: entry.showInInstallerRp
})
seen.add(seed.kind)
}
// 2) 그 외 사용자 정의: 사전순.
const rest = Object.keys(meta.terms).filter((k) => !seen.has(k))
rest.sort((a, b) => a.localeCompare(b, 'ko'))
for (const kind of rest) {
if (!mdKinds.has(kind)) continue
const entry = meta.terms[kind]
items.push({
kind,
label: entry.label,
showInInstaller: entry.showInInstaller,
showInInstallerRp: entry.showInInstallerRp
})
}
return items
}
export async function getTermLabel(packKey: string, kind: string): Promise<string> {
const meta = await loadTermsMeta(packKey)
return meta.terms[kind]?.label ?? kind
}
export async function getTermEntry(packKey: string, kind: string): Promise<TermEntry | null> {
const meta = await loadTermsMeta(packKey)
return meta.terms[kind] ?? null
}
export async function setTermVisibility(
packKey: string,
kind: string,
visibility: { showInInstaller: boolean; showInInstallerRp: boolean }
): Promise<void> {
if (!isTermKind(kind)) throw new Error('invalid term kind')
const meta = await loadTermsMeta(packKey)
const entry = meta.terms[kind]
if (!entry) throw new Error('term not found')
entry.showInInstaller = !!visibility.showInInstaller
entry.showInInstallerRp = !!visibility.showInInstallerRp
await saveTermsMeta(packKey, meta)
}
export async function loadTerm(packKey: string, kind: TermKind): Promise<string> {
if (!isTermKind(kind)) return ''
const dir = await ensurePackTermsDir(packKey)
const filePath = path.join(dir, `${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(packKey: string, kind: TermKind, markdown: string): Promise<void> {
if (!isTermKind(kind)) throw new Error('invalid term kind')
const dir = await ensurePackTermsDir(packKey)
const filePath = path.join(dir, `${kind}.md`)
const normalized = (markdown ?? '').replace(/\r\n/g, '\n')
await fsp.writeFile(filePath, normalized.endsWith('\n') ? normalized : `${normalized}\n`, 'utf8')
}
/**
* 새 약관 추가. kind 충돌은 예외. 빈 `.md` 파일을 만든다.
* v0.3.4~: builtin 보호 개념이 없어 임의 kind 를 추가/삭제할 수 있다. 다만
* `meta.terms` 에 이미 있는 kind 와 충돌하면 거부. 표시 대상 기본값은 양쪽 인스톨러 모두.
*/
export async function createTerm(packKey: string, kind: string, label: string): Promise<void> {
if (!isTermKind(kind)) throw new Error('invalid term kind')
const cleanLabel = label.trim()
if (cleanLabel.length === 0 || cleanLabel.length > 50) throw new Error('invalid label length')
const meta = await loadTermsMeta(packKey)
if (meta.terms[kind]) throw new Error('term kind already exists')
const dir = await ensurePackTermsDir(packKey)
const filePath = path.join(dir, `${kind}.md`)
// 파일 충돌도 막는다 (수동 생성된 .md 가 있을 수 있음).
try {
await fsp.access(filePath)
throw new Error('term file already exists')
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
await fsp.writeFile(filePath, `# ${cleanLabel}\n\n`, 'utf8')
// 기본 시드 kind 면 그 시드의 visibility 기본을 따르고, 그 외는 양쪽 인스톨러 모두 표시.
const seed = DEFAULT_TERM_SEEDS.find((s) => s.kind === kind)
meta.terms[kind] = {
label: cleanLabel,
showInInstaller: seed ? seed.showInInstaller : true,
showInInstallerRp: seed ? seed.showInInstallerRp : true
}
await saveTermsMeta(packKey, meta)
}
/** 약관 삭제. v0.3.4~: builtin 보호 없음 — 모든 kind 삭제 가능. */
export async function deleteTerm(packKey: string, kind: string): Promise<void> {
if (!isTermKind(kind)) throw new Error('invalid term kind')
const dir = await ensurePackTermsDir(packKey)
const filePath = path.join(dir, `${kind}.md`)
try {
await fsp.unlink(filePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
const meta = await loadTermsMeta(packKey)
if (meta.terms[kind]) {
delete meta.terms[kind]
await saveTermsMeta(packKey, meta)
}
}
/**
* 다른 음악퀴즈의 약관 전체를 현재 pack 으로 복사한다 (불러오기).
* - source 의 모든 .md 를 target 에 덮어쓴다.
* - target 에만 있던 약관 엔트리는 그대로 둔다 (source 에는 없으니 안 건드림).
* - 동일한 kind 가 source 에도 있다면 source 의 라벨/표시 대상으로 덮어씀.
*/
export async function importTerms(targetPackKey: string, sourcePackKey: string): Promise<void> {
if (!isValidPackKey(targetPackKey) || !isValidPackKey(sourcePackKey)) {
throw new Error('invalid pack key')
}
if (targetPackKey === sourcePackKey) throw new Error('source and target are identical')
const sourceDir = await ensurePackTermsDir(sourcePackKey)
const targetDir = await ensurePackTermsDir(targetPackKey)
const sourceMeta = await loadTermsMeta(sourcePackKey)
const targetMeta = await loadTermsMeta(targetPackKey)
// source 의 .md 파일을 모두 target 으로 복사.
let entries: string[] = []
try {
entries = await fsp.readdir(sourceDir)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
for (const name of entries) {
if (!name.toLowerCase().endsWith('.md')) continue
const kind = name.slice(0, -3)
if (!TERM_KIND_RE.test(kind)) continue
await fsp.copyFile(path.join(sourceDir, name), path.join(targetDir, name))
}
// 약관 엔트리도 source 기준으로 머지 (덮어쓰기).
const mergedTerms: Record<string, TermEntry> = { ...targetMeta.terms }
for (const [k, v] of Object.entries(sourceMeta.terms)) {
mergedTerms[k] = { ...v }
}
await saveTermsMeta(targetPackKey, { terms: mergedTerms })
}
/**
* 공개 라우트(`/manifest/terms/<packKey>/<file>`)에서 호출.
* - packKey 가 영문/숫자/언더스코어/하이픈만 사용했는지 검사.
* - 파일명이 .md 로 끝나고 정상 kind 패턴인지 검사.
* - _meta.json 같은 시스템 파일은 차단.
*/
export function isPublicTermsFile(packKey: string, fileName: string): boolean {
if (!isValidPackKey(packKey)) return false
if (!fileName.toLowerCase().endsWith('.md')) return false
const kind = fileName.slice(0, -3)
return TERM_KIND_RE.test(kind)
}
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
@@ -49,6 +57,8 @@ export interface MusicListEntry {
durationSec: number durationSec: number
/** 정답으로 인정할 별칭 목록. 빈 배열이면 정답은 title 뿐. */ /** 정답으로 인정할 별칭 목록. 빈 배열이면 정답은 title 뿐. */
aliases: string[] aliases: string[]
/** 곡 설명 / 트리비아 메모. 정답 채점이나 데이터팩 생성에는 사용되지 않는다. */
description: string
} }
export interface ImageListEntry { export interface ImageListEntry {

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>

View File

@@ -124,6 +124,21 @@
</div> </div>
</div> </div>
<!-- Description modal (music) -->
<div class="modalOverlay" id="descModal" hidden>
<div class="modalCard">
<header class="aliasModalHeader">
<button type="button" class="ghostLink" id="desc-back"><%= t('listEditor.descBack') %></button>
<h3 id="desc-modal-title"></h3>
<span></span>
</header>
<div class="modalBody">
<p class="muted" style="margin:0;font-size:12px;"><%= t('listEditor.descHint') %></p>
<textarea id="desc-textarea" class="textInput descTextarea" placeholder="<%= t('listEditor.descPlaceholder') %>" rows="6"></textarea>
</div>
</div>
</div>
<!-- Edit modal (image) --> <!-- Edit modal (image) -->
<div class="modalOverlay" id="editImageModal" hidden> <div class="modalOverlay" id="editImageModal" hidden>
<div class="modalCard"> <div class="modalCard">

154
views/op/terms-pack.ejs Normal file
View File

@@ -0,0 +1,154 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= t('terms.packBrowserTitle', { name: pack.name }) %></title>
<link rel="stylesheet" href="/static/styles.css" />
<style>
/* 약관 목록 — 카드 한 줄(가로 풀폭) 씩 세로로 쌓이도록. */
.termsList { display: flex; flex-direction: column; gap: 10px; margin-top: 16px; }
.termsRow {
display: flex; align-items: center; justify-content: space-between;
gap: 12px;
background: var(--bg-card);
border: 1px solid var(--border, #30363d);
border-radius: 10px;
padding: 14px 18px;
}
.termsRow .termsRowMain { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.termsRow .termsRowLabel { display: flex; align-items: center; gap: 8px; }
.termsRow .termsRowLabel h2 { margin: 0; font-size: 16px; }
.termsRow .termsRowSub { color: var(--text-muted); font-size: 12px; margin-top: 2px; }
.termsRow .termsRowActions { display: flex; gap: 8px; align-items: center; }
.visibilityBadges {
display: flex; gap: 6px; flex-wrap: wrap;
}
.visibilityBadge {
display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 999px;
background: rgba(76, 175, 80, 0.15); color: #8ed68f;
border: 1px solid rgba(76, 175, 80, 0.35);
font-size: 11px;
}
.visibilityBadge.off {
background: rgba(255,255,255,0.05); color: var(--text-muted);
border-color: rgba(255,255,255,0.12);
}
.termsSideBySide {
display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 24px;
}
@media (max-width: 900px) {
.termsSideBySide { grid-template-columns: 1fr; }
}
.termsSection {
background: var(--bg-card);
border: 1px solid var(--border, #30363d);
border-radius: 10px;
padding: 16px 18px;
}
.termsSection h2 { margin: 0 0 12px; font-size: 15px; }
.termsAddForm { display: grid; grid-template-columns: 1fr 2fr; gap: 10px; align-items: end; }
.termsAddForm .field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.termsAddForm label { font-size: 12px; color: var(--text-muted); }
.termsAddForm input, .termsImportForm select {
background: var(--bg-alt); color: var(--text);
border: 1px solid var(--border, #30363d); border-radius: 6px;
padding: 8px 10px; font-size: 13px;
}
.termsAddForm .hint { color: var(--text-muted); font-size: 11px; }
.termsAddForm .formActions { grid-column: 1 / -1; display: flex; justify-content: flex-end; }
.termsImportForm { display: grid; grid-template-columns: 1fr; gap: 10px; }
.termsImportForm .field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.termsImportForm label { font-size: 12px; color: var(--text-muted); }
.termsImportForm .formActions { display: flex; justify-content: flex-end; }
.termsImportForm .hint { color: var(--text-muted); font-size: 11px; }
</style>
</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.packTitle', { name: pack.name }) %></h1>
<p class="muted"><%= packKey %>.json</p>
</div>
</section>
<p class="muted"><%= t('terms.hint') %></p>
<section class="termsList">
<% items.forEach(function (item) { %>
<article class="termsRow">
<a class="termsRowMain" href="/op/agreement/<%= packKey %>/<%= item.kind %>" style="text-decoration:none; color:inherit;">
<div class="termsRowLabel">
<h2><%= item.label %></h2>
<span class="visibilityBadges">
<span class="visibilityBadge <%= item.showInInstaller ? '' : 'off' %>"><%= t('terms.visibilityInstallerShort') %></span>
<span class="visibilityBadge <%= item.showInInstallerRp ? '' : 'off' %>"><%= t('terms.visibilityInstallerRpShort') %></span>
</span>
</div>
<div class="termsRowSub"><%= item.kind %>.md</div>
</a>
<div class="termsRowActions">
<a class="secondaryButton" href="/op/agreement/<%= packKey %>/<%= item.kind %>"><%= t('terms.edit') %></a>
<form method="post" action="/op/agreement/<%= packKey %>/<%= item.kind %>/delete"
onsubmit="return confirm('<%= t('terms.deleteConfirm', { label: item.label }).replace(/'/g, "\\'") %>');"
style="margin:0;">
<button type="submit" class="dangerButton"><%= t('terms.deleteButton') %></button>
</form>
</div>
</article>
<% }) %>
</section>
<section class="termsSideBySide">
<div class="termsSection">
<h2><%= t('terms.addHeading') %></h2>
<form method="post" action="/op/agreement/<%= packKey %>/create" class="termsAddForm">
<div class="field">
<label for="newKind"><%= t('terms.kindLabel') %></label>
<input id="newKind" name="kind" type="text" required
pattern="[a-z0-9][a-z0-9-]{0,31}"
placeholder="<%= t('terms.kindPlaceholder') %>" />
<span class="hint"><%= t('terms.kindHint') %></span>
</div>
<div class="field">
<label for="newLabel"><%= t('terms.labelLabel') %></label>
<input id="newLabel" name="label" type="text" required maxlength="50"
placeholder="<%= t('terms.labelPlaceholder') %>" />
</div>
<div class="formActions">
<button type="submit" class="primaryButton"><%= t('terms.addButton') %></button>
</div>
</form>
</div>
<div class="termsSection">
<h2><%= t('terms.importHeading') %></h2>
<% if (sourceCandidates.length === 0) { %>
<p class="muted"><%= t('terms.importEmpty') %></p>
<% } else { %>
<form method="post" action="/op/agreement/<%= packKey %>/import" class="termsImportForm"
onsubmit="return confirm('<%= t('terms.importConfirm').replace(/'/g, "\\'") %>');">
<div class="field">
<label for="importSource"><%= t('terms.importSourceLabel') %></label>
<select id="importSource" name="source" required>
<option value=""><%= t('terms.importSourcePlaceholder') %></option>
<% sourceCandidates.forEach(function (cand) { %>
<option value="<%= cand.key %>"><%= cand.definition ? cand.definition.name : cand.key %> (<%= cand.key %>)</option>
<% }) %>
</select>
<span class="hint"><%= t('terms.importHint') %></span>
</div>
<div class="formActions">
<button type="submit" class="primaryButton"><%= t('terms.importButton') %></button>
</div>
</form>
<% } %>
</div>
</section>
</main>
</body>
</html>

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

@@ -0,0 +1,44 @@
<!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.pickPackHint') %></p>
<section class="cardRow horizontalScroll">
<% if (items.length === 0) { %>
<p class="muted"><%= t('site.empty') %></p>
<% } %>
<% items.forEach(function (item) { %>
<article class="packCard">
<a class="cardLink" href="/op/agreement/<%= item.key %>">
<h2><%= item.definition ? item.definition.name : item.key %></h2>
<p class="muted"><%= item.key %>.json</p>
<% if (item.definition) { %>
<ul class="metaList">
<li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
<li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
<li><%= t('site.modsFolder') %> <%= item.definition.modsFolder || t('site.noneFallback') %></li>
</ul>
<% } %>
</a>
</article>
<% }) %>
</section>
</main>
</body>
</html>

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

@@ -0,0 +1,63 @@
<!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/<%= packKey %>"><%= t('common.back') %></a>
<h1 style="margin-top:20px;"><%= t('terms.editorTitle', { label: label }) %></h1>
<p class="muted"><%= pack.name %> · <%= 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>
<!-- 표시 대상 토글: 어느 인스톨러에서 이 약관을 보여줄지 (중복 선택 가능). -->
<fieldset class="termsVisibility" style="margin-top:16px; padding:10px 14px; border:1px solid var(--border, #30363d); border-radius:8px;">
<legend style="padding:0 6px; font-size:12px; color:var(--text-muted);"><%= t('terms.visibilityHeading') %></legend>
<label style="display:inline-flex; align-items:center; gap:6px; margin-right:18px;">
<input type="checkbox" id="visInstaller" <%= showInInstaller ? 'checked' : '' %> />
<span><%= t('terms.visibilityInstaller') %></span>
</label>
<label style="display:inline-flex; align-items:center; gap:6px;">
<input type="checkbox" id="visInstallerRp" <%= showInInstallerRp ? 'checked' : '' %> />
<span><%= t('terms.visibilityInstallerRp') %></span>
</label>
</fieldset>
<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 PACK_KEY = <%- JSON.stringify(packKey) %>;
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>