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

@@ -1,65 +1,42 @@
# Minecraft Launcher # Minecraft Launcher
Electron 기반 커스텀 마인크래프트 런처입니다. `MRSLauncher`를 최신 소스로 가져온 뒤, 단일 모드팩 구조를 `설치 페이지 + 라이브러리` 구조로 확장했습니다. Electron 기반 커스텀 마인크래프트 런처입니다. `MRSLauncher`를 최신 소스로 가져온 뒤, 설치 페이지 + 라이브러리 구조와 관리자 사이트를 붙였습니다.
## 현재 상태 ## 현재 구조
- 여러 프로필을 설치 페이지에서 라이브러리로 추가 가능 - 프로필은 `맵`을 기본으로 두고 `모드`, `플러그인`, `서버` 기능을 조합합니다.
- 프로필 종류 지원: - 설치 페이지는 관리자 등록 프로필을 읽기 전용으로 보여줍니다.
- `modpack` - 라이브러리에서는 프로필 설치, 선택, 제거만 하고, 서버 프로필은 접속 주소를 직접 입력할 수 있습니다.
- `map` - `PLAY`를 누르면:
- `server-pack` - 서버 기능이 없는 프로필은 맵을 싱글플레이로 실행
- 라이브러리에서 프로필 선택, 제거, 자료 준비, 실행 화면 이동, 바로 실행 가능 - 서버 기능이 있고 접속 주소가 있으면 해당 주소로 접속
- 프로필별 `distribution.json` 전환 가능 - 서버 기능이 있고 접속 주소가 없으면 로컬 서버를 먼저 실행한 뒤 `localhost`로 접속
- `map` 프로필은 월드 ZIP/로컬 폴더를 `saves/`에 설치하고 `quickPlaySingleplayer`로 바로 실행 - 메인 화면 왼쪽 아래는 선택된 서버 프로필의 자동 포트 개방 상태를 표시합니다.
- `server-pack` 프로필은 로컬 서버 번들 설치, 서버 시작/중지, 선택형 터널 명령 실행, 공개 주소 표시 지원
- 라이브러리의 주소 입력칸에 `host:port`를 넣으면 실행 시 자동 접속
- 설치 페이지는 관리자가 미리 등록한 프로필을 보여주는 읽기 전용 카탈로그 화면
- 설치 페이지에서 프로필 제목, 요약, 상세 설명, 실행 조건을 확인 가능
## 중요한 제한 ## 자동 포트 개방
포트포워딩 없이 외부 사용자가 접속하게 만드는 기능은 런처만으로 해결되지 않습니다. - 현재 구현은 `UPnP + Windows 방화벽` 기준입니다.
- 성공하면 자동 개방 상태를 표시합니다.
- 이미 열려 있으면 기존 포트를 그대로 사용합니다.
- 실패하면 `직접 포트포워딩 해주세요` 안내를 표시합니다.
- 접속 주소를 직접 입력한 경우에는 자동 포트 개방을 건너뜁니다.
필요한 것 중 하나: ## 관리자 사이트
- 별도 릴레이 서버 ```bash
- 터널링 도구 npm run admin
- VPN/NAT traversal 백엔드 ```
현재 구현은 `server-pack` 프로필에 `tunnelCommand`를 넣어 외부 도구를 호출하는 자리까지 제공합니다. - 기본 주소: `http://127.0.0.1:8787`
- `distribution.json` 업로드 / 새로 만들기 / 직접 편집 가능
- 월드 ZIP 업로드 가능
- 서버용 버킷 JAR 업로드 가능
- 서버 메모리, 최대 인원수, 화이트리스트, 포트 설정 가능
추후 설계 문서: 문서:
- [docs/portforwarding-free-connection-plan.md](docs/portforwarding-free-connection-plan.md) - [docs/admin-site.md](docs/admin-site.md)
- [docs/launcher-catalog.md](docs/launcher-catalog.md)
## 프로젝트 구조
- `app/`
- Electron renderer 자산
- 설치/라이브러리/로그인/설정 화면
- `app/assets/js/catalogmanager.js`
- 관리자 등록 카탈로그 로드, 프로필 메타데이터 정규화
- `app/assets/js/profileassetmanager.js`
- 맵 ZIP, 서버 번들 ZIP/폴더 설치
- `app/assets/js/serverruntime.js`
- 로컬 서버 실행, 선택형 터널 프로세스 관리
- `src/main/index.ts`
- TypeScript 메인 프로세스 엔트리
- `index.js`
- `dist/main/index.js` 우선 실행, 없으면 `index.legacy.js` 폴백
## 카탈로그 / 프로필
기본 카탈로그:
- `app/assets/launcher/catalog.json`
원격 카탈로그:
- 관리자 배포 설정으로 연결
세부 스키마는 [docs/launcher-catalog.md](docs/launcher-catalog.md)를 보면 됩니다.
## 개발 ## 개발
@@ -73,17 +50,6 @@ Electron 기반 커스텀 마인크래프트 런처입니다. `MRSLauncher`를
npm install npm install
``` ```
관리자 사이트 실행:
```bash
npm run admin
```
설치 페이지용 프로필을 웹 UI로 관리할 수 있습니다.
- `distribution.json`도 사이트 안에서 직접 만들고 수정할 수 있습니다.
- 문서: [docs/admin-site.md](docs/admin-site.md)
개발 실행: 개발 실행:
```bash ```bash
@@ -96,8 +62,6 @@ npm start
npm run smoke:win npm run smoke:win
``` ```
이 명령은 TypeScript 메인 프로세스를 빌드한 뒤 Electron 앱을 실제로 한 번 띄우고, `LAUNCHER_SMOKE_EXIT` 환경변수로 자동 종료합니다.
TypeScript 메인 프로세스만 빌드: TypeScript 메인 프로세스만 빌드:
```bash ```bash
@@ -107,11 +71,10 @@ npm run build
배포 빌드: 배포 빌드:
```bash ```bash
npm run dist npm run dist:win
``` ```
## 참고 ## 참고
- Upstream: `https://github.com/peunsu/MRSLauncher` - Upstream: `https://github.com/peunsu/MRSLauncher`
- Original base: `https://github.com/dscalzi/HeliosLauncher` - Original base: `https://github.com/dscalzi/HeliosLauncher`
- CI: `.github/workflows/windows-smoke.yml` 에서 Windows smoke run 수행

View File

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

View File

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

View File

@@ -12,7 +12,7 @@
<div class="brandBlock"> <div class="brandBlock">
<span class="eyebrow">Launcher Admin</span> <span class="eyebrow">Launcher Admin</span>
<h1>프로필 관리</h1> <h1>프로필 관리</h1>
<p>설치 페이지에 표시할 실제 프로필을 UI로 관리합니다.</p> <p>설치 페이지에 표시할 실제 프로필을 조합형 UI로 관리합니다.</p>
</div> </div>
<div class="metaPanel"> <div class="metaPanel">
@@ -27,9 +27,7 @@
</div> </div>
<div class="addButtons"> <div class="addButtons">
<button type="button" class="primaryAction" data-add-kind="modpack">모드팩 추가</button> <button type="button" class="primaryAction" id="addProfileButton">프로필 추가</button>
<button type="button" class="secondaryAction" data-add-kind="map">맵 추가</button>
<button type="button" class="secondaryAction" data-add-kind="server-pack">서버팩 추가</button>
</div> </div>
<div class="profileListHeader"> <div class="profileListHeader">
@@ -57,7 +55,7 @@
<div id="emptyState" class="emptyState"> <div id="emptyState" class="emptyState">
<h3>프로필이 없습니다</h3> <h3>프로필이 없습니다</h3>
<p>왼쪽 버튼으로 새 모드팩, 맵, 서버팩 프로필을 추가하세요.</p> <p>왼쪽 버튼으로 새 프로필을 추가하세요. 맵은 기본이고, 모드/플러그인/서버는 체크해서 조합합니다.</p>
</div> </div>
<form id="profileEditorForm" class="editorForm" hidden> <form id="profileEditorForm" class="editorForm" hidden>
@@ -70,14 +68,6 @@
<span>프로필 ID</span> <span>프로필 ID</span>
<input id="field-id" type="text" autocomplete="off"> <input id="field-id" type="text" autocomplete="off">
</label> </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"> <label class="fieldBlock fieldBlockFull">
<span>표시 이름</span> <span>표시 이름</span>
<input id="field-name" type="text" autocomplete="off"> <input id="field-name" type="text" autocomplete="off">
@@ -93,6 +83,29 @@
</div> </div>
</section> </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"> <section class="fieldSection">
<div class="sectionHeader"> <div class="sectionHeader">
<h3>클라이언트 배포</h3> <h3>클라이언트 배포</h3>
@@ -113,7 +126,7 @@
</div> </div>
</section> </section>
<section id="mapSection" class="fieldSection" hidden> <section class="fieldSection">
<div class="sectionHeader"> <div class="sectionHeader">
<h3>맵 자료</h3> <h3>맵 자료</h3>
</div> </div>
@@ -132,42 +145,37 @@
</div> </div>
</section> </section>
<section id="serverPackSection" class="fieldSection" hidden> <section id="serverSection" class="fieldSection" hidden>
<div class="sectionHeader"> <div class="sectionHeader">
<h3>서버 자료</h3> <h3>서버 자료 / 설정</h3>
</div> </div>
<div class="fieldGrid"> <div class="fieldGrid">
<label class="fieldBlock fieldBlockFull"> <label class="fieldBlock fieldBlockFull">
<span>서버 번들 ZIP 경로 또는 URL</span> <span>버킷 JAR 경로 또는 URL</span>
<div class="uploadField"> <div class="uploadField">
<input id="field-serverBundleUrl" type="text" autocomplete="off"> <input id="field-serverJarUrl" type="text" autocomplete="off">
<button type="button" class="secondaryAction uploadButton" data-upload-target="serverBundleUrl" data-upload-accept=".zip,application/zip">파일 업로드</button> <button type="button" class="secondaryAction uploadButton" data-upload-target="serverJarUrl" data-upload-accept=".jar,application/java-archive">JAR 업로드</button>
</div> </div>
</label> </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"> <label class="fieldBlock">
<span>서버 포트</span> <span>서버 포트</span>
<input id="field-serverPort" type="number" min="1" max="65535" step="1"> <input id="field-serverPort" type="number" min="1" max="65535" step="1">
</label> </label>
<label class="fieldBlock fieldBlockFull"> <label class="fieldBlock">
<span>터널 명령</span> <span>서버 메모리 (MB)</span>
<input id="field-tunnelCommand" type="text" autocomplete="off" placeholder="예: playit-cli --port ${port}"> <input id="field-serverMemoryMb" type="number" min="512" step="256">
</label> </label>
<label class="fieldBlock fieldBlockFull"> <label class="fieldBlock">
<span>터널 주소 추출 정규식</span> <span>최대 인원수</span>
<input id="field-tunnelAddressRegex" type="text" autocomplete="off" placeholder="예: ([a-zA-Z0-9.-]+:\\d+)"> <input id="field-serverMaxPlayers" type="number" min="1" max="200" step="1">
</label> </label>
<label class="toggleBlock">
<input id="field-serverWhitelistEnabled" type="checkbox">
<span>화이트리스트 사용</span>
</label>
<div class="fieldHelpText fieldBlockFull">
직접 실행 시 런처가 `eula.txt`, `server.properties`를 자동으로 만들고 업로드한 버킷 JAR을 실행합니다.
</div>
</div> </div>
</section> </section>
@@ -175,7 +183,7 @@
<div class="sectionHeader"> <div class="sectionHeader">
<h3>운영 메모</h3> <h3>운영 메모</h3>
</div> </div>
<p>업로드 버튼은 현재 프로젝트 기준 상대 경로를 자동으로 채웁니다. 로컬 테스트에는 바로 쓸 수 있고, 외부 배포용으로는 URL로 바꿔도 됩니다.</p> <p>업로드 버튼은 현재 프로젝트 기준 상대 경로를 자동으로 채웁니다. 접속주소는 관리자 사이트가 아니라, 사용자가 라이브러리에서 직접 넣는 구조입니다.</p>
</section> </section>
</form> </form>
</main> </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 PUBLIC_DIR = path.join(__dirname, 'public')
const SAMPLE_DISTRIBUTION_PATH = path.join(PROJECT_ROOT, 'docs', 'sample_distribution.json') const SAMPLE_DISTRIBUTION_PATH = path.join(PROJECT_ROOT, 'docs', 'sample_distribution.json')
const PROFILE_KINDS = new Set(['modpack', 'map', 'server-pack'])
function normalizeText(value){ function normalizeText(value){
return typeof value === 'string' ? value.trim() : '' return typeof value === 'string' ? value.trim() : ''
} }
@@ -34,6 +32,18 @@ function normalizePort(value){
return 25565 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){ function resolveSafeProjectPath(relativePath){
const resolvedPath = path.resolve(PROJECT_ROOT, relativePath) const resolvedPath = path.resolve(PROJECT_ROOT, relativePath)
if(!resolvedPath.startsWith(PROJECT_ROOT + path.sep) && resolvedPath !== PROJECT_ROOT){ 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` 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){ function sanitizeProfile(rawProfile, index){
const kind = PROFILE_KINDS.has(rawProfile?.kind) ? rawProfile.kind : 'modpack' const {
modsEnabled,
pluginsEnabled,
serverEnabled
} = deriveFeatureFlags(rawProfile)
const sanitized = { const sanitized = {
id: normalizeText(rawProfile?.id) || `profile-${index + 1}`, id: normalizeText(rawProfile?.id) || `profile-${index + 1}`,
name: normalizeText(rawProfile?.name) || `새 프로필 ${index + 1}`, name: normalizeText(rawProfile?.name) || `새 프로필 ${index + 1}`,
kind,
description: normalizeText(rawProfile?.description), description: normalizeText(rawProfile?.description),
details: normalizeMultilineText(rawProfile?.details), 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'){ if(serverEnabled){
sanitized.worldArchiveUrl = normalizeText(rawProfile?.worldArchiveUrl) sanitized.serverJarUrl = normalizeServerJarSource(rawProfile)
sanitized.worldDirectoryName = normalizeText(rawProfile?.worldDirectoryName)
}
if(kind === 'server-pack'){
sanitized.serverBundleUrl = normalizeText(rawProfile?.serverBundleUrl)
sanitized.serverDirectoryName = normalizeText(rawProfile?.serverDirectoryName) || `${sanitized.id}-server` sanitized.serverDirectoryName = normalizeText(rawProfile?.serverDirectoryName) || `${sanitized.id}-server`
sanitized.serverLaunchCommand = normalizeText(rawProfile?.serverLaunchCommand)
sanitized.serverWorkingDirectory = normalizeText(rawProfile?.serverWorkingDirectory)
sanitized.serverPort = normalizePort(rawProfile?.serverPort) sanitized.serverPort = normalizePort(rawProfile?.serverPort)
sanitized.tunnelCommand = normalizeText(rawProfile?.tunnelCommand) sanitized.serverMemoryMb = normalizePositiveInteger(rawProfile?.serverMemoryMb, 4096, 512)
sanitized.tunnelAddressRegex = normalizeText(rawProfile?.tunnelAddressRegex) sanitized.serverMaxPlayers = normalizePositiveInteger(rawProfile?.serverMaxPlayers, 20, 1)
sanitized.serverWhitelistEnabled = normalizeBoolean(rawProfile?.serverWhitelistEnabled)
} }
if(normalizeText(rawProfile?.artwork).length > 0){ if(normalizeText(rawProfile?.artwork).length > 0){

View File

@@ -3283,8 +3283,10 @@ input:checked + .toggleSwitchSlider:before {
/* Maintains maximum width on the status bar. */ /* Maintains maximum width on the status bar. */
#server_status_wrapper { #server_status_wrapper {
position: relative;
display: inline-flex; display: inline-flex;
width: 180px; align-items: center;
width: 240px;
} }
/* Span which displays the player count of the selected server. */ /* Span which displays the player count of the selected server. */
@@ -3297,6 +3299,61 @@ input:checked + .toggleSwitchSlider:before {
white-space: nowrap; white-space: nowrap;
} }
#player_count[data-tone="success"] {
color: #8ce6a4;
text-shadow: 0px 0px 20px rgba(140, 230, 164, 0.6);
}
#player_count[data-tone="error"] {
color: #ff8f8f;
text-shadow: 0px 0px 20px rgba(255, 143, 143, 0.6);
}
#player_count[data-tone="info"] {
color: #f0d28a;
text-shadow: 0px 0px 20px rgba(240, 210, 138, 0.45);
}
#portStatusTooltip {
position: absolute;
visibility: hidden;
opacity: 0;
width: 260px;
min-height: 56px;
background-color: rgba(0, 0, 0, 0.82);
color: #fff;
border-radius: 4px;
padding: 10px 12px;
z-index: 1;
font-family: 'Pretendard Medium';
font-size: 13px;
line-height: 1.5;
transition: visibility 0s linear 0.25s, opacity 0.25s ease;
bottom: calc(100% + 15px);
left: 50%;
transform: translateX(-50%);
box-shadow: 0px 0px 20px rgb(0, 0, 0);
cursor: default;
white-space: normal;
}
#portStatusTooltip:after {
content: " ";
position: absolute;
left: 50%;
top: 100%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.82) transparent transparent transparent;
}
#server_status_wrapper:hover #portStatusTooltip {
visibility: visible;
opacity: 1;
transition-delay: 0s;
}
/* Wrapper container for the mojang status bar. */ /* Wrapper container for the mojang status bar. */
#mojangStatusWrapper { #mojangStatusWrapper {
position: relative; position: relative;
@@ -4400,6 +4457,12 @@ input:checked + .toggleSwitchSlider:before {
gap: 8px; gap: 8px;
} }
.launcherCardContent {
display: flex;
flex-direction: column;
gap: 12px;
}
.launcherFormGrid { .launcherFormGrid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -4444,6 +4507,12 @@ input:checked + .toggleSwitchSlider:before {
font: inherit; font: inherit;
} }
.launcherFieldHint {
font-size: 13px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.6);
}
.launcherCheckboxRow { .launcherCheckboxRow {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@@ -16,42 +16,97 @@ function normalizeNullableText(value){
return nextValue.length > 0 ? nextValue : null return nextValue.length > 0 ? nextValue : null
} }
function normalizeBoolean(value){
return value === true
}
function normalizePositiveInteger(value, fallback, minimum = 1){
const parsed = Number.parseInt(String(value ?? ''), 10)
if(Number.isFinite(parsed) && parsed >= minimum){
return parsed
}
return fallback
}
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 deriveLegacyKind(flags){
if(flags.serverEnabled){
return 'server-pack'
}
if(flags.modsEnabled){
return 'modpack'
}
return 'map'
}
function resolveLegacyServerJar(rawProfile){
const directValue = normalizeNullableText(rawProfile?.serverJarUrl)
if(directValue != null){
return directValue
}
const legacyBundle = normalizeNullableText(rawProfile?.serverBundleUrl)
if(legacyBundle != null && legacyBundle.toLowerCase().endsWith('.jar')){
return legacyBundle
}
return null
}
function toStoredProfile(rawProfile){ function toStoredProfile(rawProfile){
const kind = normalizeText(rawProfile.kind) || 'modpack' const flags = deriveFeatureFlags(rawProfile)
const storedProfile = { const storedProfile = {
id: normalizeText(rawProfile.id), id: normalizeText(rawProfile.id),
name: normalizeText(rawProfile.name), name: normalizeText(rawProfile.name),
kind, kind: deriveLegacyKind(flags),
description: normalizeText(rawProfile.description), description: normalizeText(rawProfile.description),
details: normalizeText(rawProfile.details), details: normalizeText(rawProfile.details),
distributionUrl: normalizeNullableText(rawProfile.distributionUrl), distributionUrl: normalizeNullableText(rawProfile.distributionUrl),
defaultServerAddress: normalizeText(rawProfile.defaultServerAddress), modsEnabled: flags.modsEnabled,
allowCustomServerAddress: rawProfile.allowCustomServerAddress === true, pluginsEnabled: flags.pluginsEnabled,
serverEnabled: flags.serverEnabled,
worldArchiveUrl: normalizeNullableText(rawProfile.worldArchiveUrl), worldArchiveUrl: normalizeNullableText(rawProfile.worldArchiveUrl),
worldDirectoryName: normalizeNullableText(rawProfile.worldDirectoryName), worldDirectoryName: normalizeNullableText(rawProfile.worldDirectoryName),
serverBundleUrl: normalizeNullableText(rawProfile.serverBundleUrl), serverJarUrl: resolveLegacyServerJar(rawProfile),
serverDirectoryName: normalizeText(rawProfile.serverDirectoryName) || 'server', serverDirectoryName: normalizeText(rawProfile.serverDirectoryName) || 'server',
serverLaunchCommand: normalizeNullableText(rawProfile.serverLaunchCommand),
serverWorkingDirectory: normalizeNullableText(rawProfile.serverWorkingDirectory),
serverPort: Number.isFinite(Number(rawProfile.serverPort)) ? Number(rawProfile.serverPort) : 25565, serverPort: Number.isFinite(Number(rawProfile.serverPort)) ? Number(rawProfile.serverPort) : 25565,
tunnelCommand: normalizeNullableText(rawProfile.tunnelCommand), serverMemoryMb: normalizePositiveInteger(rawProfile.serverMemoryMb, 4096, 512),
tunnelAddressRegex: normalizeNullableText(rawProfile.tunnelAddressRegex), serverMaxPlayers: normalizePositiveInteger(rawProfile.serverMaxPlayers, 20, 1),
serverWhitelistEnabled: normalizeBoolean(rawProfile.serverWhitelistEnabled),
artwork: normalizeText(rawProfile.artwork) artwork: normalizeText(rawProfile.artwork)
} }
if(kind !== 'map'){ if(!storedProfile.serverEnabled){
storedProfile.worldArchiveUrl = null storedProfile.serverJarUrl = null
storedProfile.worldDirectoryName = null
}
if(kind !== 'server-pack'){
storedProfile.serverBundleUrl = null
storedProfile.serverDirectoryName = 'server' storedProfile.serverDirectoryName = 'server'
storedProfile.serverLaunchCommand = null
storedProfile.serverWorkingDirectory = null
storedProfile.serverPort = 25565 storedProfile.serverPort = 25565
storedProfile.tunnelCommand = null storedProfile.serverMemoryMb = 4096
storedProfile.tunnelAddressRegex = null storedProfile.serverMaxPlayers = 20
storedProfile.serverWhitelistEnabled = false
} }
return storedProfile return storedProfile
@@ -66,21 +121,19 @@ function normalizeProfile(rawProfile, sourceType = 'catalog'){
launchIssues.push('distribution URL이 필요합니다.') launchIssues.push('distribution URL이 필요합니다.')
} }
if(storedProfile.kind === 'map'){
if(storedProfile.worldArchiveUrl == null){ if(storedProfile.worldArchiveUrl == null){
launchIssues.push('맵 ZIP 또는 로컬 월드 경로가 필요합니다.') launchIssues.push('맵 ZIP 또는 로컬 월드 경로가 필요합니다.')
} }
if(storedProfile.worldDirectoryName == null){ if(storedProfile.worldDirectoryName == null){
launchIssues.push('월드 폴더 이름이 필요합니다.') launchIssues.push('월드 폴더 이름이 필요합니다.')
} }
}
if(storedProfile.kind === 'server-pack' && storedProfile.serverBundleUrl == null){ if(storedProfile.serverEnabled && storedProfile.serverJarUrl == null){
hostIssues.push('로컬 호스팅을 하려면 서버 번들 ZIP 또는 디렉터리 경로가 필요합니다.') hostIssues.push('로컬 서버를 시작하려면 버킷 JAR 업로드가 필요합니다.')
} }
const launchReady = launchIssues.length === 0 const launchReady = launchIssues.length === 0
const hostReady = storedProfile.kind === 'server-pack' ? hostIssues.length === 0 : false const hostReady = storedProfile.serverEnabled ? hostIssues.length === 0 : false
return { return {
...storedProfile, ...storedProfile,
@@ -147,7 +200,7 @@ exports.getInstalledProfiles = async function(){
const catalog = await exports.loadCatalog() const catalog = await exports.loadCatalog()
const installedProfiles = ConfigManager.getInstalledLibraryProfiles() const installedProfiles = ConfigManager.getInstalledLibraryProfiles()
return installedProfiles.map((installedProfile) => { const mergedProfiles = installedProfiles.map((installedProfile) => {
const latestProfile = catalog.profiles.find((profile) => profile.id === installedProfile.id) const latestProfile = catalog.profiles.find((profile) => profile.id === installedProfile.id)
return latestProfile != null return latestProfile != null
? { ? {
@@ -157,6 +210,11 @@ exports.getInstalledProfiles = async function(){
} }
: installedProfile : installedProfile
}) })
ConfigManager.setInstalledLibraryProfiles(mergedProfiles)
ConfigManager.save()
return mergedProfiles
} }
exports.installProfile = async function(profileId){ exports.installProfile = async function(profileId){
@@ -214,10 +272,6 @@ exports.resolveServerAddress = function(profile){
return manualAddress return manualAddress
} }
if(typeof profile.defaultServerAddress === 'string' && profile.defaultServerAddress.length > 0){
return profile.defaultServerAddress
}
return null return null
} }
@@ -226,6 +280,14 @@ exports.setServerAddressOverride = function(profileId, address){
ConfigManager.save() ConfigManager.save()
} }
exports.shouldHostLocally = function(profile){
if(profile == null || profile.serverEnabled !== true){
return false
}
return exports.resolveServerAddress(profile) == null
}
exports.applyConfiguredProfile = function(){ exports.applyConfiguredProfile = function(){
const selectedProfile = exports.getSelectedProfileSync() const selectedProfile = exports.getSelectedProfileSync()
const distributionUrl = selectedProfile?.distributionUrl ?? DEFAULT_REMOTE_DISTRO_URL const distributionUrl = selectedProfile?.distributionUrl ?? DEFAULT_REMOTE_DISTRO_URL

View File

@@ -0,0 +1,299 @@
const childProcess = require('child_process')
const natUpnp = require('nat-upnp')
const ConfigManager = require('./configmanager')
const RULE_PREFIX = 'MRS Launcher Auto Port'
const UPnP_DESCRIPTION_PREFIX = 'MRS Launcher'
const UPnP_TTL_SECONDS = 3600
const states = new Map()
function buildState(profileId, port){
return {
profileId,
port,
statusCode: 'checking',
summary: '포트 확인 중',
message: '포트 개방 가능 여부를 확인하고 있습니다.',
tone: 'info',
firewallCreated: false,
ruleName: null,
upnpCreated: false,
checkedAt: Date.now()
}
}
function toPublicState(state){
return {
port: state.port,
statusCode: state.statusCode,
summary: state.summary,
message: state.message,
tone: state.tone,
checkedAt: state.checkedAt
}
}
function createClient(){
return natUpnp.createClient()
}
function clientGetMappings(client){
return new Promise((resolve, reject) => {
client.getMappings((error, results) => {
if(error){
reject(error)
return
}
resolve(Array.isArray(results) ? results : [])
})
})
}
function clientPortMapping(client, options){
return new Promise((resolve, reject) => {
client.portMapping(options, (error) => {
if(error){
reject(error)
return
}
resolve()
})
})
}
function clientPortUnmapping(client, options){
return new Promise((resolve, reject) => {
client.portUnmapping(options, (error) => {
if(error){
reject(error)
return
}
resolve()
})
})
}
function extractPort(mapping){
if(mapping == null){
return null
}
if(typeof mapping.public === 'number'){
return mapping.public
}
if(typeof mapping.publicPort === 'number'){
return mapping.publicPort
}
if(typeof mapping.port === 'number'){
return mapping.port
}
if(mapping.public != null && typeof mapping.public === 'object' && typeof mapping.public.port === 'number'){
return mapping.public.port
}
return null
}
function extractProtocol(mapping){
if(mapping == null){
return ''
}
const protocol = mapping.protocol ?? mapping.type ?? mapping.public?.protocol ?? ''
return String(protocol).toUpperCase()
}
async function findExistingTcpMapping(port){
const client = createClient()
const mappings = await clientGetMappings(client)
return mappings.find((mapping) => extractPort(mapping) === port && (extractProtocol(mapping) === '' || extractProtocol(mapping) === 'TCP')) ?? null
}
function getFirewallRuleName(profileId, port){
return `${RULE_PREFIX} ${profileId} ${port}`
}
function ensureWindowsFirewallRule(profileId, port){
if(process.platform !== 'win32'){
return {
created: false,
ruleName: null
}
}
const ruleName = getFirewallRuleName(profileId, port)
const result = childProcess.spawnSync('netsh', [
'advfirewall',
'firewall',
'add',
'rule',
`name=${ruleName}`,
'dir=in',
'action=allow',
'protocol=TCP',
`localport=${port}`
], {
encoding: 'utf8'
})
return {
created: result.status === 0,
ruleName
}
}
function deleteWindowsFirewallRule(ruleName){
if(process.platform !== 'win32' || !ruleName){
return
}
childProcess.spawnSync('netsh', [
'advfirewall',
'firewall',
'delete',
'rule',
`name=${ruleName}`
], {
encoding: 'utf8'
})
}
function buildPortFailureMessage(error){
const baseMessage = '자동 포트 개방 실패. 직접 포트포워딩 해주세요.'
if(error == null){
return baseMessage
}
const errorMessage = typeof error === 'string'
? error
: (error.message ?? String(error))
return `${baseMessage} ${errorMessage}`
}
exports.ensurePortAvailability = async function(profile){
if(profile?.serverEnabled !== true){
return {
port: null,
statusCode: 'not-needed',
summary: '포트 개방 필요 없음',
message: '이 프로필은 서버를 사용하지 않습니다.',
tone: 'info',
checkedAt: Date.now()
}
}
const manualAddress = ConfigManager.getLibraryServerAddressOverride(profile.id)
if(manualAddress != null && manualAddress.length > 0){
await exports.releaseProfilePort(profile.id)
return {
port: profile.serverPort ?? 25565,
statusCode: 'manual-address',
summary: '접속 주소 사용 중',
message: `직접 입력한 접속 주소가 설정되어 있어 자동 포트 개방을 건너뜁니다. (${manualAddress})`,
tone: 'info',
checkedAt: Date.now()
}
}
const port = profile.serverPort ?? 25565
const currentState = states.get(profile.id)
if(currentState != null && currentState.port === port && currentState.statusCode !== 'failed'){
currentState.checkedAt = Date.now()
return toPublicState(currentState)
}
const state = buildState(profile.id, port)
states.set(profile.id, state)
const firewallState = ensureWindowsFirewallRule(profile.id, port)
state.firewallCreated = firewallState.created
state.ruleName = firewallState.ruleName
try {
const existingMapping = await findExistingTcpMapping(port)
if(existingMapping != null){
state.statusCode = 'already-open'
state.summary = '이미 포트가 열려있습니다.'
state.message = `TCP ${port} 포트에 기존 포트포워딩이 있어 그대로 사용합니다.`
state.tone = 'success'
state.checkedAt = Date.now()
return toPublicState(state)
}
const client = createClient()
await clientPortMapping(client, {
public: port,
private: port,
protocol: 'TCP',
ttl: UPnP_TTL_SECONDS,
description: `${UPnP_DESCRIPTION_PREFIX} ${profile.id}`
})
state.upnpCreated = true
state.statusCode = 'opened'
state.summary = '자동 포트 개방 성공'
state.message = `UPnP로 TCP ${port} 포트를 열었습니다.${state.firewallCreated ? ' 윈도우 방화벽 허용도 같이 적용했습니다.' : ''}`
state.tone = 'success'
} catch (error) {
const message = typeof error?.message === 'string' ? error.message : String(error)
if(/ConflictInMappingEntry|Already/i.test(message)){
state.statusCode = 'already-open'
state.summary = '이미 포트가 열려있습니다.'
state.message = `TCP ${port} 포트에 기존 포트포워딩이 있어 그대로 사용합니다.`
state.tone = 'success'
} else {
state.statusCode = 'failed'
state.summary = '자동 포트 개방 실패'
state.message = buildPortFailureMessage(error)
state.tone = 'error'
}
}
state.checkedAt = Date.now()
return toPublicState(state)
}
exports.getPortAvailabilityState = function(profileId){
const state = states.get(profileId)
return state != null ? toPublicState(state) : null
}
exports.releaseProfilePort = async function(profileId){
const state = states.get(profileId)
if(state == null){
return
}
if(state.upnpCreated){
try {
const client = createClient()
await clientPortUnmapping(client, {
public: state.port,
protocol: 'TCP'
})
} catch (error) {
void error
}
}
if(state.firewallCreated && state.ruleName){
deleteWindowsFirewallRule(state.ruleName)
}
states.delete(profileId)
}
exports.cleanupAll = async function(){
await Promise.all(Array.from(states.keys()).map((profileId) => exports.releaseProfilePort(profileId)))
}
process.once('exit', () => {
for(const state of states.values()){
if(state.firewallCreated && state.ruleName){
deleteWindowsFirewallRule(state.ruleName)
}
}
})

View File

@@ -9,6 +9,7 @@ const os = require('os')
const path = require('path') const path = require('path')
const ConfigManager = require('./configmanager') const ConfigManager = require('./configmanager')
const ServerRuntime = require('./serverruntime')
const logger = LoggerUtil.getLogger('ProcessBuilder') const logger = LoggerUtil.getLogger('ProcessBuilder')
@@ -366,11 +367,14 @@ class ProcessBuilder {
_processAutoConnectArg(args){ _processAutoConnectArg(args){
const selectedProfileId = ConfigManager.getSelectedLibraryProfile() const selectedProfileId = ConfigManager.getSelectedLibraryProfile()
const selectedProfile = selectedProfileId != null
? ConfigManager.getInstalledLibraryProfile(selectedProfileId)
: null
const quickPlayWorld = selectedProfileId != null const quickPlayWorld = selectedProfileId != null
? ConfigManager.getLibraryQuickPlayWorld(selectedProfileId) ? ConfigManager.getLibraryQuickPlayWorld(selectedProfileId)
: null : null
if(quickPlayWorld){ if(quickPlayWorld && selectedProfile?.serverEnabled !== true){
if(mcVersionAtLeast('1.20', this.server.rawServer.minecraftVersion)){ if(mcVersionAtLeast('1.20', this.server.rawServer.minecraftVersion)){
args.push('--quickPlaySingleplayer') args.push('--quickPlaySingleplayer')
args.push(quickPlayWorld) args.push(quickPlayWorld)
@@ -379,14 +383,25 @@ class ProcessBuilder {
} }
if(ConfigManager.getAutoConnect() && this.server.rawServer.autoconnect){ if(ConfigManager.getAutoConnect() && this.server.rawServer.autoconnect){
const serverAddressOverride = selectedProfileId != null let serverAddressOverride = selectedProfileId != null
? resolveServerAddressOverride( ? resolveServerAddressOverride(
ConfigManager.getLibraryServerAddressOverride(selectedProfileId), ConfigManager.getLibraryServerAddressOverride(selectedProfileId),
this.server.port this.server.port
) )
: null : null
if(serverAddressOverride == null && selectedProfile?.serverEnabled === true){
const hostState = ServerRuntime.getHostedProfileState(selectedProfileId)
if(hostState.running){
serverAddressOverride = {
hostname: '127.0.0.1',
port: selectedProfile.serverPort ?? this.server.port
}
}
}
const hostname = serverAddressOverride?.hostname ?? this.server.hostname const hostname = serverAddressOverride?.hostname ?? this.server.hostname
const port = serverAddressOverride?.port ?? this.server.port const port = serverAddressOverride?.port ?? (selectedProfile?.serverEnabled === true ? (selectedProfile.serverPort ?? this.server.port) : this.server.port)
if(mcVersionAtLeast('1.20', this.server.rawServer.minecraftVersion)){ if(mcVersionAtLeast('1.20', this.server.rawServer.minecraftVersion)){
args.push('--quickPlayMultiplayer') args.push('--quickPlayMultiplayer')
args.push(`${hostname}:${port}`) args.push(`${hostname}:${port}`)

View File

@@ -53,7 +53,7 @@ async function downloadSourceToFile(source, destination){
const localSource = resolveLocalSource(source) const localSource = resolveLocalSource(source)
const localStat = await fs.stat(localSource) const localStat = await fs.stat(localSource)
if(localStat.isDirectory()){ if(localStat.isDirectory()){
throw new Error(`Directory source is not supported for archive cache: ${localSource}`) throw new Error(`Directory source is not supported for file cache: ${localSource}`)
} }
await fs.copy(localSource, destination, { overwrite: true }) await fs.copy(localSource, destination, { overwrite: true })
return destination return destination
@@ -83,21 +83,38 @@ async function extractZipToDirectory(zipPath, destination){
} }
} }
async function ensureCachedArchive(profileId, source, fileName){ async function ensureCachedFile(profileId, source, fileName){
const cachePath = getProfileCacheFile(profileId, fileName) const cachePath = getProfileCacheFile(profileId, fileName)
await downloadSourceToFile(source, cachePath) await downloadSourceToFile(source, cachePath)
return cachePath return cachePath
} }
async function ensureServerWorldInstalled(profile, serverDirectory){
if(!profile.worldArchiveUrl){
return null
}
const targetWorldDirectory = path.join(serverDirectory, 'world')
if(isRemoteSource(profile.worldArchiveUrl) || profile.worldArchiveUrl.endsWith('.zip') || profile.worldArchiveUrl.startsWith('file://')){
const cachePath = await ensureCachedFile(profile.id, profile.worldArchiveUrl, 'world.zip')
await extractZipToDirectory(cachePath, targetWorldDirectory)
} else {
await fs.remove(targetWorldDirectory)
await fs.copy(resolveLocalSource(profile.worldArchiveUrl), targetWorldDirectory, { overwrite: true })
}
return targetWorldDirectory
}
exports.getProfileBaseDirectory = getProfileBaseDirectory exports.getProfileBaseDirectory = getProfileBaseDirectory
exports.getServerBundleDirectory = getServerBundleDirectory exports.getServerBundleDirectory = getServerBundleDirectory
exports.prefetchProfileAssets = async function(profile){ exports.prefetchProfileAssets = async function(profile){
if(profile.worldArchiveUrl){ if(profile.worldArchiveUrl){
await ensureCachedArchive(profile.id, profile.worldArchiveUrl, 'world.zip') await ensureCachedFile(profile.id, profile.worldArchiveUrl, 'world.zip')
} }
if(profile.serverBundleUrl){ if(profile.serverJarUrl){
await ensureCachedArchive(profile.id, profile.serverBundleUrl, 'server.zip') await ensureCachedFile(profile.id, profile.serverJarUrl, 'server.jar')
} }
const currentState = ConfigManager.getLibraryProfileAssetState(profile.id) const currentState = ConfigManager.getLibraryProfileAssetState(profile.id)
@@ -118,7 +135,7 @@ exports.ensureWorldInstalled = async function(profile, serverId){
await fs.ensureDir(savesDirectory) await fs.ensureDir(savesDirectory)
if(isRemoteSource(profile.worldArchiveUrl) || profile.worldArchiveUrl.endsWith('.zip') || profile.worldArchiveUrl.startsWith('file://')){ if(isRemoteSource(profile.worldArchiveUrl) || profile.worldArchiveUrl.endsWith('.zip') || profile.worldArchiveUrl.startsWith('file://')){
const cachePath = await ensureCachedArchive(profile.id, profile.worldArchiveUrl, 'world.zip') const cachePath = await ensureCachedFile(profile.id, profile.worldArchiveUrl, 'world.zip')
await extractZipToDirectory(cachePath, targetWorldDirectory) await extractZipToDirectory(cachePath, targetWorldDirectory)
} else { } else {
await fs.remove(targetWorldDirectory) await fs.remove(targetWorldDirectory)
@@ -136,33 +153,36 @@ exports.ensureWorldInstalled = async function(profile, serverId){
return targetWorldDirectory return targetWorldDirectory
} }
exports.ensureServerBundleInstalled = async function(profile){ exports.ensureServerJarInstalled = async function(profile){
if(!profile.serverBundleUrl){ if(!profile.serverJarUrl){
return null return null
} }
const targetDirectory = getServerBundleDirectory(profile) const targetDirectory = getServerBundleDirectory(profile)
if(isRemoteSource(profile.serverBundleUrl) || profile.serverBundleUrl.endsWith('.zip') || profile.serverBundleUrl.startsWith('file://')){ const targetJarPath = path.join(targetDirectory, 'server.jar')
const cachePath = await ensureCachedArchive(profile.id, profile.serverBundleUrl, 'server.zip') await fs.ensureDir(targetDirectory)
await extractZipToDirectory(cachePath, targetDirectory)
} else { const cachePath = await ensureCachedFile(profile.id, profile.serverJarUrl, 'server.jar')
await fs.remove(targetDirectory) await fs.copy(cachePath, targetJarPath, { overwrite: true })
await fs.copy(resolveLocalSource(profile.serverBundleUrl), targetDirectory, { overwrite: true }) await ensureServerWorldInstalled(profile, targetDirectory)
}
ConfigManager.setLibraryProfileAssetState(profile.id, { ConfigManager.setLibraryProfileAssetState(profile.id, {
serverBundleInstalledAt: new Date().toISOString(), serverInstalledAt: new Date().toISOString(),
serverBundleDirectory: targetDirectory serverDirectory: targetDirectory,
serverJarPath: targetJarPath
}) })
ConfigManager.save() ConfigManager.save()
return targetDirectory return {
serverDirectory: targetDirectory,
serverJarPath: targetJarPath
}
} }
exports.ensureServerBundleInstalled = exports.ensureServerJarInstalled
exports.prepareProfileForLaunch = async function(profile, serverId){ exports.prepareProfileForLaunch = async function(profile, serverId){
if(profile.kind === 'map'){
return exports.ensureWorldInstalled(profile, serverId) return exports.ensureWorldInstalled(profile, serverId)
}
return null
} }
exports.ensureServerWorldInstalled = ensureServerWorldInstalled

View File

@@ -8,18 +8,6 @@ const installPageShell = document.querySelector('#installContainer .launcherPage
let expandedProfileId = null let expandedProfileId = null
function describeProfileKind(kind){
switch(kind){
case 'map':
return '오리지널 맵'
case 'server-pack':
return '플러그인 맵 + 서버팩'
case 'modpack':
default:
return '모드팩'
}
}
function createInstallBadge(text){ function createInstallBadge(text){
const badge = document.createElement('span') const badge = document.createElement('span')
badge.className = 'launcherBadge' badge.className = 'launcherBadge'
@@ -27,6 +15,20 @@ function createInstallBadge(text){
return badge return badge
} }
function describeProfileFeatures(profile){
const features = ['맵']
if(profile.modsEnabled){
features.push('모드')
}
if(profile.pluginsEnabled){
features.push('플러그인')
}
if(profile.serverEnabled){
features.push('서버')
}
return features
}
function createInfoLine(label, value){ function createInfoLine(label, value){
const line = document.createElement('div') const line = document.createElement('div')
line.className = 'launcherInfoLine' line.className = 'launcherInfoLine'
@@ -57,26 +59,30 @@ function buildDetailText(profile){
return profile.details.trim() return profile.details.trim()
} }
switch(profile.kind){ if(profile.serverEnabled){
case 'map': return '이 프로필은 맵을 기본으로 두고 서버 기능까지 함께 사용하는 항목입니다. 주소를 직접 넣으면 해당 서버로 접속하고, 주소를 비워두면 로컬 서버 실행 흐름을 사용할 수 있습니다.'
return '이 프로필은 싱글플레이 월드를 바로 실행하기 위한 항목입니다. 필요한 클라이언트 배포 파일과 월드 자료는 관리자가 미리 등록해둡니다.'
case 'server-pack':
return '이 프로필은 서버 실행/접속 흐름을 함께 다루는 항목입니다. 클라이언트 파일과 서버 번들은 관리자가 미리 등록하며, 사용자는 라이브러리에서 실행과 접속만 진행합니다.'
case 'modpack':
default:
return '이 프로필은 일반 모드팩 클라이언트입니다. 필요한 배포 파일은 관리자가 미리 등록하며, 사용자는 라이브러리에 추가한 뒤 실행만 하면 됩니다.'
} }
if(profile.modsEnabled){
return '이 프로필은 맵 기반 클라이언트에 모드 구성을 포함한 항목입니다. 관리자가 distribution과 월드 자료를 미리 등록해두고, 사용자는 라이브러리에 추가한 뒤 바로 실행합니다.'
}
return '이 프로필은 맵 기반 기본 항목입니다. 관리자가 distribution과 월드 자료를 미리 등록해두고, 사용자는 라이브러리에 추가한 뒤 바로 실행합니다.'
} }
function toggleExpandedProfile(profileId){ function toggleExpandedProfile(profileId){
expandedProfileId = expandedProfileId === profileId ? null : profileId expandedProfileId = expandedProfileId === profileId ? null : profileId
} }
function isInstallable(profile){
return profile.launchReady
}
async function installProfile(profile){ async function installProfile(profile){
const installedProfile = await CatalogManager.installProfile(profile.id) const installedProfile = await CatalogManager.installProfile(profile.id)
await ProfileAssetManager.prefetchProfileAssets(installedProfile) await ProfileAssetManager.prefetchProfileAssets(installedProfile)
if(installedProfile.kind === 'server-pack' && installedProfile.hostReady){ if(installedProfile.serverEnabled && installedProfile.hostReady){
await ProfileAssetManager.ensureServerBundleInstalled(installedProfile) await ProfileAssetManager.ensureServerJarInstalled(installedProfile)
} }
if(typeof refreshSelectedProfileButton === 'function'){ if(typeof refreshSelectedProfileButton === 'function'){
@@ -99,36 +105,38 @@ function createExpandedDetail(profile, installed){
const badgeRow = document.createElement('div') const badgeRow = document.createElement('div')
badgeRow.className = 'launcherExpandableMeta' badgeRow.className = 'launcherExpandableMeta'
badgeRow.appendChild(createInstallBadge(describeProfileKind(profile.kind))) describeProfileFeatures(profile).forEach((label) => {
badgeRow.appendChild(createInstallBadge(label))
})
if(installed){ if(installed){
badgeRow.appendChild(createInstallBadge('설치됨')) badgeRow.appendChild(createInstallBadge('설치됨'))
} }
if(!profile.launchReady){ if(profile.serverEnabled && profile.hostReady){
badgeRow.appendChild(createInstallBadge('실행 준비 필요')) badgeRow.appendChild(createInstallBadge('로컬 서버 가능'))
} }
if(profile.kind === 'server-pack' && profile.hostReady){ if(!profile.launchReady){
badgeRow.appendChild(createInstallBadge('호스팅 가능')) badgeRow.appendChild(createInstallBadge('설정 필요'))
} }
const infoBlock = document.createElement('div') const infoBlock = document.createElement('div')
infoBlock.className = 'launcherInfoBlock' infoBlock.className = 'launcherInfoBlock'
infoBlock.appendChild(createInfoLine('프로필 ID', profile.id)) infoBlock.appendChild(createInfoLine('프로필 ID', profile.id))
infoBlock.appendChild(createInfoLine('종류', describeProfileKind(profile.kind))) infoBlock.appendChild(createInfoLine('구성', describeProfileFeatures(profile).join(' + ')))
infoBlock.appendChild(createInfoLine('실행 준비', profile.launchReady ? '완료' : '추가 설정 필요')) infoBlock.appendChild(createInfoLine('실행 준비', profile.launchReady ? '완료' : '추가 설정 필요'))
infoBlock.appendChild(createInfoLine('월드 폴더', profile.worldDirectoryName || '미설정'))
if(profile.defaultServerAddress){ if(profile.serverEnabled){
infoBlock.appendChild(createInfoLine('기본 주소', profile.defaultServerAddress)) infoBlock.appendChild(createInfoLine('서버 포트', String(profile.serverPort ?? 25565)))
} infoBlock.appendChild(createInfoLine('서버 메모리', `${profile.serverMemoryMb ?? 4096}MB`))
if(profile.kind === 'map' && profile.worldDirectoryName){ infoBlock.appendChild(createInfoLine('최대 인원수', String(profile.serverMaxPlayers ?? 20)))
infoBlock.appendChild(createInfoLine('월드 폴더', profile.worldDirectoryName)) infoBlock.appendChild(createInfoLine('화이트리스트', profile.serverWhitelistEnabled ? '사용' : '미사용'))
} infoBlock.appendChild(createInfoLine('로컬 서버 준비', profile.hostReady ? '완료' : '버킷 JAR 필요'))
if(profile.kind === 'server-pack'){
infoBlock.appendChild(createInfoLine('로컬 호스팅', profile.hostReady ? '가능' : '관리자 설정 필요'))
} }
if(profile.launchIssues.length > 0){ if(profile.launchIssues.length > 0){
infoBlock.appendChild(createInfoLine('확인 필요', profile.launchIssues.join(' / '))) infoBlock.appendChild(createInfoLine('확인 필요', profile.launchIssues.join(' / ')))
} else if(profile.hostIssues.length > 0){ } else if(profile.hostIssues.length > 0){
infoBlock.appendChild(createInfoLine('호스팅 참고', profile.hostIssues.join(' / '))) infoBlock.appendChild(createInfoLine('서버 참고', profile.hostIssues.join(' / ')))
} }
const bodyGroup = document.createElement('div') const bodyGroup = document.createElement('div')
@@ -151,7 +159,7 @@ function createExpandedDetail(profile, installed){
const installButton = document.createElement('button') const installButton = document.createElement('button')
installButton.className = 'launcherPrimaryButton' installButton.className = 'launcherPrimaryButton'
installButton.textContent = installed ? '설치됨' : '라이브러리에 추가' installButton.textContent = installed ? '설치됨' : '라이브러리에 추가'
installButton.disabled = installed || !profile.launchReady installButton.disabled = installed || !isInstallable(profile)
installButton.addEventListener('click', async (event) => { installButton.addEventListener('click', async (event) => {
event.stopPropagation() event.stopPropagation()
try { try {
@@ -204,7 +212,7 @@ async function renderInstallView(){
if(catalog.sourceError != null){ if(catalog.sourceError != null){
const warningCard = document.createElement('article') const warningCard = document.createElement('article')
warningCard.className = 'launcherCard' warningCard.className = 'launcherCard'
warningCard.innerHTML = '<h3 class="launcherCardTitle">카탈로그 로드 경고</h3><p class="launcherCardDescription">관리자가 등록한 카탈로그를 읽지 못했습니다. 현재 보이는 목록이 없다면 배포 주소 또는 로컬 카탈로그 파일을 관리자 측에서 확인해야 합니다.</p>' warningCard.innerHTML = '<h3 class="launcherCardTitle">카탈로그 로드 경고</h3><p class="launcherCardDescription">관리자가 등록한 카탈로그를 읽지 못했습니다. 현재 보이는 목록이 없다면 관리자 사이트에서 카탈로그 파일과 배포 경로를 다시 확인하세요.</p>'
installCatalogList.appendChild(warningCard) installCatalogList.appendChild(warningCard)
} }
@@ -236,12 +244,14 @@ async function renderInstallView(){
const meta = document.createElement('div') const meta = document.createElement('div')
meta.className = 'launcherListMeta' meta.className = 'launcherListMeta'
meta.appendChild(createInstallBadge(describeProfileKind(profile.kind))) describeProfileFeatures(profile).forEach((label) => {
meta.appendChild(createInstallBadge(label))
})
if(installed){ if(installed){
meta.appendChild(createInstallBadge('설치됨')) meta.appendChild(createInstallBadge('설치됨'))
} }
if(profile.kind === 'server-pack' && profile.hostReady){ if(profile.serverEnabled && profile.hostReady){
meta.appendChild(createInstallBadge('호스팅 가능')) meta.appendChild(createInstallBadge('로컬 서버 가능'))
} }
const description = document.createElement('p') const description = document.createElement('p')
@@ -260,67 +270,31 @@ async function renderInstallView(){
await renderInstallView() await renderInstallView()
}) })
const installButton = document.createElement('button')
installButton.className = 'launcherPrimaryButton'
installButton.textContent = installed ? '설치됨' : '라이브러리에 추가'
installButton.disabled = installed || !profile.launchReady
installButton.addEventListener('click', async (event) => {
event.stopPropagation()
try {
await installProfile(profile)
expandedProfileId = profile.id
await renderInstallView()
showInstallMessage('추가 완료', `${profile.name} 프로필을 라이브러리에 추가했습니다.`)
} catch (error) {
console.error(error)
const message = error instanceof Error ? error.message : '프로필 설치 중 오류가 발생했습니다.'
showInstallMessage('설치 실패', message)
}
})
textGroup.appendChild(title)
titleRow.appendChild(textGroup)
titleRow.appendChild(meta)
main.appendChild(titleRow)
main.appendChild(description)
actions.appendChild(detailButton) actions.appendChild(detailButton)
actions.appendChild(installButton) textGroup.appendChild(title)
textGroup.appendChild(meta)
titleRow.appendChild(textGroup)
top.appendChild(main) top.appendChild(main)
top.appendChild(actions) top.appendChild(actions)
main.appendChild(titleRow)
main.appendChild(description)
row.appendChild(top) row.appendChild(top)
if(expanded){ if(expanded){
row.appendChild(createExpandedDetail(profile, installed)) row.appendChild(createExpandedDetail(profile, installed))
} }
row.addEventListener('click', async () => {
toggleExpandedProfile(profile.id)
await renderInstallView()
})
installCatalogList.appendChild(row) installCatalogList.appendChild(row)
} }
if(catalog.profiles.length === 0){
const emptyCard = document.createElement('article')
emptyCard.className = 'launcherCard'
emptyCard.innerHTML = '<h3 class="launcherCardTitle">등록된 프로필이 없습니다</h3><p class="launcherCardDescription">관리자가 카탈로그에 프로필을 추가하면 여기에 표시됩니다.</p>'
installCatalogList.appendChild(emptyCard)
}
} catch (error) { } catch (error) {
console.error(error) console.error(error)
const errorCard = document.createElement('article') const errorCard = document.createElement('article')
errorCard.className = 'launcherCard' errorCard.className = 'launcherCard'
errorCard.innerHTML = '<h3 class="launcherCardTitle">카탈로그 로드 실패</h3><p class="launcherCardDescription">관리자가 등록한 카탈로그를 읽지 못했습니다.</p>' errorCard.innerHTML = '<h3 class="launcherCardTitle">설치 페이지 로드 실패</h3><p class="launcherCardDescription">프로필 목록을 읽지 못했습니다. 관리자 사이트에서 catalog 설정을 확인하세요.</p>'
installCatalogList.appendChild(errorCard) installCatalogList.appendChild(errorCard)
} finally { } finally {
if(installPageShell != null){ if(installPageShell != null){
requestAnimationFrame(() => {
installPageShell.scrollTop = previousScrollTop installPageShell.scrollTop = previousScrollTop
})
} }
} }
} }

View File

@@ -5,8 +5,7 @@
// Requirements // Requirements
const { URL } = require('url') const { URL } = require('url')
const { const {
MojangRestAPI, MojangRestAPI
getServerStatus
} = require('helios-core/mojang') } = require('helios-core/mojang')
const { const {
RestResponseStatus, RestResponseStatus,
@@ -32,7 +31,9 @@ const {
const AuthManager = require('./assets/js/authmanager') const AuthManager = require('./assets/js/authmanager')
const CatalogManager = require('./assets/js/catalogmanager') const CatalogManager = require('./assets/js/catalogmanager')
const DiscordWrapper = require('./assets/js/discordwrapper') const DiscordWrapper = require('./assets/js/discordwrapper')
const PortManager = require('./assets/js/portmanager')
const ProcessBuilder = require('./assets/js/processbuilder') const ProcessBuilder = require('./assets/js/processbuilder')
const ServerRuntime = require('./assets/js/serverruntime')
// Launch Elements // Launch Elements
const launch_content = document.getElementById('launch_content') const launch_content = document.getElementById('launch_content')
@@ -47,6 +48,7 @@ const avatarContainer = document.getElementById('avatarContainer')
const accountMenu = document.getElementById('accountMenu') const accountMenu = document.getElementById('accountMenu')
const accountMenuName = document.getElementById('accountMenuName') const accountMenuName = document.getElementById('accountMenuName')
const accountMenuLogoutButton = document.getElementById('accountMenuLogoutButton') const accountMenuLogoutButton = document.getElementById('accountMenuLogoutButton')
const portStatusTooltip = document.getElementById('portStatusTooltip')
const loggerLanding = LoggerUtil.getLogger('Landing') const loggerLanding = LoggerUtil.getLogger('Landing')
@@ -188,7 +190,7 @@ function refreshSelectedProfileButton(){
} }
function isSelectedMapReady(profile){ function isSelectedMapReady(profile){
if(profile == null || profile.kind !== 'map'){ if(profile == null || profile.serverEnabled === true){
return false return false
} }
@@ -200,6 +202,24 @@ function isSelectedMapReady(profile){
) )
} }
function updateLandingStatusDisplay(label, value, tone, tooltip, fade = false){
const applyValues = () => {
document.getElementById('landingPlayerLabel').innerHTML = label
document.getElementById('player_count').innerHTML = value
document.getElementById('player_count').dataset.tone = tone
portStatusTooltip.textContent = tooltip
}
if(fade){
$('#server_status_wrapper').fadeOut(250, () => {
applyValues()
$('#server_status_wrapper').fadeIn(500)
})
} else {
applyValues()
}
}
// Bind launch button // Bind launch button
document.getElementById('launch_button').addEventListener('click', async e => { document.getElementById('launch_button').addEventListener('click', async e => {
loggerLanding.info('Launching game..') loggerLanding.info('Launching game..')
@@ -394,70 +414,45 @@ const refreshServerStatus = async (fade = false) => {
let pLabel = Lang.queryJS('landing.profileStatus.label') let pLabel = Lang.queryJS('landing.profileStatus.label')
let pVal = Lang.queryJS('landing.selectedProfile.noSelection') let pVal = Lang.queryJS('landing.selectedProfile.noSelection')
let pTone = 'info'
let tooltip = '라이브러리에서 실행할 프로필을 먼저 선택하세요.'
if(selectedProfile == null){ if(selectedProfile == null){
if(fade){ updateLandingStatusDisplay(pLabel, pVal, pTone, tooltip, fade)
$('#server_status_wrapper').fadeOut(250, () => {
document.getElementById('landingPlayerLabel').innerHTML = pLabel
document.getElementById('player_count').innerHTML = pVal
$('#server_status_wrapper').fadeIn(500)
})
} else {
document.getElementById('landingPlayerLabel').innerHTML = pLabel
document.getElementById('player_count').innerHTML = pVal
}
return return
} }
if(selectedProfile.kind === 'map'){ if(selectedProfile.serverEnabled !== true){
pLabel = Lang.queryJS('landing.mapStatus.label') pLabel = Lang.queryJS('landing.mapStatus.label')
pVal = isSelectedMapReady(selectedProfile) pVal = isSelectedMapReady(selectedProfile)
? Lang.queryJS('landing.mapStatus.ready') ? Lang.queryJS('landing.mapStatus.ready')
: Lang.queryJS('landing.mapStatus.notReady') : Lang.queryJS('landing.mapStatus.notReady')
pTone = selectedProfile.launchReady ? 'success' : 'error'
if(fade){ tooltip = selectedProfile.launchReady
$('#server_status_wrapper').fadeOut(250, () => { ? '이 프로필은 맵 실행 준비가 끝났습니다.'
document.getElementById('landingPlayerLabel').innerHTML = pLabel : (selectedProfile.launchIssues?.join(' / ') || '맵 실행 준비가 아직 끝나지 않았습니다.')
document.getElementById('player_count').innerHTML = pVal updateLandingStatusDisplay(pLabel, pVal, pTone, tooltip, fade)
$('#server_status_wrapper').fadeIn(500)
})
} else {
document.getElementById('landingPlayerLabel').innerHTML = pLabel
document.getElementById('player_count').innerHTML = pVal
}
return return
} }
pLabel = Lang.queryJS('landing.serverStatus.server')
pVal = Lang.queryJS('landing.serverStatus.offline')
try { try {
const distro = await DistroAPI.getDistribution() pLabel = Lang.queryJS('landing.portStatus.label')
const serv = distro?.getServerById(ConfigManager.getSelectedServer()) const portState = await PortManager.ensurePortAvailability(selectedProfile)
?? (typeof distro?.getMainServer === 'function' ? distro.getMainServer() : null) pVal = portState.summary
if(serv == null){ pTone = portState.tone
throw new Error('No server available for selected profile.') tooltip = selectedProfile.hostIssues?.length > 0
} ? `${portState.message} / ${selectedProfile.hostIssues.join(' / ')}`
: portState.message
const servStat = await getServerStatus(47, serv.hostname, serv.port)
pLabel = Lang.queryJS('landing.serverStatus.players')
pVal = servStat.players.online + '/' + servStat.players.max
} catch (err) { } catch (err) {
loggerLanding.warn('Unable to refresh server status, assuming offline.') loggerLanding.warn('Unable to refresh port status.')
loggerLanding.debug(err) loggerLanding.debug(err)
} pLabel = Lang.queryJS('landing.portStatus.label')
if(fade){ pVal = Lang.queryJS('landing.portStatus.failed')
$('#server_status_wrapper').fadeOut(250, () => { pTone = 'error'
document.getElementById('landingPlayerLabel').innerHTML = pLabel tooltip = err instanceof Error ? err.message : '자동 포트 개방 상태를 확인하지 못했습니다.'
document.getElementById('player_count').innerHTML = pVal
$('#server_status_wrapper').fadeIn(500)
})
} else {
document.getElementById('landingPlayerLabel').innerHTML = pLabel
document.getElementById('player_count').innerHTML = pVal
} }
updateLandingStatusDisplay(pLabel, pVal, pTone, tooltip, fade)
} }
refreshMojangStatuses() refreshMojangStatuses()
@@ -468,6 +463,10 @@ let mojangStatusListener = setInterval(() => refreshMojangStatuses(true), 60*60*
// Set refresh rate to once every 5 minutes. // Set refresh rate to once every 5 minutes.
let serverStatusListener = setInterval(() => refreshServerStatus(true), 300000) let serverStatusListener = setInterval(() => refreshServerStatus(true), 300000)
window.addEventListener('beforeunload', () => {
PortManager.cleanupAll().catch(() => {})
})
/** /**
* Shows an error overlay, toggles off the launch area. * Shows an error overlay, toggles off the launch area.
* *
@@ -747,10 +746,34 @@ async function dlAsync(login = true) {
if(login) { if(login) {
const authUser = ConfigManager.getSelectedAccount() const authUser = ConfigManager.getSelectedAccount()
const selectedProfile = CatalogManager.getSelectedProfileSync()
loggerLaunchSuite.info(`Sending selected account (${authUser.displayName}) to ProcessBuilder.`) loggerLaunchSuite.info(`Sending selected account (${authUser.displayName}) to ProcessBuilder.`)
let pb = new ProcessBuilder(serv, versionData, modLoaderData, authUser, remote.app.getVersion()) let pb = new ProcessBuilder(serv, versionData, modLoaderData, authUser, remote.app.getVersion())
setLaunchDetails(Lang.queryJS('landing.dlAsync.launchingGame')) setLaunchDetails(Lang.queryJS('landing.dlAsync.launchingGame'))
if(selectedProfile?.serverEnabled === true && CatalogManager.shouldHostLocally(selectedProfile)){
if(!selectedProfile.hostReady){
showLaunchFailure(
Lang.queryJS('landing.localServer.missingJarTitle'),
Lang.queryJS('landing.localServer.missingJarText')
)
return
}
setLaunchDetails(Lang.queryJS('landing.localServer.starting'))
try {
await ServerRuntime.startHostedProfile(selectedProfile)
refreshServerStatus(true)
} catch (error) {
loggerLaunchSuite.error('Failed to start local server.', error)
showLaunchFailure(
Lang.queryJS('landing.localServer.failureTitle'),
error instanceof Error ? error.message : Lang.queryJS('landing.localServer.failureText')
)
return
}
}
// const SERVER_JOINED_REGEX = /\[.+\]: \[CHAT\] [a-zA-Z0-9_]{1,16} joined the game/ // const SERVER_JOINED_REGEX = /\[.+\]: \[CHAT\] [a-zA-Z0-9_]{1,16} joined the game/
const SERVER_JOINED_REGEX = new RegExp(`\\[.+\\]: \\[CHAT\\] ${authUser.displayName} joined the game`) const SERVER_JOINED_REGEX = new RegExp(`\\[.+\\]: \\[CHAT\\] ${authUser.displayName} joined the game`)

View File

@@ -1,6 +1,4 @@
(() => { (() => {
const { clipboard } = require('electron')
const CatalogManager = require('./assets/js/catalogmanager') const CatalogManager = require('./assets/js/catalogmanager')
const ConfigManager = require('./assets/js/configmanager') const ConfigManager = require('./assets/js/configmanager')
const ProfileAssetManager = require('./assets/js/profileassetmanager') const ProfileAssetManager = require('./assets/js/profileassetmanager')
@@ -21,16 +19,18 @@ function createBadge(text){
return badge return badge
} }
function describeProfileKind(kind){ function describeProfileFeatures(profile){
switch(kind){ const features = ['맵']
case 'map': if(profile.modsEnabled){
return '맵' features.push('모드')
case 'server-pack':
return '서버팩'
case 'modpack':
default:
return '모드팩'
} }
if(profile.pluginsEnabled){
features.push('플러그인')
}
if(profile.serverEnabled){
features.push('서버')
}
return features
} }
function createParagraph(className, text){ function createParagraph(className, text){
@@ -40,23 +40,6 @@ function createParagraph(className, text){
return element return element
} }
function createInfoLine(label, value){
const line = document.createElement('div')
line.className = 'launcherInfoLine'
const labelElement = document.createElement('span')
labelElement.className = 'launcherInfoLabel'
labelElement.textContent = label
const valueElement = document.createElement('span')
valueElement.className = 'launcherInfoValue'
valueElement.textContent = value
line.appendChild(labelElement)
line.appendChild(valueElement)
return line
}
function showLibraryMessage(title, message){ function showLibraryMessage(title, message){
if(typeof setOverlayContent === 'function'){ if(typeof setOverlayContent === 'function'){
setOverlayContent(title, message, '확인') setOverlayContent(title, message, '확인')
@@ -65,55 +48,24 @@ function showLibraryMessage(title, message){
} }
} }
function describeAssetState(profile){
const state = ConfigManager.getLibraryProfileAssetState(profile.id)
if(profile.kind === 'map'){
if(state.worldInstalledAt){
return `맵 설치 완료 · ${profile.worldDirectoryName}`
}
if(profile.worldArchiveUrl){
return '맵 아카이브 준비 필요'
}
}
if(profile.kind === 'server-pack'){
if(state.serverBundleInstalledAt){
return '서버 번들 설치 완료'
}
if(profile.serverBundleUrl){
return '서버 번들 준비 필요'
}
}
return '추가 자산 없음'
}
function isProfileInstalled(profile){ function isProfileInstalled(profile){
const state = ConfigManager.getLibraryProfileAssetState(profile.id) const state = ConfigManager.getLibraryProfileAssetState(profile.id)
const mapReady = state.worldInstalledAt != null || state.prefetchedAt != null || profile.worldArchiveUrl == null
if(profile.kind === 'map'){ const serverReady = profile.serverEnabled !== true || profile.hostReady !== true || state.serverInstalledAt != null || state.prefetchedAt != null || profile.serverJarUrl == null
return state.prefetchedAt != null || profile.worldArchiveUrl == null return mapReady && serverReady
}
if(profile.kind === 'server-pack'){
return state.serverBundleInstalledAt != null || state.prefetchedAt != null || profile.serverBundleUrl == null
}
return true
} }
async function prepareProfileAssets(profile){ async function prepareProfileAssets(profile){
try { try {
await ProfileAssetManager.prefetchProfileAssets(profile) await ProfileAssetManager.prefetchProfileAssets(profile)
if(profile.kind === 'server-pack' && profile.hostReady){ if(profile.serverEnabled && profile.hostReady){
await ProfileAssetManager.ensureServerBundleInstalled(profile) await ProfileAssetManager.ensureServerJarInstalled(profile)
} }
await renderLibraryView() await renderLibraryView()
showLibraryMessage('자료 준비 완료', `${profile.name} 자료를 준비했습니다.`) showLibraryMessage('자료 준비 완료', `${profile.name} 자료를 준비했습니다.`)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
showLibraryMessage('자료 준비 실패', '프로필 자료를 내려받거나 해제하는 중 오류가 발생했습니다.') showLibraryMessage('자료 준비 실패', '프로필 자료를 내려받는 중 오류가 발생했습니다.')
} }
} }
@@ -138,63 +90,13 @@ async function applyProfileSelection(profile){
onDistroRefresh(distro) onDistroRefresh(distro)
} }
async function activateProfile(profile, launchNow = false){ function appendAddressOverrideField(profile, container){
if(!profile.configured){ if(profile.serverEnabled !== true){
const firstIssue = profile.launchIssues?.[0] ?? '이 프로필은 아직 실행 조건이 충족되지 않았습니다.'
showLibraryMessage('프로필 설정 필요', firstIssue)
return return
} }
CatalogManager.selectProfile(profile.id) const fieldGroup = document.createElement('div')
CatalogManager.applyConfiguredProfile() fieldGroup.className = 'launcherFieldGroup'
if(typeof refreshSelectedProfileButton === 'function'){
refreshSelectedProfileButton()
}
try {
const distro = await DistroAPI.refreshDistributionOrFallback()
if(distro == null){
throw new Error('Distribution refresh returned null.')
}
const currentServer = distro.getServerById(ConfigManager.getSelectedServer())
if(currentServer == null && typeof distro.getMainServer === 'function'){
const mainServer = distro.getMainServer()
if(mainServer != null){
ConfigManager.setSelectedServer(mainServer.rawServer.id)
ConfigManager.save()
}
}
const selectedServerId = ConfigManager.getSelectedServer()
if(selectedServerId != null){
await ProfileAssetManager.prepareProfileForLaunch(profile, selectedServerId)
}
onDistroRefresh(distro)
if(getCurrentView() === VIEWS.landing){
if(launchNow){
document.getElementById('launch_button').click()
}
return
}
switchView(getCurrentView(), VIEWS.landing, 250, 250, () => {}, () => {
if(launchNow){
document.getElementById('launch_button').click()
}
})
} catch (error) {
console.error(error)
showLibraryMessage('프로필 로드 실패', '선택한 프로필의 distribution.json 또는 부가 자산을 불러오지 못했습니다.')
}
}
function appendAddressOverrideField(profile, fieldGroup){
if(!profile.allowCustomServerAddress){
return
}
const label = document.createElement('label') const label = document.createElement('label')
label.className = 'launcherFieldLabel' label.className = 'launcherFieldLabel'
@@ -203,45 +105,25 @@ function appendAddressOverrideField(profile, fieldGroup){
const input = document.createElement('input') const input = document.createElement('input')
input.className = 'launcherFieldInput' input.className = 'launcherFieldInput'
input.type = 'text' input.type = 'text'
input.placeholder = profile.defaultServerAddress || 'example.com:25565' input.placeholder = '비워두면 로컬 서버 실행'
input.value = ConfigManager.getLibraryServerAddressOverride(profile.id) ?? '' input.value = ConfigManager.getLibraryServerAddressOverride(profile.id) ?? ''
input.addEventListener('change', () => { input.addEventListener('change', () => {
CatalogManager.setServerAddressOverride(profile.id, input.value) CatalogManager.setServerAddressOverride(profile.id, input.value)
if(typeof refreshServerStatus === 'function'){
refreshServerStatus(true)
}
}) })
const help = document.createElement('div')
help.className = 'launcherFieldHint'
help.textContent = profile.hostReady
? '주소를 비워두면 PLAY 시 로컬 서버를 실행하고, 값을 넣으면 해당 주소로 바로 접속합니다.'
: '주소를 비워두면 로컬 서버 실행을 시도합니다. 지금은 버킷 JAR이 없어 직접 실행 준비가 부족합니다.'
fieldGroup.appendChild(label) fieldGroup.appendChild(label)
fieldGroup.appendChild(input) fieldGroup.appendChild(input)
} fieldGroup.appendChild(help)
container.appendChild(fieldGroup)
function appendPublishedAddressField(profile, hostState, fieldGroup){
if(!hostState.publishedAddress){
return
}
const label = document.createElement('label')
label.className = 'launcherFieldLabel'
label.textContent = '호스트 공개 주소'
const row = document.createElement('div')
row.className = 'launcherInlineField'
const input = document.createElement('input')
input.className = 'launcherFieldInput'
input.type = 'text'
input.readOnly = true
input.value = hostState.publishedAddress
const copyButton = document.createElement('button')
copyButton.className = 'launcherSecondaryButton'
copyButton.textContent = '주소 복사'
copyButton.addEventListener('click', () => {
clipboard.writeText(hostState.publishedAddress)
})
row.appendChild(input)
row.appendChild(copyButton)
fieldGroup.appendChild(label)
fieldGroup.appendChild(row)
} }
async function renderLibraryView(){ async function renderLibraryView(){
@@ -273,24 +155,20 @@ async function renderLibraryView(){
const meta = document.createElement('div') const meta = document.createElement('div')
meta.className = 'launcherCardMeta' meta.className = 'launcherCardMeta'
meta.appendChild(createBadge(describeProfileKind(profile.kind))) describeProfileFeatures(profile).forEach((label) => {
if(profile.isCustom){ meta.appendChild(createBadge(label))
meta.appendChild(createBadge('커스텀')) })
}
if(profile.id === selectedProfileId){ if(profile.id === selectedProfileId){
meta.appendChild(createBadge('선택됨')) meta.appendChild(createBadge('선택됨'))
} }
if(profile.kind === 'map' && profile.worldDirectoryName){ if(!profile.launchReady){
meta.appendChild(createBadge(profile.worldDirectoryName)) meta.appendChild(createBadge('실행 준비 필요'))
} }
if(profile.kind === 'map' && !profile.launchReady){ if(profile.serverEnabled && profile.hostReady){
meta.appendChild(createBadge('맵 설정 필요')) meta.appendChild(createBadge('로컬 서버 가능'))
}
if(profile.kind === 'server-pack' && !profile.hostReady){
meta.appendChild(createBadge('호스팅 설정 필요'))
} }
if(hostState.running){ if(hostState.running){
meta.appendChild(createBadge(hostState.tunneling ? '서버+터널' : '서버 실행 중')) meta.appendChild(createBadge(hostState.ready ? '서버 실행 중' : '서버 시작 중'))
} }
titleGroup.appendChild(title) titleGroup.appendChild(title)
@@ -299,13 +177,17 @@ async function renderLibraryView(){
const description = createParagraph('launcherCardDescription', profile.description || '설명이 없습니다.') const description = createParagraph('launcherCardDescription', profile.description || '설명이 없습니다.')
const extra = document.createElement('div')
extra.className = 'launcherCardContent'
appendAddressOverrideField(profile, extra)
const actions = document.createElement('div') const actions = document.createElement('div')
actions.className = 'launcherCardActions' actions.className = 'launcherCardActions'
const installButton = document.createElement('button') const installButton = document.createElement('button')
installButton.className = 'launcherSecondaryButton' installButton.className = 'launcherSecondaryButton'
installButton.textContent = isProfileInstalled(profile) ? '설치됨' : '설치' installButton.textContent = isProfileInstalled(profile) ? '설치됨' : '설치'
installButton.disabled = isProfileInstalled(profile) || !profile.configured installButton.disabled = isProfileInstalled(profile) || !profile.launchReady
installButton.addEventListener('click', async () => { installButton.addEventListener('click', async () => {
await prepareProfileAssets(profile) await prepareProfileAssets(profile)
}) })
@@ -330,14 +212,11 @@ async function renderLibraryView(){
} }
}) })
actions.appendChild(installButton)
actions.appendChild(selectButton)
const removeButton = document.createElement('button') const removeButton = document.createElement('button')
removeButton.className = 'launcherGhostButton' removeButton.className = 'launcherGhostButton'
removeButton.textContent = '제거' removeButton.textContent = '제거'
removeButton.addEventListener('click', async () => { removeButton.addEventListener('click', async () => {
ServerRuntime.stopHostedProfile(profile.id) await ServerRuntime.stopHostedProfile(profile.id)
CatalogManager.removeProfile(profile.id) CatalogManager.removeProfile(profile.id)
if(typeof refreshSelectedProfileButton === 'function'){ if(typeof refreshSelectedProfileButton === 'function'){
refreshSelectedProfileButton() refreshSelectedProfileButton()
@@ -351,10 +230,15 @@ async function renderLibraryView(){
} }
}) })
actions.appendChild(installButton)
actions.appendChild(selectButton)
actions.appendChild(removeButton) actions.appendChild(removeButton)
card.appendChild(header) card.appendChild(header)
card.appendChild(description) card.appendChild(description)
if(profile.serverEnabled){
card.appendChild(extra)
}
card.appendChild(actions) card.appendChild(actions)
libraryList.appendChild(card) libraryList.appendChild(card)
} }
@@ -363,7 +247,7 @@ async function renderLibraryView(){
renderLibraryEmptyState(false) renderLibraryEmptyState(false)
const errorCard = document.createElement('article') const errorCard = document.createElement('article')
errorCard.className = 'launcherCard' errorCard.className = 'launcherCard'
errorCard.innerHTML = '<h3 class="launcherCardTitle">라이브러리 로드 실패</h3><p class="launcherCardDescription">선택한 카탈로그를 읽지 못했습니다. 설치 페이지에서 카탈로그 경로를 다시 확인하세요.</p>' errorCard.innerHTML = '<h3 class="launcherCardTitle">라이브러리 로드 실패</h3><p class="launcherCardDescription">선택한 카탈로그를 읽지 못했습니다. 관리자 사이트에서 카탈로그 경로를 다시 확인하세요.</p>'
libraryList.appendChild(errorCard) libraryList.appendChild(errorCard)
} }
} }

View File

@@ -3,18 +3,25 @@ const fs = require('fs-extra')
const path = require('path') const path = require('path')
const ConfigManager = require('./configmanager') const ConfigManager = require('./configmanager')
const PortManager = require('./portmanager')
const ProfileAssetManager = require('./profileassetmanager') const ProfileAssetManager = require('./profileassetmanager')
const runtimes = new Map() const runtimes = new Map()
const READY_PATTERNS = [
/Done \([0-9.]+s\)!/i,
/For help, type "help"/i
]
function getRuntime(profileId){ function getRuntime(profileId){
if(!runtimes.has(profileId)){ if(!runtimes.has(profileId)){
runtimes.set(profileId, { runtimes.set(profileId, {
serverProcess: null, serverProcess: null,
tunnelProcess: null,
logs: [], logs: [],
status: 'stopped', status: 'stopped',
publishedAddress: ConfigManager.getPublishedLibraryServerAddress(profileId) ready: false,
readyPromise: null,
readyResolver: null,
readyRejecter: null
}) })
} }
@@ -28,124 +35,124 @@ function appendLog(runtime, line){
} }
} }
function interpolateCommand(template, variables){ function createReadyPromise(runtime){
return Object.entries(variables).reduce((command, [key, value]) => { runtime.ready = false
return command.replaceAll(`\${${key}}`, String(value)) runtime.readyPromise = new Promise((resolve, reject) => {
}, template) runtime.readyResolver = resolve
runtime.readyRejecter = reject
})
return runtime.readyPromise
} }
function extractPublishedAddress(line, profile){ function resolveReady(runtime){
if(profile.tunnelAddressRegex){ if(runtime.ready === true){
const customRegex = new RegExp(profile.tunnelAddressRegex) return
const customMatch = line.match(customRegex)
if(customMatch){
return customMatch[1] ?? customMatch[0]
}
} }
const genericMatch = line.match(/([a-zA-Z0-9.-]+\.[a-zA-Z]{2,}:\d+|[a-zA-Z0-9.-]+:\d{2,5})/) runtime.ready = true
return genericMatch ? genericMatch[1] : null runtime.status = 'running'
if(runtime.readyResolver){
runtime.readyResolver(runtime)
}
runtime.readyResolver = null
runtime.readyRejecter = null
} }
async function resolveServerLaunchCommand(profile, serverDirectory){ function rejectReady(runtime, error){
if(profile.serverLaunchCommand){ runtime.ready = false
return profile.serverLaunchCommand if(runtime.readyRejecter){
runtime.readyRejecter(error)
} }
runtime.readyResolver = null
const startScript = process.platform === 'win32' ? 'start.bat' : 'start.sh' runtime.readyRejecter = null
const startScriptPath = path.join(serverDirectory, startScript)
if(await fs.pathExists(startScriptPath)){
return process.platform === 'win32' ? startScript : `./${startScript}`
}
const jarPath = path.join(serverDirectory, 'server.jar')
if(await fs.pathExists(jarPath)){
return 'java -jar server.jar nogui'
}
throw new Error('서버 시작 명령을 결정할 수 없습니다. serverLaunchCommand 또는 server.jar/start 스크립트를 준비하세요.')
} }
function resolveWorkingDirectory(profile, serverDirectory){ function resolveServerJavaExecutable(){
if(profile.serverWorkingDirectory){ const selectedServerId = ConfigManager.getSelectedServer()
return path.join(serverDirectory, profile.serverWorkingDirectory) if(selectedServerId != null){
const configured = ConfigManager.getJavaExecutable(selectedServerId)
if(configured){
return configured
} }
return serverDirectory }
return 'java'
} }
async function startTunnelProcess(profile, runtime, serverDirectory){ async function writeServerFiles(profile, serverDirectory){
if(!profile.tunnelCommand){ await fs.writeFile(path.join(serverDirectory, 'eula.txt'), 'eula=true\n', 'utf8')
return null
}
const command = interpolateCommand(profile.tunnelCommand, { const properties = [
port: profile.serverPort ?? 25565, ['enable-query', 'false'],
serverDir: serverDirectory ['max-players', String(profile.serverMaxPlayers ?? 20)],
}) ['online-mode', 'true'],
['server-port', String(profile.serverPort ?? 25565)],
['white-list', profile.serverWhitelistEnabled ? 'true' : 'false'],
['level-name', 'world']
]
const tunnelProcess = childProcess.spawn(command, { await fs.writeFile(
cwd: serverDirectory, path.join(serverDirectory, 'server.properties'),
shell: true, properties.map(([key, value]) => `${key}=${value}`).join('\n') + '\n',
detached: false 'utf8'
}) )
}
runtime.tunnelProcess = tunnelProcess function buildLaunchCommand(profile, javaExecutable){
const memoryMb = Number.isFinite(Number(profile.serverMemoryMb))
? Math.max(512, Number(profile.serverMemoryMb))
: 4096
tunnelProcess.stdout?.on('data', (chunk) => { return `"${javaExecutable}" -Xms${memoryMb}M -Xmx${memoryMb}M -jar server.jar nogui`
const text = chunk.toString()
text.split(/\r?\n/).filter(Boolean).forEach((line) => {
appendLog(runtime, `[tunnel] ${line}`)
const address = extractPublishedAddress(line, profile)
if(address){
runtime.publishedAddress = address
ConfigManager.setPublishedLibraryServerAddress(profile.id, address)
ConfigManager.save()
}
})
})
tunnelProcess.stderr?.on('data', (chunk) => {
chunk.toString().split(/\r?\n/).filter(Boolean).forEach((line) => {
appendLog(runtime, `[tunnel:err] ${line}`)
})
})
tunnelProcess.on('close', () => {
runtime.tunnelProcess = null
})
return tunnelProcess
} }
exports.startHostedProfile = async function(profile){ exports.startHostedProfile = async function(profile){
if(profile.serverEnabled !== true){
throw new Error('서버 사용이 꺼진 프로필입니다.')
}
const runtime = getRuntime(profile.id) const runtime = getRuntime(profile.id)
if(runtime.serverProcess != null){ if(runtime.serverProcess != null){
if(runtime.readyPromise != null){
await runtime.readyPromise.catch(() => {})
}
return runtime return runtime
} }
const serverDirectory = await ProfileAssetManager.ensureServerBundleInstalled(profile) const installedServer = await ProfileAssetManager.ensureServerJarInstalled(profile)
const workingDirectory = resolveWorkingDirectory(profile, serverDirectory) if(installedServer?.serverJarPath == null){
const command = await resolveServerLaunchCommand(profile, workingDirectory) throw new Error('로컬 서버를 시작하려면 버킷 JAR 업로드가 필요합니다.')
}
runtime.status = 'starting' await ProfileAssetManager.ensureServerWorldInstalled(profile, installedServer.serverDirectory)
runtime.publishedAddress = null await writeServerFiles(profile, installedServer.serverDirectory)
ConfigManager.setPublishedLibraryServerAddress(profile.id, null) await PortManager.ensurePortAvailability(profile)
ConfigManager.save()
const command = buildLaunchCommand(profile, resolveServerJavaExecutable())
appendLog(runtime, `[launcher] starting server: ${command}`) appendLog(runtime, `[launcher] starting server: ${command}`)
runtime.status = 'starting'
createReadyPromise(runtime)
const serverProcess = childProcess.spawn(command, { const serverProcess = childProcess.spawn(command, {
cwd: workingDirectory, cwd: installedServer.serverDirectory,
shell: true, shell: true,
detached: false detached: false
}) })
runtime.serverProcess = serverProcess runtime.serverProcess = serverProcess
const readyFallbackTimer = setTimeout(() => {
if(runtime.serverProcess != null){
resolveReady(runtime)
}
}, 25000)
serverProcess.stdout?.on('data', (chunk) => { serverProcess.stdout?.on('data', (chunk) => {
chunk.toString().split(/\r?\n/).filter(Boolean).forEach((line) => { chunk.toString().split(/\r?\n/).filter(Boolean).forEach((line) => {
appendLog(runtime, `[server] ${line}`) appendLog(runtime, `[server] ${line}`)
if(runtime.status !== 'running'){ if(READY_PATTERNS.some((pattern) => pattern.test(line))){
runtime.status = 'running' resolveReady(runtime)
} }
}) })
}) })
@@ -157,37 +164,39 @@ exports.startHostedProfile = async function(profile){
}) })
serverProcess.on('close', () => { serverProcess.on('close', () => {
clearTimeout(readyFallbackTimer)
runtime.serverProcess = null runtime.serverProcess = null
runtime.status = 'stopped' runtime.status = 'stopped'
runtime.publishedAddress = null runtime.ready = false
ConfigManager.setPublishedLibraryServerAddress(profile.id, null) rejectReady(runtime, new Error('서버 프로세스가 종료되었습니다.'))
ConfigManager.save() PortManager.releaseProfilePort(profile.id).catch(() => {})
}) })
if(profile.tunnelCommand){ await runtime.readyPromise.catch(() => {})
await startTunnelProcess(profile, runtime, workingDirectory)
}
return runtime return runtime
} }
exports.stopHostedProfile = function(profileId){ exports.stopHostedProfile = async function(profileId){
const runtime = getRuntime(profileId) const runtime = getRuntime(profileId)
if(runtime.tunnelProcess != null){ if(runtime.serverProcess != null){
runtime.tunnelProcess.kill() runtime.status = 'stopping'
runtime.tunnelProcess = null try {
runtime.serverProcess.stdin?.write('stop\n')
} catch (error) {
void error
} }
setTimeout(() => {
if(runtime.serverProcess != null){ if(runtime.serverProcess != null){
runtime.serverProcess.kill() runtime.serverProcess.kill()
runtime.serverProcess = null }
}, 5000)
} }
runtime.status = 'stopped' runtime.status = 'stopped'
runtime.publishedAddress = null runtime.ready = false
ConfigManager.setPublishedLibraryServerAddress(profileId, null) await PortManager.releaseProfilePort(profileId).catch(() => {})
ConfigManager.save()
} }
exports.getHostedProfileState = function(profileId){ exports.getHostedProfileState = function(profileId){
@@ -195,15 +204,14 @@ exports.getHostedProfileState = function(profileId){
return { return {
status: runtime.status, status: runtime.status,
running: runtime.serverProcess != null, running: runtime.serverProcess != null,
tunneling: runtime.tunnelProcess != null, ready: runtime.ready,
publishedAddress: runtime.publishedAddress ?? ConfigManager.getPublishedLibraryServerAddress(profileId),
logs: [...runtime.logs] logs: [...runtime.logs]
} }
} }
exports.hasRunningProfiles = function(){ exports.hasRunningProfiles = function(){
for(const runtime of runtimes.values()){ for(const runtime of runtimes.values()){
if(runtime.serverProcess != null || runtime.tunnelProcess != null){ if(runtime.serverProcess != null){
return true return true
} }
} }

View File

@@ -185,6 +185,17 @@ server = "SERVER"
offline = "OFFLINE" offline = "OFFLINE"
players = "PLAYERS" players = "PLAYERS"
[js.landing.portStatus]
label = "PORT"
failed = "AUTO OPEN FAILED"
[js.landing.localServer]
starting = "Starting local server.."
missingJarTitle = "Local Server Setup Incomplete"
missingJarText = "The launch flow tried to start a local server because no connection address was set, but no bucket JAR is registered for this profile. Upload a server JAR in the admin site or enter a connection address in the library."
failureTitle = "Local Server Start Failed"
failureText = "Could not start the local server. Check the console (CTRL + Shift + i) for details."
[js.landing.systemScan] [js.landing.systemScan]
checking = "Checking system info.." checking = "Checking system info.."
noCompatibleJava = "No Compatible<br>Java Installation Found" noCompatibleJava = "No Compatible<br>Java Installation Found"

View File

@@ -185,6 +185,17 @@ server = "서버"
offline = "오프라인" offline = "오프라인"
players = "플레이어" players = "플레이어"
[js.landing.portStatus]
label = "포트"
failed = "자동 포트 개방 실패"
[js.landing.localServer]
starting = "로컬 서버 시작 중.."
missingJarTitle = "로컬 서버 실행 준비 부족"
missingJarText = "접속 주소가 비어 있어 로컬 서버를 실행하려고 했지만, 버킷 JAR이 등록되지 않았습니다. 관리자 사이트에서 서버 JAR을 업로드하거나 라이브러리에서 접속 주소를 입력해 주세요."
failureTitle = "로컬 서버 실행 실패"
failureText = "로컬 서버를 시작하지 못했습니다. 콘솔 (CTRL + Shift + i) 에서 자세한 내용을 확인하세요."
[js.landing.systemScan] [js.landing.systemScan]
checking = "시스템 정보 확인 중.." checking = "시스템 정보 확인 중.."
noCompatibleJava = "호환되는 Java가<br>설치되지 않음" noCompatibleJava = "호환되는 Java가<br>설치되지 않음"

View File

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

View File

@@ -134,6 +134,7 @@
<div id="server_status_wrapper"> <div id="server_status_wrapper">
<span class="bot_label" id="landingPlayerLabel"><%- lang('landing.serverStatus') %></span> <span class="bot_label" id="landingPlayerLabel"><%- lang('landing.serverStatus') %></span>
<span id="player_count"><%- lang('landing.serverStatusPlaceholder') %></span> <span id="player_count"><%- lang('landing.serverStatusPlaceholder') %></span>
<div id="portStatusTooltip"></div>
</div> </div>
<div class="bot_divider"></div> <div class="bot_divider"></div>
<div id="mojangStatusWrapper"> <div id="mojangStatusWrapper">

View File

@@ -12,12 +12,14 @@ npm run admin
- `http://127.0.0.1:8787` - `http://127.0.0.1:8787`
## 현재 1차 구현 범위 ## 현재 구현 범위
- 프로필 추가 / 수정 / 삭제 / 복제 - 프로필 추가 / 수정 / 삭제 / 복제
- `modpack`, `map`, `server-pack` 종류별 입력 폼 - `맵` 기본 + `모드`, `플러그인`, `서버` 체크 조합
- `distribution.json` 업로드 / 새로 만들기 / 직접 편집 - `distribution.json` 업로드 / 새로 만들기 / 직접 편집
- 맵 ZIP, 서버 번들 ZIP 업로드 - 월드 ZIP 업로드
- 서버용 버킷 JAR 업로드
- 서버 포트 / 메모리 / 최대 인원수 / 화이트리스트 설정
- 저장 시 아래 두 파일을 동시에 갱신 - 저장 시 아래 두 파일을 동시에 갱신
- `admin/data/catalog.json` - `admin/data/catalog.json`
- `app/assets/launcher/catalog.json` - `app/assets/launcher/catalog.json`
@@ -28,11 +30,8 @@ npm run admin
- `JSON 편집` 버튼으로 현재 연결된 로컬 `distribution.json` 수정 가능 - `JSON 편집` 버튼으로 현재 연결된 로컬 `distribution.json` 수정 가능
- `새로 만들기` 버튼으로 샘플 템플릿에서 새 `distribution.json` 생성 가능 - `새로 만들기` 버튼으로 샘플 템플릿에서 새 `distribution.json` 생성 가능
- 저장된 distribution 파일은 아래에 생성됩니다. - 저장된 distribution 파일은 아래에 생성됩니다.
- `admin/data/distributions/` - `admin/data/distributions/`
- 접속주소 필드는 관리자 사이트에서 다루지 않습니다. 런처 내부 접속 흐름과 분리해서 프로필 자료만 관리합니다.
## 업로드 동작 ## 업로드 동작
업로드 버튼으로 올린 파일은 아래에 저장됩니다. 업로드 버튼으로 올린 파일은 아래에 저장됩니다.
@@ -43,15 +42,14 @@ npm run admin
예: 예:
- `admin/data/uploads/1715000000000-my-pack.zip` - `admin/data/uploads/1715000000000-paper.jar`
이 방식은 로컬 테스트에는 바로 쓸 수 있습니다.
## 주의 ## 주의
- 접속 주소는 관리자 사이트에서 다루지 않습니다.
- 접속 주소는 사용자가 런처 라이브러리에서 직접 입력합니다.
- 지금 1차 버전은 로컬 운영용입니다. - 지금 1차 버전은 로컬 운영용입니다.
- 기본 서버 바인딩은 `127.0.0.1` 이라 같은 PC에서만 접속됩니다. - 기본 서버 바인딩은 `127.0.0.1` 이라 같은 PC에서만 접속됩니다.
- 외부에서 쓰는 공개 관리자 사이트로 만들려면 인증과 공개 URL 저장 방식을 추가해야 합니다.
## 추천 운영 방식 ## 추천 운영 방식

View File

@@ -14,36 +14,33 @@
"version": 1, "version": 1,
"profiles": [ "profiles": [
{ {
"id": "my-modpack", "id": "my-map-profile",
"name": "My Modpack", "name": "My Map Profile",
"kind": "modpack",
"description": "설명", "description": "설명",
"details": "설치 페이지 상세 패널에 표시할 긴 설명", "details": "설치 페이지 상세 패널에 표시할 긴 설명",
"distributionUrl": "admin/data/distributions/my-modpack.distribution.json" "modsEnabled": true,
}, "pluginsEnabled": false,
{ "serverEnabled": false,
"id": "my-map", "distributionUrl": "admin/data/distributions/my-map-profile.distribution.json",
"name": "My Map",
"kind": "map",
"description": "싱글플레이 월드",
"details": "월드와 플레이 방식에 대한 상세 설명",
"distributionUrl": "admin/data/distributions/my-map.distribution.json",
"worldArchiveUrl": "https://example.com/worlds/my-map.zip", "worldArchiveUrl": "https://example.com/worlds/my-map.zip",
"worldDirectoryName": "My Map" "worldDirectoryName": "My Map"
}, },
{ {
"id": "my-server-pack", "id": "my-plugin-server-profile",
"name": "My Server Pack", "name": "My Plugin Server Profile",
"kind": "server-pack", "description": "플러그인 서버 포함 프로필",
"description": "클라이언트 + 로컬 서버 번들", "details": "주소가 없으면 로컬 서버를 실행하고, 주소가 있으면 해당 서버로 접속합니다.",
"details": "서버 실행 방법, 권장 인원, 접속 방식 등 상세 설명", "modsEnabled": false,
"distributionUrl": "admin/data/distributions/my-server-pack.distribution.json", "pluginsEnabled": true,
"serverBundleUrl": "https://example.com/serverpacks/my-server-pack.zip", "serverEnabled": true,
"serverDirectoryName": "my-server-pack", "distributionUrl": "admin/data/distributions/my-plugin-server-profile.distribution.json",
"serverLaunchCommand": "java -jar server.jar nogui", "worldArchiveUrl": "https://example.com/worlds/plugin-map.zip",
"worldDirectoryName": "Plugin Map",
"serverJarUrl": "https://example.com/server/paper.jar",
"serverPort": 25565, "serverPort": 25565,
"tunnelCommand": "playit-cli --port ${port}", "serverMemoryMb": 4096,
"tunnelAddressRegex": "([a-zA-Z0-9.-]+:\\d+)" "serverMaxPlayers": 20,
"serverWhitelistEnabled": false
} }
] ]
} }
@@ -53,46 +50,36 @@
- `id`: 내부 식별자 - `id`: 내부 식별자
- `name`: 라이브러리/설치 페이지 표시 이름 - `name`: 라이브러리/설치 페이지 표시 이름
- `kind`: `modpack`, `map`, `server-pack`
- `description`: 표시 설명 - `description`: 표시 설명
- `details`: 설치 페이지 상세 패널에 표시할 긴 설명 - `details`: 설치 페이지 상세 패널에 표시할 긴 설명
- `distributionUrl`: Helios distribution.json URL 또는 로컬 경로. 관리자 사이트에서 직접 만들거나 업로드 가능 - `modsEnabled`: 모드 기능 사용 여부
- `worldArchiveUrl`: `kind: map` 일 때 사용할 월드 ZIP 또는 로컬 경로 - `pluginsEnabled`: 플러그인 기능 사용 여부. 켜면 서버도 같이 사용
- `worldDirectoryName`: 게임 `saves/` 아래에 설치될 월드 폴더 이름 - `serverEnabled`: 서버 기능 사용 여부
- `serverBundleUrl`: `kind: server-pack` 일 때 사용할 서버 ZIP 또는 로컬 디렉터리/경로 - `distributionUrl`: Helios distribution.json URL 또는 로컬 경로
- `serverDirectoryName`: 서버 번들이 풀릴 하위 디렉터리 이름 - `worldArchiveUrl`: 월드 ZIP 또는 로컬 경로
- `serverLaunchCommand`: 로컬 서버 실행 명령. 비워두면 `start.sh`, `start.bat`, `server.jar` 순으로 추론 - `worldDirectoryName`: 게임 `saves/` 아래와 로컬 서버 `world/`에 사용할 월드 폴더 이름
- `serverWorkingDirectory`: 실제 실행할 작업 디렉터리. 서버 루트 기준 상대 경로 - `serverJarUrl`: 로컬 서버 실행에 사용할 버킷 JAR 경로 또는 URL
- `serverPort`: 로컬 서버 포트 - `serverPort`: 로컬 서버 포트
- `tunnelCommand`: 선택형 터널 명령. `${port}`, `${serverDir}` 치환 가능 - `serverMemoryMb`: 로컬 서버 최대 메모리(MB)
- `tunnelAddressRegex`: 터널 stdout 에서 공개 주소를 추출할 정규식 - `serverMaxPlayers`: 로컬 서버 최대 인원수
- `serverWhitelistEnabled`: 로컬 서버 화이트리스트 사용 여부
## 런처가 계산하는 상태 ## 런처가 계산하는 상태
아래 값들은 런처가 내부적으로 계산하는 상태라 파일에 직접 넣지 않아도 됩니다. 아래 값들은 런처가 내부적으로 계산하는 상태라 파일에 직접 넣지 않아도 됩니다.
- `launchReady`: 실행에 필요한 필드가 모두 있는지 여부 - `launchReady`: 실행에 필요한 필드가 모두 있는지 여부
- `hostReady`: `server-pack` 이 로컬 호스팅 가능한지 여부 - `hostReady`: 서버 기능 프로필이 로컬 호스팅 가능한지 여부
판정 기준: 판정 기준:
- `modpack`: `distributionUrl` 필요 - 공통 실행: `distributionUrl`, `worldArchiveUrl`, `worldDirectoryName`
- `map`: `distributionUrl`, `worldArchiveUrl`, `worldDirectoryName` 필요 - 로컬 서버 실행 추가 조건: `serverEnabled=true` 이고 `serverJarUrl` 존재
- `server-pack`: 클라이언트 실행은 `distributionUrl`, 로컬 호스팅은 추가로 `serverBundleUrl` 필요
## 현재 구현 범위 ## 현재 구현 범위
- `modpack`: 지원 - 맵 기반 싱글플레이 실행
- `map`: 월드 ZIP 사전 다운로드, `saves/` 설치, `--quickPlaySingleplayer` 실행 지원 - 맵 + 모드 클라이언트 실행
- `server-pack`: distribution 기반 클라이언트 + 수동 주소 입력 자동 접속 + 로컬 서버 실행 + 선택형 터널 명령 지원 - 서버 기능 프로필의 직접 주소 입력 접속
- 서버 기능 프로필의 로컬 버킷 JAR 실행
## 네트워크 참고 - 자동 포트 개방 상태 표시
포트포워딩 없이 외부 사용자가 접속하려면 런처 단독으로는 부족합니다.
필요한 것 중 하나:
- 별도 중계 서버
- 터널링 도구
- VPN/NAT traversal 백엔드
현재 구현은 그 중 `터널 명령 실행기`를 연결할 수 있는 자리까지만 제공합니다.

357
package-lock.json generated
View File

@@ -24,6 +24,7 @@
"jquery": "^3.7.1", "jquery": "^3.7.1",
"lodash.merge": "^4.6.2", "lodash.merge": "^4.6.2",
"multer": "^2.1.1", "multer": "^2.1.1",
"nat-upnp": "^1.1.1",
"semver": "^7.7.3", "semver": "^7.7.3",
"toml": "^3.0.0" "toml": "^3.0.0"
}, },
@@ -1155,7 +1156,6 @@
"version": "6.12.6", "version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
@@ -1372,13 +1372,19 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/assert-plus": { "node_modules/assert-plus": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
"integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=0.8" "node": ">=0.8"
} }
@@ -1414,7 +1420,6 @@
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/at-least-node": { "node_modules/at-least-node": {
@@ -1427,6 +1432,19 @@
"node": ">= 4.0.0" "node": ">= 4.0.0"
} }
}, },
"node_modules/aws-sign2": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
"integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==",
"engines": {
"node": "*"
}
},
"node_modules/aws4": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz",
"integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="
},
"node_modules/b4a": { "node_modules/b4a": {
"version": "1.7.3", "version": "1.7.3",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz",
@@ -1559,6 +1577,14 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/bl": { "node_modules/bl": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@@ -1883,6 +1909,11 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2093,7 +2124,6 @@
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"delayed-stream": "~1.0.0" "delayed-stream": "~1.0.0"
@@ -2183,9 +2213,7 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
"dev": true, "license": "MIT"
"license": "MIT",
"optional": true
}, },
"node_modules/crc": { "node_modules/crc": {
"version": "3.8.0", "version": "3.8.0",
@@ -2245,6 +2273,17 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/dashdash": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
"integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==",
"dependencies": {
"assert-plus": "^1.0.0"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2360,7 +2399,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.4.0" "node": ">=0.4.0"
@@ -2534,6 +2572,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/ecc-jsbn": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
"integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==",
"dependencies": {
"jsbn": "~0.1.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -3164,6 +3211,11 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
},
"node_modules/extract-zip": { "node_modules/extract-zip": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
@@ -3199,7 +3251,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-fifo": { "node_modules/fast-fifo": {
@@ -3212,7 +3263,6 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-levenshtein": { "node_modules/fast-levenshtein": {
@@ -3401,6 +3451,14 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/forever-agent": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
"integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==",
"engines": {
"node": "*"
}
},
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
@@ -3539,6 +3597,14 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/getpass": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
"integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==",
"dependencies": {
"assert-plus": "^1.0.0"
}
},
"node_modules/github-syntax-dark": { "node_modules/github-syntax-dark": {
"version": "0.5.0", "version": "0.5.0",
"resolved": "https://registry.npmjs.org/github-syntax-dark/-/github-syntax-dark-0.5.0.tgz", "resolved": "https://registry.npmjs.org/github-syntax-dark/-/github-syntax-dark-0.5.0.tgz",
@@ -3684,6 +3750,27 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/har-schema": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
"integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==",
"engines": {
"node": ">=4"
}
},
"node_modules/har-validator": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz",
"integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==",
"deprecated": "this library is no longer supported",
"dependencies": {
"ajv": "^6.12.3",
"har-schema": "^2.0.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/has-flag": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -3823,6 +3910,20 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/http-signature": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz",
"integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==",
"dependencies": {
"assert-plus": "^1.0.0",
"jsprim": "^1.2.2",
"sshpk": "^1.7.0"
},
"engines": {
"node": ">=0.8",
"npm": ">=1.3.7"
}
},
"node_modules/http2-wrapper": { "node_modules/http2-wrapper": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
@@ -3957,6 +4058,11 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz",
"integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ=="
},
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
@@ -4035,6 +4141,11 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/is-typedarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
"integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA=="
},
"node_modules/is-unicode-supported": { "node_modules/is-unicode-supported": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
@@ -4071,6 +4182,11 @@
"node": ">=16" "node": ">=16"
} }
}, },
"node_modules/isstream": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
"integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g=="
},
"node_modules/jackspeak": { "node_modules/jackspeak": {
"version": "3.4.3", "version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
@@ -4132,17 +4248,26 @@
"js-yaml": "bin/js-yaml.js" "js-yaml": "bin/js-yaml.js"
} }
}, },
"node_modules/jsbn": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
"integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg=="
},
"node_modules/json-buffer": { "node_modules/json-buffer": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="
},
"node_modules/json-schema-traverse": { "node_modules/json-schema-traverse": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-stable-stringify-without-jsonify": { "node_modules/json-stable-stringify-without-jsonify": {
@@ -4156,8 +4281,7 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"license": "ISC", "license": "ISC"
"optional": true
}, },
"node_modules/json5": { "node_modules/json5": {
"version": "2.2.3", "version": "2.2.3",
@@ -4184,6 +4308,41 @@
"graceful-fs": "^4.1.6" "graceful-fs": "^4.1.6"
} }
}, },
"node_modules/jsprim": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz",
"integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==",
"dependencies": {
"assert-plus": "1.0.0",
"extsprintf": "1.3.0",
"json-schema": "0.4.0",
"verror": "1.10.0"
},
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/jsprim/node_modules/extsprintf": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
"integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==",
"engines": [
"node >=0.6.0"
]
},
"node_modules/jsprim/node_modules/verror": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
"integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==",
"engines": [
"node >=0.6.0"
],
"dependencies": {
"assert-plus": "^1.0.0",
"core-util-is": "1.0.2",
"extsprintf": "^1.2.0"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -4239,7 +4398,6 @@
"version": "4.17.23", "version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.escaperegexp": { "node_modules/lodash.escaperegexp": {
@@ -4658,6 +4816,25 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/nat-upnp": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/nat-upnp/-/nat-upnp-1.1.1.tgz",
"integrity": "sha512-b1Q+sf9fHGCXhlWErNgTTEto8A02MnNysw3vx3kD1657+/Ae23vPEAB6QBh+9RqLL4+xw/LmjVTiLy6A7Cx0xw==",
"dependencies": {
"async": "^2.1.5",
"ip": "^1.1.4",
"request": "^2.79.0",
"xml2js": "~0.1.14"
}
},
"node_modules/nat-upnp/node_modules/async": {
"version": "2.6.4",
"resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz",
"integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==",
"dependencies": {
"lodash": "^4.17.14"
}
},
"node_modules/natural-compare": { "node_modules/natural-compare": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -4791,6 +4968,14 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/oauth-sign": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
"integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==",
"engines": {
"node": "*"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -5065,6 +5250,11 @@
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -5196,6 +5386,17 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"dependencies": {
"punycode": "^2.3.1"
},
"funding": {
"url": "https://github.com/sponsors/lupomontero"
}
},
"node_modules/pump": { "node_modules/pump": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
@@ -5210,7 +5411,6 @@
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -5306,6 +5506,58 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/request": {
"version": "2.88.2",
"resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz",
"integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==",
"deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142",
"dependencies": {
"aws-sign2": "~0.7.0",
"aws4": "^1.8.0",
"caseless": "~0.12.0",
"combined-stream": "~1.0.6",
"extend": "~3.0.2",
"forever-agent": "~0.6.1",
"form-data": "~2.3.2",
"har-validator": "~5.1.3",
"http-signature": "~1.2.0",
"is-typedarray": "~1.0.0",
"isstream": "~0.1.2",
"json-stringify-safe": "~5.0.1",
"mime-types": "~2.1.19",
"oauth-sign": "~0.9.0",
"performance-now": "^2.1.0",
"qs": "~6.5.2",
"safe-buffer": "^5.1.2",
"tough-cookie": "~2.5.0",
"tunnel-agent": "^0.6.0",
"uuid": "^3.3.2"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/request/node_modules/form-data": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
"integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.6",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 0.12"
}
},
"node_modules/request/node_modules/qs": {
"version": "6.5.5",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.5.tgz",
"integrity": "sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==",
"engines": {
"node": ">=0.6"
}
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -5797,6 +6049,30 @@
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"optional": true "optional": true
}, },
"node_modules/sshpk": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
"integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==",
"dependencies": {
"asn1": "~0.2.3",
"assert-plus": "^1.0.0",
"bcrypt-pbkdf": "^1.0.0",
"dashdash": "^1.12.0",
"ecc-jsbn": "~0.1.1",
"getpass": "^0.1.1",
"jsbn": "~0.1.0",
"safer-buffer": "^2.0.2",
"tweetnacl": "~0.14.0"
},
"bin": {
"sshpk-conv": "bin/sshpk-conv",
"sshpk-sign": "bin/sshpk-sign",
"sshpk-verify": "bin/sshpk-verify"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ssri": { "node_modules/ssri": {
"version": "12.0.0", "version": "12.0.0",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz",
@@ -6146,6 +6422,18 @@
"integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tough-cookie": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
"integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
"dependencies": {
"psl": "^1.1.28",
"punycode": "^2.1.1"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/tr46": { "node_modules/tr46": {
"version": "0.0.3", "version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -6171,6 +6459,22 @@
"utf8-byte-length": "^1.0.1" "utf8-byte-length": "^1.0.1"
} }
}, },
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -6305,7 +6609,6 @@
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"punycode": "^2.1.0" "punycode": "^2.1.0"
@@ -6324,6 +6627,15 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"bin": {
"uuid": "bin/uuid"
}
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -6496,6 +6808,17 @@
} }
} }
}, },
"node_modules/xml2js": {
"version": "0.1.14",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.1.14.tgz",
"integrity": "sha512-pbdws4PPPNc1HPluSUKamY4GWMk592K7qwcj6BExbVOhhubub8+pMda/ql68b6L3luZs/OGjGSB5goV7SnmgnA==",
"dependencies": {
"sax": ">=0.1.1"
},
"engines": {
"node": "*"
}
},
"node_modules/xmlbuilder": { "node_modules/xmlbuilder": {
"version": "15.1.1", "version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",

View File

@@ -42,6 +42,7 @@
"jquery": "^3.7.1", "jquery": "^3.7.1",
"lodash.merge": "^4.6.2", "lodash.merge": "^4.6.2",
"multer": "^2.1.1", "multer": "^2.1.1",
"nat-upnp": "^1.1.1",
"semver": "^7.7.3", "semver": "^7.7.3",
"toml": "^3.0.0" "toml": "^3.0.0"
}, },