mirror of
https://git.sanhost.net/sanasol/hytale-f2p.git
synced 2026-02-26 14:51:48 -03:00
Compare commits
45 Commits
v2.2.2-dra
...
a649bf1fcc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a649bf1fcc | ||
|
|
66faa1bb1e | ||
|
|
a63e026700 | ||
|
|
552ec42d6c | ||
|
|
fb90277be9 | ||
|
|
27c220a757 | ||
|
|
30929ee0da | ||
|
|
44834e7d12 | ||
|
|
cb7f7e51bf | ||
|
|
9b20c454d3 | ||
|
|
4e04d657b7 | ||
|
|
6d811fd7e0 | ||
|
|
8435fc698c | ||
|
|
6c369edb0f | ||
|
|
fdd8e59ec4 | ||
|
|
e7a033932f | ||
|
|
11c6d40dfe | ||
|
|
0dafb17c7b | ||
|
|
66112f15b2 | ||
|
|
0a71fdac8c | ||
|
|
4b9eae215b | ||
|
|
1510eceb0f | ||
|
|
c4b5368538 | ||
|
|
e0ebf137fc | ||
|
|
5241a502e5 | ||
|
|
3e7c7ccff3 | ||
|
|
89d09f032f | ||
|
|
8bdb78d1e2 | ||
|
|
e9e66dbca7 | ||
|
|
92a0a26251 | ||
|
|
3abe885ab4 | ||
|
|
82f1dd2739 | ||
|
|
4502c11bd0 | ||
|
|
bcc7476322 | ||
|
|
ae6a7db80a | ||
|
|
48395fbff3 | ||
|
|
aae90a72e8 | ||
|
|
b93dc027e1 | ||
|
|
fdbca6b9da | ||
|
|
b2f65bd524 | ||
|
|
bd6b05d1e4 | ||
|
|
454ca7f075 | ||
|
|
a6c61aef68 | ||
|
|
31653a37a7 | ||
|
|
1cb08f029a |
2
.github/CODE_OF_CONDUCT.md
vendored
2
.github/CODE_OF_CONDUCT.md
vendored
@@ -36,7 +36,7 @@ This Code of Conduct applies within all community spaces, and also applies when
|
|||||||
|
|
||||||
## Enforcement
|
## Enforcement
|
||||||
|
|
||||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [Discord Server, message Founders/Devs](https://discord.gg/hf2pdc). All complaints will be reviewed and investigated promptly and fairly.
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [Discord Server, message Founders/Devs](https://discord.gg/Fhbb9Yk5WW). All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
||||||
|
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/support_request.yml
vendored
2
.github/ISSUE_TEMPLATE/support_request.yml
vendored
@@ -22,7 +22,7 @@ body:
|
|||||||
value: |
|
value: |
|
||||||
If you need help or support with using the launcher, please fill out this support request.
|
If you need help or support with using the launcher, please fill out this support request.
|
||||||
Provide as much detail as possible so we can assist you effectively.
|
Provide as much detail as possible so we can assist you effectively.
|
||||||
**Need a quick assistance?** Please Open-A-Ticket in our [Discord Server](https://discord.gg/gME8rUy3MB)!
|
**Need a quick assistance?** Please Open-A-Ticket in our [Discord Server](https://discord.gg/Fhbb9Yk5WW)!
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: question
|
id: question
|
||||||
|
|||||||
198
.github/workflows/release.yml
vendored
198
.github/workflows/release.yml
vendored
@@ -6,173 +6,117 @@ on:
|
|||||||
- 'v*'
|
- 'v*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Domain for small API calls (goes through Cloudflare - fine for <100MB)
|
||||||
|
FORGEJO_API: https://git.sanhost.net/api/v1
|
||||||
|
# Direct upload URL (bypasses Cloudflare for large files) - set in repo secrets
|
||||||
|
FORGEJO_UPLOAD: ${{ secrets.FORGEJO_UPLOAD_URL }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-windows:
|
create-release:
|
||||||
runs-on: windows-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- name: Create Draft Release
|
||||||
|
run: |
|
||||||
|
curl -s -X POST "${FORGEJO_API}/repos/${GITHUB_REPOSITORY}/releases" \
|
||||||
|
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${{ github.ref_name }}\",\"name\":\"${{ github.ref_name }}\",\"body\":\"Release ${{ github.ref_name }}\",\"draft\":true,\"prerelease\":false}" \
|
||||||
|
-o release.json
|
||||||
|
cat release.json
|
||||||
|
echo "RELEASE_ID=$(cat release.json | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])')" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
build-windows:
|
||||||
|
needs: [create-release]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install Wine for cross-compilation
|
||||||
|
run: |
|
||||||
|
sudo dpkg --add-architecture i386
|
||||||
|
sudo mkdir -pm755 /etc/apt/keyrings
|
||||||
|
sudo wget -O /etc/apt/keyrings/winehq-archive.key https://dl.winehq.org/wine-builds/winehq.key
|
||||||
|
sudo wget -NP /etc/apt/sources.list.d/ https://dl.winehq.org/wine-builds/ubuntu/dists/$(lsb_release -cs)/winehq-$(lsb_release -cs).sources
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --install-recommends winehq-stable
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
|
||||||
- name: Build Windows Packages
|
- name: Build Windows Packages
|
||||||
run: npx electron-builder --win --publish never
|
run: npx electron-builder --win --publish never --config.npmRebuild=false
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
- name: Upload to Release
|
||||||
name: windows-builds
|
run: |
|
||||||
path: |
|
RELEASE_ID=$(curl -s "${FORGEJO_API}/repos/${GITHUB_REPOSITORY}/releases/tags/${{ github.ref_name }}" \
|
||||||
dist/*.exe
|
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])')
|
||||||
dist/*.exe.blockmap
|
for file in dist/*.exe dist/*.exe.blockmap dist/latest.yml; do
|
||||||
dist/latest.yml
|
[ -f "$file" ] || continue
|
||||||
|
echo "Uploading $file..."
|
||||||
|
curl -s --max-time 600 -X POST "${FORGEJO_UPLOAD}/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=$(basename $file)" \
|
||||||
|
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
|
||||||
|
-F "attachment=@${file}" || echo "Failed to upload $file"
|
||||||
|
done
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
|
needs: [create-release]
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
|
||||||
- name: Build macOS Packages
|
- name: Build macOS Packages
|
||||||
env:
|
env:
|
||||||
# Code signing
|
|
||||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||||
# Notarization
|
|
||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
run: npx electron-builder --mac --publish never
|
run: npx electron-builder --mac --publish never
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
- name: Upload to Release
|
||||||
name: macos-builds
|
run: |
|
||||||
path: |
|
RELEASE_ID=$(curl -s "${FORGEJO_API}/repos/${GITHUB_REPOSITORY}/releases/tags/${{ github.ref_name }}" \
|
||||||
dist/*.dmg
|
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])')
|
||||||
dist/*.zip
|
for file in dist/*.dmg dist/*.zip dist/*.blockmap dist/latest-mac.yml; do
|
||||||
dist/*.blockmap
|
[ -f "$file" ] || continue
|
||||||
dist/latest-mac.yml
|
echo "Uploading $file..."
|
||||||
|
curl -s --max-time 600 -X POST "${FORGEJO_UPLOAD}/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=$(basename $file)" \
|
||||||
|
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
|
||||||
|
-F "attachment=@${file}" || echo "Failed to upload $file"
|
||||||
|
done
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: [create-release]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libarchive-tools
|
sudo apt-get install -y libarchive-tools rpm
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
|
||||||
- name: Build Linux Packages
|
- name: Build Linux Packages
|
||||||
|
run: npx electron-builder --linux AppImage deb rpm pacman --publish never
|
||||||
|
|
||||||
|
- name: Upload to Release
|
||||||
run: |
|
run: |
|
||||||
npx electron-builder --linux AppImage deb rpm --publish never
|
RELEASE_ID=$(curl -s "${FORGEJO_API}/repos/${GITHUB_REPOSITORY}/releases/tags/${{ github.ref_name }}" \
|
||||||
- uses: actions/upload-artifact@v4
|
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])')
|
||||||
with:
|
for file in dist/*.AppImage dist/*.AppImage.blockmap dist/*.deb dist/*.rpm dist/*.pacman dist/latest-linux.yml; do
|
||||||
name: linux-builds
|
[ -f "$file" ] || continue
|
||||||
path: |
|
echo "Uploading $file..."
|
||||||
dist/*.AppImage
|
curl -s --max-time 600 -X POST "${FORGEJO_UPLOAD}/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=$(basename $file)" \
|
||||||
dist/*.AppImage.blockmap
|
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
|
||||||
dist/*.deb
|
-F "attachment=@${file}" || echo "Failed to upload $file"
|
||||||
dist/*.rpm
|
done
|
||||||
dist/latest-linux.yml
|
|
||||||
|
|
||||||
build-arch:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container:
|
|
||||||
image: archlinux:latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Install base packages
|
|
||||||
run: |
|
|
||||||
pacman -Syu --noconfirm
|
|
||||||
pacman -S --noconfirm \
|
|
||||||
base-devel \
|
|
||||||
git \
|
|
||||||
nodejs \
|
|
||||||
npm \
|
|
||||||
rpm-tools \
|
|
||||||
libxcrypt-compat
|
|
||||||
|
|
||||||
- name: Create build user
|
|
||||||
run: |
|
|
||||||
useradd -m builder
|
|
||||||
echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
|
|
||||||
|
|
||||||
- name: Fix Permissions
|
|
||||||
run: chown -R builder:builder .
|
|
||||||
|
|
||||||
- name: Build Arch Package
|
|
||||||
run: |
|
|
||||||
sudo -u builder bash << 'EOF'
|
|
||||||
set -e
|
|
||||||
makepkg --printsrcinfo > .SRCINFO
|
|
||||||
makepkg -s --noconfirm
|
|
||||||
EOF
|
|
||||||
|
|
||||||
- name: Fix permissions for upload
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
sudo chown -R $(id -u):$(id -g) .
|
|
||||||
|
|
||||||
- name: Upload Arch Package
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: arch-package
|
|
||||||
path: |
|
|
||||||
*.pkg.tar.zst
|
|
||||||
.SRCINFO
|
|
||||||
include-hidden-files: true
|
|
||||||
|
|
||||||
release:
|
|
||||||
needs: [build-windows, build-macos, build-linux, build-arch]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: |
|
|
||||||
startsWith(github.ref, 'refs/tags/v') ||
|
|
||||||
github.ref == 'refs/heads/main' ||
|
|
||||||
github.event_name == 'workflow_dispatch'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Download all artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: artifacts
|
|
||||||
|
|
||||||
- name: Display structure of downloaded files
|
|
||||||
run: ls -R artifacts
|
|
||||||
|
|
||||||
- name: Get version from package.json
|
|
||||||
id: pkg_version
|
|
||||||
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Create Release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
tag_name: ${{ github.ref_name }}
|
|
||||||
files: |
|
|
||||||
artifacts/arch-package/*.pkg.tar.zst
|
|
||||||
artifacts/arch-package/.SRCINFO
|
|
||||||
artifacts/linux-builds/**/*
|
|
||||||
artifacts/windows-builds/**/*
|
|
||||||
artifacts/macos-builds/**/*
|
|
||||||
generate_release_notes: true
|
|
||||||
draft: true
|
|
||||||
prerelease: false
|
|
||||||
|
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -17,6 +17,9 @@ dist/
|
|||||||
# Project Specific: Downloaded patcher (from hytale-auth-server)
|
# Project Specific: Downloaded patcher (from hytale-auth-server)
|
||||||
backend/patcher/
|
backend/patcher/
|
||||||
|
|
||||||
|
# Private docs (local only)
|
||||||
|
docs/PATCH_CDN_INFRASTRUCTURE.md
|
||||||
|
|
||||||
# macOS Specific
|
# macOS Specific
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.zst.DS_Store
|
*.zst.DS_Store
|
||||||
|
|||||||
@@ -8,9 +8,10 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Noto+Sans+Arabic:wght@300;400;500;600;700&display=swap"
|
||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<link rel="stylesheet" href="style-RTL.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-black text-white overflow-hidden font-sans select-none" tabindex="-1">
|
<body class="bg-black text-white overflow-hidden font-sans select-none" tabindex="-1">
|
||||||
|
|||||||
@@ -12,9 +12,18 @@ const i18n = (() => {
|
|||||||
{ code: 'ru-RU', name: 'Russian (Russia)' },
|
{ code: 'ru-RU', name: 'Russian (Russia)' },
|
||||||
{ code: 'sv-SE', name: 'Swedish (Sweden)' },
|
{ code: 'sv-SE', name: 'Swedish (Sweden)' },
|
||||||
{ code: 'tr-TR', name: 'Turkish (Turkey)' },
|
{ code: 'tr-TR', name: 'Turkish (Turkey)' },
|
||||||
{ code: 'id-ID', name: 'Indonesian (Indonesia)' }
|
{ code: 'id-ID', name: 'Indonesian (Indonesia)' },
|
||||||
|
{ code: 'ar-SA', name: 'Arabic (Saudi Arabia)' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// RTL languages
|
||||||
|
const rtlLanguages = ['ar-SA'];
|
||||||
|
|
||||||
|
// Check if current language is RTL
|
||||||
|
function isRTL() {
|
||||||
|
return rtlLanguages.includes(currentLang);
|
||||||
|
}
|
||||||
|
|
||||||
// Load single language file
|
// Load single language file
|
||||||
async function loadLanguage(lang) {
|
async function loadLanguage(lang) {
|
||||||
if (translations[lang]) return true;
|
if (translations[lang]) return true;
|
||||||
@@ -73,6 +82,24 @@ const i18n = (() => {
|
|||||||
const key = el.getAttribute('data-i18n-title');
|
const key = el.getAttribute('data-i18n-title');
|
||||||
el.title = t(key);
|
el.title = t(key);
|
||||||
});
|
});
|
||||||
|
// Update RTL layout
|
||||||
|
updateRTL();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update RTL layout
|
||||||
|
function updateRTL() {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const body = document.body;
|
||||||
|
|
||||||
|
if (isRTL()) {
|
||||||
|
html.setAttribute('dir', 'rtl');
|
||||||
|
html.setAttribute('lang', currentLang);
|
||||||
|
body.classList.add('rtl');
|
||||||
|
} else {
|
||||||
|
html.removeAttribute('dir');
|
||||||
|
html.setAttribute('lang', currentLang);
|
||||||
|
body.classList.remove('rtl');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize - load saved language only
|
// Initialize - load saved language only
|
||||||
@@ -88,7 +115,8 @@ const i18n = (() => {
|
|||||||
t,
|
t,
|
||||||
setLanguage,
|
setLanguage,
|
||||||
getAvailableLanguages: () => availableLanguages,
|
getAvailableLanguages: () => availableLanguages,
|
||||||
getCurrentLanguage: () => currentLang
|
getCurrentLanguage: () => currentLang,
|
||||||
|
isRTL
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ window.closeDiscordPopup = function() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.joinDiscord = async function() {
|
window.joinDiscord = async function() {
|
||||||
await window.electronAPI?.openExternal('https://discord.gg/hf2pdc');
|
await window.electronAPI?.openExternal('https://discord.gg/Fhbb9Yk5WW');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await window.electronAPI?.saveConfig({ discordPopup: true });
|
await window.electronAPI?.saveConfig({ discordPopup: true });
|
||||||
|
|||||||
@@ -1103,7 +1103,7 @@ function getRetryContextMessage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.openDiscordExternal = function() {
|
window.openDiscordExternal = function() {
|
||||||
window.electronAPI?.openExternal('https://discord.gg/hf2pdc');
|
window.electronAPI?.openExternal('https://discord.gg/Fhbb9Yk5WW');
|
||||||
};
|
};
|
||||||
|
|
||||||
window.toggleMaximize = toggleMaximize;
|
window.toggleMaximize = toggleMaximize;
|
||||||
|
|||||||
257
GUI/locales/ar-SA.json
Normal file
257
GUI/locales/ar-SA.json
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"play": "لعب",
|
||||||
|
"mods": "المودات",
|
||||||
|
"news": "الأخبار",
|
||||||
|
"chat": "دردشة اللاعبين",
|
||||||
|
"settings": "الإعدادات"
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"playersLabel": "اللاعبون:",
|
||||||
|
"manageProfiles": "إدارة الملفات الشخصية",
|
||||||
|
"defaultProfile": "الافتراضي"
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"title": "مشغل اللعب المجاني",
|
||||||
|
"playerName": "اسم اللاعب",
|
||||||
|
"playerNamePlaceholder": "أدخل اسمك",
|
||||||
|
"gameBranch": "إصدار اللعبة",
|
||||||
|
"releaseVersion": "إصدار نهائي (مستقر)",
|
||||||
|
"preReleaseVersion": "إصدار تجريبي (تجريبي)",
|
||||||
|
"customInstallation": "تثبيت مخصص",
|
||||||
|
"installationFolder": "مجلد التثبيت",
|
||||||
|
"pathPlaceholder": "الموقع الافتراضي",
|
||||||
|
"browse": "تصفح",
|
||||||
|
"installButton": "تثبيت HYTALE",
|
||||||
|
"installing": "جاري التثبيت..."
|
||||||
|
},
|
||||||
|
"play": {
|
||||||
|
"ready": "جاهز للعب",
|
||||||
|
"subtitle": "شغل Hytale وابدأ المغامرة",
|
||||||
|
"playButton": "لعب HYTALE",
|
||||||
|
"latestNews": "آخر الأخبار",
|
||||||
|
"viewAll": "عرض الكل",
|
||||||
|
"checking": "جاري التحقق...",
|
||||||
|
"play": "بدء"
|
||||||
|
},
|
||||||
|
"mods": {
|
||||||
|
"searchPlaceholder": "البحث عن مودات...",
|
||||||
|
"myMods": "موداتي",
|
||||||
|
"previous": "السابق",
|
||||||
|
"next": "التالي",
|
||||||
|
"page": "صفحة",
|
||||||
|
"of": "من",
|
||||||
|
"modalTitle": "موداتي",
|
||||||
|
"noModsFound": "لم يتم العثور على مودات",
|
||||||
|
"noModsFoundDesc": "حاول تعديل معايير البحث",
|
||||||
|
"noModsInstalled": "لا توجد مودات مثبتة",
|
||||||
|
"noModsInstalledDesc": "أضف مودات من CurseForge أو استورد ملفات محلية",
|
||||||
|
"view": "عرض",
|
||||||
|
"install": "تثبيت",
|
||||||
|
"installed": "مثبت",
|
||||||
|
"enable": "تفعيل",
|
||||||
|
"disable": "تعطيل",
|
||||||
|
"active": "نشط",
|
||||||
|
"disabled": "معطل",
|
||||||
|
"delete": "حذف المود",
|
||||||
|
"noDescription": "لا يوجد وصف متاح",
|
||||||
|
"confirmDelete": "هل أنت متأكد أنك تريد حذف \"{name}\"؟",
|
||||||
|
"confirmDeleteDesc": "لا يمكن التراجع عن هذا الإجراء.",
|
||||||
|
"confirmDeletion": "تأكيد الحذف",
|
||||||
|
"apiKeyRequired": "مطلوب مفتاح API",
|
||||||
|
"apiKeyRequiredDesc": "مطلوب مفتاح CurseForge API لتصفح المودات"
|
||||||
|
},
|
||||||
|
"news": {
|
||||||
|
"title": "كل الأخبار",
|
||||||
|
"readMore": "اقرأ المزيد"
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"title": "دردشة اللاعبين",
|
||||||
|
"pickColor": "اللون",
|
||||||
|
"inputPlaceholder": "اكتب رسالتك...",
|
||||||
|
"send": "إرسال",
|
||||||
|
"online": "متصل",
|
||||||
|
"charCounter": "{current}/{max}",
|
||||||
|
"secureChat": "دردشة آمنة - الروابط محجوبة",
|
||||||
|
"joinChat": "انضمام للدردشة",
|
||||||
|
"chooseUsername": "اختر اسم مستخدم للانضمام إلى دردشة اللاعبين",
|
||||||
|
"username": "اسم المستخدم",
|
||||||
|
"usernamePlaceholder": "أدخل اسم المستخدم...",
|
||||||
|
"usernameHint": "3-20 حرفاً، حروف، أرقام، و - و _ فقط",
|
||||||
|
"joinButton": "انضمام",
|
||||||
|
"colorModal": {
|
||||||
|
"title": "تخصيص لون اسم المستخدم",
|
||||||
|
"chooseSolid": "اختر لوناً ثابتاً:",
|
||||||
|
"customColor": "لون مخصص:",
|
||||||
|
"preview": "معاينة:",
|
||||||
|
"previewUsername": "اسم المستخدم",
|
||||||
|
"apply": "تطبيق اللون"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "الإعدادات",
|
||||||
|
"java": "بيئة تشغيل جافا",
|
||||||
|
"useCustomJava": "استخدام مسار جافا مخصص",
|
||||||
|
"javaDescription": "تجاوز بيئة جافا المرفقة واستخدام تثبيت خاص بك",
|
||||||
|
"javaPath": "مسار ملف جافا التنفيذي",
|
||||||
|
"javaPathPlaceholder": "اختر مسار جافا...",
|
||||||
|
"javaBrowse": "تصفح",
|
||||||
|
"javaHint": "اختر مجلد تثبيت جافا (يدعم ويندوز، ماك، ولينكس)",
|
||||||
|
"discord": "تكامل ديسكورد",
|
||||||
|
"enableRPC": "تفعيل نشاط ديسكورد (Rich Presence)",
|
||||||
|
"discordDescription": "إظهار نشاط المشغل الخاص بك على ديسكورد",
|
||||||
|
"game": "خيارات اللعبة",
|
||||||
|
"playerName": "اسم اللاعب",
|
||||||
|
"playerNamePlaceholder": "أدخل اسم اللاعب",
|
||||||
|
"playerNameHint": "سيتم استخدام هذا الاسم داخل اللعبة (1-16 حرفاً)",
|
||||||
|
"openGameLocation": "فتح موقع اللعبة",
|
||||||
|
"openGameLocationDesc": "فتح مجلد تثبيت اللعبة",
|
||||||
|
"account": "إدارة UUID اللاعب",
|
||||||
|
"currentUUID": "الـ UUID الحالي",
|
||||||
|
"uuidPlaceholder": "جاري تحميل UUID...",
|
||||||
|
"copyUUID": "نسخ UUID",
|
||||||
|
"regenerateUUID": "إعادة إنشاء UUID",
|
||||||
|
"uuidHint": "معرف اللاعب الفريد الخاص بك لهذا الاسم",
|
||||||
|
"manageUUIDs": "إدارة جميع الـ UUIDs",
|
||||||
|
"manageUUIDsDesc": "عرض وإدارة جميع معرفات اللاعبين",
|
||||||
|
"language": "اللغة",
|
||||||
|
"selectLanguage": "اختر اللغة",
|
||||||
|
"repairGame": "إصلاح اللعبة",
|
||||||
|
"reinstallGame": "إعادة تثبيت ملفات اللعبة (يحفظ البيانات)",
|
||||||
|
"gpuPreference": "تفضيل معالج الرسوميات (GPU)",
|
||||||
|
"gpuHint": "ميزة للمحمول فقط؛ اضبطها على Integrated إذا كنت تستخدم كمبيوتر مكتبي",
|
||||||
|
"gpuAuto": "تلقائي",
|
||||||
|
"gpuIntegrated": "مدمج",
|
||||||
|
"gpuDedicated": "منفصل",
|
||||||
|
"logs": "سجلات النظام",
|
||||||
|
"logsCopy": "نسخ",
|
||||||
|
"logsRefresh": "تحديث",
|
||||||
|
"logsFolder": "فتح المجلد",
|
||||||
|
"logsLoading": "جاري تحميل السجلات...",
|
||||||
|
"closeLauncher": "سلوك المشغل",
|
||||||
|
"closeOnStart": "إغلاق المشغل عند بدء اللعبة",
|
||||||
|
"closeOnStartDescription": "إغلاق المشغل تلقائياً بعد تشغيل Hytale",
|
||||||
|
"hwAccel": "تسريع الأجهزة (Hardware Acceleration)",
|
||||||
|
"hwAccelDescription": "تفعيل تسريع الأجهزة للمشغل",
|
||||||
|
"gameBranch": "فرع اللعبة",
|
||||||
|
"branchRelease": "إصدار نهائي",
|
||||||
|
"branchPreRelease": "إصدار تجريبي",
|
||||||
|
"branchHint": "التبديل بين الإصدار المستقر والإصدار التجريبي",
|
||||||
|
"branchWarning": "تغيير الفرع سيؤدي إلى تحميل وتثبيت نسخة مختلفة من اللعبة",
|
||||||
|
"branchSwitching": "جاري التبديل إلى {branch}...",
|
||||||
|
"branchSwitched": "تم التبديل إلى {branch} بنجاح!",
|
||||||
|
"installRequired": "التثبيت مطلوب",
|
||||||
|
"branchInstallConfirm": "سيتم تثبيت اللعبة لفرع {branch}. هل تريد الاستمرار؟"
|
||||||
|
},
|
||||||
|
"uuid": {
|
||||||
|
"modalTitle": "إدارة UUID",
|
||||||
|
"currentUserUUID": "UUID المستخدم الحالي",
|
||||||
|
"allPlayerUUIDs": "جميع معرفات UUID للاعبين",
|
||||||
|
"generateNew": "إنشاء UUID جديد",
|
||||||
|
"loadingUUIDs": "جاري تحميل الـ UUIDs...",
|
||||||
|
"setCustomUUID": "تعيين UUID مخصص",
|
||||||
|
"customPlaceholder": "أدخل UUID مخصص (الصيغة: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)",
|
||||||
|
"setUUID": "تعيين UUID",
|
||||||
|
"warning": "تحذير: تعيين UUID مخصص سيغير هوية اللاعب الحالية",
|
||||||
|
"copyTooltip": "نسخ UUID",
|
||||||
|
"regenerateTooltip": "إنشاء UUID جديد"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"modalTitle": "إدارة الملفات الشخصية",
|
||||||
|
"newProfilePlaceholder": "اسم الملف الشخصي الجديد",
|
||||||
|
"createProfile": "إنشاء ملف شخصي"
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"notificationText": "انضم إلى مجتمعنا على ديسكورد!",
|
||||||
|
"joinButton": "انضم إلى ديسكورد"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"confirm": "تأكيد",
|
||||||
|
"cancel": "إلغاء",
|
||||||
|
"save": "حفظ",
|
||||||
|
"close": "إغلاق",
|
||||||
|
"delete": "حذف",
|
||||||
|
"edit": "تعديل",
|
||||||
|
"loading": "جاري التحميل...",
|
||||||
|
"apply": "تطبيق",
|
||||||
|
"install": "تثبيت"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"gameDataNotFound": "خطأ: لم يتم العثور على بيانات اللعبة",
|
||||||
|
"gameUpdatedSuccess": "تم تحديث اللعبة بنجاح! 🎉",
|
||||||
|
"updateFailed": "فشل التحديث: {error}",
|
||||||
|
"updateError": "خطأ في التحديث: {error}",
|
||||||
|
"discordEnabled": "تم تفعيل نشاط ديسكورد",
|
||||||
|
"discordDisabled": "تم تعطيل نشاط ديسكورد",
|
||||||
|
"discordSaveFailed": "فشل حفظ إعدادات ديسكورد",
|
||||||
|
"playerNameRequired": "يرجى إدخال اسم لاعب صالح",
|
||||||
|
"playerNameSaved": "تم حفظ اسم اللاعب بنجاح",
|
||||||
|
"playerNameSaveFailed": "فشل حفظ اسم اللاعب",
|
||||||
|
"uuidCopied": "تم نسخ الـ UUID إلى الحافظة!",
|
||||||
|
"uuidCopyFailed": "فشل نسخ الـ UUID",
|
||||||
|
"uuidRegenNotAvailable": "إعادة إنشاء UUID غير متاحة",
|
||||||
|
"uuidRegenFailed": "فشل إعادة إنشاء الـ UUID",
|
||||||
|
"uuidGenerated": "تم إنشاء UUID جديد بنجاح!",
|
||||||
|
"uuidGeneratedShort": "تم إنشاء UUID جديد!",
|
||||||
|
"uuidGenerateFailed": "فشل إنشاء UUID جديد",
|
||||||
|
"uuidRequired": "يرجى إدخال UUID",
|
||||||
|
"uuidInvalidFormat": "صيغة UUID غير صالحة",
|
||||||
|
"uuidSetFailed": "فشل تعيين الـ UUID المخصص",
|
||||||
|
"uuidSetSuccess": "تم تعيين الـ UUID المخصص بنجاح!",
|
||||||
|
"uuidDeleteFailed": "فشل حذف الـ UUID",
|
||||||
|
"uuidDeleteSuccess": "تم حذف الـ UUID بنجاح!",
|
||||||
|
"modsDownloading": "جاري تحميل {name}...",
|
||||||
|
"modsTogglingMod": "جاري تبديل حالة المود...",
|
||||||
|
"modsDeletingMod": "جاري حذف المود...",
|
||||||
|
"modsLoadingMods": "جاري تحميل المودات من CurseForge...",
|
||||||
|
"modsInstalledSuccess": "تم تثبيت {name} بنجاح! 🎉",
|
||||||
|
"modsDeletedSuccess": "تم حذف {name} بنجاح",
|
||||||
|
"modsDownloadFailed": "فشل تحميل المود: {error}",
|
||||||
|
"modsToggleFailed": "فشل تبديل المود: {error}",
|
||||||
|
"modsDeleteFailed": "فشل حذف المود: {error}",
|
||||||
|
"modsModNotFound": "لم يتم العثور على معلومات المود",
|
||||||
|
"hwAccelSaved": "تم حفظ إعداد تسريع الأجهزة",
|
||||||
|
"hwAccelSaveFailed": "فشل حفظ إعداد تسريع الأجهزة",
|
||||||
|
"noUsername": "لم يتم تهيئة اسم مستخدم. يرجى حفظ اسم المستخدم أولاً.",
|
||||||
|
"switchUsernameSuccess": "تم التبديل إلى المستخدم \"{username}\" بنجاح!",
|
||||||
|
"switchUsernameFailed": "فشل تبديل اسم المستخدم",
|
||||||
|
"playerNameTooLong": "يجب أن يكون اسم اللاعب 16 حرفاً أو أقل"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"defaultTitle": "تأكيد الإجراء",
|
||||||
|
"regenerateUuidTitle": "إنشاء UUID جديد",
|
||||||
|
"regenerateUuidMessage": "هل أنت متأكد أنك تريد إنشاء UUID جديد؟ سيؤدي ذلك إلى تغيير هوية اللاعب الخاصة بك.",
|
||||||
|
"regenerateUuidButton": "إنشاء",
|
||||||
|
"setCustomUuidTitle": "تعيين UUID مخصص",
|
||||||
|
"setCustomUuidMessage": "هل أنت متأكد أنك تريد تعيين هذا الـ UUID المخصص؟ سيؤدي ذلك إلى تغيير هوية اللاعب الخاصة بك.",
|
||||||
|
"setCustomUuidButton": "تعيين UUID",
|
||||||
|
"deleteUuidTitle": "حذف UUID",
|
||||||
|
"deleteUuidMessage": "هل أنت متأكد أنك تريد حذف الـ UUID الخاص بـ \"{username}\"؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||||
|
"deleteUuidButton": "حذف",
|
||||||
|
"uninstallGameTitle": "إلغاء تثبيت اللعبة",
|
||||||
|
"uninstallGameMessage": "هل أنت متأكد أنك تريد إلغاء تثبيت Hytale؟ سيتم حذف جميع ملفات اللعبة.",
|
||||||
|
"uninstallGameButton": "إلغاء التثبيت",
|
||||||
|
"switchUsernameTitle": "تبديل الهوية",
|
||||||
|
"switchUsernameMessage": "التبديل إلى اسم المستخدم \"{username}\"؟ سيؤدي هذا إلى تغيير هوية اللاعب الحالية.",
|
||||||
|
"switchUsernameButton": "تبديل"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"initializing": "جاري التهيئة...",
|
||||||
|
"downloading": "جاري التحميل...",
|
||||||
|
"installing": "جاري التثبيت...",
|
||||||
|
"extracting": "جاري الاستخراج...",
|
||||||
|
"verifying": "جاري التحقق...",
|
||||||
|
"switchingProfile": "جاري تبديل الملف الشخصي...",
|
||||||
|
"profileSwitched": "تم تبديل الملف الشخصي!",
|
||||||
|
"startingGame": "جاري بدء اللعبة...",
|
||||||
|
"launching": "جاري التشغيل...",
|
||||||
|
"uninstallingGame": "جاري إلغاء تثبيت اللعبة...",
|
||||||
|
"gameUninstalled": "تم إلغاء تثبيت اللعبة بنجاح!",
|
||||||
|
"uninstallFailed": "فشل إلغاء التثبيت: {error}",
|
||||||
|
"startingUpdate": "جاري بدء تحديث اللعبة الإجباري...",
|
||||||
|
"installationComplete": "تم اكتمال التثبيت بنجاح!",
|
||||||
|
"installationFailed": "فشل التثبيت: {error}",
|
||||||
|
"installingGameFiles": "جاري تثبيت ملفات اللعبة...",
|
||||||
|
"installComplete": "اكتمل التثبيت!"
|
||||||
|
}
|
||||||
|
}
|
||||||
209
GUI/style-RTL.css
Normal file
209
GUI/style-RTL.css
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
body.rtl {
|
||||||
|
direction: rtl;
|
||||||
|
font-family: 'Noto Sans Arabic', 'Space Grotesk', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .sidebar {
|
||||||
|
right: 0;
|
||||||
|
left: auto;
|
||||||
|
border-right: none;
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .nav-item.active::before {
|
||||||
|
right: -8px;
|
||||||
|
left: auto;
|
||||||
|
border-radius: 4px 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .nav-tooltip {
|
||||||
|
right: 100%;
|
||||||
|
left: auto;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .nav-item:hover .nav-tooltip {
|
||||||
|
transform: translateX(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
body.rtl .main-content {
|
||||||
|
margin-right: 80px;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header Layout*/
|
||||||
|
|
||||||
|
body.rtl .players-counter {
|
||||||
|
order: 2;
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .profile-selector {
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .window-controls {
|
||||||
|
order: 3;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .profile-dropdown {
|
||||||
|
right: auto;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
body.rtl .form-group {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .radio-label,
|
||||||
|
body.rtl .checkbox-group {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .form-input {
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
body.rtl .mods-pagination {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .pagination-btn:first-child i {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .pagination-btn:last-child i {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* UUID Display */
|
||||||
|
|
||||||
|
body.rtl .uuid-display-container {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .uuid-btn {
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .uuid-input {
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
body.rtl .segmented-control {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mod Grid Layout */
|
||||||
|
body.rtl .mods-search {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .mods-search-container {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .mods-actions {
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .mod-card {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .installed-mod-card {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .installed-mod-card .mod-info {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .mods-header {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .news-section .news-header {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings Layout */
|
||||||
|
|
||||||
|
body.rtl .settings-option {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .settings-input-group {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .settings-input {
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .settings-section-title i {
|
||||||
|
margin-right: 0;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .settings-hint i {
|
||||||
|
margin-right: 0;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .custom-options,
|
||||||
|
body.rtl .custom-java-options {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .checkbox-content {
|
||||||
|
margin-right: 2rem;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .btn-content {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icons & Transformations */
|
||||||
|
|
||||||
|
body.rtl .news-title i {
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
body.rtl .uuid-modal-title i {
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
body.rtl .mods-modal-title i {
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .view-all-btn i,
|
||||||
|
body.rtl .sidebar-nav div i,
|
||||||
|
body.rtl .logs-header i,
|
||||||
|
body.rtl .home-play-button i {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .play-title i {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
body.rtl .logs-terminal {
|
||||||
|
direction: ltr;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rtl .version-display-bottom {
|
||||||
|
right: auto;
|
||||||
|
left: 1rem;
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
### ⚠️ **WARNING: READ [QUICK START](#-quick-start) before Downloading & Installing the Launcher!** ⚠️
|
### ⚠️ **WARNING: READ [QUICK START](#-quick-start) before Downloading & Installing the Launcher!** ⚠️
|
||||||
|
|
||||||
#### 🛑 **Found a problem? [Join the HF2P Discord](https://discord.gg/hf2pdc) and head to `#-⚠️-community-help`** 🛑
|
#### 🛑 **Found a problem? [Join the HF2P Discord](https://discord.gg/Fhbb9Yk5WW) and head to `#-⚠️-community-help`** 🛑
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
👍 If you like the project, <b>feel free to support us via Buy Me a Coffee!</b> ☕<br>
|
👍 If you like the project, <b>feel free to support us via Buy Me a Coffee!</b> ☕<br>
|
||||||
@@ -455,7 +455,7 @@ See [BUILD.md](docs/BUILD.md) for comprehensive build instructions.
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
**Questions? Ads? Collaboration? Endorsement? Other business-related?**
|
**Questions? Ads? Collaboration? Endorsement? Other business-related?**
|
||||||
Message the founders at https://discord.gg/hf2pdc
|
Message the founders at https://discord.gg/Fhbb9Yk5WW
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
128
SERVER.md
128
SERVER.md
@@ -2,19 +2,20 @@
|
|||||||
|
|
||||||
Play with friends online! This guide covers both easy in-game hosting and advanced dedicated server setup.
|
Play with friends online! This guide covers both easy in-game hosting and advanced dedicated server setup.
|
||||||
|
|
||||||
### **DOWNLOAD SERVER FILES (JAR/RAR/SCRIPTS) HERE: https://discord.gg/hf2pdc**
|
### **DOWNLOAD SERVER FILES (JAR/RAR/SCRIPTS) HERE: https://discord.gg/Fhbb9Yk5WW**
|
||||||
|
|
||||||
**Table of Contents**
|
**Table of Contents**
|
||||||
|
|
||||||
|
* [\[NEW!\] Play Online with Official Accounts 🆕](#new-play-online-with-official-accounts-)
|
||||||
* ["Server" Term and Definition](#server-term-and-definiton)
|
* ["Server" Term and Definition](#server-term-and-definiton)
|
||||||
* [Server Directory Location](#server-directory-location)
|
* [Server Directory Location](#server-directory-location)
|
||||||
* [A. Online Play Feature](#a-online-play-feature)
|
* [A. Host Your Singleplayer World](#a-host-your-singleplayer-world)
|
||||||
* [1. Host Your Singleplayer World using In-Game Invite Code](#1-host-your-singleplayer-world-using-in-game-invite-code)
|
* [1. Using Online-Play Feature In-Game Invite Code](#1-using-online-play-feature--in-game-invite-code)
|
||||||
* [Common Issues (UPnP/NAT/STUN) on Online Play](#common-issues-upnpnatstun-on-online-play)
|
* [Common Issues (UPnP/NAT/STUN) on Online Play](#common-issues-upnpnatstun-on-online-play)
|
||||||
* [2. Host Your Singleplayer World using Tailscale](#2-host-your-singleplayer-world-using-tailscale)
|
* [2. Using Tailscale](#2-using-tailscale)
|
||||||
|
* [3. Using Radmin VPN](#3-using-radmin-vpn)
|
||||||
* [B. Local Dedicated Server](#b-local-dedicated-server)
|
* [B. Local Dedicated Server](#b-local-dedicated-server)
|
||||||
* [1. Using Playit.gg (Recommended) ✅](#1-using-playitgg-recommended-)
|
* [1. Using Playit.gg (Recommended) ✅](#1-using-playitgg-recommended-)
|
||||||
* [2. Using Radmin VPN](#2-using-radmin-vpn)
|
|
||||||
* [C. 24/7 Dedicated Server (Advanced)](#c-247-dedicated-server-advanced)
|
* [C. 24/7 Dedicated Server (Advanced)](#c-247-dedicated-server-advanced)
|
||||||
* [Step 1: Get the Files Ready](#step-1-get-the-files-ready)
|
* [Step 1: Get the Files Ready](#step-1-get-the-files-ready)
|
||||||
* [Step 2: Place HytaleServer.jar in the Server directory](#step-2-place-hytaleserverjar-in-the-server-directory)
|
* [Step 2: Place HytaleServer.jar in the Server directory](#step-2-place-hytaleserverjar-in-the-server-directory)
|
||||||
@@ -32,6 +33,69 @@ Play with friends online! This guide covers both easy in-game hosting and advanc
|
|||||||
* [10. Getting Help](#10-getting-help)
|
* [10. Getting Help](#10-getting-help)
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<div align='center'>
|
||||||
|
<h3>
|
||||||
|
<b>
|
||||||
|
Do you want to create Hytale Game Server with EASY SETUP, AFFORDABLE PRICE, AND 24/7 SUPPORT?
|
||||||
|
</b>
|
||||||
|
</h3>
|
||||||
|
<h2>
|
||||||
|
<b>
|
||||||
|
<a href="https://cloudnord.net/hytale-server-hosting">CLOUDNORD</a> is the ANSWER! HF2P Server is available!
|
||||||
|
</b>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
**CloudNord's Hytale, Minecraft, and Game Hosting** is at the core of our Server Hosting business. Join our Gaming community and experience our large choice of premium game servers, we’ve got you covered with super high-performance hardware, fantastic support options, and powerful server hosting to build and explore your worlds without limits!
|
||||||
|
|
||||||
|
**Order your Hytale, Minecraft, or other game servers today!**
|
||||||
|
Choose Java Edition, Bedrock Edition, Cross-Play, or any of our additional supported games.
|
||||||
|
Enjoy **20% OFF** all new game servers, **available now for a limited time!** Don’t miss out.
|
||||||
|
|
||||||
|
### **CloudNord key hosting features include:**
|
||||||
|
- Instant Server Setup ⚡
|
||||||
|
- High Performance Game Servers 🚀
|
||||||
|
- Game DDoS Protection 🛡️
|
||||||
|
- Intelligent Game Backups 🧠
|
||||||
|
- Quick Modpack Installer 🔧
|
||||||
|
- Quick Plugin & Mod Installer 🧰
|
||||||
|
- Full File Access 🗃️
|
||||||
|
- 24/7 Support 📞 🏪
|
||||||
|
- Powerful Game Control Server Panel 💪
|
||||||
|
|
||||||
|
### **Check Us Out:**
|
||||||
|
* 👉 CloudNord Website: https://cloudnord.net/hytalef2p
|
||||||
|
* 👉 CloudNord Discord: https://discord.gg/TYxGrmUz4Y
|
||||||
|
* 👉 CloudNord Reviews: https://www.trustpilot.com/review/cloudnord.net?page=2&stars=5
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [NEW!] Play Online with Official Accounts 🆕
|
||||||
|
|
||||||
|
**Documentations:**
|
||||||
|
* [Hytale-Server-Docker by Sanasol](https://github.com/sanasol/hytale-server-docker/tree/main?tab=readme-ov-file#dual-authentication)
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
* Using the patched HytaleServer.jar
|
||||||
|
* Has Official Account with Purchased status on Official Hytale Website.
|
||||||
|
* This official account holder can be the server hoster or one of the players.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Running the patched HytaleServer.jar with either [B. Local Dedicated Server](#b-local-dedicated-server) or [C. 24/7 Dedicated Server (Advanced)](#c-247-dedicated-server-advanced) successfully.
|
||||||
|
2. On the server's console/terminal/CMD, server admin **MUST RUN THIS EACH BOOT** to allow players with Official Hytale game license to connect on the server:
|
||||||
|
```
|
||||||
|
/auth logout
|
||||||
|
/auth persistence Encrypted
|
||||||
|
/auth login device
|
||||||
|
```
|
||||||
|
3. Server console will show instructions, an URL and a code; these will be revoked after 10 minutes if not authorized.
|
||||||
|
4. The server hoster can open the URL directly to browser by holding Ctrl then Click on it, or copy and send it to the player with official account.
|
||||||
|
5. Once it authorized, the official accounts can join server with F2P players.
|
||||||
|
6. If you want to modify anything, look at the [Hytale-Server-Docker](https://github.com/sanasol/hytale-server-docker/) above, give the repo a STAR too.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### "Server" Term and Definiton
|
### "Server" Term and Definiton
|
||||||
|
|
||||||
"HytaleServer.jar", which called as "Server", functions as the place of authentication of the client that supposed to go to Hytale Official Authentication System but we managed our way to redirect it on our service (Thanks to Sanasol), handling approximately thousands of players worldwide to play this game for free.
|
"HytaleServer.jar", which called as "Server", functions as the place of authentication of the client that supposed to go to Hytale Official Authentication System but we managed our way to redirect it on our service (Thanks to Sanasol), handling approximately thousands of players worldwide to play this game for free.
|
||||||
@@ -41,14 +105,15 @@ Kindly support us via [our Buy Me a Coffee link](https://buymeacoffee.com/hf2p)
|
|||||||
|
|
||||||
### Server Directory Location
|
### Server Directory Location
|
||||||
|
|
||||||
Here are the directory locations of Server folder if you have installed
|
Here are the directory locations of Server folder if you have installed it on default instalation location:
|
||||||
- **Windows:** `%localappdata%\HytaleF2P\release\package\game\latest\Server`
|
- **Windows:** `%localappdata%\HytaleF2P\release\package\game\latest\Server`
|
||||||
- **macOS:** `~/Library/Application Support/HytaleF2P/release/package/game/latest/Server`
|
- **macOS:** `~/Library/Application Support/HytaleF2P/release/package/game/latest/Server`
|
||||||
- **Linux:** `~/.hytalef2p/release/package/game/latest/Server`
|
- **Linux:** `~/.hytalef2p/release/package/game/latest/Server`
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> This location only exists if the user installed the game using our launcher. The `Server` folder needed to auth the HytaleClient to play Hytale online
|
> This location only exists if the user installed the game using our launcher.
|
||||||
> (for now; we planned to add offline mode in later version of our launcher).
|
> The `Server` folder needed to auth the HytaleClient to play Hytale in Singleplayer/Multiplayer for now.
|
||||||
|
> (We planned to add offline mode in later version of our launcher).
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> Hosting a dedicated Hytale server will not need the exact similar tree. You can put it anywhere, as long as the directory has `Assets.zip` which
|
> Hosting a dedicated Hytale server will not need the exact similar tree. You can put it anywhere, as long as the directory has `Assets.zip` which
|
||||||
@@ -64,6 +129,7 @@ Terms and conditions applies.
|
|||||||
## 1. Using Online-Play Feature / In-Game Invite Code
|
## 1. Using Online-Play Feature / In-Game Invite Code
|
||||||
|
|
||||||
The easiest way to play with friends - no manual server setup required!
|
The easiest way to play with friends - no manual server setup required!
|
||||||
|
|
||||||
*The game automatically handles networking using UPnP/STUN/NAT traversal.*
|
*The game automatically handles networking using UPnP/STUN/NAT traversal.*
|
||||||
|
|
||||||
**For Online Play to work, you need:**
|
**For Online Play to work, you need:**
|
||||||
@@ -112,6 +178,7 @@ Warning: Your network configuration may prevent other players from connecting.
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details><summary><b>b. "UPnP Failed" or "Port Mapping Failed" Warning</b></summary>
|
<details><summary><b>b. "UPnP Failed" or "Port Mapping Failed" Warning</b></summary>
|
||||||
|
|
||||||
**Check your router:**
|
**Check your router:**
|
||||||
1. Log into router admin panel (usually `192.168.1.1` or `192.168.0.1`)
|
1. Log into router admin panel (usually `192.168.1.1` or `192.168.0.1`)
|
||||||
2. Find UPnP settings (often under "Advanced" or "NAT")
|
2. Find UPnP settings (often under "Advanced" or "NAT")
|
||||||
@@ -123,7 +190,8 @@ Warning: Your network configuration may prevent other players from connecting.
|
|||||||
- See "Port Forwarding" or "Workarounds or NAT/CGNAT" sections below
|
- See "Port Forwarding" or "Workarounds or NAT/CGNAT" sections below
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details><summary><b>c. "Strict NAT" or "Symmetric NAT" Warning</b></summary>
|
<details><summary><b>c. "Connected via STUN", "Strict NAT" or "Symmetric NAT" Warning</b></summary>
|
||||||
|
|
||||||
Some routers have restrictive NAT that blocks peer connections.
|
Some routers have restrictive NAT that blocks peer connections.
|
||||||
|
|
||||||
**Try:**
|
**Try:**
|
||||||
@@ -133,6 +201,7 @@ Some routers have restrictive NAT that blocks peer connections.
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
## 2. Using Tailscale
|
## 2. Using Tailscale
|
||||||
|
|
||||||
Tailscale creates mesh VPN service that streamlines connecting devices and services securely across different networks. And **works crossplatform!!**
|
Tailscale creates mesh VPN service that streamlines connecting devices and services securely across different networks. And **works crossplatform!!**
|
||||||
|
|
||||||
1. All members are required to download [Tailscale](https://tailscale.com/download) on your device.
|
1. All members are required to download [Tailscale](https://tailscale.com/download) on your device.
|
||||||
@@ -148,6 +217,17 @@ Tailscale creates mesh VPN service that streamlines connecting devices and servi
|
|||||||
* Use the new share code to connect
|
* Use the new share code to connect
|
||||||
* To test your connection, ping the host's ipv4 mentioned in Tailscale
|
* To test your connection, ping the host's ipv4 mentioned in Tailscale
|
||||||
|
|
||||||
|
## 3. Using Radmin VPN
|
||||||
|
|
||||||
|
Creates a virtual LAN - all players need to install it:
|
||||||
|
|
||||||
|
1. Download [Radmin VPN](https://www.radmin-vpn.com/) - All players install it
|
||||||
|
2. One person create a room/network, others join with network name/password
|
||||||
|
3. Host joined the world, others will connect to it.
|
||||||
|
4. Open Hytale Game > Servers > Add Servers > Direct Connect > Type IP Address of the Host from Radmin.
|
||||||
|
|
||||||
|
These options bypass all NAT/CGNAT issues. But for **Windows machines only!**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# B. Local Dedicated Server
|
# B. Local Dedicated Server
|
||||||
@@ -167,11 +247,12 @@ Free tunneling service - only the host needs to install it:
|
|||||||
* Right-click file > Properties > Turn on 'Executable as a Program' | or `chmod +x playit-linux-amd64` on terminal
|
* Right-click file > Properties > Turn on 'Executable as a Program' | or `chmod +x playit-linux-amd64` on terminal
|
||||||
* Run by double-clicking the file or `./playit-linux-amd64` via terminal
|
* Run by double-clicking the file or `./playit-linux-amd64` via terminal
|
||||||
5. Open the URL/link by `Ctrl+Click` it. If unable, select the URL, then Right-Click to Copy (`Ctrl+Shift+C` for Linux) then Paste the URL into your browser to link it with your created account.
|
5. Open the URL/link by `Ctrl+Click` it. If unable, select the URL, then Right-Click to Copy (`Ctrl+Shift+C` for Linux) then Paste the URL into your browser to link it with your created account.
|
||||||
6. **WARNING: Do not close the terminal if you are still playing or hosting the server**
|
6. Once it done, download the `run_server_with_tokens (1)` script file (`.BAT` for Windows, `.SH` for Linux) from our Discord server > channel `#open-public-server`
|
||||||
7. Once it done, download the `run_server_with_tokens` script file (`.BAT` for Windows, `.SH` for Linux) from our Discord server > channel `#open-public-server`
|
7. Put the script file to the `Server` folder in `HytaleF2P` directory (`%localappdata%\HytaleF2P\release\package\game\latest\Server`)
|
||||||
8. Put the script file to the `Server` folder in `HytaleF2P` directory (`%localappdata%\HytaleF2P\release\package\game\latest\Server`)
|
8. Rename the script file to `run_server_with_tokens` to make it easier if you run it with Terminal, then do Method A or B.
|
||||||
9. Copy the `Assets.zip` from the `%localappdata%\HytaleF2P\release\package\game\latest\` folder to the `Server\` folder. (TIP: You can use Symlink of that file to reduce disk usage!)
|
9. If you put it in `Server` folder in `HytaleF2P` launcher, change `ASSETS_PATH="${ASSETS_PATH:-./Assets.zip}"` inside the script to be `ASSETS_PATH="${ASSETS_PATH:-../Assets.zip}"`. NOTICE THE `./` and `../` DIFFERENCE.
|
||||||
10. Double-click the .BAT file to host your server, wait until it shows:
|
10. Copy the `Assets.zip` from the `%localappdata%\HytaleF2P\release\package\game\latest\` folder to the `Server\` folder. (TIP: You can use Symlink of that file to reduce disk usage!)
|
||||||
|
11. Double-click the .BAT file to host your server, wait until it shows:
|
||||||
```
|
```
|
||||||
===================================================
|
===================================================
|
||||||
Hytale Server Booted! [Multiplayer, Fresh Universe]
|
Hytale Server Booted! [Multiplayer, Fresh Universe]
|
||||||
@@ -180,16 +261,12 @@ Hytale Server Booted! [Multiplayer, Fresh Universe]
|
|||||||
11. Connect to the server by go to `Servers` in your game client, press `Add Server`, type `localhost` in the address box, use any name for your server.
|
11. Connect to the server by go to `Servers` in your game client, press `Add Server`, type `localhost` in the address box, use any name for your server.
|
||||||
12. Send the public address in Step 3 to your friends.
|
12. Send the public address in Step 3 to your friends.
|
||||||
|
|
||||||
## 2. Using Radmin VPN
|
> [!CAUTION]
|
||||||
|
> Do not close the Playit.gg Terminal OR HytaleServer Terminal if you are still playing or hosting the server.
|
||||||
|
|
||||||
Creates a virtual LAN - all players need to install it:
|
## 2. Using Tailscale [DRAFT]
|
||||||
|
|
||||||
1. Download [Radmin VPN](https://www.radmin-vpn.com/) - All players install it
|
Tailscale
|
||||||
2. One person create a room/network, others join with network name/password
|
|
||||||
3. Host joined the world, others will connect to it.
|
|
||||||
4. Open Hytale Game > Servers > Add Servers > Direct Connect > Type IP Address of the Host from Radmin.
|
|
||||||
|
|
||||||
These options bypass all NAT/CGNAT issues. But for **Windows machines only!**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -228,12 +305,12 @@ For 24/7 servers, custom configurations, or hosting on a VPS/dedicated machine.
|
|||||||
|
|
||||||
**Windows:**
|
**Windows:**
|
||||||
```batch
|
```batch
|
||||||
run_server.bat
|
run_server_with_token.bat
|
||||||
```
|
```
|
||||||
|
|
||||||
**macOS / Linux:**
|
**macOS / Linux:**
|
||||||
```bash
|
```bash
|
||||||
./run_server.sh
|
./run_server_with_token.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -503,3 +580,6 @@ See [Docker documentation](https://github.com/Hybrowse/hytale-server-docker) for
|
|||||||
- Auth Server: sanasol.ws
|
- Auth Server: sanasol.ws
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Hytale F2P Launcher - Troubleshooting Guide
|
# Hytale F2P Launcher - Troubleshooting Guide
|
||||||
|
|
||||||
This guide covers common issues and their solutions. If your issue isn't listed here, please check [existing issues](https://github.com/amiayweb/Hytale-F2P/issues) or join our [Discord](https://discord.gg/gME8rUy3MB).
|
This guide covers common issues and their solutions. If your issue isn't listed here, please check [existing issues](https://github.com/amiayweb/Hytale-F2P/issues) or join our [Discord](https://discord.gg/Fhbb9Yk5WW).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -437,7 +437,7 @@ Game sessions have a 10-hour TTL. This is by design for security.
|
|||||||
If your issue isn't resolved by this guide:
|
If your issue isn't resolved by this guide:
|
||||||
|
|
||||||
1. **Check existing issues:** [GitHub Issues](https://github.com/amiayweb/Hytale-F2P/issues)
|
1. **Check existing issues:** [GitHub Issues](https://github.com/amiayweb/Hytale-F2P/issues)
|
||||||
2. **Join Discord:** [discord.gg/gME8rUy3MB](https://discord.gg/gME8rUy3MB)
|
2. **Join Discord:** [discord.gg/Fhbb9Yk5WW](https://discord.gg/Fhbb9Yk5WW)
|
||||||
3. **Open a new issue** with:
|
3. **Open a new issue** with:
|
||||||
- Your operating system and version
|
- Your operating system and version
|
||||||
- Launcher version
|
- Launcher version
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ const logger = require('./logger');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
const FORGEJO_API = 'https://git.sanhost.net/api/v1';
|
||||||
|
const FORGEJO_REPO = 'sanasol/hytale-f2p';
|
||||||
|
|
||||||
class AppUpdater {
|
class AppUpdater {
|
||||||
constructor(mainWindow) {
|
constructor(mainWindow) {
|
||||||
@@ -14,6 +18,34 @@ class AppUpdater {
|
|||||||
this.setupAutoUpdater();
|
this.setupAutoUpdater();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the latest non-draft release tag from Forgejo and set the feed URL
|
||||||
|
*/
|
||||||
|
async _resolveUpdateUrl() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
https.get(`${FORGEJO_API}/repos/${FORGEJO_REPO}/releases?limit=5`, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => data += chunk);
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const releases = JSON.parse(data);
|
||||||
|
const latest = releases.find(r => !r.draft && !r.prerelease);
|
||||||
|
if (latest) {
|
||||||
|
const url = `https://git.sanhost.net/${FORGEJO_REPO}/releases/download/${latest.tag_name}`;
|
||||||
|
console.log(`Auto-update URL resolved to: ${url}`);
|
||||||
|
autoUpdater.setFeedURL({ provider: 'generic', url });
|
||||||
|
resolve(url);
|
||||||
|
} else {
|
||||||
|
reject(new Error('No published release found'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setupAutoUpdater() {
|
setupAutoUpdater() {
|
||||||
|
|
||||||
// Configure logger for electron-updater
|
// Configure logger for electron-updater
|
||||||
@@ -216,8 +248,10 @@ class AppUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
checkForUpdatesAndNotify() {
|
checkForUpdatesAndNotify() {
|
||||||
// Check for updates and notify if available
|
// Resolve latest release URL then check for updates
|
||||||
autoUpdater.checkForUpdatesAndNotify().catch(err => {
|
this._resolveUpdateUrl().catch(err => {
|
||||||
|
console.warn('Failed to resolve update URL:', err.message);
|
||||||
|
}).then(() => autoUpdater.checkForUpdatesAndNotify()).catch(err => {
|
||||||
console.error('Failed to check for updates:', err);
|
console.error('Failed to check for updates:', err);
|
||||||
|
|
||||||
// Network errors are not critical - just log and continue
|
// Network errors are not critical - just log and continue
|
||||||
@@ -245,8 +279,10 @@ class AppUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
checkForUpdates() {
|
checkForUpdates() {
|
||||||
// Manual check for updates (returns promise)
|
// Manual check - resolve latest release URL first
|
||||||
return autoUpdater.checkForUpdates().catch(err => {
|
return this._resolveUpdateUrl().catch(err => {
|
||||||
|
console.warn('Failed to resolve update URL:', err.message);
|
||||||
|
}).then(() => autoUpdater.checkForUpdates()).catch(err => {
|
||||||
console.error('Failed to check for updates:', err);
|
console.error('Failed to check for updates:', err);
|
||||||
|
|
||||||
// Network errors are not critical - just return no update available
|
// Network errors are not critical - just return no update available
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ function getAppDir() {
|
|||||||
const CONFIG_FILE = path.join(getAppDir(), 'config.json');
|
const CONFIG_FILE = path.join(getAppDir(), 'config.json');
|
||||||
const CONFIG_BACKUP = path.join(getAppDir(), 'config.json.bak');
|
const CONFIG_BACKUP = path.join(getAppDir(), 'config.json.bak');
|
||||||
const CONFIG_TEMP = path.join(getAppDir(), 'config.json.tmp');
|
const CONFIG_TEMP = path.join(getAppDir(), 'config.json.tmp');
|
||||||
|
const UUID_STORE_FILE = path.join(getAppDir(), 'uuid-store.json');
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// CONFIG VALIDATION
|
// CONFIG VALIDATION
|
||||||
@@ -152,6 +153,22 @@ function saveConfig(update) {
|
|||||||
|
|
||||||
// Load current config
|
// Load current config
|
||||||
const currentConfig = loadConfig();
|
const currentConfig = loadConfig();
|
||||||
|
|
||||||
|
// SAFETY: If config file exists on disk but loadConfig() returned empty,
|
||||||
|
// something is wrong (file locked, corrupted, etc.). Refuse to save
|
||||||
|
// because merging with {} would wipe all existing data (userUuids, username, etc.)
|
||||||
|
if (Object.keys(currentConfig).length === 0 && fs.existsSync(CONFIG_FILE)) {
|
||||||
|
const fileSize = fs.statSync(CONFIG_FILE).size;
|
||||||
|
if (fileSize > 2) { // More than just "{}"
|
||||||
|
console.error(`[Config] REFUSING to save — loaded empty but file exists (${fileSize} bytes). Retrying load...`);
|
||||||
|
// Wait and retry the load
|
||||||
|
const delay = attempt * 200;
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < delay) { /* busy wait */ }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const newConfig = { ...currentConfig, ...update };
|
const newConfig = { ...currentConfig, ...update };
|
||||||
const data = JSON.stringify(newConfig, null, 2);
|
const data = JSON.stringify(newConfig, null, 2);
|
||||||
|
|
||||||
@@ -238,11 +255,18 @@ function saveUsername(username) {
|
|||||||
// Check if we're actually changing the username (case-insensitive comparison)
|
// Check if we're actually changing the username (case-insensitive comparison)
|
||||||
const isRename = currentName && currentName.toLowerCase() !== newName.toLowerCase();
|
const isRename = currentName && currentName.toLowerCase() !== newName.toLowerCase();
|
||||||
|
|
||||||
|
// Also update UUID store (source of truth)
|
||||||
|
migrateUuidStoreIfNeeded();
|
||||||
|
const uuidStore = loadUuidStore();
|
||||||
|
|
||||||
if (isRename) {
|
if (isRename) {
|
||||||
// Find the UUID for the current username
|
// Find the UUID for the current username
|
||||||
const currentKey = Object.keys(userUuids).find(
|
const currentKey = Object.keys(userUuids).find(
|
||||||
k => k.toLowerCase() === currentName.toLowerCase()
|
k => k.toLowerCase() === currentName.toLowerCase()
|
||||||
);
|
);
|
||||||
|
const currentStoreKey = Object.keys(uuidStore).find(
|
||||||
|
k => k.toLowerCase() === currentName.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
if (currentKey && userUuids[currentKey]) {
|
if (currentKey && userUuids[currentKey]) {
|
||||||
// Check if target username already exists (would be a different identity)
|
// Check if target username already exists (would be a different identity)
|
||||||
@@ -258,6 +282,9 @@ function saveUsername(username) {
|
|||||||
const uuid = userUuids[currentKey];
|
const uuid = userUuids[currentKey];
|
||||||
delete userUuids[currentKey];
|
delete userUuids[currentKey];
|
||||||
userUuids[newName] = uuid;
|
userUuids[newName] = uuid;
|
||||||
|
// Same in UUID store
|
||||||
|
if (currentStoreKey) delete uuidStore[currentStoreKey];
|
||||||
|
uuidStore[newName] = uuid;
|
||||||
console.log(`[Config] Renamed identity: "${currentKey}" → "${newName}" (UUID preserved: ${uuid})`);
|
console.log(`[Config] Renamed identity: "${currentKey}" → "${newName}" (UUID preserved: ${uuid})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,11 +297,20 @@ function saveUsername(username) {
|
|||||||
const uuid = userUuids[currentKey];
|
const uuid = userUuids[currentKey];
|
||||||
delete userUuids[currentKey];
|
delete userUuids[currentKey];
|
||||||
userUuids[newName] = uuid;
|
userUuids[newName] = uuid;
|
||||||
|
// Same in UUID store
|
||||||
|
const storeKey = Object.keys(uuidStore).find(k => k.toLowerCase() === currentName.toLowerCase());
|
||||||
|
if (storeKey) {
|
||||||
|
delete uuidStore[storeKey];
|
||||||
|
uuidStore[newName] = uuid;
|
||||||
|
}
|
||||||
console.log(`[Config] Updated username case: "${currentKey}" → "${newName}"`);
|
console.log(`[Config] Updated username case: "${currentKey}" → "${newName}"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save both username and updated userUuids
|
// Save UUID store
|
||||||
|
saveUuidStore(uuidStore);
|
||||||
|
|
||||||
|
// Save both username and updated userUuids to config
|
||||||
saveConfig({ username: newName, userUuids });
|
saveConfig({ username: newName, userUuids });
|
||||||
console.log(`[Config] Username saved: "${newName}"`);
|
console.log(`[Config] Username saved: "${newName}"`);
|
||||||
return newName;
|
return newName;
|
||||||
@@ -310,6 +346,7 @@ function hasUsername() {
|
|||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// UUID MANAGEMENT - Persistent and safe
|
// UUID MANAGEMENT - Persistent and safe
|
||||||
|
// Uses separate uuid-store.json as source of truth (survives config.json corruption)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -320,10 +357,55 @@ function normalizeUsername(username) {
|
|||||||
return username.trim().toLowerCase();
|
return username.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load UUID store from separate file (independent of config.json)
|
||||||
|
*/
|
||||||
|
function loadUuidStore() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(UUID_STORE_FILE)) {
|
||||||
|
const data = fs.readFileSync(UUID_STORE_FILE, 'utf8');
|
||||||
|
if (data.trim()) {
|
||||||
|
return JSON.parse(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[UUID Store] Failed to load:', err.message);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save UUID store to separate file (atomic write)
|
||||||
|
*/
|
||||||
|
function saveUuidStore(store) {
|
||||||
|
try {
|
||||||
|
const dir = path.dirname(UUID_STORE_FILE);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const tmpFile = UUID_STORE_FILE + '.tmp';
|
||||||
|
fs.writeFileSync(tmpFile, JSON.stringify(store, null, 2), 'utf8');
|
||||||
|
fs.renameSync(tmpFile, UUID_STORE_FILE);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[UUID Store] Failed to save:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time migration: copy userUuids from config.json to uuid-store.json
|
||||||
|
*/
|
||||||
|
function migrateUuidStoreIfNeeded() {
|
||||||
|
if (fs.existsSync(UUID_STORE_FILE)) return; // Already migrated
|
||||||
|
const config = loadConfig();
|
||||||
|
if (config.userUuids && Object.keys(config.userUuids).length > 0) {
|
||||||
|
console.log('[UUID Store] Migrating', Object.keys(config.userUuids).length, 'UUIDs from config.json');
|
||||||
|
saveUuidStore(config.userUuids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get UUID for a username
|
* Get UUID for a username
|
||||||
* Creates new UUID only if user explicitly doesn't exist
|
* Source of truth: uuid-store.json (separate from config.json)
|
||||||
* Uses case-insensitive lookup to prevent duplicates, but preserves original case for display
|
* Also writes to config.json for backward compatibility
|
||||||
|
* Creates new UUID only if user doesn't exist in EITHER store
|
||||||
*/
|
*/
|
||||||
function getUuidForUser(username) {
|
function getUuidForUser(username) {
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
@@ -335,32 +417,69 @@ function getUuidForUser(username) {
|
|||||||
const displayName = username.trim();
|
const displayName = username.trim();
|
||||||
const normalizedLookup = displayName.toLowerCase();
|
const normalizedLookup = displayName.toLowerCase();
|
||||||
|
|
||||||
const config = loadConfig();
|
// Ensure UUID store exists (one-time migration from config.json)
|
||||||
const userUuids = config.userUuids || {};
|
migrateUuidStoreIfNeeded();
|
||||||
|
|
||||||
// Case-insensitive lookup - find existing key regardless of case
|
// 1. Check UUID store first (source of truth)
|
||||||
const existingKey = Object.keys(userUuids).find(k => k.toLowerCase() === normalizedLookup);
|
const uuidStore = loadUuidStore();
|
||||||
|
const storeKey = Object.keys(uuidStore).find(k => k.toLowerCase() === normalizedLookup);
|
||||||
|
|
||||||
if (existingKey) {
|
if (storeKey) {
|
||||||
// Found existing - return UUID, update display name if case changed
|
const existingUuid = uuidStore[storeKey];
|
||||||
const existingUuid = userUuids[existingKey];
|
|
||||||
|
|
||||||
// If user typed different case, update the key to new case (preserving UUID)
|
// Update case if needed
|
||||||
if (existingKey !== displayName) {
|
if (storeKey !== displayName) {
|
||||||
console.log(`[Config] Updating username case: "${existingKey}" → "${displayName}"`);
|
console.log(`[UUID Store] Updating username case: "${storeKey}" → "${displayName}"`);
|
||||||
delete userUuids[existingKey];
|
delete uuidStore[storeKey];
|
||||||
userUuids[displayName] = existingUuid;
|
uuidStore[displayName] = existingUuid;
|
||||||
saveConfig({ userUuids });
|
saveUuidStore(uuidStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync to config.json (backward compat, non-critical)
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const configUuids = config.userUuids || {};
|
||||||
|
const configKey = Object.keys(configUuids).find(k => k.toLowerCase() === normalizedLookup);
|
||||||
|
if (!configKey || configUuids[configKey] !== existingUuid) {
|
||||||
|
if (configKey) delete configUuids[configKey];
|
||||||
|
configUuids[displayName] = existingUuid;
|
||||||
|
saveConfig({ userUuids: configUuids });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Non-critical — UUID store is the source of truth
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[UUID] ${displayName} → ${existingUuid} (from uuid-store)`);
|
||||||
return existingUuid;
|
return existingUuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new UUID for new user - store with original case
|
// 2. Fallback: check config.json (recovery if uuid-store.json was lost)
|
||||||
|
const config = loadConfig();
|
||||||
|
const userUuids = config.userUuids || {};
|
||||||
|
const configKey = Object.keys(userUuids).find(k => k.toLowerCase() === normalizedLookup);
|
||||||
|
|
||||||
|
if (configKey) {
|
||||||
|
const recoveredUuid = userUuids[configKey];
|
||||||
|
console.warn(`[UUID] RECOVERED "${displayName}" → ${recoveredUuid} from config.json (uuid-store was missing)`);
|
||||||
|
|
||||||
|
// Save to UUID store
|
||||||
|
uuidStore[displayName] = recoveredUuid;
|
||||||
|
saveUuidStore(uuidStore);
|
||||||
|
|
||||||
|
return recoveredUuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. New user — generate UUID, save to BOTH stores
|
||||||
const newUuid = uuidv4();
|
const newUuid = uuidv4();
|
||||||
|
console.log(`[UUID] NEW user "${displayName}" → ${newUuid}`);
|
||||||
|
|
||||||
|
// Save to UUID store (source of truth)
|
||||||
|
uuidStore[displayName] = newUuid;
|
||||||
|
saveUuidStore(uuidStore);
|
||||||
|
|
||||||
|
// Save to config.json (backward compat)
|
||||||
userUuids[displayName] = newUuid;
|
userUuids[displayName] = newUuid;
|
||||||
saveConfig({ userUuids });
|
saveConfig({ userUuids });
|
||||||
console.log(`[Config] Created new UUID for "${displayName}": ${newUuid}`);
|
|
||||||
|
|
||||||
return newUuid;
|
return newUuid;
|
||||||
}
|
}
|
||||||
@@ -380,22 +499,26 @@ function getCurrentUuid() {
|
|||||||
* Get all UUID mappings (raw object)
|
* Get all UUID mappings (raw object)
|
||||||
*/
|
*/
|
||||||
function getAllUuidMappings() {
|
function getAllUuidMappings() {
|
||||||
|
migrateUuidStoreIfNeeded();
|
||||||
|
const uuidStore = loadUuidStore();
|
||||||
|
// Fallback to config if uuid-store is empty
|
||||||
|
if (Object.keys(uuidStore).length === 0) {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
return config.userUuids || {};
|
return config.userUuids || {};
|
||||||
|
}
|
||||||
|
return uuidStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all UUID mappings as array with current user flag
|
* Get all UUID mappings as array with current user flag
|
||||||
*/
|
*/
|
||||||
function getAllUuidMappingsArray() {
|
function getAllUuidMappingsArray() {
|
||||||
const config = loadConfig();
|
const allMappings = getAllUuidMappings();
|
||||||
const userUuids = config.userUuids || {};
|
|
||||||
const currentUsername = loadUsername();
|
const currentUsername = loadUsername();
|
||||||
// Case-insensitive comparison for isCurrent
|
|
||||||
const normalizedCurrent = currentUsername ? currentUsername.toLowerCase() : null;
|
const normalizedCurrent = currentUsername ? currentUsername.toLowerCase() : null;
|
||||||
|
|
||||||
return Object.entries(userUuids).map(([username, uuid]) => ({
|
return Object.entries(allMappings).map(([username, uuid]) => ({
|
||||||
username, // Original case preserved
|
username,
|
||||||
uuid,
|
uuid,
|
||||||
isCurrent: username.toLowerCase() === normalizedCurrent
|
isCurrent: username.toLowerCase() === normalizedCurrent
|
||||||
}));
|
}));
|
||||||
@@ -419,16 +542,20 @@ function setUuidForUser(username, uuid) {
|
|||||||
|
|
||||||
const displayName = username.trim();
|
const displayName = username.trim();
|
||||||
const normalizedLookup = displayName.toLowerCase();
|
const normalizedLookup = displayName.toLowerCase();
|
||||||
|
|
||||||
|
// 1. Update UUID store (source of truth)
|
||||||
|
migrateUuidStoreIfNeeded();
|
||||||
|
const uuidStore = loadUuidStore();
|
||||||
|
const storeKey = Object.keys(uuidStore).find(k => k.toLowerCase() === normalizedLookup);
|
||||||
|
if (storeKey) delete uuidStore[storeKey];
|
||||||
|
uuidStore[displayName] = uuid;
|
||||||
|
saveUuidStore(uuidStore);
|
||||||
|
|
||||||
|
// 2. Update config.json (backward compat)
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const userUuids = config.userUuids || {};
|
const userUuids = config.userUuids || {};
|
||||||
|
|
||||||
// Remove any existing entry with same name (case-insensitive)
|
|
||||||
const existingKey = Object.keys(userUuids).find(k => k.toLowerCase() === normalizedLookup);
|
const existingKey = Object.keys(userUuids).find(k => k.toLowerCase() === normalizedLookup);
|
||||||
if (existingKey) {
|
if (existingKey) delete userUuids[existingKey];
|
||||||
delete userUuids[existingKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store with original case
|
|
||||||
userUuids[displayName] = uuid;
|
userUuids[displayName] = uuid;
|
||||||
saveConfig({ userUuids });
|
saveConfig({ userUuids });
|
||||||
|
|
||||||
@@ -454,20 +581,30 @@ function deleteUuidForUser(username) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalizedLookup = username.trim().toLowerCase();
|
const normalizedLookup = username.trim().toLowerCase();
|
||||||
|
let deleted = false;
|
||||||
|
|
||||||
|
// 1. Delete from UUID store (source of truth)
|
||||||
|
migrateUuidStoreIfNeeded();
|
||||||
|
const uuidStore = loadUuidStore();
|
||||||
|
const storeKey = Object.keys(uuidStore).find(k => k.toLowerCase() === normalizedLookup);
|
||||||
|
if (storeKey) {
|
||||||
|
delete uuidStore[storeKey];
|
||||||
|
saveUuidStore(uuidStore);
|
||||||
|
deleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete from config.json (backward compat)
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const userUuids = config.userUuids || {};
|
const userUuids = config.userUuids || {};
|
||||||
|
|
||||||
// Case-insensitive lookup
|
|
||||||
const existingKey = Object.keys(userUuids).find(k => k.toLowerCase() === normalizedLookup);
|
const existingKey = Object.keys(userUuids).find(k => k.toLowerCase() === normalizedLookup);
|
||||||
|
|
||||||
if (existingKey) {
|
if (existingKey) {
|
||||||
delete userUuids[existingKey];
|
delete userUuids[existingKey];
|
||||||
saveConfig({ userUuids });
|
saveConfig({ userUuids });
|
||||||
console.log(`[Config] UUID deleted for "${username}"`);
|
deleted = true;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
if (deleted) console.log(`[Config] UUID deleted for "${username}"`);
|
||||||
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -788,5 +925,6 @@ module.exports = {
|
|||||||
loadVersionBranch,
|
loadVersionBranch,
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
CONFIG_FILE
|
CONFIG_FILE,
|
||||||
|
UUID_STORE_FILE
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const FORCE_CLEAN_INSTALL_VERSION = false;
|
const FORCE_CLEAN_INSTALL_VERSION = false;
|
||||||
const CLEAN_INSTALL_TEST_VERSION = '4.pwr';
|
const CLEAN_INSTALL_TEST_VERSION = 'v4';
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
FORCE_CLEAN_INSTALL_VERSION,
|
FORCE_CLEAN_INSTALL_VERSION,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const path = require('path');
|
|||||||
const { execFile } = require('child_process');
|
const { execFile } = require('child_process');
|
||||||
const { downloadFile, retryDownload } = require('../utils/fileManager');
|
const { downloadFile, retryDownload } = require('../utils/fileManager');
|
||||||
const { getOS, getArch } = require('../utils/platformUtils');
|
const { getOS, getArch } = require('../utils/platformUtils');
|
||||||
const { validateChecksum, extractVersionDetails, canUseDifferentialUpdate, needsIntermediatePatches, getInstalledClientVersion } = require('../services/versionManager');
|
const { validateChecksum, extractVersionDetails, getInstalledClientVersion, getUpdatePlan, extractVersionNumber, getAllMirrorUrls, getPatchesBaseUrl } = require('../services/versionManager');
|
||||||
const { installButler } = require('./butlerManager');
|
const { installButler } = require('./butlerManager');
|
||||||
const { GAME_DIR, CACHE_DIR, TOOLS_DIR } = require('../core/paths');
|
const { GAME_DIR, CACHE_DIR, TOOLS_DIR } = require('../core/paths');
|
||||||
const { saveVersionClient } = require('../core/config');
|
const { saveVersionClient } = require('../core/config');
|
||||||
@@ -31,15 +31,62 @@ async function acquireGameArchive(downloadUrl, targetPath, checksum, progressCal
|
|||||||
|
|
||||||
console.log(`Downloading game archive from: ${downloadUrl}`);
|
console.log(`Downloading game archive from: ${downloadUrl}`);
|
||||||
|
|
||||||
try {
|
// Try primary URL first, then mirror URLs on timeout/connection failure
|
||||||
if (allowRetry) {
|
const mirrors = await getAllMirrorUrls();
|
||||||
await retryDownload(downloadUrl, targetPath, progressCallback);
|
const primaryBase = await getPatchesBaseUrl();
|
||||||
} else {
|
const urlsToTry = [downloadUrl];
|
||||||
await downloadFile(downloadUrl, targetPath, progressCallback);
|
|
||||||
|
// Build mirror URLs by replacing the base URL
|
||||||
|
for (const mirror of mirrors) {
|
||||||
|
if (mirror !== primaryBase && downloadUrl.startsWith(primaryBase)) {
|
||||||
|
const mirrorUrl = downloadUrl.replace(primaryBase, mirror);
|
||||||
|
if (!urlsToTry.includes(mirrorUrl)) {
|
||||||
|
urlsToTry.push(mirrorUrl);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError;
|
||||||
|
for (let i = 0; i < urlsToTry.length; i++) {
|
||||||
|
const url = urlsToTry[i];
|
||||||
|
try {
|
||||||
|
if (i > 0) {
|
||||||
|
console.log(`[Download] Trying mirror ${i}: ${url}`);
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback(`Trying alternative mirror (${i}/${urlsToTry.length - 1})...`, 0, null, null, null);
|
||||||
|
}
|
||||||
|
// Clean up partial download from previous attempt
|
||||||
|
if (fs.existsSync(targetPath)) {
|
||||||
|
try { fs.unlinkSync(targetPath); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allowRetry) {
|
||||||
|
await retryDownload(url, targetPath, progressCallback);
|
||||||
|
} else {
|
||||||
|
await downloadFile(url, targetPath, progressCallback);
|
||||||
|
}
|
||||||
|
lastError = null;
|
||||||
|
break; // Success
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const enhancedError = new Error(`Archive download failed: ${error.message}`);
|
lastError = error;
|
||||||
enhancedError.originalError = error;
|
const isConnectionError = error.message && (
|
||||||
|
error.message.includes('ETIMEDOUT') ||
|
||||||
|
error.message.includes('ECONNREFUSED') ||
|
||||||
|
error.message.includes('ECONNABORTED') ||
|
||||||
|
error.message.includes('timeout')
|
||||||
|
);
|
||||||
|
if (isConnectionError && i < urlsToTry.length - 1) {
|
||||||
|
console.warn(`[Download] Connection failed (${error.message}), will try mirror...`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Non-connection error or last mirror — throw
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastError) {
|
||||||
|
const enhancedError = new Error(`Archive download failed: ${lastError.message}`);
|
||||||
|
enhancedError.originalError = lastError;
|
||||||
enhancedError.downloadUrl = downloadUrl;
|
enhancedError.downloadUrl = downloadUrl;
|
||||||
enhancedError.targetPath = targetPath;
|
enhancedError.targetPath = targetPath;
|
||||||
throw enhancedError;
|
throw enhancedError;
|
||||||
@@ -156,15 +203,15 @@ async function performIntelligentUpdate(targetVersion, branch = 'release', progr
|
|||||||
console.log(`Initiating intelligent update to version ${targetVersion}`);
|
console.log(`Initiating intelligent update to version ${targetVersion}`);
|
||||||
|
|
||||||
const currentVersion = getInstalledClientVersion();
|
const currentVersion = getInstalledClientVersion();
|
||||||
console.log(`Current version: ${currentVersion || 'none (clean install)'}`);
|
const currentBuild = extractVersionNumber(currentVersion) || 0;
|
||||||
console.log(`Target version: ${targetVersion}`);
|
const targetBuild = extractVersionNumber(targetVersion);
|
||||||
console.log(`Branch: ${branch}`);
|
console.log(`Current build: ${currentBuild}, Target build: ${targetBuild}, Branch: ${branch}`);
|
||||||
|
|
||||||
|
// For non-release branches, always do full install
|
||||||
if (branch !== 'release') {
|
if (branch !== 'release') {
|
||||||
console.log(`Pre-release branch detected - forcing full archive download`);
|
console.log('Pre-release branch detected - forcing full archive download');
|
||||||
const versionDetails = await extractVersionDetails(targetVersion, branch);
|
const versionDetails = await extractVersionDetails(targetVersion, branch);
|
||||||
const archiveName = path.basename(versionDetails.fullUrl);
|
const archivePath = path.join(cacheDir, `${branch}_0_to_${targetBuild}.pwr`);
|
||||||
const archivePath = path.join(cacheDir, `${branch}_${archiveName}`);
|
|
||||||
|
|
||||||
if (progressCallback) {
|
if (progressCallback) {
|
||||||
progressCallback('Downloading full game archive (pre-release)...', 0, null, null, null);
|
progressCallback('Downloading full game archive (pre-release)...', 0, null, null, null);
|
||||||
@@ -177,14 +224,14 @@ async function performIntelligentUpdate(targetVersion, branch = 'release', progr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentVersion) {
|
// Clean install (no current version)
|
||||||
|
if (currentBuild === 0) {
|
||||||
console.log('No existing installation detected - downloading full archive');
|
console.log('No existing installation detected - downloading full archive');
|
||||||
const versionDetails = await extractVersionDetails(targetVersion, branch);
|
const versionDetails = await extractVersionDetails(targetVersion, branch);
|
||||||
const archiveName = path.basename(versionDetails.fullUrl);
|
const archivePath = path.join(cacheDir, `${branch}_0_to_${targetBuild}.pwr`);
|
||||||
const archivePath = path.join(cacheDir, `${branch}_${archiveName}`);
|
|
||||||
|
|
||||||
if (progressCallback) {
|
if (progressCallback) {
|
||||||
progressCallback(`Downloading full game archive (first install - v${targetVersion})...`, 0, null, null, null);
|
progressCallback(`Downloading full game archive (first install - v${targetBuild})...`, 0, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
await acquireGameArchive(versionDetails.fullUrl, archivePath, null, progressCallback);
|
await acquireGameArchive(versionDetails.fullUrl, archivePath, null, progressCallback);
|
||||||
@@ -194,59 +241,67 @@ async function performIntelligentUpdate(targetVersion, branch = 'release', progr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const patchesToApply = needsIntermediatePatches(currentVersion, targetVersion);
|
// Already at target
|
||||||
|
if (currentBuild >= targetBuild) {
|
||||||
if (patchesToApply.length === 0) {
|
console.log('Already at target version or newer');
|
||||||
console.log('Already at target version or invalid version sequence');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Applying ${patchesToApply.length} differential patch(es): ${patchesToApply.join(' -> ')}`);
|
// Use mirror's update plan for optimal patch routing
|
||||||
|
try {
|
||||||
|
const plan = await getUpdatePlan(currentBuild, targetBuild, branch);
|
||||||
|
|
||||||
for (let i = 0; i < patchesToApply.length; i++) {
|
console.log(`Applying ${plan.steps.length} patch(es): ${plan.steps.map(s => `${s.from}\u2192${s.to}`).join(' + ')}`);
|
||||||
const patchVersion = patchesToApply[i];
|
|
||||||
const versionDetails = await extractVersionDetails(patchVersion, branch);
|
|
||||||
|
|
||||||
const canDifferential = canUseDifferentialUpdate(getInstalledClientVersion(), versionDetails);
|
for (let i = 0; i < plan.steps.length; i++) {
|
||||||
|
const step = plan.steps[i];
|
||||||
if (!canDifferential || !versionDetails.differentialUrl) {
|
const stepName = `${step.from}_to_${step.to}`;
|
||||||
console.log(`WARNING: Differential patch not available for ${patchVersion}, using full archive`);
|
const archivePath = path.join(cacheDir, `${branch}_${stepName}.pwr`);
|
||||||
const archiveName = path.basename(versionDetails.fullUrl);
|
const isDifferential = step.from !== 0;
|
||||||
const archivePath = path.join(cacheDir, `${branch}_${archiveName}`);
|
|
||||||
|
|
||||||
if (progressCallback) {
|
if (progressCallback) {
|
||||||
progressCallback(`Downloading full archive for ${patchVersion} (${i + 1}/${patchesToApply.length})...`, 0, null, null, null);
|
progressCallback(`Downloading patch ${i + 1}/${plan.steps.length}: ${stepName}...`, 0, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await acquireGameArchive(step.url, archivePath, null, progressCallback);
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback(`Applying patch ${i + 1}/${plan.steps.length}: ${stepName}...`, 50, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, isDifferential);
|
||||||
|
|
||||||
|
// Clean up patch file
|
||||||
|
if (fs.existsSync(archivePath)) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(archivePath);
|
||||||
|
console.log(`Cleaned up: ${stepName}.pwr`);
|
||||||
|
} catch (cleanupErr) {
|
||||||
|
console.warn(`Failed to cleanup: ${cleanupErr.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveVersionClient(`v${step.to}`);
|
||||||
|
console.log(`Patch ${stepName} applied (${i + 1}/${plan.steps.length})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Update completed. Version ${targetVersion} is now installed.`);
|
||||||
|
} catch (planError) {
|
||||||
|
console.error('Update plan failed:', planError.message);
|
||||||
|
console.log('Falling back to full archive download');
|
||||||
|
|
||||||
|
// Fallback: full install
|
||||||
|
const versionDetails = await extractVersionDetails(targetVersion, branch);
|
||||||
|
const archivePath = path.join(cacheDir, `${branch}_0_to_${targetBuild}.pwr`);
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback(`Downloading full game archive (fallback)...`, 0, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
await acquireGameArchive(versionDetails.fullUrl, archivePath, null, progressCallback);
|
await acquireGameArchive(versionDetails.fullUrl, archivePath, null, progressCallback);
|
||||||
await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, false);
|
await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, false);
|
||||||
} else {
|
saveVersionClient(targetVersion);
|
||||||
console.log(`Applying differential patch: ${versionDetails.sourceVersion} -> ${patchVersion}`);
|
|
||||||
const archiveName = path.basename(versionDetails.differentialUrl);
|
|
||||||
const archivePath = path.join(cacheDir, `${branch}_patch_${archiveName}`);
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback(`Applying patch ${i + 1}/${patchesToApply.length}: ${patchVersion}...`, 0, null, null, null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await acquireGameArchive(versionDetails.differentialUrl, archivePath, versionDetails.checksum, progressCallback);
|
|
||||||
await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, true);
|
|
||||||
|
|
||||||
if (fs.existsSync(archivePath)) {
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(archivePath);
|
|
||||||
console.log(`Cleaned up patch file: ${archiveName}`);
|
|
||||||
} catch (cleanupErr) {
|
|
||||||
console.warn(`Failed to cleanup patch file: ${cleanupErr.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
saveVersionClient(patchVersion);
|
|
||||||
console.log(`Patch ${patchVersion} applied successfully (${i + 1}/${patchesToApply.length})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Update completed successfully. Version ${targetVersion} is now installed.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureGameInstalled(targetVersion, branch = 'release', progressCallback, gameDir = GAME_DIR, cacheDir = CACHE_DIR, toolsDir = TOOLS_DIR) {
|
async function ensureGameInstalled(targetVersion, branch = 'release', progressCallback, gameDir = GAME_DIR, cacheDir = CACHE_DIR, toolsDir = TOOLS_DIR) {
|
||||||
|
|||||||
@@ -61,12 +61,39 @@ async function fetchAuthTokens(uuid, name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log('Auth tokens received from server');
|
const identityToken = data.IdentityToken || data.identityToken;
|
||||||
|
const sessionToken = data.SessionToken || data.sessionToken;
|
||||||
|
|
||||||
|
// Verify the identity token has the correct username
|
||||||
|
// This catches cases where the auth server defaults to "Player"
|
||||||
|
try {
|
||||||
|
const parts = identityToken.split('.');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
||||||
|
if (payload.username && payload.username !== name && name !== 'Player') {
|
||||||
|
console.warn(`[Auth] Token username mismatch: token has "${payload.username}", expected "${name}". Retrying...`);
|
||||||
|
// Retry once with explicit name
|
||||||
|
const retryResponse = await fetch(`${authServerUrl}/game-session/child`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ uuid: uuid, name: name, scopes: ['hytale:server', 'hytale:client'] })
|
||||||
|
});
|
||||||
|
if (retryResponse.ok) {
|
||||||
|
const retryData = await retryResponse.json();
|
||||||
|
console.log('[Auth] Retry successful');
|
||||||
return {
|
return {
|
||||||
identityToken: data.IdentityToken || data.identityToken,
|
identityToken: retryData.IdentityToken || retryData.identityToken,
|
||||||
sessionToken: data.SessionToken || data.sessionToken
|
sessionToken: retryData.SessionToken || retryData.sessionToken
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (verifyErr) {
|
||||||
|
console.warn('[Auth] Token verification skipped:', verifyErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Auth tokens received from server');
|
||||||
|
return { identityToken, sessionToken };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch auth tokens:', error.message);
|
console.error('Failed to fetch auth tokens:', error.message);
|
||||||
// Fallback to local generation if server unavailable
|
// Fallback to local generation if server unavailable
|
||||||
@@ -223,6 +250,7 @@ async function launchGame(playerNameOverride = null, progressCallback, javaPathO
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uuid = getUuidForUser(playerName);
|
const uuid = getUuidForUser(playerName);
|
||||||
|
console.log(`[Launcher] UUID for "${playerName}": ${uuid} (verify this stays constant across launches)`);
|
||||||
|
|
||||||
// Fetch tokens from auth server
|
// Fetch tokens from auth server
|
||||||
if (progressCallback) {
|
if (progressCallback) {
|
||||||
@@ -252,8 +280,8 @@ async function launchGame(playerNameOverride = null, progressCallback, javaPathO
|
|||||||
if (patchResult.client) {
|
if (patchResult.client) {
|
||||||
console.log(` Client: ${patchResult.client.patchCount || 0} occurrences`);
|
console.log(` Client: ${patchResult.client.patchCount || 0} occurrences`);
|
||||||
}
|
}
|
||||||
if (patchResult.server) {
|
if (patchResult.agent) {
|
||||||
console.log(` Server: ${patchResult.server.patchCount || 0} occurrences`);
|
console.log(` Agent: ${patchResult.agent.alreadyExists ? 'already present' : patchResult.agent.success ? 'downloaded' : 'failed'}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('Game patching failed:', patchResult.error);
|
console.warn('Game patching failed:', patchResult.error);
|
||||||
@@ -408,6 +436,17 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DualAuth Agent: Set JAVA_TOOL_OPTIONS so java picks up -javaagent: flag
|
||||||
|
// This enables runtime auth patching without modifying the server JAR
|
||||||
|
const agentJar = path.join(gameLatest, 'Server', 'dualauth-agent.jar');
|
||||||
|
if (fs.existsSync(agentJar)) {
|
||||||
|
const agentFlag = `-javaagent:"${agentJar}"`;
|
||||||
|
env.JAVA_TOOL_OPTIONS = env.JAVA_TOOL_OPTIONS
|
||||||
|
? `${env.JAVA_TOOL_OPTIONS} ${agentFlag}`
|
||||||
|
: agentFlag;
|
||||||
|
console.log('DualAuth Agent: enabled via JAVA_TOOL_OPTIONS');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let spawnOptions = {
|
let spawnOptions = {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const { promisify } = require('util');
|
|||||||
const { getResolvedAppDir, findClientPath, findUserDataPath, findUserDataRecursive, GAME_DIR, CACHE_DIR, TOOLS_DIR } = require('../core/paths');
|
const { getResolvedAppDir, findClientPath, findUserDataPath, findUserDataRecursive, GAME_DIR, CACHE_DIR, TOOLS_DIR } = require('../core/paths');
|
||||||
const { getOS, getArch } = require('../utils/platformUtils');
|
const { getOS, getArch } = require('../utils/platformUtils');
|
||||||
const { downloadFile, retryDownload, retryStalledDownload, MAX_AUTOMATIC_STALL_RETRIES } = require('../utils/fileManager');
|
const { downloadFile, retryDownload, retryStalledDownload, MAX_AUTOMATIC_STALL_RETRIES } = require('../utils/fileManager');
|
||||||
const { getLatestClientVersion, getInstalledClientVersion } = require('../services/versionManager');
|
const { getLatestClientVersion, getInstalledClientVersion, getUpdatePlan, extractVersionNumber } = require('../services/versionManager');
|
||||||
const { FORCE_CLEAN_INSTALL_VERSION, CLEAN_INSTALL_TEST_VERSION } = require('../core/testConfig');
|
const { FORCE_CLEAN_INSTALL_VERSION, CLEAN_INSTALL_TEST_VERSION } = require('../core/testConfig');
|
||||||
const { installButler } = require('./butlerManager');
|
const { installButler } = require('./butlerManager');
|
||||||
const { downloadAndReplaceHomePageUI, downloadAndReplaceLogo } = require('./uiFileManager');
|
const { downloadAndReplaceHomePageUI, downloadAndReplaceLogo } = require('./uiFileManager');
|
||||||
@@ -64,7 +64,7 @@ async function safeRemoveDirectory(dirPath, maxRetries = 3) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadPWR(branch = 'release', fileName = '7.pwr', progressCallback, cacheDir = CACHE_DIR, manualRetry = false) {
|
async function downloadPWR(branch = 'release', fileName = 'v8', progressCallback, cacheDir = CACHE_DIR, manualRetry = false, directUrl = null, expectedSize = null) {
|
||||||
const osName = getOS();
|
const osName = getOS();
|
||||||
const arch = getArch();
|
const arch = getArch();
|
||||||
|
|
||||||
@@ -72,28 +72,69 @@ async function downloadPWR(branch = 'release', fileName = '7.pwr', progressCallb
|
|||||||
throw new Error('Hytale x86_64 Intel Mac Support has not been released yet. Please check back later.');
|
throw new Error('Hytale x86_64 Intel Mac Support has not been released yet. Please check back later.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `https://game-patches.hytale.com/patches/${osName}/${arch}/${branch}/0/${fileName}`;
|
let url;
|
||||||
const dest = path.join(cacheDir, `${branch}_${fileName}`);
|
|
||||||
|
if (directUrl) {
|
||||||
|
url = directUrl;
|
||||||
|
console.log(`[DownloadPWR] Using direct URL: ${url}`);
|
||||||
|
} else {
|
||||||
|
const { getPWRUrl } = require('../services/versionManager');
|
||||||
|
try {
|
||||||
|
console.log(`[DownloadPWR] Fetching mirror URL for branch: ${branch}, version: ${fileName}`);
|
||||||
|
url = await getPWRUrl(branch, fileName);
|
||||||
|
console.log(`[DownloadPWR] Mirror URL: ${url}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[DownloadPWR] Failed to get mirror URL: ${error.message}`);
|
||||||
|
const { getPatchesBaseUrl } = require('../services/versionManager');
|
||||||
|
const baseUrl = await getPatchesBaseUrl();
|
||||||
|
url = `${baseUrl}/${osName}/${arch}/${branch}/0_to_${extractVersionNumber(fileName)}.pwr`;
|
||||||
|
console.log(`[DownloadPWR] Fallback URL: ${url}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up expected file size from manifest if not provided
|
||||||
|
if (!expectedSize) {
|
||||||
|
try {
|
||||||
|
const { fetchMirrorManifest } = require('../services/versionManager');
|
||||||
|
const manifest = await fetchMirrorManifest();
|
||||||
|
// Try to match: "0_to_11" format or "v11" format
|
||||||
|
const versionMatch = fileName.match(/^(\d+)_to_(\d+)$/);
|
||||||
|
let manifestKey;
|
||||||
|
if (versionMatch) {
|
||||||
|
manifestKey = `${osName}/${arch}/${branch}/${fileName}.pwr`;
|
||||||
|
} else {
|
||||||
|
const buildNum = extractVersionNumber(fileName);
|
||||||
|
manifestKey = `${osName}/${arch}/${branch}/0_to_${buildNum}.pwr`;
|
||||||
|
}
|
||||||
|
if (manifest.files[manifestKey]) {
|
||||||
|
expectedSize = manifest.files[manifestKey].size;
|
||||||
|
console.log(`[PWR] Expected size from manifest: ${(expectedSize / 1024 / 1024).toFixed(2)} MB`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[PWR] Could not fetch expected size from manifest: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dest = path.join(cacheDir, `${branch}_${fileName}.pwr`);
|
||||||
|
|
||||||
// Check if file exists and validate it
|
// Check if file exists and validate it
|
||||||
if (fs.existsSync(dest) && !manualRetry) {
|
if (fs.existsSync(dest) && !manualRetry) {
|
||||||
console.log('PWR file found in cache:', dest);
|
|
||||||
|
|
||||||
// Validate file size (PWR files should be > 1MB and >= 1.5GB for complete downloads)
|
|
||||||
const stats = fs.statSync(dest);
|
const stats = fs.statSync(dest);
|
||||||
if (stats.size < 1024 * 1024) {
|
if (stats.size > 1024 * 1024) {
|
||||||
return false;
|
// Validate against expected size - reject if file is truncated (< 99% of expected)
|
||||||
|
if (expectedSize && stats.size < expectedSize * 0.99) {
|
||||||
|
console.log(`[PWR] Cached file truncated: ${(stats.size / 1024 / 1024).toFixed(2)} MB, expected ${(expectedSize / 1024 / 1024).toFixed(2)} MB. Deleting and re-downloading.`);
|
||||||
|
fs.unlinkSync(dest);
|
||||||
|
} else {
|
||||||
|
console.log(`[PWR] Using cached file: ${dest} (${(stats.size / 1024 / 1024).toFixed(2)} MB)`);
|
||||||
|
return dest;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
// Check if file is under 1.5 GB (incomplete download)
|
console.log(`[PWR] Cached file too small (${stats.size} bytes), re-downloading`);
|
||||||
const sizeInMB = stats.size / 1024 / 1024;
|
|
||||||
if (sizeInMB < 1500) {
|
|
||||||
console.log(`[PWR Validation] File appears incomplete: ${sizeInMB.toFixed(2)} MB < 1.5 GB`);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Fetching PWR patch file:', url);
|
console.log(`[DownloadPWR] Downloading from: ${url}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (manualRetry) {
|
if (manualRetry) {
|
||||||
@@ -119,7 +160,7 @@ async function downloadPWR(branch = 'release', fileName = '7.pwr', progressCallb
|
|||||||
const retryStats = fs.statSync(dest);
|
const retryStats = fs.statSync(dest);
|
||||||
console.log(`PWR file downloaded (auto-retry), size: ${(retryStats.size / 1024 / 1024).toFixed(2)} MB`);
|
console.log(`PWR file downloaded (auto-retry), size: ${(retryStats.size / 1024 / 1024).toFixed(2)} MB`);
|
||||||
|
|
||||||
if (!validatePWRFile(dest)) {
|
if (!validatePWRFile(dest, expectedSize)) {
|
||||||
console.log(`[PWR Validation] PWR file validation failed after auto-retry, deleting corrupted file: ${dest}`);
|
console.log(`[PWR Validation] PWR file validation failed after auto-retry, deleting corrupted file: ${dest}`);
|
||||||
fs.unlinkSync(dest);
|
fs.unlinkSync(dest);
|
||||||
throw new Error('Downloaded PWR file is corrupted or invalid after automatic retry. Please retry manually');
|
throw new Error('Downloaded PWR file is corrupted or invalid after automatic retry. Please retry manually');
|
||||||
@@ -170,7 +211,7 @@ async function downloadPWR(branch = 'release', fileName = '7.pwr', progressCallb
|
|||||||
const stats = fs.statSync(dest);
|
const stats = fs.statSync(dest);
|
||||||
console.log(`PWR file downloaded, size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`);
|
console.log(`PWR file downloaded, size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`);
|
||||||
|
|
||||||
if (!validatePWRFile(dest)) {
|
if (!validatePWRFile(dest, expectedSize)) {
|
||||||
console.log(`[PWR Validation] PWR file validation failed, deleting corrupted file: ${dest}`);
|
console.log(`[PWR Validation] PWR file validation failed, deleting corrupted file: ${dest}`);
|
||||||
fs.unlinkSync(dest);
|
fs.unlinkSync(dest);
|
||||||
throw new Error('Downloaded PWR file is corrupted or invalid. Please retry');
|
throw new Error('Downloaded PWR file is corrupted or invalid. Please retry');
|
||||||
@@ -188,7 +229,7 @@ async function retryPWRDownload(branch, fileName, progressCallback, cacheDir = C
|
|||||||
return await downloadPWR(branch, fileName, progressCallback, cacheDir, true);
|
return await downloadPWR(branch, fileName, progressCallback, cacheDir, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir = TOOLS_DIR, branch = 'release', cacheDir = CACHE_DIR) {
|
async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir = TOOLS_DIR, branch = 'release', cacheDir = CACHE_DIR, skipExistingCheck = false) {
|
||||||
console.log(`[Butler] Starting PWR application with:`);
|
console.log(`[Butler] Starting PWR application with:`);
|
||||||
console.log(`[Butler] - PWR file: ${pwrFile}`);
|
console.log(`[Butler] - PWR file: ${pwrFile}`);
|
||||||
console.log(`[Butler] - Staging dir: ${path.join(gameDir, 'staging-temp')}`);
|
console.log(`[Butler] - Staging dir: ${path.join(gameDir, 'staging-temp')}`);
|
||||||
@@ -212,12 +253,13 @@ async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir
|
|||||||
const gameLatest = gameDir;
|
const gameLatest = gameDir;
|
||||||
const stagingDir = path.join(gameLatest, 'staging-temp');
|
const stagingDir = path.join(gameLatest, 'staging-temp');
|
||||||
|
|
||||||
|
if (!skipExistingCheck) {
|
||||||
const clientPath = findClientPath(gameLatest);
|
const clientPath = findClientPath(gameLatest);
|
||||||
|
|
||||||
if (clientPath) {
|
if (clientPath) {
|
||||||
console.log('Game files detected, skipping patch installation.');
|
console.log('Game files detected, skipping patch installation.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate and prepare directories
|
// Validate and prepare directories
|
||||||
validateGameDirectory(gameLatest, stagingDir);
|
validateGameDirectory(gameLatest, stagingDir);
|
||||||
@@ -397,6 +439,65 @@ async function updateGameFiles(newVersion, progressCallback, gameDir = GAME_DIR,
|
|||||||
}
|
}
|
||||||
console.log(`Updating game files to version: ${newVersion} (branch: ${branch})`);
|
console.log(`Updating game files to version: ${newVersion} (branch: ${branch})`);
|
||||||
|
|
||||||
|
// Determine update strategy: intermediate patches vs full reinstall
|
||||||
|
const currentVersion = loadVersionClient();
|
||||||
|
const currentBuild = extractVersionNumber(currentVersion) || 0;
|
||||||
|
const targetBuild = extractVersionNumber(newVersion);
|
||||||
|
|
||||||
|
let useIntermediatePatches = false;
|
||||||
|
let updatePlan = null;
|
||||||
|
|
||||||
|
if (currentBuild > 0 && currentBuild < targetBuild) {
|
||||||
|
try {
|
||||||
|
updatePlan = await getUpdatePlan(currentBuild, targetBuild, branch);
|
||||||
|
useIntermediatePatches = !updatePlan.isFullInstall;
|
||||||
|
if (useIntermediatePatches) {
|
||||||
|
const totalMB = (updatePlan.totalSize / 1024 / 1024).toFixed(0);
|
||||||
|
console.log(`[UpdateGameFiles] Using intermediate patches: ${updatePlan.steps.map(s => `${s.from}\u2192${s.to}`).join(' + ')} (${totalMB} MB)`);
|
||||||
|
}
|
||||||
|
} catch (planError) {
|
||||||
|
console.warn('[UpdateGameFiles] Could not get update plan, falling back to full install:', planError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useIntermediatePatches && updatePlan) {
|
||||||
|
// Apply intermediate patches directly to game dir
|
||||||
|
for (let i = 0; i < updatePlan.steps.length; i++) {
|
||||||
|
const step = updatePlan.steps[i];
|
||||||
|
const stepName = `${step.from}_to_${step.to}`;
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
const progress = 20 + Math.round((i / updatePlan.steps.length) * 60);
|
||||||
|
progressCallback(`Downloading patch ${i + 1}/${updatePlan.steps.length} (${stepName})...`, progress, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pwrFile = await downloadPWR(branch, stepName, progressCallback, cacheDir, false, step.url, step.size);
|
||||||
|
|
||||||
|
if (!pwrFile) {
|
||||||
|
throw new Error(`Failed to download patch ${stepName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback(`Applying patch ${i + 1}/${updatePlan.steps.length} (${stepName})...`, null, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyPWR(pwrFile, progressCallback, gameDir, toolsDir, branch, cacheDir, true);
|
||||||
|
|
||||||
|
// Clean up PWR file from cache
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(pwrFile)) {
|
||||||
|
fs.unlinkSync(pwrFile);
|
||||||
|
}
|
||||||
|
} catch (delErr) {
|
||||||
|
console.warn('[UpdateGameFiles] Failed to delete PWR from cache:', delErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save intermediate version so we can resume if interrupted
|
||||||
|
saveVersionClient(`v${step.to}`);
|
||||||
|
console.log(`[UpdateGameFiles] Applied patch ${stepName} (${i + 1}/${updatePlan.steps.length})`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Full install: download 0->target, apply to temp dir, swap
|
||||||
tempUpdateDir = path.join(gameDir, '..', 'temp_update');
|
tempUpdateDir = path.join(gameDir, '..', 'temp_update');
|
||||||
|
|
||||||
if (fs.existsSync(tempUpdateDir)) {
|
if (fs.existsSync(tempUpdateDir)) {
|
||||||
@@ -415,7 +516,7 @@ async function updateGameFiles(newVersion, progressCallback, gameDir = GAME_DIR,
|
|||||||
}
|
}
|
||||||
|
|
||||||
await applyPWR(pwrFile, progressCallback, tempUpdateDir, toolsDir, branch, cacheDir);
|
await applyPWR(pwrFile, progressCallback, tempUpdateDir, toolsDir, branch, cacheDir);
|
||||||
// Delete PWR file from cache after successful update
|
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(pwrFile)) {
|
if (fs.existsSync(pwrFile)) {
|
||||||
fs.unlinkSync(pwrFile);
|
fs.unlinkSync(pwrFile);
|
||||||
@@ -424,6 +525,7 @@ async function updateGameFiles(newVersion, progressCallback, gameDir = GAME_DIR,
|
|||||||
} catch (delErr) {
|
} catch (delErr) {
|
||||||
console.warn('[UpdateGameFiles] Failed to delete PWR file from cache:', delErr.message);
|
console.warn('[UpdateGameFiles] Failed to delete PWR file from cache:', delErr.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (progressCallback) {
|
if (progressCallback) {
|
||||||
progressCallback('Replacing game files...', 80, null, null, null);
|
progressCallback('Replacing game files...', 80, null, null, null);
|
||||||
}
|
}
|
||||||
@@ -448,6 +550,7 @@ async function updateGameFiles(newVersion, progressCallback, gameDir = GAME_DIR,
|
|||||||
}
|
}
|
||||||
|
|
||||||
fs.renameSync(tempUpdateDir, gameDir);
|
fs.renameSync(tempUpdateDir, gameDir);
|
||||||
|
}
|
||||||
|
|
||||||
const homeUIResult = await downloadAndReplaceHomePageUI(gameDir, progressCallback);
|
const homeUIResult = await downloadAndReplaceHomePageUI(gameDir, progressCallback);
|
||||||
console.log('HomePage.ui update result after update:', homeUIResult);
|
console.log('HomePage.ui update result after update:', homeUIResult);
|
||||||
@@ -818,7 +921,8 @@ function validateGameDirectory(gameDir, stagingDir) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Enhanced PWR file validation
|
// Enhanced PWR file validation
|
||||||
function validatePWRFile(filePath) {
|
// Accepts intermediate patches (50+ MB) and full installs (1.5+ GB)
|
||||||
|
function validatePWRFile(filePath, expectedSize = null) {
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -827,27 +931,20 @@ function validatePWRFile(filePath) {
|
|||||||
const stats = fs.statSync(filePath);
|
const stats = fs.statSync(filePath);
|
||||||
const sizeInMB = stats.size / 1024 / 1024;
|
const sizeInMB = stats.size / 1024 / 1024;
|
||||||
|
|
||||||
|
// PWR files should be at least 1 MB
|
||||||
if (stats.size < 1024 * 1024) {
|
if (stats.size < 1024 * 1024) {
|
||||||
|
console.log(`[PWR Validation] File too small: ${sizeInMB.toFixed(2)} MB`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file is under 1.5 GB (incomplete download)
|
// Validate against expected size if known (reject if < 99% of expected)
|
||||||
if (sizeInMB < 1500) {
|
if (expectedSize && stats.size < expectedSize * 0.99) {
|
||||||
console.log(`[PWR Validation] File appears incomplete: ${sizeInMB.toFixed(2)} MB < 1.5 GB`);
|
const expectedMB = expectedSize / 1024 / 1024;
|
||||||
|
console.log(`[PWR Validation] File truncated: ${sizeInMB.toFixed(2)} MB, expected ${expectedMB.toFixed(2)} MB`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic file header validation (PWR files should have specific headers)
|
console.log(`[PWR Validation] File size: ${sizeInMB.toFixed(2)} MB - OK`);
|
||||||
const buffer = fs.readFileSync(filePath, { start: 0, end: 20 });
|
|
||||||
if (buffer.length < 10) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for common PWR magic bytes or patterns
|
|
||||||
// This is a basic check - could be enhanced with actual PWR format specification
|
|
||||||
const header = buffer.toString('hex', 0, 10);
|
|
||||||
console.log(`[PWR Validation] File header: ${header}`);
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[PWR Validation] Error:`, error.message);
|
console.error(`[PWR Validation] Error:`, error.message);
|
||||||
|
|||||||
@@ -1,133 +1,500 @@
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
const { getOS, getArch } = require('../utils/platformUtils');
|
const { getOS, getArch } = require('../utils/platformUtils');
|
||||||
const { smartRequest } = require('../utils/proxyClient');
|
|
||||||
|
|
||||||
const BASE_PATCH_URL = 'https://game-patches.hytale.com/patches';
|
// Patches base URL fetched dynamically via multi-source fallback chain
|
||||||
const MANIFEST_API = 'https://files.hytalef2p.com/api/patch_manifest';
|
const AUTH_DOMAIN = process.env.HYTALE_AUTH_DOMAIN || 'auth.sanasol.ws';
|
||||||
|
const PATCHES_CONFIG_SOURCES = [
|
||||||
|
{ type: 'http', url: `https://${AUTH_DOMAIN}/api/patches-config`, name: 'primary' },
|
||||||
|
{ type: 'http', url: 'https://htdwnldsan.top/patches-config', name: 'backup-1' },
|
||||||
|
{ type: 'http', url: 'https://dl1.htdwnldsan.top/patches-config', name: 'backup-2' },
|
||||||
|
{ type: 'doh', name: '_patches.htdwnldsan.top', name_label: 'dns-txt' },
|
||||||
|
];
|
||||||
|
const HARDCODED_FALLBACK = 'https://dl.vboro.de/patches';
|
||||||
|
|
||||||
|
// Alternative mirrors (non-Cloudflare) for regions where CF is blocked
|
||||||
|
const NON_CF_MIRRORS = [
|
||||||
|
'https://dl1.htdwnldsan.top',
|
||||||
|
'https://htdwnldsan.top/patches',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Fallback: latest known build number if manifest is unreachable
|
||||||
|
const FALLBACK_LATEST_BUILD = 11;
|
||||||
|
|
||||||
|
let patchesBaseUrl = null;
|
||||||
|
let patchesConfigTime = 0;
|
||||||
|
const PATCHES_CONFIG_CACHE_DURATION = 300000; // 5 minutes
|
||||||
|
|
||||||
|
let manifestCache = null;
|
||||||
|
let manifestCacheTime = 0;
|
||||||
|
const MANIFEST_CACHE_DURATION = 60000; // 1 minute
|
||||||
|
|
||||||
|
// Disk cache path for patches URL (survives restarts)
|
||||||
|
function getDiskCachePath() {
|
||||||
|
const os = require('os');
|
||||||
|
const home = os.homedir();
|
||||||
|
let appDir;
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
appDir = path.join(home, 'AppData', 'Local', 'HytaleF2P');
|
||||||
|
} else if (process.platform === 'darwin') {
|
||||||
|
appDir = path.join(home, 'Library', 'Application Support', 'HytaleF2P');
|
||||||
|
} else {
|
||||||
|
appDir = path.join(home, '.hytalef2p');
|
||||||
|
}
|
||||||
|
return path.join(appDir, 'patches-url-cache.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDiskCache(url) {
|
||||||
|
try {
|
||||||
|
const cachePath = getDiskCachePath();
|
||||||
|
const dir = path.dirname(cachePath);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(cachePath, JSON.stringify({ patches_url: url, ts: Date.now() }), 'utf8');
|
||||||
|
} catch (e) {
|
||||||
|
// Non-critical, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDiskCache() {
|
||||||
|
try {
|
||||||
|
const cachePath = getDiskCachePath();
|
||||||
|
if (fs.existsSync(cachePath)) {
|
||||||
|
const data = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
|
||||||
|
if (data && data.patches_url) return data.patches_url;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Non-critical, ignore
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch patches URL from a single HTTP config endpoint
|
||||||
|
*/
|
||||||
|
async function fetchFromHttp(url) {
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
timeout: 8000,
|
||||||
|
headers: { 'User-Agent': 'Hytale-F2P-Launcher' }
|
||||||
|
});
|
||||||
|
if (response.data && response.data.patches_url) {
|
||||||
|
return response.data.patches_url.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
throw new Error('Invalid response');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch patches URL from DNS TXT record via DNS-over-HTTPS
|
||||||
|
*/
|
||||||
|
async function fetchFromDoh(recordName) {
|
||||||
|
const dohEndpoints = [
|
||||||
|
{ url: 'https://dns.google/resolve', params: { name: recordName, type: 'TXT' } },
|
||||||
|
{ url: 'https://cloudflare-dns.com/dns-query', params: { name: recordName, type: 'TXT' }, headers: { 'Accept': 'application/dns-json' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const endpoint of dohEndpoints) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(endpoint.url, {
|
||||||
|
params: endpoint.params,
|
||||||
|
headers: { 'User-Agent': 'Hytale-F2P-Launcher', ...(endpoint.headers || {}) },
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
const answers = response.data && response.data.Answer;
|
||||||
|
if (answers && answers.length > 0) {
|
||||||
|
// TXT records are quoted, strip quotes
|
||||||
|
const txt = answers[0].data.replace(/^"|"$/g, '');
|
||||||
|
if (txt.startsWith('http')) return txt.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Try next DoH endpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('All DoH endpoints failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch patches base URL with hardened multi-source fallback chain:
|
||||||
|
* 1. Memory cache (5 min)
|
||||||
|
* 2. HTTP: auth.sanasol.ws (primary)
|
||||||
|
* 3. HTTP: htdwnldsan.top (backup, different host/domain/registrar)
|
||||||
|
* 4. DNS TXT: _patches.htdwnldsan.top via DoH (different protocol layer)
|
||||||
|
* 5. Disk cache (survives restarts, never expires)
|
||||||
|
* 6. Hardcoded fallback URL (last resort)
|
||||||
|
*/
|
||||||
|
async function getPatchesBaseUrl() {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// 1. Memory cache
|
||||||
|
if (patchesBaseUrl && (now - patchesConfigTime) < PATCHES_CONFIG_CACHE_DURATION) {
|
||||||
|
return patchesBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2-4. Try all sources: HTTP endpoints first, then DoH
|
||||||
|
for (const source of PATCHES_CONFIG_SOURCES) {
|
||||||
|
try {
|
||||||
|
let url;
|
||||||
|
if (source.type === 'http') {
|
||||||
|
console.log(`[Mirror] Trying ${source.name}: ${source.url}`);
|
||||||
|
url = await fetchFromHttp(source.url);
|
||||||
|
} else if (source.type === 'doh') {
|
||||||
|
console.log(`[Mirror] Trying ${source.name_label}: ${source.name}`);
|
||||||
|
url = await fetchFromDoh(source.name);
|
||||||
|
}
|
||||||
|
if (url) {
|
||||||
|
patchesBaseUrl = url;
|
||||||
|
patchesConfigTime = now;
|
||||||
|
saveDiskCache(url);
|
||||||
|
console.log(`[Mirror] Patches URL (via ${source.name || source.name_label}): ${url}`);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Mirror] ${source.name || source.name_label} failed: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Stale memory cache (any age)
|
||||||
|
if (patchesBaseUrl) {
|
||||||
|
console.log('[Mirror] All sources failed, using stale memory cache:', patchesBaseUrl);
|
||||||
|
return patchesBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Disk cache (survives restarts)
|
||||||
|
const diskUrl = loadDiskCache();
|
||||||
|
if (diskUrl) {
|
||||||
|
patchesBaseUrl = diskUrl;
|
||||||
|
console.log('[Mirror] All sources failed, using disk cache:', diskUrl);
|
||||||
|
return diskUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Hardcoded fallback
|
||||||
|
console.warn('[Mirror] All sources + caches exhausted, using hardcoded fallback:', HARDCODED_FALLBACK);
|
||||||
|
patchesBaseUrl = HARDCODED_FALLBACK;
|
||||||
|
return HARDCODED_FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available mirror base URLs (primary + non-Cloudflare fallbacks)
|
||||||
|
* Used by download logic to retry on different mirrors when primary is blocked
|
||||||
|
*/
|
||||||
|
async function getAllMirrorUrls() {
|
||||||
|
const primary = await getPatchesBaseUrl();
|
||||||
|
// Deduplicate: don't include mirrors that match primary
|
||||||
|
const mirrors = NON_CF_MIRRORS.filter(m => m !== primary);
|
||||||
|
return [primary, ...mirrors];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the mirror manifest — tries primary URL first, then non-Cloudflare mirrors
|
||||||
|
*/
|
||||||
|
async function fetchMirrorManifest() {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (manifestCache && (now - manifestCacheTime) < MANIFEST_CACHE_DURATION) {
|
||||||
|
console.log('[Mirror] Using cached manifest');
|
||||||
|
return manifestCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mirrors = await getAllMirrorUrls();
|
||||||
|
|
||||||
|
for (let i = 0; i < mirrors.length; i++) {
|
||||||
|
const baseUrl = mirrors[i];
|
||||||
|
const manifestUrl = `${baseUrl}/manifest.json`;
|
||||||
|
try {
|
||||||
|
console.log(`[Mirror] Fetching manifest from: ${manifestUrl}`);
|
||||||
|
const response = await axios.get(manifestUrl, {
|
||||||
|
timeout: 15000,
|
||||||
|
maxRedirects: 5,
|
||||||
|
headers: { 'User-Agent': 'Hytale-F2P-Launcher' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data && response.data.files) {
|
||||||
|
manifestCache = response.data;
|
||||||
|
manifestCacheTime = now;
|
||||||
|
// If a non-primary mirror worked, switch to it for downloads too
|
||||||
|
if (i > 0) {
|
||||||
|
console.log(`[Mirror] Primary unreachable, switching to mirror: ${baseUrl}`);
|
||||||
|
patchesBaseUrl = baseUrl;
|
||||||
|
patchesConfigTime = now;
|
||||||
|
saveDiskCache(baseUrl);
|
||||||
|
}
|
||||||
|
console.log('[Mirror] Manifest fetched successfully');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
throw new Error('Invalid manifest structure');
|
||||||
|
} catch (error) {
|
||||||
|
const isTimeout = error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED' || error.message.includes('timeout');
|
||||||
|
console.error(`[Mirror] Error fetching manifest from ${baseUrl}: ${error.message}${isTimeout ? ' (Cloudflare may be blocked)' : ''}`);
|
||||||
|
if (i < mirrors.length - 1) {
|
||||||
|
console.log(`[Mirror] Trying next mirror...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All mirrors failed — use cached manifest if available
|
||||||
|
if (manifestCache) {
|
||||||
|
console.log('[Mirror] All mirrors failed, using expired cache');
|
||||||
|
return manifestCache;
|
||||||
|
}
|
||||||
|
throw new Error('All mirrors failed and no cached manifest available');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse manifest to get available patches for current platform
|
||||||
|
* Returns array of { from, to, key, size }
|
||||||
|
*/
|
||||||
|
function getPlatformPatches(manifest, branch = 'release') {
|
||||||
|
const os = getOS();
|
||||||
|
const arch = getArch();
|
||||||
|
const prefix = `${os}/${arch}/${branch}/`;
|
||||||
|
const patches = [];
|
||||||
|
|
||||||
|
for (const [key, info] of Object.entries(manifest.files)) {
|
||||||
|
if (key.startsWith(prefix) && key.endsWith('.pwr')) {
|
||||||
|
const filename = key.slice(prefix.length, -4); // e.g., "0_to_11"
|
||||||
|
const match = filename.match(/^(\d+)_to_(\d+)$/);
|
||||||
|
if (match) {
|
||||||
|
patches.push({
|
||||||
|
from: parseInt(match[1]),
|
||||||
|
to: parseInt(match[2]),
|
||||||
|
key,
|
||||||
|
size: info.size
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return patches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find optimal patch path using BFS with download size minimization
|
||||||
|
* Returns array of { from, to, url, size, key } steps, or null if no path found
|
||||||
|
*/
|
||||||
|
async function findOptimalPatchPath(currentBuild, targetBuild, patches) {
|
||||||
|
if (currentBuild >= targetBuild) return [];
|
||||||
|
|
||||||
|
const baseUrl = await getPatchesBaseUrl();
|
||||||
|
const edges = {};
|
||||||
|
for (const patch of patches) {
|
||||||
|
if (!edges[patch.from]) edges[patch.from] = [];
|
||||||
|
edges[patch.from].push(patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = [{ build: currentBuild, path: [], totalSize: 0 }];
|
||||||
|
let bestPath = null;
|
||||||
|
let bestSize = Infinity;
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const { build, path, totalSize } = queue.shift();
|
||||||
|
|
||||||
|
if (build === targetBuild) {
|
||||||
|
if (totalSize < bestSize) {
|
||||||
|
bestPath = path;
|
||||||
|
bestSize = totalSize;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalSize >= bestSize) continue;
|
||||||
|
|
||||||
|
const nextEdges = edges[build] || [];
|
||||||
|
for (const edge of nextEdges) {
|
||||||
|
if (edge.to <= build || edge.to > targetBuild) continue;
|
||||||
|
if (path.some(p => p.to === edge.to)) continue;
|
||||||
|
|
||||||
|
queue.push({
|
||||||
|
build: edge.to,
|
||||||
|
path: [...path, {
|
||||||
|
from: edge.from,
|
||||||
|
to: edge.to,
|
||||||
|
url: `${baseUrl}/${edge.key}`,
|
||||||
|
size: edge.size,
|
||||||
|
key: edge.key
|
||||||
|
}],
|
||||||
|
totalSize: totalSize + edge.size
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the optimal update plan from currentBuild to targetBuild
|
||||||
|
* Returns { steps: [{from, to, url, size}], totalSize, isFullInstall }
|
||||||
|
*/
|
||||||
|
async function getUpdatePlan(currentBuild, targetBuild, branch = 'release') {
|
||||||
|
const manifest = await fetchMirrorManifest();
|
||||||
|
const patches = getPlatformPatches(manifest, branch);
|
||||||
|
|
||||||
|
// Try optimal path
|
||||||
|
const steps = await findOptimalPatchPath(currentBuild, targetBuild, patches);
|
||||||
|
|
||||||
|
if (steps && steps.length > 0) {
|
||||||
|
const totalSize = steps.reduce((sum, s) => sum + s.size, 0);
|
||||||
|
console.log(`[Mirror] Update plan: ${steps.map(s => `${s.from}\u2192${s.to}`).join(' + ')} (${(totalSize / 1024 / 1024).toFixed(0)} MB)`);
|
||||||
|
return { steps, totalSize, isFullInstall: steps.length === 1 && steps[0].from === 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: full install 0 -> target
|
||||||
|
const fullPatch = patches.find(p => p.from === 0 && p.to === targetBuild);
|
||||||
|
if (fullPatch) {
|
||||||
|
const baseUrl = await getPatchesBaseUrl();
|
||||||
|
const step = {
|
||||||
|
from: 0,
|
||||||
|
to: targetBuild,
|
||||||
|
url: `${baseUrl}/${fullPatch.key}`,
|
||||||
|
size: fullPatch.size,
|
||||||
|
key: fullPatch.key
|
||||||
|
};
|
||||||
|
console.log(`[Mirror] Full install: 0\u2192${targetBuild} (${(fullPatch.size / 1024 / 1024).toFixed(0)} MB)`);
|
||||||
|
return { steps: [step], totalSize: fullPatch.size, isFullInstall: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`No patch path found from build ${currentBuild} to ${targetBuild} for ${getOS()}/${getArch()}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function getLatestClientVersion(branch = 'release') {
|
async function getLatestClientVersion(branch = 'release') {
|
||||||
try {
|
try {
|
||||||
console.log(`Fetching latest client version from API (branch: ${branch})...`);
|
console.log(`[Mirror] Fetching latest client version (branch: ${branch})...`);
|
||||||
const response = await smartRequest(`https://files.hytalef2p.com/api/version_client?branch=${branch}`, {
|
const manifest = await fetchMirrorManifest();
|
||||||
timeout: 40000,
|
const patches = getPlatformPatches(manifest, branch);
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Hytale-F2P-Launcher'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data && response.data.client_version) {
|
if (patches.length === 0) {
|
||||||
const version = response.data.client_version;
|
console.log(`[Mirror] No patches for branch '${branch}', using fallback`);
|
||||||
console.log(`Latest client version for ${branch}: ${version}`);
|
return `v${FALLBACK_LATEST_BUILD}`;
|
||||||
return version;
|
|
||||||
} else {
|
|
||||||
console.log('Warning: Invalid API response, falling back to latest known version (7.pwr)');
|
|
||||||
return '7.pwr';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const latestBuild = Math.max(...patches.map(p => p.to));
|
||||||
|
console.log(`[Mirror] Latest client version: v${latestBuild}`);
|
||||||
|
return `v${latestBuild}`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching client version:', error.message);
|
console.error('[Mirror] Error:', error.message);
|
||||||
console.log('Warning: API unavailable, falling back to latest known version (7.pwr)');
|
return `v${FALLBACK_LATEST_BUILD}`;
|
||||||
return '7.pwr';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildArchiveUrl(buildNumber, branch = 'release') {
|
/**
|
||||||
|
* Get PWR download URL for fresh install (0 -> target)
|
||||||
|
* Backward-compatible with old getPWRUrlFromNewAPI signature
|
||||||
|
* Checks mirror first, then constructs URL for the branch
|
||||||
|
*/
|
||||||
|
async function getPWRUrl(branch = 'release', version = 'v11') {
|
||||||
|
const targetBuild = extractVersionNumber(version);
|
||||||
const os = getOS();
|
const os = getOS();
|
||||||
const arch = getArch();
|
const arch = getArch();
|
||||||
return `${BASE_PATCH_URL}/${os}/${arch}/${branch}/0/${buildNumber}.pwr`;
|
|
||||||
|
try {
|
||||||
|
const manifest = await fetchMirrorManifest();
|
||||||
|
const patches = getPlatformPatches(manifest, branch);
|
||||||
|
const fullPatch = patches.find(p => p.from === 0 && p.to === targetBuild);
|
||||||
|
|
||||||
|
if (fullPatch) {
|
||||||
|
const baseUrl = await getPatchesBaseUrl();
|
||||||
|
const url = `${baseUrl}/${fullPatch.key}`;
|
||||||
|
console.log(`[Mirror] PWR URL: ${url}`);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patches.length > 0) {
|
||||||
|
// Branch exists in mirror but no full patch for this target - construct URL
|
||||||
|
console.log(`[Mirror] No 0->${targetBuild} patch found, constructing URL`);
|
||||||
|
} else {
|
||||||
|
console.log(`[Mirror] Branch '${branch}' not in mirror, constructing URL`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Mirror] Error getting PWR URL:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct mirror URL (will work if patch was uploaded but manifest is stale)
|
||||||
|
const baseUrl = await getPatchesBaseUrl();
|
||||||
|
return `${baseUrl}/${os}/${arch}/${branch}/0_to_${targetBuild}.pwr`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward-compatible alias
|
||||||
|
const getPWRUrlFromNewAPI = getPWRUrl;
|
||||||
|
|
||||||
|
// Utility function to extract version number
|
||||||
|
// Supports: "7.pwr", "v8", "v8-windows-amd64.pwr", "5_to_10", etc.
|
||||||
|
function extractVersionNumber(version) {
|
||||||
|
if (!version) return 0;
|
||||||
|
|
||||||
|
// New format: "v8" or "v8-xxx.pwr"
|
||||||
|
const vMatch = version.match(/v(\d+)/);
|
||||||
|
if (vMatch) return parseInt(vMatch[1]);
|
||||||
|
|
||||||
|
// Old format: "7.pwr"
|
||||||
|
const pwrMatch = version.match(/(\d+)\.pwr/);
|
||||||
|
if (pwrMatch) return parseInt(pwrMatch[1]);
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
const num = parseInt(version);
|
||||||
|
return isNaN(num) ? 0 : num;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildArchiveUrl(buildNumber, branch = 'release') {
|
||||||
|
const baseUrl = await getPatchesBaseUrl();
|
||||||
|
const os = getOS();
|
||||||
|
const arch = getArch();
|
||||||
|
return `${baseUrl}/${os}/${arch}/${branch}/0_to_${buildNumber}.pwr`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkArchiveExists(buildNumber, branch = 'release') {
|
async function checkArchiveExists(buildNumber, branch = 'release') {
|
||||||
const url = buildArchiveUrl(buildNumber, branch);
|
const url = await buildArchiveUrl(buildNumber, branch);
|
||||||
try {
|
try {
|
||||||
const response = await axios.head(url, { timeout: 10000 });
|
const response = await axios.head(url, { timeout: 10000 });
|
||||||
return response.status === 200;
|
return response.status === 200;
|
||||||
} catch (error) {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function discoverAvailableVersions(latestKnown, branch = 'release', maxProbe = 50) {
|
async function discoverAvailableVersions(latestKnown, branch = 'release') {
|
||||||
const available = [];
|
|
||||||
const latest = parseInt(latestKnown.replace('.pwr', ''));
|
|
||||||
|
|
||||||
for (let i = latest; i >= Math.max(1, latest - maxProbe); i--) {
|
|
||||||
const exists = await checkArchiveExists(i, branch);
|
|
||||||
if (exists) {
|
|
||||||
available.push(`${i}.pwr`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return available;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchPatchManifest(branch = 'release') {
|
|
||||||
try {
|
try {
|
||||||
const os = getOS();
|
const manifest = await fetchMirrorManifest();
|
||||||
const arch = getArch();
|
const patches = getPlatformPatches(manifest, branch);
|
||||||
const response = await smartRequest(`${MANIFEST_API}?branch=${branch}&os=${os}&arch=${arch}`, {
|
const versions = [...new Set(patches.map(p => p.to))].sort((a, b) => b - a);
|
||||||
timeout: 10000
|
return versions.map(v => `${v}.pwr`);
|
||||||
});
|
} catch {
|
||||||
return response.data.patches || {};
|
return [];
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch patch manifest:', error.message);
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function extractVersionDetails(targetVersion, branch = 'release') {
|
async function extractVersionDetails(targetVersion, branch = 'release') {
|
||||||
const buildNumber = parseInt(targetVersion.replace('.pwr', ''));
|
const buildNumber = extractVersionNumber(targetVersion);
|
||||||
const previousBuild = buildNumber - 1;
|
const fullUrl = await buildArchiveUrl(buildNumber, branch);
|
||||||
|
|
||||||
const manifest = await fetchPatchManifest(branch);
|
|
||||||
const patchInfo = manifest[buildNumber];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: targetVersion,
|
version: targetVersion,
|
||||||
buildNumber: buildNumber,
|
buildNumber,
|
||||||
buildName: `HYTALE-Build-${buildNumber}`,
|
buildName: `HYTALE-Build-${buildNumber}`,
|
||||||
fullUrl: patchInfo?.original_url || buildArchiveUrl(buildNumber, branch),
|
fullUrl,
|
||||||
differentialUrl: patchInfo?.patch_url || null,
|
differentialUrl: null,
|
||||||
checksum: patchInfo?.patch_hash || null,
|
checksum: null,
|
||||||
sourceVersion: patchInfo?.from ? `${patchInfo.from}.pwr` : (previousBuild > 0 ? `${previousBuild}.pwr` : null),
|
sourceVersion: null,
|
||||||
isDifferential: !!patchInfo?.proper_patch,
|
isDifferential: false,
|
||||||
releaseNotes: patchInfo?.patch_note || null
|
releaseNotes: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function canUseDifferentialUpdate(currentVersion, targetDetails) {
|
function canUseDifferentialUpdate() {
|
||||||
if (!targetDetails) return false;
|
// Differential updates are now handled via getUpdatePlan()
|
||||||
if (!targetDetails.differentialUrl) return false;
|
return false;
|
||||||
if (!targetDetails.isDifferential) return false;
|
|
||||||
|
|
||||||
if (!currentVersion) return false;
|
|
||||||
|
|
||||||
const currentBuild = parseInt(currentVersion.replace('.pwr', ''));
|
|
||||||
const expectedSource = parseInt(targetDetails.sourceVersion?.replace('.pwr', '') || '0');
|
|
||||||
|
|
||||||
return currentBuild === expectedSource;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function needsIntermediatePatches(currentVersion, targetVersion) {
|
function needsIntermediatePatches(currentVersion, targetVersion) {
|
||||||
if (!currentVersion) return [];
|
if (!currentVersion) return [];
|
||||||
|
const current = extractVersionNumber(currentVersion);
|
||||||
const current = parseInt(currentVersion.replace('.pwr', ''));
|
const target = extractVersionNumber(targetVersion);
|
||||||
const target = parseInt(targetVersion.replace('.pwr', ''));
|
if (current >= target) return [];
|
||||||
|
return [targetVersion];
|
||||||
const intermediates = [];
|
|
||||||
for (let i = current + 1; i <= target; i++) {
|
|
||||||
intermediates.push(`${i}.pwr`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return intermediates;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function computeFileChecksum(filePath) {
|
async function computeFileChecksum(filePath) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const hash = crypto.createHash('sha256');
|
const hash = crypto.createHash('sha256');
|
||||||
const stream = fs.createReadStream(filePath);
|
const stream = fs.createReadStream(filePath);
|
||||||
|
|
||||||
stream.on('data', data => hash.update(data));
|
stream.on('data', data => hash.update(data));
|
||||||
stream.on('end', () => resolve(hash.digest('hex')));
|
stream.on('end', () => resolve(hash.digest('hex')));
|
||||||
stream.on('error', reject);
|
stream.on('error', reject);
|
||||||
@@ -136,7 +503,6 @@ async function computeFileChecksum(filePath) {
|
|||||||
|
|
||||||
async function validateChecksum(filePath, expectedChecksum) {
|
async function validateChecksum(filePath, expectedChecksum) {
|
||||||
if (!expectedChecksum) return true;
|
if (!expectedChecksum) return true;
|
||||||
|
|
||||||
const actualChecksum = await computeFileChecksum(filePath);
|
const actualChecksum = await computeFileChecksum(filePath);
|
||||||
return actualChecksum === expectedChecksum;
|
return actualChecksum === expectedChecksum;
|
||||||
}
|
}
|
||||||
@@ -145,7 +511,7 @@ function getInstalledClientVersion() {
|
|||||||
try {
|
try {
|
||||||
const { loadVersionClient } = require('../core/config');
|
const { loadVersionClient } = require('../core/config');
|
||||||
return loadVersionClient();
|
return loadVersionClient();
|
||||||
} catch (err) {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,5 +526,14 @@ module.exports = {
|
|||||||
needsIntermediatePatches,
|
needsIntermediatePatches,
|
||||||
computeFileChecksum,
|
computeFileChecksum,
|
||||||
validateChecksum,
|
validateChecksum,
|
||||||
getInstalledClientVersion
|
getInstalledClientVersion,
|
||||||
|
fetchMirrorManifest,
|
||||||
|
getPWRUrl,
|
||||||
|
getPWRUrlFromNewAPI,
|
||||||
|
getUpdatePlan,
|
||||||
|
extractVersionNumber,
|
||||||
|
getPlatformPatches,
|
||||||
|
findOptimalPatchPath,
|
||||||
|
getPatchesBaseUrl,
|
||||||
|
getAllMirrorUrls
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ const ORIGINAL_DOMAIN = 'hytale.com';
|
|||||||
const MIN_DOMAIN_LENGTH = 4;
|
const MIN_DOMAIN_LENGTH = 4;
|
||||||
const MAX_DOMAIN_LENGTH = 16;
|
const MAX_DOMAIN_LENGTH = 16;
|
||||||
|
|
||||||
|
// DualAuth ByteBuddy Agent (runtime class transformation, no JAR modification)
|
||||||
|
const DUALAUTH_AGENT_URL = 'https://github.com/sanasol/hytale-auth-server/releases/latest/download/dualauth-agent.jar';
|
||||||
|
const DUALAUTH_AGENT_VERSION_API = 'https://api.github.com/repos/sanasol/hytale-auth-server/releases/latest';
|
||||||
|
const DUALAUTH_AGENT_FILENAME = 'dualauth-agent.jar';
|
||||||
|
const DUALAUTH_AGENT_VERSION_FILE = 'dualauth-agent.version';
|
||||||
|
|
||||||
function getTargetDomain() {
|
function getTargetDomain() {
|
||||||
if (process.env.HYTALE_AUTH_DOMAIN) {
|
if (process.env.HYTALE_AUTH_DOMAIN) {
|
||||||
return process.env.HYTALE_AUTH_DOMAIN;
|
return process.env.HYTALE_AUTH_DOMAIN;
|
||||||
@@ -23,7 +29,7 @@ const DEFAULT_NEW_DOMAIN = 'auth.sanasol.ws';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Patches HytaleClient binary to replace hytale.com with custom domain
|
* Patches HytaleClient binary to replace hytale.com with custom domain
|
||||||
* Server patching is done via pre-patched JAR download from CDN
|
* Server auth is handled by DualAuth ByteBuddy Agent (-javaagent: flag)
|
||||||
*
|
*
|
||||||
* Supports domains from 4 to 16 characters:
|
* Supports domains from 4 to 16 characters:
|
||||||
* - All F2P traffic routes to single endpoint: https://{domain} (no subdomains)
|
* - All F2P traffic routes to single endpoint: https://{domain} (no subdomains)
|
||||||
@@ -494,211 +500,144 @@ class ClientPatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if server JAR contains DualAuth classes (was patched)
|
* Get the path to the DualAuth Agent JAR in a directory
|
||||||
*/
|
*/
|
||||||
serverJarContainsDualAuth(serverPath) {
|
getAgentPath(dir) {
|
||||||
try {
|
return path.join(dir, DUALAUTH_AGENT_FILENAME);
|
||||||
const data = fs.readFileSync(serverPath);
|
|
||||||
// Check for DualAuthContext class signature in JAR
|
|
||||||
const signature = Buffer.from('DualAuthContext', 'utf8');
|
|
||||||
return data.includes(signature);
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate downloaded file is not corrupt/partial
|
* Download DualAuth ByteBuddy Agent (replaces old pre-patched JAR approach)
|
||||||
* Server JAR should be at least 50MB
|
* The agent provides runtime class transformation via -javaagent: flag
|
||||||
|
* No server JAR modification needed - original JAR stays pristine
|
||||||
*/
|
*/
|
||||||
validateServerJarSize(serverPath) {
|
async ensureAgentAvailable(serverDir, progressCallback) {
|
||||||
|
const agentPath = this.getAgentPath(serverDir);
|
||||||
|
const versionPath = path.join(serverDir, DUALAUTH_AGENT_VERSION_FILE);
|
||||||
|
|
||||||
|
console.log('=== DualAuth Agent (ByteBuddy) ===');
|
||||||
|
console.log(`Target: ${agentPath}`);
|
||||||
|
|
||||||
|
// Check local version and whether file exists
|
||||||
|
let localVersion = null;
|
||||||
|
let agentExists = false;
|
||||||
|
if (fs.existsSync(agentPath)) {
|
||||||
try {
|
try {
|
||||||
const stats = fs.statSync(serverPath);
|
const stats = fs.statSync(agentPath);
|
||||||
const minSize = 50 * 1024 * 1024; // 50MB minimum
|
if (stats.size > 1024) {
|
||||||
if (stats.size < minSize) {
|
agentExists = true;
|
||||||
console.error(` Downloaded JAR too small: ${(stats.size / 1024 / 1024).toFixed(2)} MB (expected >50MB)`);
|
if (fs.existsSync(versionPath)) {
|
||||||
return false;
|
localVersion = fs.readFileSync(versionPath, 'utf8').trim();
|
||||||
}
|
|
||||||
console.log(` Downloaded size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`);
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Patch server JAR by downloading pre-patched version from CDN
|
|
||||||
*/
|
|
||||||
async patchServer(serverPath, progressCallback, branch = 'release') {
|
|
||||||
const newDomain = this.getNewDomain();
|
|
||||||
|
|
||||||
console.log('=== Server Patcher (Pre-patched Download) ===');
|
|
||||||
console.log(`Target: ${serverPath}`);
|
|
||||||
console.log(`Branch: ${branch}`);
|
|
||||||
console.log(`Domain: ${newDomain}`);
|
|
||||||
|
|
||||||
if (!fs.existsSync(serverPath)) {
|
|
||||||
const error = `Server JAR not found: ${serverPath}`;
|
|
||||||
console.error(error);
|
|
||||||
return { success: false, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already patched
|
|
||||||
const patchFlagFile = serverPath + '.dualauth_patched';
|
|
||||||
let needsRestore = false;
|
|
||||||
|
|
||||||
if (fs.existsSync(patchFlagFile)) {
|
|
||||||
try {
|
|
||||||
const flagData = JSON.parse(fs.readFileSync(patchFlagFile, 'utf8'));
|
|
||||||
if (flagData.domain === newDomain && flagData.branch === branch) {
|
|
||||||
// Verify JAR actually contains DualAuth classes (game may have auto-updated)
|
|
||||||
if (this.serverJarContainsDualAuth(serverPath)) {
|
|
||||||
console.log(`Server already patched for ${newDomain} (${branch}), skipping`);
|
|
||||||
if (progressCallback) progressCallback('Server already patched', 100);
|
|
||||||
return { success: true, alreadyPatched: true };
|
|
||||||
} else {
|
|
||||||
console.log(' Flag exists but JAR not patched (was auto-updated?), will re-download...');
|
|
||||||
// Delete stale flag file
|
|
||||||
try { fs.unlinkSync(patchFlagFile); } catch (e) { /* ignore */ }
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(`Server patched for "${flagData.domain}" (${flagData.branch}), need to change to "${newDomain}" (${branch})`);
|
console.log('Agent file appears corrupt, re-downloading...');
|
||||||
needsRestore = true;
|
fs.unlinkSync(agentPath);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Flag file corrupt, re-patch
|
console.warn('Could not check agent file:', e.message);
|
||||||
console.log(' Flag file corrupt, will re-download');
|
|
||||||
try { fs.unlinkSync(patchFlagFile); } catch (e) { /* ignore */ }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore backup if patched for different domain
|
// Check for updates from GitHub
|
||||||
if (needsRestore) {
|
let remoteVersion = null;
|
||||||
const backupPath = serverPath + '.original';
|
let needsDownload = !agentExists;
|
||||||
if (fs.existsSync(backupPath)) {
|
if (agentExists) {
|
||||||
if (progressCallback) progressCallback('Restoring original for domain change...', 5);
|
try {
|
||||||
console.log('Restoring original JAR from backup for re-patching...');
|
if (progressCallback) progressCallback('Checking for agent updates...', 5);
|
||||||
fs.copyFileSync(backupPath, serverPath);
|
const axios = require('axios');
|
||||||
if (fs.existsSync(patchFlagFile)) {
|
const resp = await axios.get(DUALAUTH_AGENT_VERSION_API, {
|
||||||
fs.unlinkSync(patchFlagFile);
|
timeout: 5000,
|
||||||
|
headers: { 'Accept': 'application/vnd.github.v3+json' }
|
||||||
|
});
|
||||||
|
remoteVersion = resp.data.tag_name; // e.g. "v1.1.10"
|
||||||
|
if (localVersion && localVersion === remoteVersion) {
|
||||||
|
console.log(`DualAuth Agent up to date (${localVersion})`);
|
||||||
|
if (progressCallback) progressCallback('DualAuth Agent ready', 100);
|
||||||
|
return { success: true, agentPath, alreadyExists: true, version: localVersion };
|
||||||
|
}
|
||||||
|
console.log(`Agent update available: ${localVersion || 'unknown'} → ${remoteVersion}`);
|
||||||
|
needsDownload = true;
|
||||||
|
} catch (e) {
|
||||||
|
// GitHub API failed - use existing agent if available
|
||||||
|
console.warn(`Could not check for updates: ${e.message}`);
|
||||||
|
if (agentExists) {
|
||||||
|
console.log(`Using existing agent (${localVersion || 'unknown version'})`);
|
||||||
|
if (progressCallback) progressCallback('DualAuth Agent ready', 100);
|
||||||
|
return { success: true, agentPath, alreadyExists: true, version: localVersion };
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.warn(' No backup found to restore - will download fresh patched JAR');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create backup
|
if (!needsDownload) {
|
||||||
if (progressCallback) progressCallback('Creating backup...', 10);
|
if (progressCallback) progressCallback('DualAuth Agent ready', 100);
|
||||||
console.log('Creating backup...');
|
return { success: true, agentPath, alreadyExists: true, version: localVersion };
|
||||||
const backupResult = this.backupClient(serverPath);
|
|
||||||
if (!backupResult) {
|
|
||||||
console.warn(' Could not create backup - proceeding without backup');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only support standard domain (auth.sanasol.ws) via pre-patched download
|
// Download agent from GitHub releases
|
||||||
if (newDomain !== 'auth.sanasol.ws' && newDomain !== 'sanasol.ws') {
|
const action = agentExists ? 'Updating' : 'Downloading';
|
||||||
console.error(`Domain "${newDomain}" requires DualAuthPatcher - only auth.sanasol.ws is supported via pre-patched download`);
|
if (progressCallback) progressCallback(`${action} DualAuth Agent...`, 20);
|
||||||
return { success: false, error: `Unsupported domain: ${newDomain}. Only auth.sanasol.ws is supported.` };
|
console.log(`${action} from: ${DUALAUTH_AGENT_URL}`);
|
||||||
}
|
|
||||||
|
|
||||||
// Download pre-patched JAR
|
|
||||||
if (progressCallback) progressCallback('Downloading patched server JAR...', 30);
|
|
||||||
console.log('Downloading pre-patched HytaleServer.jar...');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let url;
|
// Ensure server directory exists
|
||||||
if (branch === 'pre-release') {
|
if (!fs.existsSync(serverDir)) {
|
||||||
url = 'https://patcher.authbp.xyz/download/patched_prerelease';
|
fs.mkdirSync(serverDir, { recursive: true });
|
||||||
console.log(' Using pre-release patched server from:', url);
|
|
||||||
} else {
|
|
||||||
url = 'https://patcher.authbp.xyz/download/patched_release';
|
|
||||||
console.log(' Using release patched server from:', url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = fs.createWriteStream(serverPath);
|
const tmpPath = agentPath + '.tmp';
|
||||||
let totalSize = 0;
|
const file = fs.createWriteStream(tmpPath);
|
||||||
let downloaded = 0;
|
|
||||||
|
|
||||||
const stream = await smartDownloadStream(url, (chunk, downloadedBytes, total) => {
|
const stream = await smartDownloadStream(DUALAUTH_AGENT_URL, (chunk, downloadedBytes, total) => {
|
||||||
downloaded = downloadedBytes;
|
if (progressCallback && total) {
|
||||||
totalSize = total;
|
const percent = 20 + Math.floor((downloadedBytes / total) * 70);
|
||||||
if (progressCallback && totalSize) {
|
progressCallback(`${action} agent... ${(downloadedBytes / 1024).toFixed(0)} KB`, percent);
|
||||||
const percent = 30 + Math.floor((downloaded / totalSize) * 60);
|
|
||||||
progressCallback(`Downloading... ${(downloaded / 1024 / 1024).toFixed(2)} MB`, percent);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.pipe(file);
|
stream.pipe(file);
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
file.on('finish', () => {
|
file.on('finish', () => { file.close(); resolve(); });
|
||||||
file.close();
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
file.on('error', reject);
|
file.on('error', reject);
|
||||||
stream.on('error', reject);
|
stream.on('error', reject);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(' Download successful');
|
// Verify download
|
||||||
|
const stats = fs.statSync(tmpPath);
|
||||||
// Verify downloaded JAR size and contents
|
if (stats.size < 1024) {
|
||||||
if (progressCallback) progressCallback('Verifying downloaded JAR...', 95);
|
fs.unlinkSync(tmpPath);
|
||||||
|
const error = 'Downloaded agent too small (corrupt or failed download)';
|
||||||
if (!this.validateServerJarSize(serverPath)) {
|
console.error(error);
|
||||||
console.error('Downloaded JAR appears corrupt or incomplete');
|
return { success: false, error };
|
||||||
|
|
||||||
// Restore backup on verification failure
|
|
||||||
const backupPath = serverPath + '.original';
|
|
||||||
if (fs.existsSync(backupPath)) {
|
|
||||||
fs.copyFileSync(backupPath, serverPath);
|
|
||||||
console.log('Restored backup after verification failure');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: false, error: 'Downloaded JAR verification failed - file too small (corrupt/partial download)' };
|
// Atomic move
|
||||||
|
if (fs.existsSync(agentPath)) {
|
||||||
|
fs.unlinkSync(agentPath);
|
||||||
}
|
}
|
||||||
|
fs.renameSync(tmpPath, agentPath);
|
||||||
|
|
||||||
if (!this.serverJarContainsDualAuth(serverPath)) {
|
// Save version
|
||||||
console.error('Downloaded JAR does not contain DualAuth classes - invalid or corrupt download');
|
const version = remoteVersion || 'unknown';
|
||||||
|
fs.writeFileSync(versionPath, version, 'utf8');
|
||||||
|
|
||||||
// Restore backup on verification failure
|
console.log(`DualAuth Agent ${agentExists ? 'updated' : 'downloaded'} (${(stats.size / 1024).toFixed(0)} KB, ${version})`);
|
||||||
const backupPath = serverPath + '.original';
|
if (progressCallback) progressCallback('DualAuth Agent ready', 100);
|
||||||
if (fs.existsSync(backupPath)) {
|
return { success: true, agentPath, updated: agentExists, version };
|
||||||
fs.copyFileSync(backupPath, serverPath);
|
|
||||||
console.log('Restored backup after verification failure');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: false, error: 'Downloaded JAR verification failed - missing DualAuth classes' };
|
|
||||||
}
|
|
||||||
console.log(' Verification successful - DualAuth classes present');
|
|
||||||
|
|
||||||
// Mark as patched
|
|
||||||
const sourceUrl = branch === 'pre-release'
|
|
||||||
? 'https://patcher.authbp.xyz/download/patched_prerelease'
|
|
||||||
: 'https://patcher.authbp.xyz/download/patched_release';
|
|
||||||
|
|
||||||
fs.writeFileSync(patchFlagFile, JSON.stringify({
|
|
||||||
domain: newDomain,
|
|
||||||
branch: branch,
|
|
||||||
patchedAt: new Date().toISOString(),
|
|
||||||
patcher: 'PrePatchedDownload',
|
|
||||||
source: sourceUrl
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (progressCallback) progressCallback('Server patching complete', 100);
|
|
||||||
console.log('=== Server Patching Complete ===');
|
|
||||||
return { success: true, patchCount: 1 };
|
|
||||||
|
|
||||||
} catch (downloadError) {
|
} catch (downloadError) {
|
||||||
console.error(`Failed to download patched JAR: ${downloadError.message}`);
|
console.error(`Failed to download DualAuth Agent: ${downloadError.message}`);
|
||||||
|
// Clean up temp file
|
||||||
// Restore backup on failure
|
const tmpPath = agentPath + '.tmp';
|
||||||
const backupPath = serverPath + '.original';
|
if (fs.existsSync(tmpPath)) {
|
||||||
if (fs.existsSync(backupPath)) {
|
try { fs.unlinkSync(tmpPath); } catch (e) { /* ignore */ }
|
||||||
fs.copyFileSync(backupPath, serverPath);
|
|
||||||
console.log('Restored backup after download failure');
|
|
||||||
}
|
}
|
||||||
|
// If we had an existing agent, still use it
|
||||||
return { success: false, error: `Failed to download patched server: ${downloadError.message}` };
|
if (agentExists) {
|
||||||
|
console.log('Using existing agent despite update failure');
|
||||||
|
return { success: true, agentPath, alreadyExists: true, version: localVersion };
|
||||||
|
}
|
||||||
|
return { success: false, error: downloadError.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,12 +682,12 @@ class ClientPatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure both client and server are patched before launching
|
* Ensure client is patched and DualAuth Agent is available before launching
|
||||||
*/
|
*/
|
||||||
async ensureClientPatched(gameDir, progressCallback, javaPath = null, branch = 'release') {
|
async ensureClientPatched(gameDir, progressCallback, javaPath = null, branch = 'release') {
|
||||||
const results = {
|
const results = {
|
||||||
client: null,
|
client: null,
|
||||||
server: null,
|
agent: null,
|
||||||
success: true
|
success: true
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -765,22 +704,23 @@ class ClientPatcher {
|
|||||||
results.client = { success: false, error: 'Client binary not found' };
|
results.client = { success: false, error: 'Client binary not found' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverPath = this.findServerPath(gameDir);
|
// Download DualAuth ByteBuddy Agent (runtime patching, no JAR modification)
|
||||||
if (serverPath) {
|
const serverDir = path.join(gameDir, 'Server');
|
||||||
if (progressCallback) progressCallback('Patching server JAR...', 50);
|
if (fs.existsSync(serverDir)) {
|
||||||
results.server = await this.patchServer(serverPath, (msg, pct) => {
|
if (progressCallback) progressCallback('Checking DualAuth Agent...', 50);
|
||||||
|
results.agent = await this.ensureAgentAvailable(serverDir, (msg, pct) => {
|
||||||
if (progressCallback) {
|
if (progressCallback) {
|
||||||
progressCallback(`Server: ${msg}`, pct ? 50 + pct / 2 : null);
|
progressCallback(`Agent: ${msg}`, pct ? 50 + pct / 2 : null);
|
||||||
}
|
}
|
||||||
}, branch);
|
});
|
||||||
} else {
|
} else {
|
||||||
console.warn('Could not find HytaleServer.jar');
|
console.warn('Server directory not found, skipping agent download');
|
||||||
results.server = { success: false, error: 'Server JAR not found' };
|
results.agent = { success: true, skipped: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
results.success = (results.client && results.client.success) || (results.server && results.server.success);
|
results.success = (results.client && results.client.success) || (results.agent && results.agent.success);
|
||||||
results.alreadyPatched = (results.client && results.client.alreadyPatched) && (results.server && results.server.alreadyPatched);
|
results.alreadyPatched = (results.client && results.client.alreadyPatched) && (results.agent && results.agent.alreadyExists);
|
||||||
results.patchCount = (results.client ? results.client.patchCount || 0 : 0) + (results.server ? results.server.patchCount || 0 : 0);
|
results.patchCount = results.client ? results.client.patchCount || 0 : 0;
|
||||||
|
|
||||||
if (progressCallback) progressCallback('Patching complete', 100);
|
if (progressCallback) progressCallback('Patching complete', 100);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
provider: github
|
provider: generic
|
||||||
owner: amiayweb # Change to your own GitHub username
|
url: https://git.sanhost.net/sanasol/hytale-f2p/releases/download/latest
|
||||||
repo: Hytale-F2P
|
|
||||||
|
|||||||
18
main.js
18
main.js
@@ -84,12 +84,12 @@ function setDiscordActivity() {
|
|||||||
largeImageText: 'Hytale F2P Launcher',
|
largeImageText: 'Hytale F2P Launcher',
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
label: 'GitHub',
|
label: 'Download',
|
||||||
url: 'https://github.com/amiayweb/Hytale-F2P'
|
url: 'https://git.sanhost.net/sanasol/hytale-f2p/releases'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Discord',
|
label: 'Discord',
|
||||||
url: 'https://discord.gg/hf2pdc'
|
url: 'https://discord.gg/Fhbb9Yk5WW'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -627,7 +627,7 @@ ipcMain.handle('install-game', async (event, playerName, javaPath, installPath,
|
|||||||
console.log('[Main] Processing Butler error with retry context');
|
console.log('[Main] Processing Butler error with retry context');
|
||||||
errorData.retryData = {
|
errorData.retryData = {
|
||||||
branch: error.branch || 'release',
|
branch: error.branch || 'release',
|
||||||
fileName: error.fileName || '7.pwr',
|
fileName: error.fileName || 'v8',
|
||||||
cacheDir: error.cacheDir
|
cacheDir: error.cacheDir
|
||||||
};
|
};
|
||||||
errorData.canRetry = error.canRetry !== false;
|
errorData.canRetry = error.canRetry !== false;
|
||||||
@@ -647,7 +647,7 @@ ipcMain.handle('install-game', async (event, playerName, javaPath, installPath,
|
|||||||
console.log('[Main] Processing generic error, creating default retry data');
|
console.log('[Main] Processing generic error, creating default retry data');
|
||||||
errorData.retryData = {
|
errorData.retryData = {
|
||||||
branch: 'release',
|
branch: 'release',
|
||||||
fileName: '7.pwr'
|
fileName: 'v8'
|
||||||
};
|
};
|
||||||
// For generic errors, assume it's retryable unless specified
|
// For generic errors, assume it's retryable unless specified
|
||||||
errorData.canRetry = error.canRetry !== false;
|
errorData.canRetry = error.canRetry !== false;
|
||||||
@@ -887,7 +887,7 @@ ipcMain.handle('retry-download', async (event, retryData) => {
|
|||||||
console.log('[IPC] Invalid retry data, using PWR defaults');
|
console.log('[IPC] Invalid retry data, using PWR defaults');
|
||||||
retryData = {
|
retryData = {
|
||||||
branch: 'release',
|
branch: 'release',
|
||||||
fileName: '7.pwr'
|
fileName: 'v8'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,7 +921,7 @@ ipcMain.handle('retry-download', async (event, retryData) => {
|
|||||||
} :
|
} :
|
||||||
{
|
{
|
||||||
branch: retryData?.branch || 'release',
|
branch: retryData?.branch || 'release',
|
||||||
fileName: retryData?.fileName || '7.pwr',
|
fileName: retryData?.fileName || 'v8',
|
||||||
cacheDir: retryData?.cacheDir
|
cacheDir: retryData?.cacheDir
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -964,8 +964,8 @@ ipcMain.handle('open-external', async (event, url) => {
|
|||||||
|
|
||||||
ipcMain.handle('open-download-page', async () => {
|
ipcMain.handle('open-download-page', async () => {
|
||||||
try {
|
try {
|
||||||
// Open GitHub releases page for manual download
|
// Open Forgejo releases page for manual download
|
||||||
await shell.openExternal('https://github.com/amiayweb/Hytale-F2P/releases/latest');
|
await shell.openExternal('https://git.sanhost.net/sanasol/hytale-f2p/releases/latest');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to open download page:', error);
|
console.error('Failed to open download page:', error);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "hytale-f2p-launcher",
|
"name": "hytale-f2p-launcher",
|
||||||
"version": "2.2.1",
|
"version": "2.3.8",
|
||||||
"description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support",
|
"description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support",
|
||||||
"homepage": "https://github.com/amiayweb/Hytale-F2P",
|
"homepage": "https://git.sanhost.net/sanasol/hytale-f2p",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
@@ -118,9 +118,8 @@
|
|||||||
"createStartMenuShortcut": true
|
"createStartMenuShortcut": true
|
||||||
},
|
},
|
||||||
"publish": {
|
"publish": {
|
||||||
"provider": "github",
|
"provider": "generic",
|
||||||
"owner": "amiayweb",
|
"url": "https://git.sanhost.net/sanasol/hytale-f2p/releases/download/latest"
|
||||||
"repo": "Hytale-F2P"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
133
server/README.md
Normal file
133
server/README.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# Hytale F2P - Dedicated Server
|
||||||
|
|
||||||
|
Host your own Hytale server. The scripts handle everything automatically.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Java 25+** — [Windows installer](https://download.oracle.com/java/25/latest/jdk-25_windows-x64_bin.exe) | [Other platforms](https://adoptium.net/)
|
||||||
|
- If you have the F2P launcher installed, its bundled Java will be used automatically
|
||||||
|
- **Internet connection** for first launch (downloads ~3.5 GB of game files)
|
||||||
|
- If you have the F2P launcher installed, game files are copied locally (no download needed)
|
||||||
|
|
||||||
|
## Video Guide
|
||||||
|
|
||||||
|
[](https://youtu.be/KvuXLH7SKvI)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Free Hosted Server (no PC required)
|
||||||
|
|
||||||
|
Use [play.hosting](https://play.hosting) to get a free Hytale server with F2P support:
|
||||||
|
|
||||||
|
1. Register at [play.hosting](https://play.hosting)
|
||||||
|
2. Create a **Hytale** server
|
||||||
|
3. Start the server once and wait for it to fully load
|
||||||
|
4. Go to **Files** → open the `mods` folder
|
||||||
|
5. Click **New** → **File via URL**
|
||||||
|
6. Paste: `https://github.com/sanasol/hytale-auth-server/releases/latest/download/dualauth-agent.jar`
|
||||||
|
7. Click **Query** and download the file
|
||||||
|
8. Go to **Console** and **Restart** the server
|
||||||
|
|
||||||
|
Done — your F2P server is ready to join.
|
||||||
|
|
||||||
|
### Windows (self-hosted)
|
||||||
|
|
||||||
|
1. Download `start.bat` to an empty folder
|
||||||
|
2. Double-click `start.bat`
|
||||||
|
3. Done — server starts on port **5520**
|
||||||
|
|
||||||
|
### Linux / macOS (self-hosted)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir hytale-server && cd hytale-server
|
||||||
|
curl -O https://git.sanhost.net/sanasol/hytale-f2p/raw/branch/develop/server/start.sh
|
||||||
|
chmod +x start.sh
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## What the scripts do
|
||||||
|
|
||||||
|
1. Search for the F2P launcher install (default paths + custom `installPath` from config)
|
||||||
|
2. Use bundled Java from the launcher, or fall back to system Java (25+ required)
|
||||||
|
3. Copy game files from the launcher install if available
|
||||||
|
4. Download missing files: `HytaleServer.jar` (~150 MB), `Assets.zip` (~3.3 GB), `dualauth-agent.jar` (~5 MB)
|
||||||
|
5. Check for updates on every launch (server, assets, and agent)
|
||||||
|
6. Generate a persistent server ID
|
||||||
|
7. Fetch authentication tokens
|
||||||
|
8. Start the server with dual-auth support
|
||||||
|
|
||||||
|
## Connecting
|
||||||
|
|
||||||
|
- **Same PC**: Connect to `localhost:5520` or `127.0.0.1:5520`
|
||||||
|
- **LAN**: Connect to your local IP (e.g. `192.168.1.x:5520`)
|
||||||
|
- **Internet**: Forward port `5520` (TCP + UDP) on your router, friends connect to your public IP
|
||||||
|
|
||||||
|
### No public IP? Use playit.gg (recommended)
|
||||||
|
|
||||||
|
If you're behind CGNAT or can't port forward, [playit.gg](https://playit.gg) gives you a public address for free:
|
||||||
|
|
||||||
|
1. Go to [playit.gg](https://playit.gg) and create an account
|
||||||
|
2. Download and run the playit agent
|
||||||
|
3. Create a tunnel — select **Hytale** as the game type, local port `5520`
|
||||||
|
4. Share the generated address with friends (e.g. `something.joinplayit.gg:12345`)
|
||||||
|
|
||||||
|
### Other options
|
||||||
|
|
||||||
|
- [Radmin VPN](https://www.radmin-vpn.com/) — virtual LAN, all players must install it
|
||||||
|
- [ZeroTier](https://www.zerotier.com/) — same idea, create a network, friends join and connect via VPN IP
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Set environment variables before running the script:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `SERVER_NAME` | `My Hytale Server` | Server name shown in listings |
|
||||||
|
| `BIND_ADDRESS` | `0.0.0.0:5520` | IP and port to listen on |
|
||||||
|
| `JVM_XMX` | *(Java default)* | Max memory (e.g. `4G`, `8G`) |
|
||||||
|
| `JVM_XMS` | *(Java default)* | Initial memory |
|
||||||
|
| `AUTH_MODE` | `authenticated` | Auth mode (`authenticated` or `none`) |
|
||||||
|
| `HYTALE_AUTH_DOMAIN` | `auth.sanasol.ws` | Auth server domain |
|
||||||
|
| `DOWNLOAD_BASE` | `https://download.sanasol.ws/download` | File download URL |
|
||||||
|
|
||||||
|
**Example (Linux):**
|
||||||
|
```bash
|
||||||
|
SERVER_NAME="Epic Server" JVM_XMX=4G ./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example (Windows):**
|
||||||
|
```cmd
|
||||||
|
set SERVER_NAME=Epic Server
|
||||||
|
set JVM_XMX=4G
|
||||||
|
start.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files created
|
||||||
|
|
||||||
|
```
|
||||||
|
your-folder/
|
||||||
|
├── start.sh / start.bat # Startup script
|
||||||
|
├── HytaleServer.jar # Game server (auto-downloaded)
|
||||||
|
├── Assets.zip # Game assets (auto-downloaded)
|
||||||
|
├── dualauth-agent.jar # Auth agent (auto-downloaded)
|
||||||
|
├── .server-id # Persistent server UUID
|
||||||
|
├── .versions/ # Version tracking for auto-updates
|
||||||
|
├── Server/ # Server data (created by server)
|
||||||
|
│ ├── config.json
|
||||||
|
│ └── worlds/
|
||||||
|
└── UserData/ # Player saves
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Problem | Solution |
|
||||||
|
|---------|----------|
|
||||||
|
| `java not found` | Install the F2P launcher (includes Java) or install Java 25+ from [adoptium.net](https://adoptium.net/) |
|
||||||
|
| Download fails | Check internet connection. Files can be downloaded manually from `https://download.sanasol.ws/download/` |
|
||||||
|
| Port already in use | Change port: `BIND_ADDRESS=0.0.0.0:5521 ./start.sh` |
|
||||||
|
| Out of memory | Set more RAM: `JVM_XMX=4G ./start.sh` |
|
||||||
|
| Friends can't connect | Forward port 5520 (TCP+UDP) on your router, or use [playit.gg](https://playit.gg) if you can't port forward |
|
||||||
|
|
||||||
|
## Discord
|
||||||
|
|
||||||
|
Need help? Join the community: https://discord.gg/Fhbb9Yk5WW
|
||||||
441
server/start.bat
Normal file
441
server/start.bat
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
:: ============================================================
|
||||||
|
:: Hytale F2P Dedicated Server - One-Click Starter
|
||||||
|
:: ============================================================
|
||||||
|
:: Just double-click this file to start your server!
|
||||||
|
::
|
||||||
|
:: The script will:
|
||||||
|
:: 1. Look for game files and Java in your F2P launcher install
|
||||||
|
:: 2. Auto-download anything missing
|
||||||
|
:: 3. Auto-update server, assets, and agent on each launch
|
||||||
|
:: 4. Fetch auth tokens and start the server
|
||||||
|
:: ============================================================
|
||||||
|
|
||||||
|
:: Configuration (edit these or set as environment variables)
|
||||||
|
if not defined HYTALE_AUTH_DOMAIN set "HYTALE_AUTH_DOMAIN=auth.sanasol.ws"
|
||||||
|
if not defined AUTH_SERVER set "AUTH_SERVER=https://%HYTALE_AUTH_DOMAIN%"
|
||||||
|
if not defined SERVER_NAME set "SERVER_NAME=My Hytale Server"
|
||||||
|
if not defined ASSETS_PATH set "ASSETS_PATH=.\Assets.zip"
|
||||||
|
if not defined BIND_ADDRESS set "BIND_ADDRESS=0.0.0.0:5520"
|
||||||
|
if not defined AUTH_MODE set "AUTH_MODE=authenticated"
|
||||||
|
if not defined DOWNLOAD_BASE set "DOWNLOAD_BASE=https://download.sanasol.ws/download"
|
||||||
|
|
||||||
|
:: File names
|
||||||
|
set "AGENT_JAR=dualauth-agent.jar"
|
||||||
|
set "SERVER_JAR=HytaleServer.jar"
|
||||||
|
set "AGENT_URL=https://github.com/sanasol/hytale-auth-server/releases/latest/download/dualauth-agent.jar"
|
||||||
|
set "AGENT_VERSION_API=https://api.github.com/repos/sanasol/hytale-auth-server/releases/latest"
|
||||||
|
set "VERSION_DIR=.versions"
|
||||||
|
|
||||||
|
echo ============================================================
|
||||||
|
echo Hytale F2P Dedicated Server
|
||||||
|
echo ============================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: --- Prerequisite Checks ---
|
||||||
|
|
||||||
|
where curl >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] curl is required but not found
|
||||||
|
echo [ERROR] curl comes with Windows 10+. Update Windows or install curl.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exist "%VERSION_DIR%" mkdir "%VERSION_DIR%"
|
||||||
|
|
||||||
|
:: --- Find Local F2P Launcher Install ---
|
||||||
|
|
||||||
|
set "F2P_DIR="
|
||||||
|
set "F2P_BASE=%USERPROFILE%\AppData\Local\HytaleF2P"
|
||||||
|
set "F2P_CONFIG=%F2P_BASE%\config.json"
|
||||||
|
set "JAVA_CMD=java"
|
||||||
|
|
||||||
|
:: Check config.json for custom installPath
|
||||||
|
set "F2P_CUSTOM_BASE="
|
||||||
|
if exist "%F2P_CONFIG%" (
|
||||||
|
for /f "delims=" %%p in ('powershell -Command "try { $c = Get-Content '%F2P_CONFIG%' | ConvertFrom-Json; if ($c.installPath) { $c.installPath.Trim() + '\HytaleF2P' } } catch {}" 2^>nul') do (
|
||||||
|
set "F2P_CUSTOM_BASE=%%p"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
:: Search for game files: custom path first, then default
|
||||||
|
if defined F2P_CUSTOM_BASE (
|
||||||
|
if exist "!F2P_CUSTOM_BASE!\release\package\game\latest" (
|
||||||
|
set "F2P_DIR=!F2P_CUSTOM_BASE!\release\package\game\latest"
|
||||||
|
) else if exist "!F2P_CUSTOM_BASE!\pre-release\package\game\latest" (
|
||||||
|
set "F2P_DIR=!F2P_CUSTOM_BASE!\pre-release\package\game\latest"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not defined F2P_DIR (
|
||||||
|
if exist "%F2P_BASE%\release\package\game\latest" (
|
||||||
|
set "F2P_DIR=%F2P_BASE%\release\package\game\latest"
|
||||||
|
) else if exist "%F2P_BASE%\pre-release\package\game\latest" (
|
||||||
|
set "F2P_DIR=%F2P_BASE%\pre-release\package\game\latest"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
:: --- Find Java from F2P launcher ---
|
||||||
|
|
||||||
|
:: Check config.json for custom javaPath
|
||||||
|
if exist "%F2P_CONFIG%" (
|
||||||
|
for /f "delims=" %%j in ('powershell -Command "try { $c = Get-Content '%F2P_CONFIG%' | ConvertFrom-Json; if ($c.javaPath -and (Test-Path $c.javaPath)) { $c.javaPath.Trim() } } catch {}" 2^>nul') do (
|
||||||
|
set "JAVA_CMD=%%j"
|
||||||
|
echo [INFO] Found Java in F2P config: %%j
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
:: Check bundled JRE if no custom javaPath found
|
||||||
|
if "!JAVA_CMD!"=="java" (
|
||||||
|
set "F2P_JRE_BASE="
|
||||||
|
if defined F2P_CUSTOM_BASE (
|
||||||
|
if exist "!F2P_CUSTOM_BASE!\release\package\jre\latest\bin\java.exe" (
|
||||||
|
set "F2P_JRE_BASE=!F2P_CUSTOM_BASE!\release\package\jre\latest"
|
||||||
|
) else if exist "!F2P_CUSTOM_BASE!\pre-release\package\jre\latest\bin\java.exe" (
|
||||||
|
set "F2P_JRE_BASE=!F2P_CUSTOM_BASE!\pre-release\package\jre\latest"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not defined F2P_JRE_BASE (
|
||||||
|
if exist "%F2P_BASE%\release\package\jre\latest\bin\java.exe" (
|
||||||
|
set "F2P_JRE_BASE=%F2P_BASE%\release\package\jre\latest"
|
||||||
|
) else if exist "%F2P_BASE%\pre-release\package\jre\latest\bin\java.exe" (
|
||||||
|
set "F2P_JRE_BASE=%F2P_BASE%\pre-release\package\jre\latest"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if defined F2P_JRE_BASE (
|
||||||
|
set "JAVA_CMD=!F2P_JRE_BASE!\bin\java.exe"
|
||||||
|
echo [INFO] Found Java in F2P launcher: !JAVA_CMD!
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
:: Verify java exists
|
||||||
|
"!JAVA_CMD!" -version >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
where java >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] Java is not installed and no F2P launcher JRE found
|
||||||
|
echo.
|
||||||
|
echo Options:
|
||||||
|
echo 1. Install the F2P launcher first ^(it includes Java^)
|
||||||
|
echo 2. Download Java 25: https://download.oracle.com/java/25/latest/jdk-25_windows-x64_bin.exe
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
set "JAVA_CMD=java"
|
||||||
|
)
|
||||||
|
|
||||||
|
:: Check Java version
|
||||||
|
for /f "tokens=3 delims= " %%v in ('"!JAVA_CMD!" -version 2^>^&1 ^| findstr /i "version"') do (
|
||||||
|
set "JAVA_VER_RAW=%%~v"
|
||||||
|
)
|
||||||
|
if defined JAVA_VER_RAW (
|
||||||
|
for /f "tokens=1 delims=." %%m in ("!JAVA_VER_RAW!") do set "JAVA_MAJOR=%%m"
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [INFO] Java: !JAVA_VER_RAW! ^(!JAVA_CMD!^)
|
||||||
|
|
||||||
|
if defined JAVA_MAJOR (
|
||||||
|
if !JAVA_MAJOR! LSS 25 (
|
||||||
|
echo [ERROR] Java !JAVA_MAJOR! detected. Java 25+ is REQUIRED.
|
||||||
|
echo The DualAuth agent requires Java 25 ^(class file version 69^).
|
||||||
|
echo.
|
||||||
|
echo Download Java 25: https://download.oracle.com/java/25/latest/jdk-25_windows-x64_bin.exe
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
:: --- Copy game files from F2P install ---
|
||||||
|
|
||||||
|
if defined F2P_DIR (
|
||||||
|
echo [INFO] Found F2P launcher game files: !F2P_DIR!
|
||||||
|
|
||||||
|
if not exist "%SERVER_JAR%" (
|
||||||
|
if exist "!F2P_DIR!\Server\HytaleServer.jar" (
|
||||||
|
echo [INFO] Found HytaleServer.jar in F2P launcher
|
||||||
|
echo [INFO] Copying from: !F2P_DIR!\Server\HytaleServer.jar
|
||||||
|
copy "!F2P_DIR!\Server\HytaleServer.jar" "%SERVER_JAR%" >nul
|
||||||
|
echo [INFO] Copied successfully
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exist "%ASSETS_PATH%" (
|
||||||
|
if exist "!F2P_DIR!\Assets.zip" (
|
||||||
|
echo [INFO] Found Assets.zip in F2P launcher
|
||||||
|
echo [INFO] Copying from: !F2P_DIR!\Assets.zip
|
||||||
|
copy "!F2P_DIR!\Assets.zip" "%ASSETS_PATH%" >nul
|
||||||
|
echo [INFO] Copied successfully
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
) else (
|
||||||
|
echo [INFO] No F2P launcher install found, will download files
|
||||||
|
echo.
|
||||||
|
)
|
||||||
|
|
||||||
|
:: --- Download / Update HytaleServer.jar ---
|
||||||
|
|
||||||
|
set "JAR_URL=%DOWNLOAD_BASE%/HytaleServer.jar"
|
||||||
|
set "JAR_VERSION_FILE=%VERSION_DIR%\HytaleServer.jar.version"
|
||||||
|
|
||||||
|
if not exist "%SERVER_JAR%" (
|
||||||
|
echo [INFO] HytaleServer.jar not found, downloading...
|
||||||
|
echo [INFO] Expected size: ~150 MB
|
||||||
|
curl -fL --progress-bar -o "%SERVER_JAR%.tmp" "%JAR_URL%" --connect-timeout 15 --max-time 3600
|
||||||
|
if exist "%SERVER_JAR%.tmp" (
|
||||||
|
move /y "%SERVER_JAR%.tmp" "%SERVER_JAR%" >nul
|
||||||
|
echo [INFO] HytaleServer.jar downloaded
|
||||||
|
for /f "delims=" %%h in ('powershell -Command "try { (Invoke-WebRequest -Uri '%JAR_URL%' -Method Head -UseBasicParsing).Headers['ETag'] } catch {}" 2^>nul') do (
|
||||||
|
echo %%h>"%JAR_VERSION_FILE%"
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo [ERROR] Failed to download HytaleServer.jar
|
||||||
|
echo [ERROR] Check your internet connection
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo [INFO] Checking for HytaleServer.jar updates...
|
||||||
|
set "LOCAL_JAR_VER="
|
||||||
|
if exist "%JAR_VERSION_FILE%" set /p LOCAL_JAR_VER=<"%JAR_VERSION_FILE%"
|
||||||
|
|
||||||
|
set "REMOTE_JAR_VER="
|
||||||
|
for /f "delims=" %%h in ('powershell -Command "try { (Invoke-WebRequest -Uri '%JAR_URL%' -Method Head -UseBasicParsing -TimeoutSec 10).Headers['ETag'] } catch { '' }" 2^>nul') do (
|
||||||
|
set "REMOTE_JAR_VER=%%h"
|
||||||
|
)
|
||||||
|
|
||||||
|
if defined REMOTE_JAR_VER (
|
||||||
|
if "!LOCAL_JAR_VER!"=="!REMOTE_JAR_VER!" (
|
||||||
|
echo [INFO] HytaleServer.jar is up to date
|
||||||
|
) else (
|
||||||
|
echo [INFO] HytaleServer.jar update available, downloading...
|
||||||
|
curl -fL --progress-bar -o "%SERVER_JAR%.tmp" "%JAR_URL%" --connect-timeout 15 --max-time 3600
|
||||||
|
if exist "%SERVER_JAR%.tmp" (
|
||||||
|
move /y "%SERVER_JAR%.tmp" "%SERVER_JAR%" >nul
|
||||||
|
echo !REMOTE_JAR_VER!>"%JAR_VERSION_FILE%"
|
||||||
|
echo [INFO] HytaleServer.jar updated
|
||||||
|
) else (
|
||||||
|
echo [WARN] Update failed, using existing HytaleServer.jar
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo [INFO] Could not check for updates, using existing HytaleServer.jar
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
:: --- Download / Update Assets.zip ---
|
||||||
|
|
||||||
|
set "ASSETS_URL=%DOWNLOAD_BASE%/Assets.zip"
|
||||||
|
set "ASSETS_VERSION_FILE=%VERSION_DIR%\Assets.zip.version"
|
||||||
|
|
||||||
|
if not exist "%ASSETS_PATH%" (
|
||||||
|
echo [INFO] Assets.zip not found, downloading...
|
||||||
|
echo [INFO] Expected size: ~3.3 GB - this will take a while
|
||||||
|
curl -fL --progress-bar -o "%ASSETS_PATH%.tmp" "%ASSETS_URL%" --connect-timeout 15 --max-time 7200
|
||||||
|
if exist "%ASSETS_PATH%.tmp" (
|
||||||
|
move /y "%ASSETS_PATH%.tmp" "%ASSETS_PATH%" >nul
|
||||||
|
echo [INFO] Assets.zip downloaded
|
||||||
|
for /f "delims=" %%h in ('powershell -Command "try { (Invoke-WebRequest -Uri '%ASSETS_URL%' -Method Head -UseBasicParsing).Headers['ETag'] } catch {}" 2^>nul') do (
|
||||||
|
echo %%h>"%ASSETS_VERSION_FILE%"
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo [ERROR] Failed to download Assets.zip
|
||||||
|
echo [ERROR] Check your internet connection
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo [INFO] Checking for Assets.zip updates...
|
||||||
|
set "LOCAL_ASSETS_VER="
|
||||||
|
if exist "%ASSETS_VERSION_FILE%" set /p LOCAL_ASSETS_VER=<"%ASSETS_VERSION_FILE%"
|
||||||
|
|
||||||
|
set "REMOTE_ASSETS_VER="
|
||||||
|
for /f "delims=" %%h in ('powershell -Command "try { (Invoke-WebRequest -Uri '%ASSETS_URL%' -Method Head -UseBasicParsing -TimeoutSec 10).Headers['ETag'] } catch { '' }" 2^>nul') do (
|
||||||
|
set "REMOTE_ASSETS_VER=%%h"
|
||||||
|
)
|
||||||
|
|
||||||
|
if defined REMOTE_ASSETS_VER (
|
||||||
|
if "!LOCAL_ASSETS_VER!"=="!REMOTE_ASSETS_VER!" (
|
||||||
|
echo [INFO] Assets.zip is up to date
|
||||||
|
) else (
|
||||||
|
echo [INFO] Assets.zip update available, downloading...
|
||||||
|
echo [INFO] This is a large file ^(~3.3 GB^), please be patient
|
||||||
|
curl -fL --progress-bar -o "%ASSETS_PATH%.tmp" "%ASSETS_URL%" --connect-timeout 15 --max-time 7200
|
||||||
|
if exist "%ASSETS_PATH%.tmp" (
|
||||||
|
move /y "%ASSETS_PATH%.tmp" "%ASSETS_PATH%" >nul
|
||||||
|
echo !REMOTE_ASSETS_VER!>"%ASSETS_VERSION_FILE%"
|
||||||
|
echo [INFO] Assets.zip updated
|
||||||
|
) else (
|
||||||
|
echo [WARN] Update failed, using existing Assets.zip
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo [INFO] Could not check for updates, using existing Assets.zip
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
:: --- Download / Update DualAuth Agent ---
|
||||||
|
|
||||||
|
set "AGENT_VERSION_FILE=%VERSION_DIR%\dualauth-agent.jar.version"
|
||||||
|
|
||||||
|
if not exist "%AGENT_JAR%" (
|
||||||
|
echo [INFO] Downloading DualAuth Agent...
|
||||||
|
curl -fL -# -o "%AGENT_JAR%.tmp" "%AGENT_URL%" --connect-timeout 15 --max-time 120
|
||||||
|
if exist "%AGENT_JAR%.tmp" (
|
||||||
|
move /y "%AGENT_JAR%.tmp" "%AGENT_JAR%" >nul
|
||||||
|
echo [INFO] DualAuth Agent downloaded
|
||||||
|
for /f "delims=" %%v in ('powershell -Command "try { $r = Invoke-RestMethod -Uri '%AGENT_VERSION_API%' -TimeoutSec 10; $r.tag_name } catch { '' }" 2^>nul') do (
|
||||||
|
echo %%v>"%AGENT_VERSION_FILE%"
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo [ERROR] Failed to download DualAuth Agent
|
||||||
|
echo [ERROR] Download manually: %AGENT_URL%
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo [INFO] Checking for DualAuth Agent updates...
|
||||||
|
set "LOCAL_AGENT_VER="
|
||||||
|
if exist "%AGENT_VERSION_FILE%" set /p LOCAL_AGENT_VER=<"%AGENT_VERSION_FILE%"
|
||||||
|
|
||||||
|
set "REMOTE_AGENT_VER="
|
||||||
|
for /f "delims=" %%v in ('powershell -Command "try { $r = Invoke-RestMethod -Uri '%AGENT_VERSION_API%' -TimeoutSec 10; $r.tag_name } catch { '' }" 2^>nul') do (
|
||||||
|
set "REMOTE_AGENT_VER=%%v"
|
||||||
|
)
|
||||||
|
|
||||||
|
if defined REMOTE_AGENT_VER (
|
||||||
|
if "!LOCAL_AGENT_VER!"=="!REMOTE_AGENT_VER!" (
|
||||||
|
echo [INFO] DualAuth Agent up to date ^(!LOCAL_AGENT_VER!^)
|
||||||
|
) else (
|
||||||
|
echo [INFO] Agent update: !LOCAL_AGENT_VER! -^> !REMOTE_AGENT_VER!
|
||||||
|
curl -fL -# -o "%AGENT_JAR%.tmp" "%AGENT_URL%" --connect-timeout 15 --max-time 120
|
||||||
|
if exist "%AGENT_JAR%.tmp" (
|
||||||
|
move /y "%AGENT_JAR%.tmp" "%AGENT_JAR%" >nul
|
||||||
|
echo !REMOTE_AGENT_VER!>"%AGENT_VERSION_FILE%"
|
||||||
|
echo [INFO] DualAuth Agent updated
|
||||||
|
) else (
|
||||||
|
echo [WARN] Agent update failed, using existing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo [INFO] Could not check agent updates, using existing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
:: --- Final Checks ---
|
||||||
|
|
||||||
|
if not exist "%SERVER_JAR%" (
|
||||||
|
echo [ERROR] HytaleServer.jar not found
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
if not exist "%ASSETS_PATH%" (
|
||||||
|
echo [ERROR] Assets.zip not found
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
if not exist "%AGENT_JAR%" (
|
||||||
|
echo [ERROR] dualauth-agent.jar not found
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
:: --- Generate or Load Server ID ---
|
||||||
|
|
||||||
|
set "SERVER_ID_FILE=.server-id"
|
||||||
|
if exist "%SERVER_ID_FILE%" (
|
||||||
|
set /p SERVER_ID=<"%SERVER_ID_FILE%"
|
||||||
|
echo [INFO] Server ID: !SERVER_ID!
|
||||||
|
) else (
|
||||||
|
for /f "delims=" %%i in ('powershell -Command "[guid]::NewGuid().ToString()"') do set "SERVER_ID=%%i"
|
||||||
|
echo !SERVER_ID!>"%SERVER_ID_FILE%"
|
||||||
|
echo [INFO] Generated server ID: !SERVER_ID!
|
||||||
|
)
|
||||||
|
|
||||||
|
:: --- Fetch Server Tokens ---
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [INFO] Fetching server tokens from %AUTH_SERVER%...
|
||||||
|
|
||||||
|
set "TEMP_RESPONSE=%TEMP%\hytale_auth_%RANDOM%.json"
|
||||||
|
|
||||||
|
curl -s -X POST "%AUTH_SERVER%/server/auto-auth" ^
|
||||||
|
-H "Content-Type: application/json" ^
|
||||||
|
-d "{\"server_id\": \"!SERVER_ID!\", \"server_name\": \"%SERVER_NAME%\"}" ^
|
||||||
|
--connect-timeout 10 ^
|
||||||
|
--max-time 30 ^
|
||||||
|
-o "%TEMP_RESPONSE%" 2>nul
|
||||||
|
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] Failed to connect to auth server at %AUTH_SERVER%
|
||||||
|
del "%TEMP_RESPONSE%" 2>nul
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
findstr /C:"sessionToken" "%TEMP_RESPONSE%" >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] Invalid response from auth server:
|
||||||
|
type "%TEMP_RESPONSE%"
|
||||||
|
del "%TEMP_RESPONSE%" 2>nul
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
:: Extract tokens using PowerShell
|
||||||
|
for /f "delims=" %%i in ('powershell -Command "$j = Get-Content '%TEMP_RESPONSE%' | ConvertFrom-Json; $j.sessionToken"') do set "SESSION_TOKEN=%%i"
|
||||||
|
for /f "delims=" %%i in ('powershell -Command "$j = Get-Content '%TEMP_RESPONSE%' | ConvertFrom-Json; $j.identityToken"') do set "IDENTITY_TOKEN=%%i"
|
||||||
|
|
||||||
|
del "%TEMP_RESPONSE%" 2>nul
|
||||||
|
|
||||||
|
if "!SESSION_TOKEN!"=="" (
|
||||||
|
echo [ERROR] Could not extract session token from response
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
if "!IDENTITY_TOKEN!"=="" (
|
||||||
|
echo [ERROR] Could not extract identity token from response
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [INFO] Tokens received successfully
|
||||||
|
|
||||||
|
:: --- Start Server ---
|
||||||
|
|
||||||
|
set "JAVA_ARGS="
|
||||||
|
if defined JVM_XMS set "JAVA_ARGS=!JAVA_ARGS! -Xms%JVM_XMS%"
|
||||||
|
if defined JVM_XMX set "JAVA_ARGS=!JAVA_ARGS! -Xmx%JVM_XMX%"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ============================================================
|
||||||
|
echo Starting Hytale Server
|
||||||
|
echo Name: %SERVER_NAME%
|
||||||
|
echo Bind: %BIND_ADDRESS%
|
||||||
|
echo Java: !JAVA_CMD!
|
||||||
|
echo Agent: %AGENT_JAR%
|
||||||
|
echo ============================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
"!JAVA_CMD!" %JAVA_ARGS% -javaagent:"%AGENT_JAR%" -jar "%SERVER_JAR%" ^
|
||||||
|
--assets "%ASSETS_PATH%" ^
|
||||||
|
--bind "%BIND_ADDRESS%" ^
|
||||||
|
--auth-mode "%AUTH_MODE%" ^
|
||||||
|
--disable-sentry ^
|
||||||
|
--session-token "!SESSION_TOKEN!" ^
|
||||||
|
--identity-token "!IDENTITY_TOKEN!" ^
|
||||||
|
%*
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ============================================================
|
||||||
|
echo Server stopped. Exit code: %ERRORLEVEL%
|
||||||
|
echo ============================================================
|
||||||
|
pause
|
||||||
|
|
||||||
|
endlocal
|
||||||
516
server/start.sh
Executable file
516
server/start.sh
Executable file
@@ -0,0 +1,516 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================================
|
||||||
|
# Hytale F2P Dedicated Server - One-Click Starter
|
||||||
|
# ============================================================
|
||||||
|
# Just run: ./start.sh
|
||||||
|
#
|
||||||
|
# The script will:
|
||||||
|
# 1. Look for game files in your F2P launcher install
|
||||||
|
# 2. Auto-download anything missing
|
||||||
|
# 3. Auto-update server, assets, and agent on each launch
|
||||||
|
# 4. Fetch auth tokens and start the server
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration (edit these or set as environment variables)
|
||||||
|
HYTALE_AUTH_DOMAIN="${HYTALE_AUTH_DOMAIN:-auth.sanasol.ws}"
|
||||||
|
AUTH_SERVER="${AUTH_SERVER:-https://$HYTALE_AUTH_DOMAIN}"
|
||||||
|
SERVER_NAME="${SERVER_NAME:-My Hytale Server}"
|
||||||
|
BIND_ADDRESS="${BIND_ADDRESS:-0.0.0.0:5520}"
|
||||||
|
AUTH_MODE="${AUTH_MODE:-authenticated}"
|
||||||
|
|
||||||
|
# Download URLs
|
||||||
|
DOWNLOAD_BASE="${DOWNLOAD_BASE:-https://download.sanasol.ws/download}"
|
||||||
|
AGENT_URL="https://github.com/sanasol/hytale-auth-server/releases/latest/download/dualauth-agent.jar"
|
||||||
|
AGENT_VERSION_API="https://api.github.com/repos/sanasol/hytale-auth-server/releases/latest"
|
||||||
|
|
||||||
|
# File names (in current directory)
|
||||||
|
AGENT_JAR="dualauth-agent.jar"
|
||||||
|
SERVER_JAR="HytaleServer.jar"
|
||||||
|
ASSETS_FILE="Assets.zip"
|
||||||
|
ASSETS_PATH="${ASSETS_PATH:-./Assets.zip}"
|
||||||
|
VERSION_DIR=".versions"
|
||||||
|
|
||||||
|
echo "============================================================"
|
||||||
|
echo " Hytale F2P Dedicated Server"
|
||||||
|
echo "============================================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# --- Prerequisite Checks ---
|
||||||
|
|
||||||
|
if ! command -v curl &>/dev/null; then
|
||||||
|
echo "[ERROR] curl is required but not found"
|
||||||
|
echo " Install: sudo apt install curl"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$VERSION_DIR"
|
||||||
|
|
||||||
|
# --- Find Local F2P Launcher Install ---
|
||||||
|
|
||||||
|
get_f2p_default_dir() {
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin) echo "$HOME/Library/Application Support/HytaleF2P" ;;
|
||||||
|
Linux) echo "$HOME/.hytalef2p" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Read a JSON string field from config.json using available tools
|
||||||
|
read_config_field() {
|
||||||
|
local config_file="$1" field="$2"
|
||||||
|
if [ ! -f "$config_file" ]; then return 1; fi
|
||||||
|
|
||||||
|
if command -v python3 &>/dev/null; then
|
||||||
|
python3 -c "
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
c = json.load(open('$config_file'))
|
||||||
|
v = c.get('$field', '').strip()
|
||||||
|
if v: print(v)
|
||||||
|
except: pass
|
||||||
|
" 2>/dev/null
|
||||||
|
elif command -v jq &>/dev/null; then
|
||||||
|
jq -r ".$field // empty" "$config_file" 2>/dev/null
|
||||||
|
else
|
||||||
|
grep -o "\"$field\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$config_file" 2>/dev/null | cut -d'"' -f4
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
find_f2p_install() {
|
||||||
|
local default_app_dir
|
||||||
|
default_app_dir=$(get_f2p_default_dir) || return 1
|
||||||
|
|
||||||
|
local search_dirs=()
|
||||||
|
|
||||||
|
# Check config.json for custom installPath
|
||||||
|
local config_file="$default_app_dir/config.json"
|
||||||
|
local custom_path
|
||||||
|
custom_path=$(read_config_field "$config_file" "installPath")
|
||||||
|
if [ -n "$custom_path" ]; then
|
||||||
|
local custom_f2p="$custom_path/HytaleF2P"
|
||||||
|
if [ -d "$custom_f2p" ]; then
|
||||||
|
search_dirs+=("$custom_f2p")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Always also check default location
|
||||||
|
search_dirs+=("$default_app_dir")
|
||||||
|
|
||||||
|
for base in "${search_dirs[@]}"; do
|
||||||
|
for branch in "release" "pre-release"; do
|
||||||
|
local game_dir="$base/$branch/package/game/latest"
|
||||||
|
if [ -d "$game_dir" ]; then
|
||||||
|
echo "$game_dir"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find bundled JRE from F2P launcher install
|
||||||
|
find_f2p_java() {
|
||||||
|
local default_app_dir
|
||||||
|
default_app_dir=$(get_f2p_default_dir) || return 1
|
||||||
|
|
||||||
|
local search_dirs=()
|
||||||
|
|
||||||
|
# Check config.json for custom javaPath first
|
||||||
|
local config_file="$default_app_dir/config.json"
|
||||||
|
local custom_java
|
||||||
|
custom_java=$(read_config_field "$config_file" "javaPath")
|
||||||
|
if [ -n "$custom_java" ] && [ -x "$custom_java" ]; then
|
||||||
|
echo "$custom_java"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check custom installPath
|
||||||
|
local custom_path
|
||||||
|
custom_path=$(read_config_field "$config_file" "installPath")
|
||||||
|
if [ -n "$custom_path" ]; then
|
||||||
|
local custom_f2p="$custom_path/HytaleF2P"
|
||||||
|
[ -d "$custom_f2p" ] && search_dirs+=("$custom_f2p")
|
||||||
|
fi
|
||||||
|
|
||||||
|
search_dirs+=("$default_app_dir")
|
||||||
|
|
||||||
|
for base in "${search_dirs[@]}"; do
|
||||||
|
for branch in "release" "pre-release"; do
|
||||||
|
local jre_dir="$base/$branch/package/jre/latest"
|
||||||
|
# Standard path
|
||||||
|
if [ -x "$jre_dir/bin/java" ]; then
|
||||||
|
echo "$jre_dir/bin/java"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# macOS bundle path
|
||||||
|
if [ -x "$jre_dir/Contents/Home/bin/java" ]; then
|
||||||
|
echo "$jre_dir/Contents/Home/bin/java"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_from_f2p() {
|
||||||
|
local file="$1" local_path="$2" f2p_path="$3"
|
||||||
|
|
||||||
|
if [ -f "$local_path" ]; then
|
||||||
|
return 1 # Already exists locally
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$f2p_path" ]; then
|
||||||
|
echo "[INFO] Found $file in F2P launcher install"
|
||||||
|
echo "[INFO] Copying from: $f2p_path"
|
||||||
|
cp "$f2p_path" "$local_path"
|
||||||
|
echo "[INFO] Copied successfully"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Detect Java ---
|
||||||
|
|
||||||
|
JAVA_CMD="java"
|
||||||
|
|
||||||
|
# Try F2P bundled JRE first
|
||||||
|
F2P_JAVA=$(find_f2p_java 2>/dev/null) || true
|
||||||
|
if [ -n "$F2P_JAVA" ]; then
|
||||||
|
echo "[INFO] Found Java in F2P launcher: $F2P_JAVA"
|
||||||
|
JAVA_CMD="$F2P_JAVA"
|
||||||
|
elif ! command -v java &>/dev/null; then
|
||||||
|
echo "[ERROR] Java is not installed and no F2P launcher JRE found"
|
||||||
|
echo ""
|
||||||
|
echo " Options:"
|
||||||
|
echo " 1. Install the F2P launcher first (it includes Java)"
|
||||||
|
echo " 2. Install Java 25+ manually:"
|
||||||
|
echo " Windows: https://download.oracle.com/java/25/latest/jdk-25_windows-x64_bin.exe"
|
||||||
|
echo " macOS: brew install openjdk"
|
||||||
|
echo " Ubuntu/Debian: sudo apt install openjdk-25-jre"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check Java version
|
||||||
|
JAVA_FULL=$("$JAVA_CMD" -version 2>&1 | head -1)
|
||||||
|
JAVA_VER=$(echo "$JAVA_FULL" | grep -oP '(?<=")\d+' 2>/dev/null || echo "$JAVA_FULL" | sed 's/.*"\([0-9]*\).*/\1/')
|
||||||
|
echo "[INFO] Java: $JAVA_FULL"
|
||||||
|
if [ -n "$JAVA_VER" ] && [ "$JAVA_VER" -lt 25 ] 2>/dev/null; then
|
||||||
|
echo "[ERROR] Java $JAVA_VER detected. Java 25+ is REQUIRED."
|
||||||
|
echo " The DualAuth agent requires Java 25 (class file version 69)."
|
||||||
|
echo ""
|
||||||
|
if [ -n "$F2P_JAVA" ]; then
|
||||||
|
echo " Your F2P launcher JRE is outdated. Update the launcher to get a newer Java."
|
||||||
|
else
|
||||||
|
echo " Install Java 25+:"
|
||||||
|
echo " Windows: https://download.oracle.com/java/25/latest/jdk-25_windows-x64_bin.exe"
|
||||||
|
echo " macOS: brew install openjdk"
|
||||||
|
echo " Ubuntu/Debian: sudo apt install openjdk-25-jre"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Find F2P Game Files ---
|
||||||
|
|
||||||
|
F2P_DIR=""
|
||||||
|
if F2P_DIR=$(find_f2p_install 2>/dev/null); then
|
||||||
|
echo "[INFO] Found F2P launcher game files: $F2P_DIR"
|
||||||
|
|
||||||
|
# Try to copy HytaleServer.jar from F2P install
|
||||||
|
copy_from_f2p "HytaleServer.jar" "$SERVER_JAR" "$F2P_DIR/Server/HytaleServer.jar" || true
|
||||||
|
|
||||||
|
# Try to copy Assets.zip from F2P install
|
||||||
|
copy_from_f2p "Assets.zip" "$ASSETS_PATH" "$F2P_DIR/Assets.zip" || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo "[INFO] No F2P launcher install found, will download files"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Download / Update Functions ---
|
||||||
|
|
||||||
|
get_remote_version() {
|
||||||
|
local url="$1"
|
||||||
|
local headers
|
||||||
|
headers=$(curl -sI -L "$url" --connect-timeout 10 --max-time 15 2>/dev/null | tr -d '\r')
|
||||||
|
local etag
|
||||||
|
etag=$(echo "$headers" | grep -i "^etag:" | tail -1 | sed 's/^[^:]*: *//' | tr -d '"')
|
||||||
|
if [ -n "$etag" ]; then printf '%s' "$etag"; return 0; fi
|
||||||
|
local lastmod
|
||||||
|
lastmod=$(echo "$headers" | grep -i "^last-modified:" | tail -1 | sed 's/^[^:]*: *//')
|
||||||
|
if [ -n "$lastmod" ]; then printf '%s' "$lastmod"; return 0; fi
|
||||||
|
local length
|
||||||
|
length=$(echo "$headers" | grep -i "^content-length:" | tail -1 | sed 's/^[^:]*: *//')
|
||||||
|
if [ -n "$length" ]; then printf 'size:%s' "$length"; return 0; fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
needs_update() {
|
||||||
|
local url="$1" dest="$2" name="$3"
|
||||||
|
local version_file="${VERSION_DIR}/${name}.version"
|
||||||
|
|
||||||
|
if [ ! -f "$dest" ]; then
|
||||||
|
echo "[INFO] $name not found, will download"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[INFO] Checking for $name updates..."
|
||||||
|
local remote_version
|
||||||
|
remote_version=$(get_remote_version "$url" 2>/dev/null) || true
|
||||||
|
if [ -z "$remote_version" ]; then
|
||||||
|
echo "[INFO] Could not check for updates, using existing $name"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local local_version=""
|
||||||
|
[ -f "$version_file" ] && local_version=$(cat "$version_file" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ "$remote_version" = "$local_version" ]; then
|
||||||
|
echo "[INFO] $name is up to date"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$local_version" ]; then
|
||||||
|
echo "[INFO] $name update available"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
save_version() {
|
||||||
|
local url="$1" name="$2"
|
||||||
|
local version_file="${VERSION_DIR}/${name}.version"
|
||||||
|
local ver
|
||||||
|
ver=$(get_remote_version "$url" 2>/dev/null) || true
|
||||||
|
[ -n "$ver" ] && printf '%s\n' "$ver" > "$version_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
download_file() {
|
||||||
|
local url="$1" dest="$2" name="$3" expected_mb="${4:-0}"
|
||||||
|
local tmp="${dest}.tmp"
|
||||||
|
|
||||||
|
echo "[INFO] Downloading $name..."
|
||||||
|
[ "$expected_mb" -gt 0 ] 2>/dev/null && echo "[INFO] Expected size: ~${expected_mb} MB"
|
||||||
|
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
rm -f "$tmp" 2>/dev/null || true
|
||||||
|
|
||||||
|
if [ "$expected_mb" -gt 50 ] 2>/dev/null; then
|
||||||
|
curl -fL --progress-bar -o "$tmp" "$url" --connect-timeout 15 --max-time 3600 2>&1
|
||||||
|
else
|
||||||
|
curl -fL -# -o "$tmp" "$url" --connect-timeout 15 --max-time 300 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $? -eq 0 ] && [ -f "$tmp" ]; then
|
||||||
|
local size
|
||||||
|
size=$(stat -c%s "$tmp" 2>/dev/null || stat -f%z "$tmp" 2>/dev/null || echo 0)
|
||||||
|
if [ "$size" -gt 1000 ]; then
|
||||||
|
mv -f "$tmp" "$dest"
|
||||||
|
local mb=$((size / 1024 / 1024))
|
||||||
|
echo "[INFO] $name downloaded (${mb} MB)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "[WARN] $name download too small (${size} bytes), retrying..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[WARN] Download attempt $attempt failed, retrying..."
|
||||||
|
rm -f "$tmp" 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[ERROR] Failed to download $name after 3 attempts"
|
||||||
|
rm -f "$tmp" 2>/dev/null || true
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Download / Update Server Files ---
|
||||||
|
|
||||||
|
# HytaleServer.jar
|
||||||
|
JAR_URL="${DOWNLOAD_BASE}/HytaleServer.jar"
|
||||||
|
if needs_update "$JAR_URL" "$SERVER_JAR" "HytaleServer.jar"; then
|
||||||
|
if download_file "$JAR_URL" "$SERVER_JAR" "HytaleServer.jar" "150"; then
|
||||||
|
save_version "$JAR_URL" "HytaleServer.jar"
|
||||||
|
else
|
||||||
|
if [ ! -f "$SERVER_JAR" ]; then
|
||||||
|
echo "[ERROR] HytaleServer.jar is required. Check your internet connection."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[WARN] Update failed, using existing HytaleServer.jar"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Assets.zip
|
||||||
|
ASSETS_URL="${DOWNLOAD_BASE}/Assets.zip"
|
||||||
|
if needs_update "$ASSETS_URL" "$ASSETS_PATH" "Assets.zip"; then
|
||||||
|
echo "[INFO] Assets.zip is large (~3.3 GB), this may take a while..."
|
||||||
|
if download_file "$ASSETS_URL" "$ASSETS_PATH" "Assets.zip" "3300"; then
|
||||||
|
save_version "$ASSETS_URL" "Assets.zip"
|
||||||
|
else
|
||||||
|
if [ ! -f "$ASSETS_PATH" ]; then
|
||||||
|
echo "[ERROR] Assets.zip is required. Check your internet connection."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[WARN] Update failed, using existing Assets.zip"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# DualAuth Agent (uses GitHub releases API for version tracking)
|
||||||
|
check_agent_update() {
|
||||||
|
if [ -f "$AGENT_JAR" ]; then
|
||||||
|
local agent_size
|
||||||
|
agent_size=$(stat -c%s "$AGENT_JAR" 2>/dev/null || stat -f%z "$AGENT_JAR" 2>/dev/null || echo 0)
|
||||||
|
if [ "$agent_size" -lt 10000 ]; then
|
||||||
|
echo "[WARN] Agent JAR seems corrupt (${agent_size} bytes), re-downloading..."
|
||||||
|
rm -f "$AGENT_JAR"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local version_file="${VERSION_DIR}/${AGENT_JAR}.version"
|
||||||
|
local local_version=""
|
||||||
|
[ -f "$version_file" ] && local_version=$(cat "$version_file" 2>/dev/null)
|
||||||
|
|
||||||
|
echo "[INFO] Checking for DualAuth Agent updates..."
|
||||||
|
local remote_version
|
||||||
|
remote_version=$(curl -sf "$AGENT_VERSION_API" --connect-timeout 5 --max-time 10 2>/dev/null | grep -o '"tag_name":"[^"]*"' | cut -d'"' -f4)
|
||||||
|
|
||||||
|
if [ -n "$remote_version" ]; then
|
||||||
|
if [ "$local_version" = "$remote_version" ]; then
|
||||||
|
echo "[INFO] DualAuth Agent up to date ($local_version)"
|
||||||
|
return 1
|
||||||
|
else
|
||||||
|
echo "[INFO] Agent update: ${local_version:-unknown} -> $remote_version"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[INFO] Could not check agent updates, using existing"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
AGENT_REMOTE_VERSION=""
|
||||||
|
if check_agent_update; then
|
||||||
|
echo "[INFO] Downloading DualAuth Agent..."
|
||||||
|
if curl -fL -# -o "${AGENT_JAR}.tmp" "$AGENT_URL" --connect-timeout 15 --max-time 120 2>&1 && [ -f "${AGENT_JAR}.tmp" ]; then
|
||||||
|
dl_size=$(stat -c%s "${AGENT_JAR}.tmp" 2>/dev/null || stat -f%z "${AGENT_JAR}.tmp" 2>/dev/null || echo 0)
|
||||||
|
if [ "$dl_size" -gt 10000 ]; then
|
||||||
|
mv -f "${AGENT_JAR}.tmp" "$AGENT_JAR"
|
||||||
|
AGENT_REMOTE_VERSION=$(curl -sf "$AGENT_VERSION_API" --connect-timeout 5 --max-time 10 2>/dev/null | grep -o '"tag_name":"[^"]*"' | cut -d'"' -f4)
|
||||||
|
[ -n "$AGENT_REMOTE_VERSION" ] && printf '%s\n' "$AGENT_REMOTE_VERSION" > "${VERSION_DIR}/${AGENT_JAR}.version"
|
||||||
|
echo "[INFO] DualAuth Agent ready (${AGENT_REMOTE_VERSION:-latest})"
|
||||||
|
else
|
||||||
|
echo "[WARN] Downloaded agent too small, discarding"
|
||||||
|
rm -f "${AGENT_JAR}.tmp"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
rm -f "${AGENT_JAR}.tmp" 2>/dev/null
|
||||||
|
if [ -f "$AGENT_JAR" ]; then
|
||||||
|
echo "[WARN] Agent update failed, using existing"
|
||||||
|
else
|
||||||
|
echo "[ERROR] Failed to download DualAuth Agent"
|
||||||
|
echo "[ERROR] Download manually: $AGENT_URL"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Final Checks ---
|
||||||
|
|
||||||
|
for required in "$SERVER_JAR" "$ASSETS_PATH" "$AGENT_JAR"; do
|
||||||
|
if [ ! -f "$required" ]; then
|
||||||
|
echo "[ERROR] Required file missing: $required"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Generate Server ID ---
|
||||||
|
|
||||||
|
SERVER_ID_FILE=".server-id"
|
||||||
|
if [ -f "$SERVER_ID_FILE" ]; then
|
||||||
|
SERVER_ID=$(cat "$SERVER_ID_FILE")
|
||||||
|
echo "[INFO] Server ID: $SERVER_ID"
|
||||||
|
else
|
||||||
|
SERVER_ID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen 2>/dev/null || python3 -c "import uuid; print(uuid.uuid4())" 2>/dev/null)
|
||||||
|
if [ -z "$SERVER_ID" ]; then
|
||||||
|
echo "[ERROR] Could not generate UUID. Install uuidgen or python3."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s' "$SERVER_ID" > "$SERVER_ID_FILE"
|
||||||
|
echo "[INFO] Generated server ID: $SERVER_ID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Fetch Tokens ---
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[INFO] Fetching server tokens from $AUTH_SERVER..."
|
||||||
|
|
||||||
|
TEMP_RESPONSE=$(mktemp)
|
||||||
|
curl -s -X POST "$AUTH_SERVER/server/auto-auth" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"server_id\": \"$SERVER_ID\", \"server_name\": \"$SERVER_NAME\"}" \
|
||||||
|
--connect-timeout 10 \
|
||||||
|
--max-time 30 \
|
||||||
|
-o "$TEMP_RESPONSE"
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "[ERROR] Failed to connect to auth server at $AUTH_SERVER"
|
||||||
|
rm -f "$TEMP_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -q "sessionToken" "$TEMP_RESPONSE" 2>/dev/null; then
|
||||||
|
echo "[ERROR] Invalid response from auth server:"
|
||||||
|
cat "$TEMP_RESPONSE"
|
||||||
|
rm -f "$TEMP_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Extract tokens (python3 > jq > grep fallback)
|
||||||
|
if command -v python3 &>/dev/null; then
|
||||||
|
SESSION_TOKEN=$(python3 -c "import json,sys; print(json.load(open('$TEMP_RESPONSE'))['sessionToken'])")
|
||||||
|
IDENTITY_TOKEN=$(python3 -c "import json,sys; print(json.load(open('$TEMP_RESPONSE'))['identityToken'])")
|
||||||
|
elif command -v jq &>/dev/null; then
|
||||||
|
SESSION_TOKEN=$(jq -r '.sessionToken' "$TEMP_RESPONSE")
|
||||||
|
IDENTITY_TOKEN=$(jq -r '.identityToken' "$TEMP_RESPONSE")
|
||||||
|
else
|
||||||
|
SESSION_TOKEN=$(grep -o '"sessionToken":"[^"]*"' "$TEMP_RESPONSE" | cut -d'"' -f4)
|
||||||
|
IDENTITY_TOKEN=$(grep -o '"identityToken":"[^"]*"' "$TEMP_RESPONSE" | cut -d'"' -f4)
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$TEMP_RESPONSE"
|
||||||
|
|
||||||
|
if [ -z "$SESSION_TOKEN" ] || [ -z "$IDENTITY_TOKEN" ]; then
|
||||||
|
echo "[ERROR] Could not extract tokens from auth server response"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[INFO] Tokens received successfully"
|
||||||
|
|
||||||
|
# --- Start Server ---
|
||||||
|
|
||||||
|
JAVA_ARGS=""
|
||||||
|
[ -n "${JVM_XMS:-}" ] && JAVA_ARGS="$JAVA_ARGS -Xms$JVM_XMS"
|
||||||
|
[ -n "${JVM_XMX:-}" ] && JAVA_ARGS="$JAVA_ARGS -Xmx$JVM_XMX"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "============================================================"
|
||||||
|
echo " Starting Hytale Server"
|
||||||
|
echo " Name: $SERVER_NAME"
|
||||||
|
echo " Bind: $BIND_ADDRESS"
|
||||||
|
echo " Agent: $AGENT_JAR"
|
||||||
|
echo "============================================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
exec "$JAVA_CMD" $JAVA_ARGS -javaagent:"$AGENT_JAR" -jar "$SERVER_JAR" \
|
||||||
|
--assets "$ASSETS_PATH" \
|
||||||
|
--bind "$BIND_ADDRESS" \
|
||||||
|
--auth-mode "$AUTH_MODE" \
|
||||||
|
--disable-sentry \
|
||||||
|
--session-token "$SESSION_TOKEN" \
|
||||||
|
--identity-token "$IDENTITY_TOKEN" \
|
||||||
|
"$@"
|
||||||
523
test-uuid-persistence.js
Normal file
523
test-uuid-persistence.js
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* UUID Persistence Tests
|
||||||
|
*
|
||||||
|
* Simulates the exact conditions that caused character data loss:
|
||||||
|
* - Config file corruption during updates
|
||||||
|
* - File locks making config temporarily unreadable
|
||||||
|
* - Username re-entry after config wipe
|
||||||
|
*
|
||||||
|
* Run: node test-uuid-persistence.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
// Use a temp directory so we don't mess with real config
|
||||||
|
const TEST_DIR = path.join(os.tmpdir(), 'hytale-uuid-test-' + Date.now());
|
||||||
|
const CONFIG_FILE = path.join(TEST_DIR, 'config.json');
|
||||||
|
const CONFIG_BACKUP = path.join(TEST_DIR, 'config.json.bak');
|
||||||
|
const CONFIG_TEMP = path.join(TEST_DIR, 'config.json.tmp');
|
||||||
|
const UUID_STORE_FILE = path.join(TEST_DIR, 'uuid-store.json');
|
||||||
|
|
||||||
|
// Track test results
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const failures = [];
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (condition) {
|
||||||
|
passed++;
|
||||||
|
console.log(` ✓ ${message}`);
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
failures.push(message);
|
||||||
|
console.log(` ✗ FAIL: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEqual(actual, expected, message) {
|
||||||
|
if (actual === expected) {
|
||||||
|
passed++;
|
||||||
|
console.log(` ✓ ${message}`);
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
failures.push(`${message} (expected: ${expected}, got: ${actual})`);
|
||||||
|
console.log(` ✗ FAIL: ${message} (expected: "${expected}", got: "${actual}")`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(TEST_DIR)) {
|
||||||
|
fs.rmSync(TEST_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
cleanup();
|
||||||
|
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Inline the config functions so we can override paths
|
||||||
|
// (We can't require config.js directly because it uses hardcoded getAppDir())
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function validateConfig(config) {
|
||||||
|
if (!config || typeof config !== 'object') return false;
|
||||||
|
if (config.userUuids !== undefined && typeof config.userUuids !== 'object') return false;
|
||||||
|
if (config.username !== undefined && (typeof config.username !== 'string')) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(CONFIG_FILE)) {
|
||||||
|
const data = fs.readFileSync(CONFIG_FILE, 'utf8');
|
||||||
|
if (data.trim()) {
|
||||||
|
const config = JSON.parse(data);
|
||||||
|
if (validateConfig(config)) return config;
|
||||||
|
console.warn('[Config] Primary config invalid structure, trying backup...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Config] Failed to load primary config:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(CONFIG_BACKUP)) {
|
||||||
|
const data = fs.readFileSync(CONFIG_BACKUP, 'utf8');
|
||||||
|
if (data.trim()) {
|
||||||
|
const config = JSON.parse(data);
|
||||||
|
if (validateConfig(config)) {
|
||||||
|
console.log('[Config] Recovered from backup successfully');
|
||||||
|
try { fs.writeFileSync(CONFIG_FILE, data, 'utf8'); } catch (e) {}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConfig(update) {
|
||||||
|
const maxRetries = 3;
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(TEST_DIR)) fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const currentConfig = loadConfig();
|
||||||
|
|
||||||
|
// SAFETY CHECK: refuse to save if file exists but loaded empty
|
||||||
|
if (Object.keys(currentConfig).length === 0 && fs.existsSync(CONFIG_FILE)) {
|
||||||
|
const fileSize = fs.statSync(CONFIG_FILE).size;
|
||||||
|
if (fileSize > 2) {
|
||||||
|
console.error(`[Config] REFUSING to save — loaded empty but file exists (${fileSize} bytes). Retrying...`);
|
||||||
|
const delay = attempt * 50; // shorter delay for tests
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < delay) {}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newConfig = { ...currentConfig, ...update };
|
||||||
|
const data = JSON.stringify(newConfig, null, 2);
|
||||||
|
|
||||||
|
fs.writeFileSync(CONFIG_TEMP, data, 'utf8');
|
||||||
|
const verification = JSON.parse(fs.readFileSync(CONFIG_TEMP, 'utf8'));
|
||||||
|
if (!validateConfig(verification)) throw new Error('Validation failed');
|
||||||
|
|
||||||
|
if (fs.existsSync(CONFIG_FILE)) {
|
||||||
|
try {
|
||||||
|
const currentData = fs.readFileSync(CONFIG_FILE, 'utf8');
|
||||||
|
if (currentData.trim()) fs.writeFileSync(CONFIG_BACKUP, currentData, 'utf8');
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.renameSync(CONFIG_TEMP, CONFIG_FILE);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
try { if (fs.existsSync(CONFIG_TEMP)) fs.unlinkSync(CONFIG_TEMP); } catch (e) {}
|
||||||
|
if (attempt >= maxRetries) throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadUuidStore() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(UUID_STORE_FILE)) {
|
||||||
|
const data = fs.readFileSync(UUID_STORE_FILE, 'utf8');
|
||||||
|
if (data.trim()) return JSON.parse(data);
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveUuidStore(store) {
|
||||||
|
const tmpFile = UUID_STORE_FILE + '.tmp';
|
||||||
|
fs.writeFileSync(tmpFile, JSON.stringify(store, null, 2), 'utf8');
|
||||||
|
fs.renameSync(tmpFile, UUID_STORE_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateUuidStoreIfNeeded() {
|
||||||
|
if (fs.existsSync(UUID_STORE_FILE)) return;
|
||||||
|
const config = loadConfig();
|
||||||
|
if (config.userUuids && Object.keys(config.userUuids).length > 0) {
|
||||||
|
console.log('[UUID Store] Migrating', Object.keys(config.userUuids).length, 'UUIDs');
|
||||||
|
saveUuidStore(config.userUuids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUuidForUser(username) {
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
if (!username || !username.trim()) throw new Error('Username required');
|
||||||
|
|
||||||
|
const displayName = username.trim();
|
||||||
|
const normalizedLookup = displayName.toLowerCase();
|
||||||
|
|
||||||
|
migrateUuidStoreIfNeeded();
|
||||||
|
|
||||||
|
// 1. Check UUID store (source of truth)
|
||||||
|
const uuidStore = loadUuidStore();
|
||||||
|
const storeKey = Object.keys(uuidStore).find(k => k.toLowerCase() === normalizedLookup);
|
||||||
|
if (storeKey) {
|
||||||
|
const existingUuid = uuidStore[storeKey];
|
||||||
|
if (storeKey !== displayName) {
|
||||||
|
delete uuidStore[storeKey];
|
||||||
|
uuidStore[displayName] = existingUuid;
|
||||||
|
saveUuidStore(uuidStore);
|
||||||
|
}
|
||||||
|
// Sync to config (non-critical)
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const configUuids = config.userUuids || {};
|
||||||
|
const configKey = Object.keys(configUuids).find(k => k.toLowerCase() === normalizedLookup);
|
||||||
|
if (!configKey || configUuids[configKey] !== existingUuid) {
|
||||||
|
if (configKey) delete configUuids[configKey];
|
||||||
|
configUuids[displayName] = existingUuid;
|
||||||
|
saveConfig({ userUuids: configUuids });
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return existingUuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback: check config.json
|
||||||
|
const config = loadConfig();
|
||||||
|
const userUuids = config.userUuids || {};
|
||||||
|
const configKey = Object.keys(userUuids).find(k => k.toLowerCase() === normalizedLookup);
|
||||||
|
if (configKey) {
|
||||||
|
const recoveredUuid = userUuids[configKey];
|
||||||
|
uuidStore[displayName] = recoveredUuid;
|
||||||
|
saveUuidStore(uuidStore);
|
||||||
|
return recoveredUuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. New user — generate UUID
|
||||||
|
const newUuid = uuidv4();
|
||||||
|
uuidStore[displayName] = newUuid;
|
||||||
|
saveUuidStore(uuidStore);
|
||||||
|
userUuids[displayName] = newUuid;
|
||||||
|
saveConfig({ userUuids });
|
||||||
|
return newUuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// OLD CODE (before fix) — for comparison testing
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function getUuidForUser_OLD(username) {
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
if (!username || !username.trim()) throw new Error('Username required');
|
||||||
|
const displayName = username.trim();
|
||||||
|
const normalizedLookup = displayName.toLowerCase();
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const userUuids = config.userUuids || {};
|
||||||
|
const existingKey = Object.keys(userUuids).find(k => k.toLowerCase() === normalizedLookup);
|
||||||
|
|
||||||
|
if (existingKey) {
|
||||||
|
return userUuids[existingKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// New user
|
||||||
|
const newUuid = uuidv4();
|
||||||
|
userUuids[displayName] = newUuid;
|
||||||
|
saveConfig({ userUuids });
|
||||||
|
return newUuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConfig_OLD(update) {
|
||||||
|
// OLD saveConfig without safety check
|
||||||
|
if (!fs.existsSync(TEST_DIR)) fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||||
|
const currentConfig = loadConfig();
|
||||||
|
// NO SAFETY CHECK — this is the bug
|
||||||
|
const newConfig = { ...currentConfig, ...update };
|
||||||
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(newConfig, null, 2), 'utf8');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TESTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
console.log('\n' + '='.repeat(70));
|
||||||
|
console.log('UUID PERSISTENCE TESTS — Simulating update corruption scenarios');
|
||||||
|
console.log('='.repeat(70));
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// TEST 1: Normal flow — UUID stays consistent
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
console.log('\n--- Test 1: Normal flow — UUID stays consistent ---');
|
||||||
|
setup();
|
||||||
|
|
||||||
|
const uuid1 = getUuidForUser('SpecialK');
|
||||||
|
const uuid2 = getUuidForUser('SpecialK');
|
||||||
|
const uuid3 = getUuidForUser('specialk'); // case insensitive
|
||||||
|
|
||||||
|
assertEqual(uuid1, uuid2, 'Same username returns same UUID');
|
||||||
|
assertEqual(uuid1, uuid3, 'Case-insensitive lookup returns same UUID');
|
||||||
|
assert(uuid1.match(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i), 'UUID is valid v4 format');
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// TEST 2: Simulate update corruption (THE BUG) — old code
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
console.log('\n--- Test 2: OLD CODE — Config wipe during update loses UUID ---');
|
||||||
|
setup();
|
||||||
|
|
||||||
|
// Setup: player has UUID
|
||||||
|
const oldConfig = { username: 'SpecialK', userUuids: { 'SpecialK': 'aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee' }, hasLaunchedBefore: true };
|
||||||
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(oldConfig, null, 2), 'utf8');
|
||||||
|
|
||||||
|
const uuidBefore = getUuidForUser_OLD('SpecialK');
|
||||||
|
assertEqual(uuidBefore, 'aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee', 'UUID correct before corruption');
|
||||||
|
|
||||||
|
// Simulate: config.json gets corrupted (loadConfig returns {} because file locked)
|
||||||
|
// This simulates what happens when saveConfig reads an empty/locked file
|
||||||
|
fs.writeFileSync(CONFIG_FILE, '', 'utf8'); // Simulate corruption: empty file
|
||||||
|
|
||||||
|
// Old saveConfig behavior: reads empty, merges with update, saves
|
||||||
|
// This wipes userUuids
|
||||||
|
saveConfig_OLD({ hasLaunchedBefore: true });
|
||||||
|
|
||||||
|
const configAfterCorruption = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
||||||
|
assert(!configAfterCorruption.userUuids, 'OLD CODE: userUuids wiped after corruption');
|
||||||
|
assert(!configAfterCorruption.username, 'OLD CODE: username wiped after corruption');
|
||||||
|
|
||||||
|
// Player re-enters name, gets NEW UUID (character data lost!)
|
||||||
|
const uuidAfterOld = getUuidForUser_OLD('SpecialK');
|
||||||
|
assert(uuidAfterOld !== uuidBefore, 'OLD CODE: UUID changed after corruption (BUG!)');
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// TEST 3: NEW CODE — Config wipe during update, UUID survives via uuid-store
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
console.log('\n--- Test 3: NEW CODE — Config wipe + UUID survives via uuid-store ---');
|
||||||
|
setup();
|
||||||
|
|
||||||
|
// Setup: player has UUID (stored in both config.json AND uuid-store.json)
|
||||||
|
const initialConfig = { username: 'SpecialK', userUuids: { 'SpecialK': 'aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee' }, hasLaunchedBefore: true };
|
||||||
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(initialConfig, null, 2), 'utf8');
|
||||||
|
|
||||||
|
// First call migrates to uuid-store
|
||||||
|
const uuidFirst = getUuidForUser('SpecialK');
|
||||||
|
assertEqual(uuidFirst, 'aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee', 'UUID correct before corruption');
|
||||||
|
assert(fs.existsSync(UUID_STORE_FILE), 'uuid-store.json created');
|
||||||
|
|
||||||
|
// Simulate: config.json gets wiped (same as the update bug)
|
||||||
|
fs.writeFileSync(CONFIG_FILE, '{}', 'utf8');
|
||||||
|
|
||||||
|
// Verify config is empty
|
||||||
|
const wipedConfig = loadConfig();
|
||||||
|
assert(!wipedConfig.userUuids || Object.keys(wipedConfig.userUuids).length === 0, 'Config wiped — no userUuids');
|
||||||
|
assert(!wipedConfig.username, 'Config wiped — no username');
|
||||||
|
|
||||||
|
// Player re-enters same name → UUID recovered from uuid-store!
|
||||||
|
const uuidAfterNew = getUuidForUser('SpecialK');
|
||||||
|
assertEqual(uuidAfterNew, 'aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee', 'NEW CODE: UUID preserved after config wipe!');
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// TEST 4: saveConfig safety check — refuses to overwrite good data with empty
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
console.log('\n--- Test 4: saveConfig safety check — blocks destructive writes ---');
|
||||||
|
setup();
|
||||||
|
|
||||||
|
// Setup: valid config file with data
|
||||||
|
const goodConfig = { username: 'SpecialK', userUuids: { 'SpecialK': 'aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee' }, hasLaunchedBefore: true, installPath: 'C:\\Games\\Hytale' };
|
||||||
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(goodConfig, null, 2), 'utf8');
|
||||||
|
|
||||||
|
// Make the file temporarily unreadable by writing garbage (simulates file lock/corruption)
|
||||||
|
const originalContent = fs.readFileSync(CONFIG_FILE, 'utf8');
|
||||||
|
fs.writeFileSync(CONFIG_FILE, 'NOT VALID JSON!!!', 'utf8');
|
||||||
|
|
||||||
|
// Try to save — should refuse because file exists but can't be parsed
|
||||||
|
let saveThrew = false;
|
||||||
|
try {
|
||||||
|
saveConfig({ someNewField: true });
|
||||||
|
} catch (e) {
|
||||||
|
saveThrew = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The file should still have the garbage (not overwritten with { someNewField: true })
|
||||||
|
const afterContent = fs.readFileSync(CONFIG_FILE, 'utf8');
|
||||||
|
|
||||||
|
// Restore original for backup recovery test
|
||||||
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(goodConfig, null, 2), 'utf8');
|
||||||
|
|
||||||
|
// Note: with invalid JSON, loadConfig returns {} and safety check triggers
|
||||||
|
// The save may eventually succeed on retry if the file becomes readable
|
||||||
|
// What matters is that it doesn't blindly overwrite
|
||||||
|
assert(afterContent !== '{\n "someNewField": true\n}', 'Safety check prevented blind overwrite of corrupted file');
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// TEST 5: Backup recovery — config.json corrupted, recovered from .bak
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
console.log('\n--- Test 5: Backup recovery — auto-recover from .bak ---');
|
||||||
|
setup();
|
||||||
|
|
||||||
|
// Create config and backup
|
||||||
|
const validConfig = { username: 'SpecialK', userUuids: { 'SpecialK': 'aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee' } };
|
||||||
|
fs.writeFileSync(CONFIG_BACKUP, JSON.stringify(validConfig, null, 2), 'utf8');
|
||||||
|
fs.writeFileSync(CONFIG_FILE, 'CORRUPTED', 'utf8');
|
||||||
|
|
||||||
|
const recovered = loadConfig();
|
||||||
|
assertEqual(recovered.username, 'SpecialK', 'Username recovered from backup');
|
||||||
|
assert(recovered.userUuids && recovered.userUuids['SpecialK'] === 'aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee', 'UUID recovered from backup');
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// TEST 6: Full update simulation — the exact scenario from player report
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
console.log('\n--- Test 6: Full update simulation (player report scenario) ---');
|
||||||
|
setup();
|
||||||
|
|
||||||
|
// Step 1: Player installs v2.3.4, sets username, plays game
|
||||||
|
console.log(' Step 1: Player sets up profile...');
|
||||||
|
saveConfig({ username: 'Special K', hasLaunchedBefore: true });
|
||||||
|
const originalUuid = getUuidForUser('Special K');
|
||||||
|
console.log(` Original UUID: ${originalUuid}`);
|
||||||
|
|
||||||
|
// Step 2: v2.3.5 auto-update — new app launches
|
||||||
|
console.log(' Step 2: Simulating v2.3.5 update...');
|
||||||
|
|
||||||
|
// Simulate the 3 saveConfig calls that happen during startup
|
||||||
|
// But first, simulate config being temporarily locked (returns empty)
|
||||||
|
const preUpdateContent = fs.readFileSync(CONFIG_FILE, 'utf8');
|
||||||
|
fs.writeFileSync(CONFIG_FILE, '', 'utf8'); // Simulate: file empty during write (race condition)
|
||||||
|
|
||||||
|
// These are the 3 calls from: profileManager.init, migrateUserDataToCentralized, handleFirstLaunchCheck
|
||||||
|
// With our safety check, they should NOT wipe the data
|
||||||
|
try { saveConfig({ hasLaunchedBefore: true }); } catch (e) { /* expected — safety check blocks it */ }
|
||||||
|
|
||||||
|
// Simulate file becomes readable again (antivirus releases lock)
|
||||||
|
fs.writeFileSync(CONFIG_FILE, preUpdateContent, 'utf8');
|
||||||
|
|
||||||
|
// Step 3: Player re-enters username (because UI might show empty)
|
||||||
|
console.log(' Step 3: Player re-enters username...');
|
||||||
|
const postUpdateUuid = getUuidForUser('Special K');
|
||||||
|
console.log(` Post-update UUID: ${postUpdateUuid}`);
|
||||||
|
|
||||||
|
assertEqual(postUpdateUuid, originalUuid, 'UUID survived the full update cycle!');
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// TEST 7: Multiple users — UUIDs stay independent
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
console.log('\n--- Test 7: Multiple users — UUIDs stay independent ---');
|
||||||
|
setup();
|
||||||
|
|
||||||
|
const uuidAlice = getUuidForUser('Alice');
|
||||||
|
const uuidBob = getUuidForUser('Bob');
|
||||||
|
const uuidCharlie = getUuidForUser('Charlie');
|
||||||
|
|
||||||
|
assert(uuidAlice !== uuidBob, 'Alice and Bob have different UUIDs');
|
||||||
|
assert(uuidBob !== uuidCharlie, 'Bob and Charlie have different UUIDs');
|
||||||
|
|
||||||
|
// Wipe config, all should survive
|
||||||
|
fs.writeFileSync(CONFIG_FILE, '{}', 'utf8');
|
||||||
|
|
||||||
|
assertEqual(getUuidForUser('Alice'), uuidAlice, 'Alice UUID survived config wipe');
|
||||||
|
assertEqual(getUuidForUser('Bob'), uuidBob, 'Bob UUID survived config wipe');
|
||||||
|
assertEqual(getUuidForUser('Charlie'), uuidCharlie, 'Charlie UUID survived config wipe');
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// TEST 8: UUID store deleted — recovery from config.json
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
console.log('\n--- Test 8: UUID store deleted — recovery from config.json ---');
|
||||||
|
setup();
|
||||||
|
|
||||||
|
// Create UUID via normal flow (saves to both stores)
|
||||||
|
const uuidOriginal = getUuidForUser('TestPlayer');
|
||||||
|
|
||||||
|
// Delete uuid-store.json (simulates user manually deleting it or disk issue)
|
||||||
|
fs.unlinkSync(UUID_STORE_FILE);
|
||||||
|
assert(!fs.existsSync(UUID_STORE_FILE), 'uuid-store.json deleted');
|
||||||
|
|
||||||
|
// UUID should be recovered from config.json
|
||||||
|
const uuidRecovered = getUuidForUser('TestPlayer');
|
||||||
|
assertEqual(uuidRecovered, uuidOriginal, 'UUID recovered from config.json after uuid-store deletion');
|
||||||
|
assert(fs.existsSync(UUID_STORE_FILE), 'uuid-store.json recreated after recovery');
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// TEST 9: Both stores deleted — new UUID generated (fresh install)
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
console.log('\n--- Test 9: Both stores deleted — new UUID (fresh install) ---');
|
||||||
|
setup();
|
||||||
|
|
||||||
|
const uuidFresh = getUuidForUser('NewPlayer');
|
||||||
|
|
||||||
|
// Delete both
|
||||||
|
fs.unlinkSync(UUID_STORE_FILE);
|
||||||
|
fs.unlinkSync(CONFIG_FILE);
|
||||||
|
|
||||||
|
const uuidAfterWipe = getUuidForUser('NewPlayer');
|
||||||
|
assert(uuidAfterWipe !== uuidFresh, 'New UUID generated when both stores are gone (expected for true fresh install)');
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// TEST 10: Worst case — config.json wiped AND uuid-store.json exists
|
||||||
|
// Simulates the EXACT player-reported scenario with new code
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
console.log('\n--- Test 10: Exact player scenario with new code ---');
|
||||||
|
setup();
|
||||||
|
|
||||||
|
// Player has been playing for a while
|
||||||
|
saveConfig({
|
||||||
|
username: 'Special K',
|
||||||
|
hasLaunchedBefore: true,
|
||||||
|
installPath: 'C:\\Games\\Hytale',
|
||||||
|
version_client: '2026.02.19-1a311a592',
|
||||||
|
version_branch: 'release',
|
||||||
|
userUuids: { 'Special K': '11111111-2222-4333-9444-555555555555' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// First call creates uuid-store.json
|
||||||
|
const originalUuid10 = getUuidForUser('Special K');
|
||||||
|
assertEqual(originalUuid10, '11111111-2222-4333-9444-555555555555', 'Original UUID loaded');
|
||||||
|
|
||||||
|
// BOOM: Update happens, config.json completely wiped
|
||||||
|
fs.writeFileSync(CONFIG_FILE, '{}', 'utf8');
|
||||||
|
|
||||||
|
// Username lost — player has to re-enter
|
||||||
|
const loadedUsername = loadConfig().username;
|
||||||
|
assert(!loadedUsername, 'Username is gone from config (simulating what player saw)');
|
||||||
|
|
||||||
|
// Player types "Special K" again in settings
|
||||||
|
saveConfig({ username: 'Special K' });
|
||||||
|
|
||||||
|
// Player clicks Play — getUuidForUser called
|
||||||
|
const recoveredUuid10 = getUuidForUser('Special K');
|
||||||
|
assertEqual(recoveredUuid10, '11111111-2222-4333-9444-555555555555', 'UUID recovered — character data preserved!');
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RESULTS
|
||||||
|
// ============================================================================
|
||||||
|
console.log('\n' + '='.repeat(70));
|
||||||
|
console.log(`RESULTS: ${passed} passed, ${failed} failed`);
|
||||||
|
if (failed > 0) {
|
||||||
|
console.log('\nFailures:');
|
||||||
|
failures.forEach(f => console.log(` ✗ ${f}`));
|
||||||
|
}
|
||||||
|
console.log('='.repeat(70));
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
Reference in New Issue
Block a user