Refactor launcher profiles and port automation
This commit is contained in:
95
README.md
95
README.md
@@ -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 수행
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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){
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
299
app/assets/js/portmanager.js
Normal file
299
app/assets/js/portmanager.js
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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}`)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
runtime.ready = true
|
||||||
|
runtime.status = 'running'
|
||||||
|
if(runtime.readyResolver){
|
||||||
|
runtime.readyResolver(runtime)
|
||||||
|
}
|
||||||
|
runtime.readyResolver = null
|
||||||
|
runtime.readyRejecter = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectReady(runtime, error){
|
||||||
|
runtime.ready = false
|
||||||
|
if(runtime.readyRejecter){
|
||||||
|
runtime.readyRejecter(error)
|
||||||
|
}
|
||||||
|
runtime.readyResolver = null
|
||||||
|
runtime.readyRejecter = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveServerJavaExecutable(){
|
||||||
|
const selectedServerId = ConfigManager.getSelectedServer()
|
||||||
|
if(selectedServerId != null){
|
||||||
|
const configured = ConfigManager.getJavaExecutable(selectedServerId)
|
||||||
|
if(configured){
|
||||||
|
return configured
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const genericMatch = line.match(/([a-zA-Z0-9.-]+\.[a-zA-Z]{2,}:\d+|[a-zA-Z0-9.-]+:\d{2,5})/)
|
return 'java'
|
||||||
return genericMatch ? genericMatch[1] : null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveServerLaunchCommand(profile, serverDirectory){
|
async function writeServerFiles(profile, serverDirectory){
|
||||||
if(profile.serverLaunchCommand){
|
await fs.writeFile(path.join(serverDirectory, 'eula.txt'), 'eula=true\n', 'utf8')
|
||||||
return profile.serverLaunchCommand
|
|
||||||
}
|
|
||||||
|
|
||||||
const startScript = process.platform === 'win32' ? 'start.bat' : 'start.sh'
|
const properties = [
|
||||||
const startScriptPath = path.join(serverDirectory, startScript)
|
['enable-query', 'false'],
|
||||||
if(await fs.pathExists(startScriptPath)){
|
['max-players', String(profile.serverMaxPlayers ?? 20)],
|
||||||
return process.platform === 'win32' ? startScript : `./${startScript}`
|
['online-mode', 'true'],
|
||||||
}
|
['server-port', String(profile.serverPort ?? 25565)],
|
||||||
|
['white-list', profile.serverWhitelistEnabled ? 'true' : 'false'],
|
||||||
|
['level-name', 'world']
|
||||||
|
]
|
||||||
|
|
||||||
const jarPath = path.join(serverDirectory, 'server.jar')
|
await fs.writeFile(
|
||||||
if(await fs.pathExists(jarPath)){
|
path.join(serverDirectory, 'server.properties'),
|
||||||
return 'java -jar server.jar nogui'
|
properties.map(([key, value]) => `${key}=${value}`).join('\n') + '\n',
|
||||||
}
|
'utf8'
|
||||||
|
)
|
||||||
throw new Error('서버 시작 명령을 결정할 수 없습니다. serverLaunchCommand 또는 server.jar/start 스크립트를 준비하세요.')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveWorkingDirectory(profile, serverDirectory){
|
function buildLaunchCommand(profile, javaExecutable){
|
||||||
if(profile.serverWorkingDirectory){
|
const memoryMb = Number.isFinite(Number(profile.serverMemoryMb))
|
||||||
return path.join(serverDirectory, profile.serverWorkingDirectory)
|
? Math.max(512, Number(profile.serverMemoryMb))
|
||||||
}
|
: 4096
|
||||||
return serverDirectory
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startTunnelProcess(profile, runtime, serverDirectory){
|
return `"${javaExecutable}" -Xms${memoryMb}M -Xmx${memoryMb}M -jar server.jar nogui`
|
||||||
if(!profile.tunnelCommand){
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const command = interpolateCommand(profile.tunnelCommand, {
|
|
||||||
port: profile.serverPort ?? 25565,
|
|
||||||
serverDir: serverDirectory
|
|
||||||
})
|
|
||||||
|
|
||||||
const tunnelProcess = childProcess.spawn(command, {
|
|
||||||
cwd: serverDirectory,
|
|
||||||
shell: true,
|
|
||||||
detached: false
|
|
||||||
})
|
|
||||||
|
|
||||||
runtime.tunnelProcess = tunnelProcess
|
|
||||||
|
|
||||||
tunnelProcess.stdout?.on('data', (chunk) => {
|
|
||||||
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){
|
|
||||||
runtime.tunnelProcess.kill()
|
|
||||||
runtime.tunnelProcess = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if(runtime.serverProcess != null){
|
if(runtime.serverProcess != null){
|
||||||
runtime.serverProcess.kill()
|
runtime.status = 'stopping'
|
||||||
runtime.serverProcess = null
|
try {
|
||||||
|
runtime.serverProcess.stdin?.write('stop\n')
|
||||||
|
} catch (error) {
|
||||||
|
void error
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if(runtime.serverProcess != null){
|
||||||
|
runtime.serverProcess.kill()
|
||||||
|
}
|
||||||
|
}, 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>설치되지 않음"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 저장 방식을 추가해야 합니다.
|
|
||||||
|
|
||||||
## 추천 운영 방식
|
## 추천 운영 방식
|
||||||
|
|
||||||
|
|||||||
@@ -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
357
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user