Files
minecraft_launcher/src/shared/store.ts
claude-bot 7d0f1719f3 fabric: 로더 버전 선택 + fabric-installer CLI 자동 설치
관리 사이트에서 모드 플랫폼으로 fabric 을 선택하면 jar 파일 업로드 대신, 선택한 마인크래프트 버전을 기준으로 Fabric Meta v2 API 에서 호환 로더 목록을 가져와 드롭다운으로 선택하도록 했다. 설치기는 platform.loaderVersion 만 보고 최신 fabric-installer.jar 를 받아 CLI 로 자동 설치(GUI 미표시)한다.

스키마:
- PackPlatform 에 loaderVersion?: string 추가. fabric 일 때만 사용.
- normalizePackDefinition: fabric 이면 downloadUrl 무시하고 loaderVersion 만 저장, 그 외에는 기존 downloadUrl 유지.

웹 UI(views/op/editor.ejs):
- platformType 이 fabric 일 때 platformLoaderVersion select 노출. mcVersion 셀렉트 값을 가지고 https://meta.fabricmc.net/v2/versions/loader/<mcVersion> 호출.
- mcVersion 또는 platformType 변경 시 자동 재조회. 동시 요청 경쟁은 sequence 비교로 무시.
- 이전 저장값을 우선 선택하되 목록에 없으면 최신 stable 자동 선택.
- 폼 제출 시 fabric 인데 로더 미선택이면 경고.
- 라우트(op.ts): platformLoaderVersion 폼 필드 수신.

설치기(installer/main.ts):
- client:install 분기 추가. fabric 이면 installFabricLoader 호출.
- installFabricLoader: Fabric Meta installer 메타 조회 → 최신 stable installer jar 캐시 다운로드 → java -jar fabric-installer.jar client -mcversion <ver> -loader <ver> -dir <.mc_custom> -noprofile 실행. launcher_profiles 갱신은 우리 코드(updateLauncherProfile)가 담당하므로 -noprofile.
- findJavaExecutable: JAVA_HOME → .minecraft\runtime 의 번들 자바(델타/감마/베타 등 우선순위) → PATH 폴백.
- runJavaProcess: stdout/stderr 를 로그 뷰어에 prefix 와 함께 스트리밍. 실패 시 stderr 끝부분을 메시지에 포함.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 01:28:45 +09:00

287 lines
10 KiB
TypeScript

import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
import { manifestRootPath, manifestDirPath, accountFilePath, fileListDirPath } from './paths.js'
import type {
Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType,
PackList, MusicListEntry, ImageListEntry
} from './types.js'
export async function readManifest(): Promise<Manifest> {
try {
const raw = await fsp.readFile(manifestRootPath, 'utf8')
const parsed = JSON.parse(raw) as Partial<Manifest>
if (!parsed || !Array.isArray(parsed.packs)) {
return { packs: [] }
}
return {
packs: parsed.packs.filter((entry): entry is ManifestEntry =>
typeof entry?.name === 'string' && typeof entry?.file === 'string')
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { packs: [] }
}
throw error
}
}
export async function writeManifest(manifest: Manifest): Promise<void> {
await fsp.writeFile(manifestRootPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
}
export function defaultPackDefinition(name: string): PackDefinition {
return {
name,
mcVersion: '1.20.1',
platform: { type: 'vanilla' },
modsFolder: '',
resourcepackPath: '',
serverMinRam: 2048,
serverMaxRam: 4096,
clientMinRam: 2048,
clientRecommendedRam: 4096,
mapPath: '',
serverPath: ''
}
}
function sanitizeZipFileName(input: unknown): string {
if (typeof input !== 'string') return ''
const trimmed = input.trim().replace(/^\/+/, '')
if (trimmed.length === 0) return ''
// 빈 값 허용, .zip 으로 끝나야 함, 경로 탈출 방지
if (trimmed.includes('..') || trimmed.includes('\\')) return ''
if (!/\.zip$/i.test(trimmed)) return ''
return trimmed
}
// 모드 폴더명: 영문/숫자/언더스코어/하이픈만 허용. 빈 값 허용.
function sanitizeFolderName(input: unknown): string {
if (typeof input !== 'string') return ''
const trimmed = input.trim().replace(/^\/+|\/+$/g, '')
if (trimmed.length === 0) return ''
if (!/^[a-zA-Z0-9_\-]+$/.test(trimmed)) return ''
return trimmed
}
const ALLOWED_LOADERS: LoaderType[] = ['vanilla', 'forge', 'fabric', 'neoforge']
export function normalizePackDefinition(input: Partial<PackDefinition> & Record<string, unknown>): PackDefinition {
const fallback = defaultPackDefinition(typeof input.name === 'string' ? input.name : 'new')
const platform = (input.platform ?? {}) as Partial<PackDefinition['platform']>
const platformType = ALLOWED_LOADERS.includes(platform.type as LoaderType)
? (platform.type as LoaderType)
: 'vanilla'
return {
name: typeof input.name === 'string' && input.name.trim().length > 0 ? input.name.trim() : fallback.name,
mcVersion: typeof input.mcVersion === 'string' && input.mcVersion.trim().length > 0
? input.mcVersion.trim()
: fallback.mcVersion,
platform: {
type: platformType,
// fabric 은 downloadUrl 을 쓰지 않고 loaderVersion 기반으로 자동 설치한다.
downloadUrl: platformType !== 'fabric'
&& typeof platform.downloadUrl === 'string'
&& platform.downloadUrl.trim().length > 0
? platform.downloadUrl.trim()
: undefined,
loaderVersion: platformType === 'fabric'
&& typeof (platform as { loaderVersion?: unknown }).loaderVersion === 'string'
&& ((platform as { loaderVersion?: string }).loaderVersion ?? '').trim().length > 0
? ((platform as { loaderVersion?: string }).loaderVersion ?? '').trim()
: undefined
},
modsFolder: sanitizeFolderName(input.modsFolder),
resourcepackPath: sanitizeZipFileName(input.resourcepackPath),
serverMinRam: clampNumber(input.serverMinRam, fallback.serverMinRam),
serverMaxRam: clampNumber(input.serverMaxRam, fallback.serverMaxRam),
clientMinRam: clampNumber(input.clientMinRam, fallback.clientMinRam),
clientRecommendedRam: clampNumber(input.clientRecommendedRam, fallback.clientRecommendedRam),
mapPath: sanitizeZipFileName(input.mapPath),
serverPath: sanitizeZipFileName(input.serverPath)
}
}
function clampNumber(input: unknown, fallback: number): number {
const value = typeof input === 'number' ? input : Number(input)
if (!Number.isFinite(value) || value <= 0) return fallback
return Math.floor(value)
}
export function packKeyFromFile(fileName: string): string {
return fileName.replace(/\.json$/i, '')
}
export async function loadPackDefinition(packKey: string): Promise<PackDefinition | null> {
const filePath = path.join(manifestDirPath, `${packKey}.json`)
try {
const raw = await fsp.readFile(filePath, 'utf8')
const parsed = JSON.parse(raw) as Partial<PackDefinition>
return normalizePackDefinition(parsed)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
throw error
}
}
export async function savePackDefinition(packKey: string, pack: PackDefinition): Promise<void> {
await fsp.mkdir(manifestDirPath, { recursive: true })
const filePath = path.join(manifestDirPath, `${packKey}.json`)
await fsp.writeFile(filePath, `${JSON.stringify(pack, null, 2)}\n`, 'utf8')
}
export async function listPackKeys(): Promise<string[]> {
try {
const entries = await fsp.readdir(manifestDirPath)
return entries
.filter((name) => name.toLowerCase().endsWith('.json'))
.map(packKeyFromFile)
.sort((a, b) => a.localeCompare(b, 'ko'))
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
throw error
}
}
export async function nextNewKey(): Promise<string> {
const used = new Set(await listPackKeys())
if (!used.has('new')) return 'new'
for (let i = 2; i < 1000; i += 1) {
const candidate = `new${i}`
if (!used.has(candidate)) return candidate
}
return `new-${Date.now()}`
}
export async function createPack(): Promise<{ key: string; pack: PackDefinition }> {
const key = await nextNewKey()
const pack = defaultPackDefinition(key)
await savePackDefinition(key, pack)
await syncManifestWith(key, pack.name, 'add')
return { key, pack }
}
export async function deletePackKeys(keys: string[]): Promise<void> {
for (const key of keys) {
if (!key) continue
const filePath = path.join(manifestDirPath, `${key}.json`)
try {
await fsp.unlink(filePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
await syncManifestWith(key, '', 'remove')
}
}
export async function renamePack(oldKey: string, newKey: string, pack: PackDefinition): Promise<string> {
const safeNew = sanitizePackKey(newKey) || oldKey
const targetPath = path.join(manifestDirPath, `${safeNew}.json`)
const sourcePath = path.join(manifestDirPath, `${oldKey}.json`)
if (safeNew !== oldKey && fs.existsSync(targetPath)) {
throw new Error(`이미 ${safeNew}.json 이름의 음악퀴즈가 있습니다.`)
}
await savePackDefinition(safeNew, pack)
if (safeNew !== oldKey) {
try {
await fsp.unlink(sourcePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
await syncManifestWith(oldKey, '', 'remove')
}
await syncManifestWith(safeNew, pack.name, 'upsert')
return safeNew
}
export function sanitizePackKey(value: string): string {
return value.replace(/[^a-zA-Z0-9_\-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
}
type ManifestSyncAction = 'add' | 'remove' | 'upsert'
async function syncManifestWith(key: string, name: string, action: ManifestSyncAction): Promise<void> {
const manifest = await readManifest()
const filtered = manifest.packs.filter((entry) => entry.file !== key)
if (action === 'remove') {
await writeManifest({ packs: filtered })
return
}
filtered.push({ name: name || key, file: key })
await writeManifest({ packs: filtered })
}
function defaultPackList(): PackList {
return { musicPlaylistUrl: '', imagePlaylistUrl: '', music: [], images: [] }
}
function sanitizeStr(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function sanitizeNumber(value: unknown): number {
const n = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(n) || n < 0) return 0
return Math.floor(n)
}
export function normalizePackList(input: unknown): PackList {
const fallback = defaultPackList()
if (!input || typeof input !== 'object') return fallback
const obj = input as Record<string, unknown>
const music = Array.isArray(obj.music) ? obj.music : []
const images = Array.isArray(obj.images) ? obj.images : []
return {
musicPlaylistUrl: sanitizeStr(obj.musicPlaylistUrl),
imagePlaylistUrl: sanitizeStr(obj.imagePlaylistUrl),
music: music
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object')
.map((entry): MusicListEntry => ({
url: sanitizeStr(entry.url),
title: sanitizeStr(entry.title),
artist: sanitizeStr(entry.artist),
durationSec: sanitizeNumber(entry.durationSec)
}))
.filter((entry) => entry.url.length > 0),
images: images
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object')
.map((entry): ImageListEntry => ({ url: sanitizeStr(entry.url) }))
.filter((entry) => entry.url.length > 0)
}
}
export async function loadPackList(packKey: string): Promise<PackList> {
const filePath = path.join(fileListDirPath, `${packKey}.json`)
try {
const raw = await fsp.readFile(filePath, 'utf8')
return normalizePackList(JSON.parse(raw))
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return defaultPackList()
throw error
}
}
export async function savePackList(packKey: string, list: PackList): Promise<void> {
await fsp.mkdir(fileListDirPath, { recursive: true })
const filePath = path.join(fileListDirPath, `${packKey}.json`)
const normalized = normalizePackList(list)
await fsp.writeFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
}
export async function readAccounts(): Promise<AccountEntry[]> {
try {
const raw = await fsp.readFile(accountFilePath, 'utf8')
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter((entry): entry is AccountEntry =>
typeof entry?.id === 'string' && typeof entry?.password === 'string')
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
throw error
}
}