Compare commits

..

35 Commits

Author SHA1 Message Date
sanasol
6d811fd7e0 v2.3.5: hardened fallback chain for patch URL discovery
6-step fallback: auth.sanasol.ws → htdwnldsan.top → DNS TXT via DoH → disk cache → hardcoded URL. Practically unkillable by DMCA.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:22:57 +01:00
sanasol
8435fc698c fix: replace GitHub URLs with Forgejo after DMCA takedown
GitHub repo amiayweb/Hytale-F2P was DMCA'd. Updated Discord RPC link,
download page URL, and homepage to point to Forgejo instance.
Auto-update already pointed to git.sanhost.net (no change needed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:32:37 +01:00
sanasol
6c369edb0f v2.3.4: dynamic patches URL from auth server
Launcher now fetches patches base URL from /api/patches-config endpoint
instead of using hardcoded domain. URL cached for 5 minutes, no fallback
to hardcoded domain - requires auth server connection or cached URL.
Enables instant CDN switching without launcher updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:27:55 +01:00
sanasol
fdd8e59ec4 v2.3.3: fix singleplayer crash when install path has spaces
JAVA_TOOL_OPTIONS -javaagent path was not quoted, causing JVM to
truncate at first space. Affects all users with spaces in install
path (e.g. "Hytale F2P Launcher").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:54:17 +01:00
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
sanasol
92a0a26251 ci: fix Forgejo Actions compatibility
- Remove upload-artifact/download-artifact (not supported on Forgejo)
- Each build job uploads directly to release via API
- Add `rpm` package to Linux build dependencies
- Remove separate release job, replaced by create-release + per-job upload
- Remove arch build job entirely

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 00:59:44 +01:00
sanasol
3abe885ab4 ci: adapt release workflow for Forgejo
- Windows build cross-compiles from ubuntu-latest using Wine
- Arch build disabled (commented out)
- Release action switched to actions/forgejo-release@v2
- Removed arch artifacts from release

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 00:54:58 +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
Alex
b93dc027e1 Merge pull request #277 from sanasol/refactor/bytebuddy-agent
refactor: Replace pre-patched JAR with ByteBuddy runtime agent
2026-02-08 21:17:36 +07:00
sanasol
fdbca6b9da 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>
2026-02-08 11:44:13 +01:00
Fazri Gading
b2f65bd524 Update SERVER.md 2026-02-06 14:33:22 +08:00
Fazri Gading
bd6b05d1e4 Update SERVER.md official accounts info
Added CloudNord hosting information and new section for playing online with official accounts.
2026-02-06 05:11:00 +08:00
Fazri Gading
454ca7f075 Revise and enhance Hytale F2P Server Guide
Updated the Hytale F2P Server Guide with new sections and improved formatting.
2026-02-06 03:48:17 +08:00
Alex
e7324eb176 Merge pull request #268 from amiayweb/macos-notarization
feat(macos): add code signing and notarization support
2026-02-03 17:06:27 +07: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 958 additions and 426 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,173 +6,117 @@ 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:
build-windows:
runs-on: windows-latest
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:
needs: [create-release]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Wine for cross-compilation
run: |
sudo dpkg --add-architecture i386
sudo mkdir -pm755 /etc/apt/keyrings
sudo wget -O /etc/apt/keyrings/winehq-archive.key https://dl.winehq.org/wine-builds/winehq.key
sudo wget -NP /etc/apt/sources.list.d/ https://dl.winehq.org/wine-builds/ubuntu/dists/$(lsb_release -cs)/winehq-$(lsb_release -cs).sources
sudo apt-get update
sudo apt-get install -y --install-recommends winehq-stable
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- name: Build Windows Packages
run: npx electron-builder --win --publish never
- uses: actions/upload-artifact@v4
with:
name: windows-builds
path: |
dist/*.exe
dist/*.exe.blockmap
dist/latest.yml
run: npx electron-builder --win --publish never --config.npmRebuild=false
- name: Upload to Release
run: |
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 --max-time 600 -X POST "${FORGEJO_UPLOAD}/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=$(basename $file)" \
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
-F "attachment=@${file}" || echo "Failed to upload $file"
done
build-macos:
needs: [create-release]
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- name: Build macOS Packages
env:
# Code signing
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
# Notarization
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: npx electron-builder --mac --publish never
- uses: actions/upload-artifact@v4
with:
name: macos-builds
path: |
dist/*.dmg
dist/*.zip
dist/*.blockmap
dist/latest-mac.yml
- name: Upload to Release
run: |
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 --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:
runs-on: ubuntu-latest
needs: [create-release]
steps:
- uses: actions/checkout@v4
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -y libarchive-tools
sudo apt-get install -y libarchive-tools rpm
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- name: Build Linux Packages
run: npx electron-builder --linux AppImage deb rpm pacman --publish never
- name: Upload to Release
run: |
npx electron-builder --linux AppImage deb rpm --publish never
- 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
makepkg --printsrcinfo > .SRCINFO
makepkg -s --noconfirm
EOF
- name: Fix permissions for upload
if: always()
run: |
sudo chown -R $(id -u):$(id -g) .
- name: Upload Arch Package
uses: actions/upload-artifact@v4
with:
name: arch-package
path: |
*.pkg.tar.zst
.SRCINFO
include-hidden-files: true
release:
needs: [build-windows, build-macos, build-linux, build-arch]
runs-on: ubuntu-latest
if: |
startsWith(github.ref, 'refs/tags/v') ||
github.ref == 'refs/heads/main' ||
github.event_name == 'workflow_dispatch'
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Display structure of downloaded files
run: ls -R artifacts
- name: Get version from package.json
id: pkg_version
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
files: |
artifacts/arch-package/*.pkg.tar.zst
artifacts/arch-package/.SRCINFO
artifacts/linux-builds/**/*
artifacts/windows-builds/**/*
artifacts/macos-builds/**/*
generate_release_notes: true
draft: true
prerelease: false
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/*.pacman dist/latest-linux.yml; do
[ -f "$file" ] || continue
echo "Uploading $file..."
curl -s --max-time 600 -X POST "${FORGEJO_UPLOAD}/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=$(basename $file)" \
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
-F "attachment=@${file}" || echo "Failed to upload $file"
done

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>

130
SERVER.md
View File

@@ -2,19 +2,20 @@
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**
* [\[NEW!\] Play Online with Official Accounts 🆕](#new-play-online-with-official-accounts-)
* ["Server" Term and Definition](#server-term-and-definiton)
* [Server Directory Location](#server-directory-location)
* [A. Online Play Feature](#a-online-play-feature)
* [1. Host Your Singleplayer World using In-Game Invite Code](#1-host-your-singleplayer-world-using-in-game-invite-code)
* [A. Host Your Singleplayer World](#a-host-your-singleplayer-world)
* [1. Using Online-Play Feature In-Game Invite Code](#1-using-online-play-feature--in-game-invite-code)
* [Common Issues (UPnP/NAT/STUN) on Online Play](#common-issues-upnpnatstun-on-online-play)
* [2. Host Your Singleplayer World using Tailscale](#2-host-your-singleplayer-world-using-tailscale)
* [2. Using Tailscale](#2-using-tailscale)
* [3. Using Radmin VPN](#3-using-radmin-vpn)
* [B. Local Dedicated Server](#b-local-dedicated-server)
* [1. Using Playit.gg (Recommended) ✅](#1-using-playitgg-recommended-)
* [2. Using Radmin VPN](#2-using-radmin-vpn)
* [C. 24/7 Dedicated Server (Advanced)](#c-247-dedicated-server-advanced)
* [Step 1: Get the Files Ready](#step-1-get-the-files-ready)
* [Step 2: Place HytaleServer.jar in the Server directory](#step-2-place-hytaleserverjar-in-the-server-directory)
@@ -32,6 +33,69 @@ Play with friends online! This guide covers both easy in-game hosting and advanc
* [10. Getting Help](#10-getting-help)
---
<div align='center'>
<h3>
<b>
Do you want to create Hytale Game Server with EASY SETUP, AFFORDABLE PRICE, AND 24/7 SUPPORT?
</b>
</h3>
<h2>
<b>
<a href="https://cloudnord.net/hytale-server-hosting">CLOUDNORD</a> is the ANSWER! HF2P Server is available!
</b>
</h2>
</div>
**CloudNord's Hytale, Minecraft, and Game Hosting** is at the core of our Server Hosting business. Join our Gaming community and experience our large choice of premium game servers, weve got you covered with super high-performance hardware, fantastic support options, and powerful server hosting to build and explore your worlds without limits!
**Order your Hytale, Minecraft, or other game servers today!**
Choose Java Edition, Bedrock Edition, Cross-Play, or any of our additional supported games.
Enjoy **20% OFF** all new game servers, **available now for a limited time!** Dont miss out.
### **CloudNord key hosting features include:**
- Instant Server Setup ⚡
- High Performance Game Servers 🚀
- Game DDoS Protection 🛡️
- Intelligent Game Backups 🧠
- Quick Modpack Installer 🔧
- Quick Plugin & Mod Installer 🧰
- Full File Access 🗃️
- 24/7 Support 📞 🏪
- Powerful Game Control Server Panel 💪
### **Check Us Out:**
* 👉 CloudNord Website: https://cloudnord.net/hytalef2p
* 👉 CloudNord Discord: https://discord.gg/TYxGrmUz4Y
* 👉 CloudNord Reviews: https://www.trustpilot.com/review/cloudnord.net?page=2&stars=5
---
### [NEW!] Play Online with Official Accounts 🆕
**Documentations:**
* [Hytale-Server-Docker by Sanasol](https://github.com/sanasol/hytale-server-docker/tree/main?tab=readme-ov-file#dual-authentication)
**Requirements:**
* Using the patched HytaleServer.jar
* Has Official Account with Purchased status on Official Hytale Website.
* This official account holder can be the server hoster or one of the players.
**Steps:**
1. Running the patched HytaleServer.jar with either [B. Local Dedicated Server](#b-local-dedicated-server) or [C. 24/7 Dedicated Server (Advanced)](#c-247-dedicated-server-advanced) successfully.
2. On the server's console/terminal/CMD, server admin **MUST RUN THIS EACH BOOT** to allow players with Official Hytale game license to connect on the server:
```
/auth logout
/auth persistence Encrypted
/auth login device
```
3. Server console will show instructions, an URL and a code; these will be revoked after 10 minutes if not authorized.
4. The server hoster can open the URL directly to browser by holding Ctrl then Click on it, or copy and send it to the player with official account.
5. Once it authorized, the official accounts can join server with F2P players.
6. If you want to modify anything, look at the [Hytale-Server-Docker](https://github.com/sanasol/hytale-server-docker/) above, give the repo a STAR too.
---
### "Server" Term and Definiton
"HytaleServer.jar", which called as "Server", functions as the place of authentication of the client that supposed to go to Hytale Official Authentication System but we managed our way to redirect it on our service (Thanks to Sanasol), handling approximately thousands of players worldwide to play this game for free.
@@ -41,14 +105,15 @@ Kindly support us via [our Buy Me a Coffee link](https://buymeacoffee.com/hf2p)
### Server Directory Location
Here are the directory locations of Server folder if you have installed
Here are the directory locations of Server folder if you have installed it on default instalation location:
- **Windows:** `%localappdata%\HytaleF2P\release\package\game\latest\Server`
- **macOS:** `~/Library/Application Support/HytaleF2P/release/package/game/latest/Server`
- **Linux:** `~/.hytalef2p/release/package/game/latest/Server`
> [!NOTE]
> This location only exists if the user installed the game using our launcher. The `Server` folder needed to auth the HytaleClient to play Hytale online
> (for now; we planned to add offline mode in later version of our launcher).
> This location only exists if the user installed the game using our launcher.
> The `Server` folder needed to auth the HytaleClient to play Hytale in Singleplayer/Multiplayer for now.
> (We planned to add offline mode in later version of our launcher).
> [!IMPORTANT]
> Hosting a dedicated Hytale server will not need the exact similar tree. You can put it anywhere, as long as the directory has `Assets.zip` which
@@ -64,6 +129,7 @@ Terms and conditions applies.
## 1. Using Online-Play Feature / In-Game Invite Code
The easiest way to play with friends - no manual server setup required!
*The game automatically handles networking using UPnP/STUN/NAT traversal.*
**For Online Play to work, you need:**
@@ -112,6 +178,7 @@ Warning: Your network configuration may prevent other players from connecting.
</details>
<details><summary><b>b. "UPnP Failed" or "Port Mapping Failed" Warning</b></summary>
**Check your router:**
1. Log into router admin panel (usually `192.168.1.1` or `192.168.0.1`)
2. Find UPnP settings (often under "Advanced" or "NAT")
@@ -123,7 +190,8 @@ Warning: Your network configuration may prevent other players from connecting.
- See "Port Forwarding" or "Workarounds or NAT/CGNAT" sections below
</details>
<details><summary><b>c. "Strict NAT" or "Symmetric NAT" Warning</b></summary>
<details><summary><b>c. "Connected via STUN", "Strict NAT" or "Symmetric NAT" Warning</b></summary>
Some routers have restrictive NAT that blocks peer connections.
**Try:**
@@ -133,6 +201,7 @@ Some routers have restrictive NAT that blocks peer connections.
</details>
## 2. Using Tailscale
Tailscale creates mesh VPN service that streamlines connecting devices and services securely across different networks. And **works crossplatform!!**
1. All members are required to download [Tailscale](https://tailscale.com/download) on your device.
@@ -148,6 +217,17 @@ Tailscale creates mesh VPN service that streamlines connecting devices and servi
* Use the new share code to connect
* To test your connection, ping the host's ipv4 mentioned in Tailscale
## 3. Using Radmin VPN
Creates a virtual LAN - all players need to install it:
1. Download [Radmin VPN](https://www.radmin-vpn.com/) - All players install it
2. One person create a room/network, others join with network name/password
3. Host joined the world, others will connect to it.
4. Open Hytale Game > Servers > Add Servers > Direct Connect > Type IP Address of the Host from Radmin.
These options bypass all NAT/CGNAT issues. But for **Windows machines only!**
---
# B. Local Dedicated Server
@@ -166,12 +246,13 @@ Free tunneling service - only the host needs to install it:
* Linux:
* Right-click file > Properties > Turn on 'Executable as a Program' | or `chmod +x playit-linux-amd64` on terminal
* Run by double-clicking the file or `./playit-linux-amd64` via terminal
5. Open the URL/link by `Ctrl+Click` it. If unable, select the URL, then Right-Click to Copy (`Ctrl+Shift+C` for Linux) then Paste the URL into your browser to link it with your created account.
6. **WARNING: Do not close the terminal if you are still playing or hosting the server**
7. Once it done, download the `run_server_with_tokens` script file (`.BAT` for Windows, `.SH` for Linux) from our Discord server > channel `#open-public-server`
8. Put the script file to the `Server` folder in `HytaleF2P` directory (`%localappdata%\HytaleF2P\release\package\game\latest\Server`)
9. Copy the `Assets.zip` from the `%localappdata%\HytaleF2P\release\package\game\latest\` folder to the `Server\` folder. (TIP: You can use Symlink of that file to reduce disk usage!)
10. Double-click the .BAT file to host your server, wait until it shows:
5. Open the URL/link by `Ctrl+Click` it. If unable, select the URL, then Right-Click to Copy (`Ctrl+Shift+C` for Linux) then Paste the URL into your browser to link it with your created account.
6. Once it done, download the `run_server_with_tokens (1)` script file (`.BAT` for Windows, `.SH` for Linux) from our Discord server > channel `#open-public-server`
7. Put the script file to the `Server` folder in `HytaleF2P` directory (`%localappdata%\HytaleF2P\release\package\game\latest\Server`)
8. Rename the script file to `run_server_with_tokens` to make it easier if you run it with Terminal, then do Method A or B.
9. If you put it in `Server` folder in `HytaleF2P` launcher, change `ASSETS_PATH="${ASSETS_PATH:-./Assets.zip}"` inside the script to be `ASSETS_PATH="${ASSETS_PATH:-../Assets.zip}"`. NOTICE THE `./` and `../` DIFFERENCE.
10. Copy the `Assets.zip` from the `%localappdata%\HytaleF2P\release\package\game\latest\` folder to the `Server\` folder. (TIP: You can use Symlink of that file to reduce disk usage!)
11. Double-click the .BAT file to host your server, wait until it shows:
```
===================================================
Hytale Server Booted! [Multiplayer, Fresh Universe]
@@ -180,16 +261,12 @@ Hytale Server Booted! [Multiplayer, Fresh Universe]
11. Connect to the server by go to `Servers` in your game client, press `Add Server`, type `localhost` in the address box, use any name for your server.
12. Send the public address in Step 3 to your friends.
## 2. Using Radmin VPN
> [!CAUTION]
> Do not close the Playit.gg Terminal OR HytaleServer Terminal if you are still playing or hosting the server.
Creates a virtual LAN - all players need to install it:
## 2. Using Tailscale [DRAFT]
1. Download [Radmin VPN](https://www.radmin-vpn.com/) - All players install it
2. One person create a room/network, others join with network name/password
3. Host joined the world, others will connect to it.
4. Open Hytale Game > Servers > Add Servers > Direct Connect > Type IP Address of the Host from Radmin.
These options bypass all NAT/CGNAT issues. But for **Windows machines only!**
Tailscale
---
@@ -228,12 +305,12 @@ For 24/7 servers, custom configurations, or hosting on a VPS/dedicated machine.
**Windows:**
```batch
run_server.bat
run_server_with_token.bat
```
**macOS / Linux:**
```bash
./run_server.sh
./run_server_with_token.sh
```
---
@@ -503,3 +580,6 @@ See [Docker documentation](https://github.com/Hybrowse/hytale-server-docker) for
- Auth Server: sanasol.ws

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');
@@ -30,7 +30,7 @@ async function acquireGameArchive(downloadUrl, targetPath, checksum, progressCal
}
console.log(`Downloading game archive from: ${downloadUrl}`);
try {
if (allowRetry) {
await retryDownload(downloadUrl, targetPath, progressCallback);
@@ -103,13 +103,13 @@ async function deployGameArchive(archivePath, destinationDir, toolsDir, progress
if (error) {
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();
if (cleanStderr) console.error('Deployment stderr:', cleanStderr);
if (cleanStdout) console.error('Deployment stdout:', cleanStdout);
const errorText = (stderr + ' ' + error.message).toLowerCase();
let message = 'Game deployment failed';
if (errorText.includes('unexpected eof')) {
message = 'Corrupted archive detected. Please retry download.';
if (fs.existsSync(archivePath)) {
@@ -156,20 +156,20 @@ 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);
}
await acquireGameArchive(versionDetails.fullUrl, archivePath, null, progressCallback);
await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, false);
saveVersionClient(targetVersion);
@@ -177,16 +177,16 @@ 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);
await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, false);
saveVersionClient(targetVersion);
@@ -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);
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) {
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(patchVersion);
console.log(`Patch ${patchVersion} applied successfully (${i + 1}/${patchesToApply.length})`);
}
console.log(`Update completed successfully. Version ${targetVersion} is now installed.`);
saveVersionClient(`v${step.to}`);
console.log(`Patch ${stepName} applied (${i + 1}/${plan.steps.length})`);
}
console.log(`Update completed. Version ${targetVersion} is now installed.`);
} catch (planError) {
console.error('Update plan failed:', planError.message);
console.log('Falling back to full archive download');
// Fallback: full install
const versionDetails = await extractVersionDetails(targetVersion, branch);
const archivePath = path.join(cacheDir, `${branch}_0_to_${targetBuild}.pwr`);
if (progressCallback) {
progressCallback(`Downloading full game archive (fallback)...`, 0, null, null, null);
}
await acquireGameArchive(versionDetails.fullUrl, archivePath, null, progressCallback);
await 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
@@ -412,7 +439,7 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
// This enables runtime auth patching without modifying the server JAR
const agentJar = path.join(gameLatest, 'Server', 'dualauth-agent.jar');
if (fs.existsSync(agentJar)) {
const agentFlag = `-javaagent:${agentJar}`;
const agentFlag = `-javaagent:"${agentJar}"`;
env.JAVA_TOOL_OPTIONS = env.JAVA_TOOL_OPTIONS
? `${env.JAVA_TOOL_OPTIONS} ${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 { 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,69 @@ async function downloadPWR(branch = 'release', fileName = '7.pwr', progressCallb
throw new Error('Hytale x86_64 Intel Mac Support has not been released yet. Please check back later.');
}
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 { getPatchesBaseUrl } = require('../services/versionManager');
const baseUrl = await getPatchesBaseUrl();
url = `${baseUrl}/${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 +160,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');
@@ -169,8 +210,8 @@ async function downloadPWR(branch = 'release', fileName = '7.pwr', progressCallb
// Enhanced PWR file validation
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 +229,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 +253,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 +439,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,36 +921,30 @@ 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;
}
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

@@ -1,133 +1,465 @@
const axios = require('axios');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
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 base URL fetched dynamically via multi-source fallback chain
const AUTH_DOMAIN = process.env.HYTALE_AUTH_DOMAIN || 'auth.sanasol.ws';
const PATCHES_CONFIG_SOURCES = [
{ type: 'http', url: `https://${AUTH_DOMAIN}/api/patches-config`, name: 'auth-server' },
{ type: 'http', url: 'https://htdwnldsan.top/patches-config', name: 'backup-http' },
{ type: 'doh', name: '_patches.htdwnldsan.top', name_label: 'dns-txt' },
];
const HARDCODED_FALLBACK = 'https://dl.vboro.de/patches';
async function getLatestClientVersion(branch = 'release') {
// Fallback: latest known build number if manifest is unreachable
const FALLBACK_LATEST_BUILD = 11;
let patchesBaseUrl = null;
let patchesConfigTime = 0;
const PATCHES_CONFIG_CACHE_DURATION = 300000; // 5 minutes
let manifestCache = null;
let manifestCacheTime = 0;
const MANIFEST_CACHE_DURATION = 60000; // 1 minute
// Disk cache path for patches URL (survives restarts)
function getDiskCachePath() {
const os = require('os');
const home = os.homedir();
let appDir;
if (process.platform === 'win32') {
appDir = path.join(home, 'AppData', 'Local', 'HytaleF2P');
} else if (process.platform === 'darwin') {
appDir = path.join(home, 'Library', 'Application Support', 'HytaleF2P');
} else {
appDir = path.join(home, '.hytalef2p');
}
return path.join(appDir, 'patches-url-cache.json');
}
function saveDiskCache(url) {
try {
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'
}
});
if (response.data && response.data.client_version) {
const version = response.data.client_version;
console.log(`Latest client version for ${branch}: ${version}`);
return version;
} else {
console.log('Warning: Invalid API response, falling back to latest known version (7.pwr)');
return '7.pwr';
}
} 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';
const cachePath = getDiskCachePath();
const dir = path.dirname(cachePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(cachePath, JSON.stringify({ patches_url: url, ts: Date.now() }), 'utf8');
} catch (e) {
// Non-critical, ignore
}
}
function buildArchiveUrl(buildNumber, branch = 'release') {
function loadDiskCache() {
try {
const cachePath = getDiskCachePath();
if (fs.existsSync(cachePath)) {
const data = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
if (data && data.patches_url) return data.patches_url;
}
} catch (e) {
// Non-critical, ignore
}
return null;
}
/**
* Fetch patches URL from a single HTTP config endpoint
*/
async function fetchFromHttp(url) {
const response = await axios.get(url, {
timeout: 8000,
headers: { 'User-Agent': 'Hytale-F2P-Launcher' }
});
if (response.data && response.data.patches_url) {
return response.data.patches_url.replace(/\/+$/, '');
}
throw new Error('Invalid response');
}
/**
* Fetch patches URL from DNS TXT record via DNS-over-HTTPS
*/
async function fetchFromDoh(recordName) {
const dohEndpoints = [
{ url: 'https://dns.google/resolve', params: { name: recordName, type: 'TXT' } },
{ url: 'https://cloudflare-dns.com/dns-query', params: { name: recordName, type: 'TXT' }, headers: { 'Accept': 'application/dns-json' } },
];
for (const endpoint of dohEndpoints) {
try {
const response = await axios.get(endpoint.url, {
params: endpoint.params,
headers: { 'User-Agent': 'Hytale-F2P-Launcher', ...(endpoint.headers || {}) },
timeout: 5000
});
const answers = response.data && response.data.Answer;
if (answers && answers.length > 0) {
// TXT records are quoted, strip quotes
const txt = answers[0].data.replace(/^"|"$/g, '');
if (txt.startsWith('http')) return txt.replace(/\/+$/, '');
}
} catch (e) {
// Try next DoH endpoint
}
}
throw new Error('All DoH endpoints failed');
}
/**
* Fetch patches base URL with hardened multi-source fallback chain:
* 1. Memory cache (5 min)
* 2. HTTP: auth.sanasol.ws (primary)
* 3. HTTP: htdwnldsan.top (backup, different host/domain/registrar)
* 4. DNS TXT: _patches.htdwnldsan.top via DoH (different protocol layer)
* 5. Disk cache (survives restarts, never expires)
* 6. Hardcoded fallback URL (last resort)
*/
async function getPatchesBaseUrl() {
const now = Date.now();
// 1. Memory cache
if (patchesBaseUrl && (now - patchesConfigTime) < PATCHES_CONFIG_CACHE_DURATION) {
return patchesBaseUrl;
}
// 2-4. Try all sources: HTTP endpoints first, then DoH
for (const source of PATCHES_CONFIG_SOURCES) {
try {
let url;
if (source.type === 'http') {
console.log(`[Mirror] Trying ${source.name}: ${source.url}`);
url = await fetchFromHttp(source.url);
} else if (source.type === 'doh') {
console.log(`[Mirror] Trying ${source.name_label}: ${source.name}`);
url = await fetchFromDoh(source.name);
}
if (url) {
patchesBaseUrl = url;
patchesConfigTime = now;
saveDiskCache(url);
console.log(`[Mirror] Patches URL (via ${source.name || source.name_label}): ${url}`);
return url;
}
} catch (e) {
console.warn(`[Mirror] ${source.name || source.name_label} failed: ${e.message}`);
}
}
// 5. Stale memory cache (any age)
if (patchesBaseUrl) {
console.log('[Mirror] All sources failed, using stale memory cache:', patchesBaseUrl);
return patchesBaseUrl;
}
// 6. Disk cache (survives restarts)
const diskUrl = loadDiskCache();
if (diskUrl) {
patchesBaseUrl = diskUrl;
console.log('[Mirror] All sources failed, using disk cache:', diskUrl);
return diskUrl;
}
// 7. Hardcoded fallback
console.warn('[Mirror] All sources + caches exhausted, using hardcoded fallback:', HARDCODED_FALLBACK);
patchesBaseUrl = HARDCODED_FALLBACK;
return HARDCODED_FALLBACK;
}
/**
* Fetch the mirror manifest
*/
async function fetchMirrorManifest() {
const now = Date.now();
if (manifestCache && (now - manifestCacheTime) < MANIFEST_CACHE_DURATION) {
console.log('[Mirror] Using cached manifest');
return manifestCache;
}
const baseUrl = await getPatchesBaseUrl();
const manifestUrl = `${baseUrl}/manifest.json`;
try {
console.log('[Mirror] Fetching manifest from:', manifestUrl);
const response = await axios.get(manifestUrl, {
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();
return `${BASE_PATCH_URL}/${os}/${arch}/${branch}/0/${buildNumber}.pwr`;
const prefix = `${os}/${arch}/${branch}/`;
const patches = [];
for (const [key, info] of Object.entries(manifest.files)) {
if (key.startsWith(prefix) && key.endsWith('.pwr')) {
const filename = key.slice(prefix.length, -4); // e.g., "0_to_11"
const match = filename.match(/^(\d+)_to_(\d+)$/);
if (match) {
patches.push({
from: parseInt(match[1]),
to: parseInt(match[2]),
key,
size: info.size
});
}
}
}
return patches;
}
/**
* Find optimal patch path using BFS with download size minimization
* Returns array of { from, to, url, size, key } steps, or null if no path found
*/
async function findOptimalPatchPath(currentBuild, targetBuild, patches) {
if (currentBuild >= targetBuild) return [];
const baseUrl = await getPatchesBaseUrl();
const edges = {};
for (const patch of patches) {
if (!edges[patch.from]) edges[patch.from] = [];
edges[patch.from].push(patch);
}
const queue = [{ build: currentBuild, path: [], totalSize: 0 }];
let bestPath = null;
let bestSize = Infinity;
while (queue.length > 0) {
const { build, path, totalSize } = queue.shift();
if (build === targetBuild) {
if (totalSize < bestSize) {
bestPath = path;
bestSize = totalSize;
}
continue;
}
if (totalSize >= bestSize) continue;
const nextEdges = edges[build] || [];
for (const edge of nextEdges) {
if (edge.to <= build || edge.to > targetBuild) continue;
if (path.some(p => p.to === edge.to)) continue;
queue.push({
build: edge.to,
path: [...path, {
from: edge.from,
to: edge.to,
url: `${baseUrl}/${edge.key}`,
size: edge.size,
key: edge.key
}],
totalSize: totalSize + edge.size
});
}
}
return bestPath;
}
/**
* Get the optimal update plan from currentBuild to targetBuild
* Returns { steps: [{from, to, url, size}], totalSize, isFullInstall }
*/
async function getUpdatePlan(currentBuild, targetBuild, branch = 'release') {
const manifest = await fetchMirrorManifest();
const patches = getPlatformPatches(manifest, branch);
// Try optimal path
const steps = await findOptimalPatchPath(currentBuild, targetBuild, patches);
if (steps && steps.length > 0) {
const totalSize = steps.reduce((sum, s) => sum + s.size, 0);
console.log(`[Mirror] Update plan: ${steps.map(s => `${s.from}\u2192${s.to}`).join(' + ')} (${(totalSize / 1024 / 1024).toFixed(0)} MB)`);
return { steps, totalSize, isFullInstall: steps.length === 1 && steps[0].from === 0 };
}
// Fallback: full install 0 -> target
const fullPatch = patches.find(p => p.from === 0 && p.to === targetBuild);
if (fullPatch) {
const baseUrl = await getPatchesBaseUrl();
const step = {
from: 0,
to: targetBuild,
url: `${baseUrl}/${fullPatch.key}`,
size: fullPatch.size,
key: fullPatch.key
};
console.log(`[Mirror] Full install: 0\u2192${targetBuild} (${(fullPatch.size / 1024 / 1024).toFixed(0)} MB)`);
return { steps: [step], totalSize: fullPatch.size, isFullInstall: true };
}
throw new Error(`No patch path found from build ${currentBuild} to ${targetBuild} for ${getOS()}/${getArch()}`);
}
async function getLatestClientVersion(branch = 'release') {
try {
console.log(`[Mirror] Fetching latest client version (branch: ${branch})...`);
const manifest = await fetchMirrorManifest();
const patches = getPlatformPatches(manifest, branch);
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 baseUrl = await getPatchesBaseUrl();
const url = `${baseUrl}/${fullPatch.key}`;
console.log(`[Mirror] PWR URL: ${url}`);
return url;
}
if (patches.length > 0) {
// Branch exists in mirror but no full patch for this target - construct URL
console.log(`[Mirror] No 0->${targetBuild} patch found, constructing URL`);
} else {
console.log(`[Mirror] Branch '${branch}' not in mirror, constructing URL`);
}
} catch (error) {
console.error('[Mirror] Error getting PWR URL:', error.message);
}
// Construct mirror URL (will work if patch was uploaded but manifest is stale)
const baseUrl = await getPatchesBaseUrl();
return `${baseUrl}/${os}/${arch}/${branch}/0_to_${targetBuild}.pwr`;
}
// Backward-compatible alias
const getPWRUrlFromNewAPI = getPWRUrl;
// Utility function to extract version number
// Supports: "7.pwr", "v8", "v8-windows-amd64.pwr", "5_to_10", etc.
function extractVersionNumber(version) {
if (!version) return 0;
// New format: "v8" or "v8-xxx.pwr"
const vMatch = version.match(/v(\d+)/);
if (vMatch) return parseInt(vMatch[1]);
// Old format: "7.pwr"
const pwrMatch = version.match(/(\d+)\.pwr/);
if (pwrMatch) return parseInt(pwrMatch[1]);
// Fallback
const num = parseInt(version);
return isNaN(num) ? 0 : num;
}
async function buildArchiveUrl(buildNumber, branch = 'release') {
const baseUrl = await getPatchesBaseUrl();
const os = getOS();
const arch = getArch();
return `${baseUrl}/${os}/${arch}/${branch}/0_to_${buildNumber}.pwr`;
}
async function checkArchiveExists(buildNumber, branch = 'release') {
const url = buildArchiveUrl(buildNumber, branch);
const url = await buildArchiveUrl(buildNumber, branch);
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 = await 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 +468,6 @@ async function computeFileChecksum(filePath) {
async function validateChecksum(filePath, expectedChecksum) {
if (!expectedChecksum) return true;
const actualChecksum = await computeFileChecksum(filePath);
return actualChecksum === expectedChecksum;
}
@@ -145,7 +476,7 @@ function getInstalledClientVersion() {
try {
const { loadVersionClient } = require('../core/config');
return loadVersionClient();
} catch (err) {
} catch {
return null;
}
}
@@ -160,5 +491,13 @@ module.exports = {
needsIntermediatePatches,
computeFileChecksum,
validateChecksum,
getInstalledClientVersion
getInstalledClientVersion,
fetchMirrorManifest,
getPWRUrl,
getPWRUrlFromNewAPI,
getUpdatePlan,
extractVersionNumber,
getPlatformPatches,
findOptimalPatchPath,
getPatchesBaseUrl
};

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

18
main.js
View File

@@ -84,12 +84,12 @@ function setDiscordActivity() {
largeImageText: 'Hytale F2P Launcher',
buttons: [
{
label: 'GitHub',
url: 'https://github.com/amiayweb/Hytale-F2P'
label: 'Download',
url: 'https://git.sanhost.net/sanasol/hytale-f2p/releases'
},
{
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
};
@@ -964,8 +964,8 @@ ipcMain.handle('open-external', async (event, url) => {
ipcMain.handle('open-download-page', async () => {
try {
// Open GitHub releases page for manual download
await shell.openExternal('https://github.com/amiayweb/Hytale-F2P/releases/latest');
// Open Forgejo releases page for manual download
await shell.openExternal('https://git.sanhost.net/sanasol/hytale-f2p/releases/latest');
return { success: true };
} catch (error) {
console.error('Failed to open download page:', error);

View File

@@ -1,8 +1,8 @@
{
"name": "hytale-f2p-launcher",
"version": "2.2.1",
"version": "2.3.5",
"description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support",
"homepage": "https://github.com/amiayweb/Hytale-F2P",
"homepage": "https://git.sanhost.net/sanasol/hytale-f2p",
"main": "main.js",
"scripts": {
"start": "electron .",
@@ -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"
}
}
}