Add launcher catalog workflow and smoke tests
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
github: dscalzi
|
||||
patreon: dscalzi
|
||||
custom: ['https://www.paypal.me/dscalzi']
|
||||
38
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
name: Build
|
||||
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-latest, windows-latest]
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.x
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
shell: bash
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npm run dist
|
||||
shell: bash
|
||||
22
.github/workflows/windows-smoke.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: Windows Smoke Test
|
||||
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
windows-smoke:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run Windows Smoke Test
|
||||
run: npm run smoke:win
|
||||
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/node_modules/
|
||||
/.vs/
|
||||
/.vscode/
|
||||
/target/
|
||||
/logs/
|
||||
/dist/
|
||||
22
LICENSE.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017-2026 Daniel D. Scalzi
|
||||
Copyright (c) 2024 peunsu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
109
README.md
@@ -1,9 +1,106 @@
|
||||
# Minecraft Custom Launcher
|
||||
# Minecraft Launcher
|
||||
|
||||
마인크래프트 커스텀 런처 프로젝트 저장소입니다.
|
||||
Electron 기반 커스텀 마인크래프트 런처입니다. `MRSLauncher`를 최신 소스로 가져온 뒤, 단일 모드팩 구조를 `설치 페이지 + 라이브러리` 구조로 확장했습니다.
|
||||
|
||||
## 초기 범위
|
||||
## 현재 상태
|
||||
|
||||
- 저장된 사용자 토큰 기반 인증 연동
|
||||
- 런처 기본 구조 작성
|
||||
- 이후 기능 구현을 위한 프로젝트 정리
|
||||
- 여러 프로필을 설치 페이지에서 라이브러리로 추가 가능
|
||||
- 프로필 종류 지원:
|
||||
- `modpack`
|
||||
- `map`
|
||||
- `server-pack`
|
||||
- 라이브러리에서 프로필 선택, 제거, 자료 준비, 실행 화면 이동, 바로 실행 가능
|
||||
- 프로필별 `distribution.json` 전환 가능
|
||||
- `map` 프로필은 월드 ZIP/로컬 폴더를 `saves/`에 설치하고 `quickPlaySingleplayer`로 바로 실행
|
||||
- `server-pack` 프로필은 로컬 서버 번들 설치, 서버 시작/중지, 선택형 터널 명령 실행, 공개 주소 표시 지원
|
||||
- 라이브러리의 주소 입력칸에 `host:port`를 넣으면 실행 시 자동 접속
|
||||
- 설치 페이지는 관리자가 미리 등록한 프로필을 보여주는 읽기 전용 카탈로그 화면
|
||||
- 설치 페이지에서 프로필 제목, 요약, 상세 설명, 실행 조건을 확인 가능
|
||||
|
||||
## 중요한 제한
|
||||
|
||||
포트포워딩 없이 외부 사용자가 접속하게 만드는 기능은 런처만으로 해결되지 않습니다.
|
||||
|
||||
필요한 것 중 하나:
|
||||
|
||||
- 별도 릴레이 서버
|
||||
- 터널링 도구
|
||||
- VPN/NAT traversal 백엔드
|
||||
|
||||
현재 구현은 `server-pack` 프로필에 `tunnelCommand`를 넣어 외부 도구를 호출하는 자리까지 제공합니다.
|
||||
|
||||
추후 설계 문서:
|
||||
|
||||
- [docs/portforwarding-free-connection-plan.md](docs/portforwarding-free-connection-plan.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)를 보면 됩니다.
|
||||
|
||||
## 개발
|
||||
|
||||
요구사항:
|
||||
|
||||
- Node.js 22
|
||||
|
||||
설치:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
개발 실행:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
윈도우 smoke 테스트:
|
||||
|
||||
```bash
|
||||
npm run smoke:win
|
||||
```
|
||||
|
||||
이 명령은 TypeScript 메인 프로세스를 빌드한 뒤 Electron 앱을 실제로 한 번 띄우고, `LAUNCHER_SMOKE_EXIT` 환경변수로 자동 종료합니다.
|
||||
|
||||
TypeScript 메인 프로세스만 빌드:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
배포 빌드:
|
||||
|
||||
```bash
|
||||
npm run dist
|
||||
```
|
||||
|
||||
## 참고
|
||||
|
||||
- Upstream: `https://github.com/peunsu/MRSLauncher`
|
||||
- Original base: `https://github.com/dscalzi/HeliosLauncher`
|
||||
- CI: `.github/workflows/windows-smoke.yml` 에서 Windows smoke run 수행
|
||||
|
||||
50
app/app.ejs
Normal file
@@ -0,0 +1,50 @@
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8" http-equiv="Content-Security-Policy" content="script-src 'self' 'sha256-In6B8teKZQll5heMl9bS7CESTbGvuAt3VVV86BUQBDk='"/>
|
||||
<title><%= lang('app.title') %></title>
|
||||
<script src="./assets/js/scripts/uicore.js"></script>
|
||||
<script src="./assets/js/scripts/uibinder.js"></script>
|
||||
<link type="text/css" rel="stylesheet" href="./assets/css/launcher.css">
|
||||
<style>
|
||||
body {
|
||||
/*background: url('assets/images/backgrounds/<%=bkid%>.jpg') no-repeat center center fixed;*/
|
||||
transition: background-image 1s ease;
|
||||
background-image: url('data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD//gA+Q1JFQVRPUjogZ2QtanBlZyB2MS4wICh1c2luZyBJSkcgSlBFRyB2ODApLCBkZWZhdWx0IHF1YWxpdHkK/9sAQwAIBgYHBgUIBwcHCQkICgwUDQwLCwwZEhMPFB0aHx4dGhwcICQuJyAiLCMcHCg3KSwwMTQ0NB8nOT04MjwuMzQy/9sAQwEJCQkMCwwYDQ0YMiEcITIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIy/8AAEQgAPwBwAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/aAAwDAQACEQMRAD8A8VooopDCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAP/9k=');
|
||||
background-size: cover;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
#main {
|
||||
display: none;
|
||||
height: calc(100% - 22px);
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.75) 0%, rgba(0, 0, 0, 0) 100%);
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
}
|
||||
#main[overlay] {
|
||||
filter: blur(3px) contrast(0.9) brightness(1.0);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body bkid="<%=bkid%>">
|
||||
<%- include('frame') %>
|
||||
<div id="main">
|
||||
<%- include('welcome') %>
|
||||
<%- include('login') %>
|
||||
<%- include('waiting') %>
|
||||
<%- include('loginOptions') %>
|
||||
<%- include('settings') %>
|
||||
<%- include('library') %>
|
||||
<%- include('install') %>
|
||||
<%- include('landing') %>
|
||||
</div>
|
||||
<%- include('overlay') %>
|
||||
<div id="loadingContainer">
|
||||
<div id="loadingContent">
|
||||
<div id="loadSpinnerContainer">
|
||||
<img id="loadSpinnerImage" class="rotating" src="assets/images/Icon.png">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
4329
app/assets/css/launcher.css
Normal file
BIN
app/assets/fonts/Pretendard-Black.ttf
Normal file
BIN
app/assets/fonts/Pretendard-Bold.ttf
Normal file
BIN
app/assets/fonts/Pretendard-ExtraBold.ttf
Normal file
BIN
app/assets/fonts/Pretendard-ExtraLight.ttf
Normal file
BIN
app/assets/fonts/Pretendard-Light.ttf
Normal file
BIN
app/assets/fonts/Pretendard-Medium.ttf
Normal file
BIN
app/assets/fonts/Pretendard-Regular.ttf
Normal file
BIN
app/assets/fonts/Pretendard-SemiBold.ttf
Normal file
BIN
app/assets/fonts/Pretendard-Thin.ttf
Normal file
BIN
app/assets/fonts/ringbearer.ttf
Normal file
BIN
app/assets/images/Icon.ico
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
app/assets/images/Icon.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
app/assets/images/backgrounds/0.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
app/assets/images/backgrounds/1.png
Normal file
|
After Width: | Height: | Size: 6.5 MiB |
BIN
app/assets/images/backgrounds/2.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
app/assets/images/backgrounds/3.png
Normal file
|
After Width: | Height: | Size: 799 KiB |
BIN
app/assets/images/backgrounds/4.png
Normal file
|
After Width: | Height: | Size: 6.7 MiB |
BIN
app/assets/images/backgrounds/5.png
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
BIN
app/assets/images/backgrounds/6.png
Normal file
|
After Width: | Height: | Size: 6.5 MiB |
7
app/assets/images/icons/arrow.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24.87 13.97">
|
||||
<defs>
|
||||
<style>.cls-1{fill:none;stroke:#FFF;stroke-width:2px;}</style>
|
||||
</defs>
|
||||
<title>arrow</title>
|
||||
<polyline class="cls-1" points="0.71 13.26 12.56 1.41 24.16 13.02"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 298 B |
10
app/assets/images/icons/discord.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 141.36 137.43">
|
||||
<defs>
|
||||
<style>.cls-1{fill:none;}.cls-2{clip-path:url(#clip-path);}</style>
|
||||
<clipPath id="clip-path"><rect class="cls-1" x="36.42" y="44.23" width="68.52" height="48.96"/></clipPath>
|
||||
</defs>
|
||||
<title>discord</title>
|
||||
<g class="cls-2">
|
||||
<path d="M81.23,78.48a6.14,6.14,0,1,1,6.14-6.14,6.14,6.14,0,0,1-6.14,6.14M60,78.48a6.14,6.14,0,1,1,6.14-6.14A6.14,6.14,0,0,1,60,78.48M104.41,73c-.92-7.7-8.24-22.9-8.24-22.9A43,43,0,0,0,88,45.59a17.88,17.88,0,0,0-8.38-1.27l-.13,1.06a23.52,23.52,0,0,1,5.8,1.95,87.59,87.59,0,0,1,8.17,4.87s-10.32-5.63-22.27-5.63a51.32,51.32,0,0,0-23.2,5.63,87.84,87.84,0,0,1,8.17-4.87,23.57,23.57,0,0,1,5.8-1.95l-.13-1.06a17.88,17.88,0,0,0-8.38,1.27,42.84,42.84,0,0,0-8.21,4.56S37.87,65.35,37,73s-.37,11.54-.37,11.54,4.22,5.68,9.9,7.14,7.7,1.47,7.7,1.47l3.75-4.68a21.22,21.22,0,0,1-4.65-2A24.47,24.47,0,0,1,47.93,82S61.16,88.4,70.68,88.4c10,0,22.75-6.44,22.75-6.44a24.56,24.56,0,0,1-5.35,4.56,21.22,21.22,0,0,1-4.65,2l3.75,4.68s2,0,7.7-1.47,9.89-7.14,9.89-7.14.55-3.85-.37-11.54"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
5
app/assets/images/icons/github-mark-white.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg id="githubSVG" class="mediaSVG" viewbox="0 0 98 98">
|
||||
<g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#fff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 981 B |
5
app/assets/images/icons/home.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 491.398 491.398" xml:space="preserve">
|
||||
<g>
|
||||
<path d="M481.765,220.422L276.474,15.123c-16.967-16.918-44.557-16.942-61.559,0.023L9.626,220.422c-12.835,12.833-12.835,33.65,0,46.483c12.843,12.842,33.646,12.842,46.487,0l27.828-27.832v214.872c0,19.343,15.682,35.024,35.027,35.024h74.826v-97.62c0-7.584,6.146-13.741,13.743-13.741h76.352c7.59,0,13.739,6.157,13.739,13.741v97.621h74.813c19.346,0,35.027-15.681,35.027-35.024V239.091l27.812,27.815c6.425,6.421,14.833,9.63,23.243,9.63c8.408,0,16.819-3.209,23.242-9.63C494.609,254.072,494.609,233.256,481.765,220.422z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 742 B |
9
app/assets/images/icons/instagram.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="504px" height="504px" viewBox="0 0 5040 5040" preserveAspectRatio="xMidYMid meet">
|
||||
<g id="layer101" fill="#000000" stroke="none">
|
||||
<path d="M1390 5024 c-163 -9 -239 -19 -315 -38 -281 -70 -477 -177 -660 -361 -184 -184 -292 -380 -361 -660 -43 -171 -53 -456 -53 -1445 0 -989 10 -1274 53 -1445 69 -280 177 -476 361 -660 184 -184 380 -292 660 -361 171 -43 456 -53 1445 -53 989 0 1274 10 1445 53 280 69 476 177 660 361 184 184 292 380 361 660 43 171 53 456 53 1445 0 989 -10 1274 -53 1445 -69 280 -177 476 -361 660 -184 184 -380 292 -660 361 -174 44 -454 53 -1470 52 -599 0 -960 -5 -1105 -14z m2230 -473 c58 -6 141 -18 185 -27 397 -78 638 -318 719 -714 37 -183 41 -309 41 -1290 0 -981 -4 -1107 -41 -1290 -81 -395 -319 -633 -714 -714 -183 -37 -309 -41 -1290 -41 -981 0 -1107 4 -1290 41 -397 81 -636 322 -714 719 -33 166 -38 296 -43 1100 -5 796 3 1203 27 1380 67 489 338 758 830 825 47 7 162 15 255 20 250 12 1907 4 2035 -9z"/>
|
||||
<path d="M2355 3819 c-307 -42 -561 -172 -780 -400 -244 -253 -359 -543 -359 -899 0 -361 116 -648 367 -907 262 -269 563 -397 937 -397 374 0 675 128 937 397 251 259 367 546 367 907 0 361 -116 648 -367 907 -197 203 -422 326 -690 378 -101 20 -317 27 -412 14z m400 -509 c275 -88 470 -284 557 -560 20 -65 23 -95 23 -230 0 -135 -3 -165 -23 -230 -88 -278 -284 -474 -562 -562 -65 -20 -95 -23 -230 -23 -135 0 -165 3 -230 23 -278 88 -474 284 -562 562 -20 65 -23 95 -23 230 0 135 3 165 23 230 73 230 219 403 427 507 134 67 212 83 390 79 111 -3 155 -8 210 -26z"/>
|
||||
<path d="M3750 1473 c-29 -11 -66 -38 -106 -77 -70 -71 -94 -126 -94 -221 0 -95 24 -150 94 -221 72 -71 126 -94 225 -94 168 0 311 143 311 311 0 99 -23 154 -94 225 -43 42 -76 66 -110 77 -61 21 -166 21 -226 0z"/>
|
||||
</g>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
11
app/assets/images/icons/link.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 141.36 137.43">
|
||||
<defs>
|
||||
<style>.cls-1{fill:none;}.cls-2{clip-path:url(#clip-path);}</style>
|
||||
<clipPath id="clip-path"><rect class="cls-1" x="29.3" y="52.62" width="82.77" height="34.15"/></clipPath>
|
||||
</defs>
|
||||
<title>link</title>
|
||||
<g class="cls-2">
|
||||
<path d="M75.37,65.51a3.85,3.85,0,0,0-1.73.42,8.22,8.22,0,0,1,.94,3.76A8.36,8.36,0,0,1,66.23,78H46.37a8.35,8.35,0,1,1,0-16.7h9.18a21.51,21.51,0,0,1,6.65-8.72H46.37a17.07,17.07,0,1,0,0,34.15H66.23A17,17,0,0,0,82.77,65.51Z"/>
|
||||
<path d="M66,73.88a3.85,3.85,0,0,0,1.73-.42,8.22,8.22,0,0,1-.94-3.76,8.36,8.36,0,0,1,8.35-8.35H95A8.35,8.35,0,1,1,95,78H85.8a21.51,21.51,0,0,1-6.65,8.72H95a17.07,17.07,0,0,0,0-34.15H75.13A17,17,0,0,0,58.59,73.88Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 875 B |
12
app/assets/images/icons/lock.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 141.36 137.43">
|
||||
<defs>
|
||||
<style>.cls-1{fill:none;}.cls-2{clip-path:url(#clip-path);}.cls-3{fill:#231f20;}</style>
|
||||
<clipPath id="clip-path">
|
||||
<rect class="cls-1" x="44.02" y="34.21" width="51.96" height="68.48"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<title>Lock</title>
|
||||
<g class="cls-2">
|
||||
<path class="cls-3" d="M86.16,54a16.38,16.38,0,1,0-32,0H44V102.7H96V54Zm-25.9-3.39a9.89,9.89,0,1,1,19.77,0A9.78,9.78,0,0,1,79.39,54H60.89A9.78,9.78,0,0,1,60.26,50.59ZM70,96.2a6.5,6.5,0,0,1-6.5-6.5,6.39,6.39,0,0,1,3.1-5.4V67h6.5V84.11a6.42,6.42,0,0,1,3.39,5.6A6.5,6.5,0,0,1,70,96.2Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 756 B |
5
app/assets/images/icons/map.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" xml:space="preserve">
|
||||
<g>
|
||||
<path class="st0" d="M256,0C160.798,0,83.644,77.155,83.644,172.356c0,97.162,48.158,117.862,101.386,182.495C248.696,432.161,256,512,256,512s7.304-79.839,70.97-157.148c53.228-64.634,101.386-85.334,101.386-182.495C428.356,77.155,351.202,0,256,0z M256,231.921c-32.897,0-59.564-26.668-59.564-59.564s26.668-59.564,59.564-59.564c32.896,0,59.564,26.668,59.564,59.564S288.896,231.921,256,231.921z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 596 B |
7
app/assets/images/icons/microsoft.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23">
|
||||
<path fill="#f3f3f3" d="M0 0h23v23H0z" />
|
||||
<path fill="#f35325" d="M1 1h10v10H1z" />
|
||||
<path fill="#81bc06" d="M12 1h10v10H12z" />
|
||||
<path fill="#05a6f0" d="M1 12h10v10H1z" />
|
||||
<path fill="#ffba08" d="M12 12h10v10H12z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 303 B |
5
app/assets/images/icons/mojang.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 9.677 9.667">
|
||||
<path d="M-26.332-12.098h2.715c-1.357.18-2.574 1.23-2.715 2.633z" fill="#fff" />
|
||||
<path d="M2.598.022h7.07L9.665 7c-.003 1.334-1.113 2.46-2.402 2.654H0V2.542C.134 1.2 1.3.195 2.598.022z" fill="#db2331" />
|
||||
<path d="M1.54 2.844c.314-.76 1.31-.46 1.954-.528.785-.083 1.503.272 2.1.758l.164-.9c.327.345.587.756.964 1.052.28.254.655-.342.86-.013.42.864.408 1.86.54 2.795l-.788-.373C6.9 4.17 5.126 3.052 3.656 3.685c-1.294.592-1.156 2.65.06 3.255 1.354.703 2.953.51 4.405.292-.07.42-.34.87-.834.816l-4.95.002c-.5.055-.886-.413-.838-.89l.04-4.315z" fill="#fff" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 664 B |
14
app/assets/images/icons/news.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 141.36 137.43">
|
||||
<defs>
|
||||
<style>.cls-1{fill:#231f20;}.cls-2,.cls-3{fill:none;stroke-miterlimit:10;stroke-width:6px;}.cls-2{stroke:#231f20;}.cls-3{stroke:#000;}</style>
|
||||
</defs>
|
||||
<title>News</title>
|
||||
<rect class="cls-1" x="31.77" y="32.96" width="33.79" height="20.76"/>
|
||||
<path class="cls-2" d="M115.36,113.8H27.18a6.67,6.67,0,0,1-6.67-6.67V19.27H108.2V107.1a6.71,6.71,0,0,0,6.71,6.7h0a6.71,6.71,0,0,0,6.71-6.71v-75H108.15"/>
|
||||
<line class="cls-3" x1="73.75" y1="36.18" x2="97.14" y2="36.18"/>
|
||||
<line class="cls-3" x1="73.75" y1="50.22" x2="97.14" y2="50.22"/>
|
||||
<line class="cls-3" x1="31.66" y1="64.25" x2="97.14" y2="64.25"/>
|
||||
<line class="cls-3" x1="31.66" y1="78.28" x2="97.14" y2="78.28"/>
|
||||
<line class="cls-3" x1="31.66" y1="92.31" x2="97.14" y2="92.31"/>
|
||||
<line class="cls-3" x1="31.66" y1="92.31" x2="97.14" y2="92.31"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 959 B |
10
app/assets/images/icons/profile.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 141.36 137.43">
|
||||
<defs>
|
||||
<style>.cls-1{fill:none;}.cls-2{clip-path:url(#clip-path);}</style>
|
||||
<clipPath id="clip-path"><rect class="cls-1" x="45.51" y="44.33" width="55.14" height="59.33"/></clipPath>
|
||||
</defs>
|
||||
<title>Profile</title>
|
||||
<g class="cls-2">
|
||||
<path d="M86.77,58.12A13.79,13.79,0,1,0,73,71.91,13.79,13.79,0,0,0,86.77,58.12M97,103.67a3.41,3.41,0,0,0,3.39-3.84,27.57,27.57,0,0,0-54.61,0,3.41,3.41,0,0,0,3.39,3.84Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 602 B |
10
app/assets/images/icons/settings.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 141.36 137.43">
|
||||
<defs>
|
||||
<style>.cls-1{fill:none;}.cls-2{clip-path:url(#clip-path);}</style>
|
||||
<clipPath id="clip-path"><rect class="cls-1" x="45.65" y="42.62" width="49.58" height="52.43"/></clipPath>
|
||||
</defs>
|
||||
<title>settings</title>
|
||||
<g class="cls-2">
|
||||
<path d="M70.44,75a6.19,6.19,0,1,1,5.84-6.18A6,6,0,0,1,70.44,75M91.67,63.71h-5A18.4,18.4,0,0,0,85.19,60l3.48-3.68a3.93,3.93,0,0,0,0-5.32l-1.4-1.48a3.43,3.43,0,0,0-5,0l-3.48,3.68A16.34,16.34,0,0,0,75,51.59V46.38a3.68,3.68,0,0,0-3.56-3.76h-2a3.68,3.68,0,0,0-3.56,3.76v5.21a16.23,16.23,0,0,0-3.77,1.64l-3.48-3.68a3.43,3.43,0,0,0-5,0L52.21,51a3.93,3.93,0,0,0,0,5.32L55.69,60a18.21,18.21,0,0,0-1.48,3.67h-5a3.67,3.67,0,0,0-3.56,3.76v2.1a3.68,3.68,0,0,0,3.56,3.76h4.84a18.46,18.46,0,0,0,1.64,4.3l-3.48,3.68a3.93,3.93,0,0,0,0,5.32l1.4,1.48a3.43,3.43,0,0,0,5,0l3.48-3.68a16.36,16.36,0,0,0,3.77,1.64v5.21a3.67,3.67,0,0,0,3.56,3.76h2A3.67,3.67,0,0,0,75,91.29V86.08a16.48,16.48,0,0,0,3.77-1.64l3.48,3.68a3.43,3.43,0,0,0,5,0l1.4-1.48a3.93,3.93,0,0,0,0-5.32l-3.48-3.68a18.45,18.45,0,0,0,1.63-4.3h4.85a3.68,3.68,0,0,0,3.56-3.76v-2.1a3.67,3.67,0,0,0-3.56-3.76"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
13
app/assets/images/icons/sevenstar.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 107.45 104.74">
|
||||
<defs>
|
||||
<style>.cls-1{fill:#1a171b;}</style>
|
||||
</defs>
|
||||
<title>Seven Pointed Star</title>
|
||||
<polygon class="cls-1" points="43.83 52.37 48.83 14.03 53.83 52.37 43.83 52.37"/>
|
||||
<polygon class="cls-1" points="45.71 56.28 18.85 28.47 51.95 48.46 45.71 56.28"/>
|
||||
<polygon class="cls-1" points="49.94 57.25 11.45 60.9 47.72 47.5 49.94 57.25"/>
|
||||
<polygon class="cls-1" points="53.34 54.54 32.19 86.92 44.33 50.2 53.34 54.54"/>
|
||||
<polygon class="cls-1" points="53.34 50.2 65.47 86.92 44.33 54.54 53.34 50.2"/>
|
||||
<polygon class="cls-1" points="49.94 47.5 86.21 60.91 47.72 57.25 49.94 47.5"/>
|
||||
<polygon class="cls-1" points="45.71 48.46 78.81 28.47 51.95 56.28 45.71 48.46"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 809 B |
14
app/assets/images/icons/sevenstar_circle.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 107.45 104.74">
|
||||
<defs>
|
||||
<style>.cls-1{fill:#1a171b;}.cls-2{fill:none;stroke:#1a171b;stroke-miterlimit:10;}</style>
|
||||
</defs>
|
||||
<title>Seven Pointed Star with Circle</title>
|
||||
<polygon class="cls-1" points="43.83 52.37 48.83 14.03 53.83 52.37 43.83 52.37"/>
|
||||
<polygon class="cls-1" points="45.71 56.28 18.85 28.47 51.95 48.46 45.71 56.28"/>
|
||||
<polygon class="cls-1" points="49.94 57.25 11.45 60.9 47.72 47.5 49.94 57.25"/>
|
||||
<polygon class="cls-1" points="53.34 54.54 32.19 86.92 44.33 50.2 53.34 54.54"/>
|
||||
<polygon class="cls-1" points="53.34 50.2 65.47 86.92 44.33 54.54 53.34 50.2"/>
|
||||
<polygon class="cls-1" points="49.94 47.5 86.21 60.91 47.72 57.25 49.94 47.5"/>
|
||||
<polygon class="cls-1" points="45.71 48.46 78.81 28.47 51.95 56.28 45.71 48.46"/>
|
||||
<circle class="cls-2" cx="48.83" cy="52.37" r="38"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 932 B |
8
app/assets/images/icons/sevenstar_circle_extended.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 107.45 104.74">
|
||||
<defs>
|
||||
<style>.cls-1{fill:#1a171b;}.cls-2{fill:none;stroke:#1a171b;stroke-miterlimit:10;}</style>
|
||||
</defs>
|
||||
<title>Seven Pointed Star Extended with Circle</title>
|
||||
<path class="cls-1" d="M100.93,65.54C89,62,68.18,55.65,63.54,52.13c2.7-5.23,18.8-19.2,28-27.55C81.36,31.74,63.74,43.87,58.09,45.3c-2.41-5.37-3.61-26.52-4.37-39-.77,12.46-2,33.64-4.36,39-5.7-1.46-23.3-13.57-33.49-20.72,9.26,8.37,25.39,22.36,28,27.55C39.21,55.68,18.47,62,6.52,65.55c12.32-2,33.63-6.06,39.34-4.9-.16,5.87-8.41,26.16-13.11,37.69,6.1-10.89,16.52-30.16,21-33.9,4.5,3.79,14.93,23.09,21,34C70,86.84,61.73,66.48,61.59,60.65,67.36,59.49,88.64,63.52,100.93,65.54Z"/>
|
||||
<circle class="cls-2" cx="53.73" cy="53.9" r="38"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 822 B |
15
app/assets/images/icons/sevenstar_circle_hole.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 107.45 104.74">
|
||||
<defs>
|
||||
<style>.cls-1{fill:#1a171b;}.cls-2{fill:none;stroke:#1a171b;stroke-miterlimit:10;}.cls-3{fill:#fff;}</style>
|
||||
</defs>
|
||||
<title>Seven Pointed Star with Circle and Hole</title>
|
||||
<polygon class="cls-1" points="43.83 52.37 48.83 14.03 53.83 52.37 43.83 52.37"/>
|
||||
<polygon class="cls-1" points="45.71 56.28 18.85 28.47 51.95 48.46 45.71 56.28"/>
|
||||
<polygon class="cls-1" points="49.94 57.25 11.45 60.9 47.72 47.5 49.94 57.25"/>
|
||||
<polygon class="cls-1" points="53.34 54.54 32.19 86.92 44.33 50.2 53.34 54.54"/>
|
||||
<polygon class="cls-1" points="53.34 50.2 65.47 86.92 44.33 54.54 53.34 50.2"/>
|
||||
<polygon class="cls-1" points="49.94 47.5 86.21 60.91 47.72 57.25 49.94 47.5"/>
|
||||
<polygon class="cls-1" points="45.71 48.46 78.81 28.47 51.95 56.28 45.71 48.46"/>
|
||||
<circle class="cls-2" cx="48.83" cy="52.37" r="38"/>
|
||||
<circle class="cls-3" cx="48.83" cy="52.37" r="4.56"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1018 B |
@@ -0,0 +1,9 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 107.45 104.74">
|
||||
<defs>
|
||||
<style>.cls-1{fill:#1a171b;}.cls-2{fill:none;stroke:#1a171b;stroke-miterlimit:10;}.cls-3{fill:#fff;}</style>
|
||||
</defs>
|
||||
<title>Seven Pointed Star Extended with Circle and Hole</title>
|
||||
<path class="cls-1" d="M100.93,65.54C89,62,68.18,55.65,63.54,52.13c2.7-5.23,18.8-19.2,28-27.55C81.36,31.74,63.74,43.87,58.09,45.3c-2.41-5.37-3.61-26.52-4.37-39-.77,12.46-2,33.64-4.36,39-5.7-1.46-23.3-13.57-33.49-20.72,9.26,8.37,25.39,22.36,28,27.55C39.21,55.68,18.47,62,6.52,65.55c12.32-2,33.63-6.06,39.34-4.9-.16,5.87-8.41,26.16-13.11,37.69,6.1-10.89,16.52-30.16,21-33.9,4.5,3.79,14.93,23.09,21,34C70,86.84,61.73,66.48,61.59,60.65,67.36,59.49,88.64,63.52,100.93,65.54Z"/>
|
||||
<circle class="cls-2" cx="53.73" cy="53.9" r="38"/>
|
||||
<circle class="cls-3" cx="53.73" cy="53.9" r="4.56"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 907 B |
7
app/assets/images/icons/sevenstar_extended.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 107.45 104.74">
|
||||
<defs>
|
||||
<style>.cls-1{fill:#1a171b;}</style>
|
||||
</defs>
|
||||
<title>Seven Pointed Star Extended</title>
|
||||
<path class="cls-1" d="M100.93,65.54C89,62,68.18,55.65,63.54,52.13c2.7-5.23,18.8-19.2,28-27.55C81.36,31.74,63.74,43.87,58.09,45.3c-2.41-5.37-3.61-26.52-4.37-39-.77,12.46-2,33.64-4.36,39-5.7-1.46-23.3-13.57-33.49-20.72,9.26,8.37,25.39,22.36,28,27.55C39.21,55.68,18.47,62,6.52,65.55c12.32-2,33.63-6.06,39.34-4.9-.16,5.87-8.41,26.16-13.11,37.69,6.1-10.89,16.52-30.16,21-33.9,4.5,3.79,14.93,23.09,21,34C70,86.84,61.73,66.48,61.59,60.65,67.36,59.49,88.64,63.52,100.93,65.54Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 700 B |
4
app/assets/images/icons/x.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="300" height="271" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m236 0h46l-101 115 118 156h-92.6l-72.5-94.8-83 94.8h-46l107-123-113-148h94.9l65.5 86.6zm-16.1 244h25.5l-165-218h-27.4z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 243 B |
10
app/assets/images/icons/youtube.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 141.36 137.43">
|
||||
<defs>
|
||||
<style>.cls-1{fill:none;}.cls-2{clip-path:url(#clip-path);}</style>
|
||||
<clipPath id="clip-path"><rect class="cls-1" x="38.29" y="45.86" width="70.16" height="48.48"/></clipPath>
|
||||
</defs>
|
||||
<title>youtube</title>
|
||||
<g class="cls-2">
|
||||
<path d="M84.8,69.52,65.88,79.76V59.27Zm23.65.59c0-5.14-.79-17.63-3.94-20.57S99,45.86,73.37,45.86s-28,.73-31.14,3.68S38.29,65,38.29,70.11s.79,17.63,3.94,20.57,5.52,3.68,31.14,3.68,28-.74,31.14-3.68,3.94-15.42,3.94-20.57"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 654 B |
BIN
app/assets/images/minecraft.icns
Normal file
425
app/assets/js/authmanager.js
Normal file
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* AuthManager
|
||||
*
|
||||
* This module aims to abstract login procedures. Results from Mojang's REST api
|
||||
* are retrieved through our Mojang module. These results are processed and stored,
|
||||
* if applicable, in the config using the ConfigManager. All login procedures should
|
||||
* be made through this module.
|
||||
*
|
||||
* @module authmanager
|
||||
*/
|
||||
// Requirements
|
||||
const ConfigManager = require('./configmanager')
|
||||
const { LoggerUtil } = require('helios-core')
|
||||
const { RestResponseStatus } = require('helios-core/common')
|
||||
const { MojangRestAPI, MojangErrorCode } = require('helios-core/mojang')
|
||||
const { MicrosoftAuth, MicrosoftErrorCode } = require('helios-core/microsoft')
|
||||
const { AZURE_CLIENT_ID } = require('./ipcconstants')
|
||||
const Lang = require('./langloader')
|
||||
|
||||
const log = LoggerUtil.getLogger('AuthManager')
|
||||
|
||||
// Error messages
|
||||
|
||||
function microsoftErrorDisplayable(errorCode) {
|
||||
switch (errorCode) {
|
||||
case MicrosoftErrorCode.NO_PROFILE:
|
||||
return {
|
||||
title: Lang.queryJS('auth.microsoft.error.noProfileTitle'),
|
||||
desc: Lang.queryJS('auth.microsoft.error.noProfileDesc')
|
||||
}
|
||||
case MicrosoftErrorCode.NO_XBOX_ACCOUNT:
|
||||
return {
|
||||
title: Lang.queryJS('auth.microsoft.error.noXboxAccountTitle'),
|
||||
desc: Lang.queryJS('auth.microsoft.error.noXboxAccountDesc')
|
||||
}
|
||||
case MicrosoftErrorCode.XBL_BANNED:
|
||||
return {
|
||||
title: Lang.queryJS('auth.microsoft.error.xblBannedTitle'),
|
||||
desc: Lang.queryJS('auth.microsoft.error.xblBannedDesc')
|
||||
}
|
||||
case MicrosoftErrorCode.UNDER_18:
|
||||
return {
|
||||
title: Lang.queryJS('auth.microsoft.error.under18Title'),
|
||||
desc: Lang.queryJS('auth.microsoft.error.under18Desc')
|
||||
}
|
||||
case MicrosoftErrorCode.UNKNOWN:
|
||||
return {
|
||||
title: Lang.queryJS('auth.microsoft.error.unknownTitle'),
|
||||
desc: Lang.queryJS('auth.microsoft.error.unknownDesc')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mojangErrorDisplayable(errorCode) {
|
||||
switch(errorCode) {
|
||||
case MojangErrorCode.ERROR_METHOD_NOT_ALLOWED:
|
||||
return {
|
||||
title: Lang.queryJS('auth.mojang.error.methodNotAllowedTitle'),
|
||||
desc: Lang.queryJS('auth.mojang.error.methodNotAllowedDesc')
|
||||
}
|
||||
case MojangErrorCode.ERROR_NOT_FOUND:
|
||||
return {
|
||||
title: Lang.queryJS('auth.mojang.error.notFoundTitle'),
|
||||
desc: Lang.queryJS('auth.mojang.error.notFoundDesc')
|
||||
}
|
||||
case MojangErrorCode.ERROR_USER_MIGRATED:
|
||||
return {
|
||||
title: Lang.queryJS('auth.mojang.error.accountMigratedTitle'),
|
||||
desc: Lang.queryJS('auth.mojang.error.accountMigratedDesc')
|
||||
}
|
||||
case MojangErrorCode.ERROR_INVALID_CREDENTIALS:
|
||||
return {
|
||||
title: Lang.queryJS('auth.mojang.error.invalidCredentialsTitle'),
|
||||
desc: Lang.queryJS('auth.mojang.error.invalidCredentialsDesc')
|
||||
}
|
||||
case MojangErrorCode.ERROR_RATELIMIT:
|
||||
return {
|
||||
title: Lang.queryJS('auth.mojang.error.tooManyAttemptsTitle'),
|
||||
desc: Lang.queryJS('auth.mojang.error.tooManyAttemptsDesc')
|
||||
}
|
||||
case MojangErrorCode.ERROR_INVALID_TOKEN:
|
||||
return {
|
||||
title: Lang.queryJS('auth.mojang.error.invalidTokenTitle'),
|
||||
desc: Lang.queryJS('auth.mojang.error.invalidTokenDesc')
|
||||
}
|
||||
case MojangErrorCode.ERROR_ACCESS_TOKEN_HAS_PROFILE:
|
||||
return {
|
||||
title: Lang.queryJS('auth.mojang.error.tokenHasProfileTitle'),
|
||||
desc: Lang.queryJS('auth.mojang.error.tokenHasProfileDesc')
|
||||
}
|
||||
case MojangErrorCode.ERROR_CREDENTIALS_MISSING:
|
||||
return {
|
||||
title: Lang.queryJS('auth.mojang.error.credentialsMissingTitle'),
|
||||
desc: Lang.queryJS('auth.mojang.error.credentialsMissingDesc')
|
||||
}
|
||||
case MojangErrorCode.ERROR_INVALID_SALT_VERSION:
|
||||
return {
|
||||
title: Lang.queryJS('auth.mojang.error.invalidSaltVersionTitle'),
|
||||
desc: Lang.queryJS('auth.mojang.error.invalidSaltVersionDesc')
|
||||
}
|
||||
case MojangErrorCode.ERROR_UNSUPPORTED_MEDIA_TYPE:
|
||||
return {
|
||||
title: Lang.queryJS('auth.mojang.error.unsupportedMediaTypeTitle'),
|
||||
desc: Lang.queryJS('auth.mojang.error.unsupportedMediaTypeDesc')
|
||||
}
|
||||
case MojangErrorCode.ERROR_GONE:
|
||||
return {
|
||||
title: Lang.queryJS('auth.mojang.error.accountGoneTitle'),
|
||||
desc: Lang.queryJS('auth.mojang.error.accountGoneDesc')
|
||||
}
|
||||
case MojangErrorCode.ERROR_UNREACHABLE:
|
||||
return {
|
||||
title: Lang.queryJS('auth.mojang.error.unreachableTitle'),
|
||||
desc: Lang.queryJS('auth.mojang.error.unreachableDesc')
|
||||
}
|
||||
case MojangErrorCode.ERROR_NOT_PAID:
|
||||
return {
|
||||
title: Lang.queryJS('auth.mojang.error.gameNotPurchasedTitle'),
|
||||
desc: Lang.queryJS('auth.mojang.error.gameNotPurchasedDesc')
|
||||
}
|
||||
case MojangErrorCode.UNKNOWN:
|
||||
return {
|
||||
title: Lang.queryJS('auth.mojang.error.unknownErrorTitle'),
|
||||
desc: Lang.queryJS('auth.mojang.error.unknownErrorDesc')
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown error code: ${errorCode}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Functions
|
||||
|
||||
/**
|
||||
* Add a Mojang account. This will authenticate the given credentials with Mojang's
|
||||
* authserver. The resultant data will be stored as an auth account in the
|
||||
* configuration database.
|
||||
*
|
||||
* @param {string} username The account username (email if migrated).
|
||||
* @param {string} password The account password.
|
||||
* @returns {Promise.<Object>} Promise which resolves the resolved authenticated account object.
|
||||
*/
|
||||
exports.addMojangAccount = async function(username, password) {
|
||||
try {
|
||||
const response = await MojangRestAPI.authenticate(username, password, ConfigManager.getClientToken())
|
||||
console.log(response)
|
||||
if(response.responseStatus === RestResponseStatus.SUCCESS) {
|
||||
|
||||
const session = response.data
|
||||
if(session.selectedProfile != null){
|
||||
const ret = ConfigManager.addMojangAuthAccount(session.selectedProfile.id, session.accessToken, username, session.selectedProfile.name)
|
||||
if(ConfigManager.getClientToken() == null){
|
||||
ConfigManager.setClientToken(session.clientToken)
|
||||
}
|
||||
ConfigManager.save()
|
||||
return ret
|
||||
} else {
|
||||
return Promise.reject(mojangErrorDisplayable(MojangErrorCode.ERROR_NOT_PAID))
|
||||
}
|
||||
|
||||
} else {
|
||||
return Promise.reject(mojangErrorDisplayable(response.mojangErrorCode))
|
||||
}
|
||||
|
||||
} catch (err){
|
||||
log.error(err)
|
||||
return Promise.reject(mojangErrorDisplayable(MojangErrorCode.UNKNOWN))
|
||||
}
|
||||
}
|
||||
|
||||
const AUTH_MODE = { FULL: 0, MS_REFRESH: 1, MC_REFRESH: 2 }
|
||||
|
||||
/**
|
||||
* Perform the full MS Auth flow in a given mode.
|
||||
*
|
||||
* AUTH_MODE.FULL = Full authorization for a new account.
|
||||
* AUTH_MODE.MS_REFRESH = Full refresh authorization.
|
||||
* AUTH_MODE.MC_REFRESH = Refresh of the MC token, reusing the MS token.
|
||||
*
|
||||
* @param {string} entryCode FULL-AuthCode. MS_REFRESH=refreshToken, MC_REFRESH=accessToken
|
||||
* @param {*} authMode The auth mode.
|
||||
* @returns An object with all auth data. AccessToken object will be null when mode is MC_REFRESH.
|
||||
*/
|
||||
async function fullMicrosoftAuthFlow(entryCode, authMode) {
|
||||
try {
|
||||
|
||||
let accessTokenRaw
|
||||
let accessToken
|
||||
if(authMode !== AUTH_MODE.MC_REFRESH) {
|
||||
const accessTokenResponse = await MicrosoftAuth.getAccessToken(entryCode, authMode === AUTH_MODE.MS_REFRESH, AZURE_CLIENT_ID)
|
||||
if(accessTokenResponse.responseStatus === RestResponseStatus.ERROR) {
|
||||
return Promise.reject(microsoftErrorDisplayable(accessTokenResponse.microsoftErrorCode))
|
||||
}
|
||||
accessToken = accessTokenResponse.data
|
||||
accessTokenRaw = accessToken.access_token
|
||||
} else {
|
||||
accessTokenRaw = entryCode
|
||||
}
|
||||
|
||||
const xblResponse = await MicrosoftAuth.getXBLToken(accessTokenRaw)
|
||||
if(xblResponse.responseStatus === RestResponseStatus.ERROR) {
|
||||
return Promise.reject(microsoftErrorDisplayable(xblResponse.microsoftErrorCode))
|
||||
}
|
||||
const xstsResonse = await MicrosoftAuth.getXSTSToken(xblResponse.data)
|
||||
if(xstsResonse.responseStatus === RestResponseStatus.ERROR) {
|
||||
return Promise.reject(microsoftErrorDisplayable(xstsResonse.microsoftErrorCode))
|
||||
}
|
||||
const mcTokenResponse = await MicrosoftAuth.getMCAccessToken(xstsResonse.data)
|
||||
if(mcTokenResponse.responseStatus === RestResponseStatus.ERROR) {
|
||||
return Promise.reject(microsoftErrorDisplayable(mcTokenResponse.microsoftErrorCode))
|
||||
}
|
||||
const mcProfileResponse = await MicrosoftAuth.getMCProfile(mcTokenResponse.data.access_token)
|
||||
if(mcProfileResponse.responseStatus === RestResponseStatus.ERROR) {
|
||||
return Promise.reject(microsoftErrorDisplayable(mcProfileResponse.microsoftErrorCode))
|
||||
}
|
||||
return {
|
||||
accessToken,
|
||||
accessTokenRaw,
|
||||
xbl: xblResponse.data,
|
||||
xsts: xstsResonse.data,
|
||||
mcToken: mcTokenResponse.data,
|
||||
mcProfile: mcProfileResponse.data
|
||||
}
|
||||
} catch(err) {
|
||||
log.error(err)
|
||||
return Promise.reject(microsoftErrorDisplayable(MicrosoftErrorCode.UNKNOWN))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the expiry date. Advance the expiry time by 10 seconds
|
||||
* to reduce the liklihood of working with an expired token.
|
||||
*
|
||||
* @param {number} nowMs Current time milliseconds.
|
||||
* @param {number} epiresInS Expires in (seconds)
|
||||
* @returns
|
||||
*/
|
||||
function calculateExpiryDate(nowMs, epiresInS) {
|
||||
return nowMs + ((epiresInS-10)*1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a Microsoft account. This will pass the provided auth code to Mojang's OAuth2.0 flow.
|
||||
* The resultant data will be stored as an auth account in the configuration database.
|
||||
*
|
||||
* @param {string} authCode The authCode obtained from microsoft.
|
||||
* @returns {Promise.<Object>} Promise which resolves the resolved authenticated account object.
|
||||
*/
|
||||
exports.addMicrosoftAccount = async function(authCode) {
|
||||
|
||||
const fullAuth = await fullMicrosoftAuthFlow(authCode, AUTH_MODE.FULL)
|
||||
|
||||
// Advance expiry by 10 seconds to avoid close calls.
|
||||
const now = new Date().getTime()
|
||||
|
||||
const ret = ConfigManager.addMicrosoftAuthAccount(
|
||||
fullAuth.mcProfile.id,
|
||||
fullAuth.mcToken.access_token,
|
||||
fullAuth.mcProfile.name,
|
||||
calculateExpiryDate(now, fullAuth.mcToken.expires_in),
|
||||
fullAuth.accessToken.access_token,
|
||||
fullAuth.accessToken.refresh_token,
|
||||
calculateExpiryDate(now, fullAuth.accessToken.expires_in)
|
||||
)
|
||||
ConfigManager.save()
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a Mojang account. This will invalidate the access token associated
|
||||
* with the account and then remove it from the database.
|
||||
*
|
||||
* @param {string} uuid The UUID of the account to be removed.
|
||||
* @returns {Promise.<void>} Promise which resolves to void when the action is complete.
|
||||
*/
|
||||
exports.removeMojangAccount = async function(uuid){
|
||||
try {
|
||||
const authAcc = ConfigManager.getAuthAccount(uuid)
|
||||
const response = await MojangRestAPI.invalidate(authAcc.accessToken, ConfigManager.getClientToken())
|
||||
if(response.responseStatus === RestResponseStatus.SUCCESS) {
|
||||
ConfigManager.removeAuthAccount(uuid)
|
||||
ConfigManager.save()
|
||||
return Promise.resolve()
|
||||
} else {
|
||||
log.error('Error while removing account', response.error)
|
||||
return Promise.reject(response.error)
|
||||
}
|
||||
} catch (err){
|
||||
log.error('Error while removing account', err)
|
||||
return Promise.reject(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a Microsoft account. It is expected that the caller will invoke the OAuth logout
|
||||
* through the ipc renderer.
|
||||
*
|
||||
* @param {string} uuid The UUID of the account to be removed.
|
||||
* @returns {Promise.<void>} Promise which resolves to void when the action is complete.
|
||||
*/
|
||||
exports.removeMicrosoftAccount = async function(uuid){
|
||||
try {
|
||||
ConfigManager.removeAuthAccount(uuid)
|
||||
ConfigManager.save()
|
||||
return Promise.resolve()
|
||||
} catch (err){
|
||||
log.error('Error while removing account', err)
|
||||
return Promise.reject(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the selected account with Mojang's authserver. If the account is not valid,
|
||||
* we will attempt to refresh the access token and update that value. If that fails, a
|
||||
* new login will be required.
|
||||
*
|
||||
* @returns {Promise.<boolean>} Promise which resolves to true if the access token is valid,
|
||||
* otherwise false.
|
||||
*/
|
||||
async function validateSelectedMojangAccount(){
|
||||
const current = ConfigManager.getSelectedAccount()
|
||||
const response = await MojangRestAPI.validate(current.accessToken, ConfigManager.getClientToken())
|
||||
|
||||
if(response.responseStatus === RestResponseStatus.SUCCESS) {
|
||||
const isValid = response.data
|
||||
if(!isValid){
|
||||
const refreshResponse = await MojangRestAPI.refresh(current.accessToken, ConfigManager.getClientToken())
|
||||
if(refreshResponse.responseStatus === RestResponseStatus.SUCCESS) {
|
||||
const session = refreshResponse.data
|
||||
ConfigManager.updateMojangAuthAccount(current.uuid, session.accessToken)
|
||||
ConfigManager.save()
|
||||
} else {
|
||||
log.error('Error while validating selected profile:', refreshResponse.error)
|
||||
log.info('Account access token is invalid.')
|
||||
return false
|
||||
}
|
||||
log.info('Account access token validated.')
|
||||
return true
|
||||
} else {
|
||||
log.info('Account access token validated.')
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the selected account with Microsoft's authserver. If the account is not valid,
|
||||
* we will attempt to refresh the access token and update that value. If that fails, a
|
||||
* new login will be required.
|
||||
*
|
||||
* @returns {Promise.<boolean>} Promise which resolves to true if the access token is valid,
|
||||
* otherwise false.
|
||||
*/
|
||||
async function validateSelectedMicrosoftAccount(){
|
||||
const current = ConfigManager.getSelectedAccount()
|
||||
const now = new Date().getTime()
|
||||
const mcExpiresAt = current.expiresAt
|
||||
const mcExpired = now >= mcExpiresAt
|
||||
|
||||
if(!mcExpired) {
|
||||
return true
|
||||
}
|
||||
|
||||
// MC token expired. Check MS token.
|
||||
|
||||
const msExpiresAt = current.microsoft.expires_at
|
||||
const msExpired = now >= msExpiresAt
|
||||
|
||||
if(msExpired) {
|
||||
// MS expired, do full refresh.
|
||||
try {
|
||||
const res = await fullMicrosoftAuthFlow(current.microsoft.refresh_token, AUTH_MODE.MS_REFRESH)
|
||||
|
||||
ConfigManager.updateMicrosoftAuthAccount(
|
||||
current.uuid,
|
||||
res.mcToken.access_token,
|
||||
res.accessToken.access_token,
|
||||
res.accessToken.refresh_token,
|
||||
calculateExpiryDate(now, res.accessToken.expires_in),
|
||||
calculateExpiryDate(now, res.mcToken.expires_in)
|
||||
)
|
||||
ConfigManager.save()
|
||||
return true
|
||||
} catch(_err) {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
// Only MC expired, use existing MS token.
|
||||
try {
|
||||
const res = await fullMicrosoftAuthFlow(current.microsoft.access_token, AUTH_MODE.MC_REFRESH)
|
||||
|
||||
ConfigManager.updateMicrosoftAuthAccount(
|
||||
current.uuid,
|
||||
res.mcToken.access_token,
|
||||
current.microsoft.access_token,
|
||||
current.microsoft.refresh_token,
|
||||
current.microsoft.expires_at,
|
||||
calculateExpiryDate(now, res.mcToken.expires_in)
|
||||
)
|
||||
ConfigManager.save()
|
||||
return true
|
||||
}
|
||||
catch(_err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the selected auth account.
|
||||
*
|
||||
* @returns {Promise.<boolean>} Promise which resolves to true if the access token is valid,
|
||||
* otherwise false.
|
||||
*/
|
||||
exports.validateSelected = async function(){
|
||||
const current = ConfigManager.getSelectedAccount()
|
||||
|
||||
if(current.type === 'microsoft') {
|
||||
return await validateSelectedMicrosoftAccount()
|
||||
} else {
|
||||
return await validateSelectedMojangAccount()
|
||||
}
|
||||
|
||||
}
|
||||
234
app/assets/js/catalogmanager.js
Normal file
@@ -0,0 +1,234 @@
|
||||
const fs = require('fs-extra')
|
||||
const got = require('got')
|
||||
const path = require('path')
|
||||
|
||||
const ConfigManager = require('./configmanager')
|
||||
const { DEFAULT_REMOTE_DISTRO_URL, setRemoteDistributionUrl } = require('./distromanager')
|
||||
|
||||
const LOCAL_CATALOG_PATH = path.join(__dirname, '..', 'launcher', 'catalog.json')
|
||||
|
||||
function normalizeText(value){
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function normalizeNullableText(value){
|
||||
const nextValue = normalizeText(value)
|
||||
return nextValue.length > 0 ? nextValue : null
|
||||
}
|
||||
|
||||
function toStoredProfile(rawProfile){
|
||||
const kind = normalizeText(rawProfile.kind) || 'modpack'
|
||||
const storedProfile = {
|
||||
id: normalizeText(rawProfile.id),
|
||||
name: normalizeText(rawProfile.name),
|
||||
kind,
|
||||
description: normalizeText(rawProfile.description),
|
||||
details: normalizeText(rawProfile.details),
|
||||
distributionUrl: normalizeNullableText(rawProfile.distributionUrl),
|
||||
defaultServerAddress: normalizeText(rawProfile.defaultServerAddress),
|
||||
allowCustomServerAddress: rawProfile.allowCustomServerAddress === true,
|
||||
worldArchiveUrl: normalizeNullableText(rawProfile.worldArchiveUrl),
|
||||
worldDirectoryName: normalizeNullableText(rawProfile.worldDirectoryName),
|
||||
serverBundleUrl: normalizeNullableText(rawProfile.serverBundleUrl),
|
||||
serverDirectoryName: normalizeText(rawProfile.serverDirectoryName) || 'server',
|
||||
serverLaunchCommand: normalizeNullableText(rawProfile.serverLaunchCommand),
|
||||
serverWorkingDirectory: normalizeNullableText(rawProfile.serverWorkingDirectory),
|
||||
serverPort: Number.isFinite(Number(rawProfile.serverPort)) ? Number(rawProfile.serverPort) : 25565,
|
||||
tunnelCommand: normalizeNullableText(rawProfile.tunnelCommand),
|
||||
tunnelAddressRegex: normalizeNullableText(rawProfile.tunnelAddressRegex),
|
||||
artwork: normalizeText(rawProfile.artwork)
|
||||
}
|
||||
|
||||
if(kind !== 'map'){
|
||||
storedProfile.worldArchiveUrl = null
|
||||
storedProfile.worldDirectoryName = null
|
||||
}
|
||||
|
||||
if(kind !== 'server-pack'){
|
||||
storedProfile.serverBundleUrl = null
|
||||
storedProfile.serverDirectoryName = 'server'
|
||||
storedProfile.serverLaunchCommand = null
|
||||
storedProfile.serverWorkingDirectory = null
|
||||
storedProfile.serverPort = 25565
|
||||
storedProfile.tunnelCommand = null
|
||||
storedProfile.tunnelAddressRegex = null
|
||||
}
|
||||
|
||||
return storedProfile
|
||||
}
|
||||
|
||||
function normalizeProfile(rawProfile, sourceType = 'catalog'){
|
||||
const storedProfile = toStoredProfile(rawProfile)
|
||||
const launchIssues = []
|
||||
const hostIssues = []
|
||||
|
||||
if(storedProfile.distributionUrl == null){
|
||||
launchIssues.push('distribution URL이 필요합니다.')
|
||||
}
|
||||
|
||||
if(storedProfile.kind === 'map'){
|
||||
if(storedProfile.worldArchiveUrl == null){
|
||||
launchIssues.push('맵 ZIP 또는 로컬 월드 경로가 필요합니다.')
|
||||
}
|
||||
if(storedProfile.worldDirectoryName == null){
|
||||
launchIssues.push('월드 폴더 이름이 필요합니다.')
|
||||
}
|
||||
}
|
||||
|
||||
if(storedProfile.kind === 'server-pack' && storedProfile.serverBundleUrl == null){
|
||||
hostIssues.push('로컬 호스팅을 하려면 서버 번들 ZIP 또는 디렉터리 경로가 필요합니다.')
|
||||
}
|
||||
|
||||
const launchReady = launchIssues.length === 0
|
||||
const hostReady = storedProfile.kind === 'server-pack' ? hostIssues.length === 0 : false
|
||||
|
||||
return {
|
||||
...storedProfile,
|
||||
sourceType,
|
||||
isCustom: sourceType === 'custom',
|
||||
configured: launchReady,
|
||||
launchReady,
|
||||
launchIssues,
|
||||
hostReady,
|
||||
hostIssues
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCatalogFileSource(source){
|
||||
if(source.startsWith('file://')){
|
||||
return decodeURIComponent(source.substring('file://'.length))
|
||||
}
|
||||
return path.resolve(source)
|
||||
}
|
||||
|
||||
async function readCatalogSource(source){
|
||||
if(/^https?:\/\//i.test(source)){
|
||||
return got(source, {
|
||||
responseType: 'json'
|
||||
}).json()
|
||||
}
|
||||
|
||||
return fs.readJson(resolveCatalogFileSource(source))
|
||||
}
|
||||
|
||||
exports.getLocalCatalogPath = function(){
|
||||
return LOCAL_CATALOG_PATH
|
||||
}
|
||||
|
||||
exports.loadCatalog = async function(){
|
||||
const configuredSource = ConfigManager.getLibraryCatalogSource()
|
||||
const source = configuredSource != null && configuredSource.trim().length > 0 ? configuredSource.trim() : LOCAL_CATALOG_PATH
|
||||
let rawCatalog = {
|
||||
version: 1,
|
||||
profiles: []
|
||||
}
|
||||
let sourceError = null
|
||||
|
||||
try {
|
||||
rawCatalog = await readCatalogSource(source)
|
||||
} catch (error) {
|
||||
sourceError = error
|
||||
}
|
||||
|
||||
const rawProfiles = Array.isArray(rawCatalog.profiles) ? rawCatalog.profiles : []
|
||||
|
||||
return {
|
||||
version: rawCatalog.version ?? 1,
|
||||
source,
|
||||
sourceError,
|
||||
profiles: rawProfiles
|
||||
.filter((profile) => profile != null && typeof profile.id === 'string' && typeof profile.name === 'string')
|
||||
.map((profile) => normalizeProfile(profile, 'catalog'))
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'ko'))
|
||||
}
|
||||
}
|
||||
|
||||
exports.getInstalledProfiles = async function(){
|
||||
const catalog = await exports.loadCatalog()
|
||||
const installedProfiles = ConfigManager.getInstalledLibraryProfiles()
|
||||
|
||||
return installedProfiles.map((installedProfile) => {
|
||||
const latestProfile = catalog.profiles.find((profile) => profile.id === installedProfile.id)
|
||||
return latestProfile != null
|
||||
? {
|
||||
...installedProfile,
|
||||
...latestProfile,
|
||||
installedAt: installedProfile.installedAt
|
||||
}
|
||||
: installedProfile
|
||||
})
|
||||
}
|
||||
|
||||
exports.installProfile = async function(profileId){
|
||||
const catalog = await exports.loadCatalog()
|
||||
const profile = catalog.profiles.find((entry) => entry.id === profileId)
|
||||
|
||||
if(profile == null){
|
||||
throw new Error(`Unknown profile: ${profileId}`)
|
||||
}
|
||||
|
||||
const installedProfile = {
|
||||
...profile,
|
||||
installedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
ConfigManager.upsertInstalledLibraryProfile(installedProfile)
|
||||
|
||||
if(ConfigManager.getSelectedLibraryProfile() == null){
|
||||
ConfigManager.setSelectedLibraryProfile(profile.id)
|
||||
}
|
||||
|
||||
ConfigManager.save()
|
||||
return installedProfile
|
||||
}
|
||||
|
||||
exports.removeProfile = function(profileId){
|
||||
ConfigManager.removeInstalledLibraryProfile(profileId)
|
||||
ConfigManager.save()
|
||||
}
|
||||
|
||||
exports.selectProfile = function(profileId){
|
||||
ConfigManager.setSelectedLibraryProfile(profileId)
|
||||
ConfigManager.save()
|
||||
}
|
||||
|
||||
exports.getSelectedProfileId = function(){
|
||||
return ConfigManager.getSelectedLibraryProfile()
|
||||
}
|
||||
|
||||
exports.getSelectedProfileSync = function(){
|
||||
const selectedProfileId = ConfigManager.getSelectedLibraryProfile()
|
||||
if(selectedProfileId == null){
|
||||
return null
|
||||
}
|
||||
return ConfigManager.getInstalledLibraryProfile(selectedProfileId)
|
||||
}
|
||||
|
||||
exports.resolveServerAddress = function(profile){
|
||||
if(profile == null){
|
||||
return null
|
||||
}
|
||||
|
||||
const manualAddress = ConfigManager.getLibraryServerAddressOverride(profile.id)
|
||||
if(manualAddress != null && manualAddress.length > 0){
|
||||
return manualAddress
|
||||
}
|
||||
|
||||
if(typeof profile.defaultServerAddress === 'string' && profile.defaultServerAddress.length > 0){
|
||||
return profile.defaultServerAddress
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
exports.setServerAddressOverride = function(profileId, address){
|
||||
ConfigManager.setLibraryServerAddressOverride(profileId, address)
|
||||
ConfigManager.save()
|
||||
}
|
||||
|
||||
exports.applyConfiguredProfile = function(){
|
||||
const selectedProfile = exports.getSelectedProfileSync()
|
||||
const distributionUrl = selectedProfile?.distributionUrl ?? DEFAULT_REMOTE_DISTRO_URL
|
||||
setRemoteDistributionUrl(distributionUrl)
|
||||
return selectedProfile
|
||||
}
|
||||
1000
app/assets/js/configmanager.js
Normal file
52
app/assets/js/discordwrapper.js
Normal file
@@ -0,0 +1,52 @@
|
||||
// Work in progress
|
||||
const { LoggerUtil } = require('helios-core')
|
||||
|
||||
const logger = LoggerUtil.getLogger('DiscordWrapper')
|
||||
|
||||
const { Client } = require('discord-rpc-patch')
|
||||
|
||||
const Lang = require('./langloader')
|
||||
|
||||
let client
|
||||
let activity
|
||||
|
||||
exports.initRPC = function(genSettings, servSettings, initialDetails = Lang.queryJS('discord.waiting')){
|
||||
client = new Client({ transport: 'ipc' })
|
||||
|
||||
activity = {
|
||||
details: initialDetails,
|
||||
state: Lang.queryJS('discord.state', {shortId: servSettings.shortId}),
|
||||
largeImageKey: servSettings.largeImageKey,
|
||||
largeImageText: servSettings.largeImageText,
|
||||
smallImageKey: genSettings.smallImageKey,
|
||||
smallImageText: genSettings.smallImageText,
|
||||
startTimestamp: new Date().getTime(),
|
||||
instance: false
|
||||
}
|
||||
|
||||
client.on('ready', () => {
|
||||
logger.info('Discord RPC Connected')
|
||||
client.setActivity(activity)
|
||||
})
|
||||
|
||||
client.login({clientId: genSettings.clientId}).catch(error => {
|
||||
if(error.message.includes('ENOENT')) {
|
||||
logger.info('Unable to initialize Discord Rich Presence, no client detected.')
|
||||
} else {
|
||||
logger.info('Unable to initialize Discord Rich Presence: ' + error.message, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
exports.updateDetails = function(details){
|
||||
activity.details = details
|
||||
client.setActivity(activity)
|
||||
}
|
||||
|
||||
exports.shutdownRPC = function(){
|
||||
if(!client) return
|
||||
client.clearActivity()
|
||||
client.destroy()
|
||||
client = null
|
||||
activity = null
|
||||
}
|
||||
74
app/assets/js/distromanager.js
Normal file
@@ -0,0 +1,74 @@
|
||||
const { DistributionAPI } = require('helios-core/common')
|
||||
|
||||
const ConfigManager = require('./configmanager')
|
||||
|
||||
const DEFAULT_REMOTE_DISTRO_URL = 'https://cdn.mysticred.space/launcher/distribution.json'
|
||||
|
||||
let remoteDistroUrl = DEFAULT_REMOTE_DISTRO_URL
|
||||
let activeApi = createApi(remoteDistroUrl)
|
||||
|
||||
function createApi(url) {
|
||||
const api = new DistributionAPI(
|
||||
ConfigManager.getLauncherDirectory(),
|
||||
null,
|
||||
null,
|
||||
url,
|
||||
false
|
||||
)
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
function replaceApi(url) {
|
||||
const nextApi = createApi(url)
|
||||
|
||||
if(activeApi != null){
|
||||
if(typeof activeApi.commonDir !== 'undefined'){
|
||||
nextApi.commonDir = activeApi.commonDir
|
||||
}
|
||||
if(typeof activeApi.instanceDir !== 'undefined'){
|
||||
nextApi.instanceDir = activeApi.instanceDir
|
||||
}
|
||||
if(typeof activeApi.isDevMode === 'function' && activeApi.isDevMode()){
|
||||
nextApi.toggleDevMode(true)
|
||||
}
|
||||
}
|
||||
|
||||
activeApi = nextApi
|
||||
remoteDistroUrl = url
|
||||
}
|
||||
|
||||
exports.DEFAULT_REMOTE_DISTRO_URL = DEFAULT_REMOTE_DISTRO_URL
|
||||
|
||||
exports.getRemoteDistributionUrl = function() {
|
||||
return remoteDistroUrl
|
||||
}
|
||||
|
||||
exports.setRemoteDistributionUrl = function(url) {
|
||||
if(url != null && url.trim().length > 0 && url !== remoteDistroUrl){
|
||||
replaceApi(url)
|
||||
}
|
||||
return remoteDistroUrl
|
||||
}
|
||||
|
||||
exports.resetRemoteDistributionUrl = function() {
|
||||
replaceApi(DEFAULT_REMOTE_DISTRO_URL)
|
||||
return remoteDistroUrl
|
||||
}
|
||||
|
||||
exports.DistroAPI = new Proxy({}, {
|
||||
get(_target, prop) {
|
||||
const value = activeApi[prop]
|
||||
if(typeof value === 'function'){
|
||||
return value.bind(activeApi)
|
||||
}
|
||||
return value
|
||||
},
|
||||
set(_target, prop, value) {
|
||||
activeApi[prop] = value
|
||||
return true
|
||||
},
|
||||
has(_target, prop) {
|
||||
return prop in activeApi
|
||||
}
|
||||
})
|
||||
238
app/assets/js/dropinmodutil.js
Normal file
@@ -0,0 +1,238 @@
|
||||
const fs = require('fs-extra')
|
||||
const path = require('path')
|
||||
const { ipcRenderer, shell } = require('electron')
|
||||
const { SHELL_OPCODE } = require('./ipcconstants')
|
||||
|
||||
// Group #1: File Name (without .disabled, if any)
|
||||
// Group #2: File Extension (jar, zip, or litemod)
|
||||
// Group #3: If it is disabled (if string 'disabled' is present)
|
||||
const MOD_REGEX = /^(.+(jar|zip|litemod))(?:\.(disabled))?$/
|
||||
const DISABLED_EXT = '.disabled'
|
||||
|
||||
const SHADER_REGEX = /^(.+)\.zip$/
|
||||
const SHADER_OPTION = /shaderPack=(.+)/
|
||||
const SHADER_DIR = 'shaderpacks'
|
||||
const SHADER_CONFIG = 'optionsshaders.txt'
|
||||
|
||||
/**
|
||||
* Validate that the given directory exists. If not, it is
|
||||
* created.
|
||||
*
|
||||
* @param {string} modsDir The path to the mods directory.
|
||||
*/
|
||||
exports.validateDir = function(dir) {
|
||||
fs.ensureDirSync(dir)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan for drop-in mods in both the mods folder and version
|
||||
* safe mods folder.
|
||||
*
|
||||
* @param {string} modsDir The path to the mods directory.
|
||||
* @param {string} version The minecraft version of the server configuration.
|
||||
*
|
||||
* @returns {{fullName: string, name: string, ext: string, disabled: boolean}[]}
|
||||
* An array of objects storing metadata about each discovered mod.
|
||||
*/
|
||||
exports.scanForDropinMods = function(modsDir, version) {
|
||||
const modsDiscovered = []
|
||||
if(fs.existsSync(modsDir)){
|
||||
let modCandidates = fs.readdirSync(modsDir)
|
||||
let verCandidates = []
|
||||
const versionDir = path.join(modsDir, version)
|
||||
if(fs.existsSync(versionDir)){
|
||||
verCandidates = fs.readdirSync(versionDir)
|
||||
}
|
||||
for(let file of modCandidates){
|
||||
const match = MOD_REGEX.exec(file)
|
||||
if(match != null){
|
||||
modsDiscovered.push({
|
||||
fullName: match[0],
|
||||
name: match[1],
|
||||
ext: match[2],
|
||||
disabled: match[3] != null
|
||||
})
|
||||
}
|
||||
}
|
||||
for(let file of verCandidates){
|
||||
const match = MOD_REGEX.exec(file)
|
||||
if(match != null){
|
||||
modsDiscovered.push({
|
||||
fullName: path.join(version, match[0]),
|
||||
name: match[1],
|
||||
ext: match[2],
|
||||
disabled: match[3] != null
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return modsDiscovered
|
||||
}
|
||||
|
||||
/**
|
||||
* Add dropin mods.
|
||||
*
|
||||
* @param {FileList} files The files to add.
|
||||
* @param {string} modsDir The path to the mods directory.
|
||||
*/
|
||||
exports.addDropinMods = function(files, modsdir) {
|
||||
|
||||
exports.validateDir(modsdir)
|
||||
|
||||
for(let f of files) {
|
||||
if(MOD_REGEX.exec(f.name) != null) {
|
||||
fs.moveSync(f.path, path.join(modsdir, f.name))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a drop-in mod from the file system.
|
||||
*
|
||||
* @param {string} modsDir The path to the mods directory.
|
||||
* @param {string} fullName The fullName of the discovered mod to delete.
|
||||
*
|
||||
* @returns {Promise.<boolean>} True if the mod was deleted, otherwise false.
|
||||
*/
|
||||
exports.deleteDropinMod = async function(modsDir, fullName){
|
||||
|
||||
const res = await ipcRenderer.invoke(SHELL_OPCODE.TRASH_ITEM, path.join(modsDir, fullName))
|
||||
|
||||
if(!res.result) {
|
||||
shell.beep()
|
||||
console.error('Error deleting drop-in mod.', res.error)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a discovered mod on or off. This is achieved by either
|
||||
* adding or disabling the .disabled extension to the local file.
|
||||
*
|
||||
* @param {string} modsDir The path to the mods directory.
|
||||
* @param {string} fullName The fullName of the discovered mod to toggle.
|
||||
* @param {boolean} enable Whether to toggle on or off the mod.
|
||||
*
|
||||
* @returns {Promise.<void>} A promise which resolves when the mod has
|
||||
* been toggled. If an IO error occurs the promise will be rejected.
|
||||
*/
|
||||
exports.toggleDropinMod = function(modsDir, fullName, enable){
|
||||
return new Promise((resolve, reject) => {
|
||||
const oldPath = path.join(modsDir, fullName)
|
||||
const newPath = path.join(modsDir, enable ? fullName.substring(0, fullName.indexOf(DISABLED_EXT)) : fullName + DISABLED_EXT)
|
||||
|
||||
fs.rename(oldPath, newPath, (err) => {
|
||||
if(err){
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a drop-in mod is enabled.
|
||||
*
|
||||
* @param {string} fullName The fullName of the discovered mod to toggle.
|
||||
* @returns {boolean} True if the mod is enabled, otherwise false.
|
||||
*/
|
||||
exports.isDropinModEnabled = function(fullName){
|
||||
return !fullName.endsWith(DISABLED_EXT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan for shaderpacks inside the shaderpacks folder.
|
||||
*
|
||||
* @param {string} instanceDir The path to the server instance directory.
|
||||
*
|
||||
* @returns {{fullName: string, name: string}[]}
|
||||
* An array of objects storing metadata about each discovered shaderpack.
|
||||
*/
|
||||
exports.scanForShaderpacks = function(instanceDir){
|
||||
const shaderDir = path.join(instanceDir, SHADER_DIR)
|
||||
const packsDiscovered = [{
|
||||
fullName: 'OFF',
|
||||
name: 'Off (Default)'
|
||||
}]
|
||||
if(fs.existsSync(shaderDir)){
|
||||
let modCandidates = fs.readdirSync(shaderDir)
|
||||
for(let file of modCandidates){
|
||||
const match = SHADER_REGEX.exec(file)
|
||||
if(match != null){
|
||||
packsDiscovered.push({
|
||||
fullName: match[0],
|
||||
name: match[1]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return packsDiscovered
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the optionsshaders.txt file to locate the current
|
||||
* enabled pack. If the file does not exist, OFF is returned.
|
||||
*
|
||||
* @param {string} instanceDir The path to the server instance directory.
|
||||
*
|
||||
* @returns {string} The file name of the enabled shaderpack.
|
||||
*/
|
||||
exports.getEnabledShaderpack = function(instanceDir){
|
||||
exports.validateDir(instanceDir)
|
||||
|
||||
const optionsShaders = path.join(instanceDir, SHADER_CONFIG)
|
||||
if(fs.existsSync(optionsShaders)){
|
||||
const buf = fs.readFileSync(optionsShaders, {encoding: 'utf-8'})
|
||||
const match = SHADER_OPTION.exec(buf)
|
||||
if(match != null){
|
||||
return match[1]
|
||||
} else {
|
||||
console.warn('WARNING: Shaderpack regex failed.')
|
||||
}
|
||||
}
|
||||
return 'OFF'
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the enabled shaderpack.
|
||||
*
|
||||
* @param {string} instanceDir The path to the server instance directory.
|
||||
* @param {string} pack the file name of the shaderpack.
|
||||
*/
|
||||
exports.setEnabledShaderpack = function(instanceDir, pack){
|
||||
exports.validateDir(instanceDir)
|
||||
|
||||
const optionsShaders = path.join(instanceDir, SHADER_CONFIG)
|
||||
let buf
|
||||
if(fs.existsSync(optionsShaders)){
|
||||
buf = fs.readFileSync(optionsShaders, {encoding: 'utf-8'})
|
||||
buf = buf.replace(SHADER_OPTION, `shaderPack=${pack}`)
|
||||
} else {
|
||||
buf = `shaderPack=${pack}`
|
||||
}
|
||||
fs.writeFileSync(optionsShaders, buf, {encoding: 'utf-8'})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add shaderpacks.
|
||||
*
|
||||
* @param {FileList} files The files to add.
|
||||
* @param {string} instanceDir The path to the server instance directory.
|
||||
*/
|
||||
exports.addShaderpacks = function(files, instanceDir) {
|
||||
|
||||
const p = path.join(instanceDir, SHADER_DIR)
|
||||
|
||||
exports.validateDir(p)
|
||||
|
||||
for(let f of files) {
|
||||
if(SHADER_REGEX.exec(f.name) != null) {
|
||||
fs.moveSync(f.path, path.join(p, f.name))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
28
app/assets/js/ipcconstants.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// NOTE FOR THIRD-PARTY
|
||||
// REPLACE THIS CLIENT ID WITH YOUR APPLICATION ID.
|
||||
// SEE https://github.com/dscalzi/HeliosLauncher/blob/master/docs/MicrosoftAuth.md
|
||||
exports.AZURE_CLIENT_ID = '8f387cc5-3138-4699-89a9-f97948e3927e'
|
||||
// SEE NOTE ABOVE.
|
||||
|
||||
|
||||
// Opcodes
|
||||
exports.MSFT_OPCODE = {
|
||||
OPEN_LOGIN: 'MSFT_AUTH_OPEN_LOGIN',
|
||||
OPEN_LOGOUT: 'MSFT_AUTH_OPEN_LOGOUT',
|
||||
REPLY_LOGIN: 'MSFT_AUTH_REPLY_LOGIN',
|
||||
REPLY_LOGOUT: 'MSFT_AUTH_REPLY_LOGOUT'
|
||||
}
|
||||
// Reply types for REPLY opcode.
|
||||
exports.MSFT_REPLY_TYPE = {
|
||||
SUCCESS: 'MSFT_AUTH_REPLY_SUCCESS',
|
||||
ERROR: 'MSFT_AUTH_REPLY_ERROR'
|
||||
}
|
||||
// Error types for ERROR reply.
|
||||
exports.MSFT_ERROR = {
|
||||
ALREADY_OPEN: 'MSFT_AUTH_ERR_ALREADY_OPEN',
|
||||
NOT_FINISHED: 'MSFT_AUTH_ERR_NOT_FINISHED'
|
||||
}
|
||||
|
||||
exports.SHELL_OPCODE = {
|
||||
TRASH_ITEM: 'TRASH_ITEM'
|
||||
}
|
||||
5
app/assets/js/isdev.js
Normal file
@@ -0,0 +1,5 @@
|
||||
'use strict'
|
||||
const getFromEnv = parseInt(process.env.ELECTRON_IS_DEV, 10) === 1
|
||||
const isEnvSet = 'ELECTRON_IS_DEV' in process.env
|
||||
|
||||
module.exports = isEnvSet ? getFromEnv : (process.defaultApp || /node_modules[\\/]electron[\\/]/.test(process.execPath))
|
||||
43
app/assets/js/langloader.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const fs = require('fs-extra')
|
||||
const path = require('path')
|
||||
const toml = require('toml')
|
||||
const merge = require('lodash.merge')
|
||||
|
||||
let lang
|
||||
|
||||
exports.loadLanguage = function(id){
|
||||
lang = merge(lang || {}, toml.parse(fs.readFileSync(path.join(__dirname, '..', 'lang', `${id}.toml`))) || {})
|
||||
}
|
||||
|
||||
exports.query = function(id, placeHolders){
|
||||
let query = id.split('.')
|
||||
let res = lang
|
||||
for(let q of query){
|
||||
res = res[q]
|
||||
}
|
||||
let text = res === lang ? '' : res
|
||||
if (placeHolders) {
|
||||
Object.entries(placeHolders).forEach(([key, value]) => {
|
||||
text = text.replace(`{${key}}`, value)
|
||||
})
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
exports.queryJS = function(id, placeHolders){
|
||||
return exports.query(`js.${id}`, placeHolders)
|
||||
}
|
||||
|
||||
exports.queryEJS = function(id, placeHolders){
|
||||
return exports.query(`ejs.${id}`, placeHolders)
|
||||
}
|
||||
|
||||
exports.setupLanguage = function(){
|
||||
// Load Language Files
|
||||
exports.loadLanguage('en_US')
|
||||
// Uncomment this when translations are ready
|
||||
exports.loadLanguage('ko_KR')
|
||||
|
||||
// Load Custom Language File for Launcher Customizer
|
||||
exports.loadLanguage('_custom')
|
||||
}
|
||||
69
app/assets/js/preloader.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const {ipcRenderer} = require('electron')
|
||||
const fs = require('fs-extra')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
|
||||
const ConfigManager = require('./configmanager')
|
||||
const CatalogManager = require('./catalogmanager')
|
||||
const { DistroAPI } = require('./distromanager')
|
||||
const LangLoader = require('./langloader')
|
||||
const { LoggerUtil } = require('helios-core')
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { HeliosDistribution } = require('helios-core/common')
|
||||
|
||||
const logger = LoggerUtil.getLogger('Preloader')
|
||||
|
||||
logger.info('Loading..')
|
||||
|
||||
// Load ConfigManager
|
||||
ConfigManager.load()
|
||||
CatalogManager.applyConfiguredProfile()
|
||||
|
||||
// Yuck!
|
||||
// TODO Fix this
|
||||
DistroAPI['commonDir'] = ConfigManager.getCommonDirectory()
|
||||
DistroAPI['instanceDir'] = ConfigManager.getInstanceDirectory()
|
||||
|
||||
// Load Strings
|
||||
LangLoader.setupLanguage()
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HeliosDistribution} data
|
||||
*/
|
||||
function onDistroLoad(data){
|
||||
if(data != null){
|
||||
|
||||
// Resolve the selected server if its value has yet to be set.
|
||||
if(ConfigManager.getSelectedServer() == null || data.getServerById(ConfigManager.getSelectedServer()) == null){
|
||||
logger.info('Determining default selected server..')
|
||||
ConfigManager.setSelectedServer(data.getMainServer().rawServer.id)
|
||||
ConfigManager.save()
|
||||
}
|
||||
}
|
||||
ipcRenderer.send('distributionIndexDone', data != null)
|
||||
}
|
||||
|
||||
// Ensure Distribution is downloaded and cached.
|
||||
DistroAPI.getDistribution()
|
||||
.then(heliosDistro => {
|
||||
logger.info('Loaded distribution index.')
|
||||
|
||||
onDistroLoad(heliosDistro)
|
||||
})
|
||||
.catch(err => {
|
||||
logger.info('Failed to load an older version of the distribution index.')
|
||||
logger.info('Application cannot run.')
|
||||
logger.error(err)
|
||||
|
||||
onDistroLoad(null)
|
||||
})
|
||||
|
||||
// Clean up temp dir incase previous launches ended unexpectedly.
|
||||
fs.remove(path.join(os.tmpdir(), ConfigManager.getTempNativeFolder()), (err) => {
|
||||
if(err){
|
||||
logger.warn('Error while cleaning natives directory', err)
|
||||
} else {
|
||||
logger.info('Cleaned natives directory.')
|
||||
}
|
||||
})
|
||||
952
app/assets/js/processbuilder.js
Normal file
@@ -0,0 +1,952 @@
|
||||
const AdmZip = require('adm-zip')
|
||||
const child_process = require('child_process')
|
||||
const crypto = require('crypto')
|
||||
const fs = require('fs-extra')
|
||||
const { LoggerUtil } = require('helios-core')
|
||||
const { getMojangOS, isLibraryCompatible, mcVersionAtLeast } = require('helios-core/common')
|
||||
const { Type } = require('helios-distribution-types')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
|
||||
const ConfigManager = require('./configmanager')
|
||||
|
||||
const logger = LoggerUtil.getLogger('ProcessBuilder')
|
||||
|
||||
function resolveServerAddressOverride(address, fallbackPort) {
|
||||
if(address == null || address.trim().length === 0){
|
||||
return null
|
||||
}
|
||||
|
||||
const trimmed = address.trim()
|
||||
|
||||
if(trimmed.startsWith('[')){
|
||||
const closingIndex = trimmed.indexOf(']')
|
||||
if(closingIndex !== -1){
|
||||
const host = trimmed.substring(1, closingIndex)
|
||||
const remainder = trimmed.substring(closingIndex + 1)
|
||||
if(remainder.startsWith(':')){
|
||||
return {
|
||||
hostname: host,
|
||||
port: remainder.substring(1) || fallbackPort
|
||||
}
|
||||
}
|
||||
return {
|
||||
hostname: host,
|
||||
port: fallbackPort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const splitIndex = trimmed.lastIndexOf(':')
|
||||
if(splitIndex > -1 && trimmed.indexOf(':') === splitIndex){
|
||||
return {
|
||||
hostname: trimmed.substring(0, splitIndex),
|
||||
port: trimmed.substring(splitIndex + 1) || fallbackPort
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: trimmed,
|
||||
port: fallbackPort
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Only forge and fabric are top level mod loaders.
|
||||
*
|
||||
* Forge 1.13+ launch logic is similar to fabrics, for now using usingFabricLoader flag to
|
||||
* change minor details when needed.
|
||||
*
|
||||
* Rewrite of this module may be needed in the future.
|
||||
*/
|
||||
class ProcessBuilder {
|
||||
|
||||
constructor(distroServer, vanillaManifest, modManifest, authUser, launcherVersion){
|
||||
this.gameDir = path.join(ConfigManager.getInstanceDirectory(), distroServer.rawServer.id)
|
||||
this.commonDir = ConfigManager.getCommonDirectory()
|
||||
this.server = distroServer
|
||||
this.vanillaManifest = vanillaManifest
|
||||
this.modManifest = modManifest
|
||||
this.authUser = authUser
|
||||
this.launcherVersion = launcherVersion
|
||||
this.forgeModListFile = path.join(this.gameDir, 'forgeMods.list') // 1.13+
|
||||
this.fmlDir = path.join(this.gameDir, 'forgeModList.json')
|
||||
this.llDir = path.join(this.gameDir, 'liteloaderModList.json')
|
||||
this.libPath = path.join(this.commonDir, 'libraries')
|
||||
|
||||
this.usingLiteLoader = false
|
||||
this.usingFabricLoader = false
|
||||
this.llPath = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Convienence method to run the functions typically used to build a process.
|
||||
*/
|
||||
build(){
|
||||
fs.ensureDirSync(this.gameDir)
|
||||
const tempNativePath = path.join(os.tmpdir(), ConfigManager.getTempNativeFolder(), crypto.pseudoRandomBytes(16).toString('hex'))
|
||||
process.throwDeprecation = true
|
||||
this.setupLiteLoader()
|
||||
logger.info('Using liteloader:', this.usingLiteLoader)
|
||||
this.usingFabricLoader = this.server.modules.some(mdl => mdl.rawModule.type === Type.Fabric)
|
||||
logger.info('Using fabric loader:', this.usingFabricLoader)
|
||||
const modObj = this.resolveModConfiguration(ConfigManager.getModConfiguration(this.server.rawServer.id).mods, this.server.modules)
|
||||
|
||||
// Mod list below 1.13
|
||||
// Fabric only supports 1.14+
|
||||
if(!mcVersionAtLeast('1.13', this.server.rawServer.minecraftVersion)){
|
||||
this.constructJSONModList('forge', modObj.fMods, true)
|
||||
if(this.usingLiteLoader){
|
||||
this.constructJSONModList('liteloader', modObj.lMods, true)
|
||||
}
|
||||
}
|
||||
|
||||
const uberModArr = modObj.fMods.concat(modObj.lMods)
|
||||
let args = this.constructJVMArguments(uberModArr, tempNativePath)
|
||||
|
||||
if(mcVersionAtLeast('1.13', this.server.rawServer.minecraftVersion)){
|
||||
//args = args.concat(this.constructModArguments(modObj.fMods))
|
||||
args = args.concat(this.constructModList(modObj.fMods))
|
||||
}
|
||||
|
||||
// Hide access token
|
||||
const loggableArgs = [...args]
|
||||
loggableArgs[loggableArgs.findIndex(x => x === this.authUser.accessToken)] = '**********'
|
||||
|
||||
logger.info('Launch Arguments:', loggableArgs)
|
||||
|
||||
const child = child_process.spawn(ConfigManager.getJavaExecutable(this.server.rawServer.id), args, {
|
||||
cwd: this.gameDir,
|
||||
detached: ConfigManager.getLaunchDetached()
|
||||
})
|
||||
|
||||
if(ConfigManager.getLaunchDetached()){
|
||||
child.unref()
|
||||
}
|
||||
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
data.trim().split('\n').forEach(x => console.log(`\x1b[32m[Minecraft]\x1b[0m ${x}`))
|
||||
|
||||
})
|
||||
child.stderr.on('data', (data) => {
|
||||
data.trim().split('\n').forEach(x => console.log(`\x1b[31m[Minecraft]\x1b[0m ${x}`))
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
logger.info('Exited with code', code)
|
||||
fs.remove(tempNativePath, (err) => {
|
||||
if(err){
|
||||
logger.warn('Error while deleting temp dir', err)
|
||||
} else {
|
||||
logger.info('Temp dir deleted successfully.')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return child
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the platform specific classpath separator. On windows, this is a semicolon.
|
||||
* On Unix, this is a colon.
|
||||
*
|
||||
* @returns {string} The classpath separator for the current operating system.
|
||||
*/
|
||||
static getClasspathSeparator() {
|
||||
return process.platform === 'win32' ? ';' : ':'
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an optional mod is enabled from its configuration value. If the
|
||||
* configuration value is null, the required object will be used to
|
||||
* determine if it is enabled.
|
||||
*
|
||||
* A mod is enabled if:
|
||||
* * The configuration is not null and one of the following:
|
||||
* * The configuration is a boolean and true.
|
||||
* * The configuration is an object and its 'value' property is true.
|
||||
* * The configuration is null and one of the following:
|
||||
* * The required object is null.
|
||||
* * The required object's 'def' property is null or true.
|
||||
*
|
||||
* @param {Object | boolean} modCfg The mod configuration object.
|
||||
* @param {Object} required Optional. The required object from the mod's distro declaration.
|
||||
* @returns {boolean} True if the mod is enabled, false otherwise.
|
||||
*/
|
||||
static isModEnabled(modCfg, required = null){
|
||||
return modCfg != null ? ((typeof modCfg === 'boolean' && modCfg) || (typeof modCfg === 'object' && (typeof modCfg.value !== 'undefined' ? modCfg.value : true))) : required != null ? required.def : true
|
||||
}
|
||||
|
||||
/**
|
||||
* Function which performs a preliminary scan of the top level
|
||||
* mods. If liteloader is present here, we setup the special liteloader
|
||||
* launch options. Note that liteloader is only allowed as a top level
|
||||
* mod. It must not be declared as a submodule.
|
||||
*/
|
||||
setupLiteLoader(){
|
||||
for(let ll of this.server.modules){
|
||||
if(ll.rawModule.type === Type.LiteLoader){
|
||||
if(!ll.getRequired().value){
|
||||
const modCfg = ConfigManager.getModConfiguration(this.server.rawServer.id).mods
|
||||
if(ProcessBuilder.isModEnabled(modCfg[ll.getVersionlessMavenIdentifier()], ll.getRequired())){
|
||||
if(fs.existsSync(ll.getPath())){
|
||||
this.usingLiteLoader = true
|
||||
this.llPath = ll.getPath()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if(fs.existsSync(ll.getPath())){
|
||||
this.usingLiteLoader = true
|
||||
this.llPath = ll.getPath()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an array of all enabled mods. These mods will be constructed into
|
||||
* a mod list format and enabled at launch.
|
||||
*
|
||||
* @param {Object} modCfg The mod configuration object.
|
||||
* @param {Array.<Object>} mdls An array of modules to parse.
|
||||
* @returns {{fMods: Array.<Object>, lMods: Array.<Object>}} An object which contains
|
||||
* a list of enabled forge mods and litemods.
|
||||
*/
|
||||
resolveModConfiguration(modCfg, mdls){
|
||||
let fMods = []
|
||||
let lMods = []
|
||||
|
||||
for(let mdl of mdls){
|
||||
const type = mdl.rawModule.type
|
||||
if(type === Type.ForgeMod || type === Type.LiteMod || type === Type.LiteLoader || type === Type.FabricMod){
|
||||
const o = !mdl.getRequired().value
|
||||
const e = ProcessBuilder.isModEnabled(modCfg[mdl.getVersionlessMavenIdentifier()], mdl.getRequired())
|
||||
if(!o || (o && e)){
|
||||
if(mdl.subModules.length > 0){
|
||||
const v = this.resolveModConfiguration(modCfg[mdl.getVersionlessMavenIdentifier()].mods, mdl.subModules)
|
||||
fMods = fMods.concat(v.fMods)
|
||||
lMods = lMods.concat(v.lMods)
|
||||
if(type === Type.LiteLoader){
|
||||
continue
|
||||
}
|
||||
}
|
||||
if(type === Type.ForgeMod || type === Type.FabricMod){
|
||||
fMods.push(mdl)
|
||||
} else {
|
||||
lMods.push(mdl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fMods,
|
||||
lMods
|
||||
}
|
||||
}
|
||||
|
||||
_lteMinorVersion(version) {
|
||||
return Number(this.modManifest.id.split('-')[0].split('.')[1]) <= Number(version)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test to see if this version of forge requires the absolute: prefix
|
||||
* on the modListFile repository field.
|
||||
*/
|
||||
_requiresAbsolute(){
|
||||
try {
|
||||
if(this._lteMinorVersion(9)) {
|
||||
return false
|
||||
}
|
||||
const ver = this.modManifest.id.split('-')[2]
|
||||
const pts = ver.split('.')
|
||||
const min = [14, 23, 3, 2655]
|
||||
for(let i=0; i<pts.length; i++){
|
||||
const parsed = Number.parseInt(pts[i])
|
||||
if(parsed < min[i]){
|
||||
return false
|
||||
} else if(parsed > min[i]){
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// We know old forge versions follow this format.
|
||||
// Error must be caused by newer version.
|
||||
}
|
||||
|
||||
// Equal or errored
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a mod list json object.
|
||||
*
|
||||
* @param {'forge' | 'liteloader'} type The mod list type to construct.
|
||||
* @param {Array.<Object>} mods An array of mods to add to the mod list.
|
||||
* @param {boolean} save Optional. Whether or not we should save the mod list file.
|
||||
*/
|
||||
constructJSONModList(type, mods, save = false){
|
||||
const modList = {
|
||||
repositoryRoot: ((type === 'forge' && this._requiresAbsolute()) ? 'absolute:' : '') + path.join(this.commonDir, 'modstore')
|
||||
}
|
||||
|
||||
const ids = []
|
||||
if(type === 'forge'){
|
||||
for(let mod of mods){
|
||||
ids.push(mod.getExtensionlessMavenIdentifier())
|
||||
}
|
||||
} else {
|
||||
for(let mod of mods){
|
||||
ids.push(mod.getMavenIdentifier())
|
||||
}
|
||||
}
|
||||
modList.modRef = ids
|
||||
|
||||
if(save){
|
||||
const json = JSON.stringify(modList, null, 4)
|
||||
fs.writeFileSync(type === 'forge' ? this.fmlDir : this.llDir, json, 'UTF-8')
|
||||
}
|
||||
|
||||
return modList
|
||||
}
|
||||
|
||||
// /**
|
||||
// * Construct the mod argument list for forge 1.13
|
||||
// *
|
||||
// * @param {Array.<Object>} mods An array of mods to add to the mod list.
|
||||
// */
|
||||
// constructModArguments(mods){
|
||||
// const argStr = mods.map(mod => {
|
||||
// return mod.getExtensionlessMavenIdentifier()
|
||||
// }).join(',')
|
||||
|
||||
// if(argStr){
|
||||
// return [
|
||||
// '--fml.mavenRoots',
|
||||
// path.join('..', '..', 'common', 'modstore'),
|
||||
// '--fml.mods',
|
||||
// argStr
|
||||
// ]
|
||||
// } else {
|
||||
// return []
|
||||
// }
|
||||
|
||||
// }
|
||||
|
||||
/**
|
||||
* Construct the mod argument list for forge 1.13 and Fabric
|
||||
*
|
||||
* @param {Array.<Object>} mods An array of mods to add to the mod list.
|
||||
*/
|
||||
constructModList(mods) {
|
||||
const writeBuffer = mods.map(mod => {
|
||||
return this.usingFabricLoader ? mod.getPath() : mod.getExtensionlessMavenIdentifier()
|
||||
}).join('\n')
|
||||
|
||||
if(writeBuffer) {
|
||||
fs.writeFileSync(this.forgeModListFile, writeBuffer, 'UTF-8')
|
||||
return this.usingFabricLoader ? [
|
||||
'--fabric.addMods',
|
||||
`@${this.forgeModListFile}`
|
||||
] : [
|
||||
'--fml.mavenRoots',
|
||||
path.join('..', '..', 'common', 'modstore'),
|
||||
'--fml.modLists',
|
||||
this.forgeModListFile
|
||||
]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
_processAutoConnectArg(args){
|
||||
const selectedProfileId = ConfigManager.getSelectedLibraryProfile()
|
||||
const quickPlayWorld = selectedProfileId != null
|
||||
? ConfigManager.getLibraryQuickPlayWorld(selectedProfileId)
|
||||
: null
|
||||
|
||||
if(quickPlayWorld){
|
||||
if(mcVersionAtLeast('1.20', this.server.rawServer.minecraftVersion)){
|
||||
args.push('--quickPlaySingleplayer')
|
||||
args.push(quickPlayWorld)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if(ConfigManager.getAutoConnect() && this.server.rawServer.autoconnect){
|
||||
const serverAddressOverride = selectedProfileId != null
|
||||
? resolveServerAddressOverride(
|
||||
ConfigManager.getLibraryServerAddressOverride(selectedProfileId),
|
||||
this.server.port
|
||||
)
|
||||
: null
|
||||
const hostname = serverAddressOverride?.hostname ?? this.server.hostname
|
||||
const port = serverAddressOverride?.port ?? this.server.port
|
||||
if(mcVersionAtLeast('1.20', this.server.rawServer.minecraftVersion)){
|
||||
args.push('--quickPlayMultiplayer')
|
||||
args.push(`${hostname}:${port}`)
|
||||
} else {
|
||||
args.push('--server')
|
||||
args.push(hostname)
|
||||
args.push('--port')
|
||||
args.push(port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct the argument array that will be passed to the JVM process.
|
||||
*
|
||||
* @param {Array.<Object>} mods An array of enabled mods which will be launched with this process.
|
||||
* @param {string} tempNativePath The path to store the native libraries.
|
||||
* @returns {Array.<string>} An array containing the full JVM arguments for this process.
|
||||
*/
|
||||
constructJVMArguments(mods, tempNativePath){
|
||||
if(mcVersionAtLeast('1.13', this.server.rawServer.minecraftVersion)){
|
||||
return this._constructJVMArguments113(mods, tempNativePath)
|
||||
} else {
|
||||
return this._constructJVMArguments112(mods, tempNativePath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct the argument array that will be passed to the JVM process.
|
||||
* This function is for 1.12 and below.
|
||||
*
|
||||
* @param {Array.<Object>} mods An array of enabled mods which will be launched with this process.
|
||||
* @param {string} tempNativePath The path to store the native libraries.
|
||||
* @returns {Array.<string>} An array containing the full JVM arguments for this process.
|
||||
*/
|
||||
_constructJVMArguments112(mods, tempNativePath){
|
||||
|
||||
let args = []
|
||||
|
||||
// Classpath Argument
|
||||
args.push('-cp')
|
||||
args.push(this.classpathArg(mods, tempNativePath).join(ProcessBuilder.getClasspathSeparator()))
|
||||
|
||||
// Java Arguments
|
||||
if(process.platform === 'darwin'){
|
||||
args.push('-Xdock:name=MRSLauncher')
|
||||
args.push('-Xdock:icon=' + path.join(__dirname, '..', 'images', 'minecraft.icns'))
|
||||
}
|
||||
args.push('-Xmx' + ConfigManager.getMaxRAM(this.server.rawServer.id))
|
||||
args.push('-Xms' + ConfigManager.getMinRAM(this.server.rawServer.id))
|
||||
args = args.concat(ConfigManager.getJVMOptions(this.server.rawServer.id))
|
||||
args.push('-Djava.library.path=' + tempNativePath)
|
||||
|
||||
// Main Java Class
|
||||
args.push(this.modManifest.mainClass)
|
||||
|
||||
// Forge Arguments
|
||||
args = args.concat(this._resolveForgeArgs())
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct the argument array that will be passed to the JVM process.
|
||||
* This function is for 1.13+
|
||||
*
|
||||
* Note: Required Libs https://github.com/MinecraftForge/MinecraftForge/blob/af98088d04186452cb364280340124dfd4766a5c/src/fmllauncher/java/net/minecraftforge/fml/loading/LibraryFinder.java#L82
|
||||
*
|
||||
* @param {Array.<Object>} mods An array of enabled mods which will be launched with this process.
|
||||
* @param {string} tempNativePath The path to store the native libraries.
|
||||
* @returns {Array.<string>} An array containing the full JVM arguments for this process.
|
||||
*/
|
||||
_constructJVMArguments113(mods, tempNativePath){
|
||||
|
||||
const argDiscovery = /\${*(.*)}/
|
||||
|
||||
// JVM Arguments First
|
||||
let args = this.vanillaManifest.arguments.jvm
|
||||
|
||||
// Debug securejarhandler
|
||||
// args.push('-Dbsl.debug=true')
|
||||
|
||||
if(this.modManifest.arguments.jvm != null) {
|
||||
for(const argStr of this.modManifest.arguments.jvm) {
|
||||
args.push(argStr
|
||||
.replaceAll('${library_directory}', this.libPath)
|
||||
.replaceAll('${classpath_separator}', ProcessBuilder.getClasspathSeparator())
|
||||
.replaceAll('${version_name}', this.modManifest.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
//args.push('-Dlog4j.configurationFile=D:\\WesterosCraft\\game\\common\\assets\\log_configs\\client-1.12.xml')
|
||||
|
||||
// Java Arguments
|
||||
if(process.platform === 'darwin'){
|
||||
args.push('-Xdock:name=MRSLauncher')
|
||||
args.push('-Xdock:icon=' + path.join(__dirname, '..', 'images', 'minecraft.icns'))
|
||||
}
|
||||
args.push('-Xmx' + ConfigManager.getMaxRAM(this.server.rawServer.id))
|
||||
args.push('-Xms' + ConfigManager.getMinRAM(this.server.rawServer.id))
|
||||
args = args.concat(ConfigManager.getJVMOptions(this.server.rawServer.id))
|
||||
|
||||
// Main Java Class
|
||||
args.push(this.modManifest.mainClass)
|
||||
|
||||
// Vanilla Arguments
|
||||
args = args.concat(this.vanillaManifest.arguments.game)
|
||||
|
||||
for(let i=0; i<args.length; i++){
|
||||
if(typeof args[i] === 'object' && args[i].rules != null){
|
||||
|
||||
let checksum = 0
|
||||
for(let rule of args[i].rules){
|
||||
if(rule.os != null){
|
||||
if(rule.os.name === getMojangOS()
|
||||
&& (rule.os.version == null || new RegExp(rule.os.version).test(os.release))){
|
||||
if(rule.action === 'allow'){
|
||||
checksum++
|
||||
}
|
||||
} else {
|
||||
if(rule.action === 'disallow'){
|
||||
checksum++
|
||||
}
|
||||
}
|
||||
} else if(rule.features != null){
|
||||
// We don't have many 'features' in the index at the moment.
|
||||
// This should be fine for a while.
|
||||
if(rule.features.has_custom_resolution != null && rule.features.has_custom_resolution === true){
|
||||
if(ConfigManager.getFullscreen()){
|
||||
args[i].value = [
|
||||
'--fullscreen',
|
||||
'true'
|
||||
]
|
||||
}
|
||||
checksum++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO splice not push
|
||||
if(checksum === args[i].rules.length){
|
||||
if(typeof args[i].value === 'string'){
|
||||
args[i] = args[i].value
|
||||
} else if(typeof args[i].value === 'object'){
|
||||
//args = args.concat(args[i].value)
|
||||
args.splice(i, 1, ...args[i].value)
|
||||
}
|
||||
|
||||
// Decrement i to reprocess the resolved value
|
||||
i--
|
||||
} else {
|
||||
args[i] = null
|
||||
}
|
||||
|
||||
} else if(typeof args[i] === 'string'){
|
||||
if(argDiscovery.test(args[i])){
|
||||
const identifier = args[i].match(argDiscovery)[1]
|
||||
let val = null
|
||||
switch(identifier){
|
||||
case 'auth_player_name':
|
||||
val = this.authUser.displayName.trim()
|
||||
break
|
||||
case 'version_name':
|
||||
//val = vanillaManifest.id
|
||||
val = this.server.rawServer.id
|
||||
break
|
||||
case 'game_directory':
|
||||
val = this.gameDir
|
||||
break
|
||||
case 'assets_root':
|
||||
val = path.join(this.commonDir, 'assets')
|
||||
break
|
||||
case 'assets_index_name':
|
||||
val = this.vanillaManifest.assets
|
||||
break
|
||||
case 'auth_uuid':
|
||||
val = this.authUser.uuid.trim()
|
||||
break
|
||||
case 'auth_access_token':
|
||||
val = this.authUser.accessToken
|
||||
break
|
||||
case 'user_type':
|
||||
val = this.authUser.type === 'microsoft' ? 'msa' : 'mojang'
|
||||
break
|
||||
case 'version_type':
|
||||
val = this.vanillaManifest.type
|
||||
break
|
||||
case 'resolution_width':
|
||||
val = ConfigManager.getGameWidth()
|
||||
break
|
||||
case 'resolution_height':
|
||||
val = ConfigManager.getGameHeight()
|
||||
break
|
||||
case 'natives_directory':
|
||||
val = args[i].replace(argDiscovery, tempNativePath)
|
||||
break
|
||||
case 'launcher_name':
|
||||
val = args[i].replace(argDiscovery, 'MRS-Launcher')
|
||||
break
|
||||
case 'launcher_version':
|
||||
val = args[i].replace(argDiscovery, this.launcherVersion)
|
||||
break
|
||||
case 'classpath':
|
||||
val = this.classpathArg(mods, tempNativePath).join(ProcessBuilder.getClasspathSeparator())
|
||||
break
|
||||
}
|
||||
if(val != null){
|
||||
args[i] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Autoconnect
|
||||
this._processAutoConnectArg(args)
|
||||
|
||||
|
||||
// Forge Specific Arguments
|
||||
args = args.concat(this.modManifest.arguments.game)
|
||||
|
||||
// Filter null values
|
||||
args = args.filter(arg => {
|
||||
return arg != null
|
||||
})
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the arguments required by forge.
|
||||
*
|
||||
* @returns {Array.<string>} An array containing the arguments required by forge.
|
||||
*/
|
||||
_resolveForgeArgs(){
|
||||
const mcArgs = this.modManifest.minecraftArguments.split(' ')
|
||||
const argDiscovery = /\${*(.*)}/
|
||||
|
||||
// Replace the declared variables with their proper values.
|
||||
for(let i=0; i<mcArgs.length; ++i){
|
||||
if(argDiscovery.test(mcArgs[i])){
|
||||
const identifier = mcArgs[i].match(argDiscovery)[1]
|
||||
let val = null
|
||||
switch(identifier){
|
||||
case 'auth_player_name':
|
||||
val = this.authUser.displayName.trim()
|
||||
break
|
||||
case 'version_name':
|
||||
//val = vanillaManifest.id
|
||||
val = this.server.rawServer.id
|
||||
break
|
||||
case 'game_directory':
|
||||
val = this.gameDir
|
||||
break
|
||||
case 'assets_root':
|
||||
val = path.join(this.commonDir, 'assets')
|
||||
break
|
||||
case 'assets_index_name':
|
||||
val = this.vanillaManifest.assets
|
||||
break
|
||||
case 'auth_uuid':
|
||||
val = this.authUser.uuid.trim()
|
||||
break
|
||||
case 'auth_access_token':
|
||||
val = this.authUser.accessToken
|
||||
break
|
||||
case 'user_type':
|
||||
val = this.authUser.type === 'microsoft' ? 'msa' : 'mojang'
|
||||
break
|
||||
case 'user_properties': // 1.8.9 and below.
|
||||
val = '{}'
|
||||
break
|
||||
case 'version_type':
|
||||
val = this.vanillaManifest.type
|
||||
break
|
||||
}
|
||||
if(val != null){
|
||||
mcArgs[i] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Autoconnect to the selected server.
|
||||
this._processAutoConnectArg(mcArgs)
|
||||
|
||||
// Prepare game resolution
|
||||
if(ConfigManager.getFullscreen()){
|
||||
mcArgs.push('--fullscreen')
|
||||
mcArgs.push(true)
|
||||
} else {
|
||||
mcArgs.push('--width')
|
||||
mcArgs.push(ConfigManager.getGameWidth())
|
||||
mcArgs.push('--height')
|
||||
mcArgs.push(ConfigManager.getGameHeight())
|
||||
}
|
||||
|
||||
// Mod List File Argument
|
||||
mcArgs.push('--modListFile')
|
||||
if(this._lteMinorVersion(9)) {
|
||||
mcArgs.push(path.basename(this.fmlDir))
|
||||
} else {
|
||||
mcArgs.push('absolute:' + this.fmlDir)
|
||||
}
|
||||
|
||||
|
||||
// LiteLoader
|
||||
if(this.usingLiteLoader){
|
||||
mcArgs.push('--modRepo')
|
||||
mcArgs.push(this.llDir)
|
||||
|
||||
// Set first arg to liteloader tweak class
|
||||
mcArgs.unshift('com.mumfrey.liteloader.launch.LiteLoaderTweaker')
|
||||
mcArgs.unshift('--tweakClass')
|
||||
}
|
||||
|
||||
return mcArgs
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the classpath entries all point to jar files.
|
||||
*
|
||||
* @param {Array.<String>} list Array of classpath entries.
|
||||
*/
|
||||
_processClassPathList(list) {
|
||||
|
||||
const ext = '.jar'
|
||||
const extLen = ext.length
|
||||
for(let i=0; i<list.length; i++) {
|
||||
const extIndex = list[i].indexOf(ext)
|
||||
if(extIndex > -1 && extIndex !== list[i].length - extLen) {
|
||||
list[i] = list[i].substring(0, extIndex + extLen)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the full classpath argument list for this process. This method will resolve all Mojang-declared
|
||||
* libraries as well as the libraries declared by the server. Since mods are permitted to declare libraries,
|
||||
* this method requires all enabled mods as an input
|
||||
*
|
||||
* @param {Array.<Object>} mods An array of enabled mods which will be launched with this process.
|
||||
* @param {string} tempNativePath The path to store the native libraries.
|
||||
* @returns {Array.<string>} An array containing the paths of each library required by this process.
|
||||
*/
|
||||
classpathArg(mods, tempNativePath){
|
||||
let cpArgs = []
|
||||
|
||||
if(!mcVersionAtLeast('1.17', this.server.rawServer.minecraftVersion) || this.usingFabricLoader) {
|
||||
// Add the version.jar to the classpath.
|
||||
// Must not be added to the classpath for Forge 1.17+.
|
||||
const version = this.vanillaManifest.id
|
||||
cpArgs.push(path.join(this.commonDir, 'versions', version, version + '.jar'))
|
||||
}
|
||||
|
||||
|
||||
if(this.usingLiteLoader){
|
||||
cpArgs.push(this.llPath)
|
||||
}
|
||||
|
||||
// Resolve the Mojang declared libraries.
|
||||
const mojangLibs = this._resolveMojangLibraries(tempNativePath)
|
||||
|
||||
// Resolve the server declared libraries.
|
||||
const servLibs = this._resolveServerLibraries(mods)
|
||||
|
||||
// Merge libraries, server libs with the same
|
||||
// maven identifier will override the mojang ones.
|
||||
// Ex. 1.7.10 forge overrides mojang's guava with newer version.
|
||||
const finalLibs = {...mojangLibs, ...servLibs}
|
||||
cpArgs = cpArgs.concat(Object.values(finalLibs))
|
||||
|
||||
this._processClassPathList(cpArgs)
|
||||
|
||||
return cpArgs
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the libraries defined by Mojang's version data. This method will also extract
|
||||
* native libraries and point to the correct location for its classpath.
|
||||
*
|
||||
* TODO - clean up function
|
||||
*
|
||||
* @param {string} tempNativePath The path to store the native libraries.
|
||||
* @returns {{[id: string]: string}} An object containing the paths of each library mojang declares.
|
||||
*/
|
||||
_resolveMojangLibraries(tempNativePath){
|
||||
const nativesRegex = /.+:natives-([^-]+)(?:-(.+))?/
|
||||
const libs = {}
|
||||
|
||||
const libArr = this.vanillaManifest.libraries
|
||||
fs.ensureDirSync(tempNativePath)
|
||||
for(let i=0; i<libArr.length; i++){
|
||||
const lib = libArr[i]
|
||||
if(isLibraryCompatible(lib.rules, lib.natives)){
|
||||
|
||||
// Pre-1.19 has a natives object.
|
||||
if(lib.natives != null) {
|
||||
// Extract the native library.
|
||||
const exclusionArr = lib.extract != null ? lib.extract.exclude : ['META-INF/']
|
||||
const artifact = lib.downloads.classifiers[lib.natives[getMojangOS()].replace('${arch}', process.arch.replace('x', ''))]
|
||||
|
||||
// Location of native zip.
|
||||
const to = path.join(this.libPath, artifact.path)
|
||||
|
||||
let zip = new AdmZip(to)
|
||||
let zipEntries = zip.getEntries()
|
||||
|
||||
// Unzip the native zip.
|
||||
for(let i=0; i<zipEntries.length; i++){
|
||||
const fileName = zipEntries[i].entryName
|
||||
|
||||
let shouldExclude = false
|
||||
|
||||
// Exclude noted files.
|
||||
exclusionArr.forEach(function(exclusion){
|
||||
if(fileName.indexOf(exclusion) > -1){
|
||||
shouldExclude = true
|
||||
}
|
||||
})
|
||||
|
||||
// Extract the file.
|
||||
if(!shouldExclude){
|
||||
fs.writeFile(path.join(tempNativePath, fileName), zipEntries[i].getData(), (err) => {
|
||||
if(err){
|
||||
logger.error('Error while extracting native library:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
// 1.19+ logic
|
||||
else if(lib.name.includes('natives-')) {
|
||||
|
||||
const regexTest = nativesRegex.exec(lib.name)
|
||||
// const os = regexTest[1]
|
||||
const arch = regexTest[2] ?? 'x64'
|
||||
|
||||
if(arch != process.arch) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract the native library.
|
||||
const exclusionArr = lib.extract != null ? lib.extract.exclude : ['META-INF/', '.git', '.sha1']
|
||||
const artifact = lib.downloads.artifact
|
||||
|
||||
// Location of native zip.
|
||||
const to = path.join(this.libPath, artifact.path)
|
||||
|
||||
let zip = new AdmZip(to)
|
||||
let zipEntries = zip.getEntries()
|
||||
|
||||
// Unzip the native zip.
|
||||
for(let i=0; i<zipEntries.length; i++){
|
||||
if(zipEntries[i].isDirectory) {
|
||||
continue
|
||||
}
|
||||
|
||||
const fileName = zipEntries[i].entryName
|
||||
|
||||
let shouldExclude = false
|
||||
|
||||
// Exclude noted files.
|
||||
exclusionArr.forEach(function(exclusion){
|
||||
if(fileName.indexOf(exclusion) > -1){
|
||||
shouldExclude = true
|
||||
}
|
||||
})
|
||||
|
||||
const extractName = fileName.includes('/') ? fileName.substring(fileName.lastIndexOf('/')) : fileName
|
||||
|
||||
// Extract the file.
|
||||
if(!shouldExclude){
|
||||
fs.writeFile(path.join(tempNativePath, extractName), zipEntries[i].getData(), (err) => {
|
||||
if(err){
|
||||
logger.error('Error while extracting native library:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
// No natives
|
||||
else {
|
||||
const dlInfo = lib.downloads
|
||||
const artifact = dlInfo.artifact
|
||||
const to = path.join(this.libPath, artifact.path)
|
||||
const versionIndependentId = lib.name.substring(0, lib.name.lastIndexOf(':'))
|
||||
libs[versionIndependentId] = to
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return libs
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the libraries declared by this server in order to add them to the classpath.
|
||||
* This method will also check each enabled mod for libraries, as mods are permitted to
|
||||
* declare libraries.
|
||||
*
|
||||
* @param {Array.<Object>} mods An array of enabled mods which will be launched with this process.
|
||||
* @returns {{[id: string]: string}} An object containing the paths of each library this server requires.
|
||||
*/
|
||||
_resolveServerLibraries(mods){
|
||||
const mdls = this.server.modules
|
||||
let libs = {}
|
||||
|
||||
// Locate Forge/Fabric/Libraries
|
||||
for(let mdl of mdls){
|
||||
const type = mdl.rawModule.type
|
||||
if(type === Type.ForgeHosted || type === Type.Fabric || type === Type.Library){
|
||||
libs[mdl.getVersionlessMavenIdentifier()] = mdl.getPath()
|
||||
if(mdl.subModules.length > 0){
|
||||
const res = this._resolveModuleLibraries(mdl)
|
||||
libs = {...libs, ...res}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Check for any libraries in our mod list.
|
||||
for(let i=0; i<mods.length; i++){
|
||||
if(mods.sub_modules != null){
|
||||
const res = this._resolveModuleLibraries(mods[i])
|
||||
libs = {...libs, ...res}
|
||||
}
|
||||
}
|
||||
|
||||
return libs
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively resolve the path of each library required by this module.
|
||||
*
|
||||
* @param {Object} mdl A module object from the server distro index.
|
||||
* @returns {{[id: string]: string}} An object containing the paths of each library this module requires.
|
||||
*/
|
||||
_resolveModuleLibraries(mdl){
|
||||
if(!mdl.subModules.length > 0){
|
||||
return {}
|
||||
}
|
||||
let libs = {}
|
||||
for(let sm of mdl.subModules){
|
||||
if(sm.rawModule.type === Type.Library){
|
||||
|
||||
if(sm.rawModule.classpath ?? true) {
|
||||
libs[sm.getVersionlessMavenIdentifier()] = sm.getPath()
|
||||
}
|
||||
}
|
||||
// If this module has submodules, we need to resolve the libraries for those.
|
||||
// To avoid unnecessary recursive calls, base case is checked here.
|
||||
if(mdl.subModules.length > 0){
|
||||
const res = this._resolveModuleLibraries(sm)
|
||||
libs = {...libs, ...res}
|
||||
}
|
||||
}
|
||||
return libs
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = ProcessBuilder
|
||||
168
app/assets/js/profileassetmanager.js
Normal file
@@ -0,0 +1,168 @@
|
||||
const AdmZip = require('adm-zip')
|
||||
const fs = require('fs-extra')
|
||||
const got = require('got')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
const { pipeline } = require('stream/promises')
|
||||
|
||||
const ConfigManager = require('./configmanager')
|
||||
|
||||
function getProfileBaseDirectory(profileId){
|
||||
return path.join(ConfigManager.getDataDirectory(), 'profiles', profileId)
|
||||
}
|
||||
|
||||
function getProfileDownloadDirectory(profileId){
|
||||
return path.join(getProfileBaseDirectory(profileId), 'downloads')
|
||||
}
|
||||
|
||||
function getProfileCacheFile(profileId, fileName){
|
||||
return path.join(getProfileDownloadDirectory(profileId), fileName)
|
||||
}
|
||||
|
||||
function getServerBundleDirectory(profile){
|
||||
return path.join(getProfileBaseDirectory(profile.id), profile.serverDirectoryName || 'server')
|
||||
}
|
||||
|
||||
function isRemoteSource(source){
|
||||
return /^https?:\/\//i.test(source)
|
||||
}
|
||||
|
||||
function resolveLocalSource(source){
|
||||
if(source == null){
|
||||
return null
|
||||
}
|
||||
|
||||
if(source.startsWith('file://')){
|
||||
return decodeURIComponent(source.substring('file://'.length))
|
||||
}
|
||||
|
||||
return path.resolve(source)
|
||||
}
|
||||
|
||||
async function downloadSourceToFile(source, destination){
|
||||
await fs.ensureDir(path.dirname(destination))
|
||||
|
||||
if(isRemoteSource(source)){
|
||||
await pipeline(
|
||||
got.stream(source),
|
||||
fs.createWriteStream(destination)
|
||||
)
|
||||
return destination
|
||||
}
|
||||
|
||||
const localSource = resolveLocalSource(source)
|
||||
const localStat = await fs.stat(localSource)
|
||||
if(localStat.isDirectory()){
|
||||
throw new Error(`Directory source is not supported for archive cache: ${localSource}`)
|
||||
}
|
||||
await fs.copy(localSource, destination, { overwrite: true })
|
||||
return destination
|
||||
}
|
||||
|
||||
async function extractZipToDirectory(zipPath, destination){
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'launcher-assets-'))
|
||||
try {
|
||||
await fs.ensureDir(tempDir)
|
||||
new AdmZip(zipPath).extractAllTo(tempDir, true)
|
||||
|
||||
const rootEntries = (await fs.readdir(tempDir)).filter((entry) => entry !== '__MACOSX')
|
||||
await fs.remove(destination)
|
||||
|
||||
if(rootEntries.length === 1){
|
||||
const onlyEntry = path.join(tempDir, rootEntries[0])
|
||||
const entryStat = await fs.stat(onlyEntry)
|
||||
if(entryStat.isDirectory()){
|
||||
await fs.copy(onlyEntry, destination, { overwrite: true })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await fs.copy(tempDir, destination, { overwrite: true })
|
||||
} finally {
|
||||
await fs.remove(tempDir)
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCachedArchive(profileId, source, fileName){
|
||||
const cachePath = getProfileCacheFile(profileId, fileName)
|
||||
await downloadSourceToFile(source, cachePath)
|
||||
return cachePath
|
||||
}
|
||||
|
||||
exports.getProfileBaseDirectory = getProfileBaseDirectory
|
||||
exports.getServerBundleDirectory = getServerBundleDirectory
|
||||
|
||||
exports.prefetchProfileAssets = async function(profile){
|
||||
if(profile.worldArchiveUrl){
|
||||
await ensureCachedArchive(profile.id, profile.worldArchiveUrl, 'world.zip')
|
||||
}
|
||||
if(profile.serverBundleUrl){
|
||||
await ensureCachedArchive(profile.id, profile.serverBundleUrl, 'server.zip')
|
||||
}
|
||||
|
||||
const currentState = ConfigManager.getLibraryProfileAssetState(profile.id)
|
||||
ConfigManager.setLibraryProfileAssetState(profile.id, {
|
||||
...currentState,
|
||||
prefetchedAt: new Date().toISOString()
|
||||
})
|
||||
ConfigManager.save()
|
||||
}
|
||||
|
||||
exports.ensureWorldInstalled = async function(profile, serverId){
|
||||
if(!profile.worldArchiveUrl || !profile.worldDirectoryName){
|
||||
return null
|
||||
}
|
||||
|
||||
const savesDirectory = path.join(ConfigManager.getInstanceDirectory(), serverId, 'saves')
|
||||
const targetWorldDirectory = path.join(savesDirectory, profile.worldDirectoryName)
|
||||
|
||||
await fs.ensureDir(savesDirectory)
|
||||
if(isRemoteSource(profile.worldArchiveUrl) || profile.worldArchiveUrl.endsWith('.zip') || profile.worldArchiveUrl.startsWith('file://')){
|
||||
const cachePath = await ensureCachedArchive(profile.id, profile.worldArchiveUrl, 'world.zip')
|
||||
await extractZipToDirectory(cachePath, targetWorldDirectory)
|
||||
} else {
|
||||
await fs.remove(targetWorldDirectory)
|
||||
await fs.copy(resolveLocalSource(profile.worldArchiveUrl), targetWorldDirectory, { overwrite: true })
|
||||
}
|
||||
|
||||
ConfigManager.setLibraryQuickPlayWorld(profile.id, profile.worldDirectoryName)
|
||||
ConfigManager.setLibraryProfileAssetState(profile.id, {
|
||||
worldInstalledAt: new Date().toISOString(),
|
||||
worldInstalledServerId: serverId,
|
||||
worldDirectoryName: profile.worldDirectoryName
|
||||
})
|
||||
ConfigManager.save()
|
||||
|
||||
return targetWorldDirectory
|
||||
}
|
||||
|
||||
exports.ensureServerBundleInstalled = async function(profile){
|
||||
if(!profile.serverBundleUrl){
|
||||
return null
|
||||
}
|
||||
|
||||
const targetDirectory = getServerBundleDirectory(profile)
|
||||
if(isRemoteSource(profile.serverBundleUrl) || profile.serverBundleUrl.endsWith('.zip') || profile.serverBundleUrl.startsWith('file://')){
|
||||
const cachePath = await ensureCachedArchive(profile.id, profile.serverBundleUrl, 'server.zip')
|
||||
await extractZipToDirectory(cachePath, targetDirectory)
|
||||
} else {
|
||||
await fs.remove(targetDirectory)
|
||||
await fs.copy(resolveLocalSource(profile.serverBundleUrl), targetDirectory, { overwrite: true })
|
||||
}
|
||||
|
||||
ConfigManager.setLibraryProfileAssetState(profile.id, {
|
||||
serverBundleInstalledAt: new Date().toISOString(),
|
||||
serverBundleDirectory: targetDirectory
|
||||
})
|
||||
ConfigManager.save()
|
||||
|
||||
return targetDirectory
|
||||
}
|
||||
|
||||
exports.prepareProfileForLaunch = async function(profile, serverId){
|
||||
if(profile.kind === 'map'){
|
||||
return exports.ensureWorldInstalled(profile, serverId)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
307
app/assets/js/scripts/install.js
Normal file
@@ -0,0 +1,307 @@
|
||||
const CatalogManager = require('./assets/js/catalogmanager')
|
||||
const ConfigManager = require('./assets/js/configmanager')
|
||||
const ProfileAssetManager = require('./assets/js/profileassetmanager')
|
||||
|
||||
const installCatalogList = document.getElementById('installCatalogList')
|
||||
const installDetailTitle = document.getElementById('installDetailTitle')
|
||||
const installDetailSummary = document.getElementById('installDetailSummary')
|
||||
const installDetailMeta = document.getElementById('installDetailMeta')
|
||||
const installDetailInfo = document.getElementById('installDetailInfo')
|
||||
const installDetailBody = document.getElementById('installDetailBody')
|
||||
const installDetailAddButton = document.getElementById('installDetailAddButton')
|
||||
|
||||
let selectedProfileId = null
|
||||
let latestCatalog = null
|
||||
|
||||
function describeProfileKind(kind){
|
||||
switch(kind){
|
||||
case 'map':
|
||||
return '오리지널 맵'
|
||||
case 'server-pack':
|
||||
return '플러그인 맵 + 서버팩'
|
||||
case 'modpack':
|
||||
default:
|
||||
return '모드팩'
|
||||
}
|
||||
}
|
||||
|
||||
function createInstallBadge(text){
|
||||
const badge = document.createElement('span')
|
||||
badge.className = 'launcherBadge'
|
||||
badge.textContent = text
|
||||
return badge
|
||||
}
|
||||
|
||||
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 showInstallMessage(title, message){
|
||||
if(typeof setOverlayContent === 'function'){
|
||||
setOverlayContent(title, message, '확인')
|
||||
setOverlayHandler(() => toggleOverlay(false))
|
||||
toggleOverlay(true)
|
||||
}
|
||||
}
|
||||
|
||||
function buildDetailText(profile){
|
||||
if(typeof profile.details === 'string' && profile.details.trim().length > 0){
|
||||
return profile.details.trim()
|
||||
}
|
||||
|
||||
switch(profile.kind){
|
||||
case 'map':
|
||||
return '이 프로필은 싱글플레이 월드를 바로 실행하기 위한 항목입니다. 필요한 클라이언트 배포 파일과 월드 자료는 관리자가 미리 등록해둡니다.'
|
||||
case 'server-pack':
|
||||
return '이 프로필은 서버 실행/접속 흐름을 함께 다루는 항목입니다. 클라이언트 파일과 서버 번들은 관리자가 미리 등록하며, 사용자는 라이브러리에서 실행과 접속만 진행합니다.'
|
||||
case 'modpack':
|
||||
default:
|
||||
return '이 프로필은 일반 모드팩 클라이언트입니다. 필요한 배포 파일은 관리자가 미리 등록하며, 사용자는 라이브러리에 추가한 뒤 실행만 하면 됩니다.'
|
||||
}
|
||||
}
|
||||
|
||||
function renderDetailPanel(profile){
|
||||
const installedIds = new Set(
|
||||
ConfigManager.getInstalledLibraryProfiles().map((installedProfile) => installedProfile.id)
|
||||
)
|
||||
const installed = installedIds.has(profile.id)
|
||||
|
||||
installDetailTitle.textContent = profile.name
|
||||
installDetailSummary.textContent = profile.description || '설명이 없습니다.'
|
||||
installDetailMeta.innerHTML = ''
|
||||
installDetailMeta.appendChild(createInstallBadge(describeProfileKind(profile.kind)))
|
||||
if(installed){
|
||||
installDetailMeta.appendChild(createInstallBadge('라이브러리 보유'))
|
||||
}
|
||||
if(!profile.launchReady){
|
||||
installDetailMeta.appendChild(createInstallBadge('실행 준비 필요'))
|
||||
}
|
||||
if(profile.kind === 'server-pack' && profile.hostReady){
|
||||
installDetailMeta.appendChild(createInstallBadge('로컬 호스팅 가능'))
|
||||
}
|
||||
|
||||
installDetailInfo.innerHTML = ''
|
||||
installDetailInfo.appendChild(createInfoLine('프로필 ID', profile.id))
|
||||
installDetailInfo.appendChild(createInfoLine('종류', describeProfileKind(profile.kind)))
|
||||
installDetailInfo.appendChild(createInfoLine('실행 준비', profile.launchReady ? '완료' : '추가 설정 필요'))
|
||||
|
||||
if(profile.defaultServerAddress){
|
||||
installDetailInfo.appendChild(createInfoLine('기본 주소', profile.defaultServerAddress))
|
||||
}
|
||||
if(profile.kind === 'map' && profile.worldDirectoryName){
|
||||
installDetailInfo.appendChild(createInfoLine('월드 폴더', profile.worldDirectoryName))
|
||||
}
|
||||
if(profile.kind === 'server-pack'){
|
||||
installDetailInfo.appendChild(createInfoLine('로컬 호스팅', profile.hostReady ? '가능' : '관리자 설정 필요'))
|
||||
}
|
||||
if(profile.launchIssues.length > 0){
|
||||
installDetailInfo.appendChild(createInfoLine('확인 필요', profile.launchIssues.join(' / ')))
|
||||
} else if(profile.hostIssues.length > 0){
|
||||
installDetailInfo.appendChild(createInfoLine('호스팅 참고', profile.hostIssues.join(' / ')))
|
||||
}
|
||||
|
||||
installDetailBody.textContent = buildDetailText(profile)
|
||||
installDetailAddButton.disabled = installed || !profile.launchReady
|
||||
installDetailAddButton.textContent = installed ? '이미 라이브러리에 있음' : '라이브러리에 추가'
|
||||
installDetailAddButton.onclick = async () => {
|
||||
try {
|
||||
const installedProfile = await CatalogManager.installProfile(profile.id)
|
||||
await ProfileAssetManager.prefetchProfileAssets(installedProfile)
|
||||
if(installedProfile.kind === 'server-pack' && installedProfile.hostReady){
|
||||
await ProfileAssetManager.ensureServerBundleInstalled(installedProfile)
|
||||
}
|
||||
renderDetailPanel(profile)
|
||||
await renderInstallView()
|
||||
if(typeof refreshLibraryView === 'function'){
|
||||
await refreshLibraryView()
|
||||
}
|
||||
showInstallMessage('추가 완료', `${profile.name} 프로필을 라이브러리에 추가했습니다.`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
const message = error instanceof Error ? error.message : '프로필 설치 중 오류가 발생했습니다.'
|
||||
showInstallMessage('설치 실패', message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderEmptyDetailPanel(){
|
||||
installDetailTitle.textContent = '프로필을 선택하세요'
|
||||
installDetailSummary.textContent = '왼쪽 목록에서 모드팩, 맵, 서버팩을 고르면 자세한 설명과 설치 조건을 볼 수 있습니다.'
|
||||
installDetailMeta.innerHTML = ''
|
||||
installDetailInfo.innerHTML = ''
|
||||
installDetailBody.textContent = '관리자가 등록한 프로필 상세 설명이 여기에 표시됩니다.'
|
||||
installDetailAddButton.disabled = true
|
||||
installDetailAddButton.textContent = '라이브러리에 추가'
|
||||
installDetailAddButton.onclick = null
|
||||
}
|
||||
|
||||
function selectProfile(profileId){
|
||||
selectedProfileId = profileId
|
||||
if(latestCatalog == null){
|
||||
renderEmptyDetailPanel()
|
||||
return
|
||||
}
|
||||
|
||||
const profile = latestCatalog.profiles.find((entry) => entry.id === profileId)
|
||||
if(profile == null){
|
||||
renderEmptyDetailPanel()
|
||||
return
|
||||
}
|
||||
|
||||
renderDetailPanel(profile)
|
||||
}
|
||||
|
||||
async function renderInstallView(){
|
||||
installCatalogList.innerHTML = ''
|
||||
|
||||
try {
|
||||
const catalog = await CatalogManager.loadCatalog()
|
||||
latestCatalog = catalog
|
||||
const installedIds = new Set(
|
||||
ConfigManager.getInstalledLibraryProfiles().map((profile) => profile.id)
|
||||
)
|
||||
|
||||
if(catalog.sourceError != null){
|
||||
const warningCard = document.createElement('article')
|
||||
warningCard.className = 'launcherCard'
|
||||
warningCard.innerHTML = '<h3 class="launcherCardTitle">카탈로그 로드 경고</h3><p class="launcherCardDescription">관리자가 등록한 카탈로그를 읽지 못했습니다. 현재 보이는 목록이 없다면 배포 주소 또는 로컬 카탈로그 파일을 관리자 측에서 확인해야 합니다.</p>'
|
||||
installCatalogList.appendChild(warningCard)
|
||||
}
|
||||
|
||||
for(const profile of catalog.profiles){
|
||||
const card = document.createElement('article')
|
||||
card.className = 'launcherCard'
|
||||
if(profile.id === selectedProfileId){
|
||||
card.setAttribute('selected', 'true')
|
||||
}
|
||||
|
||||
const header = document.createElement('div')
|
||||
header.className = 'launcherCardHeader'
|
||||
|
||||
const titleGroup = document.createElement('div')
|
||||
titleGroup.className = 'launcherCardTitleGroup'
|
||||
|
||||
const title = document.createElement('h3')
|
||||
title.className = 'launcherCardTitle'
|
||||
title.textContent = profile.name
|
||||
|
||||
const meta = document.createElement('div')
|
||||
meta.className = 'launcherCardMeta'
|
||||
meta.appendChild(createInstallBadge(describeProfileKind(profile.kind)))
|
||||
if(installedIds.has(profile.id)){
|
||||
meta.appendChild(createInstallBadge('설치됨'))
|
||||
}
|
||||
if(profile.kind === 'server-pack' && profile.hostReady){
|
||||
meta.appendChild(createInstallBadge('호스팅 가능'))
|
||||
}
|
||||
|
||||
titleGroup.appendChild(title)
|
||||
titleGroup.appendChild(meta)
|
||||
header.appendChild(titleGroup)
|
||||
|
||||
const description = document.createElement('p')
|
||||
description.className = 'launcherCardDescription'
|
||||
description.textContent = profile.description || '설명이 없습니다.'
|
||||
|
||||
const actions = document.createElement('div')
|
||||
actions.className = 'launcherCardActions'
|
||||
|
||||
const detailButton = document.createElement('button')
|
||||
detailButton.className = 'launcherSecondaryButton'
|
||||
detailButton.textContent = '자세히 보기'
|
||||
detailButton.addEventListener('click', () => {
|
||||
selectProfile(profile.id)
|
||||
renderInstallView()
|
||||
})
|
||||
|
||||
const installButton = document.createElement('button')
|
||||
installButton.className = 'launcherPrimaryButton'
|
||||
installButton.textContent = installedIds.has(profile.id) ? '설치됨' : '라이브러리에 추가'
|
||||
installButton.disabled = installedIds.has(profile.id) || !profile.launchReady
|
||||
installButton.addEventListener('click', async () => {
|
||||
try {
|
||||
const installedProfile = await CatalogManager.installProfile(profile.id)
|
||||
await ProfileAssetManager.prefetchProfileAssets(installedProfile)
|
||||
if(installedProfile.kind === 'server-pack' && installedProfile.hostReady){
|
||||
await ProfileAssetManager.ensureServerBundleInstalled(installedProfile)
|
||||
}
|
||||
selectProfile(profile.id)
|
||||
await renderInstallView()
|
||||
if(typeof refreshLibraryView === 'function'){
|
||||
await refreshLibraryView()
|
||||
}
|
||||
showInstallMessage('추가 완료', `${profile.name} 프로필을 라이브러리에 추가했습니다.`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
const message = error instanceof Error ? error.message : '프로필 설치 중 오류가 발생했습니다.'
|
||||
showInstallMessage('설치 실패', message)
|
||||
}
|
||||
})
|
||||
|
||||
actions.appendChild(detailButton)
|
||||
actions.appendChild(installButton)
|
||||
|
||||
card.appendChild(header)
|
||||
card.appendChild(description)
|
||||
card.appendChild(actions)
|
||||
installCatalogList.appendChild(card)
|
||||
}
|
||||
|
||||
if(catalog.profiles.length === 0){
|
||||
renderEmptyDetailPanel()
|
||||
return
|
||||
}
|
||||
|
||||
const selectedProfileStillExists = catalog.profiles.some((profile) => profile.id === selectedProfileId)
|
||||
if(!selectedProfileStillExists){
|
||||
selectedProfileId = catalog.profiles[0].id
|
||||
}
|
||||
|
||||
selectProfile(selectedProfileId)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
latestCatalog = null
|
||||
renderEmptyDetailPanel()
|
||||
|
||||
const errorCard = document.createElement('article')
|
||||
errorCard.className = 'launcherCard'
|
||||
errorCard.innerHTML = '<h3 class="launcherCardTitle">카탈로그 로드 실패</h3><p class="launcherCardDescription">관리자가 등록한 카탈로그를 읽지 못했습니다.</p>'
|
||||
installCatalogList.appendChild(errorCard)
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('installOpenSettingsButton').addEventListener('click', async () => {
|
||||
await prepareSettings()
|
||||
switchView(getCurrentView(), VIEWS.settings)
|
||||
})
|
||||
|
||||
document.getElementById('installBackToLibraryButton').addEventListener('click', async () => {
|
||||
if(typeof refreshLibraryView === 'function'){
|
||||
await refreshLibraryView()
|
||||
}
|
||||
switchView(getCurrentView(), VIEWS.library)
|
||||
})
|
||||
|
||||
document.getElementById('installDetailOpenLibraryButton').addEventListener('click', async () => {
|
||||
if(typeof refreshLibraryView === 'function'){
|
||||
await refreshLibraryView()
|
||||
}
|
||||
switchView(getCurrentView(), VIEWS.library)
|
||||
})
|
||||
|
||||
window.refreshInstallView = renderInstallView
|
||||
renderEmptyDetailPanel()
|
||||
renderInstallView()
|
||||
1040
app/assets/js/scripts/landing.js
Normal file
417
app/assets/js/scripts/library.js
Normal file
@@ -0,0 +1,417 @@
|
||||
const { clipboard } = require('electron')
|
||||
|
||||
const CatalogManager = require('./assets/js/catalogmanager')
|
||||
const ConfigManager = require('./assets/js/configmanager')
|
||||
const ProfileAssetManager = require('./assets/js/profileassetmanager')
|
||||
const ServerRuntime = require('./assets/js/serverruntime')
|
||||
const { DistroAPI } = require('./assets/js/distromanager')
|
||||
|
||||
const libraryList = document.getElementById('libraryList')
|
||||
const libraryEmptyState = document.getElementById('libraryEmptyState')
|
||||
|
||||
function renderLibraryEmptyState(isEmpty){
|
||||
libraryEmptyState.style.display = isEmpty ? 'flex' : 'none'
|
||||
}
|
||||
|
||||
function createBadge(text){
|
||||
const badge = document.createElement('span')
|
||||
badge.className = 'launcherBadge'
|
||||
badge.textContent = text
|
||||
return badge
|
||||
}
|
||||
|
||||
function describeProfileKind(kind){
|
||||
switch(kind){
|
||||
case 'map':
|
||||
return '맵'
|
||||
case 'server-pack':
|
||||
return '서버팩'
|
||||
case 'modpack':
|
||||
default:
|
||||
return '모드팩'
|
||||
}
|
||||
}
|
||||
|
||||
function createParagraph(className, text){
|
||||
const element = document.createElement('p')
|
||||
element.className = className
|
||||
element.textContent = text
|
||||
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){
|
||||
if(typeof setOverlayContent === 'function'){
|
||||
setOverlayContent(title, message, '확인')
|
||||
setOverlayHandler(() => toggleOverlay(false))
|
||||
toggleOverlay(true)
|
||||
}
|
||||
}
|
||||
|
||||
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 '추가 자산 없음'
|
||||
}
|
||||
|
||||
async function prepareProfileAssets(profile){
|
||||
try {
|
||||
await ProfileAssetManager.prefetchProfileAssets(profile)
|
||||
if(profile.kind === 'server-pack' && profile.hostReady){
|
||||
await ProfileAssetManager.ensureServerBundleInstalled(profile)
|
||||
}
|
||||
await renderLibraryView()
|
||||
showLibraryMessage('자료 준비 완료', `${profile.name} 자료를 준비했습니다.`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
showLibraryMessage('자료 준비 실패', '프로필 자료를 내려받거나 해제하는 중 오류가 발생했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
async function activateProfile(profile, launchNow = false){
|
||||
if(!profile.configured){
|
||||
const firstIssue = profile.launchIssues?.[0] ?? '이 프로필은 아직 실행 조건이 충족되지 않았습니다.'
|
||||
showLibraryMessage('프로필 설정 필요', firstIssue)
|
||||
return
|
||||
}
|
||||
|
||||
CatalogManager.selectProfile(profile.id)
|
||||
CatalogManager.applyConfiguredProfile()
|
||||
|
||||
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')
|
||||
label.className = 'launcherFieldLabel'
|
||||
label.textContent = '접속 주소'
|
||||
|
||||
const input = document.createElement('input')
|
||||
input.className = 'launcherFieldInput'
|
||||
input.type = 'text'
|
||||
input.placeholder = profile.defaultServerAddress || 'example.com:25565'
|
||||
input.value = ConfigManager.getLibraryServerAddressOverride(profile.id) ?? ''
|
||||
input.addEventListener('change', () => {
|
||||
CatalogManager.setServerAddressOverride(profile.id, input.value)
|
||||
})
|
||||
|
||||
fieldGroup.appendChild(label)
|
||||
fieldGroup.appendChild(input)
|
||||
}
|
||||
|
||||
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(){
|
||||
libraryList.innerHTML = ''
|
||||
|
||||
try {
|
||||
const installedProfiles = await CatalogManager.getInstalledProfiles()
|
||||
const selectedProfileId = CatalogManager.getSelectedProfileId()
|
||||
|
||||
renderLibraryEmptyState(installedProfiles.length === 0)
|
||||
|
||||
for(const profile of installedProfiles){
|
||||
const hostState = ServerRuntime.getHostedProfileState(profile.id)
|
||||
const card = document.createElement('article')
|
||||
card.className = 'launcherCard'
|
||||
if(profile.id === selectedProfileId){
|
||||
card.setAttribute('selected', 'true')
|
||||
}
|
||||
|
||||
const header = document.createElement('div')
|
||||
header.className = 'launcherCardHeader'
|
||||
|
||||
const titleGroup = document.createElement('div')
|
||||
titleGroup.className = 'launcherCardTitleGroup'
|
||||
|
||||
const title = document.createElement('h3')
|
||||
title.className = 'launcherCardTitle'
|
||||
title.textContent = profile.name
|
||||
|
||||
const meta = document.createElement('div')
|
||||
meta.className = 'launcherCardMeta'
|
||||
meta.appendChild(createBadge(describeProfileKind(profile.kind)))
|
||||
if(profile.isCustom){
|
||||
meta.appendChild(createBadge('커스텀'))
|
||||
}
|
||||
if(profile.id === selectedProfileId){
|
||||
meta.appendChild(createBadge('선택됨'))
|
||||
}
|
||||
if(profile.kind === 'map' && profile.worldDirectoryName){
|
||||
meta.appendChild(createBadge(profile.worldDirectoryName))
|
||||
}
|
||||
if(profile.kind === 'map' && !profile.launchReady){
|
||||
meta.appendChild(createBadge('맵 설정 필요'))
|
||||
}
|
||||
if(profile.kind === 'server-pack' && !profile.hostReady){
|
||||
meta.appendChild(createBadge('호스팅 설정 필요'))
|
||||
}
|
||||
if(hostState.running){
|
||||
meta.appendChild(createBadge(hostState.tunneling ? '서버+터널' : '서버 실행 중'))
|
||||
}
|
||||
|
||||
titleGroup.appendChild(title)
|
||||
titleGroup.appendChild(meta)
|
||||
header.appendChild(titleGroup)
|
||||
|
||||
const description = createParagraph('launcherCardDescription', profile.description || '설명이 없습니다.')
|
||||
|
||||
const infoBlock = document.createElement('div')
|
||||
infoBlock.className = 'launcherInfoBlock'
|
||||
infoBlock.appendChild(createInfoLine('자료 상태', describeAssetState(profile)))
|
||||
infoBlock.appendChild(createInfoLine('실행 준비', profile.launchReady ? '완료' : '추가 설정 필요'))
|
||||
if(profile.defaultServerAddress){
|
||||
infoBlock.appendChild(createInfoLine('기본 주소', profile.defaultServerAddress))
|
||||
}
|
||||
if(profile.kind === 'server-pack'){
|
||||
infoBlock.appendChild(createInfoLine('로컬 호스팅', profile.hostReady ? '가능' : '서버 번들 필요'))
|
||||
}
|
||||
if(hostState.running){
|
||||
infoBlock.appendChild(createInfoLine('호스트 상태', hostState.tunneling ? '터널 연결 중' : '로컬 서버 실행 중'))
|
||||
}
|
||||
if(profile.launchIssues?.length > 0){
|
||||
infoBlock.appendChild(createInfoLine('확인 필요', profile.launchIssues[0]))
|
||||
} else if(profile.hostIssues?.length > 0){
|
||||
infoBlock.appendChild(createInfoLine('호스팅 확인', profile.hostIssues[0]))
|
||||
}
|
||||
|
||||
const fieldGroup = document.createElement('div')
|
||||
fieldGroup.className = 'launcherFieldGroup'
|
||||
appendAddressOverrideField(profile, fieldGroup)
|
||||
appendPublishedAddressField(profile, hostState, fieldGroup)
|
||||
|
||||
const actions = document.createElement('div')
|
||||
actions.className = 'launcherCardActions'
|
||||
|
||||
const prepareButton = document.createElement('button')
|
||||
prepareButton.className = 'launcherSecondaryButton'
|
||||
prepareButton.textContent = '자료 준비'
|
||||
prepareButton.addEventListener('click', async () => {
|
||||
await prepareProfileAssets(profile)
|
||||
})
|
||||
|
||||
const selectButton = document.createElement('button')
|
||||
selectButton.className = 'launcherSecondaryButton'
|
||||
selectButton.textContent = '프로필 선택'
|
||||
selectButton.disabled = !profile.configured
|
||||
selectButton.addEventListener('click', async () => {
|
||||
CatalogManager.selectProfile(profile.id)
|
||||
CatalogManager.applyConfiguredProfile()
|
||||
await renderLibraryView()
|
||||
})
|
||||
|
||||
const openButton = document.createElement('button')
|
||||
openButton.className = 'launcherSecondaryButton'
|
||||
openButton.textContent = '실행 화면'
|
||||
openButton.disabled = !profile.configured
|
||||
openButton.addEventListener('click', async () => {
|
||||
await activateProfile(profile, false)
|
||||
})
|
||||
|
||||
const launchButton = document.createElement('button')
|
||||
launchButton.className = 'launcherPrimaryButton'
|
||||
launchButton.textContent = profile.kind === 'map' ? '맵 실행' : '바로 실행'
|
||||
launchButton.disabled = !profile.configured
|
||||
launchButton.addEventListener('click', async () => {
|
||||
await activateProfile(profile, true)
|
||||
})
|
||||
|
||||
actions.appendChild(prepareButton)
|
||||
actions.appendChild(selectButton)
|
||||
actions.appendChild(openButton)
|
||||
actions.appendChild(launchButton)
|
||||
|
||||
if(profile.kind === 'server-pack'){
|
||||
const startHostButton = document.createElement('button')
|
||||
startHostButton.className = 'launcherSecondaryButton'
|
||||
startHostButton.textContent = '서버 실행'
|
||||
startHostButton.disabled = hostState.running || !profile.hostReady
|
||||
startHostButton.addEventListener('click', async () => {
|
||||
try {
|
||||
await ServerRuntime.startHostedProfile(profile)
|
||||
await renderLibraryView()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
showLibraryMessage('서버 실행 실패', '서버 번들이 준비되지 않았거나 시작 명령을 찾지 못했습니다.')
|
||||
}
|
||||
})
|
||||
|
||||
const stopHostButton = document.createElement('button')
|
||||
stopHostButton.className = 'launcherGhostButton'
|
||||
stopHostButton.textContent = '서버 중지'
|
||||
stopHostButton.disabled = !hostState.running
|
||||
stopHostButton.addEventListener('click', async () => {
|
||||
ServerRuntime.stopHostedProfile(profile.id)
|
||||
await renderLibraryView()
|
||||
})
|
||||
|
||||
actions.appendChild(startHostButton)
|
||||
actions.appendChild(stopHostButton)
|
||||
}
|
||||
|
||||
const removeButton = document.createElement('button')
|
||||
removeButton.className = 'launcherGhostButton'
|
||||
removeButton.textContent = '제거'
|
||||
removeButton.addEventListener('click', async () => {
|
||||
ServerRuntime.stopHostedProfile(profile.id)
|
||||
CatalogManager.removeProfile(profile.id)
|
||||
await renderLibraryView()
|
||||
if(typeof refreshInstallView === 'function'){
|
||||
await refreshInstallView()
|
||||
}
|
||||
})
|
||||
|
||||
actions.appendChild(removeButton)
|
||||
|
||||
card.appendChild(header)
|
||||
card.appendChild(description)
|
||||
card.appendChild(infoBlock)
|
||||
if(fieldGroup.childNodes.length > 0){
|
||||
card.appendChild(fieldGroup)
|
||||
}
|
||||
card.appendChild(actions)
|
||||
libraryList.appendChild(card)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
renderLibraryEmptyState(false)
|
||||
const errorCard = document.createElement('article')
|
||||
errorCard.className = 'launcherCard'
|
||||
errorCard.innerHTML = '<h3 class="launcherCardTitle">라이브러리 로드 실패</h3><p class="launcherCardDescription">선택한 카탈로그를 읽지 못했습니다. 설치 페이지에서 카탈로그 경로를 다시 확인하세요.</p>'
|
||||
libraryList.appendChild(errorCard)
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('libraryOpenInstallButton').addEventListener('click', async () => {
|
||||
if(typeof refreshInstallView === 'function'){
|
||||
await refreshInstallView()
|
||||
}
|
||||
switchView(getCurrentView(), VIEWS.install)
|
||||
})
|
||||
|
||||
document.getElementById('libraryOpenSettingsButton').addEventListener('click', async () => {
|
||||
await prepareSettings()
|
||||
switchView(getCurrentView(), VIEWS.settings)
|
||||
})
|
||||
|
||||
document.getElementById('libraryOpenLaunchButton').addEventListener('click', async () => {
|
||||
const selectedProfile = CatalogManager.getSelectedProfileSync()
|
||||
if(selectedProfile == null){
|
||||
switchView(getCurrentView(), VIEWS.install)
|
||||
return
|
||||
}
|
||||
await activateProfile(selectedProfile, false)
|
||||
})
|
||||
|
||||
setInterval(() => {
|
||||
if(getCurrentView() === VIEWS.library && ServerRuntime.hasRunningProfiles()){
|
||||
renderLibraryView()
|
||||
}
|
||||
}, 3000)
|
||||
|
||||
window.refreshLibraryView = renderLibraryView
|
||||
renderLibraryView()
|
||||
234
app/assets/js/scripts/login.js
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Script for login.ejs
|
||||
*/
|
||||
// Validation Regexes.
|
||||
const validUsername = /^[a-zA-Z0-9_]{1,16}$/
|
||||
const basicEmail = /^\S+@\S+\.\S+$/
|
||||
//const validEmail = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i
|
||||
|
||||
// Login Elements
|
||||
const loginCancelContainer = document.getElementById('loginCancelContainer')
|
||||
const loginCancelButton = document.getElementById('loginCancelButton')
|
||||
const loginEmailError = document.getElementById('loginEmailError')
|
||||
const loginUsername = document.getElementById('loginUsername')
|
||||
const loginPasswordError = document.getElementById('loginPasswordError')
|
||||
const loginPassword = document.getElementById('loginPassword')
|
||||
const checkmarkContainer = document.getElementById('checkmarkContainer')
|
||||
const loginRememberOption = document.getElementById('loginRememberOption')
|
||||
const loginButton = document.getElementById('loginButton')
|
||||
const loginForm = document.getElementById('loginForm')
|
||||
|
||||
// Control variables.
|
||||
let lu = false, lp = false
|
||||
|
||||
|
||||
/**
|
||||
* Show a login error.
|
||||
*
|
||||
* @param {HTMLElement} element The element on which to display the error.
|
||||
* @param {string} value The error text.
|
||||
*/
|
||||
function showError(element, value){
|
||||
element.innerHTML = value
|
||||
element.style.opacity = 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Shake a login error to add emphasis.
|
||||
*
|
||||
* @param {HTMLElement} element The element to shake.
|
||||
*/
|
||||
function shakeError(element){
|
||||
if(element.style.opacity == 1){
|
||||
element.classList.remove('shake')
|
||||
void element.offsetWidth
|
||||
element.classList.add('shake')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that an email field is neither empty nor invalid.
|
||||
*
|
||||
* @param {string} value The email value.
|
||||
*/
|
||||
function validateEmail(value){
|
||||
if(value){
|
||||
if(!basicEmail.test(value) && !validUsername.test(value)){
|
||||
showError(loginEmailError, Lang.queryJS('login.error.invalidValue'))
|
||||
loginDisabled(true)
|
||||
lu = false
|
||||
} else {
|
||||
loginEmailError.style.opacity = 0
|
||||
lu = true
|
||||
if(lp){
|
||||
loginDisabled(false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lu = false
|
||||
showError(loginEmailError, Lang.queryJS('login.error.requiredValue'))
|
||||
loginDisabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the password field is not empty.
|
||||
*
|
||||
* @param {string} value The password value.
|
||||
*/
|
||||
function validatePassword(value){
|
||||
if(value){
|
||||
loginPasswordError.style.opacity = 0
|
||||
lp = true
|
||||
if(lu){
|
||||
loginDisabled(false)
|
||||
}
|
||||
} else {
|
||||
lp = false
|
||||
showError(loginPasswordError, Lang.queryJS('login.error.invalidValue'))
|
||||
loginDisabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Emphasize errors with shake when focus is lost.
|
||||
loginUsername.addEventListener('focusout', (e) => {
|
||||
validateEmail(e.target.value)
|
||||
shakeError(loginEmailError)
|
||||
})
|
||||
loginPassword.addEventListener('focusout', (e) => {
|
||||
validatePassword(e.target.value)
|
||||
shakeError(loginPasswordError)
|
||||
})
|
||||
|
||||
// Validate input for each field.
|
||||
loginUsername.addEventListener('input', (e) => {
|
||||
validateEmail(e.target.value)
|
||||
})
|
||||
loginPassword.addEventListener('input', (e) => {
|
||||
validatePassword(e.target.value)
|
||||
})
|
||||
|
||||
/**
|
||||
* Enable or disable the login button.
|
||||
*
|
||||
* @param {boolean} v True to enable, false to disable.
|
||||
*/
|
||||
function loginDisabled(v){
|
||||
if(loginButton.disabled !== v){
|
||||
loginButton.disabled = v
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable loading elements.
|
||||
*
|
||||
* @param {boolean} v True to enable, false to disable.
|
||||
*/
|
||||
function loginLoading(v){
|
||||
if(v){
|
||||
loginButton.setAttribute('loading', v)
|
||||
loginButton.innerHTML = loginButton.innerHTML.replace(Lang.queryJS('login.login'), Lang.queryJS('login.loggingIn'))
|
||||
} else {
|
||||
loginButton.removeAttribute('loading')
|
||||
loginButton.innerHTML = loginButton.innerHTML.replace(Lang.queryJS('login.loggingIn'), Lang.queryJS('login.login'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable login form.
|
||||
*
|
||||
* @param {boolean} v True to enable, false to disable.
|
||||
*/
|
||||
function formDisabled(v){
|
||||
loginDisabled(v)
|
||||
loginCancelButton.disabled = v
|
||||
loginUsername.disabled = v
|
||||
loginPassword.disabled = v
|
||||
if(v){
|
||||
checkmarkContainer.setAttribute('disabled', v)
|
||||
} else {
|
||||
checkmarkContainer.removeAttribute('disabled')
|
||||
}
|
||||
loginRememberOption.disabled = v
|
||||
}
|
||||
|
||||
let loginViewOnSuccess = VIEWS.library
|
||||
let loginViewOnCancel = VIEWS.settings
|
||||
let loginViewCancelHandler
|
||||
|
||||
function loginCancelEnabled(val){
|
||||
if(val){
|
||||
$(loginCancelContainer).show()
|
||||
} else {
|
||||
$(loginCancelContainer).hide()
|
||||
}
|
||||
}
|
||||
|
||||
loginCancelButton.onclick = (e) => {
|
||||
switchView(getCurrentView(), loginViewOnCancel, 500, 500, () => {
|
||||
loginUsername.value = ''
|
||||
loginPassword.value = ''
|
||||
loginCancelEnabled(false)
|
||||
if(loginViewCancelHandler != null){
|
||||
loginViewCancelHandler()
|
||||
loginViewCancelHandler = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Disable default form behavior.
|
||||
loginForm.onsubmit = () => { return false }
|
||||
|
||||
// Bind login button behavior.
|
||||
loginButton.addEventListener('click', () => {
|
||||
// Disable form.
|
||||
formDisabled(true)
|
||||
|
||||
// Show loading stuff.
|
||||
loginLoading(true)
|
||||
|
||||
AuthManager.addMojangAccount(loginUsername.value, loginPassword.value).then((value) => {
|
||||
updateSelectedAccount(value)
|
||||
loginButton.innerHTML = loginButton.innerHTML.replace(Lang.queryJS('login.loggingIn'), Lang.queryJS('login.success'))
|
||||
$('.circle-loader').toggleClass('load-complete')
|
||||
$('.checkmark').toggle()
|
||||
setTimeout(() => {
|
||||
switchView(VIEWS.login, loginViewOnSuccess, 500, 500, async () => {
|
||||
// Temporary workaround
|
||||
if(loginViewOnSuccess === VIEWS.settings){
|
||||
await prepareSettings()
|
||||
}
|
||||
loginViewOnSuccess = VIEWS.library // Reset this for good measure.
|
||||
loginCancelEnabled(false) // Reset this for good measure.
|
||||
loginViewCancelHandler = null // Reset this for good measure.
|
||||
loginUsername.value = ''
|
||||
loginPassword.value = ''
|
||||
$('.circle-loader').toggleClass('load-complete')
|
||||
$('.checkmark').toggle()
|
||||
loginLoading(false)
|
||||
loginButton.innerHTML = loginButton.innerHTML.replace(Lang.queryJS('login.success'), Lang.queryJS('login.login'))
|
||||
formDisabled(false)
|
||||
})
|
||||
}, 1000)
|
||||
}).catch((displayableError) => {
|
||||
loginLoading(false)
|
||||
|
||||
let actualDisplayableError
|
||||
if(isDisplayableError(displayableError)) {
|
||||
msftLoginLogger.error('Error while logging in.', displayableError)
|
||||
actualDisplayableError = displayableError
|
||||
} else {
|
||||
// Uh oh.
|
||||
msftLoginLogger.error('Unhandled error during login.', displayableError)
|
||||
actualDisplayableError = Lang.queryJS('login.error.unknown')
|
||||
}
|
||||
|
||||
setOverlayContent(actualDisplayableError.title, actualDisplayableError.desc, Lang.queryJS('login.tryAgain'))
|
||||
setOverlayHandler(() => {
|
||||
formDisabled(false)
|
||||
toggleOverlay(false)
|
||||
})
|
||||
toggleOverlay(true)
|
||||
})
|
||||
|
||||
})
|
||||
50
app/assets/js/scripts/loginOptions.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const loginOptionsCancelContainer = document.getElementById('loginOptionCancelContainer')
|
||||
const loginOptionMicrosoft = document.getElementById('loginOptionMicrosoft')
|
||||
const loginOptionMojang = document.getElementById('loginOptionMojang')
|
||||
const loginOptionsCancelButton = document.getElementById('loginOptionCancelButton')
|
||||
|
||||
let loginOptionsCancellable = false
|
||||
|
||||
let loginOptionsViewOnLoginSuccess
|
||||
let loginOptionsViewOnLoginCancel
|
||||
let loginOptionsViewOnCancel
|
||||
let loginOptionsViewCancelHandler
|
||||
|
||||
function loginOptionsCancelEnabled(val){
|
||||
if(val){
|
||||
$(loginOptionsCancelContainer).show()
|
||||
} else {
|
||||
$(loginOptionsCancelContainer).hide()
|
||||
}
|
||||
}
|
||||
|
||||
loginOptionMicrosoft.onclick = (e) => {
|
||||
switchView(getCurrentView(), VIEWS.waiting, 500, 500, () => {
|
||||
ipcRenderer.send(
|
||||
MSFT_OPCODE.OPEN_LOGIN,
|
||||
loginOptionsViewOnLoginSuccess,
|
||||
loginOptionsViewOnLoginCancel
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
loginOptionMojang.onclick = (e) => {
|
||||
switchView(getCurrentView(), VIEWS.login, 500, 500, () => {
|
||||
loginViewOnSuccess = loginOptionsViewOnLoginSuccess
|
||||
loginViewOnCancel = loginOptionsViewOnLoginCancel
|
||||
loginCancelEnabled(true)
|
||||
})
|
||||
}
|
||||
|
||||
loginOptionsCancelButton.onclick = (e) => {
|
||||
switchView(getCurrentView(), loginOptionsViewOnCancel, 500, 500, () => {
|
||||
// Clear login values (Mojang login)
|
||||
// No cleanup needed for Microsoft.
|
||||
loginUsername.value = ''
|
||||
loginPassword.value = ''
|
||||
if(loginOptionsViewCancelHandler != null){
|
||||
loginOptionsViewCancelHandler()
|
||||
loginOptionsViewCancelHandler = null
|
||||
}
|
||||
})
|
||||
}
|
||||
324
app/assets/js/scripts/overlay.js
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Script for overlay.ejs
|
||||
*/
|
||||
|
||||
/* Overlay Wrapper Functions */
|
||||
|
||||
/**
|
||||
* Check to see if the overlay is visible.
|
||||
*
|
||||
* @returns {boolean} Whether or not the overlay is visible.
|
||||
*/
|
||||
function isOverlayVisible(){
|
||||
return document.getElementById('main').hasAttribute('overlay')
|
||||
}
|
||||
|
||||
let overlayHandlerContent
|
||||
|
||||
/**
|
||||
* Overlay keydown handler for a non-dismissable overlay.
|
||||
*
|
||||
* @param {KeyboardEvent} e The keydown event.
|
||||
*/
|
||||
function overlayKeyHandler (e){
|
||||
if(e.key === 'Enter' || e.key === 'Escape'){
|
||||
document.getElementById(overlayHandlerContent).getElementsByClassName('overlayKeybindEnter')[0].click()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Overlay keydown handler for a dismissable overlay.
|
||||
*
|
||||
* @param {KeyboardEvent} e The keydown event.
|
||||
*/
|
||||
function overlayKeyDismissableHandler (e){
|
||||
if(e.key === 'Enter'){
|
||||
document.getElementById(overlayHandlerContent).getElementsByClassName('overlayKeybindEnter')[0].click()
|
||||
} else if(e.key === 'Escape'){
|
||||
document.getElementById(overlayHandlerContent).getElementsByClassName('overlayKeybindEsc')[0].click()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind overlay keydown listeners for escape and exit.
|
||||
*
|
||||
* @param {boolean} state Whether or not to add new event listeners.
|
||||
* @param {string} content The overlay content which will be shown.
|
||||
* @param {boolean} dismissable Whether or not the overlay is dismissable
|
||||
*/
|
||||
function bindOverlayKeys(state, content, dismissable){
|
||||
overlayHandlerContent = content
|
||||
document.removeEventListener('keydown', overlayKeyHandler)
|
||||
document.removeEventListener('keydown', overlayKeyDismissableHandler)
|
||||
if(state){
|
||||
if(dismissable){
|
||||
document.addEventListener('keydown', overlayKeyDismissableHandler)
|
||||
} else {
|
||||
document.addEventListener('keydown', overlayKeyHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the visibility of the overlay.
|
||||
*
|
||||
* @param {boolean} toggleState True to display, false to hide.
|
||||
* @param {boolean} dismissable Optional. True to show the dismiss option, otherwise false.
|
||||
* @param {string} content Optional. The content div to be shown.
|
||||
*/
|
||||
function toggleOverlay(toggleState, dismissable = false, content = 'overlayContent'){
|
||||
if(toggleState == null){
|
||||
toggleState = !document.getElementById('main').hasAttribute('overlay')
|
||||
}
|
||||
if(typeof dismissable === 'string'){
|
||||
content = dismissable
|
||||
dismissable = false
|
||||
}
|
||||
bindOverlayKeys(toggleState, content, dismissable)
|
||||
if(toggleState){
|
||||
document.getElementById('main').setAttribute('overlay', true)
|
||||
// Make things untabbable.
|
||||
$('#main *').attr('tabindex', '-1')
|
||||
$('#' + content).parent().children().hide()
|
||||
$('#' + content).show()
|
||||
if(dismissable){
|
||||
$('#overlayDismiss').show()
|
||||
} else {
|
||||
$('#overlayDismiss').hide()
|
||||
}
|
||||
$('#overlayContainer').fadeIn({
|
||||
duration: 250,
|
||||
start: () => {
|
||||
if(getCurrentView() === VIEWS.settings){
|
||||
document.getElementById('settingsContainer').style.backgroundColor = 'transparent'
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
document.getElementById('main').removeAttribute('overlay')
|
||||
// Make things tabbable.
|
||||
$('#main *').removeAttr('tabindex')
|
||||
$('#overlayContainer').fadeOut({
|
||||
duration: 250,
|
||||
start: () => {
|
||||
if(getCurrentView() === VIEWS.settings){
|
||||
document.getElementById('settingsContainer').style.backgroundColor = 'rgba(0, 0, 0, 0.50)'
|
||||
}
|
||||
},
|
||||
complete: () => {
|
||||
$('#' + content).parent().children().hide()
|
||||
$('#' + content).show()
|
||||
if(dismissable){
|
||||
$('#overlayDismiss').show()
|
||||
} else {
|
||||
$('#overlayDismiss').hide()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleServerSelection(toggleState){
|
||||
await prepareServerSelectionList()
|
||||
toggleOverlay(toggleState, true, 'serverSelectContent')
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the content of the overlay.
|
||||
*
|
||||
* @param {string} title Overlay title text.
|
||||
* @param {string} description Overlay description text.
|
||||
* @param {string} acknowledge Acknowledge button text.
|
||||
* @param {string} dismiss Dismiss button text.
|
||||
*/
|
||||
function setOverlayContent(title, description, acknowledge, dismiss = Lang.queryJS('overlay.dismiss')){
|
||||
document.getElementById('overlayTitle').innerHTML = title
|
||||
document.getElementById('overlayDesc').innerHTML = description
|
||||
document.getElementById('overlayAcknowledge').innerHTML = acknowledge
|
||||
document.getElementById('overlayDismiss').innerHTML = dismiss
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the onclick handler of the overlay acknowledge button.
|
||||
* If the handler is null, a default handler will be added.
|
||||
*
|
||||
* @param {function} handler
|
||||
*/
|
||||
function setOverlayHandler(handler){
|
||||
if(handler == null){
|
||||
document.getElementById('overlayAcknowledge').onclick = () => {
|
||||
toggleOverlay(false)
|
||||
}
|
||||
} else {
|
||||
document.getElementById('overlayAcknowledge').onclick = handler
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the onclick handler of the overlay dismiss button.
|
||||
* If the handler is null, a default handler will be added.
|
||||
*
|
||||
* @param {function} handler
|
||||
*/
|
||||
function setDismissHandler(handler){
|
||||
if(handler == null){
|
||||
document.getElementById('overlayDismiss').onclick = () => {
|
||||
toggleOverlay(false)
|
||||
}
|
||||
} else {
|
||||
document.getElementById('overlayDismiss').onclick = handler
|
||||
}
|
||||
}
|
||||
|
||||
/* Server Select View */
|
||||
|
||||
document.getElementById('serverSelectConfirm').addEventListener('click', async () => {
|
||||
const listings = document.getElementsByClassName('serverListing')
|
||||
for(let i=0; i<listings.length; i++){
|
||||
if(listings[i].hasAttribute('selected')){
|
||||
const serv = (await DistroAPI.getDistribution()).getServerById(listings[i].getAttribute('servid'))
|
||||
updateSelectedServer(serv)
|
||||
refreshServerStatus(true)
|
||||
toggleOverlay(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
// None are selected? Not possible right? Meh, handle it.
|
||||
if(listings.length > 0){
|
||||
const serv = (await DistroAPI.getDistribution()).getServerById(listings[i].getAttribute('servid'))
|
||||
updateSelectedServer(serv)
|
||||
toggleOverlay(false)
|
||||
}
|
||||
})
|
||||
|
||||
document.getElementById('accountSelectConfirm').addEventListener('click', async () => {
|
||||
const listings = document.getElementsByClassName('accountListing')
|
||||
for(let i=0; i<listings.length; i++){
|
||||
if(listings[i].hasAttribute('selected')){
|
||||
const authAcc = ConfigManager.setSelectedAccount(listings[i].getAttribute('uuid'))
|
||||
ConfigManager.save()
|
||||
updateSelectedAccount(authAcc)
|
||||
if(getCurrentView() === VIEWS.settings) {
|
||||
await prepareSettings()
|
||||
}
|
||||
toggleOverlay(false)
|
||||
validateSelectedAccount()
|
||||
return
|
||||
}
|
||||
}
|
||||
// None are selected? Not possible right? Meh, handle it.
|
||||
if(listings.length > 0){
|
||||
const authAcc = ConfigManager.setSelectedAccount(listings[0].getAttribute('uuid'))
|
||||
ConfigManager.save()
|
||||
updateSelectedAccount(authAcc)
|
||||
if(getCurrentView() === VIEWS.settings) {
|
||||
await prepareSettings()
|
||||
}
|
||||
toggleOverlay(false)
|
||||
validateSelectedAccount()
|
||||
}
|
||||
})
|
||||
|
||||
// Bind server select cancel button.
|
||||
document.getElementById('serverSelectCancel').addEventListener('click', () => {
|
||||
toggleOverlay(false)
|
||||
})
|
||||
|
||||
document.getElementById('accountSelectCancel').addEventListener('click', () => {
|
||||
$('#accountSelectContent').fadeOut(250, () => {
|
||||
$('#overlayContent').fadeIn(250)
|
||||
})
|
||||
})
|
||||
|
||||
function setServerListingHandlers(){
|
||||
const listings = Array.from(document.getElementsByClassName('serverListing'))
|
||||
listings.map((val) => {
|
||||
val.onclick = e => {
|
||||
if(val.hasAttribute('selected')){
|
||||
return
|
||||
}
|
||||
const cListings = document.getElementsByClassName('serverListing')
|
||||
for(let i=0; i<cListings.length; i++){
|
||||
if(cListings[i].hasAttribute('selected')){
|
||||
cListings[i].removeAttribute('selected')
|
||||
}
|
||||
}
|
||||
val.setAttribute('selected', '')
|
||||
document.activeElement.blur()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setAccountListingHandlers(){
|
||||
const listings = Array.from(document.getElementsByClassName('accountListing'))
|
||||
listings.map((val) => {
|
||||
val.onclick = e => {
|
||||
if(val.hasAttribute('selected')){
|
||||
return
|
||||
}
|
||||
const cListings = document.getElementsByClassName('accountListing')
|
||||
for(let i=0; i<cListings.length; i++){
|
||||
if(cListings[i].hasAttribute('selected')){
|
||||
cListings[i].removeAttribute('selected')
|
||||
}
|
||||
}
|
||||
val.setAttribute('selected', '')
|
||||
document.activeElement.blur()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function populateServerListings(){
|
||||
const distro = await DistroAPI.getDistribution()
|
||||
const giaSel = ConfigManager.getSelectedServer()
|
||||
const servers = distro.servers
|
||||
let htmlString = ''
|
||||
for(const serv of servers){
|
||||
htmlString += `<button class="serverListing" servid="${serv.rawServer.id}" ${serv.rawServer.id === giaSel ? 'selected' : ''}>
|
||||
<img class="serverListingImg" src="${serv.rawServer.icon}"/>
|
||||
<div class="serverListingDetails">
|
||||
<span class="serverListingName">${serv.rawServer.name}</span>
|
||||
<span class="serverListingDescription">${serv.rawServer.description}</span>
|
||||
<div class="serverListingInfo">
|
||||
<div class="serverListingVersion">${serv.rawServer.minecraftVersion}</div>
|
||||
<div class="serverListingRevision">${serv.rawServer.version}</div>
|
||||
${serv.rawServer.mainServer ? `<div class="serverListingStarWrapper">
|
||||
<svg id="Layer_1" viewBox="0 0 107.45 104.74" width="20px" height="20px">
|
||||
<defs>
|
||||
<style>.cls-1{fill:#fff;}.cls-2{fill:none;stroke:#fff;stroke-miterlimit:10;}</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M100.93,65.54C89,62,68.18,55.65,63.54,52.13c2.7-5.23,18.8-19.2,28-27.55C81.36,31.74,63.74,43.87,58.09,45.3c-2.41-5.37-3.61-26.52-4.37-39-.77,12.46-2,33.64-4.36,39-5.7-1.46-23.3-13.57-33.49-20.72,9.26,8.37,25.39,22.36,28,27.55C39.21,55.68,18.47,62,6.52,65.55c12.32-2,33.63-6.06,39.34-4.9-.16,5.87-8.41,26.16-13.11,37.69,6.1-10.89,16.52-30.16,21-33.9,4.5,3.79,14.93,23.09,21,34C70,86.84,61.73,66.48,61.59,60.65,67.36,59.49,88.64,63.52,100.93,65.54Z"/>
|
||||
<circle class="cls-2" cx="53.73" cy="53.9" r="38"/>
|
||||
</svg>
|
||||
<span class="serverListingStarTooltip">${Lang.queryJS('settings.serverListing.mainServer')}</span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</button>`
|
||||
}
|
||||
document.getElementById('serverSelectListScrollable').innerHTML = htmlString
|
||||
|
||||
}
|
||||
|
||||
function populateAccountListings(){
|
||||
const accountsObj = ConfigManager.getAuthAccounts()
|
||||
const accounts = Array.from(Object.keys(accountsObj), v=>accountsObj[v])
|
||||
let htmlString = ''
|
||||
for(let i=0; i<accounts.length; i++){
|
||||
htmlString += `<button class="accountListing" uuid="${accounts[i].uuid}" ${i===0 ? 'selected' : ''}>
|
||||
<img src="https://mc-heads.net/head/${accounts[i].uuid}/40">
|
||||
<div class="accountListingName">${accounts[i].displayName}</div>
|
||||
</button>`
|
||||
}
|
||||
document.getElementById('accountSelectListScrollable').innerHTML = htmlString
|
||||
|
||||
}
|
||||
|
||||
async function prepareServerSelectionList(){
|
||||
await populateServerListings()
|
||||
setServerListingHandlers()
|
||||
}
|
||||
|
||||
function prepareAccountSelectionList(){
|
||||
populateAccountListings()
|
||||
setAccountListingHandlers()
|
||||
}
|
||||
1583
app/assets/js/scripts/settings.js
Normal file
468
app/assets/js/scripts/uibinder.js
Normal file
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* Initialize UI functions which depend on internal modules.
|
||||
* Loaded after core UI functions are initialized in uicore.js.
|
||||
*/
|
||||
// Requirements
|
||||
const path = require('path')
|
||||
const { Type } = require('helios-distribution-types')
|
||||
|
||||
const AuthManager = require('./assets/js/authmanager')
|
||||
const ConfigManager = require('./assets/js/configmanager')
|
||||
const { DistroAPI } = require('./assets/js/distromanager')
|
||||
|
||||
let rscShouldLoad = false
|
||||
let fatalStartupError = false
|
||||
|
||||
// Mapping of each view to their container IDs.
|
||||
const VIEWS = {
|
||||
landing: '#landingContainer',
|
||||
library: '#libraryContainer',
|
||||
install: '#installContainer',
|
||||
loginOptions: '#loginOptionsContainer',
|
||||
login: '#loginContainer',
|
||||
settings: '#settingsContainer',
|
||||
welcome: '#welcomeContainer',
|
||||
waiting: '#waitingContainer'
|
||||
}
|
||||
|
||||
// The currently shown view container.
|
||||
let currentView
|
||||
|
||||
/**
|
||||
* Switch launcher views.
|
||||
*
|
||||
* @param {string} current The ID of the current view container.
|
||||
* @param {*} next The ID of the next view container.
|
||||
* @param {*} currentFadeTime Optional. The fade out time for the current view.
|
||||
* @param {*} nextFadeTime Optional. The fade in time for the next view.
|
||||
* @param {*} onCurrentFade Optional. Callback function to execute when the current
|
||||
* view fades out.
|
||||
* @param {*} onNextFade Optional. Callback function to execute when the next view
|
||||
* fades in.
|
||||
*/
|
||||
function switchView(current, next, currentFadeTime = 500, nextFadeTime = 500, onCurrentFade = () => {}, onNextFade = () => {}){
|
||||
currentView = next
|
||||
$(`${current}`).fadeOut(currentFadeTime, async () => {
|
||||
await onCurrentFade()
|
||||
$(`${next}`).fadeIn(nextFadeTime, async () => {
|
||||
await onNextFade()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently shown view container.
|
||||
*
|
||||
* @returns {string} The currently shown view container.
|
||||
*/
|
||||
function getCurrentView(){
|
||||
return currentView
|
||||
}
|
||||
|
||||
async function showMainUI(data){
|
||||
|
||||
if(!isDev){
|
||||
loggerAutoUpdater.info('Initializing..')
|
||||
ipcRenderer.send('autoUpdateAction', 'initAutoUpdater', ConfigManager.getAllowPrerelease())
|
||||
}
|
||||
|
||||
await prepareSettings(true)
|
||||
updateSelectedServer(data.getServerById(ConfigManager.getSelectedServer()))
|
||||
refreshServerStatus()
|
||||
setTimeout(() => {
|
||||
document.getElementById('frameBar').style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
|
||||
document.body.style.backgroundImage = `url('assets/images/backgrounds/${document.body.getAttribute('bkid')}.png')`
|
||||
$('#main').show()
|
||||
|
||||
const isLoggedIn = Object.keys(ConfigManager.getAuthAccounts()).length > 0
|
||||
|
||||
// If this is enabled in a development environment we'll get ratelimited.
|
||||
// The relaunch frequency is usually far too high.
|
||||
if(!isDev && isLoggedIn){
|
||||
validateSelectedAccount()
|
||||
}
|
||||
|
||||
if(ConfigManager.isFirstLaunch()){
|
||||
currentView = VIEWS.welcome
|
||||
$(VIEWS.welcome).fadeIn(1000)
|
||||
} else {
|
||||
if(isLoggedIn){
|
||||
currentView = VIEWS.library
|
||||
$(VIEWS.library).fadeIn(1000)
|
||||
} else {
|
||||
loginOptionsCancelEnabled(false)
|
||||
loginOptionsViewOnLoginSuccess = VIEWS.library
|
||||
loginOptionsViewOnLoginCancel = VIEWS.loginOptions
|
||||
currentView = VIEWS.loginOptions
|
||||
$(VIEWS.loginOptions).fadeIn(1000)
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
$('#loadingContainer').fadeOut(500, () => {
|
||||
$('#loadSpinnerImage').removeClass('rotating')
|
||||
})
|
||||
}, 250)
|
||||
|
||||
}, 750)
|
||||
// Disable tabbing to the news container.
|
||||
initNews().then(() => {
|
||||
$('#newsContainer *').attr('tabindex', '-1')
|
||||
})
|
||||
}
|
||||
|
||||
function showFatalStartupError(){
|
||||
setTimeout(() => {
|
||||
$('#loadingContainer').fadeOut(250, () => {
|
||||
document.getElementById('overlayContainer').style.background = 'none'
|
||||
setOverlayContent(
|
||||
Lang.queryJS('uibinder.startup.fatalErrorTitle'),
|
||||
Lang.queryJS('uibinder.startup.fatalErrorMessage'),
|
||||
Lang.queryJS('uibinder.startup.closeButton')
|
||||
)
|
||||
setOverlayHandler(() => {
|
||||
const window = remote.getCurrentWindow()
|
||||
window.close()
|
||||
})
|
||||
toggleOverlay(true)
|
||||
})
|
||||
}, 750)
|
||||
}
|
||||
|
||||
/**
|
||||
* Common functions to perform after refreshing the distro index.
|
||||
*
|
||||
* @param {Object} data The distro index object.
|
||||
*/
|
||||
function onDistroRefresh(data){
|
||||
updateSelectedServer(data.getServerById(ConfigManager.getSelectedServer()))
|
||||
refreshServerStatus()
|
||||
initNews()
|
||||
syncModConfigurations(data)
|
||||
ensureJavaSettings(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the mod configurations with the distro index.
|
||||
*
|
||||
* @param {Object} data The distro index object.
|
||||
*/
|
||||
function syncModConfigurations(data){
|
||||
|
||||
const syncedCfgs = []
|
||||
|
||||
for(let serv of data.servers){
|
||||
|
||||
const id = serv.rawServer.id
|
||||
const mdls = serv.modules
|
||||
const cfg = ConfigManager.getModConfiguration(id)
|
||||
|
||||
if(cfg != null){
|
||||
|
||||
const modsOld = cfg.mods
|
||||
const mods = {}
|
||||
|
||||
for(let mdl of mdls){
|
||||
const type = mdl.rawModule.type
|
||||
|
||||
if(type === Type.ForgeMod || type === Type.LiteMod || type === Type.LiteLoader || type === Type.FabricMod){
|
||||
if(!mdl.getRequired().value){
|
||||
const mdlID = mdl.getVersionlessMavenIdentifier()
|
||||
if(modsOld[mdlID] == null){
|
||||
mods[mdlID] = scanOptionalSubModules(mdl.subModules, mdl)
|
||||
} else {
|
||||
mods[mdlID] = mergeModConfiguration(modsOld[mdlID], scanOptionalSubModules(mdl.subModules, mdl), false)
|
||||
}
|
||||
} else {
|
||||
if(mdl.subModules.length > 0){
|
||||
const mdlID = mdl.getVersionlessMavenIdentifier()
|
||||
const v = scanOptionalSubModules(mdl.subModules, mdl)
|
||||
if(typeof v === 'object'){
|
||||
if(modsOld[mdlID] == null){
|
||||
mods[mdlID] = v
|
||||
} else {
|
||||
mods[mdlID] = mergeModConfiguration(modsOld[mdlID], v, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
syncedCfgs.push({
|
||||
id,
|
||||
mods
|
||||
})
|
||||
|
||||
} else {
|
||||
|
||||
const mods = {}
|
||||
|
||||
for(let mdl of mdls){
|
||||
const type = mdl.rawModule.type
|
||||
if(type === Type.ForgeMod || type === Type.LiteMod || type === Type.LiteLoader || type === Type.FabricMod){
|
||||
if(!mdl.getRequired().value){
|
||||
mods[mdl.getVersionlessMavenIdentifier()] = scanOptionalSubModules(mdl.subModules, mdl)
|
||||
} else {
|
||||
if(mdl.subModules.length > 0){
|
||||
const v = scanOptionalSubModules(mdl.subModules, mdl)
|
||||
if(typeof v === 'object'){
|
||||
mods[mdl.getVersionlessMavenIdentifier()] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
syncedCfgs.push({
|
||||
id,
|
||||
mods
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
ConfigManager.setModConfigurations(syncedCfgs)
|
||||
ConfigManager.save()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure java configurations are present for the available servers.
|
||||
*
|
||||
* @param {Object} data The distro index object.
|
||||
*/
|
||||
function ensureJavaSettings(data) {
|
||||
|
||||
// Nothing too fancy for now.
|
||||
for(const serv of data.servers){
|
||||
ConfigManager.ensureJavaConfig(serv.rawServer.id, serv.effectiveJavaOptions, serv.rawServer.javaOptions?.ram)
|
||||
}
|
||||
|
||||
ConfigManager.save()
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scan for optional sub modules. If none are found,
|
||||
* this function returns a boolean. If optional sub modules do exist,
|
||||
* a recursive configuration object is returned.
|
||||
*
|
||||
* @returns {boolean | Object} The resolved mod configuration.
|
||||
*/
|
||||
function scanOptionalSubModules(mdls, origin){
|
||||
if(mdls != null){
|
||||
const mods = {}
|
||||
|
||||
for(let mdl of mdls){
|
||||
const type = mdl.rawModule.type
|
||||
// Optional types.
|
||||
if(type === Type.ForgeMod || type === Type.LiteMod || type === Type.LiteLoader || type === Type.FabricMod){
|
||||
// It is optional.
|
||||
if(!mdl.getRequired().value){
|
||||
mods[mdl.getVersionlessMavenIdentifier()] = scanOptionalSubModules(mdl.subModules, mdl)
|
||||
} else {
|
||||
if(mdl.hasSubModules()){
|
||||
const v = scanOptionalSubModules(mdl.subModules, mdl)
|
||||
if(typeof v === 'object'){
|
||||
mods[mdl.getVersionlessMavenIdentifier()] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(Object.keys(mods).length > 0){
|
||||
const ret = {
|
||||
mods
|
||||
}
|
||||
if(!origin.getRequired().value){
|
||||
ret.value = origin.getRequired().def
|
||||
}
|
||||
return ret
|
||||
}
|
||||
}
|
||||
return origin.getRequired().def
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively merge an old configuration into a new configuration.
|
||||
*
|
||||
* @param {boolean | Object} o The old configuration value.
|
||||
* @param {boolean | Object} n The new configuration value.
|
||||
* @param {boolean} nReq If the new value is a required mod.
|
||||
*
|
||||
* @returns {boolean | Object} The merged configuration.
|
||||
*/
|
||||
function mergeModConfiguration(o, n, nReq = false){
|
||||
if(typeof o === 'boolean'){
|
||||
if(typeof n === 'boolean') return o
|
||||
else if(typeof n === 'object'){
|
||||
if(!nReq){
|
||||
n.value = o
|
||||
}
|
||||
return n
|
||||
}
|
||||
} else if(typeof o === 'object'){
|
||||
if(typeof n === 'boolean') return typeof o.value !== 'undefined' ? o.value : true
|
||||
else if(typeof n === 'object'){
|
||||
if(!nReq){
|
||||
n.value = typeof o.value !== 'undefined' ? o.value : true
|
||||
}
|
||||
|
||||
const newMods = Object.keys(n.mods)
|
||||
for(let i=0; i<newMods.length; i++){
|
||||
|
||||
const mod = newMods[i]
|
||||
if(o.mods[mod] != null){
|
||||
n.mods[mod] = mergeModConfiguration(o.mods[mod], n.mods[mod])
|
||||
}
|
||||
}
|
||||
|
||||
return n
|
||||
}
|
||||
}
|
||||
// If for some reason we haven't been able to merge,
|
||||
// wipe the old value and use the new one. Just to be safe
|
||||
return n
|
||||
}
|
||||
|
||||
async function validateSelectedAccount(){
|
||||
const selectedAcc = ConfigManager.getSelectedAccount()
|
||||
if(selectedAcc != null){
|
||||
const val = await AuthManager.validateSelected()
|
||||
if(!val){
|
||||
ConfigManager.removeAuthAccount(selectedAcc.uuid)
|
||||
ConfigManager.save()
|
||||
const accLen = Object.keys(ConfigManager.getAuthAccounts()).length
|
||||
setOverlayContent(
|
||||
Lang.queryJS('uibinder.validateAccount.failedMessageTitle'),
|
||||
accLen > 0
|
||||
? Lang.queryJS('uibinder.validateAccount.failedMessage', { 'account': selectedAcc.displayName })
|
||||
: Lang.queryJS('uibinder.validateAccount.failedMessageSelectAnotherAccount', { 'account': selectedAcc.displayName }),
|
||||
Lang.queryJS('uibinder.validateAccount.loginButton'),
|
||||
Lang.queryJS('uibinder.validateAccount.selectAnotherAccountButton')
|
||||
)
|
||||
setOverlayHandler(() => {
|
||||
|
||||
const isMicrosoft = selectedAcc.type === 'microsoft'
|
||||
|
||||
if(isMicrosoft) {
|
||||
// Empty for now
|
||||
} else {
|
||||
// Mojang
|
||||
// For convenience, pre-populate the username of the account.
|
||||
document.getElementById('loginUsername').value = selectedAcc.username
|
||||
validateEmail(selectedAcc.username)
|
||||
}
|
||||
|
||||
loginOptionsViewOnLoginSuccess = getCurrentView()
|
||||
loginOptionsViewOnLoginCancel = VIEWS.loginOptions
|
||||
|
||||
if(accLen > 0) {
|
||||
loginOptionsViewOnCancel = getCurrentView()
|
||||
loginOptionsViewCancelHandler = () => {
|
||||
if(isMicrosoft) {
|
||||
ConfigManager.addMicrosoftAuthAccount(
|
||||
selectedAcc.uuid,
|
||||
selectedAcc.accessToken,
|
||||
selectedAcc.username,
|
||||
selectedAcc.expiresAt,
|
||||
selectedAcc.microsoft.access_token,
|
||||
selectedAcc.microsoft.refresh_token,
|
||||
selectedAcc.microsoft.expires_at
|
||||
)
|
||||
} else {
|
||||
ConfigManager.addMojangAuthAccount(selectedAcc.uuid, selectedAcc.accessToken, selectedAcc.username, selectedAcc.displayName)
|
||||
}
|
||||
ConfigManager.save()
|
||||
validateSelectedAccount()
|
||||
}
|
||||
loginOptionsCancelEnabled(true)
|
||||
} else {
|
||||
loginOptionsCancelEnabled(false)
|
||||
}
|
||||
toggleOverlay(false)
|
||||
switchView(getCurrentView(), VIEWS.loginOptions)
|
||||
})
|
||||
setDismissHandler(() => {
|
||||
if(accLen > 1){
|
||||
prepareAccountSelectionList()
|
||||
$('#overlayContent').fadeOut(250, () => {
|
||||
bindOverlayKeys(true, 'accountSelectContent', true)
|
||||
$('#accountSelectContent').fadeIn(250)
|
||||
})
|
||||
} else {
|
||||
const accountsObj = ConfigManager.getAuthAccounts()
|
||||
const accounts = Array.from(Object.keys(accountsObj), v => accountsObj[v])
|
||||
// This function validates the account switch.
|
||||
setSelectedAccount(accounts[0].uuid)
|
||||
toggleOverlay(false)
|
||||
}
|
||||
})
|
||||
toggleOverlay(true, accLen > 0)
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporary function to update the selected account along
|
||||
* with the relevent UI elements.
|
||||
*
|
||||
* @param {string} uuid The UUID of the account.
|
||||
*/
|
||||
function setSelectedAccount(uuid){
|
||||
const authAcc = ConfigManager.setSelectedAccount(uuid)
|
||||
ConfigManager.save()
|
||||
updateSelectedAccount(authAcc)
|
||||
validateSelectedAccount()
|
||||
}
|
||||
|
||||
// Synchronous Listener
|
||||
document.addEventListener('readystatechange', async () => {
|
||||
|
||||
if (document.readyState === 'interactive' || document.readyState === 'complete'){
|
||||
if(rscShouldLoad){
|
||||
rscShouldLoad = false
|
||||
if(!fatalStartupError){
|
||||
const data = await DistroAPI.getDistribution()
|
||||
await showMainUI(data)
|
||||
} else {
|
||||
showFatalStartupError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}, false)
|
||||
|
||||
// Actions that must be performed after the distribution index is downloaded.
|
||||
ipcRenderer.on('distributionIndexDone', async (event, res) => {
|
||||
if(res) {
|
||||
const data = await DistroAPI.getDistribution()
|
||||
syncModConfigurations(data)
|
||||
ensureJavaSettings(data)
|
||||
if(document.readyState === 'interactive' || document.readyState === 'complete'){
|
||||
await showMainUI(data)
|
||||
} else {
|
||||
rscShouldLoad = true
|
||||
}
|
||||
} else {
|
||||
fatalStartupError = true
|
||||
if(document.readyState === 'interactive' || document.readyState === 'complete'){
|
||||
showFatalStartupError()
|
||||
} else {
|
||||
rscShouldLoad = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Util for development
|
||||
async function devModeToggle() {
|
||||
DistroAPI.toggleDevMode(true)
|
||||
const data = await DistroAPI.refreshDistributionOrFallback()
|
||||
ensureJavaSettings(data)
|
||||
updateSelectedServer(data.servers[0])
|
||||
syncModConfigurations(data)
|
||||
}
|
||||
213
app/assets/js/scripts/uicore.js
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Core UI functions are initialized in this file. This prevents
|
||||
* unexpected errors from breaking the core features. Specifically,
|
||||
* actions in this file should not require the usage of any internal
|
||||
* modules, excluding dependencies.
|
||||
*/
|
||||
// Requirements
|
||||
const $ = require('jquery')
|
||||
const {ipcRenderer, shell, webFrame} = require('electron')
|
||||
const remote = require('@electron/remote')
|
||||
const isDev = require('./assets/js/isdev')
|
||||
const { LoggerUtil } = require('helios-core')
|
||||
const Lang = require('./assets/js/langloader')
|
||||
|
||||
const loggerUICore = LoggerUtil.getLogger('UICore')
|
||||
const loggerAutoUpdater = LoggerUtil.getLogger('AutoUpdater')
|
||||
|
||||
// Log deprecation and process warnings.
|
||||
process.traceProcessWarnings = true
|
||||
process.traceDeprecation = true
|
||||
|
||||
// Disable eval function.
|
||||
window.eval = global.eval = function () {
|
||||
throw new Error('Sorry, this app does not support window.eval().')
|
||||
}
|
||||
|
||||
// Display warning when devtools window is opened.
|
||||
remote.getCurrentWebContents().on('devtools-opened', () => {
|
||||
console.log('%cThe console is dark and full of terrors.', 'color: white; -webkit-text-stroke: 4px #a02d2a; font-size: 60px; font-weight: bold')
|
||||
console.log('%cIf you\'ve been told to paste something here, you\'re being scammed.', 'font-size: 16px')
|
||||
console.log('%cUnless you know exactly what you\'re doing, close this window.', 'font-size: 16px')
|
||||
})
|
||||
|
||||
// Disable zoom, needed for darwin.
|
||||
webFrame.setZoomLevel(0)
|
||||
webFrame.setVisualZoomLevelLimits(1, 1)
|
||||
|
||||
// Initialize auto updates in production environments.
|
||||
let updateCheckListener
|
||||
if(!isDev){
|
||||
ipcRenderer.on('autoUpdateNotification', (event, arg, info) => {
|
||||
switch(arg){
|
||||
case 'checking-for-update':
|
||||
loggerAutoUpdater.info('Checking for update..')
|
||||
settingsUpdateButtonStatus(Lang.queryJS('uicore.autoUpdate.checkingForUpdateButton'), true)
|
||||
break
|
||||
case 'update-available':
|
||||
loggerAutoUpdater.info('New update available', info.version)
|
||||
|
||||
if(process.platform === 'darwin'){
|
||||
info.darwindownload = `https://github.com/peunsu/MRSLauncher/releases/download/v${info.version}/MRS-Launcher-setup-${info.version}${process.arch === 'arm64' ? '-arm64' : '-x64'}.dmg`
|
||||
showUpdateUI(info)
|
||||
}
|
||||
|
||||
populateSettingsUpdateInformation(info)
|
||||
break
|
||||
case 'update-downloaded':
|
||||
loggerAutoUpdater.info('Update ' + info.version + ' ready to be installed.')
|
||||
settingsUpdateButtonStatus(Lang.queryJS('uicore.autoUpdate.installNowButton'), false, () => {
|
||||
if(!isDev){
|
||||
ipcRenderer.send('autoUpdateAction', 'installUpdateNow')
|
||||
}
|
||||
})
|
||||
showUpdateUI(info)
|
||||
break
|
||||
case 'update-not-available':
|
||||
loggerAutoUpdater.info('No new update found.')
|
||||
settingsUpdateButtonStatus(Lang.queryJS('uicore.autoUpdate.checkForUpdatesButton'))
|
||||
break
|
||||
case 'ready':
|
||||
updateCheckListener = setInterval(() => {
|
||||
ipcRenderer.send('autoUpdateAction', 'checkForUpdate')
|
||||
}, 1800000)
|
||||
ipcRenderer.send('autoUpdateAction', 'checkForUpdate')
|
||||
break
|
||||
case 'realerror':
|
||||
if(info != null && info.code != null){
|
||||
if(info.code === 'ERR_UPDATER_INVALID_RELEASE_FEED'){
|
||||
loggerAutoUpdater.info('No suitable releases found.')
|
||||
} else if(info.code === 'ERR_XML_MISSED_ELEMENT'){
|
||||
loggerAutoUpdater.info('No releases found.')
|
||||
} else {
|
||||
loggerAutoUpdater.error('Error during update check..', info)
|
||||
loggerAutoUpdater.debug('Error Code:', info.code)
|
||||
}
|
||||
}
|
||||
break
|
||||
default:
|
||||
loggerAutoUpdater.info('Unknown argument', arg)
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to the main process changing the value of
|
||||
* allowPrerelease. If we are running a prerelease version, then
|
||||
* this will always be set to true, regardless of the current value
|
||||
* of val.
|
||||
*
|
||||
* @param {boolean} val The new allow prerelease value.
|
||||
*/
|
||||
function changeAllowPrerelease(val){
|
||||
ipcRenderer.send('autoUpdateAction', 'allowPrereleaseChange', val)
|
||||
}
|
||||
|
||||
function showUpdateUI(info){
|
||||
//TODO Make this message a bit more informative `${info.version}`
|
||||
document.getElementById('image_seal_container').setAttribute('update', true)
|
||||
document.getElementById('image_seal_container').onclick = () => {
|
||||
/*setOverlayContent('Update Available', 'A new update for the launcher is available. Would you like to install now?', 'Install', 'Later')
|
||||
setOverlayHandler(() => {
|
||||
if(!isDev){
|
||||
ipcRenderer.send('autoUpdateAction', 'installUpdateNow')
|
||||
} else {
|
||||
console.error('Cannot install updates in development environment.')
|
||||
toggleOverlay(false)
|
||||
}
|
||||
})
|
||||
setDismissHandler(() => {
|
||||
toggleOverlay(false)
|
||||
})
|
||||
toggleOverlay(true, true)*/
|
||||
switchView(getCurrentView(), VIEWS.settings, 500, 500, () => {
|
||||
settingsNavItemListener(document.getElementById('settingsNavUpdate'), false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* jQuery Example
|
||||
$(function(){
|
||||
loggerUICore.info('UICore Initialized');
|
||||
})*/
|
||||
|
||||
document.addEventListener('readystatechange', function () {
|
||||
if (document.readyState === 'interactive'){
|
||||
loggerUICore.info('UICore Initializing..')
|
||||
|
||||
// Bind close button.
|
||||
Array.from(document.getElementsByClassName('fCb')).map((val) => {
|
||||
val.addEventListener('click', e => {
|
||||
const window = remote.getCurrentWindow()
|
||||
window.close()
|
||||
})
|
||||
})
|
||||
|
||||
// Bind restore down button.
|
||||
Array.from(document.getElementsByClassName('fRb')).map((val) => {
|
||||
val.addEventListener('click', e => {
|
||||
const window = remote.getCurrentWindow()
|
||||
if(window.isMaximized()){
|
||||
window.unmaximize()
|
||||
} else {
|
||||
window.maximize()
|
||||
}
|
||||
document.activeElement.blur()
|
||||
})
|
||||
})
|
||||
|
||||
// Bind minimize button.
|
||||
Array.from(document.getElementsByClassName('fMb')).map((val) => {
|
||||
val.addEventListener('click', e => {
|
||||
const window = remote.getCurrentWindow()
|
||||
window.minimize()
|
||||
document.activeElement.blur()
|
||||
})
|
||||
})
|
||||
|
||||
// Remove focus from social media buttons once they're clicked.
|
||||
Array.from(document.getElementsByClassName('mediaURL')).map(val => {
|
||||
val.addEventListener('click', e => {
|
||||
document.activeElement.blur()
|
||||
})
|
||||
})
|
||||
|
||||
} else if(document.readyState === 'complete'){
|
||||
|
||||
//266.01
|
||||
//170.8
|
||||
//53.21
|
||||
// Bind progress bar length to length of bot wrapper
|
||||
//const targetWidth = document.getElementById("launch_content").getBoundingClientRect().width
|
||||
//const targetWidth2 = document.getElementById("server_selection").getBoundingClientRect().width
|
||||
//const targetWidth3 = document.getElementById("launch_button").getBoundingClientRect().width
|
||||
|
||||
document.getElementById('launch_details').style.maxWidth = 266.01
|
||||
document.getElementById('launch_progress').style.width = 170.8
|
||||
document.getElementById('launch_details_right').style.maxWidth = 170.8
|
||||
document.getElementById('launch_progress_label').style.width = 53.21
|
||||
|
||||
}
|
||||
|
||||
}, false)
|
||||
|
||||
/**
|
||||
* Open web links in the user's default browser.
|
||||
*/
|
||||
$(document).on('click', 'a[href^="http"]', function(event) {
|
||||
event.preventDefault()
|
||||
shell.openExternal(this.href)
|
||||
})
|
||||
|
||||
/**
|
||||
* Opens DevTools window if you hold (ctrl + shift + i).
|
||||
* This will crash the program if you are using multiple
|
||||
* DevTools, for example the chrome debugger in VS Code.
|
||||
*/
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if((e.key === 'I' || e.key === 'i') && e.ctrlKey && e.shiftKey){
|
||||
let window = remote.getCurrentWindow()
|
||||
window.toggleDevTools()
|
||||
}
|
||||
})
|
||||
9
app/assets/js/scripts/welcome.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Script for welcome.ejs
|
||||
*/
|
||||
document.getElementById('welcomeButton').addEventListener('click', e => {
|
||||
loginOptionsCancelEnabled(false) // False by default, be explicit.
|
||||
loginOptionsViewOnLoginSuccess = VIEWS.library
|
||||
loginOptionsViewOnLoginCancel = VIEWS.loginOptions
|
||||
switchView(VIEWS.welcome, VIEWS.loginOptions)
|
||||
})
|
||||
211
app/assets/js/serverruntime.js
Normal file
@@ -0,0 +1,211 @@
|
||||
const childProcess = require('child_process')
|
||||
const fs = require('fs-extra')
|
||||
const path = require('path')
|
||||
|
||||
const ConfigManager = require('./configmanager')
|
||||
const ProfileAssetManager = require('./profileassetmanager')
|
||||
|
||||
const runtimes = new Map()
|
||||
|
||||
function getRuntime(profileId){
|
||||
if(!runtimes.has(profileId)){
|
||||
runtimes.set(profileId, {
|
||||
serverProcess: null,
|
||||
tunnelProcess: null,
|
||||
logs: [],
|
||||
status: 'stopped',
|
||||
publishedAddress: ConfigManager.getPublishedLibraryServerAddress(profileId)
|
||||
})
|
||||
}
|
||||
|
||||
return runtimes.get(profileId)
|
||||
}
|
||||
|
||||
function appendLog(runtime, line){
|
||||
runtime.logs.push(line)
|
||||
if(runtime.logs.length > 200){
|
||||
runtime.logs.shift()
|
||||
}
|
||||
}
|
||||
|
||||
function interpolateCommand(template, variables){
|
||||
return Object.entries(variables).reduce((command, [key, value]) => {
|
||||
return command.replaceAll(`\${${key}}`, String(value))
|
||||
}, template)
|
||||
}
|
||||
|
||||
function extractPublishedAddress(line, profile){
|
||||
if(profile.tunnelAddressRegex){
|
||||
const customRegex = new RegExp(profile.tunnelAddressRegex)
|
||||
const customMatch = line.match(customRegex)
|
||||
if(customMatch){
|
||||
return customMatch[1] ?? customMatch[0]
|
||||
}
|
||||
}
|
||||
|
||||
const genericMatch = line.match(/([a-zA-Z0-9.-]+\.[a-zA-Z]{2,}:\d+|[a-zA-Z0-9.-]+:\d{2,5})/)
|
||||
return genericMatch ? genericMatch[1] : null
|
||||
}
|
||||
|
||||
async function resolveServerLaunchCommand(profile, serverDirectory){
|
||||
if(profile.serverLaunchCommand){
|
||||
return profile.serverLaunchCommand
|
||||
}
|
||||
|
||||
const startScript = process.platform === 'win32' ? 'start.bat' : 'start.sh'
|
||||
const startScriptPath = path.join(serverDirectory, startScript)
|
||||
if(await fs.pathExists(startScriptPath)){
|
||||
return process.platform === 'win32' ? startScript : `./${startScript}`
|
||||
}
|
||||
|
||||
const jarPath = path.join(serverDirectory, 'server.jar')
|
||||
if(await fs.pathExists(jarPath)){
|
||||
return 'java -jar server.jar nogui'
|
||||
}
|
||||
|
||||
throw new Error('서버 시작 명령을 결정할 수 없습니다. serverLaunchCommand 또는 server.jar/start 스크립트를 준비하세요.')
|
||||
}
|
||||
|
||||
function resolveWorkingDirectory(profile, serverDirectory){
|
||||
if(profile.serverWorkingDirectory){
|
||||
return path.join(serverDirectory, profile.serverWorkingDirectory)
|
||||
}
|
||||
return serverDirectory
|
||||
}
|
||||
|
||||
async function startTunnelProcess(profile, runtime, serverDirectory){
|
||||
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){
|
||||
const runtime = getRuntime(profile.id)
|
||||
if(runtime.serverProcess != null){
|
||||
return runtime
|
||||
}
|
||||
|
||||
const serverDirectory = await ProfileAssetManager.ensureServerBundleInstalled(profile)
|
||||
const workingDirectory = resolveWorkingDirectory(profile, serverDirectory)
|
||||
const command = await resolveServerLaunchCommand(profile, workingDirectory)
|
||||
|
||||
runtime.status = 'starting'
|
||||
runtime.publishedAddress = null
|
||||
ConfigManager.setPublishedLibraryServerAddress(profile.id, null)
|
||||
ConfigManager.save()
|
||||
appendLog(runtime, `[launcher] starting server: ${command}`)
|
||||
|
||||
const serverProcess = childProcess.spawn(command, {
|
||||
cwd: workingDirectory,
|
||||
shell: true,
|
||||
detached: false
|
||||
})
|
||||
|
||||
runtime.serverProcess = serverProcess
|
||||
|
||||
serverProcess.stdout?.on('data', (chunk) => {
|
||||
chunk.toString().split(/\r?\n/).filter(Boolean).forEach((line) => {
|
||||
appendLog(runtime, `[server] ${line}`)
|
||||
if(runtime.status !== 'running'){
|
||||
runtime.status = 'running'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
serverProcess.stderr?.on('data', (chunk) => {
|
||||
chunk.toString().split(/\r?\n/).filter(Boolean).forEach((line) => {
|
||||
appendLog(runtime, `[server:err] ${line}`)
|
||||
})
|
||||
})
|
||||
|
||||
serverProcess.on('close', () => {
|
||||
runtime.serverProcess = null
|
||||
runtime.status = 'stopped'
|
||||
runtime.publishedAddress = null
|
||||
ConfigManager.setPublishedLibraryServerAddress(profile.id, null)
|
||||
ConfigManager.save()
|
||||
})
|
||||
|
||||
if(profile.tunnelCommand){
|
||||
await startTunnelProcess(profile, runtime, workingDirectory)
|
||||
}
|
||||
|
||||
return runtime
|
||||
}
|
||||
|
||||
exports.stopHostedProfile = function(profileId){
|
||||
const runtime = getRuntime(profileId)
|
||||
|
||||
if(runtime.tunnelProcess != null){
|
||||
runtime.tunnelProcess.kill()
|
||||
runtime.tunnelProcess = null
|
||||
}
|
||||
|
||||
if(runtime.serverProcess != null){
|
||||
runtime.serverProcess.kill()
|
||||
runtime.serverProcess = null
|
||||
}
|
||||
|
||||
runtime.status = 'stopped'
|
||||
runtime.publishedAddress = null
|
||||
ConfigManager.setPublishedLibraryServerAddress(profileId, null)
|
||||
ConfigManager.save()
|
||||
}
|
||||
|
||||
exports.getHostedProfileState = function(profileId){
|
||||
const runtime = getRuntime(profileId)
|
||||
return {
|
||||
status: runtime.status,
|
||||
running: runtime.serverProcess != null,
|
||||
tunneling: runtime.tunnelProcess != null,
|
||||
publishedAddress: runtime.publishedAddress ?? ConfigManager.getPublishedLibraryServerAddress(profileId),
|
||||
logs: [...runtime.logs]
|
||||
}
|
||||
}
|
||||
|
||||
exports.hasRunningProfiles = function(){
|
||||
for(const runtime of runtimes.values()){
|
||||
if(runtime.serverProcess != null || runtime.tunnelProcess != null){
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
65
app/assets/js/serverstatus.js
Normal file
@@ -0,0 +1,65 @@
|
||||
const net = require('net')
|
||||
|
||||
/**
|
||||
* Retrieves the status of a minecraft server.
|
||||
*
|
||||
* @param {string} address The server address.
|
||||
* @param {number} port Optional. The port of the server. Defaults to 25565.
|
||||
* @returns {Promise.<Object>} A promise which resolves to an object containing
|
||||
* status information.
|
||||
*/
|
||||
exports.getStatus = function(address, port = 25565){
|
||||
|
||||
if(port == null || port == ''){
|
||||
port = 25565
|
||||
}
|
||||
if(typeof port === 'string'){
|
||||
port = parseInt(port)
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect(port, address, () => {
|
||||
let buff = Buffer.from([0xFE, 0x01])
|
||||
socket.write(buff)
|
||||
})
|
||||
|
||||
socket.setTimeout(2500, () => {
|
||||
socket.end()
|
||||
reject({
|
||||
code: 'ETIMEDOUT',
|
||||
errno: 'ETIMEDOUT',
|
||||
address,
|
||||
port
|
||||
})
|
||||
})
|
||||
|
||||
socket.on('data', (data) => {
|
||||
if(data != null && data != ''){
|
||||
let server_info = data.toString().split('\x00\x00\x00')
|
||||
const NUM_FIELDS = 6
|
||||
if(server_info != null && server_info.length >= NUM_FIELDS){
|
||||
resolve({
|
||||
online: true,
|
||||
version: server_info[2].replace(/\u0000/g, ''),
|
||||
motd: server_info[3].replace(/\u0000/g, ''),
|
||||
onlinePlayers: server_info[4].replace(/\u0000/g, ''),
|
||||
maxPlayers: server_info[5].replace(/\u0000/g,'')
|
||||
})
|
||||
} else {
|
||||
resolve({
|
||||
online: false
|
||||
})
|
||||
}
|
||||
}
|
||||
socket.end()
|
||||
})
|
||||
|
||||
socket.on('error', (err) => {
|
||||
socket.destroy()
|
||||
reject(err)
|
||||
// ENOTFOUND = Unable to resolve.
|
||||
// ECONNREFUSED = Unable to connect to port.
|
||||
})
|
||||
})
|
||||
|
||||
}
|
||||
42
app/assets/lang/_custom.toml
Normal file
@@ -0,0 +1,42 @@
|
||||
# Custom Language File for Launcher Customizer
|
||||
|
||||
[ejs.app]
|
||||
title = "MRS Launcher"
|
||||
|
||||
[ejs.landing]
|
||||
mediaHomeURL = "https://mysticred.space"
|
||||
mediaLinkURL = "#"
|
||||
mediaMapURL = "https://map.mysticred.space"
|
||||
mediaGitHubURL = "https://github.com/peunsu/MRSLauncher"
|
||||
mediaXURL = "#"
|
||||
mediaInstagramURL = "#"
|
||||
mediaYouTubeURL = "#"
|
||||
mediaDiscordURL = "https://discord.gg/Z8j6ahF4MJ"
|
||||
libraryButton = "라이브러리"
|
||||
installButton = "설치"
|
||||
|
||||
[ejs.settings]
|
||||
sourceGithubLink = "https://github.com/peunsu/MRSLauncher"
|
||||
supportLink = "https://github.com/peunsu/MRSLauncher/issues"
|
||||
|
||||
[ejs.library]
|
||||
eyebrow = "Library"
|
||||
title = "내 라이브러리"
|
||||
subtitle = "설치한 프로필을 선택하고 바로 실행하거나, 서버 주소를 입력해 자동 접속을 준비할 수 있습니다."
|
||||
settingsButton = "설정"
|
||||
installPageButton = "설치 페이지"
|
||||
launchPageButton = "실행 화면"
|
||||
emptyTitle = "설치된 프로필이 없습니다."
|
||||
emptyDescription = "설치 페이지에서 네가 배포한 모드팩이나 서버 클라이언트를 먼저 추가하세요."
|
||||
|
||||
[ejs.install]
|
||||
eyebrow = "Install"
|
||||
title = "설치 페이지"
|
||||
subtitle = "관리자가 미리 등록한 프로필을 둘러보고, 설명과 상세 내용을 확인한 뒤 내 라이브러리에 추가합니다."
|
||||
libraryPageButton = "라이브러리"
|
||||
notice = "설치 페이지는 읽기 전용 카탈로그입니다. 프로필 제목, 요약, 상세 설명은 관리자가 미리 등록하며, 클라이언트는 라이브러리에 추가만 할 수 있습니다."
|
||||
|
||||
[ejs.welcome]
|
||||
welcomeHeader = "Mystic Red Space"
|
||||
welcomeDescription = "2017년, 작은 모드팩 서버로 시작한 Mystic Red Space는 오랜 시간동안 모드팩 유저의 사랑을 받으면서 현재까지 수십 개 이상의 모드팩 서버를 제공하였습니다. 이제 MRS는 단순한 마인크래프트 모드팩 서버를 넘어서, 더 많은 사람들이 마인크래프트 모드 정보를 쉽게 접하고 모드팩에 대한 관심을 키울 수 있는 모드 커뮤니티로 한 걸음 나아가고 있습니다."
|
||||
welcomeDescCTA = "모드팩을 사랑하는 당신을 위한 최고의 선물이 되겠습니다."
|
||||
345
app/assets/lang/en_US.toml
Normal file
@@ -0,0 +1,345 @@
|
||||
[ejs.landing]
|
||||
updateAvailableTooltip = "Update Available"
|
||||
usernamePlaceholder = "Username"
|
||||
usernameEditButton = "Edit"
|
||||
settingsTooltip = "Settings"
|
||||
serverStatus = "SERVER"
|
||||
serverStatusPlaceholder = "OFFLINE"
|
||||
mojangStatus = "MOJANG STATUS"
|
||||
mojangStatusTooltipTitle = "Services"
|
||||
mojangStatusNETitle = "Non Essential"
|
||||
newsButton = "NEWS"
|
||||
launchButton = "PLAY"
|
||||
launchButtonPlaceholder = "• No Server Selected"
|
||||
launchDetails = "Please wait.."
|
||||
newsNavigationStatus = "{currentPage} of {totalPages}"
|
||||
newsErrorLoadSpan = "Checking for News.."
|
||||
newsErrorFailedSpan = "Failed to Load News"
|
||||
newsErrorRetryButton = "Try Again"
|
||||
newsErrorNoneSpan = "No News"
|
||||
|
||||
[ejs.login]
|
||||
loginCancelText = "Cancel"
|
||||
loginSubheader = "MINECRAFT LOGIN"
|
||||
loginEmailError = "* Invalid Value"
|
||||
loginEmailPlaceholder = "EMAIL OR USERNAME"
|
||||
loginPasswordError = "* Required"
|
||||
loginPasswordPlaceholder = "PASSWORD"
|
||||
loginForgotPasswordLink = "https://minecraft.net/password/forgot/"
|
||||
loginForgotPasswordText = "forgot password?"
|
||||
loginRememberMeText = "remember me?"
|
||||
loginButtonText = "LOGIN"
|
||||
loginNeedAccountLink = "https://minecraft.net/store/minecraft-java-edition/"
|
||||
loginNeedAccountText = "Need an Account?"
|
||||
loginPasswordDisclaimer1 = "Your password is sent directly to mojang and never stored."
|
||||
loginPasswordDisclaimer2 = "{appName} is not affiliated with Mojang AB."
|
||||
|
||||
[ejs.loginOptions]
|
||||
loginOptionsTitle = "Login Options"
|
||||
loginWithMicrosoft = "Login with Microsoft"
|
||||
loginWithMojang = "Login with Mojang"
|
||||
cancelButton = "Cancel"
|
||||
|
||||
[ejs.overlay]
|
||||
serverSelectHeader = "Available Servers"
|
||||
serverSelectConfirm = "Select"
|
||||
serverSelectCancel = "Cancel"
|
||||
accountSelectHeader = "Select an Account"
|
||||
accountSelectConfirm = "Select"
|
||||
accountSelectCancel = "Cancel"
|
||||
|
||||
[ejs.settings]
|
||||
navHeaderText = "Settings"
|
||||
navAccount = "Account"
|
||||
navMinecraft = "Minecraft"
|
||||
navMods = "Mods"
|
||||
navJava = "Java"
|
||||
navLauncher = "Launcher"
|
||||
navAbout = "About"
|
||||
navUpdates = "Updates"
|
||||
navDone = "Done"
|
||||
tabAccountHeaderText = "Account Settings"
|
||||
tabAccountHeaderDesc = "Add new accounts or manage existing ones."
|
||||
microsoftAccount = "Microsoft"
|
||||
addMicrosoftAccount = "+ Add Microsoft Account"
|
||||
mojangAccount = "Mojang"
|
||||
addMojangAccount = "+ Add Mojang Account"
|
||||
minecraftTabHeaderText = "Minecraft Settings"
|
||||
minecraftTabHeaderDesc = "Options related to game launch."
|
||||
gameResolutionTitle = "Game Resolution"
|
||||
launchFullscreenTitle = "Launch in fullscreen."
|
||||
autoConnectTitle = "Automatically connect to the server on launch."
|
||||
launchDetachedTitle = "Launch game process detached from launcher."
|
||||
launchDetachedDesc = "If the game is not detached, closing the launcher will also close the game."
|
||||
tabModsHeaderText = "Mod Settings"
|
||||
tabModsHeaderDesc = "Enable or disable mods."
|
||||
switchServerButton = "Switch"
|
||||
requiredMods = "Required Mods"
|
||||
optionalMods = "Optional Mods"
|
||||
dropinMods = "Drop-in Mods"
|
||||
addMods = "Add Mods"
|
||||
dropinRefreshNote = "(F5 to Refresh)"
|
||||
shaderpacks = "Shaderpacks"
|
||||
shaderpackDesc = "Enable or disable shaders. Please note, shaders will only run smoothly on powerful setups. You may add custom packs here."
|
||||
selectShaderpack = "Select Shaderpack"
|
||||
tabJavaHeaderText = "Java Settings"
|
||||
tabJavaHeaderDesc = "Manage the Java configuration (advanced)."
|
||||
memoryTitle = "Memory"
|
||||
maxRAM = "Maximum RAM"
|
||||
minRAM = "Minimum RAM"
|
||||
memoryDesc = "The recommended minimum RAM is 3 gigabytes. Setting the minimum and maximum values to the same value may reduce lag."
|
||||
memoryTotalTitle = "Total"
|
||||
memoryAvailableTitle = "Available"
|
||||
javaExecutableTitle = "Java Executable"
|
||||
javaExecSelDialogTitle = "Select Java Executable"
|
||||
javaExecSelButtonText = "Choose File"
|
||||
javaExecDesc = "The Java executable is validated before game launch."
|
||||
javaPathDesc = "The path should end with <strong>{pathSuffix}</strong>."
|
||||
jvmOptsTitle = "Additional JVM Options"
|
||||
jvmOptsDesc = "Options to be provided to the JVM at runtime. <em>-Xms</em> and <em>-Xmx</em> should not be included."
|
||||
launcherTabHeaderText = "Launcher Settings"
|
||||
launcherTabHeaderDesc = "Options related to the launcher itself."
|
||||
allowPrereleaseTitle = "Allow Pre-Release Updates."
|
||||
allowPrereleaseDesc = "Pre-Releases include new features which may have not been fully tested or integrated.<br>This will always be true if you are using a pre-release version."
|
||||
dataDirectoryTitle = "Data Directory"
|
||||
selectDataDirectory = "Select Data Directory"
|
||||
chooseFolder = "Choose Folder"
|
||||
dataDirectoryDesc = "All game files and local Java installations will be stored in the data directory.<br>Screenshots and world saves are stored in the instance folder for the corresponding server configuration."
|
||||
aboutTabHeaderText = "About"
|
||||
aboutTabHeaderDesc = "View information and release notes for the current version."
|
||||
aboutTitle = "{appName}"
|
||||
stableRelease = "Stable Release"
|
||||
versionText = "Version "
|
||||
sourceGithub = "GitHub"
|
||||
sourceOriginalGithub = "Original GitHub"
|
||||
support = "Support"
|
||||
devToolsConsole = "DevTools Console"
|
||||
releaseNotes = "Release Notes"
|
||||
changelog = "Changelog"
|
||||
noReleaseNotes = "No Release Notes"
|
||||
viewReleaseNotes = "View Release Notes on GitHub"
|
||||
launcherUpdatesHeaderText = "Launcher Updates"
|
||||
launcherUpdatesHeaderDesc = "Download, install, and review updates for the launcher."
|
||||
checkForUpdates = "Check for Updates"
|
||||
whatsNew = "What's New"
|
||||
updateReleaseNotes = "Update Release Notes"
|
||||
|
||||
[ejs.waiting]
|
||||
waitingText = "Waiting for Microsoft.."
|
||||
|
||||
[ejs.welcome]
|
||||
continueButton = "CONTINUE"
|
||||
|
||||
|
||||
[js.discord]
|
||||
waiting = "Waiting for Client.."
|
||||
state = "Server: {shortId}"
|
||||
|
||||
[js.index]
|
||||
microsoftLoginTitle = "Microsoft Login"
|
||||
microsoftLogoutTitle = "Microsoft Logout"
|
||||
|
||||
[js.login]
|
||||
login = "LOGIN"
|
||||
loggingIn = "LOGGING IN"
|
||||
success = "SUCCESS"
|
||||
tryAgain = "Try Again"
|
||||
|
||||
[js.login.error]
|
||||
invalidValue = "* Invalid Value"
|
||||
requiredValue = "* Required"
|
||||
|
||||
[js.login.error.unknown]
|
||||
title = "Unknown Error During Login"
|
||||
desc = "An unknown error has occurred. Please see the console for details."
|
||||
|
||||
[js.landing.launch]
|
||||
pleaseWait = "Please wait.."
|
||||
failureTitle = "Error During Launch"
|
||||
failureText = "See console (CTRL + Shift + i) for more details."
|
||||
okay = "Okay"
|
||||
|
||||
[js.landing.selectedAccount]
|
||||
noAccountSelected = "No Account Selected"
|
||||
|
||||
[js.landing.selectedServer]
|
||||
noSelection = "No Server Selected"
|
||||
loading = "Loading.."
|
||||
|
||||
[js.landing.serverStatus]
|
||||
server = "SERVER"
|
||||
offline = "OFFLINE"
|
||||
players = "PLAYERS"
|
||||
|
||||
[js.landing.systemScan]
|
||||
checking = "Checking system info.."
|
||||
noCompatibleJava = "No Compatible<br>Java Installation Found"
|
||||
installJavaMessage = "In order to launch Minecraft, you need a 64-bit installation of Java {major}. Would you like us to install a copy?"
|
||||
installJava = "Install Java"
|
||||
installJavaManually = "Install Manually"
|
||||
javaDownloadPrepare = "Preparing Java Download.."
|
||||
javaDownloadFailureTitle = "Error During Java Download"
|
||||
javaDownloadFailureText = "See console (CTRL + Shift + i) for more details."
|
||||
javaRequired = "Java is Required<br>to Launch"
|
||||
javaRequiredMessage = 'A valid x64 installation of Java {major} is required to launch.<br><br>Please refer to our <a href="https://github.com/peunsu/MRSLauncher/wiki/Java-%EA%B4%80%EB%A6%AC#java-%EC%88%98%EB%8F%99-%EC%84%A4%EC%B9%98">Java Management Guide</a> for instructions on how to manually install Java.'
|
||||
javaRequiredDismiss = "I Understand"
|
||||
javaRequiredCancel = "Go Back"
|
||||
|
||||
[js.landing.downloadJava]
|
||||
findJdkFailure = "Failed to find OpenJDK distribution."
|
||||
javaDownloadCorruptedError = "Downloaded JDK has a bad hash, the file may be corrupted."
|
||||
extractingJava = "Extracting Java"
|
||||
javaInstalled = "Java Installed!"
|
||||
|
||||
[js.landing.dlAsync]
|
||||
loadingServerInfo = "Loading server information.."
|
||||
fatalError = "Fatal Error"
|
||||
unableToLoadDistributionIndex = "Could not load a copy of the distribution index. See the console (CTRL + Shift + i) for more details."
|
||||
pleaseWait = "Please wait.."
|
||||
errorDuringLaunchTitle = "Error During Launch"
|
||||
seeConsoleForDetails = "See console (CTRL + Shift + i) for more details."
|
||||
validatingFileIntegrity = "Validating file integrity.."
|
||||
errorDuringFileVerificationTitle = "Error During File Verification"
|
||||
downloadingFiles = "Downloading files.."
|
||||
errorDuringFileDownloadTitle = "Error During File Download"
|
||||
preparingToLaunch = "Preparing to launch.."
|
||||
launchingGame = "Launching game.."
|
||||
launchWrapperNotDownloaded = "The main file, LaunchWrapper, failed to download properly. As a result, the game cannot launch.<br><br>To fix this issue, temporarily turn off your antivirus software and launch the game again.<br><br>If you have time, please <a href=\"https://github.com/peunsu/MRSLauncher/issues\">submit an issue</a> and let us know what antivirus software you use. We'll contact them and try to straighten things out."
|
||||
doneEnjoyServer = "Done. Enjoy the server!"
|
||||
checkConsoleForDetails = "Please check the console (CTRL + Shift + i) for more details."
|
||||
|
||||
[js.landing.news]
|
||||
checking = "Checking for News"
|
||||
|
||||
[js.landing.discord]
|
||||
loading = "Loading game.."
|
||||
joining = "Sailing to Westeros!"
|
||||
joined = "Exploring the Realm!"
|
||||
|
||||
[js.overlay]
|
||||
dismiss = "Dismiss"
|
||||
|
||||
[js.settings.fileSelectors]
|
||||
executables = "Executables"
|
||||
allFiles = "All Files"
|
||||
|
||||
[js.settings.mstfLogin]
|
||||
errorTitle = "Something Went Wrong"
|
||||
errorMessage = "Microsoft authentication failed. Please try again."
|
||||
okButton = "OK"
|
||||
|
||||
[js.settings.mstfLogout]
|
||||
errorTitle = "Something Went Wrong"
|
||||
errorMessage = "Microsoft logout failed. Please try again."
|
||||
okButton = "OK"
|
||||
|
||||
[js.settings.authAccountSelect]
|
||||
selectButton = "Select Account"
|
||||
selectedButton = "Selected Account ✔"
|
||||
|
||||
[js.settings.authAccountLogout]
|
||||
lastAccountWarningTitle = "Warning<br>This is Your Last Account"
|
||||
lastAccountWarningMessage = "In order to use the launcher you must be logged into at least one account. You will need to login again after.<br><br>Are you sure you want to log out?"
|
||||
confirmButton = "I'm Sure"
|
||||
cancelButton = "Cancel"
|
||||
|
||||
[js.settings.authAccountPopulate]
|
||||
username = "Username"
|
||||
uuid = "UUID"
|
||||
selectAccount = "Select Account"
|
||||
selectedAccount = "Selected Account ✓"
|
||||
logout = "Log Out"
|
||||
|
||||
[js.settings.dropinMods]
|
||||
removeButton = "Remove"
|
||||
deleteFailedTitle = "Failed to Delete<br>Drop-in Mod {fullName}"
|
||||
deleteFailedMessage = "Make sure the file is not in use and try again."
|
||||
failedToggleTitle = "Failed to Toggle<br>One or More Drop-in Mods"
|
||||
okButton = "Okay"
|
||||
|
||||
[js.settings.serverListing]
|
||||
mainServer = "Main Server"
|
||||
|
||||
[js.settings.java]
|
||||
selectedJava = "Selected: Java {version} ({vendor})"
|
||||
invalidSelection = "Invalid Selection"
|
||||
requiresJava = "Requires Java {major} x64."
|
||||
availableOptions = "Available Options for Java {major} (HotSpot VM)"
|
||||
|
||||
[js.settings.about]
|
||||
preReleaseTitle = "Pre-release"
|
||||
stableReleaseTitle = "Stable Release"
|
||||
releaseNotesFailed = "Failed to load release notes."
|
||||
|
||||
[js.settings.updates]
|
||||
newReleaseTitle = "New Release Available"
|
||||
newPreReleaseTitle = "New Pre-release Available"
|
||||
downloadingButton = "Downloading.."
|
||||
downloadButton = 'Download from GitHub<span style="font-size: 10px;color: gray;text-shadow: none !important;">Close the launcher and run the dmg to update.</span>'
|
||||
latestVersionTitle = "You Are Running the Latest Version"
|
||||
checkForUpdatesButton = "Check for Updates"
|
||||
checkingForUpdatesButton = "Checking for Updates.."
|
||||
|
||||
[js.settings.msftLogin]
|
||||
errorTitle = "Microsoft Login Failed"
|
||||
errorMessage = "We were unable to authenticate your Microsoft account. Please try again."
|
||||
okButton = "OK"
|
||||
|
||||
[js.uibinder.startup]
|
||||
fatalErrorTitle = "Fatal Error: Unable to Load Distribution Index"
|
||||
fatalErrorMessage = "A connection could not be established to our servers to download the distribution index. No local copies were available to load. <br><br>The distribution index is an essential file which provides the latest server information. The launcher is unable to start without it. Ensure you are connected to the internet and relaunch the application."
|
||||
closeButton = "Close"
|
||||
|
||||
[js.uibinder.validateAccount]
|
||||
failedMessageTitle = "Failed to Refresh Login"
|
||||
failedMessage = "We were unable to refresh the login for <strong>{account}</strong>. Please select another account or login again."
|
||||
failedMessageSelectAnotherAccount = "We were unable to refresh the login for <strong>{account}</strong>. Please login again."
|
||||
loginButton = "Login"
|
||||
selectAnotherAccountButton = "Select Another Account"
|
||||
|
||||
[js.uicore.autoUpdate]
|
||||
checkingForUpdateButton = "Checking for Updates..."
|
||||
installNowButton = "Install Now"
|
||||
checkForUpdatesButton = "Check for Updates"
|
||||
|
||||
[js.auth.microsoft.error]
|
||||
noProfileTitle = "Error During Login:<br>Profile Not Set Up"
|
||||
noProfileDesc = "Your Microsoft account does not yet have a Minecraft profile set up. If you have recently purchased the game or redeemed it through Xbox Game Pass, you have to set up your profile on <a href=\"https://minecraft.net/\">Minecraft.net</a>.<br><br>If you have not yet purchased the game, you can also do that on <a href=\"https://minecraft.net/\">Minecraft.net</a>."
|
||||
noXboxAccountTitle = "Error During Login:<br>No Xbox Account"
|
||||
noXboxAccountDesc = "Your Microsoft account has no Xbox account associated with it."
|
||||
xblBannedTitle = "Error During Login:<br>Xbox Live Unavailable"
|
||||
xblBannedDesc = "Your Microsoft account is from a country where Xbox Live is not available or banned."
|
||||
under18Title = "Error During Login:<br>Parental Approval Required"
|
||||
under18Desc = "Accounts for users under the age of 18 must be added to a Family by an adult."
|
||||
unknownTitle = "Unknown Error During Login"
|
||||
unknownDesc = "An unknown error has occurred. Please see the console for details."
|
||||
|
||||
[js.auth.mojang.error]
|
||||
methodNotAllowedTitle = "Internal Error:<br>Method Not Allowed"
|
||||
methodNotAllowedDesc = "Method not allowed. Please report this error."
|
||||
notFoundTitle = "Internal Error:<br>Not Found"
|
||||
notFoundDesc = "The authentication endpoint was not found. Please report this issue."
|
||||
accountMigratedTitle = "Error During Login:<br>Account Migrated"
|
||||
accountMigratedDesc = "You've attempted to login with a migrated account. Try again using the account email as the username."
|
||||
invalidCredentialsTitle = "Error During Login:<br>Invalid Credentials"
|
||||
invalidCredentialsDesc = "The email or password you've entered is incorrect. Please try again."
|
||||
tooManyAttemptsTitle = "Error During Login:<br>Too Many Attempts"
|
||||
tooManyAttemptsDesc = "There have been too many login attempts with this account recently. Please try again later."
|
||||
invalidTokenTitle = "Error During Login:<br>Invalid Token"
|
||||
invalidTokenDesc = "The provided access token is invalid."
|
||||
tokenHasProfileTitle = "Error During Login:<br>Token Has Profile"
|
||||
tokenHasProfileDesc = "Access token already has a profile assigned. Selecting profiles is not implemented yet."
|
||||
credentialsMissingTitle = "Error During Login:<br>Credentials Missing"
|
||||
credentialsMissingDesc = "Username/password was not submitted or password is less than 3 characters."
|
||||
invalidSaltVersionTitle = "Error During Login:<br>Invalid Salt Version"
|
||||
invalidSaltVersionDesc = "Invalid salt version."
|
||||
unsupportedMediaTypeTitle = "Internal Error:<br>Unsupported Media Type"
|
||||
unsupportedMediaTypeDesc = "Unsupported media type. Please report this error."
|
||||
accountGoneTitle = "Error During Login:<br>Account Migrated"
|
||||
accountGoneDesc = "Account has been migrated to a Microsoft account. Please log in with Microsoft."
|
||||
unreachableTitle = "Error During Login:<br>Unreachable"
|
||||
unreachableDesc = "Unable to reach the authentication servers. Ensure that they are online and you are connected to the internet."
|
||||
gameNotPurchasedTitle = "Error During Login:<br>Game Not Purchased"
|
||||
gameNotPurchasedDesc = "The account you are trying to login with has not purchased a copy of Minecraft. You may purchase a copy on <a href=\"https://minecraft.net/\">Minecraft.net</a>"
|
||||
unknownErrorTitle = "Unknown Error During Login"
|
||||
unknownErrorDesc = "An unknown error has occurred. Please see the console for details."
|
||||
345
app/assets/lang/ko_KR.toml
Normal file
@@ -0,0 +1,345 @@
|
||||
[ejs.landing]
|
||||
updateAvailableTooltip = "업데이트 가능"
|
||||
usernamePlaceholder = "사용자 이름"
|
||||
usernameEditButton = "편집"
|
||||
settingsTooltip = "설정"
|
||||
serverStatus = "서버"
|
||||
serverStatusPlaceholder = "오프라인"
|
||||
mojangStatus = "MOJANG 상태"
|
||||
mojangStatusTooltipTitle = "서비스"
|
||||
mojangStatusNETitle = "Non Essential"
|
||||
newsButton = "NEWS"
|
||||
launchButton = "PLAY"
|
||||
launchButtonPlaceholder = "• 선택된 서버 없음"
|
||||
launchDetails = "잠시만 기다려주세요.."
|
||||
newsNavigationStatus = "{currentPage} / {totalPages}"
|
||||
newsErrorLoadSpan = "뉴스를 확인하는 중.."
|
||||
newsErrorFailedSpan = "뉴스 불러오기 실패"
|
||||
newsErrorRetryButton = "다시 시도"
|
||||
newsErrorNoneSpan = "뉴스 없음"
|
||||
|
||||
[ejs.login]
|
||||
loginCancelText = "취소"
|
||||
loginSubheader = "마인크래프트 로그인"
|
||||
loginEmailError = "* 유효하지 않은 값"
|
||||
loginEmailPlaceholder = "이메일 또는 사용자 이름"
|
||||
loginPasswordError = "* 필수 항목"
|
||||
loginPasswordPlaceholder = "비밀번호"
|
||||
loginForgotPasswordLink = "https://minecraft.net/password/forgot/"
|
||||
loginForgotPasswordText = "비밀번호를 잊으셨나요?"
|
||||
loginRememberMeText = "로그인 저장"
|
||||
loginButtonText = "로그인"
|
||||
loginNeedAccountLink = "https://minecraft.net/store/minecraft-java-edition/"
|
||||
loginNeedAccountText = "계정이 없으신가요?"
|
||||
loginPasswordDisclaimer1 = "비밀번호는 Mojang에 직접 전송되며 저장되지 않습니다."
|
||||
loginPasswordDisclaimer2 = "{appName}는 Mojang AB와 관련이 없습니다."
|
||||
|
||||
[ejs.loginOptions]
|
||||
loginOptionsTitle = "로그인 옵션"
|
||||
loginWithMicrosoft = "Microsoft 계정으로 로그인"
|
||||
loginWithMojang = "Mojang 계정으로 로그인"
|
||||
cancelButton = "취소"
|
||||
|
||||
[ejs.overlay]
|
||||
serverSelectHeader = "서버 선택"
|
||||
serverSelectConfirm = "선택"
|
||||
serverSelectCancel = "취소"
|
||||
accountSelectHeader = "계정 선택"
|
||||
accountSelectConfirm = "선택"
|
||||
accountSelectCancel = "취소"
|
||||
|
||||
[ejs.settings]
|
||||
navHeaderText = "설정"
|
||||
navAccount = "계정"
|
||||
navMinecraft = "마인크래프트"
|
||||
navMods = "모드"
|
||||
navJava = "Java"
|
||||
navLauncher = "런처"
|
||||
navAbout = "정보"
|
||||
navUpdates = "업데이트"
|
||||
navDone = "완료"
|
||||
tabAccountHeaderText = "계정 설정"
|
||||
tabAccountHeaderDesc = "새로운 계정을 추가하거나 기존 계정을 관리합니다."
|
||||
microsoftAccount = "Microsoft"
|
||||
addMicrosoftAccount = "+ Microsoft 계정 추가"
|
||||
mojangAccount = "Mojang"
|
||||
addMojangAccount = "+ Mojang 계정 추가"
|
||||
minecraftTabHeaderText = "마인크래프트 설정"
|
||||
minecraftTabHeaderDesc = "게임 실행과 관련된 설정입니다."
|
||||
gameResolutionTitle = "게임 해상도"
|
||||
launchFullscreenTitle = "전체 화면으로 실행합니다."
|
||||
autoConnectTitle = "게임 실행 후 자동으로 서버에 접속합니다."
|
||||
launchDetachedTitle = "런처와 게임 프로세스를 분리하여 실행합니다."
|
||||
launchDetachedDesc = "이 옵션을 사용하면 런처를 닫아도 게임이 계속 실행됩니다."
|
||||
tabModsHeaderText = "모드 설정"
|
||||
tabModsHeaderDesc = "모드를 활성화하거나 비활성화합니다."
|
||||
switchServerButton = "변경"
|
||||
requiredMods = "필수 모드"
|
||||
optionalMods = "선택 모드"
|
||||
dropinMods = "사용자 추가 모드"
|
||||
addMods = "모드 추가"
|
||||
dropinRefreshNote = "(F5를 눌러 새로고침)"
|
||||
shaderpacks = "쉐이더팩"
|
||||
shaderpackDesc = "쉐이더를 활성화하거나 비활성화합니다. 쉐이더가 원활하게 작동하려면 높은 컴퓨터 성능이 필요합니다. 여기서 쉐이더팩을 추가할 수 있습니다."
|
||||
selectShaderpack = "쉐이더팩 선택"
|
||||
tabJavaHeaderText = "Java 설정"
|
||||
tabJavaHeaderDesc = "Java 설정을 관리합니다 (고급 설정)."
|
||||
memoryTitle = "메모리"
|
||||
maxRAM = "최대 RAM"
|
||||
minRAM = "최소 RAM"
|
||||
memoryDesc = "권장하는 최소 RAM은 4GB입니다. 최소와 최대 RAM을 같은 값으로 설정하면 게임 렉을 줄일 수 있습니다."
|
||||
memoryTotalTitle = "전체"
|
||||
memoryAvailableTitle = "사용 가능"
|
||||
javaExecutableTitle = "Java 실행 파일"
|
||||
javaExecSelDialogTitle = "Java 실행 파일 선택"
|
||||
javaExecSelButtonText = "파일 선택"
|
||||
javaExecDesc = "게임 실행 전에 Java 실행 파일의 유효성을 검사합니다."
|
||||
javaPathDesc = "Java 실행 파일의 경로는 반드시 <strong>{pathSuffix}</strong>로 끝나야 합니다."
|
||||
jvmOptsTitle = "JVM 인수 설정"
|
||||
jvmOptsDesc = "실행 시 JVM에 전달할 추가 인수를 설정합니다. <em>-Xms</em>와 <em>-Xmx</em>는 포함되지 않아야 합니다."
|
||||
launcherTabHeaderText = "런처 설정"
|
||||
launcherTabHeaderDesc = "런처와 관련된 설정입니다."
|
||||
allowPrereleaseTitle = "프리릴리즈 업데이트를 허용합니다."
|
||||
allowPrereleaseDesc = "프리릴리즈는 안정성이 보장되지 않은 기능을 포함할 수 있습니다.<br>현재 실행 중인 런처가 프리릴리즈 버전이라면, 이 설정은 항상 활성화됩니다."
|
||||
dataDirectoryTitle = "데이터 디렉토리"
|
||||
selectDataDirectory = "데이터 디렉토리 선택"
|
||||
chooseFolder = "폴더 선택"
|
||||
dataDirectoryDesc = "모든 게임 파일과 로컬 Java 설치는 데이터 디렉토리에 저장됩니다.<br>스크린샷과 월드 데이터는 서버 설정에서 지정한 인스턴스 폴더에 저장됩니다."
|
||||
aboutTabHeaderText = "정보"
|
||||
aboutTabHeaderDesc = "런처 정보와 릴리즈 노트를 확인합니다."
|
||||
aboutTitle = "{appName}"
|
||||
stableRelease = "Stable Release"
|
||||
versionText = "Version "
|
||||
sourceGithub = "GitHub"
|
||||
sourceOriginalGithub = "Original GitHub"
|
||||
support = "Support"
|
||||
devToolsConsole = "DevTools Console"
|
||||
releaseNotes = "릴리즈 노트"
|
||||
changelog = "변경 로그"
|
||||
noReleaseNotes = "릴리즈 노트 없음"
|
||||
viewReleaseNotes = "GitHub에서 릴리즈 노트 보기"
|
||||
launcherUpdatesHeaderText = "런처 업데이트"
|
||||
launcherUpdatesHeaderDesc = "런처 업데이트를 확인하고 설치합니다."
|
||||
checkForUpdates = "업데이트 확인"
|
||||
whatsNew = "새로운 기능"
|
||||
updateReleaseNotes = "업데이트 릴리즈 노트"
|
||||
|
||||
[ejs.waiting]
|
||||
waitingText = "Microsoft의 응답을 기다리는 중.."
|
||||
|
||||
[ejs.welcome]
|
||||
continueButton = "계속"
|
||||
|
||||
|
||||
[js.discord]
|
||||
waiting = "클라이언트 대기 중.."
|
||||
state = "Server: {shortId}"
|
||||
|
||||
[js.index]
|
||||
microsoftLoginTitle = "Microsoft 로그인"
|
||||
microsoftLogoutTitle = "Microsoft 로그아웃"
|
||||
|
||||
[js.login]
|
||||
login = "로그인"
|
||||
loggingIn = "로그인 중"
|
||||
success = "성공"
|
||||
tryAgain = "다시 시도"
|
||||
|
||||
[js.login.error]
|
||||
invalidValue = "* 유효하지 않은 값"
|
||||
requiredValue = "* 필수 항목"
|
||||
|
||||
[js.login.error.unknown]
|
||||
title = "알 수 없는 로그인 오류"
|
||||
desc = "로그인 중 알 수 없는 오류가 발생했습니다. 콘솔에서 자세한 내용을 확인하세요."
|
||||
|
||||
[js.landing.launch]
|
||||
pleaseWait = "잠시만 기다려주세요.."
|
||||
failureTitle = "실행 중 오류 발생"
|
||||
failureText = "콘솔 (CTRL + Shift + i) 에서 자세한 내용을 확인하세요."
|
||||
okay = "확인"
|
||||
|
||||
[js.landing.selectedAccount]
|
||||
noAccountSelected = "선택된 계정 없음"
|
||||
|
||||
[js.landing.selectedServer]
|
||||
noSelection = "선택된 서버 없음"
|
||||
loading = "로딩 중.."
|
||||
|
||||
[js.landing.serverStatus]
|
||||
server = "서버"
|
||||
offline = "오프라인"
|
||||
players = "플레이어"
|
||||
|
||||
[js.landing.systemScan]
|
||||
checking = "시스템 정보 확인 중.."
|
||||
noCompatibleJava = "호환되는 Java가<br>설치되지 않음"
|
||||
installJavaMessage = "마인크래프트 실행을 위해 64비트 Java {major} 설치가 필요합니다. Java를 설치할까요?"
|
||||
installJava = "Java 설치"
|
||||
installJavaManually = "수동 설치"
|
||||
javaDownloadPrepare = "Java 다운로드 준비 중.."
|
||||
javaDownloadFailureTitle = "Java 다운로드 중 오류 발생"
|
||||
javaDownloadFailureText = "콘솔 (CTRL + Shift + i) 에서 자세한 내용을 확인하세요."
|
||||
javaRequired = "실행을 위해<br>Java가 필요합니다"
|
||||
javaRequiredMessage = "실행을 위해 유효한 64비트 Java {major} 를 설치해야 합니다.<br><br><a href=\"https://github.com/peunsu/MRSLauncher/wiki/Java-%EA%B4%80%EB%A6%AC#java-%EC%88%98%EB%8F%99-%EC%84%A4%EC%B9%98\">Java Management Guide</a>를 참조하여 Java 수동 설치 방법을 확인하세요."
|
||||
javaRequiredDismiss = "확인"
|
||||
javaRequiredCancel = "돌아가기"
|
||||
|
||||
[js.landing.downloadJava]
|
||||
findJdkFailure = "OpenJDK를 찾을 수 없습니다."
|
||||
javaDownloadCorruptedError = "다운로드한 JDK가 손상되었습니다."
|
||||
extractingJava = "Java 압축 해제 중.."
|
||||
javaInstalled = "Java 설치 완료!"
|
||||
|
||||
[js.landing.dlAsync]
|
||||
loadingServerInfo = "서버 정보 로딩 중.."
|
||||
fatalError = "치명적 오류 발생"
|
||||
unableToLoadDistributionIndex = "배포 인덱스를 불러올 수 없습니다. 콘솔 (CTRL + Shift + i) 에서 자세한 내용을 확인하세요."
|
||||
pleaseWait = "잠시만 기다려주세요.."
|
||||
errorDuringLaunchTitle = "실행 중 오류 발생"
|
||||
seeConsoleForDetails = "콘솔 (CTRL + Shift + i) 에서 자세한 내용을 확인하세요."
|
||||
validatingFileIntegrity = "파일 무결성 검사 중.."
|
||||
errorDuringFileVerificationTitle = "파일 검사 중 오류 발생"
|
||||
downloadingFiles = "파일 다운로드 중.."
|
||||
errorDuringFileDownloadTitle = "파일 다운로드 중 오류 발생"
|
||||
preparingToLaunch = "실행 준비 중.."
|
||||
launchingGame = "게임 실행 중.."
|
||||
launchWrapperNotDownloaded = "게임 실행을 위한 메인 파일(LaunchWrapper)을 다운로드할 수 없습니다.<br><br>문제 해결을 위해 임시로 안티바이러스 프로그램을 비활성화한 뒤 게임을 다시 실행해 보세요.<br><br>만약 이 문제가 계속된다면, <a href=\"https://github.com/peunsu/MRSLauncher/issues\">GitHub 이슈 페이지</a>에 사용 중인 안티바이러스 프로그램과 함께 문제를 제보해 주세요."
|
||||
doneEnjoyServer = "완료. 즐거운 시간 되세요!"
|
||||
checkConsoleForDetails = "콘솔 (CTRL + Shift + i) 에서 자세한 내용을 확인하세요."
|
||||
|
||||
[js.landing.news]
|
||||
checking = "뉴스 확인 중"
|
||||
|
||||
[js.landing.discord]
|
||||
loading = "게임 로딩 중.."
|
||||
joining = "서버 접속 중.."
|
||||
joined = "Mystic Red Space"
|
||||
|
||||
[js.overlay]
|
||||
dismiss = "닫기"
|
||||
|
||||
[js.settings.fileSelectors]
|
||||
executables = "실행 파일"
|
||||
allFiles = "모든 파일"
|
||||
|
||||
[js.settings.mstfLogin]
|
||||
errorTitle = "오류 발생"
|
||||
errorMessage = "Microsoft 로그인에 실패했습니다. 다시 시도해 주세요."
|
||||
okButton = "확인"
|
||||
|
||||
[js.settings.mstfLogout]
|
||||
errorTitle = "오류 발생"
|
||||
errorMessage = "Microsoft 로그아웃에 실패했습니다. 다시 시도해 주세요."
|
||||
okButton = "확인"
|
||||
|
||||
[js.settings.authAccountSelect]
|
||||
selectButton = "계정 선택"
|
||||
selectedButton = "선택된 계정 ✔"
|
||||
|
||||
[js.settings.authAccountLogout]
|
||||
lastAccountWarningTitle = "경고<br>마지막 남은 계정을 로그아웃합니다."
|
||||
lastAccountWarningMessage = "런처를 사용하려면 최소한 하나의 계정은 로그인되어 있어야 합니다.<br><br>정말 로그아웃 하시겠습니까?"
|
||||
confirmButton = "확인"
|
||||
cancelButton = "취소"
|
||||
|
||||
[js.settings.authAccountPopulate]
|
||||
username = "사용자 이름"
|
||||
uuid = "UUID"
|
||||
selectAccount = "계정 선택"
|
||||
selectedAccount = "선택된 계정 ✓"
|
||||
logout = "로그아웃"
|
||||
|
||||
[js.settings.dropinMods]
|
||||
removeButton = "제거"
|
||||
deleteFailedTitle = "사용자 추가 모드 삭제 실패<br>{fullName}"
|
||||
deleteFailedMessage = "삭제하려는 파일이 사용 중인지 확인하고 다시 시도해 주세요."
|
||||
failedToggleTitle = "사용자 추가 모드를<br>활성화/비활성화할 수 없습니다."
|
||||
okButton = "확인"
|
||||
|
||||
[js.settings.serverListing]
|
||||
mainServer = "메인 서버"
|
||||
|
||||
[js.settings.java]
|
||||
selectedJava = "선택: Java {version} ({vendor})"
|
||||
invalidSelection = "유효하지 않은 선택"
|
||||
requiresJava = "64비트 Java {major}가 필요합니다."
|
||||
availableOptions = "Java {major} (HotSpot VM)의 사용 가능한 JVM 인수"
|
||||
|
||||
[js.settings.about]
|
||||
preReleaseTitle = "Pre-release"
|
||||
stableReleaseTitle = "Stable Release"
|
||||
releaseNotesFailed = "릴리즈 노트를 불러올 수 없습니다."
|
||||
|
||||
[js.settings.updates]
|
||||
newReleaseTitle = "새로운 Release 업데이트 가능"
|
||||
newPreReleaseTitle = "새로운 Pre-Release 업데이트 가능"
|
||||
downloadingButton = "다운로드 중.."
|
||||
downloadButton = 'GitHub에서 다운로드<span style="font-size: 10px;color: gray;text-shadow: none !important;">런처를 종료하고 dmg 파일을 실행하여 업데이트하세요.</span>'
|
||||
latestVersionTitle = "최신 버전을 사용하고 있습니다"
|
||||
checkForUpdatesButton = "업데이트 확인"
|
||||
checkingForUpdatesButton = "업데이트 확인 중.."
|
||||
|
||||
[js.settings.msftLogin]
|
||||
errorTitle = "Microsoft 로그인 실패"
|
||||
errorMessage = "Microsoft 계정으로 로그인할 수 없습니다. 다시 시도해 주세요."
|
||||
okButton = "확인"
|
||||
|
||||
[js.uibinder.startup]
|
||||
fatalErrorTitle = "치명적 오류: 배포 인덱스를 불러올 수 없습니다."
|
||||
fatalErrorMessage = "배포 인덱스 다운로드 서버에 연결할 수 없습니다. 로컬 복사본도 존재하지 않습니다.<br><br>배포 인덱스는 최신 서버 정보를 제공하는 필수 파일로 런처를 실행하기 위해 필요합니다. 인터넷 연결을 확인하고 프로그램을 다시 실행해 보세요."
|
||||
closeButton = "닫기"
|
||||
|
||||
[js.uibinder.validateAccount]
|
||||
failedMessageTitle = "로그인 갱신 실패"
|
||||
failedMessage = "<strong>{account}</strong>의 로그인을 갱신할 수 없습니다. 다른 계정을 선택하거나 다시 로그인하세요."
|
||||
failedMessageSelectAnotherAccount = "<strong>{account}</strong>의 로그인을 갱신할 수 없습니다. 다시 로그인하세요."
|
||||
loginButton = "로그인"
|
||||
selectAnotherAccountButton = "다른 계정 선택"
|
||||
|
||||
[js.uicore.autoUpdate]
|
||||
checkingForUpdateButton = "업데이트 확인 중..."
|
||||
installNowButton = "설치하기"
|
||||
checkForUpdatesButton = "업데이트 확인"
|
||||
|
||||
[js.auth.microsoft.error]
|
||||
noProfileTitle = "로그인 중 오류 발생:<br>프로필이 설정되지 않음"
|
||||
noProfileDesc = "Microsoft 계정에 마인크래프트 프로필이 설정되지 않았습니다. 마인크래프트를 구매했거나 Xbox Game Pass를 사용 중이라면, <a href=\"https://www.minecraft.net/\">Minecraft.net</a>에서 로그인하여 프로필을 설정하세요.<br><br>아직 게임을 구매하지 않았다면, <a href=\"https://www.minecraft.net/\">Minecraft.net</a>에서 구매하세요."
|
||||
noXboxAccountTitle = "로그인 중 오류 발생:<br>Xbox 계정 없음"
|
||||
noXboxAccountDesc = "Microsoft 계정에 연결된 Xbox 계정이 없습니다."
|
||||
xblBannedTitle = "로그인 중 오류 발생:<br>Xbox Live 이용 불가"
|
||||
xblBannedDesc = "Xbox Live 이용이 제한되거나 차단된 국가의 Microsoft 계정으로 로그인할 수 없습니다."
|
||||
under18Title = "로그인 중 오류 발생:<br>부모 동의 필요"
|
||||
under18Desc = "18세 미만 유저의 계정은 부모의 가족 구성원으로 등록되어 있어야 합니다."
|
||||
unknownTitle = "로그인 중 알 수 없는 오류 발생"
|
||||
unknownDesc = "로그인 중 알 수 없는 오류가 발생했습니다. 콘솔에서 자세한 내용을 확인하세요."
|
||||
|
||||
[js.auth.mojang.error]
|
||||
methodNotAllowedTitle = "내부 오류 발생:<br>허용되지 않은 메소드"
|
||||
methodNotAllowedDesc = "메소드가 허용되지 않습니다. 개발자에게 이 문제를 보고하세요."
|
||||
notFoundTitle = "내부 오류 발생:<br>엔드포인트를 찾을 수 없음"
|
||||
notFoundDesc = "인증 엔드포인트를 찾을 수 없습니다. 개발자에게 이 문제를 보고하세요."
|
||||
accountMigratedTitle = "로그인 중 오류 발생:<br>마이그레이션된 계정"
|
||||
accountMigratedDesc = "마이그레이션된 계정으로 로그인을 시도했습니다. 사용자 이름에 계정 이메일 주소를 입력하여 로그인하세요."
|
||||
invalidCredentialsTitle = "로그인 중 오류 발생:<br>잘못된 자격 증명"
|
||||
invalidCredentialsDesc = "이메일 또는 비밀번호가 잘못되었습니다. 다시 시도하세요."
|
||||
tooManyAttemptsTitle = "로그인 중 오류 발생:<br>시도 횟수 초과"
|
||||
tooManyAttemptsDesc = "로그인 시도 횟수가 너무 많습니다. 잠시 후 다시 시도하세요."
|
||||
invalidTokenTitle = "로그인 중 오류 발생:<br>유효하지 않은 토큰"
|
||||
invalidTokenDesc = "유효하지 않은 액세스 토큰입니다."
|
||||
tokenHasProfileTitle = "로그인 중 오류 발생:<br>프로필이 할당된 토큰"
|
||||
tokenHasProfileDesc = "액세스 토큰에 이미 프로필이 할당되어 있습니다. 프로필 선택은 아직 지원되지 않습니다."
|
||||
credentialsMissingTitle = "로그인 중 오류 발생:<br>자격 증명 누락"
|
||||
credentialsMissingDesc = "사용자 이름 또는 비밀번호가 비어있거나, 비밀번호가 3글자 미만으로 입력되었습니다."
|
||||
invalidSaltVersionTitle = "로그인 중 오류 발생:<br>유효하지 않은 Salt 버전"
|
||||
invalidSaltVersionDesc = "유효하지 않은 Salt 버전입니다."
|
||||
unsupportedMediaTypeTitle = "내부 오류 발새:<br>지원되지 않는 미디어 타입"
|
||||
unsupportedMediaTypeDesc = "지원되지 않는 미디어 타입입니다. 개발자에게 이 문제를 보고하세요."
|
||||
accountGoneTitle = "로그인 중 오류 발생:<br>마이그레이션된 계정"
|
||||
accountGoneDesc = "Microsoft로 마이그레이션된 계정입니다. Microsoft 계정으로 로그인하세요."
|
||||
unreachableTitle = "로그인 중 오류 발생:<br>서버에 연결할 수 없음"
|
||||
unreachableDesc = "인증 서버에 연결할 수 없습니다. 인증 서버가 온라인 상태이고 인터넷 연결이 정상인지 확인하세요."
|
||||
gameNotPurchasedTitle = "로그인 중 오류 발생:<br>게임을 구매하지 않음"
|
||||
gameNotPurchasedDesc = "로그인을 시도한 계정은 마인크래프트를 구매하지 않았습니다. <a href=\"https://minecraft.net/\">Minecraft.net</a>에서 게임을 구매하세요."
|
||||
unknownErrorTitle = "로그인 중 알 수 없는 오류 발생"
|
||||
unknownErrorDesc = "로그인 중 알 수 없는 오류가 발생했습니다. 콘솔에서 자세한 내용을 확인하세요."
|
||||
45
app/assets/launcher/catalog.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"version": 1,
|
||||
"profiles": [
|
||||
{
|
||||
"id": "mrs-concatenation-lite",
|
||||
"name": "Concatenation Lite",
|
||||
"kind": "modpack",
|
||||
"description": "기존 MRS 분배 인덱스를 사용하는 예시 프로필입니다. 프로필 카탈로그 구조가 동작하는지 확인할 때 사용할 수 있습니다.",
|
||||
"details": "Mystic Red Space에서 운영하던 기존 분배 인덱스를 그대로 사용합니다.\n\n사용자는 이 항목을 라이브러리에 추가한 뒤 바로 실행할 수 있습니다.\n관리자는 distribution URL과 기본 접속 주소를 유지하거나 교체해서 실서비스용 항목으로 바꿀 수 있습니다.",
|
||||
"distributionUrl": "https://cdn.mysticred.space/launcher/distribution.json",
|
||||
"defaultServerAddress": "play.mysticred.space",
|
||||
"allowCustomServerAddress": true
|
||||
},
|
||||
{
|
||||
"id": "template-original-map",
|
||||
"name": "Original Map Template",
|
||||
"kind": "map",
|
||||
"description": "오리지널 맵용 템플릿입니다. vanilla 또는 맵 전용 distribution URL과 월드 ZIP 경로를 채우면 싱글플레이 빠른 실행에 사용할 수 있습니다.",
|
||||
"details": "관리자는 이 항목에 월드 ZIP과 클라이언트 distribution URL을 미리 등록합니다.\n\n사용자는 설치 페이지에서 설명을 읽고 라이브러리에 추가한 뒤, 라이브러리에서 맵 자료를 준비하고 바로 실행할 수 있습니다.",
|
||||
"distributionUrl": "https://example.com/launcher/vanilla-map-distribution.json",
|
||||
"worldArchiveUrl": "https://example.com/maps/original-map.zip",
|
||||
"worldDirectoryName": "Original Map",
|
||||
"defaultServerAddress": "",
|
||||
"allowCustomServerAddress": false,
|
||||
"configured": false
|
||||
},
|
||||
{
|
||||
"id": "template-plugin-server-pack",
|
||||
"name": "Plugin Server Pack Template",
|
||||
"kind": "server-pack",
|
||||
"description": "플러그인 맵 + 서버 접속용 템플릿입니다. 클라이언트 distribution, 서버 번들 ZIP, 서버 시작 명령, 선택형 터널 명령을 연결하면 됩니다.",
|
||||
"details": "관리자는 클라이언트 배포 파일, 서버 번들, 서버 시작 명령을 미리 등록합니다.\n\n사용자는 설치 페이지에서 상세 내용을 확인하고 라이브러리에 추가한 뒤, 라이브러리에서 직접 서버를 켜거나 수동 주소를 넣어 접속할 수 있습니다.",
|
||||
"distributionUrl": "https://example.com/launcher/server-pack-client-distribution.json",
|
||||
"serverBundleUrl": "https://example.com/serverpacks/plugin-world-server.zip",
|
||||
"serverDirectoryName": "plugin-world-server",
|
||||
"serverLaunchCommand": "java -jar server.jar nogui",
|
||||
"serverPort": 25565,
|
||||
"tunnelCommand": "",
|
||||
"tunnelAddressRegex": "",
|
||||
"defaultServerAddress": "",
|
||||
"allowCustomServerAddress": true,
|
||||
"configured": false
|
||||
}
|
||||
]
|
||||
}
|
||||
33
app/frame.ejs
Normal file
@@ -0,0 +1,33 @@
|
||||
<div id="frameBar">
|
||||
<div id="frameResizableTop" class="frameDragPadder"></div>
|
||||
<div id="frameMain">
|
||||
<div class="frameResizableVert frameDragPadder"></div>
|
||||
<%if (process.platform === 'darwin') { %>
|
||||
<div id="frameContentDarwin">
|
||||
<div id="frameButtonDockDarwin">
|
||||
<button class="frameButtonDarwin fCb" id="frameButtonDarwin_close" tabIndex="-1"></button>
|
||||
<button class="frameButtonDarwin fMb" id="frameButtonDarwin_minimize" tabIndex="-1"></button>
|
||||
<button class="frameButtonDarwin fRb" id="frameButtonDarwin_restoredown" tabIndex="-1"></button>
|
||||
</div>
|
||||
</div>
|
||||
<% } else{ %>
|
||||
<div id="frameContentWin">
|
||||
<div id="frameTitleDock">
|
||||
<span id="frameTitleText"><%= lang('app.title') %></span>
|
||||
</div>
|
||||
<div id="frameButtonDockWin">
|
||||
<button class="frameButton fMb" id="frameButton_minimize" tabIndex="-1">
|
||||
<svg name="TitleBarMinimize" width="10" height="10" viewBox="0 0 12 12"><rect stroke="#ffffff" fill="#ffffff" width="10" height="1" x="1" y="6"></rect></svg>
|
||||
</button>
|
||||
<button class="frameButton fRb" id="frameButton_restoredown" tabIndex="-1">
|
||||
<svg name="TitleBarMaximize" width="10" height="10" viewBox="0 0 12 12"><rect width="9" height="9" x="1.5" y="1.5" fill="none" stroke="#ffffff" stroke-width="1.4px"></rect></svg>
|
||||
</button>
|
||||
<button class="frameButton fCb" id="frameButton_close" tabIndex="-1">
|
||||
<svg name="TitleBarClose" width="10" height="10" viewBox="0 0 12 12"><polygon stroke="#ffffff" fill="#ffffff" fill-rule="evenodd" points="11 1.576 6.583 6 11 10.424 10.424 11 6 6.583 1.576 11 1 10.424 5.417 6 1 1.576 1.576 1 6 5.417 10.424 1"></polygon></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
<div class="frameResizableVert frameDragPadder"></div>
|
||||
</div>
|
||||
</div>
|
||||
38
app/install.ejs
Normal file
@@ -0,0 +1,38 @@
|
||||
<div id="installContainer" style="display: none;">
|
||||
<div class="launcherPageShell">
|
||||
<div class="launcherPageHeader">
|
||||
<div>
|
||||
<span class="launcherPageEyebrow"><%- lang('install.eyebrow') %></span>
|
||||
<h2 class="launcherPageTitle"><%- lang('install.title') %></h2>
|
||||
<p class="launcherPageSubtitle"><%- lang('install.subtitle') %></p>
|
||||
</div>
|
||||
<div class="launcherPageActions">
|
||||
<button id="installOpenSettingsButton" class="launcherSecondaryButton"><%- lang('library.settingsButton') %></button>
|
||||
<button id="installBackToLibraryButton" class="launcherSecondaryButton"><%- lang('install.libraryPageButton') %></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="installCatalogNotice" class="launcherNotice">
|
||||
<span><%- lang('install.notice') %></span>
|
||||
</div>
|
||||
<div id="installProfileDetails" class="launcherNotice">
|
||||
<div class="launcherEditorHeader">
|
||||
<div>
|
||||
<span id="installDetailTitle" class="launcherEditorTitle">프로필을 선택하세요</span>
|
||||
<span id="installDetailSummary" class="launcherEditorDescription">왼쪽 목록에서 모드팩, 맵, 서버팩을 고르면 자세한 설명과 설치 조건을 볼 수 있습니다.</span>
|
||||
</div>
|
||||
<div id="installDetailMeta" class="launcherPageActions"></div>
|
||||
</div>
|
||||
<div id="installDetailInfo" class="launcherInfoBlock"></div>
|
||||
<div class="launcherFieldGroup">
|
||||
<label class="launcherFieldLabel">자세한 내용</label>
|
||||
<div id="installDetailBody" class="launcherDetailBody">관리자가 등록한 프로필 상세 설명이 여기에 표시됩니다.</div>
|
||||
</div>
|
||||
<div id="installDetailActions" class="launcherCardActions">
|
||||
<button id="installDetailAddButton" class="launcherPrimaryButton" disabled>라이브러리에 추가</button>
|
||||
<button id="installDetailOpenLibraryButton" class="launcherSecondaryButton">라이브러리 열기</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="installCatalogList" class="launcherCardGrid"></div>
|
||||
</div>
|
||||
<script src="./assets/js/scripts/install.js"></script>
|
||||
</div>
|
||||
251
app/landing.ejs
Normal file
@@ -0,0 +1,251 @@
|
||||
<div id="landingContainer" style="display: none;">
|
||||
<div id="upper">
|
||||
<div id="left">
|
||||
<div id="image_seal_container">
|
||||
<img id="image_seal" src="assets/images/Icon.png"/>
|
||||
<div id="updateAvailableTooltip"><%- lang('landing.updateAvailableTooltip') %></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="content">
|
||||
<div id="landingNavButtons">
|
||||
<button id="landingLibraryButton" class="launcherSecondaryButton"><%- lang('landing.libraryButton') %></button>
|
||||
<button id="landingInstallButton" class="launcherSecondaryButton"><%- lang('landing.installButton') %></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="right">
|
||||
<div id="rightContainer">
|
||||
<div id="user_content">
|
||||
<span id="user_text"><%- lang('landing.usernamePlaceholder') %></span>
|
||||
<div id="avatarContainer">
|
||||
<button id="avatarOverlay"><%- lang('landing.usernameEditButton') %></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mediaContent">
|
||||
<div id="internalMedia">
|
||||
<div class="mediaContainer" id="settingsMediaContainer">
|
||||
<button class="mediaButton" id="settingsMediaButton">
|
||||
<svg id="settingsSVG" class="mediaSVG" viewBox="0 0 141.36 137.43">
|
||||
<path d="M70.70475616319865,83.36934004916053 a15.320781354859122,15.320781354859122 0 1 1 14.454501310561755,-15.296030496450625 A14.850515045097694,14.850515045097694 0 0 1 70.70475616319865,83.36934004916053 M123.25082856443602,55.425620905968366 h-12.375429204248078 A45.54157947163293,45.54157947163293 0 0 0 107.21227231573047,46.243052436416285 l8.613298726156664,-9.108315894326587 a9.727087354538993,9.727087354538993 0 0 0 0,-13.167456673319956 l-3.465120177189462,-3.6631270444574313 a8.489544434114185,8.489544434114185 0 0 0 -12.375429204248078,0 l-8.613298726156664,9.108315894326587 A40.442902639482725,40.442902639482725 0 0 0 81.99114759747292,25.427580514871032 V12.532383284044531 a9.108315894326587,9.108315894326587 0 0 0 -8.811305593424633,-9.306322761594556 h-4.950171681699231 a9.108315894326587,9.108315894326587 0 0 0 -8.811305593424633,9.306322761594556 v12.895197230826497 a40.17064319698927,40.17064319698927 0 0 0 -9.331073620003052,4.0591407789933704 l-8.613298726156664,-9.108315894326587 a8.489544434114185,8.489544434114185 0 0 0 -12.375429204248078,0 L25.58394128451018,23.967279868769744 a9.727087354538993,9.727087354538993 0 0 0 0,13.167456673319956 L34.19724001066683,46.243052436416285 a45.07131316187151,45.07131316187151 0 0 0 -3.6631270444574313,9.083565035918088 h-12.375429204248078 a9.083565035918088,9.083565035918088 0 0 0 -8.811305593424633,9.306322761594556 v5.197680265784193 a9.108315894326587,9.108315894326587 0 0 0 8.811305593424633,9.306322761594556 h11.979415469712139 a45.69008462208391,45.69008462208391 0 0 0 4.0591407789933704,10.642869115653347 l-8.613298726156664,9.108315894326587 a9.727087354538993,9.727087354538993 0 0 0 0,13.167456673319956 l3.465120177189462,3.6631270444574313 a8.489544434114185,8.489544434114185 0 0 0 12.375429204248078,0 l8.613298726156664,-9.108315894326587 a40.49240435629971,40.49240435629971 0 0 0 9.331073620003052,4.0591407789933704 v12.895197230826497 a9.083565035918088,9.083565035918088 0 0 0 8.811305593424633,9.306322761594556 h4.950171681699231 A9.083565035918088,9.083565035918088 0 0 0 81.99114759747292,123.68848839660077 V110.79329116577425 a40.78941465720167,40.78941465720167 0 0 0 9.331073620003052,-4.0591407789933704 l8.613298726156664,9.108315894326587 a8.489544434114185,8.489544434114185 0 0 0 12.375429204248078,0 l3.465120177189462,-3.6631270444574313 a9.727087354538993,9.727087354538993 0 0 0 0,-13.167456673319956 l-8.613298726156664,-9.108315894326587 a45.665333763675406,45.665333763675406 0 0 0 4.034389920584874,-10.642869115653347 h12.004166328120636 a9.108315894326587,9.108315894326587 0 0 0 8.811305593424633,-9.306322761594556 v-5.197680265784193 a9.083565035918088,9.083565035918088 0 0 0 -8.811305593424633,-9.306322761594556 " id="svg_3" class=""/>
|
||||
</svg>
|
||||
<div id="settingsTooltip"><%- lang('landing.settingsTooltip') %></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mediaDivider"></div>
|
||||
<div id="externalMedia">
|
||||
<!-- <div class="mediaContainer">
|
||||
<a href="<%- lang('landing.mediaLinkURL') %>" class="mediaURL" id="linkURL">
|
||||
<svg id="linkSVG" class="mediaSVG" viewBox="35.34 34.3575 70.68 68.71500">
|
||||
<g>
|
||||
<path d="M75.37,65.51a3.85,3.85,0,0,0-1.73.42,8.22,8.22,0,0,1,.94,3.76A8.36,8.36,0,0,1,66.23,78H46.37a8.35,8.35,0,1,1,0-16.7h9.18a21.51,21.51,0,0,1,6.65-8.72H46.37a17.07,17.07,0,1,0,0,34.15H66.23A17,17,0,0,0,82.77,65.51Z"/>
|
||||
<path d="M66,73.88a3.85,3.85,0,0,0,1.73-.42,8.22,8.22,0,0,1-.94-3.76,8.36,8.36,0,0,1,8.35-8.35H95A8.35,8.35,0,1,1,95,78H85.8a21.51,21.51,0,0,1-6.65,8.72H95a17.07,17.07,0,0,0,0-34.15H75.13A17,17,0,0,0,58.59,73.88Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
</a>
|
||||
</div> -->
|
||||
<div class="mediaContainer">
|
||||
<a href="<%- lang('landing.mediaHomeURL') %>" class="mediaURL" id="homeURL">
|
||||
<svg id="homeSVG" class="mediaSVG" viewBox="0 0 491.398 491.398">
|
||||
<g>
|
||||
<path d="M481.765,220.422L276.474,15.123c-16.967-16.918-44.557-16.942-61.559,0.023L9.626,220.422c-12.835,12.833-12.835,33.65,0,46.483c12.843,12.842,33.646,12.842,46.487,0l27.828-27.832v214.872c0,19.343,15.682,35.024,35.027,35.024h74.826v-97.62c0-7.584,6.146-13.741,13.743-13.741h76.352c7.59,0,13.739,6.157,13.739,13.741v97.621h74.813c19.346,0,35.027-15.681,35.027-35.024V239.091l27.812,27.815c6.425,6.421,14.833,9.63,23.243,9.63c8.408,0,16.819-3.209,23.242-9.63C494.609,254.072,494.609,233.256,481.765,220.422z"/>
|
||||
</g>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mediaContainer">
|
||||
<a href="<%- lang('landing.mediaMapURL') %>" class="mediaURL" id="mapURL">
|
||||
<svg id="mapSVG" class="mediaSVG" viewBox="0 0 512 512">
|
||||
<g>
|
||||
<path d="M256,0C160.798,0,83.644,77.155,83.644,172.356c0,97.162,48.158,117.862,101.386,182.495C248.696,432.161,256,512,256,512s7.304-79.839,70.97-157.148c53.228-64.634,101.386-85.334,101.386-182.495C428.356,77.155,351.202,0,256,0z M256,231.921c-32.897,0-59.564-26.668-59.564-59.564s26.668-59.564,59.564-59.564c32.896,0,59.564,26.668,59.564,59.564S288.896,231.921,256,231.921z"/>
|
||||
</g>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mediaContainer">
|
||||
<a href="<%- lang('landing.mediaGitHubURL') %>" class="mediaURL" id="githubURL">
|
||||
<svg id="githubSVG" class="mediaSVG" viewbox="0 0 98 98">
|
||||
<g>
|
||||
<path d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"/>
|
||||
</g>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<!-- <div class="mediaContainer">
|
||||
<a href="<%- lang('landing.mediaXURL') %>" class="mediaURL" id="xURL">
|
||||
<svg id="xSVG" class="mediaSVG" viewBox="0 0 275 275">
|
||||
<g>
|
||||
<path d="m236 0h46l-101 115 118 156h-92.6l-72.5-94.8-83 94.8h-46l107-123-113-148h94.9l65.5 86.6zm-16.1 244h25.5l-165-218h-27.4z"/>
|
||||
</g>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mediaContainer">
|
||||
<a href="<%- lang('landing.mediaInstagramURL') %>" class="mediaURL" id="instagramURL">
|
||||
<svg id="instagramSVG" class="mediaSVG" viewBox="0 0 5040 5040">
|
||||
<defs>
|
||||
<radialGradient id="instaFill" cx="30%" cy="107%" r="150%">
|
||||
<stop offset="0%" stop-color="#fdf497"/>
|
||||
<stop offset="5%" stop-color="#fdf497"/>
|
||||
<stop offset="45%" stop-color="#fd5949"/>
|
||||
<stop offset="60%" stop-color="#d6249f"/>
|
||||
<stop offset="90%" stop-color="#285AEB"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<g>
|
||||
<path d="M1390 5024 c-163 -9 -239 -19 -315 -38 -281 -70 -477 -177 -660 -361 -184 -184 -292 -380 -361 -660 -43 -171 -53 -456 -53 -1445 0 -989 10 -1274 53 -1445 69 -280 177 -476 361 -660 184 -184 380 -292 660 -361 171 -43 456 -53 1445 -53 989 0 1274 10 1445 53 280 69 476 177 660 361 184 184 292 380 361 660 43 171 53 456 53 1445 0 989 -10 1274 -53 1445 -69 280 -177 476 -361 660 -184 184 -380 292 -660 361 -174 44 -454 53 -1470 52 -599 0 -960 -5 -1105 -14z m2230 -473 c58 -6 141 -18 185 -27 397 -78 638 -318 719 -714 37 -183 41 -309 41 -1290 0 -981 -4 -1107 -41 -1290 -81 -395 -319 -633 -714 -714 -183 -37 -309 -41 -1290 -41 -981 0 -1107 4 -1290 41 -397 81 -636 322 -714 719 -33 166 -38 296 -43 1100 -5 796 3 1203 27 1380 67 489 338 758 830 825 47 7 162 15 255 20 250 12 1907 4 2035 -9z"/>
|
||||
<path d="M2355 3819 c-307 -42 -561 -172 -780 -400 -244 -253 -359 -543 -359 -899 0 -361 116 -648 367 -907 262 -269 563 -397 937 -397 374 0 675 128 937 397 251 259 367 546 367 907 0 361 -116 648 -367 907 -197 203 -422 326 -690 378 -101 20 -317 27 -412 14z m400 -509 c275 -88 470 -284 557 -560 20 -65 23 -95 23 -230 0 -135 -3 -165 -23 -230 -88 -278 -284 -474 -562 -562 -65 -20 -95 -23 -230 -23 -135 0 -165 3 -230 23 -278 88 -474 284 -562 562 -20 65 -23 95 -23 230 0 135 3 165 23 230 73 230 219 403 427 507 134 67 212 83 390 79 111 -3 155 -8 210 -26z"/>
|
||||
<path d="M3750 1473 c-29 -11 -66 -38 -106 -77 -70 -71 -94 -126 -94 -221 0 -95 24 -150 94 -221 72 -71 126 -94 225 -94 168 0 311 143 311 311 0 99 -23 154 -94 225 -43 42 -76 66 -110 77 -61 21 -166 21 -226 0z"/>
|
||||
</g>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mediaContainer">
|
||||
<a href="<%- lang('landing.mediaYouTubeURL') %>" class="mediaURL" id="youtubeURL">
|
||||
<svg id="youtubeSVG" class="mediaSVG" viewBox="35.34 34.3575 70.68 68.71500">
|
||||
<g>
|
||||
<path d="M84.8,69.52,65.88,79.76V59.27Zm23.65.59c0-5.14-.79-17.63-3.94-20.57S99,45.86,73.37,45.86s-28,.73-31.14,3.68S38.29,65,38.29,70.11s.79,17.63,3.94,20.57,5.52,3.68,31.14,3.68,28-.74,31.14-3.68,3.94-15.42,3.94-20.57"/>
|
||||
</g>
|
||||
</svg>
|
||||
</a>
|
||||
</div> -->
|
||||
<div class="mediaContainer">
|
||||
<a href="<%- lang('landing.mediaDiscordURL') %>" class="mediaURL" id="discordURL">
|
||||
<svg id="discordSVG" class="mediaSVG" viewBox="35.34 34.3575 70.68 68.71500">
|
||||
<g>
|
||||
<path d="M81.23,78.48a6.14,6.14,0,1,1,6.14-6.14,6.14,6.14,0,0,1-6.14,6.14M60,78.48a6.14,6.14,0,1,1,6.14-6.14A6.14,6.14,0,0,1,60,78.48M104.41,73c-.92-7.7-8.24-22.9-8.24-22.9A43,43,0,0,0,88,45.59a17.88,17.88,0,0,0-8.38-1.27l-.13,1.06a23.52,23.52,0,0,1,5.8,1.95,87.59,87.59,0,0,1,8.17,4.87s-10.32-5.63-22.27-5.63a51.32,51.32,0,0,0-23.2,5.63,87.84,87.84,0,0,1,8.17-4.87,23.57,23.57,0,0,1,5.8-1.95l-.13-1.06a17.88,17.88,0,0,0-8.38,1.27,42.84,42.84,0,0,0-8.21,4.56S37.87,65.35,37,73s-.37,11.54-.37,11.54,4.22,5.68,9.9,7.14,7.7,1.47,7.7,1.47l3.75-4.68a21.22,21.22,0,0,1-4.65-2A24.47,24.47,0,0,1,47.93,82S61.16,88.4,70.68,88.4c10,0,22.75-6.44,22.75-6.44a24.56,24.56,0,0,1-5.35,4.56,21.22,21.22,0,0,1-4.65,2l3.75,4.68s2,0,7.7-1.47,9.89-7.14,9.89-7.14.55-3.85-.37-11.54"/>
|
||||
</g>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="lower">
|
||||
<div id="left">
|
||||
<div class="bot_wrapper">
|
||||
<div id="content">
|
||||
<div id="server_status_wrapper">
|
||||
<span class="bot_label" id="landingPlayerLabel"><%- lang('landing.serverStatus') %></span>
|
||||
<span id="player_count"><%- lang('landing.serverStatusPlaceholder') %></span>
|
||||
</div>
|
||||
<div class="bot_divider"></div>
|
||||
<div id="mojangStatusWrapper">
|
||||
<span class="bot_label"><%- lang('landing.mojangStatus') %></span>
|
||||
<span id="mojang_status_icon">•</span>
|
||||
<div id="mojangStatusTooltip">
|
||||
<div id="mojangStatusTooltipTitle"><%- lang('landing.mojangStatusTooltipTitle') %></div>
|
||||
<div id="mojangStatusEssentialContainer">
|
||||
<!-- Essential Mojang services are populated here. -->
|
||||
</div>
|
||||
<div id="mojangStatusNEContainer">
|
||||
<div class="mojangStatusNEBar"></div>
|
||||
<div id="mojangStatusNETitle"><%- lang('landing.mojangStatusNETitle') %></div>
|
||||
<div class="mojangStatusNEBar"></div>
|
||||
</div>
|
||||
<div id="mojangStatusNonEssentialContainer">
|
||||
<!-- Non Essential Mojang services are populated here. -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="center">
|
||||
<div class="bot_wrapper">
|
||||
<div id="content">
|
||||
<button id="newsButton">
|
||||
<!--<img src="assets/images/icons/arrow.svg" id="newsButtonSVG"/>-->
|
||||
<div id="newsButtonAlert" style="display: none;"></div>
|
||||
<svg id="newsButtonSVG" viewBox="0 0 24.87 13.97">
|
||||
<defs>
|
||||
<style>.arrowLine{fill:none;stroke:#FFF;stroke-width:2px;}</style>
|
||||
</defs>
|
||||
<polyline class="arrowLine" points="0.71 13.26 12.56 1.41 24.16 13.02"/>
|
||||
</svg>
|
||||
<span id="newsButtonText"><%- lang('landing.newsButton') %></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="right">
|
||||
<div class="bot_wrapper">
|
||||
<div id="launch_content">
|
||||
<button id="launch_button"><%- lang('landing.launchButton') %></button>
|
||||
<div class="bot_divider"></div>
|
||||
<button id="server_selection_button" class="bot_label"><%- lang('landing.launchButtonPlaceholder') %></button>
|
||||
</div>
|
||||
<div id="launch_details">
|
||||
<div id="launch_details_left">
|
||||
<span id="launch_progress_label">0%</span>
|
||||
<div class="bot_divider"></div>
|
||||
</div>
|
||||
<div id="launch_details_right">
|
||||
<progress id="launch_progress" value="22" max="100"></progress>
|
||||
<span id="launch_details_text" class="bot_label"><%- lang('landing.launchDetails') %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="newsContainer">
|
||||
<div id="newsContent" article="-1" style="display: none;">
|
||||
<div id="newsStatusContainer">
|
||||
<div id="newsStatusContent">
|
||||
<div id="newsTitleContainer">
|
||||
<a id="newsArticleTitle" href="#">Lorem Ipsum</a>
|
||||
</div>
|
||||
<div id="newsMetaContainer">
|
||||
<div id="newsArticleDateWrapper">
|
||||
<span id="newsArticleDate">Mar 15, 44 BC, 9:14 AM</span>
|
||||
</div>
|
||||
<div id="newsArticleAuthorWrapper">
|
||||
<span id="newsArticleAuthor">by Cicero</span>
|
||||
</div>
|
||||
<a href="#" id="newsArticleComments">0 Comments</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="newsNavigationContainer">
|
||||
<button id="newsNavigateLeft">
|
||||
<svg id="newsNavigationLeftSVG" viewBox="0 0 24.87 13.97">
|
||||
<defs>
|
||||
<style>.arrowLine{fill:none;stroke:#FFF;stroke-width:2px;transition: 0.25s ease;}</style>
|
||||
</defs>
|
||||
<polyline class="arrowLine" points="0.71 13.26 12.56 1.41 24.16 13.02"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span id="newsNavigationStatus"><%- lang('landing.newsNavigationStatus', { currentPage: 1, totalPages: 1 }) %></span>
|
||||
<button id="newsNavigateRight">
|
||||
<svg id="newsNavigationRightSVG" viewBox="0 0 24.87 13.97">
|
||||
<defs>
|
||||
<style>.arrowLine{fill:none;stroke:#FFF;stroke-width:2px;transition: 0.25s ease;}</style>
|
||||
</defs>
|
||||
<polyline class="arrowLine" points="0.71 13.26 12.56 1.41 24.16 13.02"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="newsArticleContainer">
|
||||
<div id="newsArticleContent">
|
||||
<div id="newsArticleContentScrollable">
|
||||
<!-- Article Content -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="newsErrorContainer">
|
||||
<div id="newsErrorLoading">
|
||||
<span id="nELoadSpan" class="newsErrorContent"><%- lang('landing.newsErrorLoadSpan') %></span>
|
||||
</div>
|
||||
<div id="newsErrorFailed" style="display: none;">
|
||||
<span id="nEFailedSpan" class="newsErrorContent"><%- lang('landing.newsErrorFailedSpan') %></span>
|
||||
<button id="newsErrorRetry"><%- lang('landing.newsErrorRetryButton') %></button>
|
||||
</div>
|
||||
<div id="newsErrorNone" style="display: none;">
|
||||
<span id="nENoneSpan" class="newsErrorContent"><%- lang('landing.newsErrorNoneSpan') %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="./assets/js/scripts/landing.js"></script>
|
||||
</div>
|
||||
22
app/library.ejs
Normal file
@@ -0,0 +1,22 @@
|
||||
<div id="libraryContainer" style="display: none;">
|
||||
<div class="launcherPageShell">
|
||||
<div class="launcherPageHeader">
|
||||
<div>
|
||||
<span class="launcherPageEyebrow"><%- lang('library.eyebrow') %></span>
|
||||
<h2 class="launcherPageTitle"><%- lang('library.title') %></h2>
|
||||
<p class="launcherPageSubtitle"><%- lang('library.subtitle') %></p>
|
||||
</div>
|
||||
<div class="launcherPageActions">
|
||||
<button id="libraryOpenSettingsButton" class="launcherSecondaryButton"><%- lang('library.settingsButton') %></button>
|
||||
<button id="libraryOpenInstallButton" class="launcherSecondaryButton"><%- lang('library.installPageButton') %></button>
|
||||
<button id="libraryOpenLaunchButton" class="launcherPrimaryButton"><%- lang('library.launchPageButton') %></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="libraryEmptyState" class="launcherEmptyState" style="display: none;">
|
||||
<span class="launcherEmptyTitle"><%- lang('library.emptyTitle') %></span>
|
||||
<span class="launcherEmptyDescription"><%- lang('library.emptyDescription') %></span>
|
||||
</div>
|
||||
<div id="libraryList" class="launcherCardGrid"></div>
|
||||
</div>
|
||||
<script src="./assets/js/scripts/library.js"></script>
|
||||
</div>
|
||||
65
app/login.ejs
Normal file
@@ -0,0 +1,65 @@
|
||||
<div id="loginContainer" style="display: none;">
|
||||
<div id="loginCancelContainer" style="display: none;">
|
||||
<button id="loginCancelButton">
|
||||
<div id="loginCancelIcon">X</div>
|
||||
<span id="loginCancelText"><%- lang('login.loginCancelText') %></span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="loginContent">
|
||||
<form id="loginForm">
|
||||
<img id="loginImageSeal" src="assets/images/Icon.png"/>
|
||||
<span id="loginSubheader"><%- lang('login.loginSubheader') %></span>
|
||||
<div class="loginFieldContainer">
|
||||
<svg id="profileSVG" class="loginSVG" viewBox="40 37 65.36 61.43">
|
||||
<g>
|
||||
<path d="M86.77,58.12A13.79,13.79,0,1,0,73,71.91,13.79,13.79,0,0,0,86.77,58.12M97,103.67a3.41,3.41,0,0,0,3.39-3.84,27.57,27.57,0,0,0-54.61,0,3.41,3.41,0,0,0,3.39,3.84Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
<span class="loginErrorSpan" id="loginEmailError"><%- lang('login.loginEmailError') %></span>
|
||||
<input id="loginUsername" class="loginField" type="text" placeholder="<%- lang('login.loginEmailPlaceholder') %>"/>
|
||||
</div>
|
||||
<div class="loginFieldContainer">
|
||||
<svg id="lockSVG" class="loginSVG" viewBox="40 32 60.36 70.43">
|
||||
<g>
|
||||
<path d="M86.16,54a16.38,16.38,0,1,0-32,0H44V102.7H96V54Zm-25.9-3.39a9.89,9.89,0,1,1,19.77,0A9.78,9.78,0,0,1,79.39,54H60.89A9.78,9.78,0,0,1,60.26,50.59ZM70,96.2a6.5,6.5,0,0,1-6.5-6.5,6.39,6.39,0,0,1,3.1-5.4V67h6.5V84.11a6.42,6.42,0,0,1,3.39,5.6A6.5,6.5,0,0,1,70,96.2Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
<span class="loginErrorSpan" id="loginPasswordError"><%- lang('login.loginPasswordError') %></span>
|
||||
<input id="loginPassword" class="loginField" type="password" placeholder="<%- lang('login.loginPasswordPlaceholder') %>"/>
|
||||
</div>
|
||||
<div id="loginOptions">
|
||||
<span class="loginSpanDim">
|
||||
<a href="<%- lang('login.loginForgotPasswordLink') %>"><%- lang('login.loginForgotPasswordText') %></a>
|
||||
</span>
|
||||
<label id="checkmarkContainer">
|
||||
<input id="loginRememberOption" type="checkbox" checked>
|
||||
<span id="loginRememberText" class="loginSpanDim"><%- lang('login.loginRememberMeText') %></span>
|
||||
<span class="loginCheckmark"></span>
|
||||
</label>
|
||||
</div>
|
||||
<button id="loginButton" disabled>
|
||||
<div id="loginButtonContent">
|
||||
<%- lang('login.loginButtonText') %>
|
||||
<svg id="loginSVG" viewBox="0 0 24.87 13.97">
|
||||
<defs>
|
||||
<style>.arrowLine{fill:none;stroke:#FFF;stroke-width:2px;transition: 0.25s ease;}</style>
|
||||
</defs>
|
||||
<polyline class="arrowLine" points="0.71 13.26 12.56 1.41 24.16 13.02"/>
|
||||
</svg>
|
||||
<div class="circle-loader">
|
||||
<div class="checkmark draw"></div>
|
||||
</div>
|
||||
<!--<div class="spinningCircle" id="loginSpinner"></div>-->
|
||||
</div>
|
||||
</button>
|
||||
<div id="loginDisclaimer">
|
||||
<span class="loginSpanDim" id="loginRegisterSpan">
|
||||
<a href="<%- lang('login.loginNeedAccountLink') %>"><%- lang('login.loginNeedAccountText') %></a>
|
||||
</span>
|
||||
<p class="loginDisclaimerText"><%- lang('login.loginPasswordDisclaimer1') %></p>
|
||||
<p class="loginDisclaimerText"><%- lang('login.loginPasswordDisclaimer2', { appName: lang('app.title') }) %></p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<script src="./assets/js/scripts/login.js"></script>
|
||||
</div>
|
||||
34
app/loginOptions.ejs
Normal file
@@ -0,0 +1,34 @@
|
||||
<div id="loginOptionsContainer" style="display: none;">
|
||||
<div id="loginOptionsContent">
|
||||
<div class="loginOptionsMainContent">
|
||||
<h2><%- lang('loginOptions.loginOptionsTitle') %></h2>
|
||||
<div class="loginOptionActions">
|
||||
<div class="loginOptionButtonContainer">
|
||||
<button id="loginOptionMicrosoft" class="loginOptionButton">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 23 23">
|
||||
<path fill="#f35325" d="M1 1h10v10H1z" />
|
||||
<path fill="#81bc06" d="M12 1h10v10H12z" />
|
||||
<path fill="#05a6f0" d="M1 12h10v10H1z" />
|
||||
<path fill="#ffba08" d="M12 12h10v10H12z" />
|
||||
</svg>
|
||||
<span><%- lang('loginOptions.loginWithMicrosoft') %></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="loginOptionButtonContainer">
|
||||
<button id="loginOptionMojang" class="loginOptionButton">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 9.677 9.667">
|
||||
<path d="M-26.332-12.098h2.715c-1.357.18-2.574 1.23-2.715 2.633z" fill="#fff" />
|
||||
<path d="M2.598.022h7.07L9.665 7c-.003 1.334-1.113 2.46-2.402 2.654H0V2.542C.134 1.2 1.3.195 2.598.022z" fill="#db2331" />
|
||||
<path d="M1.54 2.844c.314-.76 1.31-.46 1.954-.528.785-.083 1.503.272 2.1.758l.164-.9c.327.345.587.756.964 1.052.28.254.655-.342.86-.013.42.864.408 1.86.54 2.795l-.788-.373C6.9 4.17 5.126 3.052 3.656 3.685c-1.294.592-1.156 2.65.06 3.255 1.354.703 2.953.51 4.405.292-.07.42-.34.87-.834.816l-4.95.002c-.5.055-.886-.413-.838-.89l.04-4.315z" fill="#fff" />
|
||||
</svg>
|
||||
<span><%- lang('loginOptions.loginWithMojang') %></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="loginOptionCancelContainer" style="display: none;">
|
||||
<button id="loginOptionCancelButton"><%- lang('loginOptions.cancelButton') %></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="./assets/js/scripts/loginOptions.js"></script>
|
||||
</div>
|
||||
41
app/overlay.ejs
Normal file
@@ -0,0 +1,41 @@
|
||||
<div id="overlayContainer" style="display: none;">
|
||||
<div id="serverSelectContent" style="display: none;">
|
||||
<span id="serverSelectHeader"><%- lang('overlay.serverSelectHeader') %></span>
|
||||
<div id="serverSelectList">
|
||||
<div id="serverSelectListScrollable">
|
||||
<!-- Server listings populated here. -->
|
||||
</div>
|
||||
</div>
|
||||
<div id="serverSelectActions">
|
||||
<button id="serverSelectConfirm" class="overlayKeybindEnter" type="submit"><%- lang('overlay.serverSelectConfirm') %></button>
|
||||
<div id="serverSelectCancelWrapper">
|
||||
<button id="serverSelectCancel" class="overlayKeybindEsc"><%- lang('overlay.serverSelectCancel') %></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="accountSelectContent" style="display: none;">
|
||||
<span id="accountSelectHeader"><%- lang('overlay.accountSelectHeader') %></span>
|
||||
<div id="accountSelectList">
|
||||
<div id="accountSelectListScrollable">
|
||||
<!-- Accounts populated here. -->
|
||||
</div>
|
||||
</div>
|
||||
<div id="accountSelectActions">
|
||||
<button id="accountSelectConfirm" class="overlayKeybindEnter" type="submit"><%- lang('overlay.accountSelectConfirm') %></button>
|
||||
<div id="accountSelectCancelWrapper">
|
||||
<button id="accountSelectCancel" class="overlayKeybindEsc"><%- lang('overlay.accountSelectCancel') %></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="overlayContent">
|
||||
<span id="overlayTitle">Lorem Ipsum:<br>Finis Illud</span>
|
||||
<span id="overlayDesc">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud..</span>
|
||||
<div id="overlayActionContainer">
|
||||
<button id="overlayAcknowledge" class="overlayKeybindEnter">Conare Iterum</button>
|
||||
<div id="overlayDismissWrapper">
|
||||
<button id="overlayDismiss" style="display: none;" class="overlayKeybindEsc">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="./assets/js/scripts/overlay.js"></script>
|
||||
</div>
|
||||
393
app/settings.ejs
Normal file
@@ -0,0 +1,393 @@
|
||||
<div id="settingsContainer" style="display: none;">
|
||||
<div id="settingsContainerLeft">
|
||||
<div id="settingsNavContainer">
|
||||
<div id="settingsNavHeader">
|
||||
<span id="settingsNavHeaderText"><%- lang('settings.navHeaderText') %></span>
|
||||
</div>
|
||||
<div id="settingsNavItemsContainer">
|
||||
<div id="settingsNavItemsContent">
|
||||
<button class="settingsNavItem" rSc="settingsTabAccount" id="settingsNavAccount" selected><%- lang('settings.navAccount') %></button>
|
||||
<button class="settingsNavItem" rSc="settingsTabMinecraft"><%- lang('settings.navMinecraft') %></button>
|
||||
<button class="settingsNavItem" rSc="settingsTabMods"><%- lang('settings.navMods') %></button>
|
||||
<button class="settingsNavItem" rSc="settingsTabJava"><%- lang('settings.navJava') %></button>
|
||||
<button class="settingsNavItem" rSc="settingsTabLauncher"><%- lang('settings.navLauncher') %></button>
|
||||
<div class="settingsNavSpacer"></div>
|
||||
<button class="settingsNavItem" rSc="settingsTabAbout"><%- lang('settings.navAbout') %></button>
|
||||
<button class="settingsNavItem" rSc="settingsTabUpdate" id="settingsNavUpdate"><%- lang('settings.navUpdates') %></button>
|
||||
<div id="settingsNavContentBottom">
|
||||
<div class="settingsNavDivider"></div>
|
||||
<button id="settingsNavDone"><%- lang('settings.navDone') %></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settingsContainerRight">
|
||||
<div id="settingsTabAccount" class="settingsTab">
|
||||
<div class="settingsTabHeader">
|
||||
<span class="settingsTabHeaderText"><%- lang('settings.tabAccountHeaderText') %></span>
|
||||
<span class="settingsTabHeaderDesc"><%- lang('settings.tabAccountHeaderDesc') %></span>
|
||||
</div>
|
||||
<div class="settingsAuthAccountTypeContainer">
|
||||
<div class="settingsAuthAccountTypeHeader">
|
||||
<div class="settingsAuthAccountTypeHeaderLeft">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 23 23">
|
||||
<path fill="#f35325" d="M1 1h10v10H1z" />
|
||||
<path fill="#81bc06" d="M12 1h10v10H12z" />
|
||||
<path fill="#05a6f0" d="M1 12h10v10H1z" />
|
||||
<path fill="#ffba08" d="M12 12h10v10H12z" />
|
||||
</svg>
|
||||
<span><%- lang('settings.microsoftAccount') %></span>
|
||||
</div>
|
||||
<div class="settingsAuthAccountTypeHeaderRight">
|
||||
<button class="settingsAddAuthAccount" id="settingsAddMicrosoftAccount"><%- lang('settings.addMicrosoftAccount') %></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settingsCurrentAccounts" id="settingsCurrentMicrosoftAccounts">
|
||||
<!-- Microsoft auth accounts populated here. -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settingsAuthAccountTypeContainer">
|
||||
<div class="settingsAuthAccountTypeHeader">
|
||||
<div class="settingsAuthAccountTypeHeaderLeft">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 9.677 9.667">
|
||||
<path d="M-26.332-12.098h2.715c-1.357.18-2.574 1.23-2.715 2.633z" fill="#fff" />
|
||||
<path d="M2.598.022h7.07L9.665 7c-.003 1.334-1.113 2.46-2.402 2.654H0V2.542C.134 1.2 1.3.195 2.598.022z" fill="#db2331" />
|
||||
<path d="M1.54 2.844c.314-.76 1.31-.46 1.954-.528.785-.083 1.503.272 2.1.758l.164-.9c.327.345.587.756.964 1.052.28.254.655-.342.86-.013.42.864.408 1.86.54 2.795l-.788-.373C6.9 4.17 5.126 3.052 3.656 3.685c-1.294.592-1.156 2.65.06 3.255 1.354.703 2.953.51 4.405.292-.07.42-.34.87-.834.816l-4.95.002c-.5.055-.886-.413-.838-.89l.04-4.315z" fill="#fff" />
|
||||
</svg>
|
||||
<span><%- lang('settings.mojangAccount') %></span>
|
||||
</div>
|
||||
<div class="settingsAuthAccountTypeHeaderRight">
|
||||
<button class="settingsAddAuthAccount" id="settingsAddMojangAccount"><%- lang('settings.addMojangAccount') %></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settingsCurrentAccounts" id="settingsCurrentMojangAccounts">
|
||||
<!-- Mojang auth accounts populated here. -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settingsTabMinecraft" class="settingsTab" style="display: none;">
|
||||
<div class="settingsTabHeader">
|
||||
<span class="settingsTabHeaderText"><%- lang('settings.minecraftTabHeaderText') %></span>
|
||||
<span class="settingsTabHeaderDesc"><%- lang('settings.minecraftTabHeaderDesc') %></span>
|
||||
</div>
|
||||
<div id="settingsGameResolutionContainer">
|
||||
<span class="settingsFieldTitle"><%- lang('settings.gameResolutionTitle') %></span>
|
||||
<div id="settingsGameResolutionContent">
|
||||
<input type="number" id="settingsGameWidth" min="0" cValue="GameWidth">
|
||||
<div id="settingsGameResolutionCross">✖</div>
|
||||
<input type="number" id="settingsGameHeight" min="0" cValue="GameHeight">
|
||||
</div>
|
||||
</div>
|
||||
<div class="settingsFieldContainer">
|
||||
<div class="settingsFieldLeft">
|
||||
<span class="settingsFieldTitle"><%- lang('settings.launchFullscreenTitle') %></span>
|
||||
</div>
|
||||
<div class="settingsFieldRight">
|
||||
<label class="toggleSwitch">
|
||||
<input type="checkbox" cValue="Fullscreen">
|
||||
<span class="toggleSwitchSlider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settingsFieldContainer">
|
||||
<div class="settingsFieldLeft">
|
||||
<span class="settingsFieldTitle"><%- lang('settings.autoConnectTitle') %></span>
|
||||
</div>
|
||||
<div class="settingsFieldRight">
|
||||
<label class="toggleSwitch">
|
||||
<input type="checkbox" cValue="AutoConnect">
|
||||
<span class="toggleSwitchSlider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settingsFieldContainer">
|
||||
<div class="settingsFieldLeft">
|
||||
<span class="settingsFieldTitle"><%- lang('settings.launchDetachedTitle') %></span>
|
||||
<span class="settingsFieldDesc"><%- lang('settings.launchDetachedDesc') %></span>
|
||||
</div>
|
||||
<div class="settingsFieldRight">
|
||||
<label class="toggleSwitch">
|
||||
<input type="checkbox" cValue="LaunchDetached">
|
||||
<span class="toggleSwitchSlider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settingsTabMods" class="settingsTab" style="display: none;">
|
||||
<div class="settingsTabHeader">
|
||||
<span class="settingsTabHeaderText"><%- lang('settings.tabModsHeaderText') %></span>
|
||||
<span class="settingsTabHeaderDesc"><%- lang('settings.tabModsHeaderDesc') %></span>
|
||||
</div>
|
||||
<div class="settingsSelServContainer">
|
||||
<div class="settingsSelServContent">
|
||||
|
||||
</div>
|
||||
<div class="settingsSwitchServerContainer">
|
||||
<div class="settingsSwitchServerContent">
|
||||
<button class="settingsSwitchServerButton"><%- lang('settings.switchServerButton') %></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settingsModsContainer">
|
||||
<div id="settingsReqModsContainer">
|
||||
<div class="settingsModsHeader"><%- lang('settings.requiredMods') %></div>
|
||||
<div id="settingsReqModsContent">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div id="settingsOptModsContainer">
|
||||
<div class="settingsModsHeader"><%- lang('settings.optionalMods') %></div>
|
||||
<div id="settingsOptModsContent">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div id="settingsDropinModsContainer">
|
||||
<div class="settingsModsHeader"><%- lang('settings.dropinMods') %></div>
|
||||
<button id="settingsDropinFileSystemButton"><%- lang('settings.addMods') %> <span id="settingsDropinRefreshNote"><%- lang('settings.dropinRefreshNote') %></span></button>
|
||||
<div id="settingsDropinModsContent">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div id="settingsShadersContainer">
|
||||
<div class="settingsModsHeader"><%- lang('settings.shaderpacks') %></div>
|
||||
<div id="settingsShaderpackDesc"><%- lang('settings.shaderpackDesc') %></div>
|
||||
<div id="settingsShaderpackWrapper">
|
||||
<button id="settingsShaderpackButton"> + </button>
|
||||
<div class="settingsSelectContainer">
|
||||
<div class="settingsSelectSelected" id="settingsShadersSelected"><%- lang('settings.selectShaderpack') %></div>
|
||||
<div class="settingsSelectOptions" id="settingsShadersOptions" hidden>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settingsTabJava" class="settingsTab" style="display: none;">
|
||||
<div class="settingsTabHeader">
|
||||
<span class="settingsTabHeaderText"><%- lang('settings.tabJavaHeaderText') %></span>
|
||||
<span class="settingsTabHeaderDesc"><%- lang('settings.tabJavaHeaderDesc') %></span>
|
||||
</div>
|
||||
<div class="settingsSelServContainer">
|
||||
<div class="settingsSelServContent">
|
||||
|
||||
</div>
|
||||
<div class="settingsSwitchServerContainer">
|
||||
<div class="settingsSwitchServerContent">
|
||||
<button class="settingsSwitchServerButton"><%- lang('settings.switchServerButton') %></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settingsMemoryContainer">
|
||||
<div id="settingsMemoryTitle"><%- lang('settings.memoryTitle') %></div>
|
||||
<div id="settingsMemoryContent">
|
||||
<div id="settingsMemoryContentLeft">
|
||||
<div class="settingsMemoryContentItem">
|
||||
<span class="settingsMemoryHeader"><%- lang('settings.maxRAM') %></span>
|
||||
<div class="settingsMemoryActionContainer">
|
||||
<div id="settingsMaxRAMRange" class="rangeSlider" cValue="MaxRAM" serverDependent min="3" max="8" value="3" step="0.5">
|
||||
<div class="rangeSliderBar"></div>
|
||||
<div class="rangeSliderTrack"></div>
|
||||
</div>
|
||||
<span id="settingsMaxRAMLabel" class="settingsMemoryLabel">3G</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settingsMemoryContentItem">
|
||||
<span class="settingsMemoryHeader"><%- lang('settings.minRAM') %></span>
|
||||
<div class="settingsMemoryActionContainer">
|
||||
<div id="settingsMinRAMRange" class="rangeSlider" cValue="MinRAM" serverDependent min="3" max="8" value="3" step="0.5">
|
||||
<div class="rangeSliderBar"></div>
|
||||
<div class="rangeSliderTrack"></div>
|
||||
</div>
|
||||
<span id="settingsMinRAMLabel" class="settingsMemoryLabel">3G</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settingsMemoryDesc"><%- lang('settings.memoryDesc') %></div>
|
||||
</div>
|
||||
<div id="settingsMemoryContentRight">
|
||||
<div id="settingsMemoryStatus">
|
||||
<div class="settingsMemoryStatusContainer">
|
||||
<span class="settingsMemoryStatusTitle"><%- lang('settings.memoryTotalTitle') %></span>
|
||||
<span id="settingsMemoryTotal" class="settingsMemoryStatusValue">16G</span>
|
||||
</div>
|
||||
<div class="settingsMemoryStatusContainer">
|
||||
<span class="settingsMemoryStatusTitle"><%- lang('settings.memoryAvailableTitle') %></span>
|
||||
<span id="settingsMemoryAvail" class="settingsMemoryStatusValue">7.3G</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settingsFileSelContainer">
|
||||
<div class="settingsFileSelTitle"><%- lang('settings.javaExecutableTitle') %></div>
|
||||
<div class="settingsFileSelContent">
|
||||
<div id="settingsJavaExecDetails"><!-- Invalid Selection --></div>
|
||||
<div class="settingsFileSelActions">
|
||||
<div class="settingsFileSelIcon">
|
||||
<svg class="settingsFileSelSVG" x="0px" y="0px" viewBox="0 0 305.001 305.001">
|
||||
<g>
|
||||
<path d="M150.99,56.513c-14.093,9.912-30.066,21.147-38.624,39.734c-14.865,32.426,30.418,67.798,32.353,69.288c0.45,0.347,0.988,0.519,1.525,0.519c0.57,0,1.141-0.195,1.605-0.583c0.899-0.752,1.154-2.029,0.614-3.069c-0.164-0.316-16.418-31.888-15.814-54.539c0.214-7.888,11.254-16.837,22.942-26.312c10.705-8.678,22.839-18.514,29.939-30.02c15.586-25.327-1.737-50.231-1.914-50.479c-0.688-0.966-1.958-1.317-3.044-0.84c-1.085,0.478-1.686,1.652-1.438,2.811c0.035,0.164,3.404,16.633-5.97,33.6C169.301,43.634,160.816,49.603,150.99,56.513z"></path>
|
||||
<path d="M210.365,67.682c0.994-0.749,1.286-2.115,0.684-3.205c-0.602-1.09-1.913-1.571-3.077-1.129c-2.394,0.91-58.627,22.585-58.627,48.776c0,18.053,7.712,27.591,13.343,34.556c2.209,2.731,4.116,5.09,4.744,7.104c1.769,5.804-2.422,16.294-4.184,19.846c-0.508,1.022-0.259,2.259,0.605,3.005c0.467,0.403,1.05,0.607,1.634,0.607c0.497,0,0.996-0.148,1.427-0.448c0.967-0.673,23.63-16.696,19.565-36.001c-1.514-7.337-5.12-12.699-8.302-17.43c-4.929-7.329-8.489-12.624-3.088-22.403C181.419,89.556,210.076,67.899,210.365,67.682z"></path>
|
||||
<path d="M63.99,177.659c-0.964,2.885-0.509,5.75,1.315,8.283c6.096,8.462,27.688,13.123,60.802,13.123c0.002,0,0.003,0,0.004,0c4.487,0,9.224-0.088,14.076-0.262c52.943-1.896,72.58-18.389,73.39-19.09c0.883-0.764,1.119-2.037,0.57-3.067c-0.549-1.029-1.733-1.546-2.864-1.235c-18.645,5.091-53.463,6.898-77.613,6.898c-27.023,0-40.785-1.946-44.154-3.383c1.729-2.374,12.392-6.613,25.605-9.212c1.263-0.248,2.131-1.414,2.006-2.695c-0.125-1.281-1.201-2.258-2.488-2.258C106.893,164.762,68.05,165.384,63.99,177.659z"></path>
|
||||
<path d="M241.148,160.673c-10.92,0-21.275,5.472-21.711,5.705c-1.01,0.541-1.522,1.699-1.245,2.811c0.278,1.111,1.277,1.892,2.423,1.893c0.232,0.001,23.293,0.189,25.382,13.365c1.85,11.367-21.82,29.785-31.097,35.923c-1.002,0.663-1.391,1.945-0.926,3.052c0.395,0.943,1.314,1.533,2.304,1.533c0.173,0,0.348-0.018,0.522-0.056c2.202-0.47,53.855-11.852,48.394-41.927C261.862,164.541,250.278,160.673,241.148,160.673z"></path>
|
||||
<path d="M205.725,216.69c0.18-0.964-0.221-1.944-1.023-2.506l-12.385-8.675c-0.604-0.423-1.367-0.556-2.076-0.368c-0.129,0.034-13.081,3.438-31.885,5.526c-7.463,0.837-15.822,1.279-24.175,1.279c-18.799,0-31.091-2.209-32.881-3.829c-0.237-0.455-0.162-0.662-0.12-0.777c0.325-0.905,2.068-1.98,3.192-2.405c1.241-0.459,1.91-1.807,1.524-3.073c-0.385-1.266-1.69-2.012-2.978-1.702c-12.424,2.998-18.499,7.191-18.057,12.461c0.785,9.343,22.428,14.139,40.725,15.408c2.631,0.18,5.477,0.272,8.456,0.272c0.002,0,0.003,0,0.005,0c30.425,0,69.429-9.546,69.819-9.643C204.818,218.423,205.544,217.654,205.725,216.69z"></path>
|
||||
<path d="M112.351,236.745c0.938-0.611,1.354-1.77,1.021-2.838c-0.332-1.068-1.331-1.769-2.453-1.755c-1.665,0.044-16.292,0.704-17.316,10.017c-0.31,2.783,0.487,5.325,2.37,7.556c5.252,6.224,19.428,9.923,43.332,11.31c2.828,0.169,5.7,0.254,8.539,0.254c30.39,0,50.857-9.515,51.714-9.92c0.831-0.393,1.379-1.209,1.428-2.127c0.049-0.917-0.409-1.788-1.193-2.267l-15.652-9.555c-0.543-0.331-1.193-0.441-1.813-0.314c-0.099,0.021-10.037,2.082-25.035,4.119c-2.838,0.385-6.392,0.581-10.562,0.581c-14.982,0-31.646-2.448-34.842-4.05C111.843,237.455,111.902,237.075,112.351,236.745z"></path>
|
||||
<path d="M133.681,290.018c69.61-0.059,106.971-12.438,114.168-20.228c2.548-2.757,2.823-5.366,2.606-7.07c-0.535-4.194-4.354-6.761-4.788-7.04c-1.045-0.672-2.447-0.496-3.262,0.444c-0.813,0.941-0.832,2.314-0.016,3.253c0.439,0.565,0.693,1.51-0.591,2.795c-2.877,2.687-31.897,10.844-80.215,13.294c-6.619,0.345-13.561,0.519-20.633,0.52c-43.262,0-74.923-5.925-79.079-9.379c1.603-2.301,12.801-5.979,24.711-8.058c1.342-0.234,2.249-1.499,2.041-2.845c-0.208-1.346-1.449-2.273-2.805-2.096c-0.336,0.045-1.475,0.115-2.796,0.195c-19.651,1.2-42.36,3.875-43.545,13.999c-0.36,3.086,0.557,5.886,2.726,8.324c5.307,5.963,20.562,13.891,91.475,13.891C133.68,290.018,133.68,290.018,133.681,290.018z"></path>
|
||||
<path d="M261.522,271.985c-0.984-0.455-2.146-0.225-2.881,0.567c-0.103,0.11-10.568,11.054-42.035,17.48c-12.047,2.414-34.66,3.638-67.211,3.638c-32.612,0-63.643-1.283-63.953-1.296c-1.296-0.063-2.405,0.879-2.581,2.155c-0.177,1.276,0.645,2.477,1.897,2.775c0.323,0.077,32.844,7.696,77.31,7.696c21.327,0,42.08-1.733,61.684-5.151c36.553-6.408,39.112-24.533,39.203-25.301C263.082,273.474,262.504,272.44,261.522,271.985z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<input class="settingsFileSelVal" id="settingsJavaExecVal" type="text" value="null" cValue="JavaExecutable" serverDependent disabled>
|
||||
<button class="settingsFileSelButton" id="settingsJavaExecSel" dialogTitle="<%- lang('settings.javaExecSelDialogTitle') %>" dialogDirectory="false"><%- lang('settings.javaExecSelButtonText') %></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settingsFileSelDesc"><%- lang('settings.javaExecDesc') %> <strong id="settingsJavaReqDesc"><!-- Requires Java 8 x64. --></strong><br><%- lang('settings.javaPathDesc', {'pathSuffix': `bin${process.platform === 'win32' ? '\\javaw.exe' : '/java'}`}) %></div>
|
||||
</div>
|
||||
<div id="settingsJVMOptsContainer">
|
||||
<div id="settingsJVMOptsTitle"><%- lang('settings.jvmOptsTitle') %></div>
|
||||
<div id="settingsJVMOptsContent">
|
||||
<div class="settingsFileSelIcon">
|
||||
<svg class="settingsFileSelSVG" x="0px" y="0px" viewBox="0 0 305.001 305.001">
|
||||
<g>
|
||||
<path d="M150.99,56.513c-14.093,9.912-30.066,21.147-38.624,39.734c-14.865,32.426,30.418,67.798,32.353,69.288c0.45,0.347,0.988,0.519,1.525,0.519c0.57,0,1.141-0.195,1.605-0.583c0.899-0.752,1.154-2.029,0.614-3.069c-0.164-0.316-16.418-31.888-15.814-54.539c0.214-7.888,11.254-16.837,22.942-26.312c10.705-8.678,22.839-18.514,29.939-30.02c15.586-25.327-1.737-50.231-1.914-50.479c-0.688-0.966-1.958-1.317-3.044-0.84c-1.085,0.478-1.686,1.652-1.438,2.811c0.035,0.164,3.404,16.633-5.97,33.6C169.301,43.634,160.816,49.603,150.99,56.513z"></path>
|
||||
<path d="M210.365,67.682c0.994-0.749,1.286-2.115,0.684-3.205c-0.602-1.09-1.913-1.571-3.077-1.129c-2.394,0.91-58.627,22.585-58.627,48.776c0,18.053,7.712,27.591,13.343,34.556c2.209,2.731,4.116,5.09,4.744,7.104c1.769,5.804-2.422,16.294-4.184,19.846c-0.508,1.022-0.259,2.259,0.605,3.005c0.467,0.403,1.05,0.607,1.634,0.607c0.497,0,0.996-0.148,1.427-0.448c0.967-0.673,23.63-16.696,19.565-36.001c-1.514-7.337-5.12-12.699-8.302-17.43c-4.929-7.329-8.489-12.624-3.088-22.403C181.419,89.556,210.076,67.899,210.365,67.682z"></path>
|
||||
<path d="M63.99,177.659c-0.964,2.885-0.509,5.75,1.315,8.283c6.096,8.462,27.688,13.123,60.802,13.123c0.002,0,0.003,0,0.004,0c4.487,0,9.224-0.088,14.076-0.262c52.943-1.896,72.58-18.389,73.39-19.09c0.883-0.764,1.119-2.037,0.57-3.067c-0.549-1.029-1.733-1.546-2.864-1.235c-18.645,5.091-53.463,6.898-77.613,6.898c-27.023,0-40.785-1.946-44.154-3.383c1.729-2.374,12.392-6.613,25.605-9.212c1.263-0.248,2.131-1.414,2.006-2.695c-0.125-1.281-1.201-2.258-2.488-2.258C106.893,164.762,68.05,165.384,63.99,177.659z"></path>
|
||||
<path d="M241.148,160.673c-10.92,0-21.275,5.472-21.711,5.705c-1.01,0.541-1.522,1.699-1.245,2.811c0.278,1.111,1.277,1.892,2.423,1.893c0.232,0.001,23.293,0.189,25.382,13.365c1.85,11.367-21.82,29.785-31.097,35.923c-1.002,0.663-1.391,1.945-0.926,3.052c0.395,0.943,1.314,1.533,2.304,1.533c0.173,0,0.348-0.018,0.522-0.056c2.202-0.47,53.855-11.852,48.394-41.927C261.862,164.541,250.278,160.673,241.148,160.673z"></path>
|
||||
<path d="M205.725,216.69c0.18-0.964-0.221-1.944-1.023-2.506l-12.385-8.675c-0.604-0.423-1.367-0.556-2.076-0.368c-0.129,0.034-13.081,3.438-31.885,5.526c-7.463,0.837-15.822,1.279-24.175,1.279c-18.799,0-31.091-2.209-32.881-3.829c-0.237-0.455-0.162-0.662-0.12-0.777c0.325-0.905,2.068-1.98,3.192-2.405c1.241-0.459,1.91-1.807,1.524-3.073c-0.385-1.266-1.69-2.012-2.978-1.702c-12.424,2.998-18.499,7.191-18.057,12.461c0.785,9.343,22.428,14.139,40.725,15.408c2.631,0.18,5.477,0.272,8.456,0.272c0.002,0,0.003,0,0.005,0c30.425,0,69.429-9.546,69.819-9.643C204.818,218.423,205.544,217.654,205.725,216.69z"></path>
|
||||
<path d="M112.351,236.745c0.938-0.611,1.354-1.77,1.021-2.838c-0.332-1.068-1.331-1.769-2.453-1.755c-1.665,0.044-16.292,0.704-17.316,10.017c-0.31,2.783,0.487,5.325,2.37,7.556c5.252,6.224,19.428,9.923,43.332,11.31c2.828,0.169,5.7,0.254,8.539,0.254c30.39,0,50.857-9.515,51.714-9.92c0.831-0.393,1.379-1.209,1.428-2.127c0.049-0.917-0.409-1.788-1.193-2.267l-15.652-9.555c-0.543-0.331-1.193-0.441-1.813-0.314c-0.099,0.021-10.037,2.082-25.035,4.119c-2.838,0.385-6.392,0.581-10.562,0.581c-14.982,0-31.646-2.448-34.842-4.05C111.843,237.455,111.902,237.075,112.351,236.745z"></path>
|
||||
<path d="M133.681,290.018c69.61-0.059,106.971-12.438,114.168-20.228c2.548-2.757,2.823-5.366,2.606-7.07c-0.535-4.194-4.354-6.761-4.788-7.04c-1.045-0.672-2.447-0.496-3.262,0.444c-0.813,0.941-0.832,2.314-0.016,3.253c0.439,0.565,0.693,1.51-0.591,2.795c-2.877,2.687-31.897,10.844-80.215,13.294c-6.619,0.345-13.561,0.519-20.633,0.52c-43.262,0-74.923-5.925-79.079-9.379c1.603-2.301,12.801-5.979,24.711-8.058c1.342-0.234,2.249-1.499,2.041-2.845c-0.208-1.346-1.449-2.273-2.805-2.096c-0.336,0.045-1.475,0.115-2.796,0.195c-19.651,1.2-42.36,3.875-43.545,13.999c-0.36,3.086,0.557,5.886,2.726,8.324c5.307,5.963,20.562,13.891,91.475,13.891C133.68,290.018,133.68,290.018,133.681,290.018z"></path>
|
||||
<path d="M261.522,271.985c-0.984-0.455-2.146-0.225-2.881,0.567c-0.103,0.11-10.568,11.054-42.035,17.48c-12.047,2.414-34.66,3.638-67.211,3.638c-32.612,0-63.643-1.283-63.953-1.296c-1.296-0.063-2.405,0.879-2.581,2.155c-0.177,1.276,0.645,2.477,1.897,2.775c0.323,0.077,32.844,7.696,77.31,7.696c21.327,0,42.08-1.733,61.684-5.151c36.553-6.408,39.112-24.533,39.203-25.301C263.082,273.474,262.504,272.44,261.522,271.985z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<input id="settingsJVMOptsVal" cValue="JVMOptions" serverDependent type="text">
|
||||
</div>
|
||||
<div id="settingsJVMOptsDesc"><%- lang('settings.jvmOptsDesc') %><br><a href="#" id="settingsJvmOptsLink"><!-- Available Options --></a></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settingsTabLauncher" class="settingsTab" style="display: none;">
|
||||
<div class="settingsTabHeader">
|
||||
<span class="settingsTabHeaderText"><%- lang('settings.launcherTabHeaderText') %></span>
|
||||
<span class="settingsTabHeaderDesc"><%- lang('settings.launcherTabHeaderDesc') %></span>
|
||||
</div>
|
||||
<div class="settingsFieldContainer">
|
||||
<div class="settingsFieldLeft">
|
||||
<span class="settingsFieldTitle"><%- lang('settings.allowPrereleaseTitle') %></span>
|
||||
<span class="settingsFieldDesc"><%- lang('settings.allowPrereleaseDesc') %></span>
|
||||
</div>
|
||||
<div class="settingsFieldRight">
|
||||
<label class="toggleSwitch">
|
||||
<input type="checkbox" cValue="AllowPrerelease">
|
||||
<span class="toggleSwitchSlider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settingsFileSelContainer">
|
||||
<div class="settingsFileSelContent">
|
||||
<div class="settingsFieldTitle" id="settingsDataDirTitle"><%- lang('settings.dataDirectoryTitle') %></div>
|
||||
<div class="settingsFileSelActions">
|
||||
<div class="settingsFileSelIcon">
|
||||
<svg class="settingsFileSelSVG">
|
||||
<g>
|
||||
<path fill="gray" d="m10.044745,5c0,0.917174 -0.746246,1.667588 -1.667588,1.667588l-4.168971,0l-2.501382,0c-0.921009,0 -1.667588,0.750415 -1.667588,1.667588l0,6.670353l0,2.501382c0,0.917174 0.746604,1.667588 1.667588,1.667588l16.675882,0c0.921342,0 1.667588,-0.750415 1.667588,-1.667588l0,-2.501382l0,-8.337941c0,-0.917174 -0.746246,-1.667588 -1.667588,-1.667588l-8.337941,0z"/>
|
||||
<path fill="gray" d="m1.627815,1.6c-0.921009,0 -1.667588,0.746579 -1.667588,1.667588l0,4.168971l8.337941,0l0,0.833794l11.673118,0l0,-4.168971c0,-0.921009 -0.746246,-1.667588 -1.667588,-1.667588l-8.572237,0c-0.288493,-0.497692 -0.816284,-0.833794 -1.433292,-0.833794l-6.670353,0z"/>
|
||||
<path fill="lightgray" d="m10.025276,4c0,0.918984 -0.747719,1.670879 -1.670879,1.670879l-4.177198,0l-2.506319,0c-0.922827,0 -1.670879,0.751896 -1.670879,1.670879l0,6.683517l0,2.506319c0,0.918984 0.748078,1.670879 1.670879,1.670879l16.708794,0c0.923161,0 1.670879,-0.751896 1.670879,-1.670879l0,-2.506319l0,-8.354397c0,-0.918984 -0.747719,-1.670879 -1.670879,-1.670879l-8.354397,0z"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<input class="settingsFileSelVal" type="text" value="null" cValue="DataDirectory" disabled>
|
||||
<button class="settingsFileSelButton" dialogTitle="<%- lang('settings.selectDataDirectory') %>" dialogDirectory="true"><%- lang('settings.chooseFolder') %></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settingsFileSelDesc"><%- lang('settings.dataDirectoryDesc') %></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settingsTabAbout" class="settingsTab" style="display: none;">
|
||||
<div class="settingsTabHeader">
|
||||
<span class="settingsTabHeaderText"><%- lang('settings.aboutTabHeaderText') %></span>
|
||||
<span class="settingsTabHeaderDesc"><%- lang('settings.aboutTabHeaderDesc') %></span>
|
||||
</div>
|
||||
<div id="settingsAboutCurrentContainer">
|
||||
<div id="settingsAboutCurrentContent">
|
||||
<div id="settingsAboutCurrentHeadline">
|
||||
<img id="settingsAboutLogo" src="./assets/images/Icon.png">
|
||||
<span id="settingsAboutTitle"><%- lang('settings.aboutTitle', { appName: lang('app.title') }) %></span>
|
||||
</div>
|
||||
<div id="settingsAboutCurrentVersion">
|
||||
<div id="settingsAboutCurrentVersionCheck">✓</div>
|
||||
<div id="settingsAboutCurrentVersionDetails">
|
||||
<span id="settingsAboutCurrentVersionTitle"><%- lang('settings.stableRelease') %></span>
|
||||
<div id="settingsAboutCurrentVersionLine">
|
||||
<span id="settingsAboutCurrentVersionText"><%- lang('settings.versionText') %></span>
|
||||
<span id="settingsAboutCurrentVersionValue">0.0.1-alpha.18</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settingsAboutButtons">
|
||||
<a href="<%- lang('settings.sourceGithubLink') %>" id="settingsAboutSourceButton" class="settingsAboutButton"><%- lang('settings.sourceGithub') %></a>
|
||||
<!-- The following must be included in third-party usage. -->
|
||||
<a href="https://github.com/dscalzi/HeliosLauncher" id="settingsAboutSourceButton" class="settingsAboutButton"><%- lang('settings.sourceOriginalGithub') %></a>
|
||||
<a href="<%- lang('settings.supportLink') %>" id="settingsAboutSupportButton" class="settingsAboutButton"><%- lang('settings.support') %></a>
|
||||
<a href="#" id="settingsAboutDevToolsButton" class="settingsAboutButton"><%- lang('settings.devToolsConsole') %></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settingsChangelogContainer">
|
||||
<div class="settingsChangelogContent">
|
||||
<div class="settingsChangelogHeadline">
|
||||
<div class="settingsChangelogLabel"><%- lang('settings.releaseNotes') %></div>
|
||||
<div class="settingsChangelogTitle"><%- lang('settings.changelog') %></div>
|
||||
</div>
|
||||
<div class="settingsChangelogText">
|
||||
<%- lang('settings.noReleaseNotes') %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settingsChangelogActions">
|
||||
<a class="settingsChangelogButton settingsAboutButton" href="#"><%- lang('settings.viewReleaseNotes') %></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settingsTabUpdate" class="settingsTab" style="display: none;">
|
||||
<div class="settingsTabHeader">
|
||||
<span class="settingsTabHeaderText"><%- lang('settings.launcherUpdatesHeaderText') %></span>
|
||||
<span class="settingsTabHeaderDesc"><%- lang('settings.launcherUpdatesHeaderDesc') %></span>
|
||||
</div>
|
||||
<div id="settingsUpdateStatusContainer">
|
||||
<div id="settingsUpdateStatusContent">
|
||||
<div id="settingsUpdateStatusHeadline">
|
||||
<span id="settingsUpdateTitle"><!-- You Are Running the Latest Version --></span>
|
||||
</div>
|
||||
<div id="settingsUpdateVersion">
|
||||
<div id="settingsUpdateVersionCheck">✓</div>
|
||||
<div id="settingsUpdateVersionDetails">
|
||||
<span id="settingsUpdateVersionTitle"><%- lang('settings.stableRelease') %></span>
|
||||
<div id="settingsUpdateVersionLine">
|
||||
<span id="settingsUpdateVersionText"><%- lang('settings.versionText') %> </span>
|
||||
<span id="settingsUpdateVersionValue">0.0.1-alpha.18</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settingsUpdateActionContainer">
|
||||
<button id="settingsUpdateActionButton"><%- lang('settings.checkForUpdates') %></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settingsChangelogContainer">
|
||||
<div class="settingsChangelogContent">
|
||||
<div class="settingsChangelogHeadline">
|
||||
<div class="settingsChangelogLabel"><%- lang('settings.whatsNew') %></div>
|
||||
<div class="settingsChangelogTitle"><%- lang('settings.updateReleaseNotes') %></div>
|
||||
</div>
|
||||
<div class="settingsChangelogText">
|
||||
<%- lang('settings.noReleaseNotes') %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="./assets/js/scripts/settings.js"></script>
|
||||
</div>
|
||||
8
app/waiting.ejs
Normal file
@@ -0,0 +1,8 @@
|
||||
<div id="waitingContainer" style="display: none;">
|
||||
<div id="waitingContent">
|
||||
<div class="waitingSpinner"></div>
|
||||
<div id="waitingTextContainer">
|
||||
<h2><%- lang('waiting.waitingText') %></h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
25
app/welcome.ejs
Normal file
@@ -0,0 +1,25 @@
|
||||
<div id="welcomeContainer" style="display: none;">
|
||||
<!--<div class="cloudDiv">
|
||||
<div class="cloudTop"></div>
|
||||
<div class="cloudBottom"></div>
|
||||
</div>-->
|
||||
<div id="welcomeContent">
|
||||
<img id="welcomeImageSeal" src="assets/images/Icon.png"/>
|
||||
<span id="welcomeHeader"><%- lang('welcome.welcomeHeader') %></span>
|
||||
<span id="welcomeDescription"><%- lang('welcome.welcomeDescription') %></span>
|
||||
<br>
|
||||
<span id="welcomeDescCTA"><%- lang('welcome.welcomeDescCTA') %></span>
|
||||
<button id="welcomeButton">
|
||||
<div id="welcomeButtonContent">
|
||||
<%- lang('welcome.continueButton') %>
|
||||
<svg id="welcomeSVG" viewBox="0 0 24.87 13.97">
|
||||
<defs>
|
||||
<style>.arrowLine{fill:none;stroke:#FFF;stroke-width:2px;transition: 0.25s ease;}</style>
|
||||
</defs>
|
||||
<polyline class="arrowLine" points="0.71 13.26 12.56 1.41 24.16 13.02"/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<script src="./assets/js/scripts/welcome.js"></script>
|
||||
</div>
|
||||
BIN
build/icon.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
3
dev-app-update.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
owner: peunsu
|
||||
repo: MRSLauncher
|
||||
provider: github
|
||||
52
docs/MicrosoftAuth.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Microsoft Authentication
|
||||
|
||||
Authenticating with Microsoft is fully supported by Helios Launcher.
|
||||
|
||||
## Acquiring an Entra Client ID
|
||||
|
||||
1. Navigate to https://portal.azure.com
|
||||
2. In the search bar, search for **Microsoft Entra ID**.
|
||||
3. In Microsoft Entra ID, go to **App Registrations** on the left pane (Under *Manage*).
|
||||
4. Click **New Registration**.
|
||||
- Set **Name** to be your launcher's name.
|
||||
- Set **Supported account types** to *Accounts in any organizational directory (Any Microsoft Entra ID tenant - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)*
|
||||
- Leave **Redirect URI** blank.
|
||||
- Register the application.
|
||||
5. You should be on the application's management page. If not, Navigate back to **App Registrations**. Select the application you just registered.
|
||||
6. Click **Authentication** on the left pane (Under *Manage*).
|
||||
7. Click **Add Platform**.
|
||||
- Select **Mobile and desktop applications**.
|
||||
- Choose `https://login.microsoftonline.com/common/oauth2/nativeclient` as the **Redirect URI**.
|
||||
- Select **Configure** to finish adding the platform.
|
||||
8. Go to **Certificates & secrets**.
|
||||
- Select **Client secrets**.
|
||||
- Click **New client secret**.
|
||||
- Set a description.
|
||||
- Click **Add**.
|
||||
- Don't copy the client secret, adding one is just a requirement from Microsoft.
|
||||
8. Navigate back to **Overview**.
|
||||
9. Copy **Application (client) ID**.
|
||||
|
||||
|
||||
## Adding the Entra Client ID to Helios Launcher.
|
||||
|
||||
In `app/assets/js/ipcconstants.js` you'll find **`AZURE_CLIENT_ID`**. Set it to your application's id.
|
||||
|
||||
Note: Entra Client ID is NOT a secret value and **can** be stored in git. Reference: https://stackoverflow.com/questions/57306964/are-azure-active-directorys-tenantid-and-clientid-considered-secrets
|
||||
|
||||
Then relaunch your app, and login. You'll be greeted with an error message, because the app isn't whitelisted yet. Microsoft needs some activity on the app before whitelisting it. __Trying to log in before requesting whitelist is mandatory.__
|
||||
|
||||
## Requesting whitelisting from Microsoft
|
||||
|
||||
1. Ensure you have completed every step of this doc page.
|
||||
2. Fill [this form](https://aka.ms/mce-reviewappid) with the required information. Remember this is a new appID for approval. You can find both the Client ID and the Tenant ID on the overview page in the Azure Portal.
|
||||
3. Give Microsoft some time to review your app.
|
||||
4. Once you have received Microsoft's approval, allow up to 24 hours for the changes to apply.
|
||||
|
||||
----
|
||||
|
||||
You can now authenticate with Microsoft through the launcher.
|
||||
|
||||
References:
|
||||
- https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app
|
||||
- https://help.minecraft.net/hc/en-us/articles/16254801392141
|
||||
592
docs/distro.md
Normal file
@@ -0,0 +1,592 @@
|
||||
# Distribution Index
|
||||
|
||||
You can use [Nebula](https://github.com/dscalzi/Nebula) to automate the generation of a distribution index.
|
||||
|
||||
The most up to date and accurate descriptions of the distribution spec can be viewed in [helios-distribution-types](https://github.com/dscalzi/helios-distribution-types).
|
||||
|
||||
The distribution index is written in JSON. The general format of the index is as posted below.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"discord": {
|
||||
"clientId": "12334567890123456789",
|
||||
"smallImageText": "WesterosCraft",
|
||||
"smallImageKey": "seal-circle"
|
||||
},
|
||||
"rss": "https://westeroscraft.com/articles/index.rss",
|
||||
"servers": [
|
||||
{
|
||||
"id": "Example_Server",
|
||||
"name": "WesterosCraft Example Client",
|
||||
"description": "Example WesterosCraft server. Connect for fun!",
|
||||
"icon": "http://mc.westeroscraft.com/WesterosCraftLauncher/files/example_icon.png",
|
||||
"version": "0.0.1",
|
||||
"address": "mc.westeroscraft.com:1337",
|
||||
"minecraftVersion": "1.11.2",
|
||||
"discord": {
|
||||
"shortId": "Example",
|
||||
"largeImageText": "WesterosCraft Example Server",
|
||||
"largeImageKey": "server-example"
|
||||
},
|
||||
"mainServer": true,
|
||||
"autoconnect": true,
|
||||
"modules": [
|
||||
"Module Objects Here"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Distro Index Object
|
||||
|
||||
#### Example
|
||||
```JSON
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"discord": {
|
||||
"clientId": "12334567890123456789",
|
||||
"smallImageText": "WesterosCraft",
|
||||
"smallImageKey": "seal-circle"
|
||||
},
|
||||
"rss": "https://westeroscraft.com/articles/index.rss",
|
||||
"servers": []
|
||||
}
|
||||
```
|
||||
|
||||
### `DistroIndex.version: string/semver`
|
||||
|
||||
The version of the index format. Will be used in the future to gracefully push updates.
|
||||
|
||||
### `DistroIndex.discord: object`
|
||||
|
||||
Global settings for [Discord Rich Presence](https://discordapp.com/developers/docs/rich-presence/how-to).
|
||||
|
||||
**Properties**
|
||||
|
||||
* `discord.clientId: string` - Client ID for th Application registered with Discord.
|
||||
* `discord.smallImageText: string` - Tootltip for the `smallImageKey`.
|
||||
* `discord.smallImageKey: string` - Name of the uploaded image for the small profile artwork.
|
||||
|
||||
|
||||
### `DistroIndex.rss: string/url`
|
||||
|
||||
A URL to a RSS feed. Used for loading news.
|
||||
|
||||
---
|
||||
|
||||
## Server Object
|
||||
|
||||
#### Example
|
||||
```JSON
|
||||
{
|
||||
"id": "Example_Server",
|
||||
"name": "WesterosCraft Example Client",
|
||||
"description": "Example WesterosCraft server. Connect for fun!",
|
||||
"icon": "http://mc.westeroscraft.com/WesterosCraftLauncher/files/example_icon.png",
|
||||
"version": "0.0.1",
|
||||
"address": "mc.westeroscraft.com:1337",
|
||||
"minecraftVersion": "1.11.2",
|
||||
"discord": {
|
||||
"shortId": "Example",
|
||||
"largeImageText": "WesterosCraft Example Server",
|
||||
"largeImageKey": "server-example"
|
||||
},
|
||||
"mainServer": true,
|
||||
"autoconnect": true,
|
||||
"modules": []
|
||||
}
|
||||
```
|
||||
|
||||
### `Server.id: string`
|
||||
|
||||
The ID of the server. The launcher saves mod configurations and selected servers by ID. If the ID changes, all data related to the old ID **will be wiped**.
|
||||
|
||||
### `Server.name: string`
|
||||
|
||||
The name of the server. This is what users see on the UI.
|
||||
|
||||
### `Server.description: string`
|
||||
|
||||
A brief description of the server. Displayed on the UI to provide users more information.
|
||||
|
||||
### `Server.icon: string/url`
|
||||
|
||||
A URL to the server's icon. Will be displayed on the UI.
|
||||
|
||||
### `Server.version: string/semver`
|
||||
|
||||
The version of the server configuration.
|
||||
|
||||
### `Server.address: string/url`
|
||||
|
||||
The server's IP address.
|
||||
|
||||
### `Server.minecraftVersion: string`
|
||||
|
||||
The version of minecraft that the server is running.
|
||||
|
||||
### `Server.discord: object`
|
||||
|
||||
Server specific settings used for [Discord Rich Presence](https://discordapp.com/developers/docs/rich-presence/how-to).
|
||||
|
||||
**Properties**
|
||||
|
||||
* `discord.shortId: string` - Short ID for the server. Displayed on the second status line as `Server: shortId`
|
||||
* `discord.largeImageText: string` - Ttooltip for the `largeImageKey`.
|
||||
* `discord.largeImageKey: string` - Name of the uploaded image for the large profile artwork.
|
||||
|
||||
### `Server.mainServer: boolean`
|
||||
|
||||
Only one server in the array should have the `mainServer` property enabled. This will tell the launcher that this is the default server to select if either the previously selected server is invalid, or there is no previously selected server. If this field is not defined by any server (avoid this), the first server will be selected as the default. If multiple servers have `mainServer` enabled, the first one the launcher finds will be the effective value. Servers which are not the default may omit this property rather than explicitly setting it to false.
|
||||
|
||||
### `Server.autoconnect: boolean`
|
||||
|
||||
Whether or not the server can be autoconnected to. If false, the server will not be autoconnected to even when the user has the autoconnect setting enabled.
|
||||
|
||||
### `Server.javaOptions: JavaOptions`
|
||||
|
||||
**OPTIONAL**
|
||||
|
||||
Sever-specific Java options. If not provided, defaults are used by the client.
|
||||
|
||||
### `Server.modules: Module[]`
|
||||
|
||||
An array of module objects.
|
||||
|
||||
---
|
||||
|
||||
## JavaOptions Object
|
||||
|
||||
Server-specific Java options.
|
||||
|
||||
#### Example
|
||||
```JSON
|
||||
{
|
||||
"supported": ">=17",
|
||||
"suggestedMajor": 17,
|
||||
"platformOptions": [
|
||||
{
|
||||
"platform": "darwin",
|
||||
"architecture": "arm64",
|
||||
"distribution": "CORRETTO"
|
||||
}
|
||||
],
|
||||
"ram": {
|
||||
"recommended": 3072,
|
||||
"minimum": 2048
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `JavaOptions.platformOptions: JavaPlatformOptions[]`
|
||||
|
||||
**OPTIONAL**
|
||||
|
||||
Platform-specific java rules for this server configuration. Validation rules will be delegated to the client for any undefined properties. Java validation can be configured for specific platforms and architectures. The most specific ruleset will be applied.
|
||||
|
||||
Maxtrix Precedence (Highest - Lowest)
|
||||
- Current platform, current architecture (ex. win32 x64).
|
||||
- Current platform, any architecture (ex. win32).
|
||||
- Java Options base properties.
|
||||
- Client logic (default logic in the client).
|
||||
|
||||
Properties:
|
||||
|
||||
- `platformOptions.platform: string` - The platform that this validation matrix applies to.
|
||||
- `platformOptions.architecture: string` - Optional. The architecture that this validation matrix applies to. If omitted, applies to all architectures.
|
||||
- `platformOptions.distribution: string` - Optional. See `JavaOptions.distribution`.
|
||||
- `platformOptions.supported: string` - Optional. See `JavaOptions.supported`.
|
||||
- `platformOptions.suggestedMajor: number` - Optional. See `JavaOptions.suggestedMajor`.
|
||||
|
||||
### `JavaOptions.ram: object`
|
||||
|
||||
**OPTIONAL**
|
||||
|
||||
This allows you to require a minimum and recommended amount of RAM per server instance. The minimum is the smallest value the user can select in the settings slider. The recommended value will be the default value selected for that server. These values are specified in megabytes and must be an interval of 512. This allows configuration in intervals of half gigabytes. In the above example, the recommended ram value is 3 GB (3072 MB) and the minimum is 2 GB (2048 MB).
|
||||
|
||||
- `ram.recommended: number` - The recommended amount of RAM in megabytes. Must be an interval of 512.
|
||||
- `ram.minimum: number` - The absolute minimum amount of RAM in megabytes. Must be an interval of 512.
|
||||
|
||||
### `JavaOptions.distribution: string`
|
||||
|
||||
**OPTIONAL**
|
||||
|
||||
Preferred JDK distribution to download if no applicable installation could be found. If omitted, the client will decide (decision may be platform-specific).
|
||||
|
||||
### `JavaOptions.supported: string`
|
||||
|
||||
**OPTIONAL**
|
||||
|
||||
A semver range of supported JDK versions.
|
||||
|
||||
Java version syntax is platform dependent.
|
||||
|
||||
JDK 8 and prior
|
||||
```
|
||||
1.{major}.{minor}_{patch}-b{build}
|
||||
Ex. 1.8.0_152-b16
|
||||
```
|
||||
|
||||
JDK 9+
|
||||
```
|
||||
{major}.{minor}.{patch}+{build}
|
||||
Ex. 11.0.12+7
|
||||
```
|
||||
|
||||
For processing, all versions will be translated into a semver compliant string. JDK 9+ is already semver. For versions 8 and below, `1.{major}.{minor}_{patch}-b{build}` will be translated to `{major}.{minor}.{patch}+{build}`.
|
||||
|
||||
If specified, you must also specify suggestedMajor.
|
||||
|
||||
If omitted, the client will decide based on the game version.
|
||||
|
||||
### `JavaOptions.suggestedMajor: number`
|
||||
|
||||
**OPTIONAL**
|
||||
|
||||
The suggested major Java version. The suggested major should comply with the version range specified by supported, if defined. This will be used in messages displayed to the end user, and to automatically fetch a Java version.
|
||||
|
||||
NOTE If supported is specified, suggestedMajor must be set. The launcher's default value may not comply with your custom major supported range.
|
||||
|
||||
Common use case:
|
||||
- supported: '>=17.x'
|
||||
- suggestedMajor: 17
|
||||
|
||||
More involved:
|
||||
- supported: '>=16 <20'
|
||||
- suggestedMajor: 17
|
||||
|
||||
Given a wider support range, it becomes necessary to specify which major version in the range is the suggested.
|
||||
|
||||
---
|
||||
|
||||
## Module Object
|
||||
|
||||
A module is a generic representation of a file required to run the minecraft client.
|
||||
|
||||
#### Example
|
||||
```JSON
|
||||
{
|
||||
"id": "com.example:artifact:1.0.0@jar.pack.xz",
|
||||
"name": "Artifact 1.0.0",
|
||||
"type": "Library",
|
||||
"artifact": {
|
||||
"size": 4231234,
|
||||
"MD5": "7f30eefe5c51e1ae0939dab2051db75f",
|
||||
"url": "http://files.site.com/maven/com/example/artifact/1.0.0/artifact-1.0.0.jar.pack.xz"
|
||||
},
|
||||
"subModules": [
|
||||
{
|
||||
"id": "examplefile",
|
||||
"name": "Example File",
|
||||
"type": "File",
|
||||
"artifact": {
|
||||
"size": 23423,
|
||||
"MD5": "169a5e6cf30c2cc8649755cdc5d7bad7",
|
||||
"path": "examplefile.txt",
|
||||
"url": "http://files.site.com/examplefile.txt"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The parent module will be stored maven style, it's destination path will be resolved by its id. The sub module has a declared `path`, so that value will be used.
|
||||
|
||||
### `Module.id: string`
|
||||
|
||||
The ID of the module. All modules that are not of type `File` **MUST** use a maven identifier. Version information and other metadata is pulled from the identifier. Modules which are stored maven style use the identifier to resolve the destination path. If the `extension` is not provided, it defaults to `jar`.
|
||||
|
||||
**Template**
|
||||
|
||||
`my.group:arifact:version@extension`
|
||||
|
||||
`my/group/artifact/version/artifact-version.extension`
|
||||
|
||||
**Example**
|
||||
|
||||
`net.minecraft:launchwrapper:1.12` OR `net.minecraft:launchwrapper:1.12@jar`
|
||||
|
||||
`net/minecraft/launchwrapper/1.12/launchwrapper-1.12.jar`
|
||||
|
||||
If the module's artifact does not declare the `path` property, its path will be resolved from the ID.
|
||||
|
||||
### `Module.name: string`
|
||||
|
||||
The name of the module. Used on the UI.
|
||||
|
||||
### `Module.type: string`
|
||||
|
||||
The type of the module.
|
||||
|
||||
### `Module.classpath: boolean`
|
||||
|
||||
**OPTIONAL**
|
||||
|
||||
If the module is of type `Library`, whether the library should be added to the classpath. Defaults to true.
|
||||
|
||||
### `Module.required: Required`
|
||||
|
||||
**OPTIONAL**
|
||||
|
||||
Defines whether or not the module is required. If omitted, then the module will be required.
|
||||
|
||||
Only applicable for modules of type:
|
||||
* `ForgeMod`
|
||||
* `LiteMod`
|
||||
* `LiteLoader`
|
||||
|
||||
|
||||
### `Module.artifact: Artifact`
|
||||
|
||||
The download artifact for the module.
|
||||
|
||||
### `Module.subModules: Module[]`
|
||||
|
||||
**OPTIONAL**
|
||||
|
||||
An array of sub modules declared by this module. Typically, files which require other files are declared as submodules. A quick example would be a mod, and the configuration file for that mod. Submodules can also declare submodules of their own. The file is parsed recursively, so there is no limit.
|
||||
|
||||
|
||||
## Artifact Object
|
||||
|
||||
The format of the module's artifact depends on several things. The most important factor is where the file will be stored. If you are providing a simple file to be placed in the root directory of the client files, you may decided to format the module as the `examplefile` module declared above. This module provides a `path` option, allowing you to directly set where the file will be saved to. Only the `path` will affect the final downloaded file.
|
||||
|
||||
Other times, you may want to store the files maven-style, such as with libraries and mods. In this case you must declare the module as the example artifact above. The module `id` will be used to resolve the final path, effectively replacing the `path` property. It must be provided in maven format. More information on this is provided in the documentation for the `id` property.
|
||||
|
||||
The resolved/provided paths are appended to a base path depending on the module's declared type.
|
||||
|
||||
| Type | Path |
|
||||
| ---- | ---- |
|
||||
| `ForgeHosted` | ({`commonDirectory`}/libraries/{`path` OR resolved}) |
|
||||
| `Fabric` | ({`commonDirectory`}/libraries/{`path` OR resolved}) |
|
||||
| `LiteLoader` | ({`commonDirectory`}/libraries/{`path` OR resolved}) |
|
||||
| `Library` | ({`commonDirectory`}/libraries/{`path` OR resolved}) |
|
||||
| `ForgeMod` | ({`commonDirectory`}/modstore/{`path` OR resolved}) |
|
||||
| `LiteMod` | ({`commonDirectory`}/modstore/{`path` OR resolved}) |
|
||||
| `FabricMod` | ({`commonDirectory`}/mods/fabric/{`path` OR resolved}) |
|
||||
| `File` | ({`instanceDirectory`}/{`Server.id`}/{`path` OR resolved}) |
|
||||
|
||||
The `commonDirectory` and `instanceDirectory` values are stored in the launcher's config.json.
|
||||
|
||||
### `Artifact.size: number`
|
||||
|
||||
The size of the artifact.
|
||||
|
||||
### `Artifact.MD5: string`
|
||||
|
||||
The MD5 hash of the artifact. This will be used to validate local artifacts.
|
||||
|
||||
### `Artifact.path: string`
|
||||
|
||||
**OPTIONAL**
|
||||
|
||||
A relative path to where the file will be saved. This is appended to the base path for the module's declared type.
|
||||
|
||||
If this is not specified, the path will be resolved based on the module's ID.
|
||||
|
||||
### `Artifact.url: string/url`
|
||||
|
||||
The artifact's download url.
|
||||
|
||||
## Required Object
|
||||
|
||||
### `Required.value: boolean`
|
||||
|
||||
**OPTIONAL**
|
||||
|
||||
If the module is required. Defaults to true if this property is omited.
|
||||
|
||||
### `Required.def: boolean`
|
||||
|
||||
**OPTIONAL**
|
||||
|
||||
If the module is enabled by default. Has no effect unless `Required.value` is false. Defaults to true if this property is omited.
|
||||
|
||||
---
|
||||
|
||||
## Module Types
|
||||
|
||||
### ForgeHosted
|
||||
|
||||
The module type `ForgeHosted` represents forge itself. Currently, the launcher only supports modded servers, as vanilla servers can be connected to via the mojang launcher. The `Hosted` part is key, this means that the forge module must declare its required libraries as submodules.
|
||||
|
||||
Ex.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "net.minecraftforge:forge:1.11.2-13.20.1.2429",
|
||||
"name": "Minecraft Forge 1.11.2-13.20.1.2429",
|
||||
"type": "ForgeHosted",
|
||||
"artifact": {
|
||||
"size": 4450992,
|
||||
"MD5": "3fcc9b0104f0261397d3cc897e55a1c5",
|
||||
"url": "http://files.minecraftforge.net/maven/net/minecraftforge/forge/1.11.2-13.20.1.2429/forge-1.11.2-13.20.1.2429-universal.jar"
|
||||
},
|
||||
"subModules": [
|
||||
{
|
||||
"id": "net.minecraft:launchwrapper:1.12",
|
||||
"name": "Mojang (LaunchWrapper)",
|
||||
"type": "Library",
|
||||
"artifact": {
|
||||
"size": 32999,
|
||||
"MD5": "934b2d91c7c5be4a49577c9e6b40e8da",
|
||||
"url": "http://mc.westeroscraft.com/WesterosCraftLauncher/files/1.11.2/launchwrapper-1.12.jar"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
All of forge's required libraries are declared in the `version.json` file found in the root of the forge jar file. These libraries MUST be hosted and declared a submodules or forge will not work.
|
||||
|
||||
There were plans to add a `Forge` type, in which the required libraries would be resolved by the launcher and downloaded from forge's servers. The forge servers are down at times, however, so this plan was stopped half-implemented.
|
||||
|
||||
---
|
||||
|
||||
### Fabric
|
||||
|
||||
The module type `Fabric` represents the fabric mod loader. Currently, the launcher only supports modded servers, as vanilla servers can be connected to via the mojang launcher.
|
||||
|
||||
Ex.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "net.fabricmc:fabric-loader:0.15.0",
|
||||
"name": "Fabric (fabric-loader)",
|
||||
"type": "Fabric",
|
||||
"artifact": {
|
||||
"size": 1196222,
|
||||
"MD5": "a43d5a142246801343b6cedef1c102c4",
|
||||
"url": "http://localhost:8080/repo/lib/net/fabricmc/fabric-loader/0.15.0/fabric-loader-0.15.0.jar"
|
||||
},
|
||||
"subModules": [
|
||||
{
|
||||
"id": "1.20.1-fabric-0.15.0",
|
||||
"name": "Fabric (version.json)",
|
||||
"type": "VersionManifest",
|
||||
"artifact": {
|
||||
"size": 2847,
|
||||
"MD5": "69a2bd43452325ba1bc882fa0904e054",
|
||||
"url": "http://localhost:8080/repo/versions/1.20.1-fabric-0.15.0/1.20.1-fabric-0.15.0.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Fabric works similarly to Forge 1.13+.
|
||||
|
||||
---
|
||||
|
||||
### LiteLoader
|
||||
|
||||
The module type `LiteLoader` represents liteloader. It is handled as a library and added to the classpath at runtime. Special launch conditions are executed when liteloader is present and enabled. This module can be optional and toggled similarly to `ForgeMod` and `Litemod` modules.
|
||||
|
||||
Ex.
|
||||
```json
|
||||
{
|
||||
"id": "com.mumfrey:liteloader:1.11.2",
|
||||
"name": "Liteloader (1.11.2)",
|
||||
"type": "LiteLoader",
|
||||
"required": {
|
||||
"value": false,
|
||||
"def": false
|
||||
},
|
||||
"artifact": {
|
||||
"size": 1685422,
|
||||
"MD5": "3a98b5ed95810bf164e71c1a53be568d",
|
||||
"url": "http://mc.westeroscraft.com/WesterosCraftLauncher/files/1.11.2/liteloader-1.11.2.jar"
|
||||
},
|
||||
"subModules": [
|
||||
"All LiteMods go here"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Library
|
||||
|
||||
The module type `Library` represents a library file which will be required to start the minecraft process. Each library module will be dynamically added to the `-cp` (classpath) argument while building the game process.
|
||||
|
||||
Ex.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "net.sf.jopt-simple:jopt-simple:4.6",
|
||||
"name": "Jopt-simple 4.6",
|
||||
"type": "Library",
|
||||
"artifact": {
|
||||
"size": 62477,
|
||||
"MD5": "13560a58a79b46b82057686543e8d727",
|
||||
"url": "http://mc.westeroscraft.com/WesterosCraftLauncher/files/1.11.2/jopt-simple-4.6.jar"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ForgeMod
|
||||
|
||||
The module type `ForgeMod` represents a mod loaded by the Forge Mod Loader (FML). These files are stored maven-style and passed to FML using forge's [Modlist format](https://github.com/MinecraftForge/FML/wiki/New-JSON-Modlist-format).
|
||||
|
||||
Ex.
|
||||
```json
|
||||
{
|
||||
"id": "com.westeroscraft:westerosblocks:3.0.0-beta-6-133",
|
||||
"name": "WesterosBlocks (3.0.0-beta-6-133)",
|
||||
"type": "ForgeMod",
|
||||
"artifact": {
|
||||
"size": 16321712,
|
||||
"MD5": "5a89e2ab18916c18965fc93a0766cc6e",
|
||||
"url": "http://mc.westeroscraft.com/WesterosCraftLauncher/prod-1.11.2/mods/WesterosBlocks.jar"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### LiteMod
|
||||
|
||||
The module type `LiteMod` represents a mod loaded by liteloader. These files are stored maven-style and passed to liteloader using forge's [Modlist format](https://github.com/MinecraftForge/FML/wiki/New-JSON-Modlist-format). Documentation for liteloader's implementation of this can be found on [this issue](http://develop.liteloader.com/liteloader/LiteLoader/issues/34).
|
||||
|
||||
Ex.
|
||||
```json
|
||||
{
|
||||
"id": "com.mumfrey:macrokeybindmod:0.14.4-1.11.2@litemod",
|
||||
"name": "Macro/Keybind Mod (0.14.4-1.11.2)",
|
||||
"type": "LiteMod",
|
||||
"required": {
|
||||
"value": false,
|
||||
"def": false
|
||||
},
|
||||
"artifact": {
|
||||
"size": 1670811,
|
||||
"MD5": "16080785577b391d426c62c8d3138558",
|
||||
"url": "http://mc.westeroscraft.com/WesterosCraftLauncher/prod-1.11.2/mods/macrokeybindmod.litemod"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### File
|
||||
|
||||
The module type `file` represents a generic file required by the client, another module, etc. These files are stored in the server's instance directory.
|
||||
|
||||
Ex.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "com.westeroscraft:westeroscraftrp:2017-08-16",
|
||||
"name": "WesterosCraft Resource Pack (2017-08-16)",
|
||||
"type": "file",
|
||||
"artifact": {
|
||||
"size": 45241339,
|
||||
"MD5": "ec2d9fdb14d5c2eafe5975a240202f1a",
|
||||
"path": "resourcepacks/WesterosCraft.zip",
|
||||
"url": "http://mc.westeroscraft.com/WesterosCraftLauncher/prod-1.11.2/resourcepacks/WesterosCraft.zip"
|
||||
}
|
||||
}
|
||||
```
|
||||
104
docs/launcher-catalog.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Launcher Catalog
|
||||
|
||||
프로필은 관리자 측에서 미리 등록합니다.
|
||||
|
||||
- 기본 로컬 파일: `app/assets/launcher/catalog.json`
|
||||
- 또는 운영용 원격 JSON
|
||||
|
||||
클라이언트는 설치 페이지에서 이 카탈로그를 읽기 전용으로 확인하고, 원하는 항목만 자기 라이브러리에 추가합니다.
|
||||
|
||||
## 형식
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"profiles": [
|
||||
{
|
||||
"id": "my-modpack",
|
||||
"name": "My Modpack",
|
||||
"kind": "modpack",
|
||||
"description": "설명",
|
||||
"details": "설치 페이지 상세 패널에 표시할 긴 설명",
|
||||
"distributionUrl": "https://example.com/launcher/distribution.json",
|
||||
"defaultServerAddress": "example.com:25565",
|
||||
"allowCustomServerAddress": true
|
||||
},
|
||||
{
|
||||
"id": "my-map",
|
||||
"name": "My Map",
|
||||
"kind": "map",
|
||||
"description": "싱글플레이 월드",
|
||||
"details": "월드와 플레이 방식에 대한 상세 설명",
|
||||
"distributionUrl": "https://example.com/launcher/vanilla-map-distribution.json",
|
||||
"worldArchiveUrl": "https://example.com/worlds/my-map.zip",
|
||||
"worldDirectoryName": "My Map",
|
||||
"allowCustomServerAddress": false
|
||||
},
|
||||
{
|
||||
"id": "my-server-pack",
|
||||
"name": "My Server Pack",
|
||||
"kind": "server-pack",
|
||||
"description": "클라이언트 + 로컬 서버 번들",
|
||||
"details": "서버 실행 방법, 권장 인원, 접속 방식 등 상세 설명",
|
||||
"distributionUrl": "https://example.com/launcher/server-pack-client-distribution.json",
|
||||
"serverBundleUrl": "https://example.com/serverpacks/my-server-pack.zip",
|
||||
"serverDirectoryName": "my-server-pack",
|
||||
"serverLaunchCommand": "java -jar server.jar nogui",
|
||||
"serverPort": 25565,
|
||||
"tunnelCommand": "playit-cli --port ${port}",
|
||||
"tunnelAddressRegex": "([a-zA-Z0-9.-]+:\\d+)",
|
||||
"allowCustomServerAddress": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 필드
|
||||
|
||||
- `id`: 내부 식별자
|
||||
- `name`: 라이브러리/설치 페이지 표시 이름
|
||||
- `kind`: `modpack`, `map`, `server-pack`
|
||||
- `description`: 표시 설명
|
||||
- `details`: 설치 페이지 상세 패널에 표시할 긴 설명
|
||||
- `distributionUrl`: Helios distribution.json URL 또는 로컬 경로
|
||||
- `defaultServerAddress`: 기본 자동 접속 주소
|
||||
- `allowCustomServerAddress`: 사용자가 라이브러리에서 주소를 덮어쓸 수 있는지 여부
|
||||
- `worldArchiveUrl`: `kind: map` 일 때 사용할 월드 ZIP 또는 로컬 경로
|
||||
- `worldDirectoryName`: 게임 `saves/` 아래에 설치될 월드 폴더 이름
|
||||
- `serverBundleUrl`: `kind: server-pack` 일 때 사용할 서버 ZIP 또는 로컬 디렉터리/경로
|
||||
- `serverDirectoryName`: 서버 번들이 풀릴 하위 디렉터리 이름
|
||||
- `serverLaunchCommand`: 로컬 서버 실행 명령. 비워두면 `start.sh`, `start.bat`, `server.jar` 순으로 추론
|
||||
- `serverWorkingDirectory`: 실제 실행할 작업 디렉터리. 서버 루트 기준 상대 경로
|
||||
- `serverPort`: 로컬 서버 포트
|
||||
- `tunnelCommand`: 선택형 터널 명령. `${port}`, `${serverDir}` 치환 가능
|
||||
- `tunnelAddressRegex`: 터널 stdout 에서 공개 주소를 추출할 정규식
|
||||
|
||||
## 런처가 계산하는 상태
|
||||
|
||||
아래 값들은 런처가 내부적으로 계산하는 상태라 파일에 직접 넣지 않아도 됩니다.
|
||||
|
||||
- `launchReady`: 실행에 필요한 필드가 모두 있는지 여부
|
||||
- `hostReady`: `server-pack` 이 로컬 호스팅 가능한지 여부
|
||||
판정 기준:
|
||||
|
||||
- `modpack`: `distributionUrl` 필요
|
||||
- `map`: `distributionUrl`, `worldArchiveUrl`, `worldDirectoryName` 필요
|
||||
- `server-pack`: 클라이언트 실행은 `distributionUrl`, 로컬 호스팅은 추가로 `serverBundleUrl` 필요
|
||||
|
||||
## 현재 구현 범위
|
||||
|
||||
- `modpack`: 지원
|
||||
- `map`: 월드 ZIP 사전 다운로드, `saves/` 설치, `--quickPlaySingleplayer` 실행 지원
|
||||
- `server-pack`: distribution 기반 클라이언트 + 수동 주소 입력 자동 접속 + 로컬 서버 실행 + 선택형 터널 명령 지원
|
||||
|
||||
## 네트워크 참고
|
||||
|
||||
포트포워딩 없이 외부 사용자가 접속하려면 런처 단독으로는 부족합니다.
|
||||
|
||||
필요한 것 중 하나:
|
||||
|
||||
- 별도 중계 서버
|
||||
- 터널링 도구
|
||||
- VPN/NAT traversal 백엔드
|
||||
|
||||
현재 구현은 그 중 `터널 명령 실행기`를 연결할 수 있는 자리까지만 제공합니다.
|
||||
264
docs/portforwarding-free-connection-plan.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# Portforwarding-Free Connection Plan
|
||||
|
||||
이 문서는 “포트포워딩 없이 런처만으로 접속” 기능을 나중에 구현하기 위한 설계 초안이다. 현재는 구현하지 않았고, `server-pack` 프로필의 `tunnelCommand` 자리에 외부 도구를 연결할 수 있는 수준까지만 준비되어 있다.
|
||||
|
||||
## 목표
|
||||
|
||||
- 호스트 사용자가 공유기 포트포워딩을 직접 설정하지 않아도 됨
|
||||
- 접속 사용자는 런처에서 세션 주소 또는 세션 코드를 받아 바로 접속 가능
|
||||
- 가능하면 버튼 몇 번으로 `서버 실행 -> 세션 공개 -> 다른 사용자 접속` 흐름이 끝나야 함
|
||||
- 관리자가 런처와 백엔드를 함께 운영할 수 있어야 함
|
||||
|
||||
## 원하는 사용자 흐름
|
||||
|
||||
### 호스트
|
||||
|
||||
1. 라이브러리에서 `server-pack` 프로필 선택
|
||||
2. `서버 실행` 클릭
|
||||
3. 런처가 서버 프로세스를 시작
|
||||
4. 런처가 백엔드에 세션 생성 요청
|
||||
5. 백엔드가 공개 접속 주소 또는 세션 코드를 발급
|
||||
6. 호스트는 그 주소/코드를 다른 사용자에게 전달
|
||||
|
||||
### 접속자
|
||||
|
||||
1. 같은 프로필을 라이브러리에 추가
|
||||
2. 호스트가 보낸 주소 또는 세션 코드를 입력
|
||||
3. 런처가 실제 접속 주소를 해석
|
||||
4. 마인크래프트를 자동 실행하고 해당 서버로 바로 접속
|
||||
|
||||
## 필요한 구성요소
|
||||
|
||||
### 1. Session API
|
||||
|
||||
역할:
|
||||
|
||||
- 세션 생성
|
||||
- 세션 갱신
|
||||
- 세션 종료
|
||||
- 세션 조회
|
||||
- 세션 코드와 실제 릴레이 주소 매핑
|
||||
|
||||
예상 엔드포인트:
|
||||
|
||||
- `POST /api/sessions`
|
||||
- `PATCH /api/sessions/:id`
|
||||
- `DELETE /api/sessions/:id`
|
||||
- `GET /api/sessions/:code`
|
||||
|
||||
세션 데이터 예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "sess_123",
|
||||
"profileId": "my-server-pack",
|
||||
"hostUserId": "uuid-or-account-id",
|
||||
"relayAddress": "relay.example.com:34192",
|
||||
"accessCode": "ABCD-EFGH",
|
||||
"status": "active",
|
||||
"createdAt": "2026-05-04T10:00:00Z",
|
||||
"lastHeartbeatAt": "2026-05-04T10:01:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Relay or Tunnel Layer
|
||||
|
||||
실제로 포트포워딩 없는 접속을 가능하게 하는 핵심이다.
|
||||
|
||||
선택지:
|
||||
|
||||
- 자체 Relay 서버 운영
|
||||
- 기존 터널링 솔루션 연동
|
||||
- TURN 비슷한 중계 방식
|
||||
- 전용 게임 프록시 운영
|
||||
|
||||
이 프로젝트에서는 우선 아래 두 단계 접근이 현실적이다.
|
||||
|
||||
1. 외부 터널링 도구 연동
|
||||
2. 자체 세션/중계 서버로 점진적 전환
|
||||
|
||||
### 3. Launcher Host Agent
|
||||
|
||||
호스트 측 런처가 해야 하는 일:
|
||||
|
||||
- 로컬 서버 실행 상태 확인
|
||||
- 백엔드에 세션 생성
|
||||
- 터널 또는 릴레이 프로세스 시작
|
||||
- 공개 주소 수신
|
||||
- 주기적 heartbeat 전송
|
||||
- 서버 종료 시 세션 종료
|
||||
|
||||
### 4. Launcher Join Resolver
|
||||
|
||||
접속자 측 런처가 해야 하는 일:
|
||||
|
||||
- 세션 코드 입력 UI 제공
|
||||
- 코드로 세션 조회
|
||||
- 실제 접속 주소 해석
|
||||
- 선택 프로필과 세션 프로필 일치 여부 확인
|
||||
- 게임 실행 시 자동 접속 파라미터 전달
|
||||
|
||||
## 권장 1차 구현 방향
|
||||
|
||||
가장 빠른 방향은 “전용 Relay”를 바로 만드는 것이 아니라, “Session API + 터널 도구 연동”부터 시작하는 것이다.
|
||||
|
||||
### 이유
|
||||
|
||||
- 현재 런처에 `server-pack`, `tunnelCommand`, 공개 주소 표시 흐름이 이미 들어가 있음
|
||||
- 가장 적은 변경으로 실사용 가능 여부를 먼저 검증할 수 있음
|
||||
- 이후 자체 릴레이로 갈아탈 때 UI와 라이브러리 모델을 그대로 유지할 수 있음
|
||||
|
||||
### 1차 구성
|
||||
|
||||
- 런처:
|
||||
- 서버 실행
|
||||
- 터널 명령 실행
|
||||
- 터널 출력에서 공개 주소 파싱
|
||||
- Session API에 주소 등록
|
||||
- 백엔드:
|
||||
- 세션 코드 발급
|
||||
- 코드 -> 주소 매핑
|
||||
- heartbeat 만료 처리
|
||||
- 접속자 런처:
|
||||
- 세션 코드 입력
|
||||
- 코드 조회
|
||||
- 자동 접속
|
||||
|
||||
## 이후 2차 구현 방향
|
||||
|
||||
터널 명령에 외부 도구를 직접 의존하지 않고, 전용 서비스로 이동한다.
|
||||
|
||||
### 추가되는 것
|
||||
|
||||
- 릴레이 노드
|
||||
- 호스트 인증 토큰
|
||||
- 세션별 임시 접속 권한
|
||||
- 트래픽/세션 제한
|
||||
- 관리자 대시보드
|
||||
|
||||
### 기대 효과
|
||||
|
||||
- 터널 도구 설치 부담 감소
|
||||
- 사용자 경험 단순화
|
||||
- 접속 로그와 세션 통계 수집 가능
|
||||
- 악용 방지 정책 적용 가능
|
||||
|
||||
## 런처 쪽에 나중에 추가할 UI
|
||||
|
||||
### 라이브러리
|
||||
|
||||
- `세션 만들기`
|
||||
- `세션 코드 복사`
|
||||
- `세션 종료`
|
||||
- `세션 상태 보기`
|
||||
|
||||
### 설치/실행
|
||||
|
||||
- `세션 코드로 접속`
|
||||
- 최근 접속 세션 기록
|
||||
- 세션 만료 안내
|
||||
|
||||
### 설정
|
||||
|
||||
- 세션 API 주소
|
||||
- 릴레이 지역 선택
|
||||
- 호스트 세션 자동 재등록 여부
|
||||
|
||||
## 프로필 스키마 확장 예정
|
||||
|
||||
나중에 `catalog.json` 또는 분리된 관리자 설정에 아래 필드가 추가될 수 있다.
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionBackendUrl": "https://session.example.com",
|
||||
"sessionJoinMode": "code",
|
||||
"sessionCodeLength": 8,
|
||||
"relayMode": "external-tunnel",
|
||||
"relayRegion": "ap-northeast-2"
|
||||
}
|
||||
```
|
||||
|
||||
## 보안 고려사항
|
||||
|
||||
필수 항목:
|
||||
|
||||
- 세션 생성은 인증된 사용자만 가능해야 함
|
||||
- 호스트 heartbeat가 끊기면 세션 자동 만료
|
||||
- 세션 코드는 충분히 예측 불가능해야 함
|
||||
- 동일 계정의 세션 수 제한 필요
|
||||
- 악의적 트래픽에 대한 rate limit 필요
|
||||
- 프로필 ID 불일치 세션 차단 또는 경고 필요
|
||||
|
||||
추가 고려:
|
||||
|
||||
- 세션 코드에 만료 시간 포함
|
||||
- 백엔드 서명 토큰 사용
|
||||
- 서버 로그 업로드 여부
|
||||
- 접속자 IP/세션 기록 보관 정책
|
||||
|
||||
## 장애 대응
|
||||
|
||||
### 호스트 측
|
||||
|
||||
- 서버는 켜졌는데 릴레이 주소 발급 실패
|
||||
- 터널은 살아있는데 heartbeat 실패
|
||||
- 서버 프로세스 종료 후 세션 정리 누락
|
||||
|
||||
대응:
|
||||
|
||||
- 런처가 재시도 정책 보유
|
||||
- 종료 이벤트에서 세션 삭제 호출
|
||||
- stale session 정리 배치 작업 필요
|
||||
|
||||
### 접속자 측
|
||||
|
||||
- 세션 코드는 맞는데 만료됨
|
||||
- 주소는 받았는데 릴레이가 이미 죽음
|
||||
- 잘못된 프로필로 접속 시도
|
||||
|
||||
대응:
|
||||
|
||||
- 사용자에게 구체적인 실패 메시지 제공
|
||||
- 세션 재조회 버튼
|
||||
- 프로필 불일치 시 설치 페이지 이동 유도
|
||||
|
||||
## 구현 순서 제안
|
||||
|
||||
### 1단계
|
||||
|
||||
- Session API 설계
|
||||
- 세션 코드 발급/조회/만료 로직 구현
|
||||
- 런처에 세션 코드 입력 UI 추가
|
||||
|
||||
### 2단계
|
||||
|
||||
- 현재 `tunnelCommand` 기반 흐름과 Session API 연결
|
||||
- 호스트 세션 등록/heartbeat/종료 처리
|
||||
- 접속자 자동 접속 완료
|
||||
|
||||
### 3단계
|
||||
|
||||
- 자체 Relay 서비스 검토
|
||||
- 전용 호스트 인증 및 운영 정책 도입
|
||||
- 외부 터널 도구 의존도 축소
|
||||
|
||||
## 현재 코드와 연결되는 지점
|
||||
|
||||
- `app/assets/js/serverruntime.js`
|
||||
- 서버 실행 후 공개 주소 확보 지점
|
||||
- `app/assets/js/scripts/library.js`
|
||||
- 호스트 공개 주소 표시 및 수동 주소 입력 UI
|
||||
- `app/assets/js/processbuilder.js`
|
||||
- 자동 접속 인자 전달 지점
|
||||
- `app/assets/js/catalogmanager.js`
|
||||
- 프로필 메타데이터 확장 지점
|
||||
|
||||
## 결론
|
||||
|
||||
“포트포워딩 없이 접속”은 런처 버튼 하나로 끝나는 기능처럼 보여도, 실제로는 런처만의 문제가 아니라 세션 백엔드와 릴레이 계층이 함께 있어야 성립한다.
|
||||
|
||||
따라서 다음 구현 목표는 아래가 적절하다.
|
||||
|
||||
1. `Session API` 설계
|
||||
2. `server-pack + tunnelCommand` 흐름을 세션 코드 모델과 연결
|
||||
3. 이후 전용 Relay 서비스로 확장
|
||||
1584
docs/sample_distribution.json
Normal file
51
electron-builder.yml
Normal file
@@ -0,0 +1,51 @@
|
||||
appId: 'mrslauncher'
|
||||
productName: 'MRS Launcher'
|
||||
artifactName: 'MRS-Launcher-setup-${version}.${ext}'
|
||||
|
||||
copyright: 'Copyright © 2018-2026 Daniel Scalzi, Copyright © 2024 peunsu'
|
||||
|
||||
asar: true
|
||||
compression: 'maximum'
|
||||
|
||||
files:
|
||||
- '!{dist,.gitignore,.vscode,docs,dev-app-update.yml,.nvmrc,eslint.config.mjs}'
|
||||
|
||||
extraResources:
|
||||
- 'libraries'
|
||||
|
||||
# Windows Configuration
|
||||
win:
|
||||
target:
|
||||
- target: 'nsis'
|
||||
arch: 'x64'
|
||||
|
||||
# Windows Installer Configuration
|
||||
nsis:
|
||||
oneClick: false
|
||||
perMachine: false
|
||||
allowElevation: true
|
||||
allowToChangeInstallationDirectory: true
|
||||
|
||||
# macOS Configuration
|
||||
mac:
|
||||
target:
|
||||
- target: 'dmg'
|
||||
arch:
|
||||
- 'x64'
|
||||
- 'arm64'
|
||||
artifactName: 'MRS-Launcher-setup-${version}-${arch}.${ext}'
|
||||
category: 'public.app-category.games'
|
||||
|
||||
# Linux Configuration
|
||||
linux:
|
||||
target: 'AppImage'
|
||||
maintainer: 'Daniel Scalzi, peunsu'
|
||||
vendor: 'Daniel Scalzi, peunsu'
|
||||
synopsis: 'Modded Minecraft Launcher'
|
||||
description: 'Custom launcher which allows users to join modded servers. All mods, configurations, and updates are handled automatically.'
|
||||
category: 'Game'
|
||||
|
||||
|
||||
directories:
|
||||
buildResources: 'build'
|
||||
output: 'dist'
|
||||
55
eslint.config.mjs
Normal file
@@ -0,0 +1,55 @@
|
||||
import js from '@eslint/js';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import stylistic from '@stylistic/eslint-plugin';
|
||||
import globals from 'globals';
|
||||
|
||||
export default defineConfig(
|
||||
{
|
||||
ignores: ['**/dist/**', 'node_modules', 'eslint.config.mjs'],
|
||||
},
|
||||
js.configs.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
ecmaVersion: 2024
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.js'],
|
||||
plugins: {
|
||||
'@stylistic': stylistic,
|
||||
},
|
||||
rules: {
|
||||
'@stylistic/semi': ['error', 'never'],
|
||||
'@stylistic/quotes': ['error', 'single'],
|
||||
'@stylistic/indent': ['error', 4],
|
||||
'@stylistic/member-delimiter-style': ['error', {
|
||||
multiline: {
|
||||
delimiter: 'none',
|
||||
requireLast: false
|
||||
},
|
||||
singleline: {
|
||||
delimiter: 'comma',
|
||||
requireLast: false
|
||||
}
|
||||
}],
|
||||
'@stylistic/linebreak-style': ['error', 'windows'],
|
||||
'no-var': ['error'],
|
||||
'no-control-regex': 'off',
|
||||
'no-unused-vars': ['error', {
|
||||
vars: 'all',
|
||||
args: 'after-used',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
ignoreRestSiblings: false,
|
||||
argsIgnorePattern: '^_|^reject$'
|
||||
}],
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['app/assets/js/scripts/*.js'],
|
||||
rules: {
|
||||
'no-unused-vars': 'off',
|
||||
'no-undef': 'off'
|
||||
}
|
||||
}
|
||||
);
|
||||
16
index.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const compiledMainPath = path.join(__dirname, 'dist', 'main', 'index.js')
|
||||
const legacyMainPath = path.join(__dirname, 'index.legacy.js')
|
||||
|
||||
if(fs.existsSync(compiledMainPath)){
|
||||
try {
|
||||
require(compiledMainPath)
|
||||
} catch (error) {
|
||||
console.warn('[launcher] compiled TypeScript main process failed, falling back to legacy entry.', error)
|
||||
require(legacyMainPath)
|
||||
}
|
||||
} else {
|
||||
require(legacyMainPath)
|
||||
}
|
||||
369
index.legacy.js
Normal file
@@ -0,0 +1,369 @@
|
||||
const remoteMain = require('@electron/remote/main')
|
||||
remoteMain.initialize()
|
||||
|
||||
// Requirements
|
||||
const { app, BrowserWindow, ipcMain, Menu, shell } = require('electron')
|
||||
const autoUpdater = require('electron-updater').autoUpdater
|
||||
const ejse = require('ejs-electron')
|
||||
const fs = require('fs')
|
||||
const isDev = require('./app/assets/js/isdev')
|
||||
const path = require('path')
|
||||
const semver = require('semver')
|
||||
const { pathToFileURL } = require('url')
|
||||
const { AZURE_CLIENT_ID, MSFT_OPCODE, MSFT_REPLY_TYPE, MSFT_ERROR, SHELL_OPCODE } = require('./app/assets/js/ipcconstants')
|
||||
const LangLoader = require('./app/assets/js/langloader')
|
||||
const smokeExitEnabled = process.env.LAUNCHER_SMOKE_EXIT === '1'
|
||||
const smokeExitDelayMs = Number.parseInt(process.env.LAUNCHER_SMOKE_EXIT_DELAY_MS ?? '5000', 10)
|
||||
|
||||
// Setup Lang
|
||||
LangLoader.setupLanguage()
|
||||
|
||||
// Setup auto updater.
|
||||
function initAutoUpdater(event, data) {
|
||||
|
||||
if(data){
|
||||
autoUpdater.allowPrerelease = true
|
||||
} else {
|
||||
// Defaults to true if application version contains prerelease components (e.g. 0.12.1-alpha.1)
|
||||
// autoUpdater.allowPrerelease = true
|
||||
}
|
||||
|
||||
if(isDev){
|
||||
autoUpdater.autoInstallOnAppQuit = false
|
||||
autoUpdater.updateConfigPath = path.join(__dirname, 'dev-app-update.yml')
|
||||
}
|
||||
if(process.platform === 'darwin'){
|
||||
autoUpdater.autoDownload = false
|
||||
}
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
event.sender.send('autoUpdateNotification', 'update-available', info)
|
||||
})
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
event.sender.send('autoUpdateNotification', 'update-downloaded', info)
|
||||
})
|
||||
autoUpdater.on('update-not-available', (info) => {
|
||||
event.sender.send('autoUpdateNotification', 'update-not-available', info)
|
||||
})
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
event.sender.send('autoUpdateNotification', 'checking-for-update')
|
||||
})
|
||||
autoUpdater.on('error', (err) => {
|
||||
event.sender.send('autoUpdateNotification', 'realerror', err)
|
||||
})
|
||||
}
|
||||
|
||||
// Open channel to listen for update actions.
|
||||
ipcMain.on('autoUpdateAction', (event, arg, data) => {
|
||||
switch(arg){
|
||||
case 'initAutoUpdater':
|
||||
console.log('Initializing auto updater.')
|
||||
initAutoUpdater(event, data)
|
||||
event.sender.send('autoUpdateNotification', 'ready')
|
||||
break
|
||||
case 'checkForUpdate':
|
||||
autoUpdater.checkForUpdates()
|
||||
.catch(err => {
|
||||
event.sender.send('autoUpdateNotification', 'realerror', err)
|
||||
})
|
||||
break
|
||||
case 'allowPrereleaseChange':
|
||||
if(!data){
|
||||
const preRelComp = semver.prerelease(app.getVersion())
|
||||
if(preRelComp != null && preRelComp.length > 0){
|
||||
autoUpdater.allowPrerelease = true
|
||||
} else {
|
||||
autoUpdater.allowPrerelease = data
|
||||
}
|
||||
} else {
|
||||
autoUpdater.allowPrerelease = data
|
||||
}
|
||||
break
|
||||
case 'installUpdateNow':
|
||||
autoUpdater.quitAndInstall()
|
||||
break
|
||||
default:
|
||||
console.log('Unknown argument', arg)
|
||||
break
|
||||
}
|
||||
})
|
||||
// Redirect distribution index event from preloader to renderer.
|
||||
ipcMain.on('distributionIndexDone', (event, res) => {
|
||||
event.sender.send('distributionIndexDone', res)
|
||||
})
|
||||
|
||||
// Handle trash item.
|
||||
ipcMain.handle(SHELL_OPCODE.TRASH_ITEM, async (event, ...args) => {
|
||||
try {
|
||||
await shell.trashItem(args[0])
|
||||
return {
|
||||
result: true
|
||||
}
|
||||
} catch(error) {
|
||||
return {
|
||||
result: false,
|
||||
error: error
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Disable hardware acceleration.
|
||||
// https://electronjs.org/docs/tutorial/offscreen-rendering
|
||||
app.disableHardwareAcceleration()
|
||||
|
||||
|
||||
const REDIRECT_URI_PREFIX = 'https://login.microsoftonline.com/common/oauth2/nativeclient?'
|
||||
|
||||
// Microsoft Auth Login
|
||||
let msftAuthWindow
|
||||
let msftAuthSuccess
|
||||
let msftAuthViewSuccess
|
||||
let msftAuthViewOnClose
|
||||
ipcMain.on(MSFT_OPCODE.OPEN_LOGIN, (ipcEvent, ...arguments_) => {
|
||||
if (msftAuthWindow) {
|
||||
ipcEvent.reply(MSFT_OPCODE.REPLY_LOGIN, MSFT_REPLY_TYPE.ERROR, MSFT_ERROR.ALREADY_OPEN, msftAuthViewOnClose)
|
||||
return
|
||||
}
|
||||
msftAuthSuccess = false
|
||||
msftAuthViewSuccess = arguments_[0]
|
||||
msftAuthViewOnClose = arguments_[1]
|
||||
msftAuthWindow = new BrowserWindow({
|
||||
title: LangLoader.queryJS('index.microsoftLoginTitle'),
|
||||
backgroundColor: '#222222',
|
||||
width: 520,
|
||||
height: 600,
|
||||
frame: true,
|
||||
icon: getPlatformIcon('Icon')
|
||||
})
|
||||
|
||||
msftAuthWindow.on('closed', () => {
|
||||
msftAuthWindow = undefined
|
||||
})
|
||||
|
||||
msftAuthWindow.on('close', () => {
|
||||
if(!msftAuthSuccess) {
|
||||
ipcEvent.reply(MSFT_OPCODE.REPLY_LOGIN, MSFT_REPLY_TYPE.ERROR, MSFT_ERROR.NOT_FINISHED, msftAuthViewOnClose)
|
||||
}
|
||||
})
|
||||
|
||||
msftAuthWindow.webContents.on('did-navigate', (_, uri) => {
|
||||
if (uri.startsWith(REDIRECT_URI_PREFIX)) {
|
||||
let queryMap = {}
|
||||
|
||||
new URL(uri).searchParams.forEach((v, k) => {
|
||||
queryMap[k] = v;
|
||||
});
|
||||
|
||||
ipcEvent.reply(MSFT_OPCODE.REPLY_LOGIN, MSFT_REPLY_TYPE.SUCCESS, queryMap, msftAuthViewSuccess)
|
||||
|
||||
msftAuthSuccess = true
|
||||
msftAuthWindow.close()
|
||||
msftAuthWindow = null
|
||||
}
|
||||
})
|
||||
|
||||
msftAuthWindow.removeMenu()
|
||||
msftAuthWindow.loadURL(`https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?prompt=select_account&client_id=${AZURE_CLIENT_ID}&response_type=code&scope=XboxLive.signin%20offline_access&redirect_uri=https://login.microsoftonline.com/common/oauth2/nativeclient`)
|
||||
})
|
||||
|
||||
// Microsoft Auth Logout
|
||||
let msftLogoutWindow
|
||||
let msftLogoutSuccess
|
||||
let msftLogoutSuccessSent
|
||||
ipcMain.on(MSFT_OPCODE.OPEN_LOGOUT, (ipcEvent, uuid, isLastAccount) => {
|
||||
if (msftLogoutWindow) {
|
||||
ipcEvent.reply(MSFT_OPCODE.REPLY_LOGOUT, MSFT_REPLY_TYPE.ERROR, MSFT_ERROR.ALREADY_OPEN)
|
||||
return
|
||||
}
|
||||
|
||||
msftLogoutSuccess = false
|
||||
msftLogoutSuccessSent = false
|
||||
msftLogoutWindow = new BrowserWindow({
|
||||
title: LangLoader.queryJS('index.microsoftLogoutTitle'),
|
||||
backgroundColor: '#222222',
|
||||
width: 520,
|
||||
height: 600,
|
||||
frame: true,
|
||||
icon: getPlatformIcon('Icon')
|
||||
})
|
||||
|
||||
msftLogoutWindow.on('closed', () => {
|
||||
msftLogoutWindow = undefined
|
||||
})
|
||||
|
||||
msftLogoutWindow.on('close', () => {
|
||||
if(!msftLogoutSuccess) {
|
||||
ipcEvent.reply(MSFT_OPCODE.REPLY_LOGOUT, MSFT_REPLY_TYPE.ERROR, MSFT_ERROR.NOT_FINISHED)
|
||||
} else if(!msftLogoutSuccessSent) {
|
||||
msftLogoutSuccessSent = true
|
||||
ipcEvent.reply(MSFT_OPCODE.REPLY_LOGOUT, MSFT_REPLY_TYPE.SUCCESS, uuid, isLastAccount)
|
||||
}
|
||||
})
|
||||
|
||||
msftLogoutWindow.webContents.on('did-navigate', (_, uri) => {
|
||||
if(uri.startsWith('https://login.microsoftonline.com/common/oauth2/v2.0/logoutsession')) {
|
||||
msftLogoutSuccess = true
|
||||
setTimeout(() => {
|
||||
if(!msftLogoutSuccessSent) {
|
||||
msftLogoutSuccessSent = true
|
||||
ipcEvent.reply(MSFT_OPCODE.REPLY_LOGOUT, MSFT_REPLY_TYPE.SUCCESS, uuid, isLastAccount)
|
||||
}
|
||||
|
||||
if(msftLogoutWindow) {
|
||||
msftLogoutWindow.close()
|
||||
msftLogoutWindow = null
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
})
|
||||
|
||||
msftLogoutWindow.removeMenu()
|
||||
msftLogoutWindow.loadURL('https://login.microsoftonline.com/common/oauth2/v2.0/logout')
|
||||
})
|
||||
|
||||
// Keep a global reference of the window object, if you don't, the window will
|
||||
// be closed automatically when the JavaScript object is garbage collected.
|
||||
let win
|
||||
|
||||
function createWindow() {
|
||||
|
||||
win = new BrowserWindow({
|
||||
width: 980,
|
||||
height: 552,
|
||||
icon: getPlatformIcon('Icon'),
|
||||
frame: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'app', 'assets', 'js', 'preloader.js'),
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false
|
||||
},
|
||||
backgroundColor: '#171614'
|
||||
})
|
||||
remoteMain.enable(win.webContents)
|
||||
|
||||
const data = {
|
||||
bkid: Math.floor((Math.random() * fs.readdirSync(path.join(__dirname, 'app', 'assets', 'images', 'backgrounds')).length)),
|
||||
lang: (str, placeHolders) => LangLoader.queryEJS(str, placeHolders)
|
||||
}
|
||||
Object.entries(data).forEach(([key, val]) => ejse.data(key, val))
|
||||
|
||||
win.loadURL(pathToFileURL(path.join(__dirname, 'app', 'app.ejs')).toString())
|
||||
|
||||
if(smokeExitEnabled){
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
setTimeout(() => {
|
||||
app.quit()
|
||||
}, Number.isFinite(smokeExitDelayMs) ? smokeExitDelayMs : 5000)
|
||||
})
|
||||
}
|
||||
|
||||
/*win.once('ready-to-show', () => {
|
||||
win.show()
|
||||
})*/
|
||||
|
||||
win.removeMenu()
|
||||
|
||||
win.resizable = true
|
||||
|
||||
win.on('closed', () => {
|
||||
win = null
|
||||
})
|
||||
}
|
||||
|
||||
function createMenu() {
|
||||
|
||||
if(process.platform === 'darwin') {
|
||||
|
||||
// Extend default included application menu to continue support for quit keyboard shortcut
|
||||
let applicationSubMenu = {
|
||||
label: 'Application',
|
||||
submenu: [{
|
||||
label: 'About Application',
|
||||
selector: 'orderFrontStandardAboutPanel:'
|
||||
}, {
|
||||
type: 'separator'
|
||||
}, {
|
||||
label: 'Quit',
|
||||
accelerator: 'Command+Q',
|
||||
click: () => {
|
||||
app.quit()
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
// New edit menu adds support for text-editing keyboard shortcuts
|
||||
let editSubMenu = {
|
||||
label: 'Edit',
|
||||
submenu: [{
|
||||
label: 'Undo',
|
||||
accelerator: 'CmdOrCtrl+Z',
|
||||
selector: 'undo:'
|
||||
}, {
|
||||
label: 'Redo',
|
||||
accelerator: 'Shift+CmdOrCtrl+Z',
|
||||
selector: 'redo:'
|
||||
}, {
|
||||
type: 'separator'
|
||||
}, {
|
||||
label: 'Cut',
|
||||
accelerator: 'CmdOrCtrl+X',
|
||||
selector: 'cut:'
|
||||
}, {
|
||||
label: 'Copy',
|
||||
accelerator: 'CmdOrCtrl+C',
|
||||
selector: 'copy:'
|
||||
}, {
|
||||
label: 'Paste',
|
||||
accelerator: 'CmdOrCtrl+V',
|
||||
selector: 'paste:'
|
||||
}, {
|
||||
label: 'Select All',
|
||||
accelerator: 'CmdOrCtrl+A',
|
||||
selector: 'selectAll:'
|
||||
}]
|
||||
}
|
||||
|
||||
// Bundle submenus into a single template and build a menu object with it
|
||||
let menuTemplate = [applicationSubMenu, editSubMenu]
|
||||
let menuObject = Menu.buildFromTemplate(menuTemplate)
|
||||
|
||||
// Assign it to the application
|
||||
Menu.setApplicationMenu(menuObject)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function getPlatformIcon(filename){
|
||||
let ext
|
||||
switch(process.platform) {
|
||||
case 'win32':
|
||||
ext = 'ico'
|
||||
break
|
||||
case 'darwin':
|
||||
case 'linux':
|
||||
default:
|
||||
ext = 'png'
|
||||
break
|
||||
}
|
||||
|
||||
return path.join(__dirname, 'app', 'assets', 'images', `${filename}.${ext}`)
|
||||
}
|
||||
|
||||
app.on('ready', createWindow)
|
||||
app.on('ready', createMenu)
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// On macOS it is common for applications and their menu bar
|
||||
// to stay active until the user quits explicitly with Cmd + Q
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (win === null) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||