Compare commits

..

23 Commits

Author SHA1 Message Date
sanasol
e7a033932f v2.3.2: fix truncated download cache, update Discord link
Fix pre-release downloads failing with "unexpected EOF" by validating
cached PWR file sizes against manifest expected sizes. Previously only
checked > 1MB which accepted truncated files. Also update Discord
invite link to new server across all files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:14:20 +01:00
sanasol
11c6d40dfe chore: remove private docs from repo
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 14:38:34 +01:00
sanasol
0dafb17c7b v2.3.1: CDN redirect gateway, fix token username bug
- Migrate patch downloads to auth server redirect gateway (302 -> CDN)
  Allows instant CDN switching via admin panel without launcher update
- Fix identity token "Player" username mismatch on fresh install
  Add token username verification with retry in fetchAuthTokens
- Refactor versionManager to use mirror manifest via auth.sanasol.ws/patches
- Add optimal patch routing (BFS) for differential updates
- Add PATCH_CDN_INFRASTRUCTURE.md documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 14:36:09 +01:00
sanasol
66112f15b2 fix: use placeholder publish URL (runtime resolver overrides it)
The actual update URL is resolved dynamically via Forgejo API
in _resolveUpdateUrl() before each update check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:08:30 +01:00
sanasol
0a71fdac8c v2.3.0: migrate auto-update to Forgejo, add Arch build
- Switch auto-update from GitHub to Forgejo (generic provider)
- Dynamically resolve latest release URL via Forgejo API
- Add pacman target to Linux builds
- Hide direct upload URL in repository secret

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 11:55:35 +01:00
sanasol
4b9eae215b ci: add Arch Linux pacman build and hide upload URL
- Add pacman target to electron-builder Linux build
- Upload .pacman packages to release
- FORGEJO_UPLOAD now uses repository secret

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 11:45:07 +01:00
sanasol
1510eceb0f Hide direct upload IP in repository secret
Move FORGEJO_UPLOAD URL from hardcoded value to ${{ secrets.FORGEJO_UPLOAD_URL }}
to avoid exposing server IP when repo goes public.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 11:35:53 +01:00
sanasol
c4b5368538 fix: use direct Forgejo port 3001 for uploads (bypass Traefik+Cloudflare)
- FORGEJO_UPLOAD now uses http://208.69.78.130:3001 (plain HTTP direct to Forgejo)
- Removed -sk flags and Host headers (not needed for plain HTTP)
- Added --max-time 600 for large file uploads
- Cloudflare 100MB limit and Traefik HTTP/2 stream errors both bypassed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 02:24:17 +01:00
sanasol
e0ebf137fc fix: add Host header for direct IP uploads to route to Forgejo
Without Host header, Traefik routes direct IP requests to the
default backend (hytale-auth) instead of Forgejo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 02:03:15 +01:00
sanasol
5241a502e5 fix: disable npmRebuild for Windows cross-compilation
ELECTRON_BUILDER_SKIP_NATIVE_REBUILD env var not recognized by
electron-builder 26.6.0. Use --config.npmRebuild=false CLI flag
to skip register-scheme native module rebuild.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 01:35:59 +01:00
sanasol
3e7c7ccff3 fix: use domain for API calls, direct IP only for file uploads
Cloudflare runners can't reach direct IP. Split into two env vars:
- FORGEJO_API (domain) for release creation and ID lookups
- FORGEJO_UPLOAD (direct IP) for large file uploads >100MB

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 01:33:21 +01:00
sanasol
89d09f032f fix: bypass Cloudflare 100MB upload limit for release assets
Use direct server IP for Forgejo API calls to avoid Cloudflare
proxy rejecting large file uploads (413 Payload Too Large).
macOS DMG/ZIP artifacts are ~350MB each.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 01:16:50 +01:00
sanasol
8bdb78d1e2 ci: fix Windows cross-compilation and add cloudsol runner
- Skip native module rebuild for Windows (register-scheme can't cross-compile)
- ELECTRON_BUILDER_SKIP_NATIVE_REBUILD=true for Windows builds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 01:12:33 +01:00
sanasol
e9e66dbca7 Merge branch 'refactor/bytebuddy-agent' into develop
# Conflicts:
#	.github/workflows/release.yml
2026-02-20 01:01:29 +01:00
AMIAY
82f1dd2739 Merge branch 'main' into develop 2026-02-11 10:48:06 +01:00
AMIAY
4502c11bd0 Use new version API and default to v8 PWR 2026-02-11 10:43:18 +01:00
AMIAY
bcc7476322 Bump package version to 2.2.2
Update package.json version from 2.2.1 to 2.2.2 to mark a patch release.
2026-02-11 09:29:12 +01:00
AMIAY
ae6a7db80a Update Discord link in README.md 2026-02-09 11:41:22 +01:00
AMIAY
48395fbff3 Merge pull request #278 from amiayweb/amiayweb-patch-1
Update Discord link for community help
2026-02-09 11:30:28 +01:00
AMIAY
aae90a72e8 Update Discord link for community help 2026-02-09 11:30:09 +01:00
AMIAY
a6c61aef68 Merge pull request #259 from amiayweb/fix/release-2-2-1-env 2026-02-02 08:05:00 +01:00
Fazri Gading
31653a37a7 fix: release v2.2.1 add virtual .env file creation in release workflow
Added steps to create a virtual .env file for different platforms during the release process.
2026-02-02 14:44:33 +08:00
Fazri Gading
1cb08f029a Release Stable Build v2.2.1 (#258)
* 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

---------

Co-authored-by: TalesAmaral <57869141+TalesAmaral@users.noreply.github.com>
Co-authored-by: walti0 <95646872+walti0@users.noreply.github.com>
Co-authored-by: AMIAY <letudiantenrap.collab@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-02 05:58:56 +01:00
18 changed files with 625 additions and 271 deletions

View File

@@ -36,7 +36,7 @@ This Code of Conduct applies within all community spaces, and also applies when
## 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.

View File

@@ -22,7 +22,7 @@ body:
value: |
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.
**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
id: question

View File

@@ -6,6 +6,12 @@ on:
- 'v*'
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:
create-release:
runs-on: ubuntu-latest
@@ -13,7 +19,7 @@ jobs:
- uses: actions/checkout@v4
- name: Create Draft Release
run: |
curl -s -X POST "https://git.sanhost.net/api/v1/repos/${GITHUB_REPOSITORY}/releases" \
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}" \
@@ -40,16 +46,16 @@ jobs:
- run: npm ci
- name: Build Windows Packages
run: npx electron-builder --win --publish never
run: npx electron-builder --win --publish never --config.npmRebuild=false
- name: Upload to Release
run: |
RELEASE_ID=$(curl -s "https://git.sanhost.net/api/v1/repos/${GITHUB_REPOSITORY}/releases/tags/${{ github.ref_name }}" \
RELEASE_ID=$(curl -s "${FORGEJO_API}/repos/${GITHUB_REPOSITORY}/releases/tags/${{ github.ref_name }}" \
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])')
for file in dist/*.exe dist/*.exe.blockmap dist/latest.yml; do
[ -f "$file" ] || continue
echo "Uploading $file..."
curl -s -X POST "https://git.sanhost.net/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=$(basename $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
@@ -75,19 +81,19 @@ jobs:
- name: Upload to Release
run: |
RELEASE_ID=$(curl -s "https://git.sanhost.net/api/v1/repos/${GITHUB_REPOSITORY}/releases/tags/${{ github.ref_name }}" \
RELEASE_ID=$(curl -s "${FORGEJO_API}/repos/${GITHUB_REPOSITORY}/releases/tags/${{ github.ref_name }}" \
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])')
for file in dist/*.dmg dist/*.zip dist/*.blockmap dist/latest-mac.yml; do
[ -f "$file" ] || continue
echo "Uploading $file..."
curl -s -X POST "https://git.sanhost.net/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=$(basename $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:
needs: [create-release]
runs-on: ubuntu-latest
needs: [create-release]
steps:
- uses: actions/checkout@v4
- name: Install build dependencies
@@ -101,16 +107,16 @@ jobs:
- run: npm ci
- name: Build Linux Packages
run: npx electron-builder --linux AppImage deb rpm --publish never
run: npx electron-builder --linux AppImage deb rpm pacman --publish never
- name: Upload to Release
run: |
RELEASE_ID=$(curl -s "https://git.sanhost.net/api/v1/repos/${GITHUB_REPOSITORY}/releases/tags/${{ github.ref_name }}" \
RELEASE_ID=$(curl -s "${FORGEJO_API}/repos/${GITHUB_REPOSITORY}/releases/tags/${{ github.ref_name }}" \
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])')
for file in dist/*.AppImage dist/*.AppImage.blockmap dist/*.deb dist/*.rpm dist/latest-linux.yml; do
for file in dist/*.AppImage dist/*.AppImage.blockmap dist/*.deb dist/*.rpm dist/*.pacman dist/latest-linux.yml; do
[ -f "$file" ] || continue
echo "Uploading $file..."
curl -s -X POST "https://git.sanhost.net/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=$(basename $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

3
.gitignore vendored
View File

@@ -17,6 +17,9 @@ dist/
# Project Specific: Downloaded patcher (from hytale-auth-server)
backend/patcher/
# Private docs (local only)
docs/PATCH_CDN_INFRASTRUCTURE.md
# macOS Specific
.DS_Store
*.zst.DS_Store

View File

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

View File

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

View File

@@ -18,7 +18,7 @@
### ⚠️ **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>
👍 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">
**Questions? Ads? Collaboration? Endorsement? Other business-related?**
Message the founders at https://discord.gg/hf2pdc
Message the founders at https://discord.gg/Fhbb9Yk5WW
</div>

View File

@@ -2,7 +2,7 @@
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**

View File

@@ -1,6 +1,6 @@
# 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:
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:
- Your operating system and version
- Launcher version

View File

@@ -4,6 +4,10 @@ const logger = require('./logger');
const fs = require('fs');
const path = require('path');
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 {
constructor(mainWindow) {
@@ -14,6 +18,34 @@ class AppUpdater {
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() {
// Configure logger for electron-updater
@@ -216,8 +248,10 @@ class AppUpdater {
}
checkForUpdatesAndNotify() {
// Check for updates and notify if available
autoUpdater.checkForUpdatesAndNotify().catch(err => {
// Resolve latest release URL then check for updates
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);
// Network errors are not critical - just log and continue
@@ -245,8 +279,10 @@ class AppUpdater {
}
checkForUpdates() {
// Manual check for updates (returns promise)
return autoUpdater.checkForUpdates().catch(err => {
// Manual check - resolve latest release URL first
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);
// Network errors are not critical - just return no update available

View File

@@ -1,5 +1,5 @@
const FORCE_CLEAN_INSTALL_VERSION = false;
const CLEAN_INSTALL_TEST_VERSION = '4.pwr';
const CLEAN_INSTALL_TEST_VERSION = 'v4';
module.exports = {
FORCE_CLEAN_INSTALL_VERSION,

View File

@@ -3,7 +3,7 @@ const path = require('path');
const { execFile } = require('child_process');
const { downloadFile, retryDownload } = require('../utils/fileManager');
const { getOS, getArch } = require('../utils/platformUtils');
const { validateChecksum, extractVersionDetails, canUseDifferentialUpdate, needsIntermediatePatches, getInstalledClientVersion } = require('../services/versionManager');
const { validateChecksum, extractVersionDetails, getInstalledClientVersion, getUpdatePlan, extractVersionNumber } = require('../services/versionManager');
const { installButler } = require('./butlerManager');
const { GAME_DIR, CACHE_DIR, TOOLS_DIR } = require('../core/paths');
const { saveVersionClient } = require('../core/config');
@@ -156,15 +156,15 @@ async function performIntelligentUpdate(targetVersion, branch = 'release', progr
console.log(`Initiating intelligent update to version ${targetVersion}`);
const currentVersion = getInstalledClientVersion();
console.log(`Current version: ${currentVersion || 'none (clean install)'}`);
console.log(`Target version: ${targetVersion}`);
console.log(`Branch: ${branch}`);
const currentBuild = extractVersionNumber(currentVersion) || 0;
const targetBuild = extractVersionNumber(targetVersion);
console.log(`Current build: ${currentBuild}, Target build: ${targetBuild}, Branch: ${branch}`);
// For non-release branches, always do full install
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 archiveName = path.basename(versionDetails.fullUrl);
const archivePath = path.join(cacheDir, `${branch}_${archiveName}`);
const archivePath = path.join(cacheDir, `${branch}_0_to_${targetBuild}.pwr`);
if (progressCallback) {
progressCallback('Downloading full game archive (pre-release)...', 0, null, null, null);
@@ -177,14 +177,14 @@ async function performIntelligentUpdate(targetVersion, branch = 'release', progr
return;
}
if (!currentVersion) {
// Clean install (no current version)
if (currentBuild === 0) {
console.log('No existing installation detected - downloading full archive');
const versionDetails = await extractVersionDetails(targetVersion, branch);
const archiveName = path.basename(versionDetails.fullUrl);
const archivePath = path.join(cacheDir, `${branch}_${archiveName}`);
const archivePath = path.join(cacheDir, `${branch}_0_to_${targetBuild}.pwr`);
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);
@@ -194,59 +194,67 @@ async function performIntelligentUpdate(targetVersion, branch = 'release', progr
return;
}
const patchesToApply = needsIntermediatePatches(currentVersion, targetVersion);
if (patchesToApply.length === 0) {
console.log('Already at target version or invalid version sequence');
// Already at target
if (currentBuild >= targetBuild) {
console.log('Already at target version or newer');
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++) {
const patchVersion = patchesToApply[i];
const versionDetails = await extractVersionDetails(patchVersion, branch);
console.log(`Applying ${plan.steps.length} patch(es): ${plan.steps.map(s => `${s.from}\u2192${s.to}`).join(' + ')}`);
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}`);
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;
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(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}`);
await acquireGameArchive(step.url, archivePath, null, progressCallback);
if (progressCallback) {
progressCallback(`Applying patch ${i + 1}/${patchesToApply.length}: ${patchVersion}...`, 0, null, null, null);
progressCallback(`Applying patch ${i + 1}/${plan.steps.length}: ${stepName}...`, 50, null, null, null);
}
await acquireGameArchive(versionDetails.differentialUrl, archivePath, versionDetails.checksum, progressCallback);
await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, true);
await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, isDifferential);
// Clean up patch file
if (fs.existsSync(archivePath)) {
try {
fs.unlinkSync(archivePath);
console.log(`Cleaned up patch file: ${archiveName}`);
console.log(`Cleaned up: ${stepName}.pwr`);
} catch (cleanupErr) {
console.warn(`Failed to cleanup patch file: ${cleanupErr.message}`);
console.warn(`Failed to cleanup: ${cleanupErr.message}`);
}
}
saveVersionClient(`v${step.to}`);
console.log(`Patch ${stepName} applied (${i + 1}/${plan.steps.length})`);
}
saveVersionClient(patchVersion);
console.log(`Patch ${patchVersion} applied successfully (${i + 1}/${patchesToApply.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');
console.log(`Update completed successfully. Version ${targetVersion} is now installed.`);
// 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);
}
}
async function ensureGameInstalled(targetVersion, branch = 'release', progressCallback, gameDir = GAME_DIR, cacheDir = CACHE_DIR, toolsDir = TOOLS_DIR) {

View File

@@ -61,12 +61,39 @@ async function fetchAuthTokens(uuid, name) {
}
const data = await response.json();
console.log('Auth tokens received from server');
const identityToken = data.IdentityToken || data.identityToken;
const sessionToken = data.SessionToken || data.sessionToken;
return {
identityToken: data.IdentityToken || data.identityToken,
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');
return { identityToken, sessionToken };
} catch (error) {
console.error('Failed to fetch auth tokens:', error.message);
// Fallback to local generation if server unavailable

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 { getOS, getArch } = require('../utils/platformUtils');
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 { installButler } = require('./butlerManager');
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 arch = getArch();
@@ -72,28 +72,68 @@ 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.');
}
const url = `https://game-patches.hytale.com/patches/${osName}/${arch}/${branch}/0/${fileName}`;
const dest = path.join(cacheDir, `${branch}_${fileName}`);
let url;
// Check if file exists and validate it
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);
if (stats.size < 1024 * 1024) {
return false;
}
// Check if file is under 1.5 GB (incomplete download)
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;
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 { MIRROR_BASE_URL } = require('../services/versionManager');
url = `${MIRROR_BASE_URL}/${osName}/${arch}/${branch}/0_to_${extractVersionNumber(fileName)}.pwr`;
console.log(`[DownloadPWR] Fallback URL: ${url}`);
}
}
console.log('Fetching PWR patch file:', 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
if (fs.existsSync(dest) && !manualRetry) {
const stats = fs.statSync(dest);
if (stats.size > 1024 * 1024) {
// 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 {
console.log(`[PWR] Cached file too small (${stats.size} bytes), re-downloading`);
}
}
console.log(`[DownloadPWR] Downloading from: ${url}`);
try {
if (manualRetry) {
@@ -119,7 +159,7 @@ async function downloadPWR(branch = 'release', fileName = '7.pwr', progressCallb
const retryStats = fs.statSync(dest);
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}`);
fs.unlinkSync(dest);
throw new Error('Downloaded PWR file is corrupted or invalid after automatic retry. Please retry manually');
@@ -170,7 +210,7 @@ async function downloadPWR(branch = 'release', fileName = '7.pwr', progressCallb
const stats = fs.statSync(dest);
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}`);
fs.unlinkSync(dest);
throw new Error('Downloaded PWR file is corrupted or invalid. Please retry');
@@ -188,7 +228,7 @@ async function retryPWRDownload(branch, fileName, progressCallback, cacheDir = C
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] - PWR file: ${pwrFile}`);
console.log(`[Butler] - Staging dir: ${path.join(gameDir, 'staging-temp')}`);
@@ -212,11 +252,12 @@ async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir
const gameLatest = gameDir;
const stagingDir = path.join(gameLatest, 'staging-temp');
const clientPath = findClientPath(gameLatest);
if (clientPath) {
console.log('Game files detected, skipping patch installation.');
return;
if (!skipExistingCheck) {
const clientPath = findClientPath(gameLatest);
if (clientPath) {
console.log('Game files detected, skipping patch installation.');
return;
}
}
// Validate and prepare directories
@@ -397,57 +438,118 @@ async function updateGameFiles(newVersion, progressCallback, gameDir = GAME_DIR,
}
console.log(`Updating game files to version: ${newVersion} (branch: ${branch})`);
tempUpdateDir = path.join(gameDir, '..', 'temp_update');
// Determine update strategy: intermediate patches vs full reinstall
const currentVersion = loadVersionClient();
const currentBuild = extractVersionNumber(currentVersion) || 0;
const targetBuild = extractVersionNumber(newVersion);
if (fs.existsSync(tempUpdateDir)) {
fs.rmSync(tempUpdateDir, { recursive: true, force: true });
}
fs.mkdirSync(tempUpdateDir, { recursive: true });
let useIntermediatePatches = false;
let updatePlan = null;
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);
// 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);
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);
}
} 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) {
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 {
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;
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');
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);
console.log('HomePage.ui update result after update:', homeUIResult);
@@ -818,7 +920,8 @@ function validateGameDirectory(gameDir, stagingDir) {
}
// Enhanced PWR file validation
function validatePWRFile(filePath) {
// Accepts intermediate patches (50+ MB) and full installs (1.5+ GB)
function validatePWRFile(filePath, expectedSize = null) {
try {
if (!fs.existsSync(filePath)) {
return false;
@@ -827,27 +930,20 @@ function validatePWRFile(filePath) {
const stats = fs.statSync(filePath);
const sizeInMB = stats.size / 1024 / 1024;
// PWR files should be at least 1 MB
if (stats.size < 1024 * 1024) {
console.log(`[PWR Validation] File too small: ${sizeInMB.toFixed(2)} MB`);
return false;
}
// Check if file is under 1.5 GB (incomplete download)
if (sizeInMB < 1500) {
console.log(`[PWR Validation] File appears incomplete: ${sizeInMB.toFixed(2)} MB < 1.5 GB`);
// Validate against expected size if known (reject if < 99% of expected)
if (expectedSize && stats.size < expectedSize * 0.99) {
const expectedMB = expectedSize / 1024 / 1024;
console.log(`[PWR Validation] File truncated: ${sizeInMB.toFixed(2)} MB, expected ${expectedMB.toFixed(2)} MB`);
return false;
}
// 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}`);
console.log(`[PWR Validation] File size: ${sizeInMB.toFixed(2)} MB - OK`);
return true;
} catch (error) {
console.error(`[PWR Validation] Error:`, error.message);

View File

@@ -2,40 +2,248 @@ const axios = require('axios');
const crypto = require('crypto');
const fs = require('fs');
const { getOS, getArch } = require('../utils/platformUtils');
const { smartRequest } = require('../utils/proxyClient');
const BASE_PATCH_URL = 'https://game-patches.hytale.com/patches';
const MANIFEST_API = 'https://files.hytalef2p.com/api/patch_manifest';
// Patches CDN via auth server redirect gateway (allows instant CDN switching)
const AUTH_DOMAIN = process.env.HYTALE_AUTH_DOMAIN || 'auth.sanasol.ws';
const MIRROR_BASE_URL = `https://${AUTH_DOMAIN}/patches`;
const MIRROR_MANIFEST_URL = `${MIRROR_BASE_URL}/manifest.json`;
// Fallback: latest known build number if manifest is unreachable
const FALLBACK_LATEST_BUILD = 11;
let manifestCache = null;
let manifestCacheTime = 0;
const MANIFEST_CACHE_DURATION = 60000; // 1 minute
/**
* Fetch the mirror manifest from MEGA S4
*/
async function fetchMirrorManifest() {
const now = Date.now();
if (manifestCache && (now - manifestCacheTime) < MANIFEST_CACHE_DURATION) {
console.log('[Mirror] Using cached manifest');
return manifestCache;
}
try {
console.log('[Mirror] Fetching manifest from:', MIRROR_MANIFEST_URL);
const response = await axios.get(MIRROR_MANIFEST_URL, {
timeout: 15000,
headers: { 'User-Agent': 'Hytale-F2P-Launcher' }
});
if (response.data && response.data.files) {
manifestCache = response.data;
manifestCacheTime = now;
console.log('[Mirror] Manifest fetched successfully');
return response.data;
}
throw new Error('Invalid manifest structure');
} catch (error) {
console.error('[Mirror] Error fetching manifest:', error.message);
if (manifestCache) {
console.log('[Mirror] Using expired cache');
return manifestCache;
}
throw error;
}
}
/**
* 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
*/
function findOptimalPatchPath(currentBuild, targetBuild, patches) {
if (currentBuild >= targetBuild) return [];
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: `${MIRROR_BASE_URL}/${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 = 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') {
try {
console.log(`Fetching latest client version from API (branch: ${branch})...`);
const response = await smartRequest(`https://files.hytalef2p.com/api/version_client?branch=${branch}`, {
timeout: 40000,
headers: {
'User-Agent': 'Hytale-F2P-Launcher'
}
});
console.log(`[Mirror] Fetching latest client version (branch: ${branch})...`);
const manifest = await fetchMirrorManifest();
const patches = getPlatformPatches(manifest, branch);
if (response.data && response.data.client_version) {
const version = response.data.client_version;
console.log(`Latest client version for ${branch}: ${version}`);
return version;
if (patches.length === 0) {
console.log(`[Mirror] No patches for branch '${branch}', using fallback`);
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) {
console.error('[Mirror] Error:', error.message);
return `v${FALLBACK_LATEST_BUILD}`;
}
}
/**
* 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 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('Warning: Invalid API response, falling back to latest known version (7.pwr)');
return '7.pwr';
console.log(`[Mirror] Branch '${branch}' not in mirror, constructing URL`);
}
} catch (error) {
console.error('Error fetching client version:', error.message);
console.log('Warning: API unavailable, falling back to latest known version (7.pwr)');
return '7.pwr';
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) {
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;
}
function buildArchiveUrl(buildNumber, branch = 'release') {
const os = getOS();
const arch = getArch();
return `${BASE_PATCH_URL}/${os}/${arch}/${branch}/0/${buildNumber}.pwr`;
return `${MIRROR_BASE_URL}/${os}/${arch}/${branch}/0_to_${buildNumber}.pwr`;
}
async function checkArchiveExists(buildNumber, branch = 'release') {
@@ -43,91 +251,56 @@ async function checkArchiveExists(buildNumber, branch = 'release') {
try {
const response = await axios.head(url, { timeout: 10000 });
return response.status === 200;
} catch (error) {
} catch {
return false;
}
}
async function discoverAvailableVersions(latestKnown, branch = 'release', maxProbe = 50) {
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') {
async function discoverAvailableVersions(latestKnown, branch = 'release') {
try {
const os = getOS();
const arch = getArch();
const response = await smartRequest(`${MANIFEST_API}?branch=${branch}&os=${os}&arch=${arch}`, {
timeout: 10000
});
return response.data.patches || {};
} catch (error) {
console.error('Failed to fetch patch manifest:', error.message);
return {};
const manifest = await fetchMirrorManifest();
const patches = getPlatformPatches(manifest, branch);
const versions = [...new Set(patches.map(p => p.to))].sort((a, b) => b - a);
return versions.map(v => `${v}.pwr`);
} catch {
return [];
}
}
async function extractVersionDetails(targetVersion, branch = 'release') {
const buildNumber = parseInt(targetVersion.replace('.pwr', ''));
const previousBuild = buildNumber - 1;
const manifest = await fetchPatchManifest(branch);
const patchInfo = manifest[buildNumber];
const buildNumber = extractVersionNumber(targetVersion);
const fullUrl = buildArchiveUrl(buildNumber, branch);
return {
version: targetVersion,
buildNumber: buildNumber,
buildNumber,
buildName: `HYTALE-Build-${buildNumber}`,
fullUrl: patchInfo?.original_url || buildArchiveUrl(buildNumber, branch),
differentialUrl: patchInfo?.patch_url || null,
checksum: patchInfo?.patch_hash || null,
sourceVersion: patchInfo?.from ? `${patchInfo.from}.pwr` : (previousBuild > 0 ? `${previousBuild}.pwr` : null),
isDifferential: !!patchInfo?.proper_patch,
releaseNotes: patchInfo?.patch_note || null
fullUrl,
differentialUrl: null,
checksum: null,
sourceVersion: null,
isDifferential: false,
releaseNotes: null
};
}
function canUseDifferentialUpdate(currentVersion, targetDetails) {
if (!targetDetails) return false;
if (!targetDetails.differentialUrl) 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 canUseDifferentialUpdate() {
// Differential updates are now handled via getUpdatePlan()
return false;
}
function needsIntermediatePatches(currentVersion, targetVersion) {
if (!currentVersion) return [];
const current = parseInt(currentVersion.replace('.pwr', ''));
const target = parseInt(targetVersion.replace('.pwr', ''));
const intermediates = [];
for (let i = current + 1; i <= target; i++) {
intermediates.push(`${i}.pwr`);
}
return intermediates;
const current = extractVersionNumber(currentVersion);
const target = extractVersionNumber(targetVersion);
if (current >= target) return [];
return [targetVersion];
}
async function computeFileChecksum(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('data', data => hash.update(data));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
@@ -136,7 +309,6 @@ async function computeFileChecksum(filePath) {
async function validateChecksum(filePath, expectedChecksum) {
if (!expectedChecksum) return true;
const actualChecksum = await computeFileChecksum(filePath);
return actualChecksum === expectedChecksum;
}
@@ -145,7 +317,7 @@ function getInstalledClientVersion() {
try {
const { loadVersionClient } = require('../core/config');
return loadVersionClient();
} catch (err) {
} catch {
return null;
}
}
@@ -160,5 +332,13 @@ module.exports = {
needsIntermediatePatches,
computeFileChecksum,
validateChecksum,
getInstalledClientVersion
getInstalledClientVersion,
fetchMirrorManifest,
getPWRUrl,
getPWRUrlFromNewAPI,
getUpdatePlan,
extractVersionNumber,
getPlatformPatches,
findOptimalPatchPath,
MIRROR_BASE_URL
};

View File

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

10
main.js
View File

@@ -89,7 +89,7 @@ function setDiscordActivity() {
},
{
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');
errorData.retryData = {
branch: error.branch || 'release',
fileName: error.fileName || '7.pwr',
fileName: error.fileName || 'v8',
cacheDir: error.cacheDir
};
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');
errorData.retryData = {
branch: 'release',
fileName: '7.pwr'
fileName: 'v8'
};
// For generic errors, assume it's retryable unless specified
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');
retryData = {
branch: 'release',
fileName: '7.pwr'
fileName: 'v8'
};
}
@@ -921,7 +921,7 @@ ipcMain.handle('retry-download', async (event, retryData) => {
} :
{
branch: retryData?.branch || 'release',
fileName: retryData?.fileName || '7.pwr',
fileName: retryData?.fileName || 'v8',
cacheDir: retryData?.cacheDir
};

View File

@@ -1,6 +1,6 @@
{
"name": "hytale-f2p-launcher",
"version": "2.2.1",
"version": "2.3.2",
"description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support",
"homepage": "https://github.com/amiayweb/Hytale-F2P",
"main": "main.js",
@@ -118,9 +118,8 @@
"createStartMenuShortcut": true
},
"publish": {
"provider": "github",
"owner": "amiayweb",
"repo": "Hytale-F2P"
"provider": "generic",
"url": "https://git.sanhost.net/sanasol/hytale-f2p/releases/download/latest"
}
}
}