Compare commits

..

1 Commits

Author SHA1 Message Date
AMIAY
e5b44341f1 Develop (#282)
* fix: resolve cross-platform EPERM permissions errors

modManager.js:
- Switch from hardcoded 'junction' to dynamic symlink type based on OS (fixing Linux EPERM).
- Add retry logic for directory removal to handle file locking race conditions.
- Improve broken symlink detection during profile sync.

gameManager.js:
- Implement retry loop (3 attempts) for game directory removal in updateGameFiles to prevent EBUSY/EPERM errors on Windows.

paths.js:
- Prevent fs.mkdirSync failure in getModsPath by pre-checking for broken symbolic links.

* fix: missing pacman builds

* prepare release for 2.1.1

minor fix for EPERM error permission

* prepare release 2.1.1

minor fix EPERM permission error

* prepare release 2.1.1

* Update README.md Windows Prequisites for ARM64 builds

* fix: remove broken symlink after detected

* fix: add pathexists for paths.js to check symlink

* fix: isbrokenlink should be true to remove the symlink

* add arch package .pkg.tar.zst for release

* fix: release workflow for build-arch and build-linux

* build-arch job now only build arch .pkg.tar.zst package instead of the whole generic linux.
* build-linux job now exclude .pacman package since its deprecated and should not be used.

* fix: removes pacman build as it replaced by tar.zst and adds build:arch shortcut for pkgbuild

* aur: add proper VCS (-git) PKGBUILD

created clean VCS-based PKGBUILD following arch packaging conventions.

this explicitly marked as a rolling (-git) build and derives its version dynamically from git tags and commit history via pkgver(). previous hybrid approach has been changed.

key changes:
- use -git suffix to clearly indicate rolling source builds
- set pkgver=0 and compute the actual version via pkgver()
- build only a directory layout using electron-builder (--dir)
- avoid generating AppImage, deb, rpm, or pacman installers
- align build and package steps with Arch packaging guidelines

note: this PKGBUILD is intended for development and AUR use only and is not suitable for binary redistribution or release artifacts.

* ci: add fixed-version PKGBUILD for Arch Linux releases

this PKGBUILD intended for CI and GitHub release artifacts. targets tagged releases only and uses a fixed pkgver that matches the corresponding git tag. all of the VCS logic has been removed to PKGBUILD-git to ensure reproducible builds and stable versioning suitable for binary distribution.

the build process relies on electron-builder directory output (--dir) and packages only the unpacked application into a standard Arch Linux package (.pkg.tar.zst). other distro format are excluded from this path and handled separately.

this change establishes a clear separation between:
- rolling AUR development builds (-git)
- CI-generated, versioned Arch Linux release packages

the result is predictable artifact naming, correct version alignment, and Arch-compliant packaging for downstream users.

* Update README.md

adds information for Arch build

* Update README.md

BUILD.md location was changed and now this link is poiting to nothing

* Update PKGBUILD

* Update PKGBUILD-git

* chore: fix ubuntu/debian part in README.md

* Polish language support (#195)

* Update support_request.yml

Added hardware specification

* Update bug_report.yml

Add logs textfield to bug report

* chore: add changelog in README.md

* fix screenshot input in feature_request.yml

* add hardware spec input in bug_report.yml

* fix: PKGBUILD pkgname variable fix

* userdata migration [need review from other OS]

* french translate

* Add German and Swedish translations

Added de.json and sv.json locale files for German and Swedish language support. Updated i18n.js to register 'de' and 'sv' as available languages in the launcher.

* Update README.md

* chore: add offline-mode warning to the README.md

* chore: add downloads counter in README.md

* fix: Steam Deck/Ubuntu crash - use system libzstd.so

The bundled libzstd.so is incompatible with glibc 2.41's stricter heap
validation, causing "free(): invalid pointer" crashes.

Solution: Automatically replace bundled libzstd.so with system version
on Linux. The launcher detects and symlinks to /usr/lib/libzstd.so.1.

- Auto-detect system libzstd at common paths (Arch, Debian, Fedora)
- Backup bundled version as libzstd.so.bundled
- Create symlink to system version
- Add HYTALE_NO_LIBZSTD_FIX=1 to disable if needed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: remove Windows and Linux ARM64 information on the README.md

* Update support_request.yml

* fix: improve update system UX and macOS compatibility

Update System Improvements:
- Fix duplicate update popups by disabling legacy updater.js
- Add skip button to update popup (shows after 30s, on error, or after download)
- Add macOS-specific handling with manual download as primary option
- Add missing open-download-page IPC handler
- Add missing unblockInterface() method to properly clean up after popup close
- Add quitAndInstallUpdate alias in preload for compatibility
- Remove pulse animation when download completes
- Fix manual download button to show correct status and close popup
- Sync player name to settings input after first install

Client Patcher Cleanup:
- Remove server patching code (server uses pre-patched JAR from CDN)
- Simplify to client-only patching
- Remove unused imports (crypto, AdmZip, execSync, spawn, javaManager)
- Remove unused methods (stringToUtf8, findAndReplaceDomainUtf8)
- Move localhost dev code to backup file for reference

Code Quality Fixes:
- Fix duplicate DOMContentLoaded handlers in install.js
- Fix duplicate checkForUpdates definition in preload.js
- Fix redundant if/else in onProgressUpdate callback
- Fix typo "Harwadre" -> "Hardware" in preload.js

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add Russian language support

Added Russian (ru) to the list of available languages.

* chore: drafting documentation on SERVER.md

* Some updates in Russian language localization file

* fix

* Update ru.json

* Fixed Java runtime name and fixed typo

* fixed untranslated place

* Update ru.json

* Update ru.json

* Update ru.json

* Update ru.json

* Update ru.json

* fix: timeout getLatestClient 

fixes #138

* fix: change default version to 7.pwr in main.js

* fix: change default release version to 7.pwr

* fix: change version release to 7.pwr

* docs: Add comprehensive troubleshooting guide (#209)

Add TROUBLESHOOTING.md with solutions for common issues including:

- Windows: Firewall configuration, duplicate mods, SmartScreen
- Linux: GPU detection (NVIDIA/AMD), SDL3_image/libpng dependencies,
  Wayland/X11 issues, Steam Deck support
- macOS: Rosetta 2 for Apple Silicon, code signing, quarantine
- Connection: Server boot failures, regional restrictions
- Authentication: Token errors, config reset procedures
- Avatar/Cosmetics: F2P limitations documentation
- Backup locations for all platforms
- Log locations for bug reports

Solutions compiled from closed GitHub issues (#205, #155, #90, #60,
#144, #192) and community feedback.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* Standardize language codes, improve formatting, and update all locale files. (#224)

* Update German (Germany) localization

* Update Español (España) localization

* Update French (France) localization

* Update Polish (Poland) localization

* Update Portuguese (Brazil) localization

* Update Russian (Russia) localization

* Update Swedish (Sweden) localization

* Update Turkish (Turkey) localization

* Update language codes, names and alphabetical in i18n system

* Changed Spanish language name to the Formal name "Spanish (Spain)"

* Fix PKGBUILD-git

* Fix PKGBUILD

* delete cache after installation

* Enforce 16-char player name limit and update mod sync

Added a maxlength attribute to the player name input and enforced a 16-character limit in both install and settings scripts, providing user feedback if exceeded. Refactored modManager.js to replace symlink-based mod management with a copy-based system, copying enabled mods to HytaleSaves\Mods and removing legacy symlink logic to improve compatibility and avoid permission issues.

* Update installation subtitle

* chore: update quickstart link in README.md

* chore: delete warning of Ubuntu-Debian at Linux Prequisites section

* added featured server list from api

* Add Featured Servers page to GUI

* Update Discord invite URL in client patcher

* Add differential update system

* Remove launcher chat and add Discord popup

* fix: removed 'check disk space' alert on permission file error

* fix: upgrade tar to ^7.5.6 version

* fix: re-add universal arch for mac

* fix: upgrade electron/rebuild to 4.0.3

* fix: removed override tar version

* fix: pkgbuild version to 2.1.2

* fix: src.tar.zst and srcinfo missing files

* feat: add Indonesian language translation

* fix: GPU preference hint to Laptop-only

* feat: create two columns for settings page

* Add Discord invite link to rpc

* docs: add recordings form, fix OS list

* Release v2.2.0

* Release v2.2.0

* Release v2.2.0

* chore: delete icon.ico, moved to build folder

* chore: delete icon.png, moved to build folder

* fix: build and release for tag push-only in release.yml

* fix: gamescope steam deck issue fixes #186 hopefully

* Support branch selection for server patching

* chose: add auto-patch system for pre-release JAR

* fix: preserves arch x64 on linux target for #242

* fix: removed arm64 flags

* fix: redo package.json arch

* update package-lock.json

* Update release.yml

* chore: sync package-lock with package.json

* fix: reorder fedora libzstd paths to first iteration

* feat: enhance gpu detection, drafting

* fix: comprehensive UUID/username persistence bug fixes (#252)

* fix: comprehensive UUID/username persistence bug fixes

Major fixes for UUID/skin reset issues that caused players to lose cosmetics:

Core fixes:
- Username rename now preserves UUID (atomic rename, not new identity)
- Atomic config writes with backup/recovery system
- Case-insensitive UUID lookup with case-preserving storage
- Pre-launch validation blocks play if no username configured
- Removed saveUsername calls from launch/install flows

UUID Modal fixes:
- Fixed isCurrent badge showing on wrong user
- Added switch identity button to change between saved usernames
- Fixed custom UUID input using unsaved DOM username
- UUID list now refreshes when player name changes
- Enabled copy/paste in custom UUID input field

UI/UX improvements:
- Added translation keys for switch username functionality
- CSS user-select fix for UUID input fields
- Allowed Ctrl+V/C/X/A shortcuts in Electron

Files: config.js, gameLauncher.js, gameManager.js, playerManager.js,
launcher.js, settings.js, main.js, preload.js, style.css, en.json

See UUID_BUGS_FIX_PLAN.md for detailed bug list (18 bugs, 16 fixed)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(i18n): add switch username translations to all locales

Added translation keys for username switching functionality:
- notifications.noUsername
- notifications.switchUsernameSuccess
- notifications.switchUsernameFailed
- notifications.playerNameTooLong
- confirm.switchUsernameTitle
- confirm.switchUsernameMessage
- confirm.switchUsernameButton

Languages updated: de-DE, es-ES, fr-FR, id-ID, pl-PL, pt-BR, ru-RU, sv-SE, tr-TR

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: move UUID_BUGS_FIX_PLAN.md to docs folder

* docs: update UUID_BUGS_FIX_PLAN with complete fix details

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* chore: rearrange, fix, and improve README.md

* chore: link downloads, platform, and version to release page in README.md

* chore: update discord link

* chore: insert contact link in CODE_OF_CONDUCT.md

* fix: missing version text on launcher

* chore: update quickstart button link to header

* chore: update discord link and give warning quickstart

* chore revise online play hosting instructions in README

Updated instructions for hosting an online game and clarified troubleshooting steps.

* Fix Turkish translations in tr-TR.json

* fix: EPERM error in Repair Game Button [windows testing needed]

* fix: invalid generated token that caused hangs on exit [windows testing needed]

* fix: major bug - hytale won't launch with laptop machine and ghost processes

* fix: discord RPC destroy error if not connected

* fix: major bug - detach game process to avoid launcher-held handles causing zombie process

* docs: add analysis on ghost process and launcher cleanup

* revert generateLocalTokens, wrong analysis on game launching issue

* revert add deps for generateLocalTokens

* Add proxy client and route downloads through it

* fix: Prevent JAR file corruption during proxy downloads

Fixed binary file corruption when downloading through proxy by using PassThrough stream to preserve data integrity while tracking download progress.

* Improve featured servers layout with Discord integration

- Add Discord button to server cards when discord link is present in API data
- Remove HF2P Servers section to use full width for featured servers
- Increase server card size (300x180px banner, larger fonts and spacing)
- Simplify layout from 2-column grid to single full-width container
- Discord button opens external browser with server invite link

* package version to 2.2.1

Update package.json version from 2.2.0 to 2.2.1 to publish a patch release.

* fix: add game_running_marker to prevent duplicate launches

* Add smart proxy with direct-fallback and logging

* fix: remove duplicate check

* fix: cache invalidation from .env prevents multiple launch attempts

for all env related, it is necessary to clear cache first, otherwise on few launch attempts the game wouldn't run

* fix: redact proxy_url and remove timed out emoji

* Prepare Release v2.2.1

* docs: enhance bug report template with placeholders and options

Updated the bug report template to include placeholders and additional Linux distributions.

* chore revise windows prerequisites and changelog

Updated prerequisites and changelog for version 2.2.1.

* chore: improvise badges, relocate star history, fix discord links

* chore: fix release notes for v2.2.1

* feat(macos): add code signing and notarization support

Add macOS code signing and notarization for Gatekeeper compatibility:

- Add hardened runtime configuration in package.json
- Add entitlements.mac.plist for required app permissions
- Enable built-in electron-builder notarization
- Add code signing and notarization secrets to workflow

Required GitHub Secrets:
- CSC_LINK: Base64-encoded .p12 certificate file
- CSC_KEY_PASSWORD: Password for the .p12 certificate
- APPLE_ID: Apple Developer account email
- APPLE_APP_SPECIFIC_PASSWORD: App-specific password from appleid.apple.com
- APPLE_TEAM_ID: 10-character Apple Developer Team ID

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Revise and enhance Hytale F2P Server Guide

Updated the Hytale F2P Server Guide with new sections and improved formatting.

* Update SERVER.md official accounts info

Added CloudNord hosting information and new section for playing online with official accounts.

* Update SERVER.md

* refactor: replace pre-patched JAR download with ByteBuddy agent

Migrate from downloading pre-patched server JARs from CDN to downloading
the DualAuth ByteBuddy Agent from GitHub releases. The server JAR stays
pristine - auth patching happens at runtime via -javaagent: flag.

clientPatcher.js:
- Replace patchServer() with ensureAgentAvailable()
- Download dualauth-agent.jar to Server/ directory
- Remove serverJarContainsDualAuth() and validateServerJarSize()

gameLauncher.js:
- Set JAVA_TOOL_OPTIONS env var with -javaagent: for runtime patching
- Update logging to show agent status instead of server patch count

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Bump package version to 2.2.2

Update package.json version from 2.2.1 to 2.2.2 to mark a patch release.

* Use new version API and default to v8 PWR

---------

Co-authored-by: Fazri Gading <fazrigading@gmail.com>
Co-authored-by: TalesAmaral <57869141+TalesAmaral@users.noreply.github.com>
Co-authored-by: walti0 <95646872+walti0@users.noreply.github.com>
Co-authored-by: Fazri Gading <super.fai700@gmail.com>
Co-authored-by: sanasol <mail@sanasol.ws>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Terromur <79866197+Terromur@users.noreply.github.com>
Co-authored-by: Zakhar Smokotov <zaharb840@gmail.com>
Co-authored-by: xSamiVS <samtaiebc@gmail.com>
Co-authored-by: MetricsLite <66024355+MetricsLite@users.noreply.github.com>
2026-02-11 11:13:14 +01:00
17 changed files with 543 additions and 634 deletions

View File

@@ -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/Fhbb9Yk5WW). 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/hf2pdc). 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.

View File

@@ -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/Fhbb9Yk5WW)! **Need a quick assistance?** Please Open-A-Ticket in our [Discord Server](https://discord.gg/gME8rUy3MB)!
- type: textarea - type: textarea
id: question id: question

View File

@@ -6,117 +6,201 @@ 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:
create-release:
runs-on: ubuntu-latest
steps:
- 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: build-windows:
needs: [create-release] runs-on: windows-latest
runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - 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: Create Virtual .env File
run: npx electron-builder --win --publish never --config.npmRebuild=false # Because main.js needed physical env, we need to create virtual one to store it
- name: Upload to Release
run: | run: |
RELEASE_ID=$(curl -s "${FORGEJO_API}/repos/${GITHUB_REPOSITORY}/releases/tags/${{ github.ref_name }}" \ $env_content = @"
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])') HF2P_PROXY_URL=${{ secrets.HF2P_PROXY_URL }}
for file in dist/*.exe dist/*.exe.blockmap dist/latest.yml; do HF2P_SECRET_KEY=${{ secrets.HF2P_SECRET_KEY }}
[ -f "$file" ] || continue "@
echo "Uploading $file..." Set-Content -Path .env -Value $env_content
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 }}" \ - name: Build Windows Packages
-F "attachment=@${file}" || echo "Failed to upload $file" run: npx electron-builder --win --publish never
done - uses: actions/upload-artifact@v4
with:
name: windows-builds
path: |
dist/*.exe
dist/*.exe.blockmap
dist/latest.yml
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: Create Virtual .env File
run: |
cat << EOF > .env
HF2P_PROXY_URL=${{ secrets.HF2P_PROXY_URL }}
HF2P_SECRET_KEY=${{ secrets.HF2P_SECRET_KEY }}
EOF
- 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
- name: Upload to Release with:
run: | name: macos-builds
RELEASE_ID=$(curl -s "${FORGEJO_API}/repos/${GITHUB_REPOSITORY}/releases/tags/${{ github.ref_name }}" \ path: |
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])') dist/*.dmg
for file in dist/*.dmg dist/*.zip dist/*.blockmap dist/latest-mac.yml; do dist/*.zip
[ -f "$file" ] || continue dist/*.blockmap
echo "Uploading $file..." dist/latest-mac.yml
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 rpm sudo apt-get install -y libarchive-tools
- 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: Create Virtual .env File
run: npx electron-builder --linux AppImage deb rpm pacman --publish never
- name: Upload to Release
run: | run: |
RELEASE_ID=$(curl -s "${FORGEJO_API}/repos/${GITHUB_REPOSITORY}/releases/tags/${{ github.ref_name }}" \ cat << EOF > .env
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])') HF2P_PROXY_URL=${{ secrets.HF2P_PROXY_URL }}
for file in dist/*.AppImage dist/*.AppImage.blockmap dist/*.deb dist/*.rpm dist/*.pacman dist/latest-linux.yml; do HF2P_SECRET_KEY=${{ secrets.HF2P_SECRET_KEY }}
[ -f "$file" ] || continue EOF
echo "Uploading $file..."
curl -s --max-time 600 -X POST "${FORGEJO_UPLOAD}/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=$(basename $file)" \ - name: Build Linux Packages
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \ run: |
-F "attachment=@${file}" || echo "Failed to upload $file" npx electron-builder --linux AppImage deb rpm --publish never
done - uses: actions/upload-artifact@v4
with:
name: linux-builds
path: |
dist/*.AppImage
dist/*.AppImage.blockmap
dist/*.deb
dist/*.rpm
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
cat << EOP > .env
HF2P_PROXY_URL=${{ secrets.HF2P_PROXY_URL }}
HF2P_SECRET_KEY=${{ secrets.HF2P_SECRET_KEY }}
EOP
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
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
View File

@@ -17,9 +17,6 @@ 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

View File

@@ -53,7 +53,7 @@ window.closeDiscordPopup = function() {
}; };
window.joinDiscord = async function() { window.joinDiscord = async function() {
await window.electronAPI?.openExternal('https://discord.gg/Fhbb9Yk5WW'); await window.electronAPI?.openExternal('https://discord.gg/hf2pdc');
try { try {
await window.electronAPI?.saveConfig({ discordPopup: true }); await window.electronAPI?.saveConfig({ discordPopup: true });

View File

@@ -1103,7 +1103,7 @@ function getRetryContextMessage() {
} }
window.openDiscordExternal = function() { window.openDiscordExternal = function() {
window.electronAPI?.openExternal('https://discord.gg/Fhbb9Yk5WW'); window.electronAPI?.openExternal('https://discord.gg/hf2pdc');
}; };
window.toggleMaximize = toggleMaximize; window.toggleMaximize = toggleMaximize;

View File

@@ -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/Fhbb9Yk5WW) and head to `#-⚠️-community-help`** 🛑 #### 🛑 **Found a problem? [Join the HF2P Discord](https://discord.gg/hf2pdc) 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/Fhbb9Yk5WW Message the founders at https://discord.gg/hf2pdc
</div> </div>

View File

@@ -2,7 +2,7 @@
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/Fhbb9Yk5WW** ### **DOWNLOAD SERVER FILES (JAR/RAR/SCRIPTS) HERE: https://discord.gg/hf2pdc**
**Table of Contents** **Table of Contents**

View File

@@ -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/Fhbb9Yk5WW). 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).
--- ---
@@ -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/Fhbb9Yk5WW](https://discord.gg/Fhbb9Yk5WW) 2. **Join Discord:** [discord.gg/gME8rUy3MB](https://discord.gg/gME8rUy3MB)
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

View File

@@ -4,10 +4,6 @@ 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) {
@@ -18,34 +14,6 @@ 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
@@ -248,10 +216,8 @@ class AppUpdater {
} }
checkForUpdatesAndNotify() { checkForUpdatesAndNotify() {
// Resolve latest release URL then check for updates // Check for updates and notify if available
this._resolveUpdateUrl().catch(err => { autoUpdater.checkForUpdatesAndNotify().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
@@ -279,10 +245,8 @@ class AppUpdater {
} }
checkForUpdates() { checkForUpdates() {
// Manual check - resolve latest release URL first // Manual check for updates (returns promise)
return this._resolveUpdateUrl().catch(err => { return autoUpdater.checkForUpdates().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

View File

@@ -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, getInstalledClientVersion, getUpdatePlan, extractVersionNumber } = require('../services/versionManager'); const { validateChecksum, extractVersionDetails, canUseDifferentialUpdate, needsIntermediatePatches, getInstalledClientVersion } = 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');
@@ -30,7 +30,7 @@ async function acquireGameArchive(downloadUrl, targetPath, checksum, progressCal
} }
console.log(`Downloading game archive from: ${downloadUrl}`); console.log(`Downloading game archive from: ${downloadUrl}`);
try { try {
if (allowRetry) { if (allowRetry) {
await retryDownload(downloadUrl, targetPath, progressCallback); await retryDownload(downloadUrl, targetPath, progressCallback);
@@ -103,13 +103,13 @@ async function deployGameArchive(archivePath, destinationDir, toolsDir, progress
if (error) { if (error) {
const cleanStderr = stderr.replace(/[\u2714\u2716\u2713\u2717\u26A0\uD83D[\uDC00-\uDFFF]]/g, '').trim(); const cleanStderr = stderr.replace(/[\u2714\u2716\u2713\u2717\u26A0\uD83D[\uDC00-\uDFFF]]/g, '').trim();
const cleanStdout = stdout.replace(/[\u2714\u2716\u2713\u2717\u26A0\uD83D[\uDC00-\uDFFF]]/g, '').trim(); const cleanStdout = stdout.replace(/[\u2714\u2716\u2713\u2717\u26A0\uD83D[\uDC00-\uDFFF]]/g, '').trim();
if (cleanStderr) console.error('Deployment stderr:', cleanStderr); if (cleanStderr) console.error('Deployment stderr:', cleanStderr);
if (cleanStdout) console.error('Deployment stdout:', cleanStdout); if (cleanStdout) console.error('Deployment stdout:', cleanStdout);
const errorText = (stderr + ' ' + error.message).toLowerCase(); const errorText = (stderr + ' ' + error.message).toLowerCase();
let message = 'Game deployment failed'; let message = 'Game deployment failed';
if (errorText.includes('unexpected eof')) { if (errorText.includes('unexpected eof')) {
message = 'Corrupted archive detected. Please retry download.'; message = 'Corrupted archive detected. Please retry download.';
if (fs.existsSync(archivePath)) { if (fs.existsSync(archivePath)) {
@@ -156,20 +156,20 @@ 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();
const currentBuild = extractVersionNumber(currentVersion) || 0; console.log(`Current version: ${currentVersion || 'none (clean install)'}`);
const targetBuild = extractVersionNumber(targetVersion); console.log(`Target version: ${targetVersion}`);
console.log(`Current build: ${currentBuild}, Target build: ${targetBuild}, Branch: ${branch}`); console.log(`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 archivePath = path.join(cacheDir, `${branch}_0_to_${targetBuild}.pwr`); const archiveName = path.basename(versionDetails.fullUrl);
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);
} }
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);
saveVersionClient(targetVersion); saveVersionClient(targetVersion);
@@ -177,16 +177,16 @@ async function performIntelligentUpdate(targetVersion, branch = 'release', progr
return; return;
} }
// Clean install (no current version) if (!currentVersion) {
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 archivePath = path.join(cacheDir, `${branch}_0_to_${targetBuild}.pwr`); const archiveName = path.basename(versionDetails.fullUrl);
const archivePath = path.join(cacheDir, `${branch}_${archiveName}`);
if (progressCallback) { if (progressCallback) {
progressCallback(`Downloading full game archive (first install - v${targetBuild})...`, 0, null, null, null); progressCallback(`Downloading full game archive (first install - v${targetVersion})...`, 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);
saveVersionClient(targetVersion); saveVersionClient(targetVersion);
@@ -194,67 +194,59 @@ async function performIntelligentUpdate(targetVersion, branch = 'release', progr
return; return;
} }
// Already at target const patchesToApply = needsIntermediatePatches(currentVersion, targetVersion);
if (currentBuild >= targetBuild) {
console.log('Already at target version or newer'); if (patchesToApply.length === 0) {
console.log('Already at target version or invalid version sequence');
return; return;
} }
// Use mirror's update plan for optimal patch routing console.log(`Applying ${patchesToApply.length} differential patch(es): ${patchesToApply.join(' -> ')}`);
try {
const plan = await getUpdatePlan(currentBuild, targetBuild, branch);
console.log(`Applying ${plan.steps.length} patch(es): ${plan.steps.map(s => `${s.from}\u2192${s.to}`).join(' + ')}`);
for (let i = 0; i < plan.steps.length; i++) {
const step = plan.steps[i];
const stepName = `${step.from}_to_${step.to}`;
const archivePath = path.join(cacheDir, `${branch}_${stepName}.pwr`);
const isDifferential = step.from !== 0;
for (let i = 0; i < patchesToApply.length; i++) {
const patchVersion = patchesToApply[i];
const versionDetails = await extractVersionDetails(patchVersion, branch);
const canDifferential = canUseDifferentialUpdate(getInstalledClientVersion(), versionDetails);
if (!canDifferential || !versionDetails.differentialUrl) {
console.log(`WARNING: Differential patch not available for ${patchVersion}, using full archive`);
const archiveName = path.basename(versionDetails.fullUrl);
const archivePath = path.join(cacheDir, `${branch}_${archiveName}`);
if (progressCallback) { if (progressCallback) {
progressCallback(`Downloading patch ${i + 1}/${plan.steps.length}: ${stepName}...`, 0, null, null, null); progressCallback(`Downloading full archive for ${patchVersion} (${i + 1}/${patchesToApply.length})...`, 0, null, null, null);
} }
await acquireGameArchive(step.url, archivePath, null, progressCallback); await acquireGameArchive(versionDetails.fullUrl, archivePath, null, progressCallback);
await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, false);
} else {
console.log(`Applying differential patch: ${versionDetails.sourceVersion} -> ${patchVersion}`);
const archiveName = path.basename(versionDetails.differentialUrl);
const archivePath = path.join(cacheDir, `${branch}_patch_${archiveName}`);
if (progressCallback) { if (progressCallback) {
progressCallback(`Applying patch ${i + 1}/${plan.steps.length}: ${stepName}...`, 50, null, null, null); progressCallback(`Applying patch ${i + 1}/${patchesToApply.length}: ${patchVersion}...`, 0, null, null, null);
} }
await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, isDifferential); await acquireGameArchive(versionDetails.differentialUrl, archivePath, versionDetails.checksum, progressCallback);
await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, true);
// Clean up patch file
if (fs.existsSync(archivePath)) { if (fs.existsSync(archivePath)) {
try { try {
fs.unlinkSync(archivePath); fs.unlinkSync(archivePath);
console.log(`Cleaned up: ${stepName}.pwr`); console.log(`Cleaned up patch file: ${archiveName}`);
} catch (cleanupErr) { } catch (cleanupErr) {
console.warn(`Failed to cleanup: ${cleanupErr.message}`); console.warn(`Failed to cleanup patch file: ${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.`); saveVersionClient(patchVersion);
} catch (planError) { console.log(`Patch ${patchVersion} applied successfully (${i + 1}/${patchesToApply.length})`);
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 deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, false);
saveVersionClient(targetVersion);
} }
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) {

View File

@@ -61,39 +61,12 @@ async function fetchAuthTokens(uuid, name) {
} }
const data = await response.json(); const data = await response.json();
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 {
identityToken: retryData.IdentityToken || retryData.identityToken,
sessionToken: retryData.SessionToken || retryData.sessionToken
};
}
}
}
} catch (verifyErr) {
console.warn('[Auth] Token verification skipped:', verifyErr.message);
}
console.log('Auth tokens received from server'); console.log('Auth tokens received from server');
return { identityToken, sessionToken };
return {
identityToken: data.IdentityToken || data.identityToken,
sessionToken: data.SessionToken || data.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
@@ -439,7 +412,7 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
// This enables runtime auth patching without modifying the server JAR // This enables runtime auth patching without modifying the server JAR
const agentJar = path.join(gameLatest, 'Server', 'dualauth-agent.jar'); const agentJar = path.join(gameLatest, 'Server', 'dualauth-agent.jar');
if (fs.existsSync(agentJar)) { if (fs.existsSync(agentJar)) {
const agentFlag = `-javaagent:"${agentJar}"`; const agentFlag = `-javaagent:${agentJar}`;
env.JAVA_TOOL_OPTIONS = env.JAVA_TOOL_OPTIONS env.JAVA_TOOL_OPTIONS = env.JAVA_TOOL_OPTIONS
? `${env.JAVA_TOOL_OPTIONS} ${agentFlag}` ? `${env.JAVA_TOOL_OPTIONS} ${agentFlag}`
: agentFlag; : agentFlag;

View File

@@ -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, getUpdatePlan, extractVersionNumber } = require('../services/versionManager'); const { getLatestClientVersion, getInstalledClientVersion } = 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 = 'v8', progressCallback, cacheDir = CACHE_DIR, manualRetry = false, directUrl = null, expectedSize = null) { async function downloadPWR(branch = 'release', fileName = 'v8', progressCallback, cacheDir = CACHE_DIR, manualRetry = false) {
const osName = getOS(); const osName = getOS();
const arch = getArch(); const arch = getArch();
@@ -72,68 +72,43 @@ async function downloadPWR(branch = 'release', fileName = 'v8', progressCallback
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 { getPWRUrlFromNewAPI } = require('../services/versionManager');
let url; let url;
let isUsingNewAPI = false;
if (directUrl) {
url = directUrl; try {
console.log(`[DownloadPWR] Using direct URL: ${url}`); console.log(`[DownloadPWR] Fetching URL from new API for branch: ${branch}, version: ${fileName}`);
} else { url = await getPWRUrlFromNewAPI(branch, fileName);
const { getPWRUrl } = require('../services/versionManager'); isUsingNewAPI = true;
try { console.log(`[DownloadPWR] Using new API URL: ${url}`);
console.log(`[DownloadPWR] Fetching mirror URL for branch: ${branch}, version: ${fileName}`); } catch (error) {
url = await getPWRUrl(branch, fileName); console.error(`[DownloadPWR] Failed to get URL from new API: ${error.message}`);
console.log(`[DownloadPWR] Mirror URL: ${url}`); console.log(`[DownloadPWR] Falling back to old URL format`);
} catch (error) { url = `https://game-patches.hytale.com/patches/${osName}/${arch}/${branch}/0/${fileName}.pwr`;
console.error(`[DownloadPWR] Failed to get mirror URL: ${error.message}`);
const { MIRROR_BASE_URL } = require('../services/versionManager');
url = `${MIRROR_BASE_URL}/${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`); 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) {
// Validate against expected size - reject if file is truncated (< 99% of expected) return false;
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); // Check if file is under 1.5 GB (incomplete download)
} else { const sizeInMB = stats.size / 1024 / 1024;
console.log(`[PWR] Using cached file: ${dest} (${(stats.size / 1024 / 1024).toFixed(2)} MB)`); if (sizeInMB < 1500) {
return dest; console.log(`[PWR Validation] File appears incomplete: ${sizeInMB.toFixed(2)} MB < 1.5 GB`);
} return false;
} else {
console.log(`[PWR] Cached file too small (${stats.size} bytes), re-downloading`);
} }
} }
console.log(`[DownloadPWR] Downloading from: ${url}`); console.log(`Fetching PWR patch file from ${isUsingNewAPI ? 'NEW API' : 'old API'}:`, url);
try { try {
if (manualRetry) { if (manualRetry) {
@@ -159,7 +134,7 @@ async function downloadPWR(branch = 'release', fileName = 'v8', progressCallback
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, expectedSize)) { if (!validatePWRFile(dest)) {
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');
@@ -209,8 +184,8 @@ async function downloadPWR(branch = 'release', fileName = 'v8', progressCallback
// Enhanced PWR file validation // Enhanced PWR file validation
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, expectedSize)) { if (!validatePWRFile(dest)) {
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');
@@ -228,7 +203,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, skipExistingCheck = false) { async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir = TOOLS_DIR, branch = 'release', cacheDir = CACHE_DIR) {
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')}`);
@@ -252,12 +227,11 @@ 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
@@ -438,119 +412,58 @@ 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 tempUpdateDir = path.join(gameDir, '..', 'temp_update');
const currentVersion = loadVersionClient();
const currentBuild = extractVersionNumber(currentVersion) || 0;
const targetBuild = extractVersionNumber(newVersion);
let useIntermediatePatches = false; if (fs.existsSync(tempUpdateDir)) {
let updatePlan = null; fs.rmSync(tempUpdateDir, { recursive: true, force: true });
}
fs.mkdirSync(tempUpdateDir, { recursive: true });
if (currentBuild > 0 && currentBuild < targetBuild) { if (progressCallback) {
try { progressCallback('Downloading new game version...', 20, null, null, null);
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) { const pwrFile = await downloadPWR(branch, newVersion, progressCallback, cacheDir);
// 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) { if (progressCallback) {
const progress = 20 + Math.round((i / updatePlan.steps.length) * 60); progressCallback('Extracting new files...', 60, null, null, null);
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); await applyPWR(pwrFile, progressCallback, tempUpdateDir, toolsDir, branch, cacheDir);
// Delete PWR file from cache after successful update
try {
if (fs.existsSync(pwrFile)) {
fs.unlinkSync(pwrFile);
console.log('[UpdateGameFiles] PWR file deleted from cache after successful update:', pwrFile);
}
} catch (delErr) {
console.warn('[UpdateGameFiles] Failed to delete PWR file from cache:', delErr.message);
}
if (progressCallback) {
progressCallback('Replacing game files...', 80, null, null, null);
}
if (!pwrFile) { if (fs.existsSync(gameDir)) {
throw new Error(`Failed to download patch ${stepName}`); console.log('Removing old game files...');
} let retries = 3;
while (retries > 0) {
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 { try {
if (fs.existsSync(pwrFile)) { fs.rmSync(gameDir, { recursive: true, force: true });
fs.unlinkSync(pwrFile); break;
} } catch (err) {
} catch (delErr) { if ((err.code === 'EPERM' || err.code === 'EBUSY') && retries > 0) {
console.warn('[UpdateGameFiles] Failed to delete PWR from cache:', delErr.message); retries--;
} console.log(`[UpdateGameFiles] Removal failed with ${err.code}, retrying in 1s... (${retries} retries left)`);
await new Promise(resolve => setTimeout(resolve, 1000));
// Save intermediate version so we can resume if interrupted } else {
saveVersionClient(`v${step.to}`); throw err;
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');
if (fs.existsSync(tempUpdateDir)) {
fs.rmSync(tempUpdateDir, { recursive: true, force: true });
}
fs.mkdirSync(tempUpdateDir, { recursive: true });
if (progressCallback) {
progressCallback('Downloading new game version...', 20, null, null, null);
}
const pwrFile = await downloadPWR(branch, newVersion, progressCallback, cacheDir);
if (progressCallback) {
progressCallback('Extracting new files...', 60, null, null, null);
}
await applyPWR(pwrFile, progressCallback, tempUpdateDir, toolsDir, branch, cacheDir);
try {
if (fs.existsSync(pwrFile)) {
fs.unlinkSync(pwrFile);
console.log('[UpdateGameFiles] PWR file deleted from cache after successful update:', pwrFile);
}
} catch (delErr) {
console.warn('[UpdateGameFiles] Failed to delete PWR file from cache:', delErr.message);
}
if (progressCallback) {
progressCallback('Replacing game files...', 80, null, null, null);
}
if (fs.existsSync(gameDir)) {
console.log('Removing old game files...');
let retries = 3;
while (retries > 0) {
try {
fs.rmSync(gameDir, { recursive: true, force: true });
break;
} catch (err) {
if ((err.code === 'EPERM' || err.code === 'EBUSY') && retries > 0) {
retries--;
console.log(`[UpdateGameFiles] Removal failed with ${err.code}, retrying in 1s... (${retries} retries left)`);
await new Promise(resolve => setTimeout(resolve, 1000));
} else {
throw err;
}
} }
} }
} }
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);
@@ -920,30 +833,36 @@ function validateGameDirectory(gameDir, stagingDir) {
} }
// Enhanced PWR file validation // Enhanced PWR file validation
// Accepts intermediate patches (50+ MB) and full installs (1.5+ GB) function validatePWRFile(filePath) {
function validatePWRFile(filePath, expectedSize = null) {
try { try {
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
return false; return false;
} }
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;
} }
// Validate against expected size if known (reject if < 99% of expected) // Check if file is under 1.5 GB (incomplete download)
if (expectedSize && stats.size < expectedSize * 0.99) { if (sizeInMB < 1500) {
const expectedMB = expectedSize / 1024 / 1024; console.log(`[PWR Validation] File appears incomplete: ${sizeInMB.toFixed(2)} MB < 1.5 GB`);
console.log(`[PWR Validation] File truncated: ${sizeInMB.toFixed(2)} MB, expected ${expectedMB.toFixed(2)} MB`);
return false; return false;
} }
console.log(`[PWR Validation] File size: ${sizeInMB.toFixed(2)} MB - OK`); // Basic file header validation (PWR files should have specific headers)
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);

View File

@@ -2,240 +2,186 @@ const axios = require('axios');
const crypto = require('crypto'); const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const { getOS, getArch } = require('../utils/platformUtils'); const { getOS, getArch } = require('../utils/platformUtils');
const { smartRequest } = require('../utils/proxyClient');
// Patches CDN via auth server redirect gateway (allows instant CDN switching) const BASE_PATCH_URL = 'https://game-patches.hytale.com/patches';
const AUTH_DOMAIN = process.env.HYTALE_AUTH_DOMAIN || 'auth.sanasol.ws'; const MANIFEST_API = 'https://files.hytalef2p.com/api/patch_manifest';
const MIRROR_BASE_URL = `https://${AUTH_DOMAIN}/patches`; const NEW_API_URL = 'https://thecute.cloud/ShipOfYarn/api.php';
const MIRROR_MANIFEST_URL = `${MIRROR_BASE_URL}/manifest.json`;
// Fallback: latest known build number if manifest is unreachable let apiCache = null;
const FALLBACK_LATEST_BUILD = 11; let apiCacheTime = 0;
const API_CACHE_DURATION = 60000; // 1 minute
let manifestCache = null; async function fetchNewAPI() {
let manifestCacheTime = 0;
const MANIFEST_CACHE_DURATION = 60000; // 1 minute
/**
* Fetch the mirror manifest from MEGA S4
*/
async function fetchMirrorManifest() {
const now = Date.now(); const now = Date.now();
if (manifestCache && (now - manifestCacheTime) < MANIFEST_CACHE_DURATION) { if (apiCache && (now - apiCacheTime) < API_CACHE_DURATION) {
console.log('[Mirror] Using cached manifest'); console.log('[NewAPI] Using cached API data');
return manifestCache; return apiCache;
} }
try { try {
console.log('[Mirror] Fetching manifest from:', MIRROR_MANIFEST_URL); console.log('[NewAPI] Fetching from:', NEW_API_URL);
const response = await axios.get(MIRROR_MANIFEST_URL, { const response = await axios.get(NEW_API_URL, {
timeout: 15000, timeout: 15000,
headers: { 'User-Agent': 'Hytale-F2P-Launcher' } headers: {
'User-Agent': 'Hytale-F2P-Launcher'
}
}); });
if (response.data && response.data.files) { if (response.data && response.data.hytale) {
manifestCache = response.data; apiCache = response.data;
manifestCacheTime = now; apiCacheTime = now;
console.log('[Mirror] Manifest fetched successfully'); console.log('[NewAPI] API data fetched and cached successfully');
return response.data; return response.data;
} else {
throw new Error('Invalid API response structure');
} }
throw new Error('Invalid manifest structure');
} catch (error) { } catch (error) {
console.error('[Mirror] Error fetching manifest:', error.message); console.error('[NewAPI] Error fetching API:', error.message);
if (manifestCache) { if (apiCache) {
console.log('[Mirror] Using expired cache'); console.log('[NewAPI] Using expired cache due to error');
return manifestCache; return apiCache;
} }
throw error; throw error;
} }
} }
/** async function getLatestVersionFromNewAPI(branch = 'release') {
* Parse manifest to get available patches for current platform try {
* Returns array of { from, to, key, size } const apiData = await fetchNewAPI();
*/ const osName = getOS();
function getPlatformPatches(manifest, branch = 'release') { const arch = getArch();
const os = getOS();
const arch = getArch(); let osKey = osName;
const prefix = `${os}/${arch}/${branch}/`; if (osName === 'darwin') {
const patches = []; osKey = 'mac';
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
});
}
} }
const branchData = apiData.hytale[branch];
if (!branchData || !branchData[osKey]) {
throw new Error(`No data found for branch: ${branch}, OS: ${osKey}`);
}
const osData = branchData[osKey];
const versions = Object.keys(osData).filter(key => key.endsWith('.pwr'));
if (versions.length === 0) {
throw new Error(`No .pwr files found for ${osKey}`);
}
const versionNumbers = versions.map(v => {
const match = v.match(/v(\d+)/);
return match ? parseInt(match[1]) : 0;
});
const latestVersionNumber = Math.max(...versionNumbers);
console.log(`[NewAPI] Latest version number: ${latestVersionNumber} for branch ${branch}`);
return `v${latestVersionNumber}`;
} catch (error) {
console.error('[NewAPI] Error getting latest version:', error.message);
throw error;
} }
return patches;
} }
/** async function getPWRUrlFromNewAPI(branch = 'release', version = 'v8') {
* Find optimal patch path using BFS with download size minimization try {
* Returns array of { from, to, url, size, key } steps, or null if no path found const apiData = await fetchNewAPI();
*/ const osName = getOS();
function findOptimalPatchPath(currentBuild, targetBuild, patches) { const arch = getArch();
if (currentBuild >= targetBuild) return [];
let osKey = osName;
const edges = {}; if (osName === 'darwin') {
for (const patch of patches) { osKey = 'mac';
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; let fileName;
if (osName === 'windows') {
const nextEdges = edges[build] || []; fileName = `${version}-windows-amd64.pwr`;
for (const edge of nextEdges) { } else if (osName === 'linux') {
if (edge.to <= build || edge.to > targetBuild) continue; fileName = `${version}-linux-amd64.pwr`;
if (path.some(p => p.to === edge.to)) continue; } else if (osName === 'darwin') {
fileName = `${version}-darwin-arm64.pwr`;
queue.push({
build: edge.to,
path: [...path, {
from: edge.from,
to: edge.to,
url: `${MIRROR_BASE_URL}/${edge.key}`,
size: edge.size,
key: edge.key
}],
totalSize: totalSize + edge.size
});
} }
const branchData = apiData.hytale[branch];
if (!branchData || !branchData[osKey]) {
throw new Error(`No data found for branch: ${branch}, OS: ${osKey}`);
}
const osData = branchData[osKey];
const url = osData[fileName];
if (!url) {
throw new Error(`No URL found for ${fileName}`);
}
console.log(`[NewAPI] URL for ${fileName}: ${url}`);
return url;
} catch (error) {
console.error('[NewAPI] Error getting PWR URL:', error.message);
throw error;
} }
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 = 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 step = {
from: 0,
to: targetBuild,
url: `${MIRROR_BASE_URL}/${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(`[Mirror] Fetching latest client version (branch: ${branch})...`); console.log(`[NewAPI] Fetching latest client version from new API (branch: ${branch})...`);
const manifest = await fetchMirrorManifest();
const patches = getPlatformPatches(manifest, branch); // Utiliser la nouvelle API
const latestVersion = await getLatestVersionFromNewAPI(branch);
if (patches.length === 0) { console.log(`[NewAPI] Latest client version for ${branch}: ${latestVersion}`);
console.log(`[Mirror] No patches for branch '${branch}', using fallback`); return latestVersion;
return `v${FALLBACK_LATEST_BUILD}`;
}
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('[Mirror] Error:', error.message); console.error('[NewAPI] Error fetching client version from new API:', error.message);
return `v${FALLBACK_LATEST_BUILD}`; console.log('[NewAPI] Falling back to old API...');
// Fallback vers l'ancienne API si la nouvelle échoue
try {
const response = await smartRequest(`https://files.hytalef2p.com/api/version_client?branch=${branch}`, {
timeout: 40000,
headers: {
'User-Agent': 'Hytale-F2P-Launcher'
}
});
if (response.data && response.data.client_version) {
const version = response.data.client_version;
console.log(`Latest client version for ${branch} (old API): ${version}`);
return version;
} else {
console.log('Warning: Invalid API response, falling back to latest known version (v8)');
return 'v8';
}
} catch (fallbackError) {
console.error('Error fetching client version from old API:', fallbackError.message);
console.log('Warning: Both APIs unavailable, falling back to latest known version (v8)');
return 'v8';
}
} }
} }
/** // Fonction utilitaire pour extraire le numéro de version
* Get PWR download URL for fresh install (0 -> target) // Supporte les formats: "7.pwr", "v8", "v8-windows-amd64.pwr", etc.
* 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 arch = getArch();
try {
const manifest = await fetchMirrorManifest();
const patches = getPlatformPatches(manifest, branch);
const fullPatch = patches.find(p => p.from === 0 && p.to === targetBuild);
if (fullPatch) {
const url = `${MIRROR_BASE_URL}/${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)
return `${MIRROR_BASE_URL}/${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) { function extractVersionNumber(version) {
if (!version) return 0; if (!version) return 0;
// New format: "v8" or "v8-xxx.pwr" // Nouveau format: "v8" ou "v8-xxx.pwr"
const vMatch = version.match(/v(\d+)/); const vMatch = version.match(/v(\d+)/);
if (vMatch) return parseInt(vMatch[1]); if (vMatch) {
return parseInt(vMatch[1]);
// Old format: "7.pwr" }
// Ancien format: "7.pwr"
const pwrMatch = version.match(/(\d+)\.pwr/); const pwrMatch = version.match(/(\d+)\.pwr/);
if (pwrMatch) return parseInt(pwrMatch[1]); if (pwrMatch) {
return parseInt(pwrMatch[1]);
// Fallback }
// Fallback: essayer de parser directement
const num = parseInt(version); const num = parseInt(version);
return isNaN(num) ? 0 : num; return isNaN(num) ? 0 : num;
} }
@@ -243,7 +189,7 @@ function extractVersionNumber(version) {
function buildArchiveUrl(buildNumber, branch = 'release') { function buildArchiveUrl(buildNumber, branch = 'release') {
const os = getOS(); const os = getOS();
const arch = getArch(); const arch = getArch();
return `${MIRROR_BASE_URL}/${os}/${arch}/${branch}/0_to_${buildNumber}.pwr`; return `${BASE_PATCH_URL}/${os}/${arch}/${branch}/0/${buildNumber}.pwr`;
} }
async function checkArchiveExists(buildNumber, branch = 'release') { async function checkArchiveExists(buildNumber, branch = 'release') {
@@ -251,56 +197,91 @@ async function checkArchiveExists(buildNumber, branch = 'release') {
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 { } catch (error) {
return false; return false;
} }
} }
async function discoverAvailableVersions(latestKnown, branch = 'release') { async function discoverAvailableVersions(latestKnown, branch = 'release', maxProbe = 50) {
const available = [];
const latest = extractVersionNumber(latestKnown);
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 manifest = await fetchMirrorManifest(); const os = getOS();
const patches = getPlatformPatches(manifest, branch); const arch = getArch();
const versions = [...new Set(patches.map(p => p.to))].sort((a, b) => b - a); const response = await smartRequest(`${MANIFEST_API}?branch=${branch}&os=${os}&arch=${arch}`, {
return versions.map(v => `${v}.pwr`); timeout: 10000
} catch { });
return []; return response.data.patches || {};
} 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 = extractVersionNumber(targetVersion); const buildNumber = extractVersionNumber(targetVersion);
const fullUrl = buildArchiveUrl(buildNumber, branch); const previousBuild = buildNumber - 1;
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, fullUrl: patchInfo?.original_url || buildArchiveUrl(buildNumber, branch),
differentialUrl: null, differentialUrl: patchInfo?.patch_url || null,
checksum: null, checksum: patchInfo?.patch_hash || null,
sourceVersion: null, sourceVersion: patchInfo?.from ? `${patchInfo.from}.pwr` : (previousBuild > 0 ? `${previousBuild}.pwr` : null),
isDifferential: false, isDifferential: !!patchInfo?.proper_patch,
releaseNotes: null releaseNotes: patchInfo?.patch_note || null
}; };
} }
function canUseDifferentialUpdate() { function canUseDifferentialUpdate(currentVersion, targetDetails) {
// Differential updates are now handled via getUpdatePlan() if (!targetDetails) return false;
return false; if (!targetDetails.differentialUrl) return false;
if (!targetDetails.isDifferential) return false;
if (!currentVersion) return false;
const currentBuild = extractVersionNumber(currentVersion);
const expectedSource = extractVersionNumber(targetDetails.sourceVersion);
return currentBuild === expectedSource;
} }
function needsIntermediatePatches(currentVersion, targetVersion) { function needsIntermediatePatches(currentVersion, targetVersion) {
if (!currentVersion) return []; if (!currentVersion) return [];
const current = extractVersionNumber(currentVersion); const current = extractVersionNumber(currentVersion);
const target = extractVersionNumber(targetVersion); const target = extractVersionNumber(targetVersion);
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);
@@ -309,6 +290,7 @@ 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;
} }
@@ -317,7 +299,7 @@ function getInstalledClientVersion() {
try { try {
const { loadVersionClient } = require('../core/config'); const { loadVersionClient } = require('../core/config');
return loadVersionClient(); return loadVersionClient();
} catch { } catch (err) {
return null; return null;
} }
} }
@@ -333,12 +315,8 @@ module.exports = {
computeFileChecksum, computeFileChecksum,
validateChecksum, validateChecksum,
getInstalledClientVersion, getInstalledClientVersion,
fetchMirrorManifest, fetchNewAPI,
getPWRUrl, getLatestVersionFromNewAPI,
getPWRUrlFromNewAPI, getPWRUrlFromNewAPI,
getUpdatePlan, extractVersionNumber
extractVersionNumber,
getPlatformPatches,
findOptimalPatchPath,
MIRROR_BASE_URL
}; };

View File

@@ -1,2 +1,3 @@
provider: generic provider: github
url: https://git.sanhost.net/sanasol/hytale-f2p/releases/download/latest owner: amiayweb # Change to your own GitHub username
repo: Hytale-F2P

View File

@@ -89,7 +89,7 @@ function setDiscordActivity() {
}, },
{ {
label: 'Discord', label: 'Discord',
url: 'https://discord.gg/Fhbb9Yk5WW' url: 'https://discord.gg/hf2pdc'
} }
] ]
}); });

View File

@@ -1,6 +1,6 @@
{ {
"name": "hytale-f2p-launcher", "name": "hytale-f2p-launcher",
"version": "2.3.3", "version": "2.2.2",
"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://github.com/amiayweb/Hytale-F2P",
"main": "main.js", "main": "main.js",
@@ -118,8 +118,9 @@
"createStartMenuShortcut": true "createStartMenuShortcut": true
}, },
"publish": { "publish": {
"provider": "generic", "provider": "github",
"url": "https://git.sanhost.net/sanasol/hytale-f2p/releases/download/latest" "owner": "amiayweb",
"repo": "Hytale-F2P"
} }
} }
} }