Refactor launcher profiles and port automation
Some checks failed
Build / release (macos-latest) (push) Has been cancelled
Build / release (ubuntu-latest) (push) Has been cancelled
Build / release (windows-latest) (push) Has been cancelled
Windows Smoke Test / windows-smoke (push) Has been cancelled

This commit is contained in:
2026-05-05 21:52:17 +09:00
parent e266387784
commit 9786cfe031
22 changed files with 1558 additions and 798 deletions

View File

@@ -2,37 +2,45 @@
"version": 1,
"profiles": [
{
"id": "mrs-concatenation-lite",
"name": "Concatenation Lite",
"kind": "modpack",
"description": "기존 MRS 분배 인덱스를 사용하는 예시 프로필입니다. 프로필 카탈로그 구조가 동작하는지 확인할 때 사용할 수 있습니다.",
"details": "Mystic Red Space에서 운영하던 기존 분배 인덱스를 그대로 사용합니다.\n\n사용자는 이 항목을 라이브러리에 추가한 뒤 바로 실행할 수 있습니다.\n관리자는 distribution 파일만 교체해서 실서비스용 항목으로 바꿀 수 있습니다.",
"distributionUrl": "https://cdn.mysticred.space/launcher/distribution.json"
"id": "template-map-base",
"name": "Map Base Template",
"description": "맵만 사용하는 기본 프로필 예시입니다.",
"details": "맵 기반 기본 프로필입니다. 월드 ZIP과 distribution 파일만 있으면 싱글플레이 실행 흐름으로 사용할 수 있습니다.",
"modsEnabled": false,
"pluginsEnabled": false,
"serverEnabled": false,
"distributionUrl": "https://example.com/launcher/map-base.distribution.json",
"worldArchiveUrl": "https://example.com/worlds/map-base.zip",
"worldDirectoryName": "Map Base"
},
{
"id": "template-original-map",
"name": "Original Map Template",
"kind": "map",
"description": "오리지널 맵용 템플릿입니다. vanilla 또는 맵 전용 distribution URL과 월드 ZIP 경로를 채우면 싱글플레이 빠른 실행에 사용할 수 있습니다.",
"details": "관리자는 이 항목에 월드 ZIP과 클라이언트 distribution URL을 미리 등록합니다.\n\n사용자는 설치 페이지에서 설명을 읽고 라이브러리에 추가한 뒤, 라이브러리에서 맵 자료를 준비하고 바로 실행할 수 있습니다.",
"distributionUrl": "https://example.com/launcher/vanilla-map-distribution.json",
"worldArchiveUrl": "https://example.com/maps/original-map.zip",
"worldDirectoryName": "Original Map"
"id": "template-map-mods",
"name": "Map + Mods Template",
"description": "맵과 모드를 함께 쓰는 프로필 예시입니다.",
"details": "맵 기반에 모드 구성이 포함된 프로필입니다. distribution 파일은 모드가 포함된 클라이언트용으로 준비하면 됩니다.",
"modsEnabled": true,
"pluginsEnabled": false,
"serverEnabled": false,
"distributionUrl": "https://example.com/launcher/map-mods.distribution.json",
"worldArchiveUrl": "https://example.com/worlds/map-mods.zip",
"worldDirectoryName": "Map Mods"
},
{
"id": "template-plugin-server-pack",
"name": "Plugin Server Pack Template",
"kind": "server-pack",
"description": "플러그인 맵 + 서버 접속용 템플릿입니다. 클라이언트 distribution, 서버 번들 ZIP, 서버 시작 명령, 선택형 터널 명령을 연결하면 됩니다.",
"details": "관리자는 클라이언트 배포 파일, 서버 번들, 서버 시작 명령을 미리 등록합니다.\n\n사용자는 설치 페이지에서 상세 내용을 확인하고 라이브러리에 추가한 뒤, 라이브러리에서 직접 서버를 켜거나 수동 주소를 넣어 접속할 수 있습니다.",
"distributionUrl": "https://example.com/launcher/server-pack-client-distribution.json",
"serverBundleUrl": "https://example.com/serverpacks/plugin-world-server.zip",
"serverDirectoryName": "plugin-world-server",
"serverLaunchCommand": "java -jar server.jar nogui",
"serverWorkingDirectory": "",
"id": "template-map-plugin-server",
"name": "Map + Plugin Server Template",
"description": "맵, 플러그인, 서버를 함께 쓰는 프로필 예시입니다.",
"details": "플러그인을 켜면 서버도 같이 사용합니다. 주소를 비우면 로컬 서버를 띄우고, 주소를 입력하면 해당 서버로 바로 접속하는 흐름에 맞춘 예시입니다.",
"modsEnabled": false,
"pluginsEnabled": true,
"serverEnabled": true,
"distributionUrl": "https://example.com/launcher/map-plugin-server.distribution.json",
"worldArchiveUrl": "https://example.com/worlds/plugin-map.zip",
"worldDirectoryName": "Plugin Map",
"serverJarUrl": "https://example.com/server/paper.jar",
"serverPort": 25565,
"tunnelCommand": "",
"tunnelAddressRegex": ""
"serverMemoryMb": 4096,
"serverMaxPlayers": 20,
"serverWhitelistEnabled": false
}
]
}

View File

@@ -19,8 +19,8 @@ const editorHint = document.getElementById('editorHint')
const saveCatalogButton = document.getElementById('saveCatalogButton')
const duplicateProfileButton = document.getElementById('duplicateProfileButton')
const deleteProfileButton = document.getElementById('deleteProfileButton')
const mapSection = document.getElementById('mapSection')
const serverPackSection = document.getElementById('serverPackSection')
const addProfileButton = document.getElementById('addProfileButton')
const serverSection = document.getElementById('serverSection')
const editDistributionButton = document.getElementById('editDistributionButton')
const createDistributionButton = document.getElementById('createDistributionButton')
const distributionEditorModal = document.getElementById('distributionEditorModal')
@@ -32,20 +32,20 @@ const saveDistributionFileButton = document.getElementById('saveDistributionFile
const fieldElements = {
id: document.getElementById('field-id'),
kind: document.getElementById('field-kind'),
name: document.getElementById('field-name'),
description: document.getElementById('field-description'),
details: document.getElementById('field-details'),
modsEnabled: document.getElementById('field-modsEnabled'),
pluginsEnabled: document.getElementById('field-pluginsEnabled'),
serverEnabled: document.getElementById('field-serverEnabled'),
distributionUrl: document.getElementById('field-distributionUrl'),
worldArchiveUrl: document.getElementById('field-worldArchiveUrl'),
worldDirectoryName: document.getElementById('field-worldDirectoryName'),
serverBundleUrl: document.getElementById('field-serverBundleUrl'),
serverDirectoryName: document.getElementById('field-serverDirectoryName'),
serverLaunchCommand: document.getElementById('field-serverLaunchCommand'),
serverWorkingDirectory: document.getElementById('field-serverWorkingDirectory'),
serverJarUrl: document.getElementById('field-serverJarUrl'),
serverPort: document.getElementById('field-serverPort'),
tunnelCommand: document.getElementById('field-tunnelCommand'),
tunnelAddressRegex: document.getElementById('field-tunnelAddressRegex')
serverMemoryMb: document.getElementById('field-serverMemoryMb'),
serverMaxPlayers: document.getElementById('field-serverMaxPlayers'),
serverWhitelistEnabled: document.getElementById('field-serverWhitelistEnabled')
}
function slugify(value){
@@ -60,24 +60,24 @@ function isRemoteUrl(value){
return /^https?:\/\//i.test(String(value ?? '').trim())
}
function createProfile(kind){
function createProfile(){
const timestamp = Date.now()
return {
id: `${kind}-${timestamp}`,
name: kind === 'map' ? '새 맵 프로필' : kind === 'server-pack' ? '새 서버팩 프로필' : '새 모드팩 프로필',
kind,
id: `profile-${timestamp}`,
name: '새 프로필',
description: '',
details: '',
modsEnabled: false,
pluginsEnabled: false,
serverEnabled: false,
distributionUrl: '',
worldArchiveUrl: '',
worldDirectoryName: '',
serverBundleUrl: '',
serverDirectoryName: `${kind}-${timestamp}-server`,
serverLaunchCommand: '',
serverWorkingDirectory: '',
serverJarUrl: '',
serverPort: 25565,
tunnelCommand: '',
tunnelAddressRegex: ''
serverMemoryMb: 4096,
serverMaxPlayers: 20,
serverWhitelistEnabled: false
}
}
@@ -108,6 +108,20 @@ function selectProfile(profileId){
populateEditor()
}
function describeProfileFeatures(profile){
const badges = ['맵']
if(profile.modsEnabled){
badges.push('모드')
}
if(profile.pluginsEnabled){
badges.push('플러그인')
}
if(profile.serverEnabled){
badges.push('서버')
}
return badges
}
function renderSidebar(){
profileList.innerHTML = ''
profileCount.textContent = `${state.catalog.profiles.length}`
@@ -126,10 +140,9 @@ function renderSidebar(){
const meta = document.createElement('div')
meta.className = 'profileListMeta'
meta.innerHTML = `
<span class="badge">${profile.kind}</span>
${profile.distributionUrl ? '<span class="badge">distribution 연결</span>' : '<span class="badge">distribution 비어있음</span>'}
`
meta.innerHTML = describeProfileFeatures(profile)
.map((label) => `<span class="badge">${label}</span>`)
.join('')
const description = document.createElement('div')
description.className = 'profileListDescription'
@@ -146,9 +159,18 @@ function renderSidebar(){
}
}
function syncKindSections(kind){
mapSection.hidden = kind !== 'map'
serverPackSection.hidden = kind !== 'server-pack'
function syncFeatureDependencies(profile, showMessage = false){
if(profile.pluginsEnabled && !profile.serverEnabled){
profile.serverEnabled = true
if(showMessage){
showStatus('플러그인 사용을 켜면 서버 사용도 자동으로 같이 켜집니다.', 'info')
}
}
}
function syncServerSection(profile){
serverSection.hidden = !profile?.serverEnabled
fieldElements.serverEnabled.disabled = profile?.pluginsEnabled === true
}
function updateDistributionEditorHint(profile, pathOverride){
@@ -183,29 +205,32 @@ function populateEditor(){
if(!profile){
editorHint.textContent = '왼쪽에서 프로필을 선택하거나 새 프로필을 추가하세요.'
syncServerSection(null)
updateDistributionEditorHint(null)
return
}
editorHint.textContent = `${profile.kind} 프로필을 편집 중입니다. 저장하면 런처 카탈로그도 같이 갱신됩니다.`
syncFeatureDependencies(profile)
editorHint.textContent = '맵은 기본이고, 모드/플러그인/서버를 체크해서 조합하는 프로필입니다. 저장하면 런처 카탈로그도 같이 갱신됩니다.'
fieldElements.id.value = profile.id ?? ''
fieldElements.kind.value = profile.kind ?? 'modpack'
fieldElements.name.value = profile.name ?? ''
fieldElements.description.value = profile.description ?? ''
fieldElements.details.value = profile.details ?? ''
fieldElements.modsEnabled.checked = profile.modsEnabled === true
fieldElements.pluginsEnabled.checked = profile.pluginsEnabled === true
fieldElements.serverEnabled.checked = profile.serverEnabled === true
fieldElements.distributionUrl.value = profile.distributionUrl ?? ''
fieldElements.worldArchiveUrl.value = profile.worldArchiveUrl ?? ''
fieldElements.worldDirectoryName.value = profile.worldDirectoryName ?? ''
fieldElements.serverBundleUrl.value = profile.serverBundleUrl ?? ''
fieldElements.serverDirectoryName.value = profile.serverDirectoryName ?? ''
fieldElements.serverLaunchCommand.value = profile.serverLaunchCommand ?? ''
fieldElements.serverWorkingDirectory.value = profile.serverWorkingDirectory ?? ''
fieldElements.serverJarUrl.value = profile.serverJarUrl ?? ''
fieldElements.serverPort.value = profile.serverPort ?? 25565
fieldElements.tunnelCommand.value = profile.tunnelCommand ?? ''
fieldElements.tunnelAddressRegex.value = profile.tunnelAddressRegex ?? ''
fieldElements.serverMemoryMb.value = profile.serverMemoryMb ?? 4096
fieldElements.serverMaxPlayers.value = profile.serverMaxPlayers ?? 20
fieldElements.serverWhitelistEnabled.checked = profile.serverWhitelistEnabled === true
syncKindSections(profile.kind)
syncServerSection(profile)
updateDistributionEditorHint(profile)
}
@@ -216,8 +241,10 @@ function updateSelectedProfile(patch){
}
Object.assign(profile, patch)
syncFeatureDependencies(profile)
markDirty(true)
renderSidebar()
syncServerSection(profile)
if(Object.prototype.hasOwnProperty.call(patch, 'distributionUrl')){
updateDistributionEditorHint(profile, patch.distributionUrl)
@@ -248,15 +275,27 @@ function bindTextField(fieldName){
})
}
function bindNumberField(fieldName){
function bindNumberField(fieldName, fallback){
fieldElements[fieldName].addEventListener('input', (event) => {
const value = Number.parseInt(event.target.value || '25565', 10)
const value = Number.parseInt(event.target.value || String(fallback), 10)
updateSelectedProfile({
[fieldName]: Number.isFinite(value) ? value : 25565
[fieldName]: Number.isFinite(value) ? value : fallback
})
})
}
function bindCheckboxField(fieldName){
fieldElements[fieldName].addEventListener('change', (event) => {
updateSelectedProfile({
[fieldName]: event.target.checked
})
if(fieldName === 'pluginsEnabled' || fieldName === 'serverEnabled'){
populateEditor()
}
})
}
function bindProfileForm(){
profileEditorForm.addEventListener('submit', (event) => {
event.preventDefault()
@@ -269,30 +308,14 @@ function bindProfileForm(){
bindTextField('distributionUrl')
bindTextField('worldArchiveUrl')
bindTextField('worldDirectoryName')
bindTextField('serverBundleUrl')
bindTextField('serverDirectoryName')
bindTextField('serverLaunchCommand')
bindTextField('serverWorkingDirectory')
bindNumberField('serverPort')
bindTextField('tunnelCommand')
bindTextField('tunnelAddressRegex')
fieldElements.kind.addEventListener('change', (event) => {
const profile = getSelectedProfile()
if(!profile){
return
}
profile.kind = event.target.value
if(profile.kind === 'server-pack' && !profile.serverDirectoryName){
profile.serverDirectoryName = `${slugify(profile.id || profile.name) || 'profile'}-server`
}
markDirty(true)
renderSidebar()
populateEditor()
})
bindTextField('serverJarUrl')
bindNumberField('serverPort', 25565)
bindNumberField('serverMemoryMb', 4096)
bindNumberField('serverMaxPlayers', 20)
bindCheckboxField('modsEnabled')
bindCheckboxField('pluginsEnabled')
bindCheckboxField('serverEnabled')
bindCheckboxField('serverWhitelistEnabled')
for(const button of document.querySelectorAll('.uploadButton')){
button.addEventListener('click', async () => {
@@ -507,12 +530,23 @@ async function loadMeta(){
localCatalogUrl.href = meta.localCatalogUrl
}
function normalizeLoadedProfile(profile){
return {
...createProfile(),
...profile,
modsEnabled: profile.modsEnabled === true,
pluginsEnabled: profile.pluginsEnabled === true,
serverEnabled: profile.serverEnabled === true || profile.pluginsEnabled === true,
serverWhitelistEnabled: profile.serverWhitelistEnabled === true
}
}
async function loadCatalog(){
const response = await fetch('/api/catalog')
const catalog = await response.json()
state.catalog = {
version: 1,
profiles: Array.isArray(catalog.profiles) ? catalog.profiles : []
profiles: Array.isArray(catalog.profiles) ? catalog.profiles.map(normalizeLoadedProfile) : []
}
if(state.catalog.profiles.length > 0){
@@ -526,8 +560,8 @@ async function loadCatalog(){
populateEditor()
}
function addProfile(kind){
const profile = createProfile(kind)
function addProfile(){
const profile = createProfile()
state.catalog.profiles.push(profile)
markDirty(true)
selectProfile(profile.id)
@@ -587,7 +621,10 @@ async function saveCatalog(){
throw new Error(result.message || '카탈로그 저장에 실패했습니다.')
}
state.catalog = result.catalog
state.catalog = {
version: 1,
profiles: result.catalog.profiles.map(normalizeLoadedProfile)
}
if(!state.catalog.profiles.some((profile) => profile.id === state.selectedProfileId)){
state.selectedProfileId = state.catalog.profiles[0]?.id ?? null
}
@@ -605,11 +642,9 @@ async function saveCatalog(){
}
function bindTopLevelActions(){
for(const button of document.querySelectorAll('[data-add-kind]')){
button.addEventListener('click', () => {
addProfile(button.dataset.addKind)
})
}
addProfileButton.addEventListener('click', () => {
addProfile()
})
saveCatalogButton.addEventListener('click', async () => {
await saveCatalog()

View File

@@ -12,7 +12,7 @@
<div class="brandBlock">
<span class="eyebrow">Launcher Admin</span>
<h1>프로필 관리</h1>
<p>설치 페이지에 표시할 실제 프로필을 UI로 관리합니다.</p>
<p>설치 페이지에 표시할 실제 프로필을 조합형 UI로 관리합니다.</p>
</div>
<div class="metaPanel">
@@ -27,9 +27,7 @@
</div>
<div class="addButtons">
<button type="button" class="primaryAction" data-add-kind="modpack">모드팩 추가</button>
<button type="button" class="secondaryAction" data-add-kind="map">맵 추가</button>
<button type="button" class="secondaryAction" data-add-kind="server-pack">서버팩 추가</button>
<button type="button" class="primaryAction" id="addProfileButton">프로필 추가</button>
</div>
<div class="profileListHeader">
@@ -57,7 +55,7 @@
<div id="emptyState" class="emptyState">
<h3>프로필이 없습니다</h3>
<p>왼쪽 버튼으로 새 모드팩, 맵, 서버팩 프로필을 추가하세요.</p>
<p>왼쪽 버튼으로 새 프로필을 추가하세요. 맵은 기본이고, 모드/플러그인/서버는 체크해서 조합합니다.</p>
</div>
<form id="profileEditorForm" class="editorForm" hidden>
@@ -70,14 +68,6 @@
<span>프로필 ID</span>
<input id="field-id" type="text" autocomplete="off">
</label>
<label class="fieldBlock">
<span>종류</span>
<select id="field-kind">
<option value="modpack">modpack</option>
<option value="map">map</option>
<option value="server-pack">server-pack</option>
</select>
</label>
<label class="fieldBlock fieldBlockFull">
<span>표시 이름</span>
<input id="field-name" type="text" autocomplete="off">
@@ -93,6 +83,29 @@
</div>
</section>
<section class="fieldSection">
<div class="sectionHeader">
<h3>구성 옵션</h3>
</div>
<div class="fieldGrid">
<label class="toggleBlock">
<input id="field-modsEnabled" type="checkbox">
<span>모드 사용</span>
</label>
<label class="toggleBlock">
<input id="field-pluginsEnabled" type="checkbox">
<span>플러그인 사용</span>
</label>
<label class="toggleBlock">
<input id="field-serverEnabled" type="checkbox">
<span>서버 사용</span>
</label>
<div class="fieldHelpText fieldBlockFull">
맵은 모든 프로필의 기본입니다. 플러그인을 켜면 서버 사용도 자동으로 같이 켜집니다.
</div>
</div>
</section>
<section class="fieldSection">
<div class="sectionHeader">
<h3>클라이언트 배포</h3>
@@ -113,7 +126,7 @@
</div>
</section>
<section id="mapSection" class="fieldSection" hidden>
<section class="fieldSection">
<div class="sectionHeader">
<h3>맵 자료</h3>
</div>
@@ -132,42 +145,37 @@
</div>
</section>
<section id="serverPackSection" class="fieldSection" hidden>
<section id="serverSection" class="fieldSection" hidden>
<div class="sectionHeader">
<h3>서버 자료</h3>
<h3>서버 자료 / 설정</h3>
</div>
<div class="fieldGrid">
<label class="fieldBlock fieldBlockFull">
<span>서버 번들 ZIP 경로 또는 URL</span>
<span>버킷 JAR 경로 또는 URL</span>
<div class="uploadField">
<input id="field-serverBundleUrl" type="text" autocomplete="off">
<button type="button" class="secondaryAction uploadButton" data-upload-target="serverBundleUrl" data-upload-accept=".zip,application/zip">파일 업로드</button>
<input id="field-serverJarUrl" type="text" autocomplete="off">
<button type="button" class="secondaryAction uploadButton" data-upload-target="serverJarUrl" data-upload-accept=".jar,application/java-archive">JAR 업로드</button>
</div>
</label>
<label class="fieldBlock">
<span>서버 폴더 이름</span>
<input id="field-serverDirectoryName" type="text" autocomplete="off">
</label>
<label class="fieldBlock fieldBlockFull">
<span>서버 실행 명령</span>
<input id="field-serverLaunchCommand" type="text" autocomplete="off" placeholder="java -jar server.jar nogui">
</label>
<label class="fieldBlock">
<span>작업 디렉터리</span>
<input id="field-serverWorkingDirectory" type="text" autocomplete="off" placeholder="빈 값이면 서버 루트">
</label>
<label class="fieldBlock">
<span>서버 포트</span>
<input id="field-serverPort" type="number" min="1" max="65535" step="1">
</label>
<label class="fieldBlock fieldBlockFull">
<span>터널 명령</span>
<input id="field-tunnelCommand" type="text" autocomplete="off" placeholder="예: playit-cli --port ${port}">
<label class="fieldBlock">
<span>서버 메모리 (MB)</span>
<input id="field-serverMemoryMb" type="number" min="512" step="256">
</label>
<label class="fieldBlock fieldBlockFull">
<span>터널 주소 추출 정규식</span>
<input id="field-tunnelAddressRegex" type="text" autocomplete="off" placeholder="예: ([a-zA-Z0-9.-]+:\\d+)">
<label class="fieldBlock">
<span>최대 인원수</span>
<input id="field-serverMaxPlayers" type="number" min="1" max="200" step="1">
</label>
<label class="toggleBlock">
<input id="field-serverWhitelistEnabled" type="checkbox">
<span>화이트리스트 사용</span>
</label>
<div class="fieldHelpText fieldBlockFull">
직접 실행 시 런처가 `eula.txt`, `server.properties`를 자동으로 만들고 업로드한 버킷 JAR을 실행합니다.
</div>
</div>
</section>
@@ -175,7 +183,7 @@
<div class="sectionHeader">
<h3>운영 메모</h3>
</div>
<p>업로드 버튼은 현재 프로젝트 기준 상대 경로를 자동으로 채웁니다. 로컬 테스트에는 바로 쓸 수 있고, 외부 배포용으로는 URL로 바꿔도 됩니다.</p>
<p>업로드 버튼은 현재 프로젝트 기준 상대 경로를 자동으로 채웁니다. 접속주소는 관리자 사이트가 아니라, 사용자가 라이브러리에서 직접 넣는 구조입니다.</p>
</section>
</form>
</main>

View File

@@ -14,8 +14,6 @@ const LAUNCHER_CATALOG_PATH = path.join(PROJECT_ROOT, 'app', 'assets', 'launcher
const PUBLIC_DIR = path.join(__dirname, 'public')
const SAMPLE_DISTRIBUTION_PATH = path.join(PROJECT_ROOT, 'docs', 'sample_distribution.json')
const PROFILE_KINDS = new Set(['modpack', 'map', 'server-pack'])
function normalizeText(value){
return typeof value === 'string' ? value.trim() : ''
}
@@ -34,6 +32,18 @@ function normalizePort(value){
return 25565
}
function normalizePositiveInteger(value, fallback, minimum = 1){
const parsed = Number.parseInt(String(value ?? ''), 10)
if(Number.isFinite(parsed) && parsed >= minimum){
return parsed
}
return fallback
}
function normalizeBoolean(value){
return value === true
}
function resolveSafeProjectPath(relativePath){
const resolvedPath = path.resolve(PROJECT_ROOT, relativePath)
if(!resolvedPath.startsWith(PROJECT_ROOT + path.sep) && resolvedPath !== PROJECT_ROOT){
@@ -53,30 +63,72 @@ function createDistributionFileName(profileId){
return `${safeId.length > 0 ? safeId : 'distribution'}.distribution.json`
}
function deriveFeatureFlags(rawProfile){
const legacyKind = normalizeText(rawProfile?.kind)
let modsEnabled = normalizeBoolean(rawProfile?.modsEnabled)
let pluginsEnabled = normalizeBoolean(rawProfile?.pluginsEnabled)
let serverEnabled = normalizeBoolean(rawProfile?.serverEnabled)
if(legacyKind === 'modpack'){
modsEnabled = true
} else if(legacyKind === 'server-pack'){
pluginsEnabled = true
serverEnabled = true
}
if(pluginsEnabled){
serverEnabled = true
}
return {
modsEnabled,
pluginsEnabled,
serverEnabled
}
}
function normalizeServerJarSource(rawProfile){
const directValue = normalizeText(rawProfile?.serverJarUrl)
if(directValue.length > 0){
return directValue
}
const legacyBundle = normalizeText(rawProfile?.serverBundleUrl)
if(legacyBundle.toLowerCase().endsWith('.jar')){
return legacyBundle
}
return ''
}
function sanitizeProfile(rawProfile, index){
const kind = PROFILE_KINDS.has(rawProfile?.kind) ? rawProfile.kind : 'modpack'
const {
modsEnabled,
pluginsEnabled,
serverEnabled
} = deriveFeatureFlags(rawProfile)
const sanitized = {
id: normalizeText(rawProfile?.id) || `profile-${index + 1}`,
name: normalizeText(rawProfile?.name) || `새 프로필 ${index + 1}`,
kind,
description: normalizeText(rawProfile?.description),
details: normalizeMultilineText(rawProfile?.details),
distributionUrl: normalizeText(rawProfile?.distributionUrl)
distributionUrl: normalizeText(rawProfile?.distributionUrl),
modsEnabled,
pluginsEnabled,
serverEnabled,
worldArchiveUrl: normalizeText(rawProfile?.worldArchiveUrl),
worldDirectoryName: normalizeText(rawProfile?.worldDirectoryName)
}
if(kind === 'map'){
sanitized.worldArchiveUrl = normalizeText(rawProfile?.worldArchiveUrl)
sanitized.worldDirectoryName = normalizeText(rawProfile?.worldDirectoryName)
}
if(kind === 'server-pack'){
sanitized.serverBundleUrl = normalizeText(rawProfile?.serverBundleUrl)
if(serverEnabled){
sanitized.serverJarUrl = normalizeServerJarSource(rawProfile)
sanitized.serverDirectoryName = normalizeText(rawProfile?.serverDirectoryName) || `${sanitized.id}-server`
sanitized.serverLaunchCommand = normalizeText(rawProfile?.serverLaunchCommand)
sanitized.serverWorkingDirectory = normalizeText(rawProfile?.serverWorkingDirectory)
sanitized.serverPort = normalizePort(rawProfile?.serverPort)
sanitized.tunnelCommand = normalizeText(rawProfile?.tunnelCommand)
sanitized.tunnelAddressRegex = normalizeText(rawProfile?.tunnelAddressRegex)
sanitized.serverMemoryMb = normalizePositiveInteger(rawProfile?.serverMemoryMb, 4096, 512)
sanitized.serverMaxPlayers = normalizePositiveInteger(rawProfile?.serverMaxPlayers, 20, 1)
sanitized.serverWhitelistEnabled = normalizeBoolean(rawProfile?.serverWhitelistEnabled)
}
if(normalizeText(rawProfile?.artwork).length > 0){