Compare commits

..

16 Commits

Author SHA1 Message Date
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
35 changed files with 1798 additions and 269 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 코어 수로 자동 결정.

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).
--- ---

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. 서버 다운로드 및 설치

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

@@ -232,63 +232,59 @@ 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'
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 +293,71 @@ 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">' + 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 +593,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 +640,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 +675,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 +699,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 })
} }
})() })()
} }

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": "취소 중…"
}, },
@@ -97,6 +100,8 @@
"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 +135,8 @@
"baseDownloading": "베이스 리소스팩 다운로드 중", "baseDownloading": "베이스 리소스팩 다운로드 중",
"buildingWithBase": "베이스에 음악·사진 추가 중", "buildingWithBase": "베이스에 음악·사진 추가 중",
"buildingZip": "zip 빌드 중", "buildingZip": "zip 빌드 중",
"installComplete": "설치 완료" "installComplete": "설치 완료",
"imageRetry": "속도제한(HTTP {{code}}) — {{secs}}초 후 재시도"
}, },
"pack": { "pack": {
"description": "음악퀴즈 리소스팩 - {{name}}" "description": "음악퀴즈 리소스팩 - {{name}}"
@@ -142,6 +148,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": "서버 다운로드 및 설치",
@@ -195,10 +205,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}}",
@@ -221,6 +232,8 @@
"skipResourcepack": "resourcepackPath가 비어 있어 리소스팩 다운로드를 건너뜁니다.", "skipResourcepack": "resourcepackPath가 비어 있어 리소스팩 다운로드를 건너뜁니다.",
"resourcepackDownload": "리소스팩 다운로드: {{url}}", "resourcepackDownload": "리소스팩 다운로드: {{url}}",
"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}}",

View File

@@ -125,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)",

View File

@@ -1,6 +1,6 @@
{ {
"name": "minecraft-music-quiz-installer", "name": "minecraft-music-quiz-installer",
"version": "0.3.23", "version": "0.4.13",
"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

@@ -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종 화이트리스트 대신
@@ -485,12 +510,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 +540,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 +702,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,14 @@ 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',
// 진행률 표시 안정화 (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'))))
}) })
} }
@@ -251,43 +284,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 +537,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 +791,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 +855,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 +884,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 +1272,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 })
@@ -1582,7 +1815,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

@@ -24,8 +24,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

View File

@@ -23,10 +23,12 @@ import {
savePackList, savePackList,
setPackPublic, setPackPublic,
setTermVisibility, setTermVisibility,
writeAccounts writeAccounts,
SUPPORTED_JDK_MAJORS
} from '../../shared/store.js' } from '../../shared/store.js'
import { hashPassword, isHashed, verifyPassword } from '../password.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'
@@ -187,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) => {
@@ -570,6 +598,7 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
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

@@ -39,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

@@ -52,6 +52,7 @@ export function defaultPackDefinition(name: string): PackDefinition {
serverMaxRam: 4096, serverMaxRam: 4096,
clientMinRam: 2048, clientMinRam: 2048,
clientRecommendedRam: 4096, clientRecommendedRam: 4096,
recommendedJdk: DEFAULT_JDK_MAJOR,
mapPath: '', mapPath: '',
serverPath: '' serverPath: ''
} }
@@ -78,6 +79,24 @@ 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
}
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']>
@@ -112,6 +131,7 @@ export function normalizePackDefinition(input: Partial<PackDefinition> & Record<
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)
} }

View File

@@ -28,6 +28,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" />
@@ -126,6 +137,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 () {