Compare commits

...

31 Commits

Author SHA1 Message Date
13e4e0eb13 fix(installer): fabric 를 .minecraft 에 설치 — 바닐라 라이브러리/버전 누락 해결
근본 원인(실측): fabric-installer 를 -dir .mc_custom 으로 돌리면 .mc_custom 에
versions/ 와 libraries/ 를 실제 폴더로 만드는데 fabric 것만 들어가고 바닐라 버전
JSON·라이브러리는 빠진다. 그러면 .minecraft 로의 링크가 "폴더 있음" 으로 건너뛰어져
런처가 바닐라 데이터를 못 찾고 "Unable to prepare assets for download" 로 실패.
(웹 조사 확인: assets/libraries/versions 는 런처 폴더 .minecraft 아래 형제 폴더.)

- fabric 을 .minecraft 에 설치(-dir .minecraft)해 바닐라와 같은 위치에 둠. 최신 fabric
  버전 폴더에 .jar 이 없어 설치 완료 판정을 .json 존재로 변경.
- linkMinecraftRuntimeDirs: 예전 설치가 gameDir 에 만든 불완전한 versions/libraries
  실제 폴더를 .minecraft 링크로 교체(관리 데이터라 안전). assets 는 보존.
- 문서 갱신. 0.4.21→0.4.22.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 22:42:40 +09:00
e80933d21e fix(installer): 런타임 폴더 원본 없을 때 빈 폴더 생성 (assets 준비 실패 완화)
.minecraft/{assets,libraries,versions} 가 없으면(바닐라 미실행/다른 런처 환경)
링크를 건너뛰어 .mc_custom/<dir> 이 아예 없어졌고, 그러면 마인크래프트가
"Unable to prepare assets for download" 로 실패했다(코드 주석에도 명시된 원인).
원본이 없으면 .mc_custom/<dir> 을 빈 폴더로 만들어 런처가 그 gameDir 에 직접
받아가도록 수정. 0.4.20→0.4.21.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 22:20:39 +09:00
df5947f8bb fix(installer): 최종 리소스팩 다운로드 실패 시 부분 파일 삭제
downloadStream 실패 시 .mc_custom/resourcepacks/<파일>.zip 에 0바이트/부분 다운로드
파일이 남아 마인크래프트 리소스팩 목록에 깨진 채로 노출될 수 있던 문제 수정.
finalResourcepack:install catch 에서 대상 zip 을 삭제. 0.4.19→0.4.20.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 00:39:18 +09:00
3c404ecd8c feat(installer): 최종 리소스팩 등록 시 베이스 리소스팩 다운로드 생략
최종 리소스팩(finalResourcepackPath !== ".")이 등록돼 있으면 완성본을 설치 마지막에
따로 받으므로, 클라이언트 설치 단계에서 베이스 리소스팩 다운로드를 건너뛴다
("등록한 최종 리소스팩만 다운로드"). 최종 리소스팩이 "."(없음)이면 기존대로 베이스를
받는다. 0.4.18→0.4.19.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 00:25:10 +09:00
e30d7418df feat(installer): 최종 리소스팩 다운로드 진행률(퍼센트) 표시
"예" 선택 후 최종 리소스팩 다운로드 시 퍼센트/진행바를 보여준다.
- finalResourcepack:install 을 downloadFile → downloadStream 으로 바꿔 content-length
  기반 진행률을 계산하고 finalResourcepack:progress 이벤트로 렌더러에 전송.
- preload onFinalResourcepackProgress 구독 추가, 렌더러에 진행바+% 표시(완료 100%,
  실패 시 바 숨김). 0.4.17→0.4.18.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 00:11:19 +09:00
bce87f1362 fix(installer): 팩 변경 시 finalRpHandled 리셋
완료 화면에서 뒤로 가 다른 퀴즈팩을 선택하면 finalRpHandled 가 true 로 남아,
새 팩에 최종 리소스팩이 있어도 "설치하시겠습니까?" 페이지가 스킵되던 문제 수정.
팩 선택 키가 바뀌면 finalRpHandled=false 로 초기화. 0.4.16→0.4.17.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-23 00:48:17 +09:00
e5dcc47548 feat: 최종 리소스팩(선택 설치) + 약관 표시대상 추가
- pack.finalResourcepackPath 추가(/file/resourcepacks/outputs/, "."=없음, 기본 ".").
  편집기에 "최종 리소스팩 (.zip)" 입력 필드 추가.
- 약관 표시대상에 showInFinalResourcepack("최종 리소스팩에 표시") 3번째 토글 추가
  (스키마/시드/마이그레이션/편집기 체크박스·배지·저장 라우트, index.json 노출).
- 간편설치기 완료 단계: 기존 "직접 적용" 경고 제거 → 최종 리소스팩이 "."이 아니면
  "리소스팩을 설치하시겠습니까?" 예/아니요. 아니요→완료, 예→최종 리소스팩 약관
  (showInFinalResourcepack) 동의→/file/resourcepacks/outputs/ 에서 다운로드→완료.
  "." 이면 이 페이지 자체를 건너뜀.
- 0.4.15→0.4.16.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-23 00:33:07 +09:00
f53e54493d feat(installer): 완료 화면에 리소스팩 안내 경고 추가
음악퀴즈 간편설치기 마지막(완료) 단계, 마인크래프트 실행 직전에
"리소스팩은 리소스팩 설치기로 설치해서 마인크래프트 내에서 직접 적용해야 합니다"
경고 문구를 눈에 띄게 표시. 0.4.14→0.4.15.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-22 01:02:47 +09:00
9abf6da5f1 fix(rp): 노래 403 개선 — 플레이어 클라이언트 다변화 + 곡별 지연 재시도
v0.4.12 의 yt-dlp 내부 재시도(--retries)로도 남는 403(특정 클라이언트/스트림 URL
차단, 예: android_vr)에 대응:
- yt-dlp 에 --extractor-args youtube:player_client=default,web_safari,tv,ios 추가.
  한 클라이언트의 URL 이 403 이어도 다른 클라이언트 포맷으로 받는다(클라이언트명
  유효성은 실제 yt-dlp 로 검증).
- 실패한 곡의 순차 재시도를 1회 → 최대 3회로, 시도마다 점점 더(최대 20s) 쉬어
  유튜브 rate-limit 이 풀릴 시간을 준다. 0.4.13→0.4.14.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-22 00:45:33 +09:00
48d5223671 fix(installer): 다운로드 일시 오류 시 자동 재시도(백오프)
메인 간편설치기의 fetchBuffer/downloadFile 은 재시도가 없어, 너무 빠르게/자주
요청해 생기는 일시적 오류(429·5xx·네트워크 끊김·타임아웃, 일부 403)에 곧바로
실패했다. RP 설치기처럼 일시 오류를 지수 백오프(Retry-After 우선)로 최대 3회
재시도하도록 보강(모드 다중 다운로드/서버·맵·리소스팩·매니페스트 모두 적용).
404 등 영구 오류는 그대로 실패. 0.4.12→0.4.13.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-20 18:30:27 +09:00
51af4464e8 fix(rp): yt-dlp 403(Forbidden) 간헐 실패에 내부 재시도 추가
증상: 첫 곡은 정상인데 두 번째 곡부터 "HTTP Error 403: Forbidden — unable to
download video data" 로 노래 다운로드 실패(재시도하면 됨). 유튜브가 미디어 스트림에
간헐적 403 을 주는 케이스.
- yt-dlp 인자에 --retries 10 / --fragment-retries 10 / --extractor-retries 3 /
  --retry-sleep http:exp=1:30 / --socket-timeout 30 추가 → yt-dlp 가 스스로
  재시도·재추출해 한 번의 403 으로 곡이 실패하지 않게 함.
- 인자 유효성은 실제 yt-dlp 로 파싱 검증. 0.4.11→0.4.12.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-20 16:32:21 +09:00
971992b530 fix(rp): 종료 시 임시 파일 정리 보장 (실패→재시도 중 닫아도 삭제)
기존 window-all-closed 는 fsp.rm 을 fire-and-forget 로 부르고 곧바로 app.quit()
하여 삭제 완료 전에 프로세스가 죽었고, '재시도 중' 에는 yt-dlp/ffmpeg 자식이 파일을
잠가 삭제가 실패했다. before-quit 에서 종료를 미루고 자식을 먼저 종료해 잠금을 푼 뒤,
.temp 를 재시도 삭제(잠금 해제 대기)한 다음 실제 종료하도록 변경. 0.4.10→0.4.11.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-20 16:18:18 +09:00
868bee7931 perf(rp): 사진 다운로드 병렬화로 속도 개선
한 장씩 순차+딜레이(0.8s)로 느리던 사진 다운로드를 워커 풀(동시 4~8, CPU 기반)로
병렬화. 장간 고정 딜레이 제거, 음악 단계 후 쿨다운 4s→1s 로 축소. 429 는 기존
지수 백오프 재시도(images.ts)가 흡수하고, 첫 하드 실패 시 새 작업 배정을 멈춘 뒤
워커가 드레인되면 그 오류를 던진다(이어받기·취소 동작 유지). 0.4.9→0.4.10.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-20 13:54:53 +09:00
f85e0439b8 feat(server): JDK 탐색 우선순위 재정의 (.mc_custom → 환경변수 → Program Files)
요청 사양대로 탐색 순서 변경:
  ① .mc_custom/jdk(설치기 자동설치 위치)가 있으면 먼저 사용(권장이면 match, 아니면 경고)
  ② 환경변수(JAVA_HOME/JDK_HOME)가 권장 버전이면 사용
  ③ 기본 폴더 C:\Program Files\Java 에 권장 버전 있으면 사용
  ④ 없으면 환경변수 자바(없으면 Program Files 자바)로 폴백 + "권장과 다름" 경고
소스별 후보 수집 헬퍼(mcCustom/env/programFiles) 분리. 문서도 갱신. 0.4.8→0.4.9.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-17 18:35:40 +09:00
812cabd405 fix(server): 권장 JDK 상세 릴리스명 XSS escape + 문서 갱신
- 편집기 "자세히 보기" 에서 Adoptium release_name 을 escape 없이 innerHTML 에
  넣던 부분을 escHtml 처리(외부 API 값 → 관리 페이지 XSS 방지).
- docs/installer.md 의 JDK 탐색 순서/자동 설치 경로를 실제 코드(C:\Program Files\Java
  → 환경변수 → .mc_custom\jdk, 불일치 경고)와 일치하도록 갱신.

exe 산출물은 변경 없음(사이트/문서 수정) → 재빌드/재릴리스 없음.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-17 14:47:49 +09:00
fefa2dcdd2 feat(server): 권장 JDK 목록을 Adoptium 에서 동적으로 + 자세히 보기(스냅샷)
- 하드코딩 [8,11,17,21,25] 대신 Adoptium(Temurin) API 에서 실제 배포 버전을 가져와
  편집기 드롭다운을 채운다. 브라우저 CORS 차단이라 서버가 프록시:
  GET /op/jdk-versions (메이저 목록), GET /op/jdk-versions/:major (GA/EA 릴리스명).
  6h 캐시 + 네트워크 실패 시 정적 폴백.
- 편집기: 기본은 LTS(25/21/17/11/8)만 표시, "자세히 보기" 체크 시 비-LTS 메이저까지
  펼치고 선택 메이저의 GA/EA 스냅샷 릴리스명을 함께 보여준다.
- normalizeRecommendedJdk 를 고정 목록 대신 8~99 범위로 완화(동적 메이저 허용).
- 버전 0.4.7→0.4.8.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-17 14:43:55 +09:00
9d9b5183b8 feat(server): 권장 JDK 탐색을 PF/환경변수 중심으로 + 불일치 경고 + .mc_custom/jdk 설치
- JDK 탐색을 C:\Program Files\Java → 환경변수(JAVA_HOME/JDK_HOME) → 설치기
  자동설치 위치 순으로 훑어 권장 버전을 우선 선택. 권장이 없으면 가장 높은 버전을
  대신 고르되 match=false 로 표시.
- 권장과 다른 버전을 찾거나 직접 선택하면 "권장과 달라 정상 실행이 안 될 수 있음"
  경고를 띄우고, 동의(확인) 시에만 진행(무조건 차단 대신 경고+동의).
- 자동 설치 경로를 %APPDATA%/jdk → .mc_custom/jdk 로 변경(파일제거기가 함께 정리).
  Adoptium zip 의 중첩 jdk-* 폴더까지 탐색하도록 resolveJdkHome 보강.
- 버전 0.4.6→0.4.7.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-17 12:13:33 +09:00
a1d8435b71 feat(server): 사이트에서 권장 JDK 선택 + 설치기 권장 JDK 우선 탐색
- pack 에 recommendedJdk 추가(shared/types, store 정규화, 기본 25, 허용목록
  [8,11,17,21,25]). 사이트 편집기에 JDK 버전 드롭다운 추가(직접 입력 X).
- 설치기 JDK 탐색 순서 변경: ① 권장 JDK 자동설치 위치(temurin-<권장>) 우선 →
  ② 없으면 컴퓨터 JDK(JAVA_HOME/Program Files, 권장 버전 이상만 인정).
- 자동 설치/버전 검증도 pack 의 권장 버전을 사용(하드코딩 25 제거).
- 렌더러에 권장 JDK 표시, 관련 안내/문서 갱신. 버전 0.4.5→0.4.6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-17 08:59:41 +09:00
a6118039a3 fix(server): 직접 선택한 JDK 도 Java 25+ 검증 후 진행
자동 탐색은 Java 25 미만을 걸렀지만, 사용자가 직접 입력/폴더선택한 JDK 는
버전 검증 없이 다음 단계로 넘어가 run.bat 이 낡은 자바로 패치될 수 있었다.
그러면 서버가 여전히 UnsupportedClassVersionError 로 뜨지 않는다.

- jdk:verify IPC 추가(java -version 으로 메이저 버전 확인) + preload 노출.
- JDK 단계 "다음" 에서 선택 경로를 검증해 Java 25 미만이거나 버전을 못 읽으면
  진행을 막고 안내. 자동 설치/자동 탐색 경로도 이 게이트를 통과.
- 버전 0.4.4→0.4.5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-17 00:45:06 +09:00
48e0421135 fix(rp): 사진 다운로드 HTTP 429(속도제한) 내성 강화
증상: 리소스팩 설치기 사진 다운로드에서 "N번 사진 다운로드 실패: HTTP 429" 로
설치가 중단됨. 원인: 음악(yt-dlp) 단계가 유튜브를 많이 두드려 IP 가 일시
throttle 되고, 사진 단계는 순차이긴 하나 요청 간 간격이 없어 i.ytimg.com 429 를
유발/회복하지 못하고 첫 장부터 실패.

- 음악 단계 직후 사진 단계 시작 전 쿨다운(4s).
- 사진 요청 사이 간격(0.8s+jitter)으로 429 유발 억제.
- 재시도 5→6회, 재시도마다 로그/진행률로 대기 상태 표시(멈춘 것처럼 보이지 않게).
- 최종 실패가 429면 "잠시 뒤 다시 시도하면 이어받는다"는 안내 추가.
- 버전 0.4.3→0.4.4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-17 00:29:23 +09:00
cf29270102 fix(server): Java 25 번들 + run.bat 이 설치기 JDK 를 쓰도록 수정
증상: 서버 실행 시 UnsupportedClassVersionError (class 69.0=Java 25 서버를
Java 17 로 실행). 원인: (1) 자동 설치 JDK 가 21 로 최신 MC(Java 25)에 부족,
(2) server:install 이 jdkPath 를 무시해 run.bat 이 시스템 PATH 의 낡은 java 사용.

- 번들 JDK 21→25(Adoptium Temurin 25). BUNDLED_JDK_MAJOR 상수로 일원화.
- jdk:detect 가 `java -version` 으로 메이저 버전을 확인해 25 미만은 "없음" 처리
  → 낡은 JAVA_HOME/시스템 자바에 걸리지 않고 자동 설치(25)로 유도.
- server:install 이 run.bat 의 `java` 실행 토큰을 설치기 JDK 의 java 로 치환.
  자동설치 JDK 는 %APPDATA% 전개형 경로라 한글 사용자명에도 안전, latin1
  라운드트립으로 기존 주석 바이트 보존.
- 안내 문구/문서(Java 25), 버전 0.4.2→0.4.3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-17 00:11:50 +09:00
10362e204a fix(installer): 약관 본문 로드 실패 시 동의 차단 + 다시 시도
로드 실패 경로에서 markReadable()로 체크박스가 활성화돼, 약관을 못 본 채로
동의할 수 있던 문제 수정. termLoaded 플래그를 두어 본문이 정상 로드된 뒤에만
동의 체크를 허용하고, 실패 시에는 체크/다음을 막고 "다시 시도" 버튼만 노출.
installer / installer-rp 동일 적용. 버전 0.4.1 → 0.4.2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-16 00:09:51 +09:00
3f553f958d feat(installer): 약관을 한 건씩 페이지로 — 개별 동의 + 상단 스크롤 + 끝까지 읽기 강제
- "위 모든 약관에 동의합니다" 통합 체크(탭 방식)를 약관별 페이지 흐름으로 변경.
  각 약관마다 "해당 약관에 동의합니다" 체크박스, [다음] 이면 다음 약관 페이지로.
- 다음 약관을 보여줄 때 화면(pageHost)을 맨 위로 스크롤해 처음부터 읽게 함.
- 본문을 끝까지 내려 읽어야 동의 체크가 활성화(짧아서 스크롤 불필요하면 즉시 허용).
  뒤로/앞으로 이동 시 이전 동의 상태 유지.
- installer / installer-rp 양쪽 적용, 로케일 키(agreeThis/readToBottom/stepLabel) 추가.
- 버전 0.4.0 → 0.4.1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-15 23:54:10 +09:00
996a7cc03e feat(installer): v0.4.0 — 파일제거기, 커스텀 폴더명, 포트포워딩 개편, 이름/개발자용 표기
- 음악퀴즈 파일제거 도구 신규 추가: 동의 → 휴지통/완전삭제 선택 → 커스텀 폴더
  (현재값 + 기본 .mc_custom) 전체, 데스크톱 바로가기, gameDir 가 해당 폴더인
  마인크래프트 런처 프로필 정리.
- MC_CUSTOM_DIR .env 로 커스텀 게임 폴더 이름 유동화(.mc_custom 기본). 렌더러
  사전에도 실제 폴더명 반영.
- 간편포트포워딩: 실행 중 UPnP 매핑 유지, 창 닫힘/종료 시 자동 제거(activePort 추적).
- 개발자용 빌드는 창/헤더 제목 앞에 (개발자용) 표기, exe 이름에도 반영.
- exe 이름 변경: 음악퀴즈 간편설치기 / 음악퀴즈 리소스팩설치기 / 간편포트포워딩.
- README 및 .env 템플릿 갱신, 버전 0.3.23 → 0.4.0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-15 23:21:46 +09:00
00aa47ed17 feat(installer): record EULA acceptance timestamp in eula.txt
Write an ISO 8601 "# EULA accepted at: <time>" comment when the user
accepts the EULA, so the agreement time is captured alongside eula=true.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-15 22:29:07 +09:00
9c2a92c101 docs: align account section with account.local.json + scrypt reality
The 계정 section still described account.json as the account store and
scrypt hashing as future work; both are now implemented. Point it at the
gitignored account.local.json (0600) seed/scrypt flow, consistent with the
migration section below.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-12 01:06:05 +09:00
662c3c7b23 docs+comment: pin account.json untrack-after-redeploy TODO
추적 해제는 코드 작업이 아니라 사용자의 1회 재배포에 게이트된 운영 절차이므로,
잊히지 않도록 명시적 TODO 를 코드(paths.ts) + 운영 문서(admin-site.md)에 고정.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-11 13:31:50 +09:00
face70b3e9 security: env-gate trust proxy, 0600 account file, seed+template for account.json untrack
리뷰 지적 추가 반영(서버 전용).
- trust proxy 를 항상 켜던 것을 TRUST_PROXY=true 일 때만 켜도록 변경. 직접 노출
  시 X-Forwarded-For 조작으로 로그인 rate limit 을 우회하던 문제 차단(프록시 뒤면
  TRUST_PROXY=true 설정).
- account.local.json 을 0o600(소유자 전용)으로 저장.
- 서버 시작 시 account.local.json 이 없으면 account.json 에서 시드(0o600). 이렇게
  하면 재배포 직후(로그인 전에도) 로컬 계정 파일이 항상 존재해, 이후 account.json
  을 안전하게 추적 해제할 수 있다.
- account.example.json 템플릿 추가.

account.json 자체의 git 추적 해제는 서버가 한 번 재배포되어 account.local.json 이
생성된 뒤 후속 커밋에서 처리(그 전에 지우면 pull 시 삭제되어 로그인이 막힘).

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-07-11 13:27:32 +09:00
651c63faa6 security: revert auto-upgrade failure logging (keep silent per request)
사용자 요청으로 평문→해시 자동 업그레이드 저장 실패 시 console.error 로그를
원래대로 조용한 catch 로 되돌림. 나머지 보안 개선(로그인 rate-limit,
account.local.json 분리, secure 쿠키 옵션)은 유지.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-07-11 13:23:17 +09:00
f392842d7f security: login rate-limit, gitignored account store, secure cookie opt, upgrade-fail log
리뷰 지적 4건 반영(서버 전용, exe 영향 없음).
- 로그인 IP 기준 실패 제한(15분 창 10회 → 15분 차단). 브루트포스 + scrypt CPU
  남용 방지. 인메모리, 무한 성장 가드 포함.
- 운영 계정을 gitignore 된 account.local.json 으로 이전. readAccounts 는 local
  우선, 없으면 추적되는 account.json 을 시드로 읽음. writeAccounts 는 local 에만
  기록 → 첫 로그인 자동 해시 업그레이드부터는 추적 평문 파일을 더 쓰지 않음.
  (account.json 을 git rm --cached 하면 서버 pull 시 삭제되는 위험이 있어 추적 자체는
  건드리지 않고, 실질 사용 파일만 분리.)
- 세션 쿠키 secure 를 SESSION_COOKIE_SECURE=true 로 켤 수 있게(HTTPS 배포용).
- 평문→해시 자동 업그레이드 저장 실패를 조용히 무시하지 않고 console.error 로 기록.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-07-11 12:31:34 +09:00
00344084c7 security: hash operator passwords (scrypt) + persistent session secret
- 운영자 로그인 비밀번호를 평문 비교(===)에서 Node 내장 scrypt 해시 + 상수시간
  비교(verifyPassword)로 전환. 새 파일 src/server/password.ts. 기존 account.json
  의 평문 비밀번호는 그대로 검증되며, 로그인 성공 시 scrypt 해시로 자동 업그레이드
  후 저장(writeAccounts 추가). 외부 의존성 없음.
- 세션 시크릿을 하드코딩 폴백('...dev-secret') 대신, 환경변수 우선 → 없으면
  .session-secret 파일에 영구 랜덤값 생성/보관하도록 변경(세션 위조 방지, 재시작
  후에도 세션 유지). .session-secret 은 .gitignore 에 추가.

서버(사이트) 전용 변경이라 설치기 exe 는 영향 없음(재빌드 불필요).

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-07-11 12:24:42 +09:00
43 changed files with 2373 additions and 315 deletions

View File

@@ -31,6 +31,14 @@ SITE_BASE_URL=https://mq.example.com
# 특별히 다른 경로를 쓰고 싶을 때만 아래를 풀어서 우선 적용시키세요. # 특별히 다른 경로를 쓰고 싶을 때만 아래를 풀어서 우선 적용시키세요.
# MANIFEST_URL=https://mq.example.com/manifest.json # MANIFEST_URL=https://mq.example.com/manifest.json
# ----- 커스텀 게임 폴더 이름 -----
# 음악퀴즈 전용 게임/캐시 폴더의 이름. %APPDATA% 바로 아래에 이 이름으로 생성됩니다.
# 비워두면 기본값 `.mc_custom` 을 사용합니다. 경로 구분자(/ \)와 `..` 는 무시됩니다.
# 설치기·리소스팩설치기·파일제거기가 모두 이 값을 공유하므로, 값을 바꾸면
# 세 exe 를 같은 값으로 다시 빌드해야 서로 같은 폴더를 가리킵니다.
# MC_CUSTOM_DIR=.mc_custom
# ----- 리소스팩 설치기 ----- # ----- 리소스팩 설치기 -----
# yt-dlp 동시 다운로드 수(1~8). 비워두면 CPU 코어 수로 자동 결정. # yt-dlp 동시 다운로드 수(1~8). 비워두면 CPU 코어 수로 자동 결정.

View File

@@ -27,6 +27,12 @@ SITE_BASE_URL=http://127.0.0.1:3000
# 특별히 다른 경로를 쓰고 싶을 때만 아래를 풀어서 우선 적용시키세요. # 특별히 다른 경로를 쓰고 싶을 때만 아래를 풀어서 우선 적용시키세요.
# MANIFEST_URL=http://127.0.0.1:3000/manifest.json # MANIFEST_URL=http://127.0.0.1:3000/manifest.json
# ----- 커스텀 게임 폴더 이름 -----
# 음악퀴즈 전용 게임/캐시 폴더의 이름. %APPDATA% 바로 아래에 이 이름으로 생성됩니다.
# 비워두면 기본값 `.mc_custom` 을 사용합니다. 경로 구분자(/ \)와 `..` 는 무시됩니다.
# MC_CUSTOM_DIR=.mc_custom
# ----- 리소스팩 설치기 ----- # ----- 리소스팩 설치기 -----
# yt-dlp 동시 다운로드 수(1~8). 비워두면 CPU 코어 수로 자동 결정. # yt-dlp 동시 다운로드 수(1~8). 비워두면 CPU 코어 수로 자동 결정.

4
.gitignore vendored
View File

@@ -7,3 +7,7 @@ conversations/
.env .env
.env.local .env.local
.env.*.local .env.*.local
# 세션 서명용 자동 생성 시크릿. 절대 커밋 금지.
.session-secret
# 운영 계정(해시 비밀번호) 파일. 추적되는 account.json 대신 이 파일을 사용. 커밋 금지.
account.local.json

View File

@@ -5,6 +5,8 @@
- **관리 사이트** — 음악퀴즈 정보(JSON)와 음악·사진 목록, 데이터팩 출력을 한 곳에서 운영. - **관리 사이트** — 음악퀴즈 정보(JSON)와 음악·사진 목록, 데이터팩 출력을 한 곳에서 운영.
- **음악퀴즈 간편설치기 (`.exe`)** — `manifest.json` 기반으로 사용자가 마인크래프트 본체·서버·모드를 자동 설치. - **음악퀴즈 간편설치기 (`.exe`)** — `manifest.json` 기반으로 사용자가 마인크래프트 본체·서버·모드를 자동 설치.
- **리소스팩 간편설치기 (`.exe`)** — 음악퀴즈 음악·표지를 yt-dlp 로 받아 painting variant 텍스처 리소스팩으로 패키징. - **리소스팩 간편설치기 (`.exe`)** — 음악퀴즈 음악·표지를 yt-dlp 로 받아 painting variant 텍스처 리소스팩으로 패키징.
- **간편포트포워딩 (`.exe`)** — 원하는 포트를 입력해 UPnP 로 개방. 프로그램을 켜 두는 동안만 열려 있고 창을 닫으면 자동으로 닫힙니다.
- **음악퀴즈 파일제거 (`.exe`)** — 설치기들이 만든 게임 폴더·캐시와 런처 프로필을 휴지통 이동 또는 완전 삭제로 한 번에 정리.
--- ---
@@ -15,7 +17,9 @@
| `src/server/` | 음악퀴즈 관리 웹사이트 (Express + EJS) | `bun start` 또는 `npm start` | | `src/server/` | 음악퀴즈 관리 웹사이트 (Express + EJS) | `bun start` 또는 `npm start` |
| `src/installer/` | 음악퀴즈 간편설치기 (Electron) | `npm run installer` | | `src/installer/` | 음악퀴즈 간편설치기 (Electron) | `npm run installer` |
| `src/installer-rp/` | 리소스팩 간편설치기 (Electron) | `npm run installer:rp` | | `src/installer-rp/` | 리소스팩 간편설치기 (Electron) | `npm run installer:rp` |
| `src/shared/` | 두 설치기와 서버가 공유하는 타입·스토어 | — | | `src/installer-pf/` | 간편포트포워딩 도구 (Electron) | `npm run installer:pf` |
| `src/installer-uninstall/` | 음악퀴즈 파일제거 도구 (Electron) | `npm run installer:uninstall` |
| `src/shared/` | 설치기들과 서버가 공유하는 타입·스토어 | — |
| `views/` | EJS 템플릿 (관리 사이트) | — | | `views/` | EJS 템플릿 (관리 사이트) | — |
| `manifest/` | 음악퀴즈별 정의 JSON | — | | `manifest/` | 음악퀴즈별 정의 JSON | — |
| `file/list/` | 음악퀴즈별 음악·사진 목록 JSON | — | | `file/list/` | 음악퀴즈별 음악·사진 목록 JSON | — |
@@ -43,6 +47,8 @@
이렇게 분리해 두면 사용자가 평소 쓰던 마인크래프트와 음악퀴즈 설정이 섞이지 않고, 음악퀴즈만 삭제해도 본체에는 영향이 없습니다. 이렇게 분리해 두면 사용자가 평소 쓰던 마인크래프트와 음악퀴즈 설정이 섞이지 않고, 음악퀴즈만 삭제해도 본체에는 영향이 없습니다.
> **폴더 이름 바꾸기.** 기본값은 `.mc_custom` 이지만 `.env` / `.env.build` 의 `MC_CUSTOM_DIR` 로 다른 이름을 지정할 수 있습니다. 설치기·리소스팩설치기·파일제거기가 모두 이 값을 공유하므로, 값을 바꾸면 세 exe 를 같은 값으로 다시 빌드해야 서로 같은 폴더를 가리킵니다. (경로 구분자 `/ \` 와 `..` 는 무시되어 항상 `%APPDATA%` 바로 아래 단일 폴더가 됩니다.)
--- ---
## 빠른 시작 ## 빠른 시작
@@ -65,8 +71,17 @@ npm run installer
# 3) 리소스팩 간편설치기를 Electron 으로 실행해 보기 # 3) 리소스팩 간편설치기를 Electron 으로 실행해 보기
npm run installer:rp npm run installer:rp
# 4) 음악퀴즈 간편설치기 윈도우 .exe 빌드 # 4) 간편포트포워딩 / 파일제거 도구 실행해 보기
npm run dist:win npm run installer:pf
npm run installer:uninstall
# 5) 윈도우 .exe 빌드 (개별)
npm run dist:win # 음악퀴즈 간편설치기
npm run dist:win:rp # 음악퀴즈 리소스팩설치기
npm run dist:win:pf # 간편포트포워딩
npm run dist:win:uninstall # 음악퀴즈 파일제거
npm run dist:win:dev # (개발자용) 음악퀴즈 간편설치기
npm run dist:win:rp:dev # (개발자용) 음악퀴즈 리소스팩설치기
``` ```
리소스팩 설치기는 `yt-dlp` 가 필요합니다. 자동 다운로드되지만, 막혀 있는 환경이라면 [`docs/yt-dlp-setup.md`](docs/yt-dlp-setup.md) 참고. 리소스팩 설치기는 `yt-dlp` 가 필요합니다. 자동 다운로드되지만, 막혀 있는 환경이라면 [`docs/yt-dlp-setup.md`](docs/yt-dlp-setup.md) 참고.
@@ -155,9 +170,13 @@ minecraft_launcher/
│ ├─ server/ Express + EJS 관리 사이트 │ ├─ server/ Express + EJS 관리 사이트
│ ├─ installer/ 음악퀴즈 간편설치기 (Electron 메인 + preload) │ ├─ installer/ 음악퀴즈 간편설치기 (Electron 메인 + preload)
│ ├─ installer-rp/ 리소스팩 간편설치기 (Electron 메인 + 음악/이미지 파이프라인) │ ├─ installer-rp/ 리소스팩 간편설치기 (Electron 메인 + 음악/이미지 파이프라인)
│ ├─ installer-pf/ 간편포트포워딩 도구 (Electron 메인 + preload)
│ ├─ installer-uninstall/ 음악퀴즈 파일제거 도구 (Electron 메인 + preload)
│ └─ shared/ 공용 타입, 매니페스트 스토어, mojang/upnp 유틸 │ └─ shared/ 공용 타입, 매니페스트 스토어, mojang/upnp 유틸
├─ installer/ 음악퀴즈 설치기 렌더러(HTML/CSS/JS) ├─ installer/ 음악퀴즈 설치기 렌더러(HTML/CSS/JS)
├─ installer-rp/ 리소스팩 설치기 렌더러(HTML/CSS/JS) ├─ installer-rp/ 리소스팩 설치기 렌더러(HTML/CSS/JS)
├─ installer-pf/ 간편포트포워딩 렌더러(HTML/JS)
├─ installer-uninstall/ 파일제거 렌더러(HTML/JS)
├─ views/ 관리 사이트 EJS 템플릿 ├─ views/ 관리 사이트 EJS 템플릿
├─ public/ 관리 사이트 정적 파일(styles.css 등) ├─ public/ 관리 사이트 정적 파일(styles.css 등)
├─ manifest/ 음악퀴즈 JSON 정의 (운영자가 편집) ├─ manifest/ 음악퀴즈 JSON 정의 (운영자가 편집)
@@ -172,18 +191,24 @@ minecraft_launcher/
├─ manifest.json 사이트 루트 매니페스트 (자동 관리) ├─ manifest.json 사이트 루트 매니페스트 (자동 관리)
├─ account.json 관리자 계정 (절대 외부 노출 금지) ├─ account.json 관리자 계정 (절대 외부 노출 금지)
├─ package.json ├─ package.json
└─ tsconfig.{,server,installer,installer-rp}.json └─ tsconfig.{,server,installer,installer-rp,installer-pf,installer-uninstall}.json
``` ```
--- ---
## 빌드 산출물 / 배포 ## 빌드 산출물 / 배포
| 산출물 | 빌드 명령 | 비고 | | 산출물(파일명) | 빌드 명령 | 비고 |
| --- | --- | --- | | --- | --- | --- |
| 관리 사이트 (Node 실행) | `npm start` | systemd 등으로 띄우기. 외부 도메인이 manifest 의 base URL 이 됩니다. | | 관리 사이트 (Node 실행) | `npm start` | systemd 등으로 띄우기. 외부 도메인이 manifest 의 base URL 이 됩니다. |
| 음악퀴즈 간편설치기 `.exe` | `npm run dist:win` | `electron-builder.yml` 설정 사용. | | `음악퀴즈 간편설치기-<버전>.exe` | `npm run dist:win` | `electron-builder.yml`. |
| 리소스팩 간편설치기 `.exe` | `tsconfig.installer-rp.json` 빌드 후 `electron-builder` 수동 패키징 | | | `음악퀴즈 리소스팩설치기-<버전>.exe` | `npm run dist:win:rp` | `electron-builder-rp.yml`. |
| `간편포트포워딩-<버전>.exe` | `npm run dist:win:pf` | `electron-builder-pf.yml`. |
| `음악퀴즈 파일제거-<버전>.exe` | `npm run dist:win:uninstall` | `electron-builder-uninstall.yml`. |
| `(개발자용) 음악퀴즈 간편설치기-<버전>.exe` | `npm run dist:win:dev` | 비공개(public=false) 팩만 노출. 제목 앞에 `(개발자용)` 표시. |
| `(개발자용) 음악퀴즈 리소스팩설치기-<버전>.exe` | `npm run dist:win:rp:dev` | 상동. |
빌드 결과물은 `release/` 폴더에 생성됩니다(포터블 exe).
--- ---

3
account.example.json Normal file
View File

@@ -0,0 +1,3 @@
[
{ "id": "admin", "password": "여기에-비밀번호를-넣으세요" }
]

View File

@@ -50,7 +50,9 @@ npm start # 기본 포트 3000.
## 계정 ## 계정
`account.json` 에 정의합니다(루트 디렉터리). **외부 HTTP 로 절대 노출되지 않도록 라우팅에서 제외돼 있습니다.** 운영 계정은 **gitignore 된 `account.local.json`**(루트 디렉터리, 0600, scrypt 해시)에 저장됩니다. 추적되는 `account.json` 은 서버에 `account.local.json` 이 없을 때만 읽는 **시드 소스**로, 서버 시작 시 자동으로 `account.local.json`(0600) 으로 복사됩니다. 두 파일 모두 **외부 HTTP 로 절대 노출되지 않도록 라우팅에서 제외돼 있습니다.**
시드 포맷(`account.json`):
```json ```json
[ [
@@ -58,7 +60,7 @@ npm start # 기본 포트 3000.
] ]
``` ```
> 운영 환경에서는 평문 비밀번호 대신 해시를 쓰도록 추후 보강할 여지가 있습니다. 평문 비밀번호로 시드해도 로그인 성공 시 자동으로 scrypt 해시(`scrypt$<salt>$<hash>`)로 업그레이드되어 `account.local.json` 에만 저장됩니다. 자세한 마이그레이션 절차는 아래 "운영 계정 파일 마이그레이션" 을 참고하세요.
## 대시보드 (`/op/dashboard`) ## 대시보드 (`/op/dashboard`)
@@ -138,4 +140,14 @@ say [musicquiz] 데이터팩 초기화
- `account.json` 은 라우팅에서 차단되어 있으나, 디스크 권한도 운영자만 접근 가능하게 두는 것이 안전합니다. - `account.json` 은 라우팅에서 차단되어 있으나, 디스크 권한도 운영자만 접근 가능하게 두는 것이 안전합니다.
- 관리자 비밀번호는 충분히 강하게 설정. - 관리자 비밀번호는 충분히 강하게 설정.
### 운영 계정 파일 마이그레이션 (미완결 — 재배포 게이트)
운영 계정은 이제 gitignore 된 `account.local.json`(0o600, scrypt 해시)에만 저장됩니다. 추적되는 `account.json` 은 서버에 `account.local.json` 이 없을 때만 읽는 **시드 소스**로 남겨 둔 상태입니다. 남은 위생 작업이 하나 있습니다:
1. **[운영] 최신 main 재배포** → 서버가 `account.local.json`(0o600) 을 자동 생성하는지 확인.
2. **[운영] 비밀번호 로테이션** — git 히스토리에 평문 비밀번호가 남아 있어 추적 해제로는 지워지지 않으므로, 이것이 실질적 최우선 보안 조치입니다.
3. **[후속 커밋] `account.json` 추적 해제** — 위 1번(서버에 `account.local.json` 존재) 확인 **후에만** `git rm --cached account.json` 를 별도 커밋으로 진행. 같은 커밋에서 하면 시드 소스가 사라져 로그인이 막힙니다.
> 코드 상 앵커: `src/shared/paths.ts` 의 `TODO(untrack-after-redeploy)` 주석.
- 모든 `/op/*` 라우트는 세션 기반 인증 미들웨어를 거칩니다. 세션 만료 시 자동으로 로그인 페이지로 리다이렉트. - 모든 `/op/*` 라우트는 세션 기반 인증 미들웨어를 거칩니다. 세션 만료 시 자동으로 로그인 페이지로 리다이렉트.

View File

@@ -28,9 +28,16 @@
### 3-2. JDK 확인 ### 3-2. JDK 확인
- 환경변수(`JAVA_HOME`, `JDK_HOME`) → 자동 설치 위치(`%APPDATA%\jdk\temurin-21`) → `C:\Program Files\Java` 순으로 자동 탐색. - 각 음악퀴즈(pack)는 사이트 편집기에서 **권장 JDK 버전**(예: Java 25)을 지정합니다. 편집기 목록은 Adoptium(Temurin)에서 실제 배포되는 버전을 불러오며, "자세히 보기"로 비-LTS·스냅샷까지 확인할 수 있습니다. 설치기는 이 권장 버전을 기준으로 동작합니다.
- **자동 설치** 버튼을 누르면 Adoptium Temurin 21 LTS Windows x64 zip 을 받아 `%APPDATA%\jdk\temurin-21\` 에 풀어 사용합니다. - 탐색 순서:
1. **설치기 자동 설치 위치 `%APPDATA%\.mc_custom\jdk`**(구버전 `%APPDATA%\jdk` 호환)에 JDK 가 있으면 **가장 먼저 사용**합니다(권장 버전이면 그대로, 혹시 다른 버전이면 경고).
2. 없으면 **환경변수(`JAVA_HOME` / `JDK_HOME`)** 의 JDK 가 **권장 버전이면** 사용합니다.
3. 그래도 없으면 **기본 폴더 `C:\Program Files\Java`** 에서 **권장 버전**을 찾아 사용합니다.
4. 권장 버전을 어디서도 못 찾으면 **환경변수 자바(없으면 Program Files 자바)로 폴백**하고, "권장 버전과 달라 서버가 정상 실행되지 않을 수 있다"는 **경고**를 띄웁니다.
- 직접 선택/입력한 경로도 "다음" 에서 검증해, 권장과 다르면 경고 후 동의(확인) 시에만 진행합니다(무조건 차단이 아니라 경고+동의). 아무 JDK 도 없으면 자동 설치로 유도합니다.
- **자동 설치** 버튼을 누르면 Adoptium Temurin(권장 버전) Windows x64 zip 을 받아 **`%APPDATA%\.mc_custom\jdk\temurin-<권장>\`** 에 풀어 사용합니다. (`.mc_custom` 안에 두므로 "음악퀴즈 파일제거" 도구로 한 번에 정리됩니다. `MC_CUSTOM_DIR` 로 폴더명을 바꾼 경우 그 폴더 아래 `jdk`.)
- 설치 중 같은 버튼이 "설치 취소" 로 바뀌고, 누르면 다운로드를 즉시 중단하고 부분 파일을 정리합니다. - 설치 중 같은 버튼이 "설치 취소" 로 바뀌고, 누르면 다운로드를 즉시 중단하고 부분 파일을 정리합니다.
- 서버 zip 의 `run.bat` 이 시스템 PATH 의 `java` 를 그대로 쓰면 낡은 자바로 실행돼 실패할 수 있어, 설치기가 준비/선택한 JDK 의 `java` 를 쓰도록 `run.bat` 을 자동으로 수정합니다(자동 설치 JDK 는 `%APPDATA%` 전개형 경로라 한글 사용자명에도 안전).
### 3-3. 서버 다운로드 및 설치 ### 3-3. 서버 다운로드 및 설치
@@ -71,7 +78,7 @@
2. `.minecraft` 최상위 설정 파일(`options.txt`, `optionsof.txt`, `servers.dat`, `usercache.json`, …) 을 `.mc_custom` 으로 복사. 이미 같은 이름이 있으면 보존. 2. `.minecraft` 최상위 설정 파일(`options.txt`, `optionsof.txt`, `servers.dat`, `usercache.json`, …) 을 `.mc_custom` 으로 복사. 이미 같은 이름이 있으면 보존.
3. 플랫폼 설치: 3. 플랫폼 설치:
- `vanilla` → 건너뜀. - `vanilla` → 건너뜀.
- `fabric` → Adoptium 자동 설치 → 최신 fabric-installer.jar 다운로드 → `java -jar fabric-installer.jar client -mcversion X -loader Y -dir .mc_custom -noprofile` 자동 실행. - `fabric` → Adoptium 자동 설치 → 최신 fabric-installer.jar 다운로드 → `java -jar fabric-installer.jar client -mcversion X -loader Y -dir .minecraft -noprofile` 자동 실행. (fabric 버전/라이브러리는 런처 폴더 `.minecraft` 에 바닐라와 함께 설치해야 런처가 찾을 수 있다. `.mc_custom` 에 설치하면 바닐라 버전·라이브러리가 빠져 "Unable to prepare assets for download" 로 실패한다.)
- `forge` / `neoforge``platform.downloadUrl` 의 설치 jar 다운로드(사용자가 직접 실행하거나 마인크래프트 런처가 인식). - `forge` / `neoforge``platform.downloadUrl` 의 설치 jar 다운로드(사용자가 직접 실행하거나 마인크래프트 런처가 인식).
4. `modsFolder` 의 모든 `.jar``resourcepackPath` zip, `mapPath` zip 을 자동 다운로드. 4. `modsFolder` 의 모든 `.jar``resourcepackPath` zip, `mapPath` zip 을 자동 다운로드.
5. `.minecraft\{assets,libraries,versions}``.mc_custom\{assets,libraries,versions}` 로 junction 링크. (없으면 "Unable to prepare assets for download" 오류로 마인크래프트가 실패하기 때문) 5. `.minecraft\{assets,libraries,versions}``.mc_custom\{assets,libraries,versions}` 로 junction 링크. (없으면 "Unable to prepare assets for download" 오류로 마인크래프트가 실패하기 때문)

View File

@@ -29,7 +29,7 @@ extraResources:
- "**/*" - "**/*"
win: win:
target: portable target: portable
artifactName: MusicQuizInstaller-Dev-${version}-Portable.${ext} artifactName: (개발자용) 음악퀴즈 간편설치기-${version}.${ext}
icon: build/icon.ico icon: build/icon.ico
portable: portable:
artifactName: MusicQuizInstaller-Dev-${version}-Portable.${ext} artifactName: (개발자용) 음악퀴즈 간편설치기-${version}.${ext}

View File

@@ -27,7 +27,7 @@ extraResources:
- "**/*" - "**/*"
win: win:
target: portable target: portable
artifactName: 마인크래프트간편포트포워딩-${version}-Portable.${ext} artifactName: 간편포트포워딩-${version}.${ext}
icon: build/icon.ico icon: build/icon.ico
portable: portable:
artifactName: 마인크래프트간편포트포워딩-${version}-Portable.${ext} artifactName: 간편포트포워딩-${version}.${ext}

View File

@@ -29,7 +29,7 @@ extraResources:
- "**/*" - "**/*"
win: win:
target: portable target: portable
artifactName: MusicQuizResourcepackInstaller-Dev-${version}-Portable.${ext} artifactName: (개발자용) 음악퀴즈 리소스팩설치기-${version}.${ext}
icon: build/icon.ico icon: build/icon.ico
portable: portable:
artifactName: MusicQuizResourcepackInstaller-Dev-${version}-Portable.${ext} artifactName: (개발자용) 음악퀴즈 리소스팩설치기-${version}.${ext}

View File

@@ -35,7 +35,7 @@ extraResources:
- "**/*" - "**/*"
win: win:
target: portable target: portable
artifactName: ${productName}-${version}-Portable.${ext} artifactName: 음악퀴즈 리소스팩설치기-${version}.${ext}
icon: build/icon.ico icon: build/icon.ico
portable: portable:
artifactName: ${productName}-${version}-Portable.${ext} artifactName: 음악퀴즈 리소스팩설치기-${version}.${ext}

View File

@@ -0,0 +1,38 @@
appId: kr.tkrmagid.musicquiz.uninstall
productName: 음악퀴즈 파일제거
# 루트 package.json 의 "main" 은 메인 설치기를 가리키므로, 패키지된 앱이
# 파일제거 도구를 진입점으로 쓰도록 빌드 시 main 을 덮어쓴다.
extraMetadata:
main: dist/installer-uninstall/main.js
directories:
output: release
buildResources: build
files:
- dist/installer-uninstall/**
- dist/shared/**
- installer-uninstall/**
# index.html 은 메인 설치기와 동일한 styles.css 를 공유함
# (`<link href="../installer/styles.css">`). 그 한 파일만 명시적으로 포함.
- installer/styles.css
- build/icon.*
- package.json
# 이 도구는 sharp(이미지 처리)를 쓰지 않으므로 통째로 제외해 exe 크기를 줄인다.
- "!node_modules/sharp/**"
- "!node_modules/@img/**"
# MC_CUSTOM_DIR 을 커스텀으로 빌드했다면 파일제거기도 같은 폴더를 가리켜야 하므로
# `.env.build` 를 함께 배포한다. i18n 사전(locales/installer-uninstall/ko-kr.json)도 함께.
extraResources:
- from: .
to: .
filter:
- .env.build
- from: locales
to: locales
filter:
- "**/*"
win:
target: portable
artifactName: 음악퀴즈 파일제거-${version}.${ext}
icon: build/icon.ico
portable:
artifactName: 음악퀴즈 파일제거-${version}.${ext}

View File

@@ -32,7 +32,7 @@ extraResources:
- "**/*" - "**/*"
win: win:
target: portable target: portable
artifactName: ${productName}-${version}-Portable.${ext} artifactName: 음악퀴즈 간편설치기-${version}.${ext}
icon: build/icon.ico icon: build/icon.ico
portable: portable:
artifactName: ${productName}-${version}-Portable.${ext} artifactName: 음악퀴즈 간편설치기-${version}.${ext}

View File

@@ -189,63 +189,61 @@ function showAgreementError(message) {
section.querySelector('#retry').addEventListener('click', renderAgreement) section.querySelector('#retry').addEventListener('click', renderAgreement)
} }
// 약관을 한 건씩 페이지로 보여준다. 각 약관마다 "해당 약관에 동의합니다" 체크가 있고,
// 끝까지 스크롤해 읽어야 체크할 수 있으며, [다음] 을 누르면 다음 약관 페이지가 화면
// 맨 위에서부터 나온다. 마지막 약관까지 동의하면 다음 단계(step2)로 진행한다.
function renderAgreementWithKinds(KINDS) { function renderAgreementWithKinds(KINDS) {
var idx = 0
var accepted = {} // kind -> true (뒤로 갔다 와도 동의 상태 유지)
var cache = {} // kind -> 렌더된 HTML
var section = document.createElement('section') var section = document.createElement('section')
section.className = 'page' section.className = 'page'
pageHost.appendChild(section)
function renderCurrent() {
var k = KINDS[idx]
var isLast = idx === KINDS.length - 1
section.innerHTML = section.innerHTML =
'<h2>' + escapeHtml(tt('agreement.heading')) + '</h2>' + '<h2>' + escapeHtml(tt('agreement.heading')) + '</h2>' +
'<p class="formMessage">' + escapeHtml(tt('agreement.intro')) + '</p>' + '<p class="formMessage">' + escapeHtml(tt('agreement.stepLabel', { current: idx + 1, total: KINDS.length, label: k.tab })) + '</p>' +
'<div class="tabBar" id="agTabs">' + '<div class="agreementBody" id="agBody" style="border-radius:10px;">' + escapeHtml(tt('agreement.loading')) + '</div>' +
KINDS.map(function (k, i) { '<div class="formMessage" id="agHint">' + escapeHtml(tt('agreement.readToBottom')) + '</div>' +
return '<button type="button" class="tabBtn' + (i === 0 ? ' active' : '') + '" data-ag="' + k.id + '">' + escapeHtml(k.tab) + '</button>' '<label class="toggleRow" style="margin-top:8px;"><input type="checkbox" id="agAccept" disabled /> ' +
}).join('') + escapeHtml(tt('agreement.agreeThis')) + '</label>' +
'</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="formMessage" id="agMsg"></div>' +
'<div class="actionRow">' + '<div class="actionRow">' +
' <button class="secondaryBtn" id="back">' + escapeHtml(tt('common.back')) + '</button>' + ' <button class="secondaryBtn" id="back">' + escapeHtml(tt('common.back')) + '</button>' +
' <button class="primaryBtn" id="next" disabled>' + escapeHtml(tt('common.next')) + '</button>' + ' <button class="primaryBtn" id="next" disabled>' + escapeHtml(tt('common.next')) + '</button>' +
'</div>' '</div>'
pageHost.appendChild(section)
var body = section.querySelector('#agBody') var body = section.querySelector('#agBody')
var tabs = section.querySelectorAll('[data-ag]')
var nextBtn = section.querySelector('#next')
var accept = section.querySelector('#agAccept') var accept = section.querySelector('#agAccept')
var nextBtn = section.querySelector('#next')
var msg = section.querySelector('#agMsg') var msg = section.querySelector('#agMsg')
var hint = section.querySelector('#agHint')
// 본문 캐시. 탭 전환 시 재요청하지 않음. // 새 약관 페이지는 화면 맨 위에서부터 보이도록 스크롤을 올린다.
var cache = {} if (pageHost) pageHost.scrollTop = 0
function showKind(kind) { // 약관 본문이 정상 로드된 뒤에만 동의를 허용한다. 로드 실패 상태에서는
if (cache[kind]) { body.innerHTML = cache[kind]; return } // 스크롤을 해도 동의 체크가 켜지지 않는다("무조건 약관을 읽도록").
body.textContent = tt('agreement.loading') var termLoaded = false
api.getTerm(kind).then(function (res) {
if (!res.ok) { function markReadable() {
body.innerHTML = '<p class="formMessage error">' + escapeHtml(tt('agreement.loadFailed', { message: res.message || '' })) + '</p>' if (!termLoaded || !accept.disabled) return
return accept.disabled = false
hint.textContent = ''
} }
var html = renderTermsMarkdown(res.content || '') function checkScrolled() {
cache[kind] = html if (body.scrollHeight - body.scrollTop - body.clientHeight <= 6) markReadable()
body.innerHTML = html
}).catch(function (err) {
body.innerHTML = '<p class="formMessage error">' + escapeHtml(tt('agreement.loadFailed', { message: err.message })) + '</p>'
})
} }
body.addEventListener('scroll', checkScrolled)
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 () { accept.addEventListener('change', function () {
accepted[k.id] = accept.checked
nextBtn.disabled = !accept.checked nextBtn.disabled = !accept.checked
if (accept.checked) msg.textContent = '' if (accept.checked) { msg.textContent = ''; msg.classList.remove('error') }
}) })
nextBtn.addEventListener('click', function () { nextBtn.addEventListener('click', function () {
@@ -254,11 +252,69 @@ function renderAgreementWithKinds(KINDS) {
msg.classList.add('error') msg.classList.add('error')
return return
} }
renderStep2() if (isLast) renderStep2()
else { idx++; renderCurrent() }
})
section.querySelector('#back').addEventListener('click', function () {
if (idx === 0) renderStep1()
else { idx--; renderCurrent() }
}) })
section.querySelector('#back').addEventListener('click', renderStep1)
showKind(KINDS[0].id) function afterLoad() {
termLoaded = true
body.scrollTop = 0
if (accepted[k.id]) {
accept.disabled = false
accept.checked = true
nextBtn.disabled = false
hint.textContent = ''
}
setTimeout(function () {
if (body.scrollHeight - body.clientHeight <= 6) markReadable()
else checkScrolled()
}, 0)
}
// 약관 본문 로드 실패: 동의 불가 상태를 유지하고 다시 시도만 허용한다.
function showLoadError(message) {
termLoaded = false
accept.disabled = true
accept.checked = false
accepted[k.id] = false
nextBtn.disabled = true
hint.textContent = tt('agreement.readToBottom')
body.innerHTML =
'<p class="formMessage error">' + escapeHtml(tt('agreement.loadFailed', { message: message || '' })) + '</p>' +
'<div class="actionRow" style="margin-top:10px;"><button class="secondaryBtn" id="agRetry">' + escapeHtml(tt('agreement.retry')) + '</button></div>'
var retry = section.querySelector('#agRetry')
if (retry) retry.addEventListener('click', loadTerm)
}
function loadTerm() {
if (cache[k.id]) {
body.innerHTML = cache[k.id]
afterLoad()
return
}
body.textContent = tt('agreement.loading')
api.getTerm(k.id).then(function (res) {
if (!res.ok) {
showLoadError(res.message || '')
return
}
var html = renderTermsMarkdown(res.content || '')
cache[k.id] = html
body.innerHTML = html
afterLoad()
}).catch(function (err) {
showLoadError(err && err.message ? err.message : '')
})
}
loadTerm()
}
renderCurrent()
} }
// 인스톨러용 미니 markdown 렌더러. 사이트 termsEditor 와 같은 규칙을 처리한다. // 인스톨러용 미니 markdown 렌더러. 사이트 termsEditor 와 같은 규칙을 처리한다.

View File

@@ -0,0 +1,22 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<title>음악퀴즈 파일제거</title>
<link rel="stylesheet" href="../installer/styles.css" />
</head>
<body>
<header class="appHeader">
<h1>음악퀴즈 파일제거</h1>
</header>
<main id="pageHost"></main>
<aside class="logViewer" id="logViewer" hidden>
<header><h2>로그</h2><button type="button" id="logToggle">접기</button></header>
<pre id="logBody"></pre>
</aside>
<script src="./renderer.js"></script>
</body>
</html>

View File

@@ -0,0 +1,209 @@
'use strict'
const api = window.uninstaller
let I18N = {}
function tt(key, params) {
var parts = String(key).split('.')
var cur = I18N
for (var i = 0; i < parts.length; i++) {
if (cur && typeof cur === 'object' && parts[i] in cur) {
cur = cur[parts[i]]
} else {
return key
}
}
if (typeof cur !== 'string') return key
if (!params) return cur
return cur.replace(/\{\{\s*(\w+)\s*\}\}/g, function (_m, name) {
return name in params ? String(params[name]) : '{{' + name + '}}'
})
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : c === '"' ? '&quot;' : '&#39;'
})
}
const pageHost = document.getElementById('pageHost')
const logViewer = document.getElementById('logViewer')
const logBody = document.getElementById('logBody')
const logToggle = document.getElementById('logToggle')
logToggle.addEventListener('click', function () {
logViewer.classList.toggle('collapsed')
if (logViewer.classList.contains('collapsed')) {
logViewer.style.height = '36px'
logToggle.textContent = tt('logViewer.expand')
} else {
logViewer.style.height = ''
logToggle.textContent = tt('logViewer.collapse')
}
})
api.onLog(function (line) {
logViewer.hidden = false
logBody.textContent += line + '\n'
logBody.scrollTop = logBody.scrollHeight
})
function applyStaticI18n() {
document.title = tt('app.title')
var h1 = document.querySelector('.appHeader h1')
if (h1) h1.textContent = tt('app.title')
var logH2 = logViewer.querySelector('header h2')
if (logH2) logH2.textContent = tt('logViewer.heading')
logToggle.textContent = tt('logViewer.collapse')
}
// ── 1단계: 삭제 동의 ──────────────────────────────
function renderConfirm() {
pageHost.innerHTML =
'<section class="page">' +
' <h2>' + escapeHtml(tt('confirm.heading')) + '</h2>' +
' <p class="formMessage" style="margin-top:8px;font-size:15px;">' + escapeHtml(tt('confirm.question')) + '</p>' +
' <p class="formMessage" style="margin-top:8px;">' + escapeHtml(tt('confirm.detail')) + '</p>' +
' <div id="previewBox" class="progressCard" style="margin-top:14px;"><p class="formMessage">' +
escapeHtml(tt('confirm.loadingPreview')) + '</p></div>' +
' <div class="actionRow" style="margin-top:16px;">' +
' <button class="primaryBtn" id="agreeBtn" disabled>' + escapeHtml(tt('confirm.agreeBtn')) + '</button>' +
' <button class="secondaryBtn" id="cancelBtn">' + escapeHtml(tt('confirm.cancelBtn')) + '</button>' +
' </div>' +
'</section>'
var agreeBtn = document.getElementById('agreeBtn')
var cancelBtn = document.getElementById('cancelBtn')
var previewBox = document.getElementById('previewBox')
cancelBtn.addEventListener('click', function () { api.quit() })
api.preview().then(function (p) {
var existingDirs = p.existingDirs || []
var items = []
if (existingDirs.length) {
for (var d = 0; d < existingDirs.length; d++) {
items.push(tt('confirm.itemCustomDir', { path: existingDirs[d] }))
}
} else {
var first = (p.allTargetDirs && p.allTargetDirs[0]) || ''
items.push(tt('confirm.itemCustomDirMissing', { path: first }))
}
if (p.shortcutExists) items.push(tt('confirm.itemShortcut'))
if (p.launcherProfiles && p.launcherProfiles.length) {
items.push(tt('confirm.itemProfiles', { names: p.launcherProfiles.join(', ') }))
}
var nothing = !existingDirs.length && !p.shortcutExists && (!p.launcherProfiles || !p.launcherProfiles.length)
var html = '<p class="formMessage"><strong>' + escapeHtml(tt('confirm.previewTitle')) + '</strong></p><ul>'
for (var i = 0; i < items.length; i++) html += '<li>' + escapeHtml(items[i]) + '</li>'
html += '</ul>'
if (nothing) html += '<p class="formMessage">' + escapeHtml(tt('confirm.nothingFound')) + '</p>'
previewBox.innerHTML = html
agreeBtn.disabled = false
agreeBtn.addEventListener('click', function () { renderChoose(p) })
}).catch(function (err) {
previewBox.innerHTML = '<p class="formMessage error">' +
escapeHtml(tt('confirm.previewFail', { message: (err && err.message) || String(err) })) + '</p>'
agreeBtn.disabled = false
agreeBtn.addEventListener('click', function () { renderChoose({ existingDirs: [], allTargetDirs: [], shortcutExists: false, launcherProfiles: [] }) })
})
}
// ── 2단계: 삭제 방식 선택 ─────────────────────────
function renderChoose(preview) {
pageHost.innerHTML =
'<section class="page">' +
' <h2>' + escapeHtml(tt('choose.heading')) + '</h2>' +
' <p class="formMessage" style="margin-top:8px;">' + escapeHtml(tt('choose.intro')) + '</p>' +
' <div class="actionRow" style="margin-top:18px;flex-direction:column;gap:12px;align-items:stretch;">' +
' <button class="secondaryBtn" id="trashBtn" style="padding:16px;text-align:left;">' +
' <strong>' + escapeHtml(tt('choose.trashTitle')) + '</strong><br/>' +
' <span class="formMessage">' + escapeHtml(tt('choose.trashDesc')) + '</span></button>' +
' <button class="secondaryBtn" id="permBtn" style="padding:16px;text-align:left;">' +
' <strong>' + escapeHtml(tt('choose.permTitle')) + '</strong><br/>' +
' <span class="formMessage">' + escapeHtml(tt('choose.permDesc')) + '</span></button>' +
' </div>' +
' <div class="actionRow" style="margin-top:16px;">' +
' <button class="secondaryBtn" id="backBtn">' + escapeHtml(tt('choose.backBtn')) + '</button>' +
' </div>' +
' <div id="runState"></div>' +
'</section>'
var trashBtn = document.getElementById('trashBtn')
var permBtn = document.getElementById('permBtn')
var backBtn = document.getElementById('backBtn')
var runState = document.getElementById('runState')
backBtn.addEventListener('click', function () { renderConfirm() })
function runMode(mode) {
var warn = mode === 'permanent' ? tt('choose.confirmPermanent') : tt('choose.confirmTrash')
if (!window.confirm(warn)) return
trashBtn.disabled = true
permBtn.disabled = true
backBtn.disabled = true
runState.innerHTML = '<p class="formMessage" style="margin-top:14px;">' + escapeHtml(tt('choose.running')) + '</p>'
api.run(mode).then(function (result) {
renderResult(result, mode)
}).catch(function (err) {
runState.innerHTML = '<p class="formMessage error" style="margin-top:14px;">' +
escapeHtml(tt('choose.error', { message: (err && err.message) || String(err) })) + '</p>'
trashBtn.disabled = false
permBtn.disabled = false
backBtn.disabled = false
})
}
trashBtn.addEventListener('click', function () { runMode('trash') })
permBtn.addEventListener('click', function () { runMode('permanent') })
}
// ── 3단계: 결과 ──────────────────────────────────
function renderResult(result, mode) {
var total = (result.removed ? result.removed.length : 0) + (result.profilesRemoved ? result.profilesRemoved.length : 0)
var hasErr = result.errors && result.errors.length
var cls = hasErr ? 'error' : 'done'
var badge = hasErr ? tt('result.badgePartial') : tt('result.badgeOk')
var lines = ''
if (result.removed && result.removed.length) {
lines += '<p class="formMessage"><strong>' + escapeHtml(tt('result.removedTitle')) + '</strong></p><ul>'
for (var i = 0; i < result.removed.length; i++) lines += '<li>' + escapeHtml(result.removed[i]) + '</li>'
lines += '</ul>'
}
if (result.profilesRemoved && result.profilesRemoved.length) {
lines += '<p class="formMessage"><strong>' + escapeHtml(tt('result.profilesTitle')) + '</strong></p><ul>'
for (var j = 0; j < result.profilesRemoved.length; j++) lines += '<li>' + escapeHtml(result.profilesRemoved[j]) + '</li>'
lines += '</ul>'
}
if (hasErr) {
lines += '<p class="formMessage error"><strong>' + escapeHtml(tt('result.errorsTitle')) + '</strong></p><ul>'
for (var k = 0; k < result.errors.length; k++) lines += '<li>' + escapeHtml(result.errors[k]) + '</li>'
lines += '</ul>'
}
if (total === 0 && !hasErr) {
lines += '<p class="formMessage">' + escapeHtml(tt('result.nothing')) + '</p>'
}
pageHost.innerHTML =
'<section class="page">' +
' <div class="progressCard ' + cls + '" style="margin-top:6px;">' +
' <div class="cardTop"><span class="statusBadge ' + (hasErr ? 'fail' : 'ok') + '">' + escapeHtml(badge) + '</span> ' +
' <span class="label">' + escapeHtml(tt(mode === 'permanent' ? 'mode.permanent' : 'mode.trash')) + '</span></div>' +
lines +
' <p class="formMessage" style="margin-top:10px;"><small>' + escapeHtml(tt('result.note')) + '</small></p>' +
' </div>' +
' <div class="actionRow" style="margin-top:16px;">' +
' <button class="primaryBtn" id="quitBtn">' + escapeHtml(tt('result.quitBtn')) + '</button>' +
' </div>' +
'</section>'
document.getElementById('quitBtn').addEventListener('click', function () { api.quit() })
}
;(async function () {
try { I18N = (await api.loadLocale()) || {} } catch (_) { I18N = {} }
applyStaticI18n()
renderConfirm()
})()

View File

@@ -136,6 +136,8 @@ function renderStep1() {
tt('step1.subtitle', { mc: pack.pack.mcVersion, platform: pack.pack.platform.type }) + '</small>' tt('step1.subtitle', { mc: pack.pack.mcVersion, platform: pack.pack.platform.type }) + '</small>'
if (state.selectedPackKey === pack.key) btn.classList.add('selected') if (state.selectedPackKey === pack.key) btn.classList.add('selected')
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
// 팩이 바뀌면 최종 리소스팩 처리 상태를 초기화(새 팩의 설치 여부를 다시 묻도록).
if (state.selectedPackKey !== pack.key) state.finalRpHandled = false
state.selectedPackKey = pack.key state.selectedPackKey = pack.key
nextBtn.disabled = false nextBtn.disabled = false
renderList() renderList()
@@ -232,63 +234,62 @@ function showAgreementError(message) {
section.querySelector('#retry').addEventListener('click', renderAgreement) section.querySelector('#retry').addEventListener('click', renderAgreement)
} }
function renderAgreementWithKinds(KINDS) { // 약관을 한 건씩 페이지로 보여준다. 각 약관마다 "해당 약관에 동의합니다" 체크가 있고,
// 끝까지 스크롤해 읽어야 체크할 수 있으며, [다음] 을 누르면 다음 약관 페이지가 화면
// 맨 위에서부터 나온다. 마지막 약관까지 동의하면 다음 단계(step2)로 진행한다.
function renderAgreementWithKinds(KINDS, opts) {
opts = opts || {}
var onComplete = opts.onComplete || renderStep2 // 마지막 약관 동의 후
var onBack = opts.onBack || renderStep1 // 첫 약관에서 뒤로
var idx = 0
var accepted = {} // kind -> true (뒤로 갔다 와도 동의 상태 유지)
var cache = {} // kind -> 렌더된 HTML
var section = document.createElement('section') var section = document.createElement('section')
section.className = 'page' 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) pageHost.appendChild(section)
function renderCurrent() {
var k = KINDS[idx]
var isLast = idx === KINDS.length - 1
section.innerHTML =
'<h2>' + tt('agreement.heading') + '</h2>' +
'<p class="formMessage">' + escapeHtml(tt('agreement.stepLabel', { current: idx + 1, total: KINDS.length, label: k.tab })) + '</p>' +
'<div class="agreementBody" id="agBody" style="border-radius:10px;">' + tt('agreement.loading') + '</div>' +
'<div class="formMessage" id="agHint">' + tt('agreement.readToBottom') + '</div>' +
'<label class="toggleRow" style="margin-top:8px;"><input type="checkbox" id="agAccept" disabled /> ' +
tt('agreement.agreeThis') + '</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>'
var body = section.querySelector('#agBody') var body = section.querySelector('#agBody')
var tabs = section.querySelectorAll('[data-ag]')
var nextBtn = section.querySelector('#next')
var accept = section.querySelector('#agAccept') var accept = section.querySelector('#agAccept')
var nextBtn = section.querySelector('#next')
var msg = section.querySelector('#agMsg') var msg = section.querySelector('#agMsg')
var hint = section.querySelector('#agHint')
// 약관 본문은 한 번 받으면 캐시. 탭 전환 시 재요청하지 않는다. // 약관 페이지는 화면 맨 위에서부터 보이도록 스크롤을 올린다.
var cache = {} if (pageHost) pageHost.scrollTop = 0
function showKind(kind) { // 약관 본문이 정상 로드된 뒤에만 동의를 허용한다. 로드 실패 상태에서는
if (cache[kind]) { // 스크롤을 해도 동의 체크가 켜지지 않는다("무조건 약관을 읽도록").
body.innerHTML = cache[kind] var termLoaded = false
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) { function markReadable() {
b.addEventListener('click', function () { if (!termLoaded || !accept.disabled) return
tabs.forEach(function (x) { x.classList.remove('active') }) accept.disabled = false
b.classList.add('active') hint.textContent = ''
showKind(b.getAttribute('data-ag')) }
}) function checkScrolled() {
}) // 본문을 끝까지(하단 근처) 내렸으면 동의 체크를 허용한다.
if (body.scrollHeight - body.scrollTop - body.clientHeight <= 6) markReadable()
}
body.addEventListener('scroll', checkScrolled)
accept.addEventListener('change', function () { accept.addEventListener('change', function () {
accepted[k.id] = accept.checked
nextBtn.disabled = !accept.checked nextBtn.disabled = !accept.checked
if (accept.checked) msg.textContent = '' if (accept.checked) { msg.textContent = ''; msg.classList.remove('error') }
}) })
nextBtn.addEventListener('click', function () { nextBtn.addEventListener('click', function () {
@@ -297,11 +298,71 @@ function renderAgreementWithKinds(KINDS) {
msg.classList.add('error') msg.classList.add('error')
return return
} }
renderStep2() if (isLast) onComplete()
else { idx++; renderCurrent() }
})
section.querySelector('#back').addEventListener('click', function () {
if (idx === 0) onBack()
else { idx--; renderCurrent() }
}) })
section.querySelector('#back').addEventListener('click', renderStep1)
showKind(KINDS[0].id) function afterLoad() {
termLoaded = true
body.scrollTop = 0
// 이미 동의했던 약관으로 되돌아온 경우: 체크/다음 활성화 유지.
if (accepted[k.id]) {
accept.disabled = false
accept.checked = true
nextBtn.disabled = false
hint.textContent = ''
}
// 레이아웃 확정 후: 스크롤이 필요 없을 만큼 짧은 약관이면 바로 동의 허용.
setTimeout(function () {
if (body.scrollHeight - body.clientHeight <= 6) markReadable()
else checkScrolled()
}, 0)
}
// 약관 본문 로드 실패: 동의 불가 상태를 유지하고 다시 시도만 허용한다.
function showLoadError(message) {
termLoaded = false
accept.disabled = true
accept.checked = false
accepted[k.id] = false
nextBtn.disabled = true
hint.textContent = tt('agreement.readToBottom')
body.innerHTML =
'<p class="formMessage error">' + tt('agreement.loadFailed', { message: message || '' }) + '</p>' +
'<div class="actionRow" style="margin-top:10px;"><button class="secondaryBtn" id="agRetry">' + tt('agreement.retry') + '</button></div>'
var retry = section.querySelector('#agRetry')
if (retry) retry.addEventListener('click', loadTerm)
}
function loadTerm() {
if (cache[k.id]) {
body.innerHTML = cache[k.id]
afterLoad()
return
}
body.textContent = tt('agreement.loading')
installerApi.getTerm(k.id).then(function (res) {
if (!res.ok) {
showLoadError(res.message || '')
return
}
var html = renderTermsMarkdown(res.content || '')
cache[k.id] = html
body.innerHTML = html
afterLoad()
}).catch(function (err) {
showLoadError(err && err.message ? err.message : '')
})
}
loadTerm()
}
renderCurrent()
} }
// 인스톨러용 미니 markdown 렌더러. 사이트 termsEditor 와 동일한 규칙을 처리한다. // 인스톨러용 미니 markdown 렌더러. 사이트 termsEditor 와 동일한 규칙을 처리한다.
@@ -537,10 +598,17 @@ function renderSubStep31(host, back, done) {
} }
function renderSubStep32(host, back, done) { function renderSubStep32(host, back, done) {
// 선택한 음악퀴즈(pack)의 권장 JDK. 설치기는 이 버전을 우선 찾고/설치한다.
var selPack = (state.packs && state.packs.find)
? state.packs.find(function (p) { return p.key === state.selectedPackKey })
: null
var recommendedJdk = (selPack && selPack.pack && selPack.pack.recommendedJdk) ? selPack.pack.recommendedJdk : 25
host.innerHTML = host.innerHTML =
'<h3>' + tt('step3.sub32.heading') + '</h3>' + '<h3>' + tt('step3.sub32.heading') + '</h3>' +
'<p class="formMessage">' + tt('step3.sub32.description') + '</p>' + '<p class="formMessage">' + tt('step3.sub32.description') + '</p>' +
'<div class="fieldset"><label><input id="jdkPath" type="text" placeholder="C:\\Program Files\\Java\\jdk-17" value="' + (state.serverInstall.jdk || '') + '" /></label>' + '<p class="formMessage"><strong>' + tt('step3.sub32.recommended', { major: recommendedJdk }) + '</strong></p>' +
'<div class="fieldset"><label><input id="jdkPath" type="text" placeholder="C:\\Program Files\\Java\\jdk-' + recommendedJdk + '" value="' + (state.serverInstall.jdk || '') + '" /></label>' +
'<button class="secondaryBtn" id="pickJdk">' + tt('step3.sub32.pickFolder') + '</button>' + '<button class="secondaryBtn" id="pickJdk">' + tt('step3.sub32.pickFolder') + '</button>' +
'<button class="secondaryBtn" id="auto">' + tt('step3.sub32.auto') + '</button>' + '<button class="secondaryBtn" id="auto">' + tt('step3.sub32.auto') + '</button>' +
'<button class="secondaryBtn" id="install">' + tt('step3.sub32.install') + '</button></div>' + '<button class="secondaryBtn" id="install">' + tt('step3.sub32.install') + '</button></div>' +
@@ -577,14 +645,20 @@ function renderSubStep32(host, back, done) {
autoBtn.addEventListener('click', async function () { autoBtn.addEventListener('click', async function () {
if (installing) return if (installing) return
var detect = await installerApi.detectJdk() var detect = await installerApi.detectJdk(recommendedJdk)
if (detect.found) { if (detect.found && detect.match) {
input.value = detect.path input.value = detect.path
msg.textContent = tt('step3.sub32.found', { path: detect.path }) msg.textContent = tt('step3.sub32.foundRecommended', { path: detect.path, major: recommendedJdk })
msg.classList.remove('error') msg.classList.remove('error')
msg.classList.add('success') msg.classList.add('success')
} else if (detect.found) {
// 권장 버전이 아닌 다른 JDK 를 찾음 → 경고와 함께 채워 넣는다.
input.value = detect.path
msg.textContent = tt('step3.sub32.foundMismatch', { path: detect.path, major: detect.major, required: recommendedJdk })
msg.classList.remove('success')
msg.classList.add('error')
} else { } else {
msg.textContent = tt('step3.sub32.notFound') msg.textContent = tt('step3.sub32.notFound', { major: recommendedJdk })
msg.classList.remove('success') msg.classList.remove('success')
msg.classList.add('error') msg.classList.add('error')
} }
@@ -606,7 +680,7 @@ function renderSubStep32(host, back, done) {
msg.classList.remove('success', 'error') msg.classList.remove('success', 'error')
msg.textContent = tt('step3.sub32.downloading') msg.textContent = tt('step3.sub32.downloading')
try { try {
var result = await installerApi.installJdk() var result = await installerApi.installJdk(recommendedJdk)
if (result.ok && result.path) { if (result.ok && result.path) {
input.value = result.path input.value = result.path
state.serverInstall.jdk = result.path state.serverInstall.jdk = result.path
@@ -630,24 +704,62 @@ function renderSubStep32(host, back, done) {
if (installing) return if (installing) return
back() back()
}) })
nextBtn.addEventListener('click', function () { nextBtn.addEventListener('click', async function () {
if (installing) return if (installing) return
if (!input.value.trim()) { var val = input.value.trim()
if (!val) {
msg.textContent = tt('step3.sub32.pathRequired') msg.textContent = tt('step3.sub32.pathRequired')
msg.classList.add('error') msg.classList.add('error')
return return
} }
state.serverInstall.jdk = input.value.trim() // 직접 입력/선택한 JDK 를 확인한다. 자바를 못 읽으면 진행을 막고, 권장과 다른
// 버전이면 "정상 실행이 안 될 수 있다"는 경고를 띄운 뒤 사용자가 동의하면 진행한다.
nextBtn.disabled = true
msg.classList.remove('success', 'error')
msg.textContent = tt('step3.sub32.verifying')
var required = recommendedJdk
try {
var v = await installerApi.verifyJdk(val, recommendedJdk)
required = (v && v.required) || recommendedJdk
if (!v || !v.major) {
msg.classList.add('error')
msg.textContent = tt('step3.sub32.versionUnknown')
nextBtn.disabled = false
return
}
if (!v.match) {
// 권장 버전과 다름 → 경고 후 동의 시에만 계속.
var proceed = window.confirm(tt('step3.sub32.mismatchConfirm', { major: v.major, required: required }))
if (!proceed) {
msg.classList.add('error')
msg.textContent = tt('step3.sub32.foundMismatch', { path: val, major: v.major, required: required })
nextBtn.disabled = false
return
}
}
} catch (err) {
msg.classList.add('error')
msg.textContent = tt('step3.sub32.versionUnknown')
nextBtn.disabled = false
return
}
nextBtn.disabled = false
state.serverInstall.jdk = val
done() done()
}) })
;(async function () { ;(async function () {
var detect = await installerApi.detectJdk() var detect = await installerApi.detectJdk(recommendedJdk)
if (detect.found && !input.value) { if (detect.found && !input.value) {
input.value = detect.path input.value = detect.path
msg.textContent = tt('step3.sub32.autoDetected', { path: detect.path }) if (detect.match) {
msg.textContent = tt('step3.sub32.foundRecommended', { path: detect.path, major: recommendedJdk })
msg.classList.add('success') msg.classList.add('success')
} else {
msg.textContent = tt('step3.sub32.foundMismatch', { path: detect.path, major: detect.major, required: recommendedJdk })
msg.classList.add('error')
}
} else if (!detect.found) { } else if (!detect.found) {
msg.textContent = tt('step3.sub32.notFoundHint') msg.textContent = tt('step3.sub32.notFoundHint', { major: recommendedJdk })
} }
})() })()
} }
@@ -949,9 +1061,154 @@ function renderSubStep42(host, back, done) {
})() })()
} }
// 최종 리소스팩 설치 여부 질문 (예/아니요).
function renderResourcepackPrompt() {
setActiveStep(5)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + tt('resourcepack.promptHeading') + '</h2>' +
'<p class="formMessage">' + escapeHtml(tt('resourcepack.promptBody')) + '</p>' +
'<div class="actionRow">' +
' <button class="secondaryBtn" id="rpNo">' + tt('resourcepack.no') + '</button>' +
' <button class="primaryBtn" id="rpYes">' + tt('resourcepack.yes') + '</button>' +
'</div>'
pageHost.appendChild(section)
section.querySelector('#rpNo').addEventListener('click', function () {
state.finalRpHandled = true
renderStep5()
})
section.querySelector('#rpYes').addEventListener('click', renderResourcepackTerms)
}
// "예" → 최종 리소스팩 다운로드 약관(showInFinalResourcepack) 동의 → 다운로드.
function renderResourcepackTerms() {
setActiveStep(5)
clearPage()
var loading = document.createElement('section')
loading.className = 'page'
loading.innerHTML = '<h2>' + tt('agreement.heading') + '</h2><p class="formMessage">' + tt('agreement.loading') + '</p>'
pageHost.appendChild(loading)
installerApi.getFinalTermsList().then(function (res) {
if (!res || !res.ok) {
showResourcepackTermsError((res && res.message) || 'unknown')
return
}
var terms = (res.terms || []).map(function (t) { return { id: t.kind, tab: t.label } })
if (terms.length === 0) {
// 표시할 약관이 없으면 약관 단계를 건너뛰고 바로 다운로드.
downloadFinalResourcepack()
return
}
clearPage()
renderAgreementWithKinds(terms, { onComplete: downloadFinalResourcepack, onBack: renderResourcepackPrompt })
}).catch(function (err) {
showResourcepackTermsError(err && err.message ? err.message : 'unknown')
})
}
function showResourcepackTermsError(message) {
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + tt('agreement.heading') + '</h2>' +
'<p class="formMessage error">' + escapeHtml(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', renderResourcepackPrompt)
section.querySelector('#retry').addEventListener('click', renderResourcepackTerms)
}
// 약관 동의 후 최종 리소스팩 실제 다운로드 → 완료되면 완료 화면으로.
function downloadFinalResourcepack() {
setActiveStep(5)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + tt('resourcepack.downloadHeading') + '</h2>' +
'<div class="formMessage" id="rpMsg">' + tt('resourcepack.downloading') + '</div>' +
'<div id="rpBarWrap" style="margin-top:10px;height:16px;background:rgba(255,255,255,0.08);border:1px solid var(--border,#30363d);border-radius:8px;overflow:hidden;">' +
' <div id="rpBar" style="height:100%;width:0%;background:var(--accent,#6cf);transition:width 0.15s;"></div>' +
'</div>' +
'<div class="formMessage" id="rpPercent" style="margin-top:6px;">0%</div>' +
'<div class="actionRow" id="rpActions" hidden></div>'
pageHost.appendChild(section)
var msg = section.querySelector('#rpMsg')
var actions = section.querySelector('#rpActions')
var bar = section.querySelector('#rpBar')
var barWrap = section.querySelector('#rpBarWrap')
var percentEl = section.querySelector('#rpPercent')
// 다운로드 진행률(퍼센트) 구독. 완료/실패 시 해제.
var unsubscribe = null
if (installerApi.onFinalResourcepackProgress) {
unsubscribe = installerApi.onFinalResourcepackProgress(function (info) {
if (info && info.percent >= 0) {
bar.style.width = info.percent + '%'
percentEl.textContent = info.percent + '%'
} else if (info) {
// 전체 크기를 모르면 받은 용량(MB)만 표시.
percentEl.textContent = (info.loaded / 1048576).toFixed(1) + ' MB'
}
})
}
function stopProgress() { if (unsubscribe) { unsubscribe(); unsubscribe = null } }
function proceed() { stopProgress(); state.finalRpHandled = true; renderStep5() }
installerApi.installFinalResourcepack().then(function (res) {
stopProgress()
if (res && res.ok) {
bar.style.width = '100%'
percentEl.textContent = '100%'
msg.textContent = tt('resourcepack.downloadDone')
msg.classList.add('success')
actions.innerHTML = '<button class="primaryBtn" id="rpNext">' + tt('common.next') + '</button>'
actions.querySelector('#rpNext').addEventListener('click', proceed)
} else {
barWrap.hidden = true
percentEl.hidden = true
msg.textContent = tt('resourcepack.downloadFailed', { message: (res && res.message) || 'unknown' })
msg.classList.add('error')
actions.innerHTML =
'<button class="secondaryBtn" id="rpRetry">' + tt('agreement.retry') + '</button>' +
'<button class="primaryBtn" id="rpSkip">' + tt('common.next') + '</button>'
actions.querySelector('#rpRetry').addEventListener('click', downloadFinalResourcepack)
actions.querySelector('#rpSkip').addEventListener('click', proceed)
}
actions.hidden = false
}).catch(function (err) {
stopProgress()
barWrap.hidden = true
percentEl.hidden = true
msg.textContent = tt('resourcepack.downloadFailed', { message: (err && err.message) ? err.message : String(err) })
msg.classList.add('error')
actions.innerHTML =
'<button class="secondaryBtn" id="rpRetry">' + tt('agreement.retry') + '</button>' +
'<button class="primaryBtn" id="rpSkip">' + tt('common.next') + '</button>'
actions.querySelector('#rpRetry').addEventListener('click', downloadFinalResourcepack)
actions.querySelector('#rpSkip').addEventListener('click', proceed)
actions.hidden = false
})
}
function renderStep5() { function renderStep5() {
setActiveStep(5) setActiveStep(5)
clearPage() clearPage()
// 최종 리소스팩(finalResourcepackPath)이 "." 이 아니고 아직 처리하지 않았으면,
// 완료 화면 전에 "리소스팩을 설치하시겠습니까?" 를 먼저 묻는다.
var selPack = state.packs.find(function (p) { return p.key === state.selectedPackKey })
var finalRp = (selPack && selPack.pack && selPack.pack.finalResourcepackPath) ? selPack.pack.finalResourcepackPath : '.'
if (finalRp !== '.' && !state.finalRpHandled) {
renderResourcepackPrompt()
return
}
var section = document.createElement('section') var section = document.createElement('section')
section.className = 'page' section.className = 'page'
// 서버 마무리 액션 (바로가기/서버 실행) 은 step3 를 거친 호스트 만 노출한다. // 서버 마무리 액션 (바로가기/서버 실행) 은 step3 를 거친 호스트 만 노출한다.

View File

@@ -33,7 +33,7 @@
}, },
"agreement": { "agreement": {
"heading": "약관 동의", "heading": "약관 동의",
"intro": "리소스팩을 설치하기 전에 아래 약관을 모두 확인하고 동의해 주세요.", "intro": "리소스팩을 설치하기 전에 아래 약관을 한 건씩 끝까지 읽고 동의해 주세요.",
"tabResourcepack": "리소스팩 약관", "tabResourcepack": "리소스팩 약관",
"tabInstaller": "리소스팩 설치기 약관", "tabInstaller": "리소스팩 설치기 약관",
"loading": "약관을 불러오는 중...", "loading": "약관을 불러오는 중...",
@@ -41,6 +41,9 @@
"listLoadFailed": "약관 표시에 실패하여 설치를 진행할 수 없습니다.\n사유: {{message}}\n네트워크 상태를 확인하고 다시 시도하거나, 처음 단계로 돌아가 주세요.", "listLoadFailed": "약관 표시에 실패하여 설치를 진행할 수 없습니다.\n사유: {{message}}\n네트워크 상태를 확인하고 다시 시도하거나, 처음 단계로 돌아가 주세요.",
"retry": "다시 시도", "retry": "다시 시도",
"agreeAll": "위 모든 약관에 동의합니다.", "agreeAll": "위 모든 약관에 동의합니다.",
"agreeThis": "해당 약관에 동의합니다.",
"readToBottom": "약관을 끝까지 내려서 확인하면 동의할 수 있습니다.",
"stepLabel": "약관 {{current}}/{{total}} — {{label}}",
"agreeRequired": "약관에 동의해야 다음 단계로 진행할 수 있습니다.", "agreeRequired": "약관에 동의해야 다음 단계로 진행할 수 있습니다.",
"cancelling": "취소 중…" "cancelling": "취소 중…"
}, },
@@ -91,12 +94,15 @@
"musicTrackDone": "{{idx}}번 노래 완료: {{name}}", "musicTrackDone": "{{idx}}번 노래 완료: {{name}}",
"musicTrackSkip": "{{idx}}번 노래는 이전에 받아둠 → 건너뜀(이어받기)", "musicTrackSkip": "{{idx}}번 노래는 이전에 받아둠 → 건너뜀(이어받기)",
"musicRefreshRetry": "{{count}}곡 다운로드 실패 → yt-dlp/ffmpeg 최신 버전으로 재설치 후 실패한 곡만 재시도", "musicRefreshRetry": "{{count}}곡 다운로드 실패 → yt-dlp/ffmpeg 최신 버전으로 재설치 후 실패한 곡만 재시도",
"musicTrackRetryWait": "{{idx}}번 노래 재시도 {{attempt}}회차 — 유튜브 속도제한(403) 회피로 {{secs}}초 대기 후 다시 시도",
"ytdlpReinstall": "yt-dlp.exe 최신 버전으로 강제 재설치 중…", "ytdlpReinstall": "yt-dlp.exe 최신 버전으로 강제 재설치 중…",
"ffmpegReinstall": "ffmpeg.exe 최신 버전으로 강제 재설치 중…", "ffmpegReinstall": "ffmpeg.exe 최신 버전으로 강제 재설치 중…",
"imageStart": "사진 다운로드 시작 ({{total}}장)", "imageStart": "사진 다운로드 시작 ({{total}}장)",
"imageDownloading": "{{idx}}번 사진 다운로드 중…", "imageDownloading": "{{idx}}번 사진 다운로드 중…",
"imageDone": "{{idx}}번 사진 완료: {{name}}", "imageDone": "{{idx}}번 사진 완료: {{name}}",
"imageSkip": "{{idx}}번 사진은 이전에 받아둠 → 건너뜀(이어받기)", "imageSkip": "{{idx}}번 사진은 이전에 받아둠 → 건너뜀(이어받기)",
"imageCooldown": "음악 다운로드 직후라 유튜브 속도제한을 피하려고 {{secs}}초 대기합니다…",
"imageRetry": "{{idx}}번 사진 재시도 {{attempt}}회차 (HTTP {{code}}) — {{secs}}초 후 다시 시도",
"baseDownload": "베이스 리소스팩 다운로드: {{path}}", "baseDownload": "베이스 리소스팩 다운로드: {{path}}",
"baseUrl": " URL: {{url}}", "baseUrl": " URL: {{url}}",
"baseReceived": "베이스 리소스팩 받음 ({{kb}} KB)", "baseReceived": "베이스 리소스팩 받음 ({{kb}} KB)",
@@ -130,7 +136,8 @@
"baseDownloading": "베이스 리소스팩 다운로드 중", "baseDownloading": "베이스 리소스팩 다운로드 중",
"buildingWithBase": "베이스에 음악·사진 추가 중", "buildingWithBase": "베이스에 음악·사진 추가 중",
"buildingZip": "zip 빌드 중", "buildingZip": "zip 빌드 중",
"installComplete": "설치 완료" "installComplete": "설치 완료",
"imageRetry": "속도제한(HTTP {{code}}) — {{secs}}초 후 재시도"
}, },
"pack": { "pack": {
"description": "음악퀴즈 리소스팩 - {{name}}" "description": "음악퀴즈 리소스팩 - {{name}}"
@@ -142,6 +149,7 @@
"cancelledByUser": "사용자가 설치를 취소했습니다.", "cancelledByUser": "사용자가 설치를 취소했습니다.",
"musicDownloadFailed": "{{idx}}번 노래 다운로드 실패: {{message}}", "musicDownloadFailed": "{{idx}}번 노래 다운로드 실패: {{message}}",
"imageDownloadFailed": "{{idx}}번 사진 다운로드 실패: {{message}}", "imageDownloadFailed": "{{idx}}번 사진 다운로드 실패: {{message}}",
"imageRateLimitHint": "유튜브가 IP를 잠시 속도제한(429)했습니다. 몇 분 뒤 다시 설치를 시도하면 이미 받은 사진은 건너뛰고 이어받습니다.",
"imageNormalizeFailed": "{{idx}}번 사진 정규화 실패: {{message}}", "imageNormalizeFailed": "{{idx}}번 사진 정규화 실패: {{message}}",
"baseDownloadFailed": "베이스 리소스팩 다운로드 실패: {{message}}", "baseDownloadFailed": "베이스 리소스팩 다운로드 실패: {{message}}",
"ytdlpSignal": "yt-dlp 가 신호 {{signal}} 로 종료됨", "ytdlpSignal": "yt-dlp 가 신호 {{signal}} 로 종료됨",

View File

@@ -0,0 +1,63 @@
{
"app": {
"title": "음악퀴즈 파일제거"
},
"logViewer": {
"heading": "로그",
"collapse": "접기",
"expand": "펼치기"
},
"mode": {
"trash": "휴지통으로 이동",
"permanent": "컴퓨터에서 완전 삭제"
},
"confirm": {
"heading": "음악퀴즈 파일제거",
"question": "음악퀴즈 간편설치기로 설치한 내용들과, 음악퀴즈 관련된 내용을 전부 삭제하시겠습니까?",
"detail": "음악퀴즈 간편설치기·리소스팩설치기가 만든 게임 폴더와 캐시, 마인크래프트 런처의 음악퀴즈 프로필을 정리합니다. 평소 쓰던 .minecraft 본체는 건드리지 않습니다.",
"loadingPreview": "삭제 대상을 확인하는 중…",
"previewTitle": "삭제 대상",
"itemCustomDir": "음악퀴즈 전용 폴더: {{path}}",
"itemCustomDirMissing": "음악퀴즈 전용 폴더가 없음(이미 삭제됨): {{path}}",
"itemShortcut": "바탕화면의 'MusicQuiz Server' 바로가기",
"itemProfiles": "마인크래프트 런처 프로필: {{names}}",
"nothingFound": "삭제할 음악퀴즈 관련 항목을 찾지 못했습니다. 이미 정리된 상태일 수 있습니다.",
"previewFail": "삭제 대상 확인 실패: {{message}}",
"agreeBtn": "동의하고 계속",
"cancelBtn": "취소(종료)"
},
"choose": {
"heading": "삭제 방식 선택",
"intro": "삭제 방식을 선택하세요.",
"trashTitle": "휴지통으로 이동",
"trashDesc": "휴지통으로 옮깁니다. 실수했을 때 되돌릴 수 있습니다.",
"permTitle": "컴퓨터에서 완전 삭제",
"permDesc": "휴지통을 거치지 않고 즉시 완전히 지웁니다. 되돌릴 수 없습니다.",
"backBtn": "뒤로",
"confirmTrash": "선택한 음악퀴즈 관련 항목을 휴지통으로 이동합니다. 계속하시겠습니까?",
"confirmPermanent": "선택한 음악퀴즈 관련 항목을 컴퓨터에서 완전히 삭제합니다. 되돌릴 수 없습니다. 계속하시겠습니까?",
"running": "삭제하는 중…",
"error": "삭제 중 오류: {{message}}"
},
"result": {
"badgeOk": "완료",
"badgePartial": "일부 실패",
"removedTitle": "삭제한 항목",
"profilesTitle": "제거한 런처 프로필",
"errorsTitle": "삭제하지 못한 항목",
"nothing": "삭제할 항목이 없었습니다.",
"note": "직접 지정한 위치에 서버를 설치했다면 그 폴더는 위치를 알 수 없어 자동 삭제되지 않습니다. 필요하면 직접 삭제하세요.",
"quitBtn": "종료"
},
"log": {
"start": "삭제 시작 ({{mode}})",
"removedDir": "폴더 제거: {{path}}",
"removedShortcut": "바로가기 제거: {{path}}",
"removedProfile": "런처 프로필 제거: {{name}}",
"removeFail": "제거 실패: {{path}} — {{message}}",
"customDirMissing": "음악퀴즈 폴더가 이미 없음: {{path}}",
"launcherParseFail": "launcher_profiles.json 을 읽지 못함: {{path}}",
"launcherWriteFail": "launcher_profiles.json 갱신 실패: {{message}}",
"done": "정리 완료 — 총 {{count}}개 항목 처리"
}
}

View File

@@ -41,7 +41,7 @@
}, },
"agreement": { "agreement": {
"heading": "약관 동의", "heading": "약관 동의",
"intro": "설치 전에 아래 약관을 모두 확인하고 동의해 주세요.", "intro": "설치 전에 아래 약관을 한 건씩 끝까지 읽고 동의해 주세요.",
"tabMap": "맵 약관", "tabMap": "맵 약관",
"tabMod": "모드 약관", "tabMod": "모드 약관",
"tabInstaller": "설치기 약관", "tabInstaller": "설치기 약관",
@@ -50,6 +50,9 @@
"listLoadFailed": "약관 표시에 실패하여 설치를 진행할 수 없습니다.\n사유: {{message}}\n네트워크 상태를 확인하고 다시 시도하거나, 처음 단계로 돌아가 주세요.", "listLoadFailed": "약관 표시에 실패하여 설치를 진행할 수 없습니다.\n사유: {{message}}\n네트워크 상태를 확인하고 다시 시도하거나, 처음 단계로 돌아가 주세요.",
"retry": "다시 시도", "retry": "다시 시도",
"agreeAll": "위 모든 약관에 동의합니다.", "agreeAll": "위 모든 약관에 동의합니다.",
"agreeThis": "해당 약관에 동의합니다.",
"readToBottom": "약관을 끝까지 내려서 확인하면 동의할 수 있습니다.",
"stepLabel": "약관 {{current}}/{{total}} — {{label}}",
"agreeRequired": "약관에 동의해야 다음 단계로 진행할 수 있습니다." "agreeRequired": "약관에 동의해야 다음 단계로 진행할 수 있습니다."
}, },
"step1": { "step1": {
@@ -87,17 +90,24 @@
"auto": "자동 탐색", "auto": "자동 탐색",
"install": "자동 설치", "install": "자동 설치",
"installCancel": "설치 취소", "installCancel": "설치 취소",
"recommended": "권장 JDK: Java {{major}} (이 음악퀴즈 서버 실행용)",
"found": "JDK 발견: {{path}}", "found": "JDK 발견: {{path}}",
"foundRecommended": "권장 JDK(Java {{major}}) 발견: {{path}}",
"foundMismatch": "⚠ 권장(Java {{required}})이 아닌 Java {{major}} 를 찾았습니다: {{path}} — 권장 버전과 달라 서버가 정상 실행되지 않을 수 있습니다. 권장 버전을 쓰려면 \"자동 설치\" 를 누르세요.",
"mismatchConfirm": "선택한 Java {{major}} 는 권장 버전(Java {{required}})과 다릅니다. 권장 버전과 달라 서버가 정상 실행되지 않을 수 있습니다. 이대로 계속하시겠습니까?",
"autoDetected": "JDK 자동 탐색됨: {{path}}", "autoDetected": "JDK 자동 탐색됨: {{path}}",
"notFound": "JDK를 자동으로 찾지 못했습니다. \"자동 설치\" 를 눌러 JDK를 설치하거나 직접 선택해 주세요.", "notFound": "서버 실행에 필요한 Java {{major}} 이상을 찾지 못했습니다(낮은 버전이 설치돼 있어도 서버가 뜨지 않습니다). \"자동 설치\" 를 눌러 설치하거나 직접 선택해 주세요.",
"notFoundHint": "JDK를 자동으로 찾지 못했습니다. \"자동 설치\" 를 누르면 JDK를 받아 설치합니다.", "notFoundHint": "Java {{major}} 이상을 찾지 못했습니다. \"자동 설치\" 를 누르면 Temurin {{major}} 를 받아 설치합니다.",
"cancelRequested": "JDK 설치 취소 요청 중...", "cancelRequested": "JDK 설치 취소 요청 중...",
"downloading": "JDK 다운로드 중...", "downloading": "JDK 다운로드 중...",
"installComplete": "JDK 자동 설치 완료: {{path}}", "installComplete": "JDK 자동 설치 완료: {{path}}",
"installCanceled": "JDK 설치 취소됨", "installCanceled": "JDK 설치 취소됨",
"installFailed": "JDK 설치 실패: {{message}}", "installFailed": "JDK 설치 실패: {{message}}",
"installError": "JDK 설치 오류: {{message}}", "installError": "JDK 설치 오류: {{message}}",
"pathRequired": "JDK 경로를 입력해 주세요." "pathRequired": "JDK 경로를 입력해 주세요.",
"verifying": "선택한 JDK 버전을 확인하는 중…",
"versionTooLow": "선택한 Java {{major}} 는 너무 낮습니다. 서버 실행에는 Java {{required}} 이상이 필요합니다. \"자동 설치\" 로 Temurin {{required}} 를 받거나 Java {{required}} 이상 폴더를 선택해 주세요.",
"versionUnknown": "선택한 경로에서 Java 실행 파일을 찾지 못했거나 버전을 확인할 수 없습니다. JDK 폴더(bin\\java.exe 가 있는 곳)를 선택하거나 \"자동 설치\" 를 눌러 주세요."
}, },
"sub33": { "sub33": {
"heading": "서버 다운로드 및 설치", "heading": "서버 다운로드 및 설치",
@@ -150,6 +160,16 @@
"failed": "설치 실패: {{message}}" "failed": "설치 실패: {{message}}"
} }
}, },
"resourcepack": {
"promptHeading": "리소스팩 설치",
"promptBody": "이 음악퀴즈의 최종 리소스팩을 설치하시겠습니까? 설치 후에는 마인크래프트 내에서 직접 리소스팩을 적용해야 합니다.",
"yes": "예",
"no": "아니요",
"downloadHeading": "최종 리소스팩 다운로드",
"downloading": "최종 리소스팩을 다운로드하는 중…",
"downloadDone": "최종 리소스팩 다운로드 완료. 마인크래프트 설정 → 리소스팩에서 직접 적용해 주세요.",
"downloadFailed": "최종 리소스팩 다운로드 실패: {{message}}"
},
"step5": { "step5": {
"heading": "설치 완료", "heading": "설치 완료",
"summary": "", "summary": "",
@@ -195,10 +215,11 @@
}, },
"log": { "log": {
"manifestDownload": "manifest 다운로드: {{url}}", "manifestDownload": "manifest 다운로드: {{url}}",
"downloadRetry": "다운로드 일시 오류({{reason}}) — {{attempt}}번째 재시도, {{secs}}초 후",
"packLoadFail": "pack 로드 실패 ({{file}}): {{message}}", "packLoadFail": "pack 로드 실패 ({{file}}): {{message}}",
"packsLoaded": "로드된 음악퀴즈: {{count}}개", "packsLoaded": "로드된 음악퀴즈: {{count}}개",
"selectedPack": "선택: {{key}}", "selectedPack": "선택: {{key}}",
"jdkInstallStart": "JDK(Temurin 21) 자동 설치 시작 — 다운로드 중...", "jdkInstallStart": "JDK(Temurin {{major}}) 자동 설치 시작 — 다운로드 중...",
"jdkDownloadProgress": "JDK 다운로드: {{percent}}% ({{loaded}}MB / {{total}}MB)", "jdkDownloadProgress": "JDK 다운로드: {{percent}}% ({{loaded}}MB / {{total}}MB)",
"jdkExtracting": "JDK 압축 해제 중...", "jdkExtracting": "JDK 압축 해제 중...",
"jdkDoneRoot": "JDK 자동 설치 완료: {{path}}", "jdkDoneRoot": "JDK 자동 설치 완료: {{path}}",
@@ -220,7 +241,12 @@
"modDownload": "모드 다운로드: {{file}}", "modDownload": "모드 다운로드: {{file}}",
"skipResourcepack": "resourcepackPath가 비어 있어 리소스팩 다운로드를 건너뜁니다.", "skipResourcepack": "resourcepackPath가 비어 있어 리소스팩 다운로드를 건너뜁니다.",
"resourcepackDownload": "리소스팩 다운로드: {{url}}", "resourcepackDownload": "리소스팩 다운로드: {{url}}",
"skipBaseForFinal": "최종 리소스팩이 등록돼 있어 베이스 리소스팩은 건너뜁니다(설치 마지막에 최종 리소스팩만 받음).",
"finalResourcepackDownload": "최종 리소스팩 다운로드: {{url}}",
"finalResourcepackSaved": "최종 리소스팩 저장: {{path}}",
"serverInstallPath": "서버 설치 경로: {{path}}", "serverInstallPath": "서버 설치 경로: {{path}}",
"runBatJavaPatched": "run.bat 이 설치기가 준비한 자바를 쓰도록 수정했습니다: {{java}}",
"runBatJavaSkip": "설치기가 준비한 JDK 를 찾지 못해 run.bat 의 자바 경로는 그대로 둡니다(시스템 자바 사용).",
"mojangEulaFetchFail": "Minecraft EULA 페이지 조회 실패: {{message}}", "mojangEulaFetchFail": "Minecraft EULA 페이지 조회 실패: {{message}}",
"eulaAccepted": "EULA 동의 저장 완료.", "eulaAccepted": "EULA 동의 저장 완료.",
"configEditorOpen": "서버 설정 편집기 실행: {{url}}", "configEditorOpen": "서버 설정 편집기 실행: {{url}}",
@@ -264,9 +290,11 @@
"settingCopyFail": "설정 복사 실패 ({{name}}): {{message}}", "settingCopyFail": "설정 복사 실패 ({{name}}): {{message}}",
"settingCopySummary": "기존 마인크래프트 설정 복사: 새로 복사 {{copied}}개 / 동기화(options 류 덮어쓰기) {{synced}}개 / 보존(이미 존재) {{skipped}}개.", "settingCopySummary": "기존 마인크래프트 설정 복사: 새로 복사 {{copied}}개 / 동기화(options 류 덮어쓰기) {{synced}}개 / 보존(이미 존재) {{skipped}}개.",
"settingCopyError": "기존 설정 복사 중 오류: {{message}}", "settingCopyError": "기존 설정 복사 중 오류: {{message}}",
"runtimeDirMissing": ".minecraft/{{dir}} 가 없습니다. 마인크래프트 런처를 한 번 실행한 뒤 다시 시도해주세요.", "runtimeDirMissing": ".minecraft/{{dir}} 가 없어 공용 링크를 만들 수 없습니다(런처가 이 폴더에 직접 받게 됩니다).",
"runtimeDirCreated": ".mc_custom/{{dir}} 폴더를 새로 만들었습니다(런처가 여기에 직접 다운로드).",
"runtimeDirExists": ".mc_custom/{{dir}} 가 실제 폴더로 이미 존재 — 건너뜀.", "runtimeDirExists": ".mc_custom/{{dir}} 가 실제 폴더로 이미 존재 — 건너뜀.",
"runtimeLinkCreated": "링크 생성: .mc_custom/{{dir}} → .minecraft/{{dir}}", "runtimeLinkCreated": "링크 생성: .mc_custom/{{dir}} → .minecraft/{{dir}}",
"runtimeLinkReplaced": "불완전한 .mc_custom/{{dir}} 폴더를 .minecraft/{{dir}} 링크로 교체했습니다(바닐라 데이터 포함).",
"runtimeLinkFail": "링크 생성 실패 ({{dir}}): {{message}}", "runtimeLinkFail": "링크 생성 실패 ({{dir}}): {{message}}",
"shortcutCreated": "바로가기 생성: {{path}}", "shortcutCreated": "바로가기 생성: {{path}}",
"shortcutFailed": "바로가기 생성 실패", "shortcutFailed": "바로가기 생성 실패",

View File

@@ -30,7 +30,8 @@
"title": "관리자 로그인", "title": "관리자 로그인",
"password": "비밀번호", "password": "비밀번호",
"submit": "로그인", "submit": "로그인",
"wrongPassword": "비밀번호가 올바르지 않습니다." "wrongPassword": "비밀번호가 올바르지 않습니다.",
"tooManyAttempts": "로그인 시도가 너무 많습니다. 약 {{minutes}}분 후 다시 시도해 주세요."
}, },
"dashboard": { "dashboard": {
"title": "음악퀴즈 목록", "title": "음악퀴즈 목록",
@@ -124,6 +125,13 @@
"serverMaxRam": "서버 최대 램 (MB)", "serverMaxRam": "서버 최대 램 (MB)",
"clientMinRam": "클라이언트 최소 램 (MB)", "clientMinRam": "클라이언트 최소 램 (MB)",
"clientRecommendedRam": "클라이언트 권장 램 (MB)", "clientRecommendedRam": "클라이언트 권장 램 (MB)",
"recommendedJdk": "권장 JDK (서버 실행용)",
"recommendedJdkHint": "설치기가 이 버전을 우선 찾고, 없으면 자동 설치합니다. 서버가 요구하는 자바 버전에 맞춰 고르세요(최신 마인크래프트는 보통 Java 25). 목록은 Adoptium(Temurin)에서 실제 배포되는 버전을 불러옵니다.",
"jdkShowDetails": "자세히 보기 (모든 버전·스냅샷)",
"jdkDetailsGa": "정식(GA)",
"jdkDetailsEa": "개발 스냅샷(EA)",
"jdkDetailsNone": "상세 버전 정보를 불러오지 못했습니다.",
"jdkLtsSuffix": " (LTS)",
"mapPath": "맵 파일 (.zip)", "mapPath": "맵 파일 (.zip)",
"mapPathHint": "/file/maps/ 아래 zip 파일 이름.", "mapPathHint": "/file/maps/ 아래 zip 파일 이름.",
"serverPath": "서버 파일 (.zip)", "serverPath": "서버 파일 (.zip)",
@@ -132,6 +140,8 @@
"modsFolderHint": "/file/mods/&lt;폴더이름&gt;/ 안의 모든 .jar을 자동으로 받습니다. 비워두면 모드를 받지 않습니다.", "modsFolderHint": "/file/mods/&lt;폴더이름&gt;/ 안의 모든 .jar을 자동으로 받습니다. 비워두면 모드를 받지 않습니다.",
"resourcepackPath": "베이스 리소스팩 (.zip)", "resourcepackPath": "베이스 리소스팩 (.zip)",
"resourcepackHint": "/file/resourcepacks/ 아래 .zip 파일 이름. 리소스팩 설치기가 이 zip 위에 음악·사진을 얹어 최종 리소스팩을 만듭니다. 비워두면 처음부터 새로 만듭니다.", "resourcepackHint": "/file/resourcepacks/ 아래 .zip 파일 이름. 리소스팩 설치기가 이 zip 위에 음악·사진을 얹어 최종 리소스팩을 만듭니다. 비워두면 처음부터 새로 만듭니다.",
"finalResourcepackPath": "최종 리소스팩 (.zip)",
"finalResourcepackHint": "/file/resourcepacks/outputs/ 아래 .zip 파일 이름. 간편설치기 마지막에 사용자가 선택 설치할 수 있습니다. 없음으로 하려면 \".\" 만 입력하세요.",
"outputPackName": "생성되는 리소스팩 이름", "outputPackName": "생성되는 리소스팩 이름",
"outputPackNamePlaceholder": "예: 음악퀴즈 테스트팩", "outputPackNamePlaceholder": "예: 음악퀴즈 테스트팩",
"outputPackNameHint": "리소스팩 설치기가 만들어 내는 zip 파일 이름이자, 마인크래프트 리소스팩 목록의 제목이 됩니다. 비워두면 파일이름_resourcepack 형태로 자동 지정됩니다. Windows 파일명 금지 문자(\\ / : * ? \" &lt; &gt; |)는 자동으로 _ 로 바뀝니다.", "outputPackNameHint": "리소스팩 설치기가 만들어 내는 zip 파일 이름이자, 마인크래프트 리소스팩 목록의 제목이 됩니다. 비워두면 파일이름_resourcepack 형태로 자동 지정됩니다. Windows 파일명 금지 문자(\\ / : * ? \" &lt; &gt; |)는 자동으로 _ 로 바뀝니다.",
@@ -168,8 +178,10 @@
"visibilityHeading": "표시 대상 (중복 선택 가능)", "visibilityHeading": "표시 대상 (중복 선택 가능)",
"visibilityInstaller": "설치기에 표시", "visibilityInstaller": "설치기에 표시",
"visibilityInstallerRp": "리소스팩 설치기에 표시", "visibilityInstallerRp": "리소스팩 설치기에 표시",
"visibilityFinalResourcepack": "최종 리소스팩에 표시",
"visibilityInstallerShort": "설치기", "visibilityInstallerShort": "설치기",
"visibilityInstallerRpShort": "리소스팩", "visibilityInstallerRpShort": "리소스팩",
"visibilityFinalResourcepackShort": "최종팩",
"addHeading": "약관 추가", "addHeading": "약관 추가",
"kindLabel": "식별자", "kindLabel": "식별자",
"kindPlaceholder": "예: privacy", "kindPlaceholder": "예: privacy",

View File

@@ -1,6 +1,6 @@
{ {
"name": "minecraft-music-quiz-installer", "name": "minecraft-music-quiz-installer",
"version": "0.3.23", "version": "0.4.22",
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트", "description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
"main": "dist/installer/main.js", "main": "dist/installer/main.js",
"scripts": { "scripts": {
@@ -10,13 +10,15 @@
"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",
"installer:pf": "tsc -p tsconfig.installer-pf.json && electron dist/installer-pf/main.js", "installer:pf": "tsc -p tsconfig.installer-pf.json && electron dist/installer-pf/main.js",
"installer:uninstall": "tsc -p tsconfig.installer-uninstall.json && electron dist/installer-uninstall/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",
"build:launcher-icon": "node scripts/build-launcher-icon.cjs", "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": "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",
"dist:win:pf": "tsc -p tsconfig.installer-pf.json && electron-builder --win --config electron-builder-pf.yml", "dist:win:pf": "tsc -p tsconfig.installer-pf.json && electron-builder --win --config electron-builder-pf.yml",
"dist:win:dev": "npm run preinstall:sharp-win32 && npm run build:launcher-icon && tsc -p tsconfig.installer.json && electron-builder --win --config electron-builder-dev.yml", "dist:win:dev": "npm run preinstall:sharp-win32 && npm run build:launcher-icon && tsc -p tsconfig.installer.json && electron-builder --win --config electron-builder-dev.yml",
"dist:win:rp:dev": "npm run preinstall:sharp-win32 && tsc -p tsconfig.installer-rp.json && electron-builder --win --config electron-builder-rp-dev.yml" "dist:win:rp:dev": "npm run preinstall:sharp-win32 && tsc -p tsconfig.installer-rp.json && electron-builder --win --config electron-builder-rp-dev.yml",
"dist:win:uninstall": "tsc -p tsconfig.installer-uninstall.json && electron-builder --win --config electron-builder-uninstall.yml"
}, },
"dependencies": { "dependencies": {
"@types/archiver": "^7.0.0", "@types/archiver": "^7.0.0",

View File

@@ -17,6 +17,7 @@
var tabBtns = document.querySelectorAll('.tabBar .tabBtn') var tabBtns = document.querySelectorAll('.tabBar .tabBtn')
var visInstaller = document.getElementById('visInstaller') var visInstaller = document.getElementById('visInstaller')
var visInstallerRp = document.getElementById('visInstallerRp') var visInstallerRp = document.getElementById('visInstallerRp')
var visFinalResourcepack = document.getElementById('visFinalResourcepack')
editor.value = INITIAL || '' editor.value = INITIAL || ''
var dirty = false var dirty = false
@@ -28,6 +29,7 @@
// 토글이 바뀌어도 dirty 표시. 저장 시 함께 전송된다. // 토글이 바뀌어도 dirty 표시. 저장 시 함께 전송된다.
if (visInstaller) visInstaller.addEventListener('change', function () { setDirty(true) }) if (visInstaller) visInstaller.addEventListener('change', function () { setDirty(true) })
if (visInstallerRp) visInstallerRp.addEventListener('change', function () { setDirty(true) }) if (visInstallerRp) visInstallerRp.addEventListener('change', function () { setDirty(true) })
if (visFinalResourcepack) visFinalResourcepack.addEventListener('change', function () { setDirty(true) })
// ─── markdown 미리 보기용 미니 렌더러 ──────────────────────────────── // ─── markdown 미리 보기용 미니 렌더러 ────────────────────────────────
// 정식 markdown 파서는 아니지만, 본 편집기가 만들어 내는 형태(#, ##, ###, // 정식 markdown 파서는 아니지만, 본 편집기가 만들어 내는 형태(#, ##, ###,
@@ -171,6 +173,7 @@
var payload = { content: editor.value } var payload = { content: editor.value }
if (visInstaller) payload.showInInstaller = !!visInstaller.checked if (visInstaller) payload.showInInstaller = !!visInstaller.checked
if (visInstallerRp) payload.showInInstallerRp = !!visInstallerRp.checked if (visInstallerRp) payload.showInInstallerRp = !!visInstallerRp.checked
if (visFinalResourcepack) payload.showInFinalResourcepack = !!visFinalResourcepack.checked
fetch('/op/agreement/' + encodeURIComponent(PACK_KEY) + '/' + encodeURIComponent(TERM_KIND), { fetch('/op/agreement/' + encodeURIComponent(PACK_KEY) + '/' + encodeURIComponent(TERM_KIND), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -16,6 +16,13 @@ const localeDict = i18n.dict
let mainWindow: BrowserWindow | null = null let mainWindow: BrowserWindow | null = null
// 이 도구가 UPnP 로 직접 열어둔 포트. 앱이 살아 있는 동안 매핑을 유지하고,
// 창을 닫거나 종료할 때 이 포트의 매핑을 제거한다(사용자가 라우터에 직접 만든
// 영구 규칙으로 이미 열려 있던 preForwarded 포트는 우리 것이 아니므로 추적하지 않음).
let activePort: number | null = null
// before-quit 재진입 가드. 매핑 정리를 마친 뒤에만 실제 종료로 넘어가게 한다.
let cleanupDone = false
function createMainWindow(): void { function createMainWindow(): void {
const iconPath = path.join(__dirname, '..', '..', 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png') const iconPath = path.join(__dirname, '..', '..', 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png')
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
@@ -355,6 +362,13 @@ ipcMain.handle('pf:open', async (_event, portInput: number): Promise<PortForward
const localIp = detectLocalIpv4() const localIp = detectLocalIpv4()
if (localIp) sendLog(t('log.localIp', { ip: localIp })) if (localIp) sendLog(t('log.localIp', { ip: localIp }))
// 다른 포트를 이미 우리가 열어둔 상태에서 새 포트를 열면, 이전 포트 매핑을 먼저 닫아
// 매핑이 새는 것을 막는다.
if (activePort !== null && activePort !== port) {
await removeUpnpMapping(activePort)
activePort = null
}
// 이전에 남은 매핑을 먼저 제거해 "사용자 라우터 규칙으로 이미 열린 상태" 와 구별. // 이전에 남은 매핑을 먼저 제거해 "사용자 라우터 규칙으로 이미 열린 상태" 와 구별.
sendLog(t('log.cleanup')) sendLog(t('log.cleanup'))
await removeUpnpMapping(port) await removeUpnpMapping(port)
@@ -387,6 +401,8 @@ ipcMain.handle('pf:open', async (_event, portInput: number): Promise<PortForward
sendLog(t('log.upnpTry', { port })) sendLog(t('log.upnpTry', { port }))
try { try {
await openPortViaUpnp(port) await openPortViaUpnp(port)
// 우리가 연 포트로 기록 → 앱 종료/창 닫힘 시 자동으로 매핑 제거.
activePort = port
sendLog(t('log.upnpReqOk')) sendLog(t('log.upnpReqOk'))
} catch (error) { } catch (error) {
const msg = (error as Error).message const msg = (error as Error).message
@@ -415,6 +431,7 @@ ipcMain.handle('pf:close', async (_event, portInput: number): Promise<void> => {
const port = Number.isFinite(portInput) && portInput > 0 && portInput < 65536 ? Math.floor(portInput) : 25565 const port = Number.isFinite(portInput) && portInput > 0 && portInput < 65536 ? Math.floor(portInput) : 25565
sendLog(t('log.closeTry', { port })) sendLog(t('log.closeTry', { port }))
await removeUpnpMapping(port) await removeUpnpMapping(port)
if (activePort === port) activePort = null
}) })
ipcMain.handle('pf:quit', async () => { ipcMain.handle('pf:quit', async () => {
@@ -432,6 +449,20 @@ app.whenReady().then(() => {
}) })
}) })
// 창을 닫거나 종료할 때, 이 도구가 열어둔 UPnP 매핑을 제거한 뒤 실제 종료로 넘어간다.
// removeUpnpMapping 은 비동기라 before-quit 을 한 번 막고(cleanup) 끝나면 다시 quit 한다.
app.on('before-quit', (event) => {
if (cleanupDone || activePort === null) return
event.preventDefault()
const port = activePort
activePort = null
sendLog(t('log.closeTry', { port }))
void removeUpnpMapping(port).finally(() => {
cleanupDone = true
app.quit()
})
})
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
app.quit() app.quit()
}) })

View File

@@ -35,10 +35,16 @@ export function ytIdFromUrl(url: string): string {
* 5xx 게이트웨이 계열도 잠깐 뒤 다시 받으면 성공하는 경우가 많다. * 5xx 게이트웨이 계열도 잠깐 뒤 다시 받으면 성공하는 경우가 많다.
*/ */
const TRANSIENT_CODES = new Set([408, 425, 429, 500, 502, 503, 504]) const TRANSIENT_CODES = new Set([408, 425, 429, 500, 502, 503, 504])
const MAX_RETRIES = 5 const MAX_RETRIES = 6
/** 백오프 상한(ms). Retry-After 헤더가 비정상적으로 커도 이 이상은 기다리지 않는다. */ /** 백오프 상한(ms). Retry-After 헤더가 비정상적으로 커도 이 이상은 기다리지 않는다. */
const MAX_BACKOFF_MS = 60000 const MAX_BACKOFF_MS = 60000
/**
* 재시도가 일어날 때 호출되는 훅. 상위(main)에서 로그/진행률로 표시해, 429 백오프
* 대기 중에도 화면이 멈춘 것처럼 보이지 않게 한다.
*/
export type ImageRetryHook = (info: { attempt: number; delayMs: number; code: number | null }) => void
/** Retry-After 헤더(초 또는 HTTP-date) → 대기 ms. 못 읽으면 null. */ /** Retry-After 헤더(초 또는 HTTP-date) → 대기 ms. 못 읽으면 null. */
function parseRetryAfter(h: string | string[] | undefined): number | null { function parseRetryAfter(h: string | string[] | undefined): number | null {
if (!h) return null if (!h) return null
@@ -57,7 +63,7 @@ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms
* 429/5xx 등 일시적 오류는 지수 백오프(+jitter, Retry-After 우선)로 최대 * 429/5xx 등 일시적 오류는 지수 백오프(+jitter, Retry-After 우선)로 최대
* MAX_RETRIES 회 재시도한다. 그 외 4xx 나 재시도 소진 시 reject. * MAX_RETRIES 회 재시도한다. 그 외 4xx 나 재시도 소진 시 reject.
*/ */
function fetchBuffer(url: string, redirects = 0, attempt = 0): Promise<Buffer> { function fetchBuffer(url: string, redirects = 0, attempt = 0, onRetry?: ImageRetryHook): 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')))
@@ -65,10 +71,11 @@ function fetchBuffer(url: string, redirects = 0, attempt = 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 retryLater = (headerDelay: number | null, code: number | null): void => {
const backoff = Math.min(MAX_BACKOFF_MS, 1000 * 2 ** attempt) + Math.floor(Math.random() * 500) const backoff = Math.min(MAX_BACKOFF_MS, 1000 * 2 ** attempt) + Math.floor(Math.random() * 500)
const delay = headerDelay ?? backoff const delay = headerDelay ?? backoff
sleep(delay).then(() => fetchBuffer(url, redirects, attempt + 1).then(resolve, reject)) try { onRetry?.({ attempt: attempt + 1, delayMs: delay, code }) } catch { /* noop */ }
sleep(delay).then(() => fetchBuffer(url, redirects, attempt + 1, onRetry).then(resolve, reject))
} }
const req = lib.get(target, { const req = lib.get(target, {
timeout: 30000, timeout: 30000,
@@ -77,13 +84,13 @@ function fetchBuffer(url: string, redirects = 0, attempt = 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, attempt) fetchBuffer(new URL(res.headers.location, target).toString(), redirects + 1, attempt, onRetry)
.then(resolve, reject) .then(resolve, reject)
return return
} }
if (TRANSIENT_CODES.has(code) && attempt < MAX_RETRIES) { if (TRANSIENT_CODES.has(code) && attempt < MAX_RETRIES) {
res.resume() res.resume()
retryLater(parseRetryAfter(res.headers['retry-after'])) retryLater(parseRetryAfter(res.headers['retry-after']), code)
return return
} }
if (code !== 200) { if (code !== 200) {
@@ -98,7 +105,7 @@ function fetchBuffer(url: string, redirects = 0, attempt = 0): Promise<Buffer> {
req.on('error', (err) => { req.on('error', (err) => {
// 연결 끊김/리셋 등 네트워크 오류도 몇 번은 재시도. // 연결 끊김/리셋 등 네트워크 오류도 몇 번은 재시도.
if (attempt < MAX_RETRIES) { if (attempt < MAX_RETRIES) {
retryLater(null) retryLater(null, null)
return return
} }
reject(err) reject(err)
@@ -134,18 +141,18 @@ function decodeDataUrl(url: string): Buffer | null {
* 실패하면 `hqdefault.jpg` 로 폴백. * 실패하면 `hqdefault.jpg` 로 폴백.
* - 그 외 URL 은 HTTP GET 으로 그대로 받음. * - 그 외 URL 은 HTTP GET 으로 그대로 받음.
*/ */
export async function downloadImage(rawUrl: string): Promise<Buffer> { export async function downloadImage(rawUrl: string, onRetry?: ImageRetryHook): Promise<Buffer> {
const dataBuf = decodeDataUrl(rawUrl) const dataBuf = decodeDataUrl(rawUrl)
if (dataBuf) return dataBuf if (dataBuf) return dataBuf
const ytId = ytIdFromUrl(rawUrl) const ytId = ytIdFromUrl(rawUrl)
if (ytId) { if (ytId) {
try { try {
return await fetchBuffer(`https://i.ytimg.com/vi/${ytId}/maxresdefault.jpg`) return await fetchBuffer(`https://i.ytimg.com/vi/${ytId}/maxresdefault.jpg`, 0, 0, onRetry)
} catch { } catch {
return await fetchBuffer(`https://i.ytimg.com/vi/${ytId}/hqdefault.jpg`) return await fetchBuffer(`https://i.ytimg.com/vi/${ytId}/hqdefault.jpg`, 0, 0, onRetry)
} }
} }
return fetchBuffer(rawUrl) return fetchBuffer(rawUrl, 0, 0, onRetry)
} }
/** /**

View File

@@ -9,7 +9,7 @@ import { URL } from 'node:url'
import type { ChildProcess } from 'node:child_process' import type { ChildProcess } from 'node:child_process'
import type { Manifest, PackDefinition, PackList } from '../shared/types.js' import type { Manifest, PackDefinition, PackList } from '../shared/types.js'
import { normalizePackDefinition } from '../shared/store.js' import { normalizePackDefinition } from '../shared/store.js'
import { getAppDataDir, getMcCustomDir } from '../shared/paths.js' import { getAppDataDir, getMcCustomDir, withCustomDirName } from '../shared/paths.js'
import { loadEnv, getManifestUrl } from '../shared/env.js' import { loadEnv, getManifestUrl } from '../shared/env.js'
import { loadComponentI18n } from '../shared/i18n.js' import { loadComponentI18n } from '../shared/i18n.js'
import { resolveAudience, isPackVisibleForAudience, type Audience } from '../shared/audience.js' import { resolveAudience, isPackVisibleForAudience, type Audience } from '../shared/audience.js'
@@ -77,6 +77,13 @@ function pickMusicConcurrency(): number {
*/ */
const MUSIC_START_STAGGER_MS = 2000 const MUSIC_START_STAGGER_MS = 2000
/** 사진(썸네일) 동시 다운로드 상한. 순차 대신 병렬로 받아 속도를 높인다. 429 는 images.ts 의 지수 백오프 재시도가 흡수한다. */
const IMAGE_CONCURRENCY_CAP = 8
/** 음악(yt-dlp) 단계 직후 유튜브 IP throttle 이 남아 있을 수 있어, 사진 단계 전 아주 잠깐만 쉰다. */
const IMAGE_PHASE_COOLDOWN_MS = 1000
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
/** start-gate. 여러 worker 가 동시에 acquire 해도 직렬화되어 순차 통과. */ /** start-gate. 여러 worker 가 동시에 acquire 해도 직렬화되어 순차 통과. */
let musicStartChain: Promise<void> = Promise.resolve() let musicStartChain: Promise<void> = Promise.resolve()
let nextMusicStartAt = 0 let nextMusicStartAt = 0
@@ -273,7 +280,25 @@ ipcMain.handle('rp:packs:select', async (_event, packKey: string) => {
sendLog(t('log.selectedPack', { key: packKey })) sendLog(t('log.selectedPack', { key: packKey }))
}) })
ipcMain.handle('rp:i18n:dict', () => localeDict) // 개발자용 빌드(musicQuizAudience=developer)면 렌더러에 넘기는 사전의 제목 앞에
// "(개발자용) " 을 붙여, 창 제목/헤더에 개발자용임을 표시한다.
function dictForRenderer(): Record<string, unknown> {
// 커스텀 폴더명을 UI 문구에 반영.
const base = withCustomDirName(localeDict)
if (getAudience() !== 'developer') return base
const prefix = '(개발자용) '
const appBlock = (base.app ?? {}) as Record<string, unknown>
const title = appBlock.title
return {
...base,
app: {
...appBlock,
title: typeof title === 'string' && !title.startsWith(prefix) ? prefix + title : title
}
}
}
ipcMain.handle('rp:i18n:dict', () => dictForRenderer())
// ── IPC: 약관 다운로드 ────────────────────────────── // ── IPC: 약관 다운로드 ──────────────────────────────
// v0.3.4~ : 사이트에서 임의 kind 가 만들어질 수 있으니 5종 화이트리스트 대신 // v0.3.4~ : 사이트에서 임의 kind 가 만들어질 수 있으니 5종 화이트리스트 대신
@@ -468,11 +493,25 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
await refreshBinariesOnce() await refreshBinariesOnce()
throwIfCancelled() throwIfCancelled()
nextMusicStartAt = Date.now() nextMusicStartAt = Date.now()
// 403(Forbidden)은 대개 유튜브의 일시적 rate-limit 이라, 곡마다 점점 더 오래
// 쉬면서 여러 번 다시 시도한다(마지막 시도에서만 에러 진행률 표시).
const MAX_TRACK_RETRIES = 3
for (const i of failed) { for (const i of failed) {
throwIfCancelled() throwIfCancelled()
let ok = false
for (let attempt = 1; attempt <= MAX_TRACK_RETRIES; attempt++) {
throwIfCancelled()
if (attempt > 1) {
const waitMs = Math.min(20000, 3000 * attempt)
sendLog(t('log.musicTrackRetryWait', { idx: i + 1, attempt, secs: Math.round(waitMs / 1000) }))
await sleep(waitMs)
throwIfCancelled()
}
await acquireMusicStartSlot() await acquireMusicStartSlot()
throwIfCancelled() throwIfCancelled()
const ok = await tryDownloadTrack(i, true) ok = await tryDownloadTrack(i, attempt === MAX_TRACK_RETRIES)
if (ok) break
}
if (!ok) { if (!ok) {
throwIfCancelled() throwIfCancelled()
const idx = i + 1 const idx = i + 1
@@ -485,12 +524,27 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
const paintingDir = path.join(tempRoot, 'painting') const paintingDir = path.join(tempRoot, 'painting')
await fsp.mkdir(paintingDir, { recursive: true }) await fsp.mkdir(paintingDir, { recursive: true })
sendLog(t('log.imageStart', { total: imageTotal })) sendLog(t('log.imageStart', { total: imageTotal }))
for (let i = 0; i < imageTotal; i++) { // 음악(yt-dlp) 단계에서 유튜브를 많이 두드렸다면 IP throttle 이 남아 사진 첫 장부터
throwIfCancelled() // 429 가 날 수 있다. 다운로드할 사진이 실제로 있고 직전에 음악을 받았다면 잠깐 쉰다.
const entry = pack.list.images[i] if (imageTotal > 0 && musicTotal > 0) {
sendLog(t('log.imageCooldown', { secs: Math.round(IMAGE_PHASE_COOLDOWN_MS / 1000) }))
await sleep(IMAGE_PHASE_COOLDOWN_MS)
}
// 여러 장을 동시에 받아 속도를 높인다(순차+딜레이 대신 워커 풀). 유튜브 429 는
// 각 다운로드의 지수 백오프 재시도(images.ts)가 흡수하고, 첫 하드 실패를 만나면
// 새 작업 배정을 멈춘 뒤 워커가 모두 끝나면 그 오류를 던진다.
const images = pack.list.images
let imageNext = 0
let imageError: Error | null = null
async function imageWorker(): Promise<void> {
while (true) {
if (state.cancelRequested || imageError) return
const i = imageNext++
if (i >= imageTotal) return
const idx = i + 1 const idx = i + 1
// 이전 시도에서 이미 정규화해둔 사진은 건너뛴다(이어받기). const entry = images[i]
const coverPath = path.join(paintingDir, coverFileName(idx)) const coverPath = path.join(paintingDir, coverFileName(idx))
// 이전 시도에서 이미 정규화해둔 사진은 건너뛴다(이어받기).
if (await fileExists(coverPath)) { if (await fileExists(coverPath)) {
sendLog(t('log.imageSkip', { idx })) sendLog(t('log.imageSkip', { idx }))
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' }) sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' })
@@ -500,27 +554,47 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
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, (info) => {
const secs = Math.ceil(info.delayMs / 1000)
sendLog(t('log.imageRetry', { idx, attempt: info.attempt, code: info.code ?? '-', secs }))
sendProgress({
phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running',
message: t('progress.imageRetry', { code: info.code ?? '-', secs })
})
})
} catch (err) { } catch (err) {
// 부분 생성됐을 수 있는 커버 파일 제거(이어받기 시 완성본 오인 방지). // 부분 생성됐을 수 있는 커버 파일 제거(이어받기 시 완성본 오인 방지).
await fsp.rm(coverPath, { force: true }).catch(() => {}) 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 }) const rawMsg = (err as Error).message
throw new Error(t('errors.imageDownloadFailed', { idx, message: (err as Error).message })) // 429(rate limit)는 유튜브가 IP 를 일시 차단한 것. 잠시 뒤 다시 시도하면
// 이미 받은 사진은 건너뛰고 이어받는다는 안내를 덧붙인다.
const msg = /429/.test(rawMsg) ? `${rawMsg}${t('errors.imageRateLimitHint')}` : rawMsg
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: msg })
if (!imageError) imageError = new Error(t('errors.imageDownloadFailed', { idx, message: msg }))
return
} }
throwIfCancelled() if (state.cancelRequested || imageError) return
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 = coverPath
try { try {
await normalizeToCover(buf, outPath) await normalizeToCover(buf, coverPath)
} catch (err) { } catch (err) {
// 변환 중 부분 생성된 PNG 제거(이어받기 시 완성본 오인 방지). // 변환 중 부분 생성된 PNG 제거(이어받기 시 완성본 오인 방지).
await fsp.rm(coverPath, { force: true }).catch(() => {}) 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 })) if (!imageError) imageError = new Error(t('errors.imageNormalizeFailed', { idx, message: (err as Error).message }))
return
} }
sendLog(t('log.imageDone', { idx, name: path.basename(outPath) })) sendLog(t('log.imageDone', { idx, name: path.basename(coverPath) }))
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' }) sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' })
} }
}
const imageWorkerCount = Math.min(IMAGE_CONCURRENCY_CAP, Math.max(1, imageTotal), Math.max(concurrency, 4))
const imageWorkers: Promise<void>[] = []
for (let w = 0; w < imageWorkerCount; w++) imageWorkers.push(imageWorker())
await Promise.all(imageWorkers)
throwIfCancelled()
if (imageError) throw imageError
// 2-4. 베이스 리소스팩 다운로드 (있을 때만) // 2-4. 베이스 리소스팩 다운로드 (있을 때만)
throwIfCancelled() throwIfCancelled()
@@ -642,8 +716,37 @@ app.whenReady().then(() => {
}) })
}) })
// 종료 시 임시 파일(.temp) 정리. 예전엔 window-all-closed 에서 fsp.rm 을 fire-and-forget
// 으로 호출하고 곧바로 app.quit() 해서, 삭제가 끝나기 전에 프로세스가 죽어 미리 받아둔
// 내용이 남곤 했다. 또 '재시도 중' 이면 yt-dlp/ffmpeg 자식이 파일을 잠가 삭제가 실패한다.
// → 자식을 먼저 죽여 잠금을 풀고, 삭제가 끝날 때까지 종료를 미룬 뒤 실제로 종료한다.
let rpExitCleanupDone = false
async function cleanupTempOnExit(): Promise<void> {
state.cancelRequested = true
for (const child of state.activeChildren) {
try { if (!child.killed) child.kill() } catch { /* noop */ }
}
const tempDir = path.join(getMcCustomDir(), '.temp')
// 자식 종료로 파일 잠금이 풀릴 시간을 주며 몇 번 재시도.
for (let attempt = 0; attempt < 3; attempt++) {
await new Promise((r) => setTimeout(r, attempt === 0 ? 200 : 400))
try {
await fsp.rm(tempDir, { recursive: true, force: true })
if (!fs.existsSync(tempDir)) return
} catch { /* 잠금 등 — 다음 시도 */ }
}
}
app.on('before-quit', (event) => {
if (rpExitCleanupDone) return
event.preventDefault()
void cleanupTempOnExit().finally(() => {
rpExitCleanupDone = true
app.quit()
})
})
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
// 강제 종료 시에도 임시 파일 정리. // app.quit() → before-quit 에서 임시 파일 정리한 뒤 실제 종료.
fsp.rm(path.join(getMcCustomDir(), '.temp'), { recursive: true, force: true }).catch(() => {})
if (process.platform !== 'darwin') app.quit() if (process.platform !== 'darwin') app.quit()
}) })

View File

@@ -38,6 +38,17 @@ export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string>
// 단일 파일이 아니라 HLS/DASH fragmented 스트림일 때 청크를 병렬로. // 단일 파일이 아니라 HLS/DASH fragmented 스트림일 때 청크를 병렬로.
// 일반 progressive 다운로드에는 영향 없음. // 일반 progressive 다운로드에는 영향 없음.
'--concurrent-fragments', '5', '--concurrent-fragments', '5',
// 유튜브가 미디어 스트림에 간헐적으로 403(Forbidden) 을 준다(특히 두 번째 곡부터
// throttle/클라이언트 이슈). yt-dlp 가 스스로 재시도·재추출하도록 해서 한 번의
// 403 으로 곡 전체가 실패하지 않게 한다.
'--retries', '10',
'--fragment-retries', '10',
'--extractor-retries', '3',
'--retry-sleep', 'http:exp=1:30',
'--socket-timeout', '30',
// 특정 플레이어 클라이언트(예: android_vr)의 스트림 URL 이 403 되는 경우가 있어
// 여러 클라이언트를 함께 시도한다. 하나가 막혀도 다른 클라이언트 포맷으로 받는다.
'--extractor-args', 'youtube:player_client=default,web_safari,tv,ios',
// 진행률 표시 안정화 (yt-dlp 가 \r 대신 새 줄로 출력). // 진행률 표시 안정화 (yt-dlp 가 \r 대신 새 줄로 출력).
'--newline', '--newline',
'--extract-audio', '--extract-audio',

View File

@@ -0,0 +1,224 @@
import { app, BrowserWindow, ipcMain, shell } from 'electron'
import path from 'node:path'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import { loadEnv } from '../shared/env.js'
import { getAppDataDir, getMcCustomDir } from '../shared/paths.js'
import { loadComponentI18n } from '../shared/i18n.js'
// 음악퀴즈 파일제거 도구. 음악퀴즈 간편설치기 / 리소스팩설치기가 만든 데이터를
// 한 번에 정리한다. (설치기 exe 자체는 사용자가 임의 위치에 둔 포터블이라
// 위치를 알 수 없어 삭제 대상에서 제외)
loadEnv()
const i18n = loadComponentI18n('installer-uninstall')
const t = i18n.t
const localeDict = i18n.dict
let mainWindow: BrowserWindow | null = null
function createMainWindow(): void {
const iconPath = path.join(__dirname, '..', '..', 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png')
mainWindow = new BrowserWindow({
width: 720,
height: 620,
icon: iconPath,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
})
mainWindow.removeMenu()
void mainWindow.loadFile(path.join(__dirname, '..', '..', 'installer-uninstall', 'index.html'))
}
function sendLog(line: string): void {
if (!mainWindow || mainWindow.isDestroyed()) return
const stamped = `[${new Date().toLocaleTimeString('ko-KR', { hour12: false })}] ${line}`
mainWindow.webContents.send('log', stamped)
}
/** 마인크래프트 런처 프로필 파일 경로. */
function launcherProfilesPath(): string {
return path.join(getAppDataDir(), '.minecraft', 'launcher_profiles.json')
}
/** 데스크톱에 설치기가 만든 서버 실행 바로가기 경로. */
function serverShortcutPath(): string {
return path.join(app.getPath('desktop'), 'MusicQuiz Server.lnk')
}
/** 기본 폴더 이름(.mc_custom) 경로. MC_CUSTOM_DIR 을 바꿔 빌드해도, 예전 기본 폴더가 남아 있을 수 있으므로 함께 지운다. */
function defaultCustomDir(): string {
return path.join(getAppDataDir(), '.mc_custom')
}
/** 삭제 대상 커스텀 폴더 후보(현재 설정값 + 기본 .mc_custom)를 대소문자 무시로 중복 제거. */
function targetCustomDirs(): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const dir of [getMcCustomDir(), defaultCustomDir()]) {
const key = path.resolve(dir).toLowerCase()
if (!seen.has(key)) {
seen.add(key)
out.push(dir)
}
}
return out
}
/** 두 경로가 같은 폴더거나, a 가 b 하위인지(대소문자 무시 — Windows 파일계). */
function isSameOrInside(child: string, parent: string): boolean {
const c = path.resolve(child).replace(/[\\/]+$/, '').toLowerCase()
const p = path.resolve(parent).replace(/[\\/]+$/, '').toLowerCase()
return c === p || c.startsWith(p + path.sep.toLowerCase()) || c.startsWith(p + '/')
}
interface UninstallPreview {
existingDirs: string[]
allTargetDirs: string[]
shortcutExists: boolean
launcherProfiles: string[]
}
/** 삭제 전 미리보기: 실제로 존재하는 대상만 추려 렌더러에 보여준다. */
ipcMain.handle('uninstall:preview', async (): Promise<UninstallPreview> => {
const allTargetDirs = targetCustomDirs()
const existingDirs = allTargetDirs.filter((d) => fs.existsSync(d))
const shortcutExists = fs.existsSync(serverShortcutPath())
const launcherProfiles = findMusicQuizProfiles(allTargetDirs)
return { existingDirs, allTargetDirs, shortcutExists, launcherProfiles }
})
/** launcher_profiles.json 에서 gameDir 가 커스텀 폴더(또는 그 하위)인 프로필 이름 목록. */
function findMusicQuizProfiles(customDirs: string[]): string[] {
const file = launcherProfilesPath()
if (!fs.existsSync(file)) return []
try {
const json = JSON.parse(fs.readFileSync(file, 'utf8')) as {
profiles?: Record<string, { name?: string; gameDir?: string }>
}
const profiles = json.profiles ?? {}
const names: string[] = []
for (const [key, prof] of Object.entries(profiles)) {
const gameDir = typeof prof?.gameDir === 'string' ? prof.gameDir : ''
if (gameDir && customDirs.some((d) => isSameOrInside(gameDir, d))) {
names.push(typeof prof?.name === 'string' && prof.name ? prof.name : key)
}
}
return names
} catch {
return []
}
}
interface UninstallResult {
removed: string[]
profilesRemoved: string[]
errors: string[]
}
/** 하나의 파일/폴더를 mode 에 따라 휴지통 이동 또는 완전 삭제. */
async function removeOne(target: string, mode: 'trash' | 'permanent'): Promise<void> {
if (mode === 'trash') {
await shell.trashItem(target)
} else {
await fsp.rm(target, { recursive: true, force: true })
}
}
/** launcher_profiles.json 에서 음악퀴즈 프로필만 제거하고 나머지는 보존. */
async function cleanLauncherProfiles(customDirs: string[]): Promise<string[]> {
const file = launcherProfilesPath()
if (!fs.existsSync(file)) return []
let json: { profiles?: Record<string, { name?: string; gameDir?: string }> }
try {
json = JSON.parse(await fsp.readFile(file, 'utf8'))
} catch {
sendLog(t('log.launcherParseFail', { path: file }))
return []
}
const profiles = json.profiles ?? {}
const removed: string[] = []
for (const [key, prof] of Object.entries(profiles)) {
const gameDir = typeof prof?.gameDir === 'string' ? prof.gameDir : ''
if (gameDir && customDirs.some((d) => isSameOrInside(gameDir, d))) {
removed.push(typeof prof?.name === 'string' && prof.name ? prof.name : key)
delete profiles[key]
}
}
if (removed.length > 0) {
json.profiles = profiles
await fsp.writeFile(file, `${JSON.stringify(json, null, 2)}\n`, 'utf8')
}
return removed
}
ipcMain.handle('uninstall:run', async (_event, modeInput: unknown): Promise<UninstallResult> => {
const mode: 'trash' | 'permanent' = modeInput === 'permanent' ? 'permanent' : 'trash'
const customDirs = targetCustomDirs()
const removed: string[] = []
const errors: string[] = []
sendLog(t('log.start', { mode: t(mode === 'trash' ? 'mode.trash' : 'mode.permanent') }))
// 1) 커스텀 게임/캐시 폴더 통째로(현재 설정값 + 기본 .mc_custom).
for (const customDir of customDirs) {
if (fs.existsSync(customDir)) {
try {
await removeOne(customDir, mode)
removed.push(customDir)
sendLog(t('log.removedDir', { path: customDir }))
} catch (err) {
const msg = (err as Error).message
errors.push(`${customDir}: ${msg}`)
sendLog(t('log.removeFail', { path: customDir, message: msg }))
}
} else {
sendLog(t('log.customDirMissing', { path: customDir }))
}
}
// 2) 데스크톱 서버 실행 바로가기.
const shortcut = serverShortcutPath()
if (fs.existsSync(shortcut)) {
try {
await removeOne(shortcut, mode)
removed.push(shortcut)
sendLog(t('log.removedShortcut', { path: shortcut }))
} catch (err) {
const msg = (err as Error).message
errors.push(`${shortcut}: ${msg}`)
sendLog(t('log.removeFail', { path: shortcut, message: msg }))
}
}
// 3) 마인크래프트 런처 프로필에서 음악퀴즈 설정 제거.
let profilesRemoved: string[] = []
try {
profilesRemoved = await cleanLauncherProfiles(customDirs)
for (const name of profilesRemoved) sendLog(t('log.removedProfile', { name }))
} catch (err) {
const msg = (err as Error).message
errors.push(`launcher_profiles.json: ${msg}`)
sendLog(t('log.launcherWriteFail', { message: msg }))
}
sendLog(t('log.done', { count: removed.length + profilesRemoved.length }))
return { removed, profilesRemoved, errors }
})
ipcMain.handle('uninstall:i18n:dict', () => localeDict)
ipcMain.handle('uninstall:quit', () => app.quit())
app.whenReady().then(() => {
createMainWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
})
})
app.on('window-all-closed', () => {
app.quit()
})

View File

@@ -0,0 +1,43 @@
import { contextBridge, ipcRenderer } from 'electron'
interface UninstallPreview {
customDir: string
customDirExists: boolean
shortcutExists: boolean
launcherProfiles: string[]
}
interface UninstallResult {
removed: string[]
profilesRemoved: string[]
errors: string[]
}
const api = {
/** i18n 사전을 렌더러에 전달. */
loadLocale: (): Promise<Record<string, unknown>> => ipcRenderer.invoke('uninstall:i18n:dict'),
/** 삭제 전, 실제로 존재하는 대상 미리보기. */
preview: (): Promise<UninstallPreview> => ipcRenderer.invoke('uninstall:preview'),
/** 삭제 실행. mode: 'trash'(휴지통) | 'permanent'(완전 삭제). */
run: (mode: 'trash' | 'permanent'): Promise<UninstallResult> => ipcRenderer.invoke('uninstall:run', mode),
/** 프로그램 종료. */
quit: (): Promise<void> => ipcRenderer.invoke('uninstall:quit'),
/** 로그 스트림 구독. */
onLog: (handler: (line: string) => void): (() => void) => {
const listener = (_event: unknown, line: string) => handler(line)
ipcRenderer.on('log', listener)
return () => ipcRenderer.removeListener('log', listener)
}
}
contextBridge.exposeInMainWorld('uninstaller', api)
declare global {
interface Window {
uninstaller: typeof api
}
}

View File

@@ -6,7 +6,7 @@ import os from 'node:os'
import path from 'node:path' import path from 'node:path'
import fs from 'node:fs' import fs from 'node:fs'
import fsp from 'node:fs/promises' import fsp from 'node:fs/promises'
import { spawn } from 'node:child_process' import { spawn, spawnSync } from 'node:child_process'
import { URL } from 'node:url' import { URL } from 'node:url'
import natUpnp from 'nat-upnp' import natUpnp from 'nat-upnp'
// extract-zip은 CommonJS 기본 export. // extract-zip은 CommonJS 기본 export.
@@ -19,7 +19,8 @@ import type {
ServerInstallPayload ServerInstallPayload
} from './types.js' } from './types.js'
import type { Manifest, PackDefinition } from '../shared/types.js' import type { Manifest, PackDefinition } from '../shared/types.js'
import { normalizePackDefinition } from '../shared/store.js' import { normalizePackDefinition, normalizeRecommendedJdk } from '../shared/store.js'
import { getMcCustomDirName, withCustomDirName } from '../shared/paths.js'
import { loadEnv, getManifestUrl } from '../shared/env.js' import { loadEnv, getManifestUrl } from '../shared/env.js'
import { loadComponentI18n } from '../shared/i18n.js' import { loadComponentI18n } from '../shared/i18n.js'
import { resolveAudience, isPackVisibleForAudience, type Audience } from '../shared/audience.js' import { resolveAudience, isPackVisibleForAudience, type Audience } from '../shared/audience.js'
@@ -108,29 +109,61 @@ process.on('unhandledRejection', (reason) => {
try { sendLog(t('log.internalError', { message: reason instanceof Error ? reason.message : String(reason) })) } catch {} try { sendLog(t('log.internalError', { message: reason instanceof Error ? reason.message : String(reason) })) } catch {}
}) })
function fetchBuffer(url: string): Promise<Buffer> { // 너무 빠르게/자주 요청해 생기는 일시적 오류(429 Too Many Requests, 5xx, 네트워크
// 끊김/타임아웃 등)는 잠깐 쉬고 몇 번 다시 시도한다. 403 도 일부 CDN 이 rate-limit 에
// 쓰므로 포함. 그 외 4xx(404 등)나 재시도 소진 시에는 그대로 실패.
const DOWNLOAD_TRANSIENT_CODES = new Set([403, 408, 425, 429, 500, 502, 503, 504])
const DOWNLOAD_MAX_RETRIES = 3
const DOWNLOAD_MAX_BACKOFF_MS = 30000
/** Retry-After 헤더(초 또는 HTTP-date) → 대기 ms. 못 읽으면 null. */
function parseRetryAfterMs(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(DOWNLOAD_MAX_BACKOFF_MS, Math.max(0, secs * 1000))
const date = Date.parse(v)
if (!Number.isNaN(date)) return Math.min(DOWNLOAD_MAX_BACKOFF_MS, Math.max(0, date - Date.now()))
return null
}
function fetchBuffer(url: string, redirects = 0, attempt = 0): Promise<Buffer> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const target = new URL(url) const target = new URL(url)
const transport = target.protocol === 'https:' ? https : http const transport = target.protocol === 'https:' ? https : http
const retryLater = (headerDelay: number | null, reason: string): void => {
const backoff = Math.min(DOWNLOAD_MAX_BACKOFF_MS, 1000 * 2 ** attempt) + Math.floor(Math.random() * 500)
const delay = headerDelay ?? backoff
sendLog(t('log.downloadRetry', { reason, attempt: attempt + 1, secs: Math.ceil(delay / 1000) }))
sleep(delay).then(() => fetchBuffer(url, redirects, attempt + 1).then(resolve, reject))
}
const request = transport.get(target, { timeout: 30000 }, (response) => { const request = transport.get(target, { timeout: 30000 }, (response) => {
if (response.statusCode === 301 || response.statusCode === 302) { const code = response.statusCode ?? 0
const redirect = response.headers.location if ((code === 301 || code === 302 || code === 303 || code === 307 || code === 308) && response.headers.location) {
if (redirect) {
response.resume() response.resume()
fetchBuffer(new URL(redirect, target).toString()).then(resolve, reject) if (redirects > 8) { reject(new Error(`HTTP ${code} (too many redirects)`)); return }
fetchBuffer(new URL(response.headers.location, target).toString(), redirects + 1, attempt).then(resolve, reject)
return return
} }
} if (DOWNLOAD_TRANSIENT_CODES.has(code) && attempt < DOWNLOAD_MAX_RETRIES) {
if ((response.statusCode ?? 0) >= 400) {
response.resume() response.resume()
reject(new Error(`HTTP ${response.statusCode}`)) retryLater(parseRetryAfterMs(response.headers['retry-after']), `HTTP ${code}`)
return
}
if (code >= 400) {
response.resume()
reject(new Error(`HTTP ${code}`))
return return
} }
const chunks: Buffer[] = [] const chunks: Buffer[] = []
response.on('data', (chunk: Buffer) => chunks.push(chunk)) response.on('data', (chunk: Buffer) => chunks.push(chunk))
response.on('end', () => resolve(Buffer.concat(chunks))) response.on('end', () => resolve(Buffer.concat(chunks)))
}) })
request.on('error', reject) request.on('error', (err) => {
// 연결 끊김/리셋 등 네트워크 오류도 몇 번은 재시도.
if (attempt < DOWNLOAD_MAX_RETRIES) { retryLater(null, (err as Error).message); return }
reject(err)
})
request.on('timeout', () => request.destroy(new Error(t('errors.requestTimeout')))) request.on('timeout', () => request.destroy(new Error(t('errors.requestTimeout'))))
}) })
} }
@@ -222,6 +255,65 @@ ipcMain.handle('terms:list', async (): Promise<{ ok: boolean; terms?: Array<{ ki
} }
}) })
// 최종 리소스팩 다운로드용 약관 목록. index.json 에서 showInFinalResourcepack=true 만 추린다.
ipcMain.handle('terms:listFinal', 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.showInFinalResourcepack !== 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 }
}
})
// 최종 리소스팩(zip)을 /file/resourcepacks/outputs/ 에서 받아 .mc_custom/resourcepacks/ 에 저장.
ipcMain.handle('finalResourcepack:install', async (): Promise<{ ok: boolean; path?: string; message?: string }> => {
const pack = state.selectedKey ? state.packs.get(state.selectedKey) : undefined
if (!pack) return { ok: false, message: t('errors.packNotFound2') }
const finalName = pack.pack.finalResourcepackPath
if (!finalName || finalName === '.') return { ok: false, message: 'no final resourcepack' }
let dest = ''
try {
const cleaned = path.basename(finalName.replace(/^\/+/, ''))
const url = `${state.baseUrl}/file/resourcepacks/outputs/${encodeURIComponent(cleaned)}`
const destDir = path.join(getAppDataDir(), getMcCustomDirName(), 'resourcepacks')
dest = path.join(destDir, cleaned)
await fsp.mkdir(destDir, { recursive: true })
sendLog(t('log.finalResourcepackDownload', { url }))
// 진행률(퍼센트)을 렌더러로 흘려보내며 스트리밍 다운로드.
let lastPct = -1
const controller = new AbortController()
await downloadStream(url, dest, controller.signal, (loaded, total) => {
const percent = total > 0 ? Math.min(100, Math.max(0, Math.floor((loaded / total) * 100))) : -1
if (percent !== lastPct) {
lastPct = percent
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('finalResourcepack:progress', { loaded, total, percent })
}
}
})
sendLog(t('log.finalResourcepackSaved', { path: dest }))
return { ok: true, path: dest }
} catch (error) {
// 실패 시 부분/0바이트 zip 이 마인크래프트 리소스팩 목록에 깨진 채로 남지 않도록 삭제.
if (dest) await fsp.rm(dest, { force: true }).catch(() => {})
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'))
@@ -251,43 +343,186 @@ ipcMain.handle('install:validatePath', async (_event, target: string) => {
return { ok: true, message: absolute } return { ok: true, message: absolute }
}) })
ipcMain.handle('jdk:detect', async () => { // 권장 JDK 메이저는 사이트의 pack.recommendedJdk 로 결정된다(미지정/이상값은 기본값 25).
const candidates: string[] = [] // 최신 마인크래프트 서버 jar 이 "class file version 69.0"(=Java 25)처럼 특정 자바 버전을
if (process.env.JAVA_HOME) candidates.push(process.env.JAVA_HOME) // 요구하며, 그보다 낮은 자바로 실행하면 UnsupportedClassVersionError 로 서버가 뜨지 않는다.
if (process.env.JDK_HOME) candidates.push(process.env.JDK_HOME) /** 자동 설치 폴더 이름(예: temurin-25). */
// 자동 설치 위치(우리 설치기가 만든 JDK)도 후보에 포함. function jdkDirName(major: number): string {
candidates.push(path.join(getAppDataDir(), 'jdk', 'temurin-21')) return `temurin-${major}`
candidates.push('C:\\Program Files\\Java') }
for (const candidate of candidates) { /**
if (!candidate) continue * java 실행 파일의 메이저 버전을 조회한다(`java -version` 은 stderr 로 출력).
* 'openjdk version "25.0.3"' → 25, '"1.8.0_xx"' → 8.
* 실행 실패/파싱 실패 시 0.
*/
function getJavaMajor(javaExe: string): number {
try { try {
const stat = await fsp.stat(candidate) const res = spawnSync(javaExe, ['-version'], { encoding: 'utf8', timeout: 8000 })
if (stat.isFile()) { const out = `${res.stdout || ''}${res.stderr || ''}`
return { found: true, path: candidate } const m = out.match(/version\s+"(\d+)(?:\.(\d+))?/i)
} if (!m) return 0
if (stat.isDirectory()) { let major = parseInt(m[1], 10)
const javaExe = path.join(candidate, 'bin', process.platform === 'win32' ? 'java.exe' : 'java') if (major === 1 && m[2]) major = parseInt(m[2], 10) // 1.8 → 8
if (fs.existsSync(javaExe)) { return Number.isNaN(major) ? 0 : major
return { found: true, path: candidate } } catch {
} return 0
const entries = await fsp.readdir(candidate)
for (const entry of entries) {
const child = path.join(candidate, entry)
const childJava = path.join(child, 'bin', process.platform === 'win32' ? 'java.exe' : 'java')
if (fs.existsSync(childJava)) {
return { found: true, path: child }
}
} }
}
/** 후보 JDK 홈에서 java 실행 파일 경로를 찾는다(홈 직하 또는 한 단계 감싼 jdk-* 하위까지). */
function findJavaExeInHome(candidate: string): string {
const javaName = process.platform === 'win32' ? 'java.exe' : 'java'
try {
const stat = fs.statSync(candidate)
if (stat.isFile()) return candidate
const direct = path.join(candidate, 'bin', javaName)
if (fs.existsSync(direct)) return direct
for (const entry of fs.readdirSync(candidate)) {
const childJava = path.join(candidate, entry, 'bin', javaName)
if (fs.existsSync(childJava)) return childJava
} }
} catch { } catch {
continue return ''
}
return ''
}
/** %appdata%/<.mc_custom>. 자동 설치 JDK 는 이 폴더 아래 jdk/ 에 둔다. */
function customRootDir(): string {
return path.join(getAppDataDir(), getMcCustomDirName())
}
/** 설치기 자동 설치 JDK 의 루트(.mc_custom/jdk). */
function installerJdkRoot(): string {
return path.join(customRootDir(), 'jdk')
}
/** dir(또는 그 한 단계 하위 jdk-* 폴더)에서 java 를 찾아 실제 JDK 홈(<home>/bin/java)을 돌려준다. 없으면 ''. */
function resolveJdkHome(dir: string): string {
const javaExe = findJavaExeInHome(dir)
if (!javaExe) return ''
// findJavaExeInHome 은 항상 <home>/bin/java(.exe) 를 돌려준다 → 상위 두 단계가 홈.
return path.basename(path.dirname(javaExe)) === 'bin' ? path.dirname(path.dirname(javaExe)) : dir
}
/**
* parent 아래에서 JDK 홈들을 모두 찾는다. parent 자신, 그리고 각 하위 폴더를 검사하되,
* Adoptium zip 처럼 한 단계 더 감싼 `jdk-*` 하위까지 resolveJdkHome 으로 풀어낸다.
* (예: .mc_custom/jdk/temurin-25/jdk-25.0.3+9/bin/java.exe)
*/
function jdkHomesUnder(parent: string): string[] {
const out: string[] = []
const add = (h: string): void => { if (h) out.push(h) }
add(resolveJdkHome(parent))
try {
for (const entry of fs.readdirSync(parent)) {
add(resolveJdkHome(path.join(parent, entry)))
}
} catch {
/* 폴더 없음 등 무시 */
}
return out
}
interface JdkEntry { home: string; major: number }
/** 홈 목록 → {home, major} 목록(중복/버전0 제거). */
function homesToEntries(homes: string[]): JdkEntry[] {
const javaName = process.platform === 'win32' ? 'java.exe' : 'java'
const seen = new Set<string>()
const out: JdkEntry[] = []
for (const home of homes) {
const key = home.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
const major = getJavaMajor(path.join(home, 'bin', javaName))
if (major > 0) out.push({ home, major })
}
return out
}
/** 설치기 자동 설치 위치(.mc_custom/jdk, 구버전 %APPDATA%/jdk 호환)의 JDK 들. */
function mcCustomJdkEntries(): JdkEntry[] {
const homes: string[] = []
for (const base of [installerJdkRoot(), path.join(getAppDataDir(), 'jdk')]) {
homes.push(...jdkHomesUnder(base))
}
return homesToEntries(homes)
}
/** 환경변수(JAVA_HOME/JDK_HOME)에 등록된 JDK 들. */
function envJdkEntries(): JdkEntry[] {
const homes: string[] = []
for (const env of [process.env.JAVA_HOME, process.env.JDK_HOME]) {
if (env) {
const h = resolveJdkHome(env)
if (h) homes.push(h)
} }
} }
return { found: false, path: '' } return homesToEntries(homes)
}
/** 기본 자바 설치 위치 C:\Program Files\Java 아래의 JDK 들. */
function programFilesJdkEntries(): JdkEntry[] {
return homesToEntries(jdkHomesUnder('C:\\Program Files\\Java'))
}
// JDK 탐색 우선순위(요청 사양):
// ① .mc_custom/jdk(설치기가 설치해둔 위치)가 있으면 먼저 사용 — 권장 버전이면 그대로,
// 혹시 다른 버전이면 경고(match=false).
// ② 환경변수(JAVA_HOME/JDK_HOME) 가 권장 버전이면 사용.
// ③ 기본 폴더 C:\Program Files\Java 에 권장 버전이 있으면 사용.
// ④ 없으면 환경변수 자바로 폴백하고 "권장과 다름" 경고(match=false).
// (환경변수 자바도 없으면 Program Files 자바로 폴백, 그것도 없으면 not found → 자동 설치 유도.)
ipcMain.handle('jdk:detect', async (_event, recommendedInput?: number) => {
const required = normalizeRecommendedJdk(recommendedInput)
const recommended = (entries: JdkEntry[]): JdkEntry | undefined => entries.find((e) => e.major === required)
const best = (entries: JdkEntry[]): JdkEntry => entries.slice().sort((a, b) => b.major - a.major)[0]
// ① .mc_custom/jdk 우선.
const mc = mcCustomJdkEntries()
if (mc.length > 0) {
const rec = recommended(mc)
if (rec) return { found: true, path: rec.home, major: required, match: true }
const b = best(mc)
return { found: true, path: b.home, major: b.major, match: false }
}
// ② 환경변수 JDK 가 권장 버전이면 사용.
const env = envJdkEntries()
const envRec = recommended(env)
if (envRec) return { found: true, path: envRec.home, major: required, match: true }
// ③ 기본 폴더(Program Files\Java)에 권장 버전이 있으면 사용.
const pf = programFilesJdkEntries()
const pfRec = recommended(pf)
if (pfRec) return { found: true, path: pfRec.home, major: required, match: true }
// ④ 폴백: 환경변수 자바 → (없으면) Program Files 자바 + 경고.
if (env.length > 0) {
const b = best(env)
return { found: true, path: b.home, major: b.major, match: false }
}
if (pf.length > 0) {
const b = best(pf)
return { found: true, path: b.home, major: b.major, match: false }
}
return { found: false, path: '', major: required, match: false }
}) })
// ── JDK 자동 설치(Temurin 21, 취소 가능) ────────────────────────────── // 사용자가 직접 입력/선택한 JDK 경로의 자바 버전을 확인한다.
// major=0 → 경로에서 java 를 못 찾거나 버전을 못 읽음(진행 차단용).
// match=true → 권장 버전과 정확히 일치.
// match=false → 다른 버전(진행은 허용하되 렌더러가 경고를 띄운다).
ipcMain.handle('jdk:verify', async (_event, jdkPath: string, recommendedInput?: number): Promise<{ ok: boolean; major: number; required: number; match: boolean }> => {
const required = normalizeRecommendedJdk(recommendedInput)
const javaExe = jdkPath ? findJavaExeInHome(jdkPath) : ''
const major = javaExe ? getJavaMajor(javaExe) : 0
return { ok: major > 0, major, required, match: major === required }
})
// ── JDK 자동 설치(권장 버전 Temurin, .mc_custom/jdk 에 설치, 취소 가능) ──────
interface JdkInstallState { interface JdkInstallState {
controller: AbortController | null controller: AbortController | null
destDir: string | null destDir: string | null
@@ -361,22 +596,24 @@ function downloadStream(
}) })
} }
ipcMain.handle('jdk:install', async (): Promise<{ ok: boolean; path?: string; message?: string }> => { ipcMain.handle('jdk:install', async (_event, recommendedInput?: number): Promise<{ ok: boolean; path?: string; message?: string }> => {
if (jdkInstall.inProgress) { if (jdkInstall.inProgress) {
return { ok: false, message: t('errors.jdkBusy') } return { ok: false, message: t('errors.jdkBusy') }
} }
const required = normalizeRecommendedJdk(recommendedInput)
jdkInstall.inProgress = true jdkInstall.inProgress = true
const controller = new AbortController() const controller = new AbortController()
jdkInstall.controller = controller jdkInstall.controller = controller
const tmpRoot = path.join(getAppDataDir(), 'jdk-cache') // 자동 설치 JDK 는 .mc_custom/jdk 아래에 둔다(파일제거기가 .mc_custom 통째로 정리 가능).
const tmpRoot = path.join(installerJdkRoot(), '.cache')
await fsp.mkdir(tmpRoot, { recursive: true }) await fsp.mkdir(tmpRoot, { recursive: true })
const tempZip = path.join(tmpRoot, `temurin-21-${Date.now()}.zip`) const tempZip = path.join(tmpRoot, `${jdkDirName(required)}-${Date.now()}.zip`)
const destDir = path.join(getAppDataDir(), 'jdk', 'temurin-21') const destDir = path.join(installerJdkRoot(), jdkDirName(required))
jdkInstall.destDir = destDir jdkInstall.destDir = destDir
try { try {
// Adoptium API v3: latest GA JDK 21 Windows x64. 본문은 307 로 GitHub 릴리즈로 리다이렉트. // Adoptium API v3: latest GA JDK(Windows x64). 본문은 307 로 GitHub 릴리즈로 리다이렉트.
const url = 'https://api.adoptium.net/v3/binary/latest/21/ga/windows/x64/jdk/hotspot/normal/eclipse?project=jdk' const url = `https://api.adoptium.net/v3/binary/latest/${required}/ga/windows/x64/jdk/hotspot/normal/eclipse?project=jdk`
sendLog(t('log.jdkInstallStart')) sendLog(t('log.jdkInstallStart', { major: required }))
let lastPctReported = -1 let lastPctReported = -1
await downloadStream(url, tempZip, controller.signal, (loaded, total) => { await downloadStream(url, tempZip, controller.signal, (loaded, total) => {
if (total > 0) { if (total > 0) {
@@ -613,6 +850,54 @@ async function downloadResourcepackZip(pack: PackDefinition, customRoot: string)
await downloadFile(url, target) await downloadFile(url, target)
} }
/**
* run.bat 에 넣을 java 실행 명령을 만든다. 자동 설치 JDK 는 %APPDATA% 아래에 있으므로
* 사용자명이 한글이어도 인코딩 문제 없이 실행되도록 `%APPDATA%\...\java.exe` 형태로
* 만든다(사용자명은 cmd 가 런타임에 전개). %APPDATA% 밖(예: Program Files)이면 절대경로.
*/
function javaCommandForRunBat(javaExe: string): string {
const appData = getAppDataDir()
const rel = path.relative(appData, javaExe)
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
return `"%APPDATA%\\${rel.split(/[\\/]/).join('\\')}"`
}
return `"${javaExe}"`
}
/**
* 서버 zip 의 run.bat 이 시스템 PATH 의 `java` 를 그대로 쓰면, 사용자의 낡은 자바(예: 17)로
* 실행돼 최신 마인크래프트 서버가 UnsupportedClassVersionError 로 뜨지 않는다.
* 설치기가 준비/선택한 JDK 의 java 를 쓰도록 run.bat 의 `java` 실행 토큰을 치환한다.
* - `java ... -jar ...`, `java @args nogui`, `"java" ...` 형태 지원.
* - 이미 경로/다른 실행기를 쓰는 줄은 건드리지 않는다.
* 바이트 보존을 위해 latin1 로 읽고 쓰되(비-ASCII 주석 깨짐 방지), 삽입하는 경로는
* %APPDATA% 전개형이라 ASCII 라 안전하다.
*/
async function patchServerRunBatJava(installPath: string, jdkPath: string): Promise<void> {
const runBat = path.join(installPath, 'run.bat')
if (!fs.existsSync(runBat)) return
const javaExe = findJavaExeInHome(jdkPath)
if (!javaExe) {
sendLog(t('log.runBatJavaSkip'))
return
}
const command = javaCommandForRunBat(javaExe)
const original = await fsp.readFile(runBat, { encoding: 'latin1' })
const lines = original.split(/\r?\n/)
let changed = false
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(/^(\s*)"?java(?:\.exe)?"?(\s+.*)$/i)
if (m && /(-jar|\bnogui\b|@|-Xm)/i.test(m[2])) {
lines[i] = `${m[1]}${command}${m[2]}`
changed = true
}
}
if (changed) {
await fsp.writeFile(runBat, lines.join('\r\n'), { encoding: 'latin1' })
sendLog(t('log.runBatJavaPatched', { java: command }))
}
}
ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) => { ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) => {
const pack = state.packs.get(payload.packKey) const pack = state.packs.get(payload.packKey)
if (!pack) throw new Error(t('errors.packNotFound2')) if (!pack) throw new Error(t('errors.packNotFound2'))
@@ -629,6 +914,8 @@ ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) =
// 동의 흐름은 renderer가 별도 IPC로 읽고 동의 시 덮어쓴다. // 동의 흐름은 renderer가 별도 IPC로 읽고 동의 시 덮어쓴다.
// 설치기는 포트를 직접 열지 않는다(요청). run.bat 에 UPnP 자동 개방을 주입하지 // 설치기는 포트를 직접 열지 않는다(요청). run.bat 에 UPnP 자동 개방을 주입하지
// 않으며, 사용자가 라우터에서 수동 포워딩을 하고 포트포워딩 페이지에서 확인만 한다. // 않으며, 사용자가 라우터에서 수동 포워딩을 하고 포트포워딩 페이지에서 확인만 한다.
// 단, run.bat 이 시스템의 낡은 자바를 쓰지 않도록 설치기가 준비한 JDK 로 바꿔준다.
await patchServerRunBatJava(installPath, payload.jdkPath)
}) })
ipcMain.handle('server:readEula', async (_event, installPath: string): Promise<{ exists: boolean; content: string }> => { ipcMain.handle('server:readEula', async (_event, installPath: string): Promise<{ exists: boolean; content: string }> => {
@@ -656,7 +943,12 @@ ipcMain.handle('server:fetchMinecraftEula', async (): Promise<{ url: string; htm
ipcMain.handle('server:acceptEula', async (_event, installPath: string) => { ipcMain.handle('server:acceptEula', async (_event, installPath: string) => {
const target = path.join(installPath, 'eula.txt') const target = path.join(installPath, 'eula.txt')
await fsp.writeFile(target, `# Generated by music quiz installer\neula=true\n`, 'utf8') const acceptedAt = new Date()
await fsp.writeFile(
target,
`# Generated by music quiz installer\n# EULA accepted at: ${acceptedAt.toISOString()}\neula=true\n`,
'utf8',
)
sendLog(t('log.eulaAccepted')) sendLog(t('log.eulaAccepted'))
}) })
@@ -1039,7 +1331,7 @@ function sleep(ms: number): Promise<void> {
ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) => { ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) => {
const pack = state.packs.get(payload.packKey) const pack = state.packs.get(payload.packKey)
if (!pack) throw new Error(t('errors.packNotFound2')) if (!pack) throw new Error(t('errors.packNotFound2'))
const customRoot = path.join(getAppDataDir(), '.mc_custom') const customRoot = path.join(getAppDataDir(), getMcCustomDirName())
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 })
@@ -1063,7 +1355,13 @@ ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) =
} }
await downloadModsFolder(pack.pack, customRoot) await downloadModsFolder(pack.pack, customRoot)
// 최종 리소스팩이 등록돼 있으면(finalResourcepackPath !== "."), 완성본을 설치 마지막에
// 따로 받으므로 베이스 리소스팩은 받지 않는다("최종 리소스팩만 다운로드").
if (pack.pack.finalResourcepackPath && pack.pack.finalResourcepackPath !== '.') {
sendLog(t('log.skipBaseForFinal'))
} else {
await downloadResourcepackZip(pack.pack, customRoot) await downloadResourcepackZip(pack.pack, customRoot)
}
if (payload.skipMap) { if (payload.skipMap) {
// 참가자 모드: 이전 설치 흐름에서 설치러가 풀어둔 맵이 있다면 제거한다. // 참가자 모드: 이전 설치 흐름에서 설치러가 풀어둔 맵이 있다면 제거한다.
@@ -1104,11 +1402,19 @@ async function installFabricLoader(pack: PackDefinition, customRoot: string): Pr
// 다시 쓰려고 시도해서, 마인크래프트나 다른 프로세스가 그 파일을 잡고 // 다시 쓰려고 시도해서, 마인크래프트나 다른 프로세스가 그 파일을 잡고
// 있으면 FileSystemException 으로 실패한다. 결과 파일이 그대로 있으면 // 있으면 FileSystemException 으로 실패한다. 결과 파일이 그대로 있으면
// 재실행할 필요가 없으므로 그냥 통과. // 재실행할 필요가 없으므로 그냥 통과.
// fabric 은 .minecraft(런처 폴더)에 설치한다. gameDir(.mc_custom)에 설치하면 fabric 이
// versions/ 와 libraries/ 를 거기에 실제 폴더로 만드는데, 이 폴더에는 fabric 자체 것만
// 들어가고 바닐라 마인크래프트의 버전 JSON·라이브러리는 빠져 있다. 그러면 .minecraft 로의
// 링크가 "이미 폴더 있음" 으로 건너뛰어져, 런처가 바닐라 데이터를 찾지 못하고
// "Unable to prepare assets for download" 로 실패한다. 그래서 .minecraft 에 설치해
// 바닐라와 같은 위치에 두고, 링크는 뒤(linkMinecraftRuntimeDirs)에서 성공하게 한다.
const mcRoot = path.join(getAppDataDir(), '.minecraft')
await fsp.mkdir(mcRoot, { recursive: true })
const versionId = `fabric-loader-${loaderVersion}-${pack.mcVersion}` const versionId = `fabric-loader-${loaderVersion}-${pack.mcVersion}`
const versionDir = path.join(customRoot, 'versions', versionId) const versionDir = path.join(mcRoot, 'versions', versionId)
const versionJar = path.join(versionDir, `${versionId}.jar`)
const versionJson = path.join(versionDir, `${versionId}.json`) const versionJson = path.join(versionDir, `${versionId}.json`)
if (fs.existsSync(versionJar) && fs.existsSync(versionJson)) { // 최신 fabric 은 버전 폴더에 .json 만 만든다(.jar 없음). json 존재를 설치 완료 기준으로 본다.
if (fs.existsSync(versionJson)) {
sendLog(t('log.fabricAlreadyInstalled', { id: versionId, dir: versionDir })) sendLog(t('log.fabricAlreadyInstalled', { id: versionId, dir: versionDir }))
return return
} }
@@ -1148,10 +1454,10 @@ async function installFabricLoader(pack: PackDefinition, customRoot: string): Pr
'client', 'client',
'-mcversion', pack.mcVersion, '-mcversion', pack.mcVersion,
'-loader', loaderVersion, '-loader', loaderVersion,
'-dir', customRoot, '-dir', mcRoot,
'-noprofile' '-noprofile'
] ]
sendLog(t('log.fabricInstallStart', { mc: pack.mcVersion, loader: loaderVersion, dir: customRoot })) sendLog(t('log.fabricInstallStart', { mc: pack.mcVersion, loader: loaderVersion, dir: mcRoot }))
await runJavaProcess(javaCmd, args) await runJavaProcess(javaCmd, args)
sendLog(t('log.fabricInstallDone')) sendLog(t('log.fabricInstallDone'))
} }
@@ -1457,13 +1763,39 @@ async function linkMinecraftRuntimeDirs(customRoot: string): Promise<void> {
const src = path.join(mcRoot, dir) const src = path.join(mcRoot, dir)
const dst = path.join(customRoot, dir) const dst = path.join(customRoot, dir)
if (!fs.existsSync(src)) { if (!fs.existsSync(src)) {
// .minecraft 에 원본이 없으면(바닐라를 한 번도 안 받았거나 런처가 다른 위치를 쓰는
// 환경) 링크를 만들 수 없다. 이때 그냥 건너뛰면 .mc_custom/<dir> 이 아예 없어
// 런처가 "Unable to prepare assets for download" 로 실패한다. 빈 폴더라도 만들어
// 런처가 그 gameDir 에 직접 받아갈 수 있게 한다(없을 때만).
sendLog(t('log.runtimeDirMissing', { dir })) sendLog(t('log.runtimeDirMissing', { dir }))
try {
if (!fs.existsSync(dst)) {
await fsp.mkdir(dst, { recursive: true })
sendLog(t('log.runtimeDirCreated', { dir }))
}
} catch (err) {
sendLog(t('log.runtimeLinkFail', { dir, message: (err as Error).message }))
}
continue continue
} }
let existing: import('node:fs').Stats | null = null let existing: import('node:fs').Stats | null = null
try { existing = await fsp.lstat(dst) } catch { existing = null } try { existing = await fsp.lstat(dst) } catch { existing = null }
if (existing) { if (existing) {
if (existing.isSymbolicLink()) continue // 이미 링크됨 if (existing.isSymbolicLink()) continue // 이미 링크됨
// versions/libraries 가 실제 폴더면, 예전 버전이 gameDir 에 fabric 을 설치하며 만든
// 불완전한(바닐라 누락) 폴더일 수 있다. .minecraft 의 완전한 데이터로 링크되도록
// 교체한다. 이 두 폴더는 런처가 관리하는 데이터라 삭제해도 세이브/리소스팩 같은
// 사용자 데이터 손실은 없다. (assets 는 받아둔 에셋 보존을 위해 그대로 둔다.)
if (dir === 'versions' || dir === 'libraries') {
try {
await fsp.rm(dst, { recursive: true, force: true })
await fsp.symlink(src, dst, 'junction')
sendLog(t('log.runtimeLinkReplaced', { dir }))
} catch (err) {
sendLog(t('log.runtimeLinkFail', { dir, message: (err as Error).message }))
}
continue
}
sendLog(t('log.runtimeDirExists', { dir })) sendLog(t('log.runtimeDirExists', { dir }))
continue continue
} }
@@ -1582,7 +1914,27 @@ ipcMain.handle('finish:startLauncher', async () => {
sendLog(t('log.launcherAllFail')) sendLog(t('log.launcherAllFail'))
}) })
ipcMain.handle('i18n:dict', () => localeDict) // 개발자용 빌드(musicQuizAudience=developer)면 렌더러에 넘기는 사전의 제목 앞에
// "(개발자용) " 을 붙여, 창 제목/헤더에 개발자용임을 표시한다.
function dictForRenderer(): Record<string, unknown> {
// 커스텀 폴더명을 UI 문구에 반영.
const base = withCustomDirName(localeDict)
if (getAudience() !== 'developer') return base
const prefix = '(개발자용) '
const appBlock = (base.app ?? {}) as Record<string, unknown>
const withPrefix = (v: unknown): unknown =>
typeof v === 'string' && !v.startsWith(prefix) ? prefix + v : v
return {
...base,
app: {
...appBlock,
browserTitle: withPrefix(appBlock.browserTitle),
headerTitle: withPrefix(appBlock.headerTitle)
}
}
}
ipcMain.handle('i18n:dict', () => dictForRenderer())
ipcMain.handle('app:quit', () => { ipcMain.handle('app:quit', () => {
// 모든 창을 닫고 앱 종료. macOS에서도 종료(설치기는 한 번 쓰고 끝이니 잔류시키지 않음). // 모든 창을 닫고 앱 종료. macOS에서도 종료(설치기는 한 번 쓰고 끝이니 잔류시키지 않음).

View File

@@ -17,6 +17,12 @@ const api = {
// 메인 인스톨러용 약관 목록 (사이트의 visibility 토글에 따라 필터링됨) // 메인 인스톨러용 약관 목록 (사이트의 visibility 토글에 따라 필터링됨)
getTermsList: (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> => getTermsList: (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> =>
ipcRenderer.invoke('terms:list'), ipcRenderer.invoke('terms:list'),
// 최종 리소스팩 다운로드용 약관 목록 (showInFinalResourcepack 필터)
getFinalTermsList: (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> =>
ipcRenderer.invoke('terms:listFinal'),
// 최종 리소스팩 다운로드 실행
installFinalResourcepack: (): Promise<{ ok: boolean; path?: string; message?: string }> =>
ipcRenderer.invoke('finalResourcepack:install'),
// 3-1 // 3-1
pickFolder: (): Promise<string | null> => ipcRenderer.invoke('dialog:pickFolder'), pickFolder: (): Promise<string | null> => ipcRenderer.invoke('dialog:pickFolder'),
@@ -24,8 +30,12 @@ const api = {
ipcRenderer.invoke('install:validatePath', target), ipcRenderer.invoke('install:validatePath', target),
// 3-2 // 3-2
detectJdk: (): Promise<{ found: boolean; path: string }> => ipcRenderer.invoke('jdk:detect'), detectJdk: (recommendedJdk?: number): Promise<{ found: boolean; path: string; major: number; match: boolean }> =>
installJdk: (): Promise<{ ok: boolean; path?: string; message?: string }> => ipcRenderer.invoke('jdk:install'), ipcRenderer.invoke('jdk:detect', recommendedJdk),
verifyJdk: (jdkPath: string, recommendedJdk?: number): Promise<{ ok: boolean; major: number; required: number; match: boolean }> =>
ipcRenderer.invoke('jdk:verify', jdkPath, recommendedJdk),
installJdk: (recommendedJdk?: number): Promise<{ ok: boolean; path?: string; message?: string }> =>
ipcRenderer.invoke('jdk:install', recommendedJdk),
cancelJdkInstall: (): Promise<{ ok: boolean }> => ipcRenderer.invoke('jdk:cancelInstall'), cancelJdkInstall: (): Promise<{ ok: boolean }> => ipcRenderer.invoke('jdk:cancelInstall'),
// 3-3 // 3-3
@@ -64,6 +74,15 @@ const api = {
const listener = (_event: unknown, line: string) => handler(line) const listener = (_event: unknown, line: string) => handler(line)
ipcRenderer.on('log', listener) ipcRenderer.on('log', listener)
return () => ipcRenderer.removeListener('log', listener) return () => ipcRenderer.removeListener('log', listener)
},
// 최종 리소스팩 다운로드 진행률 구독. 반환값을 호출하면 구독 해제.
onFinalResourcepackProgress: (
handler: (info: { loaded: number; total: number; percent: number }) => void
): (() => void) => {
const listener = (_event: unknown, info: { loaded: number; total: number; percent: number }) => handler(info)
ipcRenderer.on('finalResourcepack:progress', listener)
return () => ipcRenderer.removeListener('finalResourcepack:progress', listener)
} }
} }

View File

@@ -1,10 +1,13 @@
import express from 'express' 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 fs from 'node:fs'
import fsp from 'node:fs/promises' import fsp from 'node:fs/promises'
import crypto from 'node:crypto'
import { import {
manifestRootPath, manifestDirPath, manifestTermsDirPath, manifestRootPath, manifestDirPath, manifestTermsDirPath,
fileDirPath, viewsDirPath, publicDirPath fileDirPath, viewsDirPath, publicDirPath, projectRoot,
accountFilePath, accountLocalFilePath
} from '../shared/paths.js' } from '../shared/paths.js'
import { import {
ensurePackTermsDir, isPublicTermsFile, listTermsWithLabels, loadPackDefinition ensurePackTermsDir, isPublicTermsFile, listTermsWithLabels, loadPackDefinition
@@ -25,7 +28,24 @@ const app = express()
app.set('view engine', 'ejs') app.set('view engine', 'ejs')
app.set('views', viewsDirPath) app.set('views', viewsDirPath)
app.set('trust proxy', 1) // 리버스 프록시 뒤일 때만 켠다. 항상 켜두면 직접 노출 시 X-Forwarded-For 조작으로
// req.ip 를 위조해 로그인 rate limit 을 우회할 수 있다. 프록시 뒤라면 TRUST_PROXY=true.
app.set('trust proxy', process.env.TRUST_PROXY === 'true' ? 1 : false)
// 추적되는 account.json(과거 평문 노출)을 gitignore 된 account.local.json 으로 시드한다.
// 로컬 파일이 이미 있으면 건드리지 않음. 이후 계정 쓰기/자동 해시 업그레이드는 로컬
// 파일에만 반영되어, 재배포로 account.json 을 추적 해제해도 로그인이 유지된다.
function seedLocalAccounts(): void {
try {
if (fs.existsSync(accountLocalFilePath)) return
if (!fs.existsSync(accountFilePath)) return
fs.copyFileSync(accountFilePath, accountLocalFilePath)
fs.chmodSync(accountLocalFilePath, 0o600)
} catch {
// 실패해도 readAccounts 가 account.json 으로 폴백하므로 치명적이지 않음.
}
}
seedLocalAccounts()
app.use(express.urlencoded({ extended: true })) app.use(express.urlencoded({ extended: true }))
app.use(express.json()) app.use(express.json())
@@ -38,13 +58,36 @@ app.use((_req, res, next) => {
next() next()
}) })
// 세션 시크릿: 환경변수 우선, 없으면 하드코딩(위조 위험) 대신 영구 랜덤 시크릿을
// 파일로 생성/보관한다(재시작해도 세션 유지). 파일 접근 불가 시엔 프로세스 수명 동안만
// 유효한 랜덤값으로 폴백(그래도 하드코딩보다 안전).
function resolveSessionSecret(): string {
const fromEnv = process.env.SESSION_SECRET
if (fromEnv && fromEnv.length >= 16) return fromEnv
const secretPath = path.join(projectRoot, '.session-secret')
try {
if (fs.existsSync(secretPath)) {
const existing = fs.readFileSync(secretPath, 'utf8').trim()
if (existing.length >= 16) return existing
}
const generated = crypto.randomBytes(32).toString('hex')
fs.writeFileSync(secretPath, generated, { mode: 0o600 })
return generated
} catch {
return crypto.randomBytes(32).toString('hex')
}
}
app.use(session({ app.use(session({
secret: process.env.SESSION_SECRET ?? 'music-quiz-installer-dev-secret', secret: resolveSessionSecret(),
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { cookie: {
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax',
// HTTPS 전용 배포면 SESSION_COOKIE_SECURE=true 로 secure 쿠키 활성화.
// HTTP 접근이 섞이면 로그인 쿠키가 안 실리므로 기본값은 false.
secure: process.env.SESSION_COOKIE_SECURE === 'true',
maxAge: 1000 * 60 * 60 * 8 maxAge: 1000 * 60 * 60 * 8
} }
})) }))

48
src/server/password.ts Normal file
View File

@@ -0,0 +1,48 @@
import crypto from 'node:crypto'
// 운영자 비밀번호 저장/검증. 외부 의존성 없이 Node 내장 scrypt 사용.
// 저장 형식: `scrypt$<saltHex>$<hashHex>`. 검증은 항상 상수시간 비교.
// 기존 account.json 의 평문 비밀번호는 verifyPassword 가 그대로 검증할 수 있고,
// 로그인 성공 시 호출측에서 hashPassword 로 재저장(자동 업그레이드)한다.
const SCHEME = 'scrypt'
const KEY_LEN = 32
const SALT_LEN = 16
export function hashPassword(plain: string): string {
const salt = crypto.randomBytes(SALT_LEN)
const hash = crypto.scryptSync(plain, salt, KEY_LEN)
return `${SCHEME}$${salt.toString('hex')}$${hash.toString('hex')}`
}
export function isHashed(stored: string): boolean {
return typeof stored === 'string' && stored.startsWith(`${SCHEME}$`)
}
export function verifyPassword(plain: string, stored: string): boolean {
if (typeof stored !== 'string' || stored.length === 0) return false
if (isHashed(stored)) {
const parts = stored.split('$')
if (parts.length !== 3) return false
let salt: Buffer
let expected: Buffer
try {
salt = Buffer.from(parts[1], 'hex')
expected = Buffer.from(parts[2], 'hex')
} catch {
return false
}
if (expected.length === 0) return false
let derived: Buffer
try {
derived = crypto.scryptSync(plain, salt, expected.length)
} catch {
return false
}
return derived.length === expected.length && crypto.timingSafeEqual(derived, expected)
}
// 레거시 평문: 길이 노출을 피하려 양쪽을 sha256 으로 고정 길이화한 뒤 상수시간 비교.
const a = crypto.createHash('sha256').update(plain, 'utf8').digest()
const b = crypto.createHash('sha256').update(stored, 'utf8').digest()
return crypto.timingSafeEqual(a, b)
}

View File

@@ -22,9 +22,13 @@ import {
saveTerm, saveTerm,
savePackList, savePackList,
setPackPublic, setPackPublic,
setTermVisibility setTermVisibility,
writeAccounts,
SUPPORTED_JDK_MAJORS
} from '../../shared/store.js' } from '../../shared/store.js'
import { hashPassword, isHashed, verifyPassword } from '../password.js'
import { fetchReleaseVersions } from '../../shared/mojang.js' import { fetchReleaseVersions } from '../../shared/mojang.js'
import { fetchJdkAvailability, fetchJdkReleaseNames } from '../../shared/jdkVersions.js'
import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js' import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js'
import { requireAuth } from '../middleware/auth.js' import { requireAuth } from '../middleware/auth.js'
import type { PackDefinition, PackList } from '../../shared/types.js' import type { PackDefinition, PackList } from '../../shared/types.js'
@@ -33,6 +37,40 @@ import { buildSongsMcfunction } from '../datapack.js'
export const opRouter = Router() export const opRouter = Router()
// 로그인 브루트포스 + scrypt CPU 남용 방지용 IP 기준 인메모리 실패 제한.
const LOGIN_WINDOW_MS = 15 * 60 * 1000
const LOGIN_MAX_FAILS = 10
const loginFails = new Map<string, { count: number; first: number; blockedUntil: number }>()
function loginClientKey(req: { ip?: string; socket?: { remoteAddress?: string } }): string {
return req.ip || req.socket?.remoteAddress || 'unknown'
}
/** 차단 중이면 남은 ms, 아니면 0. 접근 시 만료된 항목은 정리. */
function loginBlockedMs(key: string): number {
const now = Date.now()
const entry = loginFails.get(key)
if (!entry) return 0
if (entry.blockedUntil > now) return entry.blockedUntil - now
if (now - entry.first > LOGIN_WINDOW_MS) loginFails.delete(key)
return 0
}
function recordLoginFail(key: string): void {
const now = Date.now()
let entry = loginFails.get(key)
if (!entry || now - entry.first > LOGIN_WINDOW_MS) entry = { count: 0, first: now, blockedUntil: 0 }
entry.count += 1
if (entry.count >= LOGIN_MAX_FAILS) entry.blockedUntil = now + LOGIN_WINDOW_MS
loginFails.set(key, entry)
// 맵 무한 성장 방지(분산 시도 대비): 상한 초과 시 만료 항목 정리.
if (loginFails.size > 5000) {
for (const [k, v] of loginFails) {
if (v.blockedUntil <= now && now - v.first > LOGIN_WINDOW_MS) loginFails.delete(k)
}
}
}
function pickFirstValue(value: unknown): string { function pickFirstValue(value: unknown): string {
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : '' if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : ''
return typeof value === 'string' ? value : '' return typeof value === 'string' ? value : ''
@@ -56,13 +94,32 @@ opRouter.get('/op', (req, res) => {
opRouter.post('/op', async (req, res, next) => { opRouter.post('/op', async (req, res, next) => {
try { try {
const clientKey = loginClientKey(req)
const blockedMs = loginBlockedMs(clientKey)
if (blockedMs > 0) {
res.status(429).render('op/login', {
error: t('login.tooManyAttempts', { minutes: Math.ceil(blockedMs / 60000) })
})
return
}
const password = pickFirstValue(req.body.password) const password = pickFirstValue(req.body.password)
const accounts = await readAccounts() const accounts = await readAccounts()
const matched = accounts.find((entry) => entry.password === password) const matched = accounts.find((entry) => verifyPassword(password, entry.password))
if (!matched) { if (!matched) {
recordLoginFail(clientKey)
res.status(401).render('op/login', { error: t('login.wrongPassword') }) res.status(401).render('op/login', { error: t('login.wrongPassword') })
return return
} }
loginFails.delete(clientKey)
// 평문으로 저장돼 있던 비밀번호는 로그인 성공 시 scrypt 해시로 자동 업그레이드.
if (!isHashed(matched.password)) {
try {
matched.password = hashPassword(password)
await writeAccounts(accounts)
} catch {
// 업그레이드 실패는 로그인 자체에 영향 주지 않음.
}
}
req.session.userId = matched.id req.session.userId = matched.id
res.redirect('/op/dashboard') res.redirect('/op/dashboard')
} catch (error) { } catch (error) {
@@ -132,13 +189,39 @@ opRouter.get('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
packKey, packKey,
pack: definition, pack: definition,
releases, releases,
isPublic isPublic,
jdkOptions: SUPPORTED_JDK_MAJORS
}) })
} catch (error) { } catch (error) {
next(error) next(error)
} }
}) })
// ─── /op/jdk-versions ───────────────────────────────────────────────────
// Adoptium(Temurin) 에서 실제 배포되는 JDK 메이저 목록을 프록시(브라우저 직접 호출은
// CORS 로 막힘). 편집기의 "권장 JDK" 드롭다운을 이 결과로 채운다.
opRouter.get('/op/jdk-versions', requireAuth, async (_req, res, next) => {
try {
res.json(await fetchJdkAvailability())
} catch (error) {
next(error)
}
})
// 특정 메이저의 상세 릴리스 이름(GA + EA 스냅샷). "자세히 보기" 에서 사용.
opRouter.get('/op/jdk-versions/:major', requireAuth, async (req, res, next) => {
try {
const major = Number(req.params.major)
if (!Number.isInteger(major) || major < 8 || major > 99) {
res.status(400).json({ error: 'invalid major' })
return
}
res.json(await fetchJdkReleaseNames(major))
} catch (error) {
next(error)
}
})
// ─── /op/list ────────────────────────────────────────────────────────── // ─── /op/list ──────────────────────────────────────────────────────────
// 음악퀴즈를 카드 한 줄로 표시. 카드 클릭 → /op/list/:packName // 음악퀴즈를 카드 한 줄로 표시. 카드 클릭 → /op/list/:packName
opRouter.get('/op/list', requireAuth, async (req, res, next) => { opRouter.get('/op/list', requireAuth, async (req, res, next) => {
@@ -452,6 +535,7 @@ opRouter.get('/op/agreement/:packName/:kind', requireAuth, async (req, res, next
label: entry.label, label: entry.label,
showInInstaller: entry.showInInstaller, showInInstaller: entry.showInInstaller,
showInInstallerRp: entry.showInInstallerRp, showInInstallerRp: entry.showInInstallerRp,
showInFinalResourcepack: entry.showInFinalResourcepack,
content content
}) })
} catch (error) { } catch (error) {
@@ -479,10 +563,12 @@ opRouter.post('/op/agreement/:packName/:kind', requireAuth, async (req, res, nex
if ( if (
typeof req.body?.showInInstaller === 'boolean' typeof req.body?.showInInstaller === 'boolean'
|| typeof req.body?.showInInstallerRp === 'boolean' || typeof req.body?.showInInstallerRp === 'boolean'
|| typeof req.body?.showInFinalResourcepack === 'boolean'
) { ) {
await setTermVisibility(packKey, kind, { await setTermVisibility(packKey, kind, {
showInInstaller: req.body.showInInstaller === true, showInInstaller: req.body.showInInstaller === true,
showInInstallerRp: req.body.showInInstallerRp === true showInInstallerRp: req.body.showInInstallerRp === true,
showInFinalResourcepack: req.body.showInFinalResourcepack === true
}) })
} }
res.json({ ok: true }) res.json({ ok: true })
@@ -510,11 +596,13 @@ 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),
finalResourcepackPath: pickFirstValue(req.body.finalResourcepackPath),
outputPackName: pickFirstValue(req.body.outputPackName), 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)),
clientRecommendedRam: Number(pickFirstValue(req.body.clientRecommendedRam)), clientRecommendedRam: Number(pickFirstValue(req.body.clientRecommendedRam)),
recommendedJdk: Number(pickFirstValue(req.body.recommendedJdk)),
mapPath: pickFirstValue(req.body.mapPath), mapPath: pickFirstValue(req.body.mapPath),
serverPath: pickFirstValue(req.body.serverPath) serverPath: pickFirstValue(req.body.serverPath)
} }

View File

@@ -73,7 +73,9 @@ export function createI18n(filePath: string): I18n {
* 1. 패키징된 Electron 앱이면 `process.resourcesPath/locales/<component>/ko-kr.json` * 1. 패키징된 Electron 앱이면 `process.resourcesPath/locales/<component>/ko-kr.json`
* 2. `<프로젝트 루트>/locales/<component>/ko-kr.json` * 2. `<프로젝트 루트>/locales/<component>/ko-kr.json`
*/ */
export function loadComponentI18n(component: 'server' | 'installer' | 'installer-rp' | 'installer-pf'): I18n { export function loadComponentI18n(
component: 'server' | 'installer' | 'installer-rp' | 'installer-pf' | 'installer-uninstall'
): I18n {
// 컴파일된 dist/shared/i18n.js 기준으로 프로젝트 루트는 2단계 위. // 컴파일된 dist/shared/i18n.js 기준으로 프로젝트 루트는 2단계 위.
const projectRoot = path.resolve(__dirname, '..', '..') const projectRoot = path.resolve(__dirname, '..', '..')

118
src/shared/jdkVersions.ts Normal file
View File

@@ -0,0 +1,118 @@
import https from 'node:https'
// Adoptium(Temurin) API 로부터 실제 배포되는 JDK 버전을 가져온다. 설치기가 이 벤더를
// 쓰므로 사이트의 "권장 JDK" 목록도 같은 소스에서 채운다(하드코딩 금지). 네트워크
// 실패 시에는 정적 폴백을 돌려줘 편집기가 항상 동작하도록 한다.
const AVAILABLE_URL = 'https://api.adoptium.net/v3/info/available_releases'
const CACHE_TTL_MS = 6 * 60 * 60 * 1000 // 6시간
const FALLBACK_MAJORS = [8, 11, 17, 21, 25]
const FALLBACK_LTS = [8, 11, 17, 21, 25]
export interface JdkAvailability {
/** 배포되는 모든 메이저(예: 8,11,17,21,25,26). 내림차순. */
available: number[]
/** LTS 메이저만(예: 8,11,17,21,25). 내림차순. */
lts: number[]
mostRecentLts: number
mostRecentFeature: number
}
export interface JdkReleaseNames {
major: number
/** 정식(GA) 릴리스 이름들(예: jdk-25.0.3+9). */
ga: string[]
/** 개발 스냅샷(EA) 릴리스 이름들(예: jdk-25.0.4+6-ea-beta). */
ea: string[]
}
let availCache: { data: JdkAvailability; at: number } | null = null
const releaseNamesCache = new Map<number, { data: JdkReleaseNames; at: number }>()
function fetchJson<T>(url: string): Promise<T> {
return new Promise((resolve, reject) => {
const request = https.get(url, { timeout: 8000, headers: { accept: 'application/json' } }, (response) => {
const code = response.statusCode ?? 0
if (code !== 200) {
response.resume()
reject(new Error(`Adoptium HTTP ${code}`))
return
}
const chunks: Buffer[] = []
response.on('data', (c: Buffer) => chunks.push(c))
response.on('end', () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')) as T)
} catch (error) {
reject(error as Error)
}
})
})
request.on('error', reject)
request.on('timeout', () => request.destroy(new Error('Adoptium timeout')))
})
}
const sortDesc = (nums: number[]): number[] => [...new Set(nums)].sort((a, b) => b - a)
export async function fetchJdkAvailability(): Promise<JdkAvailability> {
if (availCache && Date.now() - availCache.at < CACHE_TTL_MS) return availCache.data
try {
const raw = await fetchJson<{
available_releases?: number[]
available_lts_releases?: number[]
most_recent_lts?: number
most_recent_feature_release?: number
}>(AVAILABLE_URL)
const available = sortDesc(
(Array.isArray(raw.available_releases) ? raw.available_releases : FALLBACK_MAJORS).filter((n) => Number.isInteger(n))
)
const lts = sortDesc(
(Array.isArray(raw.available_lts_releases) ? raw.available_lts_releases : FALLBACK_LTS).filter((n) => Number.isInteger(n))
)
const data: JdkAvailability = {
available,
lts,
mostRecentLts: Number(raw.most_recent_lts) || (lts[0] ?? 21),
mostRecentFeature: Number(raw.most_recent_feature_release) || (available[0] ?? 25)
}
availCache = { data, at: Date.now() }
return data
} catch {
return (
availCache?.data ?? {
available: sortDesc(FALLBACK_MAJORS),
lts: sortDesc(FALLBACK_LTS),
mostRecentLts: 25,
mostRecentFeature: 25
}
)
}
}
async function fetchReleaseNames(major: number, type: 'ga' | 'ea'): Promise<string[]> {
const url =
`https://api.adoptium.net/v3/assets/feature_releases/${major}/${type}` +
'?architecture=x64&image_type=jdk&os=windows&vendor=eclipse&page_size=10'
try {
const arr = await fetchJson<Array<{ release_name?: string }>>(url)
if (!Array.isArray(arr)) return []
const names: string[] = []
for (const r of arr) {
if (typeof r?.release_name === 'string' && !names.includes(r.release_name)) names.push(r.release_name)
}
return names
} catch {
return []
}
}
export async function fetchJdkReleaseNames(major: number): Promise<JdkReleaseNames> {
const cached = releaseNamesCache.get(major)
if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.data
const [ga, ea] = await Promise.all([fetchReleaseNames(major, 'ga'), fetchReleaseNames(major, 'ea')])
const data: JdkReleaseNames = { major, ga, ea }
releaseNamesCache.set(major, { data, at: Date.now() })
return data
}

View File

@@ -6,7 +6,17 @@ 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 manifestTermsDirPath = path.join(manifestDirPath, 'terms')
// 추적되는 account.json(과거 평문 노출)을 대체할, gitignore 된 운영 계정 파일.
// readAccounts 는 이 파일을 우선 사용하고, 없을 때만 account.json 을 시드로 읽는다.
//
// TODO(untrack-after-redeploy): account.json 은 아직 git 추적 상태다. 절대 지금
// 같은 커밋에서 `git rm --cached account.json` 하지 말 것 — 서버에 account.local.json
// 이 아직 없을 때 시드 소스가 사라져 로그인이 막힌다(chicken-and-egg).
// 안전한 순서: (1) 이 커밋 배포 → 서버가 account.local.json(0o600) 자동 생성 확인 →
// (2) 그 다음 후속 커밋에서 account.json 추적 해제.
// 주의: 추적 해제는 위생일 뿐, 히스토리의 평문 비밀번호는 지워지지 않는다 → 비밀번호 로테이션이 실질 조치.
export const accountFilePath = path.join(projectRoot, 'account.json') export const accountFilePath = path.join(projectRoot, 'account.json')
export const accountLocalFilePath = path.join(projectRoot, 'account.local.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')
export const fileDatapacksDirPath = path.join(fileDirPath, 'datapacks') export const fileDatapacksDirPath = path.join(fileDirPath, 'datapacks')
@@ -29,9 +39,43 @@ export function getAppDataDir(): string {
return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config') return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config')
} }
/** %appdata%/.mc_custom — 음악퀴즈 관련 외부 도구/캐시 보관 디렉터리. */ /**
* 커스텀 게임 디렉터리의 폴더 이름. 기본은 `.mc_custom` 이지만 환경변수
* `MC_CUSTOM_DIR` 로 다른 이름을 지정할 수 있다(.env / .env.build 로 주입).
* 경로 구분자·상위경로 이스케이프(`/`, `\`, `..`)는 제거해 항상 %appdata%
* 바로 아래 단일 폴더로 강제한다.
*/
export function getMcCustomDirName(): string {
const raw = (process.env.MC_CUSTOM_DIR ?? '').trim()
if (!raw) return '.mc_custom'
const sanitized = raw.replace(/[\\/]+/g, '').replace(/\.\.+/g, '.')
return sanitized || '.mc_custom'
}
/** %appdata%/<MC_CUSTOM_DIR|.mc_custom> — 음악퀴즈 관련 게임 폴더/외부 도구/캐시 보관 디렉터리. */
export function getMcCustomDir(): string { export function getMcCustomDir(): string {
return path.join(getAppDataDir(), '.mc_custom') return path.join(getAppDataDir(), getMcCustomDirName())
}
/**
* 사전/문자열 구조 안의 리터럴 `.mc_custom` 을 실제 폴더 이름으로 치환한 깊은
* 복사본을 돌려준다. 기본값(`.mc_custom`)이면 원본을 그대로 반환한다. 렌더러로
* 넘기는 i18n 사전에 적용해 UI 안내 문구가 실제 폴더 이름과 어긋나지 않게 한다.
*/
export function withCustomDirName<T>(value: T): T {
const name = getMcCustomDirName()
if (name === '.mc_custom') return value
const replace = (v: unknown): unknown => {
if (typeof v === 'string') return v.split('.mc_custom').join(name)
if (Array.isArray(v)) return v.map(replace)
if (v && typeof v === 'object') {
const out: Record<string, unknown> = {}
for (const [k, val] of Object.entries(v as Record<string, unknown>)) out[k] = replace(val)
return out
}
return v
}
return replace(value) as T
} }
/** /**

View File

@@ -3,7 +3,7 @@ import fsp from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import { import {
manifestRootPath, manifestDirPath, manifestTermsDirPath, manifestRootPath, manifestDirPath, manifestTermsDirPath,
accountFilePath, fileListDirPath accountFilePath, accountLocalFilePath, fileListDirPath
} from './paths.js' } from './paths.js'
import type { import type {
Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType, Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType,
@@ -47,11 +47,13 @@ export function defaultPackDefinition(name: string): PackDefinition {
platform: { type: 'vanilla' }, platform: { type: 'vanilla' },
modsFolder: '', modsFolder: '',
resourcepackPath: '', resourcepackPath: '',
finalResourcepackPath: '.',
outputPackName: '', outputPackName: '',
serverMinRam: 2048, serverMinRam: 2048,
serverMaxRam: 4096, serverMaxRam: 4096,
clientMinRam: 2048, clientMinRam: 2048,
clientRecommendedRam: 4096, clientRecommendedRam: 4096,
recommendedJdk: DEFAULT_JDK_MAJOR,
mapPath: '', mapPath: '',
serverPath: '' serverPath: ''
} }
@@ -78,6 +80,31 @@ function sanitizeFolderName(input: unknown): string {
const ALLOWED_LOADERS: LoaderType[] = ['vanilla', 'forge', 'fabric', 'neoforge'] const ALLOWED_LOADERS: LoaderType[] = ['vanilla', 'forge', 'fabric', 'neoforge']
/**
* 편집기 드롭다운의 정적 폴백 목록(LTS). 실제 목록은 Adoptium API 에서 가져오며,
* 네트워크 실패 시에만 이 값을 쓴다.
* 마인크래프트 버전대별: 8(구버전) / 11(1.17) / 17(1.18~1.20.4) / 21(1.20.5~) / 25(최신).
*/
export const SUPPORTED_JDK_MAJORS: number[] = [8, 11, 17, 21, 25]
/** recommendedJdk 미지정/이상값일 때의 기본 권장 버전. */
export const DEFAULT_JDK_MAJOR = 25
/**
* 입력값을 JDK 메이저로 보정. 특정 목록에 얽매이지 않고 실제 배포되는 자바 메이저
* 범위(8~99)면 허용하고, 벗어나면 기본값. (목록은 Adoptium 에서 동적으로 온다.)
*/
export function normalizeRecommendedJdk(input: unknown): number {
const n = Math.floor(Number(input))
return Number.isFinite(n) && n >= 8 && n <= 99 ? n : DEFAULT_JDK_MAJOR
}
/** "최종 리소스팩" 값 보정. 빈 값/`.` 은 "없음"을 뜻하는 `.` 으로, 그 외는 zip 파일명으로. */
export function normalizeFinalResourcepack(input: unknown): string {
const raw = typeof input === 'string' ? input.trim() : ''
if (raw === '' || raw === '.') return '.'
return sanitizeZipFileName(raw)
}
export function normalizePackDefinition(input: Partial<PackDefinition> & Record<string, unknown>): PackDefinition { export function normalizePackDefinition(input: Partial<PackDefinition> & Record<string, unknown>): PackDefinition {
const fallback = defaultPackDefinition(typeof input.name === 'string' ? input.name : 'new') const fallback = defaultPackDefinition(typeof input.name === 'string' ? input.name : 'new')
const platform = (input.platform ?? {}) as Partial<PackDefinition['platform']> const platform = (input.platform ?? {}) as Partial<PackDefinition['platform']>
@@ -106,12 +133,14 @@ export function normalizePackDefinition(input: Partial<PackDefinition> & Record<
}, },
modsFolder: sanitizeFolderName(input.modsFolder), modsFolder: sanitizeFolderName(input.modsFolder),
resourcepackPath: sanitizeZipFileName(input.resourcepackPath), resourcepackPath: sanitizeZipFileName(input.resourcepackPath),
finalResourcepackPath: normalizeFinalResourcepack(input.finalResourcepackPath),
// 표시명은 사용자 입력을 보존(공백/마침표 trim 만). 파일명 안전 처리는 설치기 측에서. // 표시명은 사용자 입력을 보존(공백/마침표 trim 만). 파일명 안전 처리는 설치기 측에서.
outputPackName: typeof input.outputPackName === 'string' ? input.outputPackName.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),
clientRecommendedRam: clampNumber(input.clientRecommendedRam, fallback.clientRecommendedRam), clientRecommendedRam: clampNumber(input.clientRecommendedRam, fallback.clientRecommendedRam),
recommendedJdk: normalizeRecommendedJdk(input.recommendedJdk),
mapPath: sanitizeZipFileName(input.mapPath), mapPath: sanitizeZipFileName(input.mapPath),
serverPath: sanitizeZipFileName(input.serverPath) serverPath: sanitizeZipFileName(input.serverPath)
} }
@@ -373,12 +402,13 @@ const DEFAULT_TERM_SEEDS: Array<{
label: string label: string
showInInstaller: boolean showInInstaller: boolean
showInInstallerRp: boolean showInInstallerRp: boolean
showInFinalResourcepack: boolean
}> = [ }> = [
{ kind: 'map', label: '맵 약관', showInInstaller: true, showInInstallerRp: false }, { kind: 'map', label: '맵 약관', showInInstaller: true, showInInstallerRp: false, showInFinalResourcepack: false },
{ kind: 'mod', label: '모드 약관', showInInstaller: true, showInInstallerRp: false }, { kind: 'mod', label: '모드 약관', showInInstaller: true, showInInstallerRp: false, showInFinalResourcepack: false },
{ kind: 'installer', label: '설치기 약관', showInInstaller: true, showInInstallerRp: false }, { kind: 'installer', label: '설치기 약관', showInInstaller: true, showInInstallerRp: false, showInFinalResourcepack: false },
{ kind: 'resourcepack', label: '리소스팩 약관', showInInstaller: false, showInInstallerRp: true }, { kind: 'resourcepack', label: '리소스팩 약관', showInInstaller: false, showInInstallerRp: true, showInFinalResourcepack: true },
{ kind: 'installer-rp', label: '리소스팩 설치기 약관', showInInstaller: false, showInInstallerRp: true } { kind: 'installer-rp', label: '리소스팩 설치기 약관', showInInstaller: false, showInInstallerRp: true, showInFinalResourcepack: false }
] ]
const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/ const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
@@ -391,6 +421,7 @@ export interface TermEntry {
label: string label: string
showInInstaller: boolean showInInstaller: boolean
showInInstallerRp: boolean showInInstallerRp: boolean
showInFinalResourcepack: boolean
} }
interface TermsMeta { interface TermsMeta {
@@ -482,7 +513,8 @@ async function ensureMetaInitialized(dir: string, dirWasJustCreated: boolean): P
meta.terms[seed.kind] = { meta.terms[seed.kind] = {
label: seed.label, label: seed.label,
showInInstaller: seed.showInInstaller, showInInstaller: seed.showInInstaller,
showInInstallerRp: seed.showInInstallerRp showInInstallerRp: seed.showInInstallerRp,
showInFinalResourcepack: seed.showInFinalResourcepack
} }
changed = true changed = true
} }
@@ -523,10 +555,11 @@ async function ensureMetaInitialized(dir: string, dirWasJustCreated: boolean): P
terms[seed.kind] = { terms[seed.kind] = {
label: seed.label, label: seed.label,
showInInstaller: seed.showInInstaller, showInInstaller: seed.showInInstaller,
showInInstallerRp: seed.showInInstallerRp showInInstallerRp: seed.showInInstallerRp,
showInFinalResourcepack: seed.showInFinalResourcepack
} }
} }
// 구 스키마의 사용자 정의 약관은 양쪽 인스톨러에 보이도록 기본값으로. // 구 스키마의 사용자 정의 약관은 양쪽 인스톨러에 보이도록 기본값으로(최종 리소스팩은 기본 off).
for (const [k, label] of Object.entries(oldCustomLabels)) { for (const [k, label] of Object.entries(oldCustomLabels)) {
if (terms[k]) continue if (terms[k]) continue
try { try {
@@ -534,7 +567,7 @@ async function ensureMetaInitialized(dir: string, dirWasJustCreated: boolean): P
} catch { } catch {
continue continue
} }
terms[k] = { label, showInInstaller: true, showInInstallerRp: true } terms[k] = { label, showInInstaller: true, showInInstallerRp: true, showInFinalResourcepack: false }
} }
await fsp.writeFile(metaPath, `${JSON.stringify({ terms }, null, 2)}\n`, 'utf8') await fsp.writeFile(metaPath, `${JSON.stringify({ terms }, null, 2)}\n`, 'utf8')
} }
@@ -555,7 +588,8 @@ async function loadTermsMeta(packKey: string): Promise<TermsMeta> {
result.terms[k] = { result.terms[k] = {
label, label,
showInInstaller: entry.showInInstaller === true, showInInstaller: entry.showInInstaller === true,
showInInstallerRp: entry.showInInstallerRp === true showInInstallerRp: entry.showInInstallerRp === true,
showInFinalResourcepack: entry.showInFinalResourcepack === true
} }
} }
} }
@@ -580,6 +614,7 @@ export interface TermItem {
label: string label: string
showInInstaller: boolean showInInstaller: boolean
showInInstallerRp: boolean showInInstallerRp: boolean
showInFinalResourcepack: boolean
} }
/** /**
@@ -613,7 +648,8 @@ export async function listTermsWithLabels(packKey: string): Promise<TermItem[]>
kind: seed.kind, kind: seed.kind,
label: entry.label, label: entry.label,
showInInstaller: entry.showInInstaller, showInInstaller: entry.showInInstaller,
showInInstallerRp: entry.showInInstallerRp showInInstallerRp: entry.showInInstallerRp,
showInFinalResourcepack: entry.showInFinalResourcepack
}) })
seen.add(seed.kind) seen.add(seed.kind)
} }
@@ -627,7 +663,8 @@ export async function listTermsWithLabels(packKey: string): Promise<TermItem[]>
kind, kind,
label: entry.label, label: entry.label,
showInInstaller: entry.showInInstaller, showInInstaller: entry.showInInstaller,
showInInstallerRp: entry.showInInstallerRp showInInstallerRp: entry.showInInstallerRp,
showInFinalResourcepack: entry.showInFinalResourcepack
}) })
} }
return items return items
@@ -646,7 +683,7 @@ export async function getTermEntry(packKey: string, kind: string): Promise<TermE
export async function setTermVisibility( export async function setTermVisibility(
packKey: string, packKey: string,
kind: string, kind: string,
visibility: { showInInstaller: boolean; showInInstallerRp: boolean } visibility: { showInInstaller: boolean; showInInstallerRp: boolean; showInFinalResourcepack: boolean }
): Promise<void> { ): Promise<void> {
if (!isTermKind(kind)) throw new Error('invalid term kind') if (!isTermKind(kind)) throw new Error('invalid term kind')
const meta = await loadTermsMeta(packKey) const meta = await loadTermsMeta(packKey)
@@ -654,6 +691,7 @@ export async function setTermVisibility(
if (!entry) throw new Error('term not found') if (!entry) throw new Error('term not found')
entry.showInInstaller = !!visibility.showInInstaller entry.showInInstaller = !!visibility.showInInstaller
entry.showInInstallerRp = !!visibility.showInInstallerRp entry.showInInstallerRp = !!visibility.showInInstallerRp
entry.showInFinalResourcepack = !!visibility.showInFinalResourcepack
await saveTermsMeta(packKey, meta) await saveTermsMeta(packKey, meta)
} }
@@ -703,7 +741,8 @@ export async function createTerm(packKey: string, kind: string, label: string):
meta.terms[kind] = { meta.terms[kind] = {
label: cleanLabel, label: cleanLabel,
showInInstaller: seed ? seed.showInInstaller : true, showInInstaller: seed ? seed.showInInstaller : true,
showInInstallerRp: seed ? seed.showInInstallerRp : true showInInstallerRp: seed ? seed.showInInstallerRp : true,
showInFinalResourcepack: seed ? seed.showInFinalResourcepack : false
} }
await saveTermsMeta(packKey, meta) await saveTermsMeta(packKey, meta)
} }
@@ -778,14 +817,24 @@ export function isPublicTermsFile(packKey: string, fileName: string): boolean {
} }
export async function readAccounts(): Promise<AccountEntry[]> { export async function readAccounts(): Promise<AccountEntry[]> {
// gitignore 된 account.local.json 우선. 없으면(ENOENT) 추적되는 account.json 을 시드로.
for (const filePath of [accountLocalFilePath, accountFilePath]) {
try { try {
const raw = await fsp.readFile(accountFilePath, 'utf8') const raw = await fsp.readFile(filePath, 'utf8')
const parsed = JSON.parse(raw) const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return [] if (!Array.isArray(parsed)) return []
return parsed.filter((entry): entry is AccountEntry => return parsed.filter((entry): entry is AccountEntry =>
typeof entry?.id === 'string' && typeof entry?.password === 'string') typeof entry?.id === 'string' && typeof entry?.password === 'string')
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
throw error throw error
} }
}
return []
}
// 운영 계정 저장은 항상 gitignore 된 account.local.json 에만 한다(추적 파일 오염 방지).
// 비밀번호(해시) 파일이므로 소유자만 읽기/쓰기(0o600).
export async function writeAccounts(accounts: AccountEntry[]): Promise<void> {
await fsp.writeFile(accountLocalFilePath, `${JSON.stringify(accounts, null, 2)}\n`, { mode: 0o600 })
} }

View File

@@ -14,8 +14,14 @@ export interface PackDefinition {
platform: PackPlatform platform: PackPlatform
/** /file/mods/<modsFolder>/ 폴더 안의 모든 .jar을 자동 다운로드. */ /** /file/mods/<modsFolder>/ 폴더 안의 모든 .jar을 자동 다운로드. */
modsFolder: string modsFolder: string
/** /file/resourcepacks/<resourcepackPath> 의 단일 .zip을 그대로 다운로드. */ /** /file/resourcepacks/<resourcepackPath> 의 단일 .zip을 그대로 다운로드(리소스팩 설치기의 베이스). */
resourcepackPath: string resourcepackPath: string
/**
* 간편설치기 마지막에 선택 설치하는 "최종 리소스팩" zip 파일 이름.
* /file/resourcepacks/outputs/<finalResourcepackPath> 에서 받는다.
* `"."` 이면 없음(설치 여부 질문 자체를 건너뜀). 기본값은 `"."`.
*/
finalResourcepackPath: string
/** /**
* 리소스팩 설치기가 만들어 내는 최종 zip 파일의 이름(확장자 제외). * 리소스팩 설치기가 만들어 내는 최종 zip 파일의 이름(확장자 제외).
* 빈 문자열이면 설치기가 `<packKey>_resourcepack` 형식으로 기본 이름을 만든다. * 빈 문자열이면 설치기가 `<packKey>_resourcepack` 형식으로 기본 이름을 만든다.
@@ -28,6 +34,11 @@ export interface PackDefinition {
serverMaxRam: number serverMaxRam: number
clientMinRam: number clientMinRam: number
clientRecommendedRam: number clientRecommendedRam: number
/**
* 서버 실행에 권장하는 JDK 메이저 버전(예: 25). 설치기가 이 버전을 우선 탐색/설치한다.
* 사이트에서 선택하며, 허용 목록(SUPPORTED_JDK_MAJORS) 밖의 값은 기본값으로 보정된다.
*/
recommendedJdk: number
/** /file/maps/<mapPath> 에서 받아 .mc_custom/saves 로 풀 zip 파일 이름. */ /** /file/maps/<mapPath> 에서 받아 .mc_custom/saves 로 풀 zip 파일 이름. */
mapPath: string mapPath: string
/** /file/servers/<serverPath> 에서 받아 서버 설치 경로로 풀 zip 파일 이름. */ /** /file/servers/<serverPath> 에서 받아 서버 설치 경로로 풀 zip 파일 이름. */

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["src/installer-uninstall/**/*.ts", "src/shared/**/*.ts"]
}

View File

@@ -80,6 +80,17 @@
<span><%= t('editor.clientRecommendedRam') %></span> <span><%= t('editor.clientRecommendedRam') %></span>
<input type="number" name="clientRecommendedRam" value="<%= pack.clientRecommendedRam %>" min="512" required /> <input type="number" name="clientRecommendedRam" value="<%= pack.clientRecommendedRam %>" min="512" required />
</label> </label>
<label>
<span><%= t('editor.recommendedJdk') %></span>
<select name="recommendedJdk" id="recommendedJdk" data-current="<%= pack.recommendedJdk %>" required>
<option value="<%= pack.recommendedJdk %>" selected>Java <%= pack.recommendedJdk %></option>
</select>
<label class="muted" style="font-weight:normal;display:flex;align-items:center;gap:6px;margin-top:4px;">
<input type="checkbox" id="jdkShowDetails" style="width:auto;" /> <%= t('editor.jdkShowDetails') %>
</label>
<div id="jdkDetails" class="muted" hidden style="margin-top:4px;font-size:12px;line-height:1.6;word-break:break-all;"></div>
<small class="muted"><%= t('editor.recommendedJdkHint') %></small>
</label>
<label> <label>
<span><%= t('editor.mapPath') %></span> <span><%= t('editor.mapPath') %></span>
<input name="mapPath" value="<%= pack.mapPath %>" placeholder="my-map.zip" pattern=".+\.zip" /> <input name="mapPath" value="<%= pack.mapPath %>" placeholder="my-map.zip" pattern=".+\.zip" />
@@ -108,6 +119,11 @@
<input name="outputPackName" value="<%= pack.outputPackName %>" placeholder="<%= t('editor.outputPackNamePlaceholder') %>" /> <input name="outputPackName" value="<%= pack.outputPackName %>" placeholder="<%= t('editor.outputPackNamePlaceholder') %>" />
<small class="muted"><%= t('editor.outputPackNameHint') %></small> <small class="muted"><%= t('editor.outputPackNameHint') %></small>
</label> </label>
<label class="fullSpan">
<span><%= t('editor.finalResourcepackPath') %></span>
<input name="finalResourcepackPath" value="<%= pack.finalResourcepackPath %>" placeholder="my-pack.zip ( . = 없음 )" />
<small class="muted"><%= t('editor.finalResourcepackHint') %></small>
</label>
</div> </div>
<button class="primaryButton" type="submit"><%= t('common.save') %></button> <button class="primaryButton" type="submit"><%= t('common.save') %></button>
@@ -126,6 +142,83 @@
function formatLoaderLoadFailed(message) { function formatLoaderLoadFailed(message) {
return I18N.loaderLoadFailedPrefix.replace('__M__', message) return I18N.loaderLoadFailedPrefix.replace('__M__', message)
} }
var JDK = {
current: <%= pack.recommendedJdk %>,
fallback: <%- JSON.stringify(jdkOptions) %>,
detailsGa: <%- JSON.stringify(t('editor.jdkDetailsGa')) %>,
detailsEa: <%- JSON.stringify(t('editor.jdkDetailsEa')) %>,
detailsLoading: <%- JSON.stringify(t('common.loading')) %>,
detailsNone: <%- JSON.stringify(t('editor.jdkDetailsNone')) %>,
ltsSuffix: <%- JSON.stringify(t('editor.jdkLtsSuffix')) %>
}
</script>
<script>
(function () {
var sel = document.getElementById('recommendedJdk')
var details = document.getElementById('jdkDetails')
var toggle = document.getElementById('jdkShowDetails')
if (!sel || !toggle) return
var current = Number(sel.getAttribute('data-current')) || JDK.current
var avail = null // { available:[], lts:[] }
function rebuild() {
var availList, ltsList
if (avail) { availList = avail.available; ltsList = avail.lts }
else { availList = JDK.fallback.slice().sort(function (a, b) { return b - a }); ltsList = availList }
var ltsSet = {}
ltsList.forEach(function (m) { ltsSet[m] = true })
var list = toggle.checked ? availList.slice() : ltsList.slice()
if (list.indexOf(current) < 0) list = [current].concat(list)
var seen = {}, html = ''
list.forEach(function (m) {
if (seen[m]) return
seen[m] = true
var label = 'Java ' + m + (ltsSet[m] ? JDK.ltsSuffix : '')
html += '<option value="' + m + '"' + (m === current ? ' selected' : '') + '>' + label + '</option>'
})
sel.innerHTML = html
sel.value = String(current)
}
function escHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : c === '"' ? '&quot;' : '&#39;'
})
}
function detailRow(label, names) {
return '<div><strong>' + escHtml(label) + ':</strong> ' +
names.slice(0, 6).map(escHtml).join(', ') + '</div>'
}
function loadDetails(major) {
details.hidden = false
details.textContent = JDK.detailsLoading
fetch('/op/jdk-versions/' + major)
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json() })
.then(function (d) {
// release_name 은 외부 API 값이라 반드시 escape 후 삽입(관리 페이지 XSS 방지).
var html = ''
if (d.ga && d.ga.length) html += detailRow(JDK.detailsGa, d.ga)
if (d.ea && d.ea.length) html += detailRow(JDK.detailsEa, d.ea)
details.innerHTML = html || escHtml(JDK.detailsNone)
})
.catch(function () { details.textContent = JDK.detailsNone })
}
sel.addEventListener('change', function () {
current = Number(sel.value)
if (toggle.checked) loadDetails(current)
})
toggle.addEventListener('change', function () {
rebuild()
if (toggle.checked) loadDetails(current)
else { details.hidden = true; details.innerHTML = '' }
})
fetch('/op/jdk-versions')
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json() })
.then(function (d) { avail = d; rebuild() })
.catch(function () { rebuild() })
})()
</script> </script>
<script> <script>
(function () { (function () {

View File

@@ -87,6 +87,7 @@
<span class="visibilityBadges"> <span class="visibilityBadges">
<span class="visibilityBadge <%= item.showInInstaller ? '' : 'off' %>"><%= t('terms.visibilityInstallerShort') %></span> <span class="visibilityBadge <%= item.showInInstaller ? '' : 'off' %>"><%= t('terms.visibilityInstallerShort') %></span>
<span class="visibilityBadge <%= item.showInInstallerRp ? '' : 'off' %>"><%= t('terms.visibilityInstallerRpShort') %></span> <span class="visibilityBadge <%= item.showInInstallerRp ? '' : 'off' %>"><%= t('terms.visibilityInstallerRpShort') %></span>
<span class="visibilityBadge <%= item.showInFinalResourcepack ? '' : 'off' %>"><%= t('terms.visibilityFinalResourcepackShort') %></span>
</span> </span>
</div> </div>
<div class="termsRowSub"><%= item.kind %>.md</div> <div class="termsRowSub"><%= item.kind %>.md</div>

View File

@@ -36,10 +36,14 @@
<input type="checkbox" id="visInstaller" <%= showInInstaller ? 'checked' : '' %> /> <input type="checkbox" id="visInstaller" <%= showInInstaller ? 'checked' : '' %> />
<span><%= t('terms.visibilityInstaller') %></span> <span><%= t('terms.visibilityInstaller') %></span>
</label> </label>
<label style="display:inline-flex; align-items:center; gap:6px;"> <label style="display:inline-flex; align-items:center; gap:6px; margin-right:18px;">
<input type="checkbox" id="visInstallerRp" <%= showInInstallerRp ? 'checked' : '' %> /> <input type="checkbox" id="visInstallerRp" <%= showInInstallerRp ? 'checked' : '' %> />
<span><%= t('terms.visibilityInstallerRp') %></span> <span><%= t('terms.visibilityInstallerRp') %></span>
</label> </label>
<label style="display:inline-flex; align-items:center; gap:6px;">
<input type="checkbox" id="visFinalResourcepack" <%= showInFinalResourcepack ? 'checked' : '' %> />
<span><%= t('terms.visibilityFinalResourcepack') %></span>
</label>
</fieldset> </fieldset>
<p class="muted" style="font-size:12px;"><%= t('terms.slashHint') %></p> <p class="muted" style="font-size:12px;"><%= t('terms.slashHint') %></p>