Add launcher admin catalog site
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 18:48:13 +09:00
parent 9251fabdf8
commit c4cdd0ceba
12 changed files with 7999 additions and 5916 deletions

2
.gitignore vendored
View File

@@ -4,3 +4,5 @@
/target/ /target/
/logs/ /logs/
/dist/ /dist/
admin/data/uploads/*
!admin/data/uploads/.gitkeep

View File

@@ -73,6 +73,16 @@ Electron 기반 커스텀 마인크래프트 런처입니다. `MRSLauncher`를
npm install npm install
``` ```
관리자 사이트 실행:
```bash
npm run admin
```
설치 페이지용 프로필을 웹 UI로 관리할 수 있습니다.
- 문서: [docs/admin-site.md](docs/admin-site.md)
개발 실행: 개발 실행:
```bash ```bash

44
admin/data/catalog.json Normal file
View File

@@ -0,0 +1,44 @@
{
"version": 1,
"profiles": [
{
"id": "mrs-concatenation-lite",
"name": "Concatenation Lite",
"kind": "modpack",
"description": "기존 MRS 분배 인덱스를 사용하는 예시 프로필입니다. 프로필 카탈로그 구조가 동작하는지 확인할 때 사용할 수 있습니다.",
"details": "Mystic Red Space에서 운영하던 기존 분배 인덱스를 그대로 사용합니다.\n\n사용자는 이 항목을 라이브러리에 추가한 뒤 바로 실행할 수 있습니다.\n관리자는 distribution URL과 기본 접속 주소를 유지하거나 교체해서 실서비스용 항목으로 바꿀 수 있습니다.",
"distributionUrl": "https://cdn.mysticred.space/launcher/distribution.json",
"defaultServerAddress": "play.mysticred.space",
"allowCustomServerAddress": true
},
{
"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",
"defaultServerAddress": "",
"allowCustomServerAddress": false,
"worldArchiveUrl": "https://example.com/maps/original-map.zip",
"worldDirectoryName": "Original Map"
},
{
"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",
"defaultServerAddress": "",
"allowCustomServerAddress": true,
"serverBundleUrl": "https://example.com/serverpacks/plugin-world-server.zip",
"serverDirectoryName": "plugin-world-server",
"serverLaunchCommand": "java -jar server.jar nogui",
"serverWorkingDirectory": "",
"serverPort": 25565,
"tunnelCommand": "",
"tunnelAddressRegex": ""
}
]
}

View File

@@ -0,0 +1 @@

467
admin/public/app.js Normal file
View File

@@ -0,0 +1,467 @@
const state = {
catalog: {
version: 1,
profiles: []
},
meta: null,
selectedProfileId: null,
dirty: false
}
const profileList = document.getElementById('profileList')
const profileCount = document.getElementById('profileCount')
const localCatalogUrl = document.getElementById('localCatalogUrl')
const launcherCatalogPath = document.getElementById('launcherCatalogPath')
const emptyState = document.getElementById('emptyState')
const profileEditorForm = document.getElementById('profileEditorForm')
const statusBanner = document.getElementById('statusBanner')
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 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'),
distributionUrl: document.getElementById('field-distributionUrl'),
defaultServerAddress: document.getElementById('field-defaultServerAddress'),
allowCustomServerAddress: document.getElementById('field-allowCustomServerAddress'),
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'),
serverPort: document.getElementById('field-serverPort'),
tunnelCommand: document.getElementById('field-tunnelCommand'),
tunnelAddressRegex: document.getElementById('field-tunnelAddressRegex')
}
function slugify(value){
return String(value ?? '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
function createProfile(kind){
const timestamp = Date.now()
return {
id: `${kind}-${timestamp}`,
name: kind === 'map' ? '새 맵 프로필' : kind === 'server-pack' ? '새 서버팩 프로필' : '새 모드팩 프로필',
kind,
description: '',
details: '',
distributionUrl: '',
defaultServerAddress: '',
allowCustomServerAddress: kind !== 'map',
worldArchiveUrl: '',
worldDirectoryName: '',
serverBundleUrl: '',
serverDirectoryName: `${kind}-${timestamp}-server`,
serverLaunchCommand: '',
serverWorkingDirectory: '',
serverPort: 25565,
tunnelCommand: '',
tunnelAddressRegex: ''
}
}
function getSelectedProfile(){
return state.catalog.profiles.find((profile) => profile.id === state.selectedProfileId) ?? null
}
function markDirty(nextDirty = true){
state.dirty = nextDirty
saveCatalogButton.textContent = nextDirty ? '카탈로그 저장 *' : '카탈로그 저장'
}
function showStatus(message, tone = 'info'){
statusBanner.hidden = false
statusBanner.dataset.tone = tone
statusBanner.textContent = message
}
function clearStatus(){
statusBanner.hidden = true
statusBanner.textContent = ''
delete statusBanner.dataset.tone
}
function selectProfile(profileId){
state.selectedProfileId = profileId
renderSidebar()
populateEditor()
}
function renderSidebar(){
profileList.innerHTML = ''
profileCount.textContent = `${state.catalog.profiles.length}`
for(const profile of state.catalog.profiles){
const button = document.createElement('button')
button.type = 'button'
button.className = 'profileListItem'
if(profile.id === state.selectedProfileId){
button.setAttribute('selected', 'true')
}
const title = document.createElement('div')
title.className = 'profileListName'
title.textContent = profile.name || profile.id
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>'}
`
const description = document.createElement('div')
description.className = 'profileListDescription'
description.textContent = profile.description || '설명이 없습니다.'
button.appendChild(title)
button.appendChild(meta)
button.appendChild(description)
button.addEventListener('click', () => {
selectProfile(profile.id)
})
profileList.appendChild(button)
}
}
function syncKindSections(kind){
mapSection.hidden = kind !== 'map'
serverPackSection.hidden = kind !== 'server-pack'
}
function populateEditor(){
const profile = getSelectedProfile()
const hasSelection = profile != null
emptyState.hidden = hasSelection
profileEditorForm.hidden = !hasSelection
duplicateProfileButton.disabled = !hasSelection
deleteProfileButton.disabled = !hasSelection
if(!profile){
editorHint.textContent = '왼쪽에서 프로필을 선택하거나 새 프로필을 추가하세요.'
return
}
editorHint.textContent = `${profile.kind} 프로필을 편집 중입니다. 저장하면 런처 카탈로그도 같이 갱신됩니다.`
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.distributionUrl.value = profile.distributionUrl ?? ''
fieldElements.defaultServerAddress.value = profile.defaultServerAddress ?? ''
fieldElements.allowCustomServerAddress.checked = profile.allowCustomServerAddress === true
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.serverPort.value = profile.serverPort ?? 25565
fieldElements.tunnelCommand.value = profile.tunnelCommand ?? ''
fieldElements.tunnelAddressRegex.value = profile.tunnelAddressRegex ?? ''
syncKindSections(profile.kind)
}
function updateSelectedProfile(patch){
const profile = getSelectedProfile()
if(!profile){
return
}
Object.assign(profile, patch)
markDirty(true)
renderSidebar()
}
function bindTextField(fieldName){
fieldElements[fieldName].addEventListener('input', (event) => {
if(fieldName === 'id'){
const profile = getSelectedProfile()
if(!profile){
return
}
const previousId = profile.id
profile.id = event.target.value
state.selectedProfileId = profile.id
markDirty(true)
renderSidebar()
if(previousId !== profile.id){
showStatus('프로필 ID를 바꿨습니다. 저장하면 이 ID로 반영됩니다.', 'info')
}
return
}
updateSelectedProfile({ [fieldName]: event.target.value })
})
}
function bindCheckboxField(fieldName){
fieldElements[fieldName].addEventListener('change', (event) => {
updateSelectedProfile({
[fieldName]: event.target.checked
})
})
}
function bindNumberField(fieldName){
fieldElements[fieldName].addEventListener('input', (event) => {
const value = Number.parseInt(event.target.value || '25565', 10)
updateSelectedProfile({
[fieldName]: Number.isFinite(value) ? value : 25565
})
})
}
function bindProfileForm(){
bindTextField('id')
bindTextField('name')
bindTextField('description')
bindTextField('details')
bindTextField('distributionUrl')
bindTextField('defaultServerAddress')
bindCheckboxField('allowCustomServerAddress')
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 === 'map'){
profile.allowCustomServerAddress = false
}
if(profile.kind !== 'server-pack' && !profile.serverDirectoryName){
profile.serverDirectoryName = `${slugify(profile.id || profile.name) || 'profile'}-server`
}
markDirty(true)
renderSidebar()
populateEditor()
})
for(const button of document.querySelectorAll('.uploadButton')){
button.addEventListener('click', async () => {
const targetField = button.dataset.uploadTarget
const accept = button.dataset.uploadAccept
await uploadIntoField(targetField, accept)
})
}
}
async function uploadIntoField(targetField, accept){
const profile = getSelectedProfile()
if(!profile){
return
}
const picker = document.createElement('input')
picker.type = 'file'
picker.accept = accept || ''
picker.addEventListener('change', async () => {
const file = picker.files?.[0]
if(!file){
return
}
const formData = new FormData()
formData.append('file', file)
try {
showStatus(`${file.name} 업로드 중...`, 'info')
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
})
const result = await response.json()
if(!response.ok || result.ok !== true){
throw new Error(result.message || '업로드에 실패했습니다.')
}
profile[targetField] = result.file.path
fieldElements[targetField].value = result.file.path
markDirty(true)
renderSidebar()
showStatus(`업로드 완료: ${result.file.path}`, 'success')
} catch (error) {
console.error(error)
showStatus(error instanceof Error ? error.message : '업로드에 실패했습니다.', 'error')
}
})
picker.click()
}
async function loadMeta(){
const response = await fetch('/api/meta')
const meta = await response.json()
state.meta = meta
launcherCatalogPath.textContent = meta.launcherCatalogPath
localCatalogUrl.textContent = meta.localCatalogUrl
localCatalogUrl.href = meta.localCatalogUrl
}
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 : []
}
if(state.catalog.profiles.length > 0){
state.selectedProfileId = state.catalog.profiles[0].id
} else {
state.selectedProfileId = null
}
markDirty(false)
renderSidebar()
populateEditor()
}
function addProfile(kind){
const profile = createProfile(kind)
state.catalog.profiles.push(profile)
markDirty(true)
selectProfile(profile.id)
showStatus(`${profile.name} 프로필을 추가했습니다.`, 'success')
}
function duplicateSelectedProfile(){
const profile = getSelectedProfile()
if(!profile){
return
}
const clonedProfile = structuredClone(profile)
clonedProfile.id = `${slugify(profile.id || profile.name) || 'profile'}-copy-${Date.now()}`
clonedProfile.name = `${profile.name} 복제본`
state.catalog.profiles.push(clonedProfile)
markDirty(true)
selectProfile(clonedProfile.id)
showStatus('복제본을 만들었습니다.', 'success')
}
function deleteSelectedProfile(){
const profile = getSelectedProfile()
if(!profile){
return
}
const confirmed = window.confirm(`'${profile.name}' 프로필을 삭제할까요?`)
if(!confirmed){
return
}
state.catalog.profiles = state.catalog.profiles.filter((entry) => entry.id !== profile.id)
state.selectedProfileId = state.catalog.profiles[0]?.id ?? null
markDirty(true)
renderSidebar()
populateEditor()
showStatus('프로필을 삭제했습니다.', 'success')
}
async function saveCatalog(){
try {
clearStatus()
saveCatalogButton.disabled = true
showStatus('카탈로그 저장 중...', 'info')
const response = await fetch('/api/catalog', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(state.catalog)
})
const result = await response.json()
if(!response.ok || result.ok !== true){
throw new Error(result.message || '카탈로그 저장에 실패했습니다.')
}
state.catalog = result.catalog
if(!state.catalog.profiles.some((profile) => profile.id === state.selectedProfileId)){
state.selectedProfileId = state.catalog.profiles[0]?.id ?? null
}
markDirty(false)
renderSidebar()
populateEditor()
showStatus('카탈로그를 저장했고 런처 카탈로그에도 동기화했습니다.', 'success')
} catch (error) {
console.error(error)
showStatus(error instanceof Error ? error.message : '카탈로그 저장에 실패했습니다.', 'error')
} finally {
saveCatalogButton.disabled = false
}
}
function bindTopLevelActions(){
for(const button of document.querySelectorAll('[data-add-kind]')){
button.addEventListener('click', () => {
addProfile(button.dataset.addKind)
})
}
saveCatalogButton.addEventListener('click', async () => {
await saveCatalog()
})
duplicateProfileButton.addEventListener('click', () => {
duplicateSelectedProfile()
})
deleteProfileButton.addEventListener('click', () => {
deleteSelectedProfile()
})
}
async function bootstrap(){
bindProfileForm()
bindTopLevelActions()
try {
await Promise.all([
loadMeta(),
loadCatalog()
])
showStatus('관리자 사이트를 불러왔습니다. 저장하면 런처 카탈로그가 같이 갱신됩니다.', 'info')
} catch (error) {
console.error(error)
showStatus(error instanceof Error ? error.message : '관리자 사이트를 불러오지 못했습니다.', 'error')
}
}
bootstrap()

189
admin/public/index.html Normal file
View File

@@ -0,0 +1,189 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Launcher Admin</title>
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<div class="adminShell">
<aside class="sidebar">
<div class="brandBlock">
<span class="eyebrow">Launcher Admin</span>
<h1>프로필 관리</h1>
<p>설치 페이지에 표시할 실제 프로필을 UI로 관리합니다.</p>
</div>
<div class="metaPanel">
<div class="metaRow">
<span class="metaLabel">런처 카탈로그</span>
<code id="launcherCatalogPath">불러오는 중</code>
</div>
<div class="metaRow">
<span class="metaLabel">로컬 catalog URL</span>
<a id="localCatalogUrl" href="#" target="_blank" rel="noreferrer">불러오는 중</a>
</div>
</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>
</div>
<div class="profileListHeader">
<span>프로필 목록</span>
<span id="profileCount">0개</span>
</div>
<div id="profileList" class="profileList"></div>
</aside>
<main class="editorPane">
<div class="topBar">
<div>
<span class="eyebrow">Catalog Editor</span>
<h2>선택한 프로필 편집</h2>
<p id="editorHint">왼쪽에서 프로필을 선택하거나 새 프로필을 추가하세요.</p>
</div>
<div class="topBarActions">
<button type="button" id="duplicateProfileButton" class="secondaryAction" disabled>복제</button>
<button type="button" id="deleteProfileButton" class="dangerAction" disabled>삭제</button>
<button type="button" id="saveCatalogButton" class="primaryAction">카탈로그 저장</button>
</div>
</div>
<div id="statusBanner" class="statusBanner" hidden></div>
<div id="emptyState" class="emptyState">
<h3>프로필이 없습니다</h3>
<p>왼쪽 버튼으로 새 모드팩, 맵, 서버팩 프로필을 추가하세요.</p>
</div>
<form id="profileEditorForm" class="editorForm" hidden>
<section class="fieldSection">
<div class="sectionHeader">
<h3>기본 정보</h3>
</div>
<div class="fieldGrid">
<label class="fieldBlock">
<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">
</label>
<label class="fieldBlock fieldBlockFull">
<span>짧은 설명</span>
<input id="field-description" type="text" autocomplete="off">
</label>
<label class="fieldBlock fieldBlockFull">
<span>자세한 설명</span>
<textarea id="field-details" rows="6"></textarea>
</label>
</div>
</section>
<section class="fieldSection">
<div class="sectionHeader">
<h3>클라이언트 배포</h3>
</div>
<div class="fieldGrid">
<label class="fieldBlock fieldBlockFull">
<span>distribution.json 경로 또는 URL</span>
<div class="uploadField">
<input id="field-distributionUrl" type="text" autocomplete="off">
<button type="button" class="secondaryAction uploadButton" data-upload-target="distributionUrl" data-upload-accept=".json,application/json">파일 업로드</button>
</div>
</label>
<label class="fieldBlock">
<span>기본 접속 주소</span>
<input id="field-defaultServerAddress" type="text" autocomplete="off" placeholder="example.com:25565">
</label>
<label class="toggleBlock">
<input id="field-allowCustomServerAddress" type="checkbox">
<span>사용자가 라이브러리에서 주소를 바꿀 수 있음</span>
</label>
</div>
</section>
<section id="mapSection" class="fieldSection" hidden>
<div class="sectionHeader">
<h3>맵 자료</h3>
</div>
<div class="fieldGrid">
<label class="fieldBlock fieldBlockFull">
<span>월드 ZIP 경로 또는 URL</span>
<div class="uploadField">
<input id="field-worldArchiveUrl" type="text" autocomplete="off">
<button type="button" class="secondaryAction uploadButton" data-upload-target="worldArchiveUrl" data-upload-accept=".zip,application/zip">파일 업로드</button>
</div>
</label>
<label class="fieldBlock">
<span>월드 폴더 이름</span>
<input id="field-worldDirectoryName" type="text" autocomplete="off">
</label>
</div>
</section>
<section id="serverPackSection" class="fieldSection" hidden>
<div class="sectionHeader">
<h3>서버팩 자료</h3>
</div>
<div class="fieldGrid">
<label class="fieldBlock fieldBlockFull">
<span>서버 번들 ZIP 경로 또는 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>
</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>
<label class="fieldBlock fieldBlockFull">
<span>터널 주소 추출 정규식</span>
<input id="field-tunnelAddressRegex" type="text" autocomplete="off" placeholder="예: ([a-zA-Z0-9.-]+:\\d+)">
</label>
</div>
</section>
<section class="fieldSection helperSection">
<div class="sectionHeader">
<h3>운영 메모</h3>
</div>
<p>업로드 버튼은 현재 프로젝트 기준 상대 경로를 자동으로 채웁니다. 로컬 테스트에는 바로 쓸 수 있고, 외부 배포용으로는 URL로 바꿔도 됩니다.</p>
</section>
</form>
</main>
</div>
<script src="./app.js"></script>
</body>
</html>

376
admin/public/styles.css Normal file
View File

@@ -0,0 +1,376 @@
:root {
--bg: #0f1110;
--panel: #161a18;
--panel-strong: #1d231f;
--panel-soft: rgba(255, 255, 255, 0.04);
--line: rgba(255, 255, 255, 0.08);
--text: #f5f4ef;
--muted: rgba(245, 244, 239, 0.68);
--accent: #f0bf57;
--accent-strong: #ffd980;
--danger: #ff7b63;
--shadow: 0 24px 50px rgba(0, 0, 0, 0.28);
--radius: 22px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
background:
radial-gradient(circle at top left, rgba(240, 191, 87, 0.12), transparent 30%),
linear-gradient(180deg, #0d0f0e 0%, #111512 100%);
color: var(--text);
font-family: "Pretendard Variable", "Noto Sans KR", sans-serif;
}
button,
input,
textarea,
select {
font: inherit;
}
a {
color: var(--accent-strong);
}
code {
display: inline-block;
padding: 6px 8px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.06);
color: #fff4d8;
word-break: break-all;
}
.adminShell {
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 20px;
padding: 28px 24px;
border-right: 1px solid var(--line);
background: rgba(10, 12, 11, 0.86);
backdrop-filter: blur(16px);
}
.brandBlock h1,
.topBar h2,
.emptyState h3,
.sectionHeader h3 {
margin: 0;
}
.brandBlock p,
.topBar p,
.emptyState p,
.helperSection p {
margin: 0;
line-height: 1.6;
color: var(--muted);
}
.eyebrow {
display: inline-block;
margin-bottom: 10px;
color: var(--accent);
letter-spacing: 0.14em;
text-transform: uppercase;
font-size: 12px;
font-weight: 700;
}
.metaPanel,
.emptyState,
.fieldSection,
.statusBanner {
padding: 18px 20px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
box-shadow: var(--shadow);
}
.metaPanel {
display: flex;
flex-direction: column;
gap: 14px;
}
.metaRow {
display: flex;
flex-direction: column;
gap: 8px;
}
.metaLabel,
.profileListHeader,
.fieldBlock span,
.toggleBlock span {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
}
.profileListHeader {
display: flex;
justify-content: space-between;
color: var(--muted);
}
.addButtons {
display: grid;
gap: 10px;
}
.profileList {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 120px;
}
.profileListItem {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
padding: 16px;
border: 1px solid var(--line);
border-radius: 18px;
background: var(--panel-soft);
color: var(--text);
text-align: left;
cursor: pointer;
transition: border-color 0.2s ease, transform 0.2s ease, background 0.2s ease;
}
.profileListItem:hover,
.profileListItem:focus {
border-color: rgba(240, 191, 87, 0.45);
transform: translateY(-1px);
outline: none;
}
.profileListItem[selected="true"] {
border-color: rgba(240, 191, 87, 0.72);
background: rgba(240, 191, 87, 0.1);
}
.profileListName {
font-size: 16px;
font-weight: 800;
}
.profileListMeta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.profileListDescription {
font-size: 14px;
color: var(--muted);
line-height: 1.45;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.editorPane {
display: flex;
flex-direction: column;
gap: 20px;
padding: 28px;
}
.topBar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 24px;
}
.topBarActions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.editorForm {
display: flex;
flex-direction: column;
gap: 18px;
}
.sectionHeader {
margin-bottom: 16px;
}
.fieldGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.fieldBlock,
.toggleBlock {
display: flex;
flex-direction: column;
gap: 8px;
}
.fieldBlockFull {
grid-column: 1 / -1;
}
.toggleBlock {
justify-content: flex-end;
}
.toggleBlock input {
width: 18px;
height: 18px;
margin: 0;
}
.toggleBlock {
flex-direction: row;
align-items: center;
}
input,
textarea,
select {
width: 100%;
padding: 14px 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 14px;
background: var(--panel-strong);
color: var(--text);
}
textarea {
resize: vertical;
min-height: 150px;
}
.uploadField {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
}
.primaryAction,
.secondaryAction,
.dangerAction {
padding: 13px 18px;
border-radius: 14px;
border: 1px solid transparent;
color: var(--text);
cursor: pointer;
white-space: nowrap;
transition: transform 0.2s ease, opacity 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.primaryAction {
background: var(--accent);
color: #211808;
font-weight: 800;
}
.secondaryAction {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
}
.dangerAction {
background: rgba(255, 123, 99, 0.1);
border-color: rgba(255, 123, 99, 0.3);
color: #ffb4a5;
}
.primaryAction:hover,
.secondaryAction:hover,
.dangerAction:hover {
transform: translateY(-1px);
}
.primaryAction:disabled,
.secondaryAction:disabled,
.dangerAction:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
}
.statusBanner {
font-size: 14px;
line-height: 1.5;
}
.statusBanner[data-tone="success"] {
border-color: rgba(115, 208, 144, 0.3);
background: rgba(115, 208, 144, 0.1);
}
.statusBanner[data-tone="error"] {
border-color: rgba(255, 123, 99, 0.3);
background: rgba(255, 123, 99, 0.1);
}
.statusBanner[data-tone="info"] {
border-color: rgba(240, 191, 87, 0.28);
background: rgba(240, 191, 87, 0.1);
}
@media (max-width: 1200px) {
.adminShell {
grid-template-columns: 1fr;
}
.sidebar {
border-right: none;
border-bottom: 1px solid var(--line);
}
}
@media (max-width: 760px) {
.editorPane,
.sidebar {
padding: 20px;
}
.fieldGrid,
.uploadField,
.topBar {
grid-template-columns: 1fr;
}
.topBar {
display: flex;
flex-direction: column;
}
}

236
admin/server.js Normal file
View File

@@ -0,0 +1,236 @@
const express = require('express')
const fs = require('fs-extra')
const multer = require('multer')
const path = require('path')
const HOST = process.env.LAUNCHER_ADMIN_HOST || '127.0.0.1'
const PORT = Number.parseInt(process.env.LAUNCHER_ADMIN_PORT || '8787', 10)
const PROJECT_ROOT = path.resolve(__dirname, '..')
const RUNTIME_DATA_DIR = path.join(__dirname, 'data')
const UPLOADS_DIR = path.join(RUNTIME_DATA_DIR, 'uploads')
const RUNTIME_CATALOG_PATH = path.join(RUNTIME_DATA_DIR, 'catalog.json')
const LAUNCHER_CATALOG_PATH = path.join(PROJECT_ROOT, 'app', 'assets', 'launcher', 'catalog.json')
const PUBLIC_DIR = path.join(__dirname, 'public')
const PROFILE_KINDS = new Set(['modpack', 'map', 'server-pack'])
function normalizeText(value){
return typeof value === 'string' ? value.trim() : ''
}
function normalizeMultilineText(value){
return typeof value === 'string'
? value.replace(/\r\n/g, '\n').trim()
: ''
}
function normalizeBoolean(value){
return value === true
}
function normalizePort(value){
const port = Number.parseInt(String(value ?? ''), 10)
if(Number.isFinite(port) && port >= 1 && port <= 65535){
return port
}
return 25565
}
function sanitizeProfile(rawProfile, index){
const kind = PROFILE_KINDS.has(rawProfile?.kind) ? rawProfile.kind : 'modpack'
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),
defaultServerAddress: normalizeText(rawProfile?.defaultServerAddress),
allowCustomServerAddress: normalizeBoolean(rawProfile?.allowCustomServerAddress)
}
if(kind === 'map'){
sanitized.worldArchiveUrl = normalizeText(rawProfile?.worldArchiveUrl)
sanitized.worldDirectoryName = normalizeText(rawProfile?.worldDirectoryName)
}
if(kind === 'server-pack'){
sanitized.serverBundleUrl = normalizeText(rawProfile?.serverBundleUrl)
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)
}
if(normalizeText(rawProfile?.artwork).length > 0){
sanitized.artwork = normalizeText(rawProfile.artwork)
}
return sanitized
}
function sanitizeCatalog(payload){
const profiles = Array.isArray(payload?.profiles) ? payload.profiles : []
return {
version: 1,
profiles: profiles.map((profile, index) => sanitizeProfile(profile, index))
}
}
function toProjectRelativePath(targetPath){
return path.relative(PROJECT_ROOT, targetPath).split(path.sep).join('/')
}
async function ensureRuntimeCatalog(){
await fs.ensureDir(RUNTIME_DATA_DIR)
await fs.ensureDir(UPLOADS_DIR)
if(!(await fs.pathExists(RUNTIME_CATALOG_PATH))){
if(await fs.pathExists(LAUNCHER_CATALOG_PATH)){
await fs.copy(LAUNCHER_CATALOG_PATH, RUNTIME_CATALOG_PATH, { overwrite: true })
} else {
await fs.writeJson(RUNTIME_CATALOG_PATH, { version: 1, profiles: [] }, { spaces: 2 })
}
}
}
async function readCatalog(){
await ensureRuntimeCatalog()
return sanitizeCatalog(await fs.readJson(RUNTIME_CATALOG_PATH))
}
async function writeCatalog(catalog){
const sanitizedCatalog = sanitizeCatalog(catalog)
await fs.ensureDir(path.dirname(LAUNCHER_CATALOG_PATH))
await fs.writeJson(RUNTIME_CATALOG_PATH, sanitizedCatalog, { spaces: 2 })
await fs.writeJson(LAUNCHER_CATALOG_PATH, sanitizedCatalog, { spaces: 2 })
return sanitizedCatalog
}
function createUploadStorage(){
return multer.diskStorage({
destination(_req, _file, callback){
fs.ensureDir(UPLOADS_DIR)
.then(() => callback(null, UPLOADS_DIR))
.catch((error) => callback(error))
},
filename(_req, file, callback){
const extension = path.extname(file.originalname)
const baseName = path.basename(file.originalname, extension)
.replace(/[^a-zA-Z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^[-_.]+|[-_.]+$/g, '')
const timestamp = Date.now()
const safeBaseName = baseName.length > 0 ? baseName : 'file'
callback(null, `${timestamp}-${safeBaseName}${extension.toLowerCase()}`)
}
})
}
async function start(){
await ensureRuntimeCatalog()
const app = express()
const upload = multer({
storage: createUploadStorage(),
limits: {
fileSize: 1024 * 1024 * 1024
}
})
app.use(express.json({ limit: '5mb' }))
app.use('/uploads', express.static(UPLOADS_DIR))
app.get('/api/meta', async (_req, res) => {
res.json({
host: HOST,
port: PORT,
runtimeCatalogPath: toProjectRelativePath(RUNTIME_CATALOG_PATH),
launcherCatalogPath: toProjectRelativePath(LAUNCHER_CATALOG_PATH),
localCatalogUrl: `http://${HOST}:${PORT}/catalog.json`
})
})
app.get('/api/catalog', async (_req, res, next) => {
try {
res.json(await readCatalog())
} catch (error) {
next(error)
}
})
app.put('/api/catalog', async (req, res, next) => {
try {
const savedCatalog = await writeCatalog(req.body)
res.json({
ok: true,
catalog: savedCatalog
})
} catch (error) {
next(error)
}
})
app.post('/api/upload', upload.single('file'), async (req, res, next) => {
try {
if(req.file == null){
res.status(400).json({
ok: false,
message: '업로드할 파일이 없습니다.'
})
return
}
const relativePath = toProjectRelativePath(req.file.path)
res.json({
ok: true,
file: {
name: req.file.originalname,
storedName: req.file.filename,
size: req.file.size,
path: relativePath,
localUrl: `http://${HOST}:${PORT}/uploads/${encodeURIComponent(req.file.filename)}`
}
})
} catch (error) {
next(error)
}
})
app.get('/catalog.json', async (_req, res, next) => {
try {
res.sendFile(RUNTIME_CATALOG_PATH)
} catch (error) {
next(error)
}
})
app.use(express.static(PUBLIC_DIR))
app.get(/.*/, (_req, res) => {
res.sendFile(path.join(PUBLIC_DIR, 'index.html'))
})
app.use((error, _req, res, _next) => {
console.error(error)
res.status(500).json({
ok: false,
message: error instanceof Error ? error.message : '관리자 서버 처리 중 오류가 발생했습니다.'
})
})
app.listen(PORT, HOST, () => {
console.log(`[launcher-admin] running on http://${HOST}:${PORT}`)
console.log(`[launcher-admin] runtime catalog: ${toProjectRelativePath(RUNTIME_CATALOG_PATH)}`)
console.log(`[launcher-admin] launcher catalog mirror: ${toProjectRelativePath(LAUNCHER_CATALOG_PATH)}`)
})
}
start().catch((error) => {
console.error(error)
process.exit(1)
})

View File

@@ -18,11 +18,10 @@
"description": "오리지널 맵용 템플릿입니다. vanilla 또는 맵 전용 distribution URL과 월드 ZIP 경로를 채우면 싱글플레이 빠른 실행에 사용할 수 있습니다.", "description": "오리지널 맵용 템플릿입니다. vanilla 또는 맵 전용 distribution URL과 월드 ZIP 경로를 채우면 싱글플레이 빠른 실행에 사용할 수 있습니다.",
"details": "관리자는 이 항목에 월드 ZIP과 클라이언트 distribution URL을 미리 등록합니다.\n\n사용자는 설치 페이지에서 설명을 읽고 라이브러리에 추가한 뒤, 라이브러리에서 맵 자료를 준비하고 바로 실행할 수 있습니다.", "details": "관리자는 이 항목에 월드 ZIP과 클라이언트 distribution URL을 미리 등록합니다.\n\n사용자는 설치 페이지에서 설명을 읽고 라이브러리에 추가한 뒤, 라이브러리에서 맵 자료를 준비하고 바로 실행할 수 있습니다.",
"distributionUrl": "https://example.com/launcher/vanilla-map-distribution.json", "distributionUrl": "https://example.com/launcher/vanilla-map-distribution.json",
"worldArchiveUrl": "https://example.com/maps/original-map.zip",
"worldDirectoryName": "Original Map",
"defaultServerAddress": "", "defaultServerAddress": "",
"allowCustomServerAddress": false, "allowCustomServerAddress": false,
"configured": false "worldArchiveUrl": "https://example.com/maps/original-map.zip",
"worldDirectoryName": "Original Map"
}, },
{ {
"id": "template-plugin-server-pack", "id": "template-plugin-server-pack",
@@ -31,15 +30,15 @@
"description": "플러그인 맵 + 서버 접속용 템플릿입니다. 클라이언트 distribution, 서버 번들 ZIP, 서버 시작 명령, 선택형 터널 명령을 연결하면 됩니다.", "description": "플러그인 맵 + 서버 접속용 템플릿입니다. 클라이언트 distribution, 서버 번들 ZIP, 서버 시작 명령, 선택형 터널 명령을 연결하면 됩니다.",
"details": "관리자는 클라이언트 배포 파일, 서버 번들, 서버 시작 명령을 미리 등록합니다.\n\n사용자는 설치 페이지에서 상세 내용을 확인하고 라이브러리에 추가한 뒤, 라이브러리에서 직접 서버를 켜거나 수동 주소를 넣어 접속할 수 있습니다.", "details": "관리자는 클라이언트 배포 파일, 서버 번들, 서버 시작 명령을 미리 등록합니다.\n\n사용자는 설치 페이지에서 상세 내용을 확인하고 라이브러리에 추가한 뒤, 라이브러리에서 직접 서버를 켜거나 수동 주소를 넣어 접속할 수 있습니다.",
"distributionUrl": "https://example.com/launcher/server-pack-client-distribution.json", "distributionUrl": "https://example.com/launcher/server-pack-client-distribution.json",
"defaultServerAddress": "",
"allowCustomServerAddress": true,
"serverBundleUrl": "https://example.com/serverpacks/plugin-world-server.zip", "serverBundleUrl": "https://example.com/serverpacks/plugin-world-server.zip",
"serverDirectoryName": "plugin-world-server", "serverDirectoryName": "plugin-world-server",
"serverLaunchCommand": "java -jar server.jar nogui", "serverLaunchCommand": "java -jar server.jar nogui",
"serverWorkingDirectory": "",
"serverPort": 25565, "serverPort": 25565,
"tunnelCommand": "", "tunnelCommand": "",
"tunnelAddressRegex": "", "tunnelAddressRegex": ""
"defaultServerAddress": "",
"allowCustomServerAddress": true,
"configured": false
} }
] ]
} }

48
docs/admin-site.md Normal file
View File

@@ -0,0 +1,48 @@
# 관리자 사이트
런처 설치 페이지에 표시되는 프로필을 JSON 직접 수정 없이 웹 UI로 관리하는 로컬 관리자 사이트입니다.
## 실행
```bash
npm run admin
```
기본 주소:
- `http://127.0.0.1:8787`
## 현재 1차 구현 범위
- 프로필 추가 / 수정 / 삭제 / 복제
- `modpack`, `map`, `server-pack` 종류별 입력 폼
- `distribution.json`, 맵 ZIP, 서버 번들 ZIP 업로드
- 저장 시 아래 두 파일을 동시에 갱신
- `admin/data/catalog.json`
- `app/assets/launcher/catalog.json`
## 업로드 동작
업로드 버튼으로 올린 파일은 아래에 저장됩니다.
- `admin/data/uploads/`
카탈로그에는 현재 프로젝트 기준 상대 경로가 저장됩니다.
예:
- `admin/data/uploads/1715000000000-my-pack.zip`
이 방식은 로컬 테스트에는 바로 쓸 수 있습니다.
## 주의
- 지금 1차 버전은 로컬 운영용입니다.
- 기본 서버 바인딩은 `127.0.0.1` 이라 같은 PC에서만 접속됩니다.
- 외부에서 쓰는 공개 관리자 사이트로 만들려면 인증과 공개 URL 저장 방식을 추가해야 합니다.
## 추천 운영 방식
1. 관리자 사이트에서 프로필과 자료 경로를 입력
2. 로컬 런처에서 실제 표시와 실행 확인
3. 이후 필요하면 업로드 경로를 공개 URL 기반으로 확장

742
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@
"private": true, "private": true,
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"admin": "node admin/server.js",
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"start": "npm run build && electron .", "start": "npm run build && electron .",
"smoke": "npm run build && node scripts/smoke-runner.js", "smoke": "npm run build && node scripts/smoke-runner.js",
@@ -32,6 +33,7 @@
"ejs": "^3.1.10", "ejs": "^3.1.10",
"ejs-electron": "^3.0.0", "ejs-electron": "^3.0.0",
"electron-updater": "^6.7.3", "electron-updater": "^6.7.3",
"express": "^5.2.1",
"fs-extra": "^11.3.3", "fs-extra": "^11.3.3",
"github-syntax-dark": "^0.5.0", "github-syntax-dark": "^0.5.0",
"got": "^11.8.5", "got": "^11.8.5",
@@ -39,12 +41,13 @@
"helios-distribution-types": "^1.3.0", "helios-distribution-types": "^1.3.0",
"jquery": "^3.7.1", "jquery": "^3.7.1",
"lodash.merge": "^4.6.2", "lodash.merge": "^4.6.2",
"multer": "^2.1.1",
"semver": "^7.7.3", "semver": "^7.7.3",
"toml": "^3.0.0" "toml": "^3.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0",
"@stylistic/eslint-plugin": "^5.6.1", "@stylistic/eslint-plugin": "^5.6.1",
"@types/node": "^22.0.0",
"electron": "^39.2.7", "electron": "^39.2.7",
"electron-builder": "^26.4.0", "electron-builder": "^26.4.0",
"eslint": "^9.39.2", "eslint": "^9.39.2",