124 Commits

Author SHA1 Message Date
AMIAY
7a9a67d8e8 Merge pull request #180 from amiayweb/develop
Release version v2.1.0
2026-01-25 23:24:19 +01:00
Fazri Gading
4c854953fe add support link on README 2026-01-26 06:02:35 +08:00
Fazri Gading
4cd0539ce3 Merge pull request #172 from Rahul-Sahani04/develop
feat: Add option to toggle hardware acceleration for launcher. Issue #170
2026-01-26 05:10:24 +08:00
Fazri Gading
fa2d451f90 Merge branch 'develop' into develop 2026-01-26 05:09:36 +08:00
Fazri Gading
a4faa7138c Merge branch 'develop' of https://github.com/amiayweb/Hytale-F2P into develop 2026-01-26 05:05:31 +08:00
Fazri Gading
d285dc7517 fix: async-await for toggle and cleanup discordRPC 2026-01-26 05:05:25 +08:00
Fazri Gading
ceadd69eea Update release.yml: changed heads ref 2026-01-26 05:03:31 +08:00
Fazri Gading
6f0dd27c1d Update README.md header 2026-01-26 04:59:41 +08:00
Fazri Gading
ba95187ee6 fix: err_bad_request code 416 due to file size matched remote size, updated timeout to 15mins 2026-01-26 04:58:02 +08:00
AMIAY
9e54e07b22 R2 cdn added 2026-01-25 21:26:46 +01:00
AMIAY
a8e7e57c86 Merge pull request #178 from fazrigading/develop
fix: discordRPC error due to incorrect type value, update dotenv in package-lock
2026-01-25 21:01:53 +01:00
Terromur
d1ab58d51b Merge pull request #177 from amiayweb/fix-icon
Fix icon
2026-01-26 00:30:09 +05:00
Fazri Gading
8781025df9 chore: update readme.md, todo changelog 2026-01-26 03:28:30 +08:00
Terromur
81c52e9507 Fix icon 2026-01-26 00:28:09 +05:00
Fazri Gading
45314620e4 fix: Discord ID int to str, duplicate run of cleanupDiscordRPC function, and dismiss setTimeout on discordRPC destroy 2026-01-26 02:16:33 +08:00
Fazri Gading
43d5d20351 chore: disable patcher log to reduce logging length 2026-01-26 01:52:54 +08:00
Fazri Gading
72b4e0cba8 Merge branch 'develop' of https://github.com/amiayweb/Hytale-F2P into develop 2026-01-26 00:27:25 +08:00
AMIAY
25d5131a7b Merge branch 'develop' of https://github.com/amiayweb/Hytale-F2P into develop 2026-01-25 17:02:06 +01:00
AMIAY
ad3c73563d temp jar patcher 2026-01-25 17:02:02 +01:00
Rahul-Sahani04
f0f19f690f feat: Add option to toggle hardware acceleration for launcher #170 2026-01-25 21:08:47 +05:30
Fazri Gading
b27860a655 fix: adds back dotenv in package-lock.json 2026-01-25 23:25:33 +08:00
Fazri Gading
9788d0e496 Merge branch 'develop' of https://github.com/amiayweb/Hytale-F2P into develop 2026-01-25 22:38:10 +08:00
Terromur
2a5780c2d4 Fix icon 2026-01-25 18:37:57 +05:00
AMIAY
8263b3f99b update pkgbuild 2026-01-25 14:37:00 +01:00
AMIAY
db56ef1624 onUpdateError fix 2026-01-25 14:31:17 +01:00
AMIAY
35f900d6ab Update fileManager.js 2026-01-25 14:15:19 +01:00
AMIAY
e1d1383ab7 Update package.json 2026-01-25 14:02:58 +01:00
AMIAY
8326deddb1 Merge branch 'develop' of https://github.com/amiayweb/Hytale-F2P into develop 2026-01-25 13:31:38 +01:00
AMIAY
b11b78f7dc trying 2026-01-25 13:31:08 +01:00
Fazri Gading
62a2d76e4a Merge branch 'develop' of https://github.com/amiayweb/Hytale-F2P into develop 2026-01-25 20:22:49 +08:00
Terromur
0ca8b4e02f Deleting garbage envs 2026-01-25 17:17:36 +05:00
Fazri Gading
c6a9d0ae07 merge last two commits to develop (#165)
* Add correct auto-detect version and commit

If a person uses PKGBUILD, it will automatically determine the latest version and commit.

* Remove maintainer and change to npm ci

---------

Co-authored-by: Terromur <79866197+Terromur@users.noreply.github.com>
2026-01-25 20:03:54 +08:00
Terromur
f438d6c8e0 Update PKGBUILD
Set png file from GUI/icon.png to 256x256 resolution for compatibility support.
2026-01-25 16:41:48 +05:00
Fazri Gading
f07e4a2004 Merge pull request #166 from amiayweb/main
merge last two commits from main
2026-01-25 18:37:47 +08:00
Fazri Gading
131580d3ba merge last two commits to develop (#165)
* Add correct auto-detect version and commit

If a person uses PKGBUILD, it will automatically determine the latest version and commit.

* Remove maintainer and change to npm ci

---------

Co-authored-by: Terromur <79866197+Terromur@users.noreply.github.com>
2026-01-25 18:36:40 +08:00
Fazri Gading
084347db03 prepare release for v2.1.0 (#164)
* fix: update tar to 7.5.6

* test: release on main branch using tag

* chore: remove previous release branch part

* fix: add deps for bsdtar

* fix: fix build tar.zst for arch

* fix: missing npm ci on release yml

* fix: remove pacman package json

* fix: revert tar version

* fix: revert tar in package-lock.json

* Update release.yml
2026-01-25 18:35:45 +08:00
Fazri Gading
589c5b457f Update release.yml 2026-01-25 18:34:01 +08:00
Fazri Gading
790d4d3f29 fix: revert tar in package-lock.json 2026-01-25 18:07:34 +08:00
Fazri Gading
52313910dc fix: revert tar version 2026-01-25 18:05:43 +08:00
Fazri Gading
a3f4d8e9d8 fix: remove pacman package json 2026-01-25 18:01:45 +08:00
Fazri Gading
86d617a4d3 fix: missing npm ci on release yml 2026-01-25 17:40:26 +08:00
Fazri Gading
0a97ac95fc fix: fix build tar.zst for arch 2026-01-25 17:27:55 +08:00
Fazri Gading
b94b45681b fix: add deps for bsdtar 2026-01-25 17:14:34 +08:00
Terromur
4086612e9d Remove maintainer and change to npm ci 2026-01-25 14:12:20 +05:00
Terromur
e7fca5a4c7 Add correct auto-detect version and commit
If a person uses PKGBUILD, it will automatically determine the latest version and commit.
2026-01-25 13:52:00 +05:00
Fazri Gading
e7bd20a1ec chore: remove previous release branch part 2026-01-25 16:50:28 +08:00
Fazri Gading
151b017653 test: release on main branch using tag 2026-01-25 16:46:26 +08:00
Fazri Gading
da3e14c434 fix: update tar to 7.5.6 2026-01-25 16:45:39 +08:00
Fazri Gading
6302734eeb fix: add missing icons for all platforms 2026-01-25 16:14:21 +08:00
Fazri Gading
07191860be chore: more detailed gitignore 2026-01-25 16:13:27 +08:00
Fazri Gading
2f767f191e chore: delete unused get-env-var functions 2026-01-25 15:31:29 +08:00
Fazri Gading
de9c7d81f5 fix: replace pacman build with pkg.tar.zst and remove its deps, changed CF and Discord key mode 2026-01-25 14:45:45 +08:00
Fazri Gading
4c3277392e merge branch 'main' (lost 4 commits) into develop 2026-01-25 13:54:15 +08:00
sanasol
f287cb55b9 Merge remote-tracking branch 'origin/develop' into develop 2026-01-25 01:27:29 +01:00
sanasol
d87db04653 feat(patcher): Implement DualAuth patcher with enhanced server patching
- Introduce DualAuthPatcher with support for hybrid authentication
- Update default auth domain to `auth.sanasol.ws`
- Integrate Java detection and bundled JRE handling for patcher execution
- Add server patch flag for avoiding redundant patching
- Automate DualAuthPatcher setup: download, compile, and execute with dependencies
- Enhance patching logic for extended logging and modularity
2026-01-25 01:27:19 +01:00
AMIAY
67aa41aefe fix 2026-01-25 01:03:49 +01:00
AMIAY
bd1dd146a9 Merge branch 'develop' of https://github.com/amiayweb/Hytale-F2P into develop 2026-01-25 00:19:14 +01:00
AMIAY
c8d7707b70 need test - electron updater 2026-01-25 00:19:11 +01:00
xSamiVS
127c38f98b Update Spanish locale, add missing CurseForge API Key translation, implement Turkish translation, and fix contributor links comma. (#135)
* Update Spanish locale and add missing CurseForge API Key translation

- Updated the Spanish locale name to distinguish between multiple locale types.
- Added missing translation for the page indicating the missing CurseForge API Key.

* Implemented Turkish locale support

* Add Turkish locale to available languages

* Add missing comma in contributor links

* Correct Portuguese language name in available languages

---------

Co-authored-by: Fazri Gading <fazrigading@gmail.com>
2026-01-25 06:01:42 +08:00
AMIAY
f974d9c767 Update package-lock.json 2026-01-24 22:33:18 +01:00
Fazri Gading
7e4a45e466 Merge branch 'release' into develop 2026-01-25 05:24:08 +08:00
Fazri Gading
ea21fb15d6 fix: JRE retry button 2026-01-25 05:18:22 +08:00
sanasol
3d54cea9e7 feat(patcher): Support variable-length domains (4-16 chars)
- Add support for domains from 4 to 16 characters
- Domains <= 10 chars: direct replacement, subdomains stripped
- Domains 11-16 chars: split mode (first 6 chars -> subdomain prefix)
- Add length-prefixed byte format encoding for client binary
- Verify binary contents when checking if already patched
- Detect file updates and archive old backups with timestamps
- Fallback to legacy UTF-16LE format for older binaries
- Update patcher version to 2.0.0

Based on patching approach from Mollomm1/Hytale-EMULATOR

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 21:11:20 +01:00
AMIAY
9f43a32779 fix hardcoded branch + pre-release/release issue 2026-01-24 19:01:34 +01:00
Fazri Gading
9c8a12f25c fix: lastProgressTime variable init 2026-01-25 01:58:54 +08:00
Fazri Gading
a7d0523186 feat: auto-resume download process & auto-retry if disconnected (#143) 2026-01-25 01:36:20 +08:00
AMIAY
a6f716c61b removed getInstalledClientVersion 2026-01-24 13:44:56 +01:00
AMIAY
ca8ed171d1 removed overlay installation 2026-01-24 13:32:36 +01:00
AMIAY
679799c074 fix installation branch 2026-01-24 12:33:42 +01:00
AMIAY
87b168dd4c fix 2026-01-24 12:22:15 +01:00
AMIAY
679f065e24 delete updateManager 2026-01-24 12:09:54 +01:00
AMIAY
ecae7d2ee5 update 2026-01-24 12:06:45 +01:00
AMIAY
fa50fec34d Merge branch 'develop' of https://github.com/amiayweb/Hytale-F2P into develop 2026-01-24 12:05:24 +01:00
AMIAY
c900129c1f fix patch 2026-01-24 12:05:10 +01:00
AMIAY
6b75858515 Delete .env.example 2026-01-24 12:03:07 +01:00
AMIAY
61bcdf9413 curseforge api 2026-01-24 12:01:37 +01:00
AMIAY
411d7d8aaf fix 2026-01-24 12:00:01 +01:00
Fazri Gading
8a87c7c4d9 docs: add and adjust more info on readme 2026-01-24 16:35:42 +08:00
Fazri Gading
34f93e962b docs: adjusted github template & add new contributors name (#133)
* docs: add new contributors to the list

* docs: fix template and new adjustments

* docs: fix github template & add new contributors

* removed config.yml
2026-01-24 15:57:20 +08:00
AMIAY
d8393543df fixing 2026-01-24 02:49:21 +01:00
AMIAY
3579d82776 fix (to try) 2026-01-24 01:41:09 +01:00
Fazri Gading
e005b4293b docs: add footnotes and fixes 2026-01-24 04:10:54 +08:00
Fazri Gading
e43897f816 Draft Enhancement & Documentation for README.md
Needs some work on few TODO. Contributors PR are welcome.
2026-01-24 03:21:25 +08:00
AMIAY
3983fdb1bc pre-release & release game version [to check] 2026-01-23 17:54:57 +01:00
Fazri Gading
2a87acfe46 Final patch for release.yml v2.0.11 2026-01-23 22:46:31 +08:00
Fazri Gading
a2e2d5e5fd implemented late patch should be in #115 2026-01-23 22:10:35 +08:00
Fazri Gading
34143d9872 patch v2.0.11-beta: fix env issue in GA release, warn Intel Mac users, add com templates. (#115)
* fix: throw error for Intel Mac user
* docs: first draft of issue and PR template
* fix: env of curseforge API key and discord client ID
2026-01-23 21:30:27 +08:00
Fazri Gading
08c2218cf8 Merge branch 'feature/community-templates' into develop 2026-01-23 21:14:20 +08:00
Fazri Gading
032418b7f7 Merge branch 'fix/x86-mac-pwr-warning' into develop 2026-01-23 21:11:46 +08:00
Fazri Gading
fc05725a43 Merge pull request #114 from amiayweb/env-test
Merge v2.0.11-beta Build 2: env test & readme and server guide fix
2026-01-23 16:32:42 +08:00
Fazri Gading
203a56879f Update release.yml 2026-01-23 16:20:40 +08:00
Fazri Gading
7a0065ea2b Merge branch 'develop' of https://github.com/amiayweb/Hytale-F2P into develop 2026-01-23 14:48:23 +08:00
Fazri Gading
ac08eb50ff Merge #112: update README.md and merge PR #105
Update README.md and merge PR #105 to develop branch
2026-01-23 14:37:06 +08:00
Fazri Gading
70fe4203ef Merge branch 'develop' of https://github.com/amiayweb/Hytale-F2P into develop 2026-01-23 13:11:43 +08:00
Fazri Gading
f433120084 fix: adds EGL env var to detect installed NVIDIA GPU 2026-01-23 13:11:19 +08:00
AMIAY
f4099acbed Merge pull request #104 from chasem-dev/v2.0.11
V2.0.11 - Auto Updater
2026-01-23 02:04:15 +01:00
Fazri Gading
da843257c1 docs: first draft of issue and PR template 2026-01-23 06:00:36 +08:00
Fazri Gading
e4576042be fix: throw error for Intel Mac user 2026-01-23 05:57:21 +08:00
AMIAY
1ba6b22b74 fixed imgur restriction for UK 2026-01-22 20:40:28 +01:00
chasem-dev
a1bc88b754 Update package-lock.json to include new dependencies and versions, enhancing project stability and compatibility. 2026-01-22 14:07:04 -05:00
chasem-dev
24c2371b50 Add semantic versioning policy documentation - numerical versions only 2026-01-22 13:10:01 -05:00
chasem-dev
4c6e1a616e Merge upstream/develop into v2.0.11 - sync with main repository 2026-01-22 13:07:34 -05:00
chasem-dev
b54eb4e834 Remove outdated documentation files related to auto-updates, build instructions, and testing updates. Update dev-app-update.yml and package.json to reflect the correct GitHub owner. This cleanup streamlines the project and ensures accurate configuration for future updates. 2026-01-22 13:05:34 -05:00
chasem-dev
a1c74e4175 Fix: Remove portable target to fix SHA512 checksum mismatch
The portable and nsis targets both produced x64.exe files with the same
name, causing one to overwrite the other. The latest.yml contained the
checksum from one build while the actual file was from the other build.

Removed portable target - nsis installer is sufficient.
Bump version to 2.0.11

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-22 12:50:10 -05:00
chasem-dev
260e6c1126 Bump version to 2.0.10
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-22 11:53:34 -05:00
chasem-dev
6eb628559b Fix: Use explicit latest-linux.yml to prevent yml file collision
The glob pattern latest*.yml was matching both latest-linux.yml AND
latest.yml from the Linux build, causing the Windows latest.yml to be
overwritten with incorrect checksums.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-22 11:53:15 -05:00
chasem-dev
052b5dc7dc Bump version to 2.0.9 2026-01-22 11:30:59 -05:00
chasem-dev
7e9b5046df Bump version to 2.0.8 2026-01-22 11:13:01 -05:00
chasem-dev
204d6b21f6 Fix SHA512 checksum mismatch handling - clear cache and retry automatically 2026-01-22 11:12:59 -05:00
chasem-dev
740d516cfe Bump version to 2.0.7 2026-01-22 10:36:19 -05:00
chasem-dev
ce052add0d Fix update popup showing for same version - add version comparison checks 2026-01-22 10:36:18 -05:00
chasem-dev
d7a904c641 Bump version to 2.0.6 2026-01-22 10:18:33 -05:00
chasem-dev
d5d2f60c97 Bump version to 2.0.5 2026-01-22 10:18:30 -05:00
chasem-dev
61433bfeea Fix Linux metadata files in workflow and improve error handling 2026-01-22 10:18:28 -05:00
chasem-dev
9eb5d1759c Update publish config to point to chasem-dev fork 2026-01-22 09:11:10 -05:00
Arnav Singh
68d697576a feat: add 'Close launcher on game start' option and improve app termination behavior (#93)
* update main branch to release/v2.0.2b (#86)

* add more linux pkgs, create auto-release and pre-release feature for Github Actions

* removed package-lock from gitignore

* update .gitignore for local build

* add package-lock.json to maintain stability development

* update version to 2.0.2b also add deps for rpm and arch

* update 2.0.2b: add arm64 support, product and executable name, maintainers; remove snap;

* update 2.0.2b: add latest.yml for win & linux, arm64 support; remove snap

* fix release build naming

* Prepare release v2.0.2b

* feat: add 'Close launcher on game start' option and improve app termination behavior

- Added 'Close launcher on game start' setting in GUI and backend.
- Implemented automatic app quit after game launch if setting is enabled.
- Added Cmd+Q (Mac) and Ctrl+Q/Alt+F4 (Win/Linux) shortcuts to quit the app.
- Updated 'window-close' handler to fully quit the app instead of just closing the window.
- Added i18n support for the new setting in English, Spanish, and Portuguese.

---------

Co-authored-by: Fazri Gading <fazrigading@gmail.com>
Co-authored-by: Arnav Singh <hi.arnavsingh3@gmail.com>
2026-01-22 18:11:16 +08:00
Rahul Sahani
a8da559e93 Custom Mod loading fix (#92)
* feat: Add Repair Game functionality including UserData backup and cache clearing

* feat: Add In-App Logs Viewer and Logs Folder shortcut

* feat: Add Open Logs feature

* disable dev tools

* Fix Settings UI

* Implement custom mod loading, autoimport, auto repair

* Fixed Custom Mod loading issues and merge issues

* feat: Externalize sensitive API keys and Discord client ID into environment variables using dotenv.

* feat(mods): add profile-based mod management and auto-repair
2026-01-22 18:01:57 +08:00
AMIAY
75f9403888 Display launcher version in UI
Adds a version display element to the bottom right of the UI, fetching the version from package.json via a new IPC handler. Updates main.js, preload.js, and ui.js to support retrieving and displaying the version, and adds relevant styles in style.css.
2026-01-22 08:07:32 +01:00
AMIAY
b61c94d348 Add splash screen to launcher startup
Introduced a new splash screen (splash.html) and updated main.js to display it on startup before loading the main window. The splash screen is shown for 2.5 seconds as a placeholder for future loading logic, improving user experience during application launch.
2026-01-22 07:59:27 +01:00
AMIAY
c0109575d6 Adjust news card aspect ratio and add Play tab style
Set a default aspect ratio for .news-card and add a specific style for the LATEST NEWS section in the Play tab to override the aspect ratio and use full height.
2026-01-22 07:43:39 +01:00
AMIAY
2a024b61dd Add installation effects and draggable progress bar
Introduces animated installation effects overlay and makes the progress bar draggable. Adds maximize window support, improves window controls styling, and enforces a single app instance. Removes the unused Skins page and related translations. Refines  various UI details for a more polished user experience.
2026-01-22 07:41:35 +01:00
chasem-dev
1c39e8e4c6 Update auto-update UI and configuration
- Fix version display (newVersion field)
- Add download progress bar with real-time updates
- Reorder buttons: Install & Restart (primary), Manually Download (secondary)
- Update dev-app-update.yml to point to fork
- Update package.json version to 2.0.2
2026-01-22 00:26:46 -05:00
chasem-dev
753bd4fd61 Add cache clearing documentation for electron-updater
- Introduced CLEAR-UPDATE-CACHE.md to guide users on clearing the electron-updater cache across macOS, Windows, and Linux.
- Added programmatic method for cache clearing in JavaScript.
- Enhanced update handling in main.js and preload.js to support new update events.
- Updated GUI styles for download buttons and progress indicators in update.js and style.css.
2026-01-22 00:26:01 -05:00
chasem-dev
cefb4c5575 Add electron-updater auto-update support
- Install electron-updater package
- Configure GitHub releases publish settings
- Create AppUpdater class with full update lifecycle
- Integrate auto-update into main.js
- Add comprehensive documentation (AUTO-UPDATES.md, TESTING-UPDATES.md)
- Set up dev-app-update.yml for testing
2026-01-22 00:03:02 -05:00
46 changed files with 4810 additions and 1234 deletions

View File

@@ -1,2 +0,0 @@
CURSEFORGE_API_KEY=$1234asdxXXXXXXkQCXXXXXXXXXXASDb32
DISCORD_CLIENT_ID=561263XXXXXX

View File

@@ -3,6 +3,13 @@ description: Create a report to help us improve
title: "[BUG] "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Bug is a problem which impairs or prevents the functions of the launcher from working as intended.
Thanks for taking the time to fill out a bug report!
Please provide as much information as you can to help us understand and reproduce the issue.
- type: textarea
id: description
attributes:
@@ -43,8 +50,8 @@ body:
id: version
attributes:
label: Version
description: What version of the project are you running?
placeholder: "e.g. v1.2.3"
description: What version of the launcher are you running?
placeholder: "e.g. \"v2.0.11 stable/pre-release\""
validations:
required: true
@@ -54,29 +61,16 @@ body:
label: Operating System
description: What operating system are you using?
options:
- Windows
- macOS
- Linux
- iOS
- Android
- Other
- Windows 10
- Windows 11
- macOS (Apple Silicon)
- macOS (Intel)
- Linux Ubuntu/Debian-based
- Linux Fedora/RHEL-based
- Linux Arch-based
validations:
required: true
- type: dropdown
id: browser
attributes:
label: Browser (if applicable)
description: What browser are you using?
options:
- Chrome
- Firefox
- Safari
- Edge
- Opera
- Other
- N/A
- type: textarea
id: additional
attributes:

View File

@@ -16,8 +16,10 @@ body:
id: problem
attributes:
label: Is your feature request related to a problem? Please describe.
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
description: A clear and concise description of what the problem is.
placeholder: "Ex. I'm always frustrated when [...]"
validations:
required: true
- type: textarea
id: solution
@@ -34,6 +36,14 @@ body:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
placeholder: "Describe any alternative solutions or features you've considered."
validations:
required: true
- type: screenshots
id: screenshots
attributes:
label: Screenshots (Optional)
description: If applicable, add screenshots to help explain your request.
- type: textarea
id: additional

View File

@@ -0,0 +1,24 @@
name: New Translation Request
description: Request new language translation for text or content on the launcher
title: "[TRANSLATION REQUEST] "
labels: ["translation request"]
body:
- type: input
id: language
attributes:
label: Request New Language
description: What language do you want our launcher to support?
placeholder: "e.g. German (de-DE), Russian (ru-RU), etc."
validations:
required: true
- type: dropdown
id: contriution_willingness
attributes:
label: Willingness to Contribute
description: Are you willing to help with the translation effort?
options:
- Yes, I can help translate from English to the requested language!
- No, I just want to request the language.
validations:
required: true

View File

@@ -3,6 +3,13 @@ description: Request help or support
title: "[SUPPORT] "
labels: ["support"]
body:
- type: markdown
attributes:
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)!
- type: textarea
id: question
attributes:
@@ -17,14 +24,18 @@ body:
attributes:
label: Context
description: Provide any relevant context or background information.
placeholder: "I've tried..., I expected..., but got..."
placeholder: "I've tried..., but got..."
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: What version are you using?
placeholder: "e.g. v1.2.3"
placeholder: "e.g. v2.0.11 stable/pre-release"
validations:
required: true
- type: dropdown
id: platform
@@ -32,13 +43,15 @@ body:
label: Platform
description: What platform are you using?
options:
- Windows
- macOS
- Linux
- iOS
- Android
- Web Browser
- Other
- Windows 10
- Windows 11
- macOS (Apple Silicon)
- macOS (Intel)
- Linux Ubuntu/Debian-based
- Linux Fedora/RHEL-based
- Linux Arch-based
validations:
required: true
- type: textarea
id: logs
@@ -46,6 +59,8 @@ body:
label: Logs or Error Messages
description: If applicable, paste any error messages or logs here.
render: shell
validations:
required: true
- type: textarea
id: additional

View File

@@ -0,0 +1,41 @@
name: Translation Fix Request
description: Request a fix of translation for text or content in the launcher
title: "[TRANSLATION FIX] "
labels: ["translation fix"]
body:
- type: input
id: language
attributes:
label: Target Language
description: What language do you want to translate to?
placeholder: "e.g. Spanish (es-ES), Portuguese (pt-BR), etc."
validations:
required: true
- type: textarea
id: source_text
attributes:
label: Source Text
description: The original text that needs to be translated.
placeholder: "Paste the text here..."
validations:
required: true
- type: textarea
id: context
attributes:
label: Context
description: Provide context about where this text appears or how it's used.
placeholder: "This text appears in..., It's used for..."
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If applicable, add screenshots to help explain your problem.
- type: textarea
id: notes
attributes:
label: Additional Notes
description: Any specific instructions or notes for the translator.

View File

@@ -3,7 +3,7 @@ name: Build and Release
on:
push:
branches:
- release
- main
tags:
- 'v*'
workflow_dispatch:
@@ -13,7 +13,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# FIX Install bsdtar for Pacman builds
- name: Install build dependencies
run: |
sudo apt-get update
@@ -25,14 +24,6 @@ jobs:
cache: 'npm'
- run: npm ci
- name: Create .env file
env:
CF_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
DISCORD_ID: ${{ secrets.DISCORD_CLIENT_ID }}
run: |
echo "CURSEFORGE_API_KEY=$CF_KEY" > .env
echo "DISCORD_CLIENT_ID=$DISCORD_ID" >> .env
- name: Build Linux Packages
run: |
npx electron-builder --linux --x64 --arm64 --publish never
@@ -44,7 +35,7 @@ jobs:
dist/*.AppImage.blockmap
dist/*.deb
dist/*.rpm
dist/*.pacman
dist/*.pkg.tar.zst
dist/latest-linux.yml
build-windows:
@@ -57,14 +48,6 @@ jobs:
cache: 'npm'
- run: npm ci
- name: Create .env file
env:
CF_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
DISCORD_ID: ${{ secrets.DISCORD_CLIENT_ID }}
run: |
echo "CURSEFORGE_API_KEY=$CF_KEY" > .env
echo "DISCORD_CLIENT_ID=$DISCORD_ID" >> .env
- name: Build Windows Packages
run: npx electron-builder --win --publish never
- uses: actions/upload-artifact@v4
@@ -85,15 +68,7 @@ jobs:
cache: 'npm'
- run: npm ci
- name: Create .env file
env:
CF_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
DISCORD_ID: ${{ secrets.DISCORD_CLIENT_ID }}
run: |
echo "CURSEFORGE_API_KEY=$CF_KEY" > .env
echo "DISCORD_CLIENT_ID=$DISCORD_ID" >> .env
- name: Build Windows Packages
- name: Build macOS Packages
run: npx electron-builder --mac --publish never
- uses: actions/upload-artifact@v4
with:
@@ -108,7 +83,7 @@ jobs:
runs-on: ubuntu-latest
if: |
startsWith(github.ref, 'refs/tags/v') ||
github.ref == 'refs/heads/release' ||
github.ref == 'refs/heads/main' ||
github.event_name == 'workflow_dispatch'
permissions:
@@ -137,12 +112,12 @@ jobs:
# If it's a tag, use the tag.
tag_name: ${{ github.ref_type == 'tag' && github.ref_name || format('v{0}.r{1}', steps.pkg_version.outputs.VERSION, github.run_number) }}
# If it's the 'release' branch, use 'v2.0.2-beta.r42'
name: ${{ github.ref_type == 'tag' && github.ref_name || format('v{0}-beta.r{1}', steps.pkg_version.outputs.VERSION, github.run_number) }}
# name: ${{ github.ref_type == 'tag' && github.ref_name || format('v{0}-beta.r{1}', steps.pkg_version.outputs.VERSION, github.run_number) }}
files: |
artifacts/linux-builds/**/*
artifacts/windows-builds/**/*
artifacts/macos-builds/**/*
generate_release_notes: true
draft: true
# DYNAMIC FLAGS: Mark as pre-release ONLY IF it's NOT a tag (meaning it's a branch push)
prerelease: ${{ github.ref_type != 'tag' }}
prerelease: false

35
.gitignore vendored
View File

@@ -1,14 +1,23 @@
dist/*
node_modules/*
bun.lock
# Build artifacts
src/
pkg/
# Package files
*.tar.zst
*.zst.DS_Store
*.zst
bun.lockb
# General / Node
node_modules/
dist/
.env
# Arch Linux / makepkg: Ignore folders created when running 'makepkg' locally
/src/
/pkg/
# Built packages: {revents committing large binaries
*.pkg.tar.zst
*.pkg.tar.xz
# Source downloads used by PKGBUILD
*.src.tar.gz
# Project Specific: Downloaded patcher (from hytale-auth-server)
backend/patcher/
# macOS Specific
.DS_Store
*.zst.DS_Store
bun.lock

View File

@@ -123,6 +123,29 @@
value="Player" />
</div>
<div class="form-group">
<label class="form-label" data-i18n="install.gameBranch">Game Version</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="installBranch" value="release" class="custom-radio"
checked>
<span class="radio-text">
<i class="fas fa-check-circle mr-2"></i>
<span data-i18n="install.releaseVersion">Release (Stable)</span>
</span>
</label>
<label class="radio-label">
<input type="radio" name="installBranch" value="pre-release"
class="custom-radio">
<span class="radio-text">
<i class="fas fa-flask mr-2"></i>
<span data-i18n="install.preReleaseVersion">Pre-Release
(Experimental)</span>
</span>
</label>
</div>
</div>
<div class="form-group">
<label class="checkbox-group">
<input type="checkbox" id="installCustomCheck" class="custom-checkbox">
@@ -337,14 +360,40 @@
</div>
</button>
</div>
</div>
<div class="settings-option">
<div class="settings-input-group">
<label class="settings-input-label" data-i18n="settings.gameBranch">Game
Branch</label>
<div class="segmented-control">
<input type="radio" id="branch-release" name="gameBranch"
value="release" checked>
<label for="branch-release"
data-i18n="settings.branchRelease">Release</label>
<input type="radio" id="branch-pre-release" name="gameBranch"
value="pre-release">
<label for="branch-pre-release"
data-i18n="settings.branchPreRelease">Pre-Release</label>
</div>
<p class="settings-hint">
<i class="fas fa-info-circle"></i>
<span data-i18n="settings.branchHint">Switch between stable release and
experimental pre-release versions</span>
</p>
<p class="settings-hint" style="color: #f39c12;">
<i class="fas fa-exclamation-triangle"></i>
<span data-i18n="settings.branchWarning">Changing branch will download
and install a different game version</span>
</p>
</div>
</div>
<div class="settings-option">
<label class="settings-input-label" data-i18n="settings.gpuPreference">GPU
Preference</label>
<div class="segmented-control">
<input type="radio" id="gpu-auto" name="gpuPreference" value="auto"
checked>
<input type="radio" id="gpu-auto" name="gpuPreference" value="auto" checked>
<label for="gpu-auto" data-i18n="settings.gpuAuto">Auto</label>
<input type="radio" id="gpu-integrated" name="gpuPreference"
value="integrated">
@@ -422,8 +471,8 @@
<div class="checkbox-content">
<div class="checkbox-title" data-i18n="settings.enableRPC">Enable
Discord Rich Presence</div>
<div class="checkbox-description"
data-i18n="settings.discordDescription">Show your launcher activity
<div class="checkbox-description" data-i18n="settings.discordDescription">
Show your launcher activity
on Discord
</div>
</div>
@@ -442,13 +491,28 @@
<input type="checkbox" id="closeLauncherCheck" />
<span class="checkmark"></span>
<div class="checkbox-content">
<div class="checkbox-title" data-i18n="settings.closeOnStart">Close Launcher on game start</div>
<div class="checkbox-description" data-i18n="settings.closeOnStartDescription">
<div class="checkbox-title" data-i18n="settings.closeOnStart">Close Launcher
on game start</div>
<div class="checkbox-description"
data-i18n="settings.closeOnStartDescription">
Automatically close the launcher after Hytale has launched
</div>
</div>
</label>
</div>
<div class="settings-option">
<label class="settings-checkbox">
<input type="checkbox" id="launcherHwAccelCheck" />
<span class="checkmark"></span>
<div class="checkbox-content">
<div class="checkbox-title" data-i18n="settings.hwAccel">Launcher Hardware
Acceleration</div>
<div class="checkbox-description" data-i18n="settings.hwAccelDescription">
Enable hardware acceleration for the launcher UI (Requires restart)
</div>
</div>
</label>
</div>
</div>
@@ -501,8 +565,8 @@
<div class="settings-option">
<div class="settings-input-group">
<label class="settings-input-label"
data-i18n="settings.selectLanguage">Select Language</label>
<label class="settings-input-label" data-i18n="settings.selectLanguage">Select
Language</label>
<select id="languageSelect" class="settings-input">
<!-- Options populated by i18n.js -->
</select>
@@ -575,20 +639,23 @@
<span id="progressSpeed"></span>
<span id="progressSize"></span>
</div>
<div id="progressErrorContainer" class="progress-error-container" style="display: none;">
<div id="progressErrorMessage" class="progress-error-message"></div>
<div class="progress-retry-section">
<span id="progressRetryInfo" class="progress-retry-info"></span>
<div class="progress-retry-buttons">
<button id="progressJRRetryBtn" class="progress-retry-btn" style="display: none;">
Retry Java Download
</button>
<button id="progressPWRRetryBtn" class="progress-retry-btn" style="display: none;">
Retry Game Download
</button>
<button id="progressRetryBtn" class="progress-retry-btn" style="display: none;">
Retry Download
</button>
</div>
</div>
</div>
<!-- Installation effects overlay -->
<div id="installationEffects" class="installation-effects" style="display: none;">
<div class="space-effects">
<div class="warp-line"></div>
<div class="warp-line"></div>
<div class="warp-line"></div>
<div class="warp-line"></div>
<div class="warp-line"></div>
<div class="warp-line"></div>
<div class="warp-line"></div>
<div class="warp-line"></div>
</div>
</div>
@@ -742,7 +809,11 @@
<a href="https://github.com/ericiskoolbeans" target="_blank"
class="text-blue-400 hover:text-blue-300 transition-colors">@ericiskoolbeans</a>,
<a href="https://github.com/fazrigading" target="_blank"
class="text-blue-400 hover:text-blue-300 transition-colors">@fazrigading</a>
class="text-blue-400 hover:text-blue-300 transition-colors">@fazrigading</a>,
<a href="https://github.com/Rahul-Sahani04" target="_blank"
class="text-blue-400 hover:text-blue-300 transition-colors">@Rahul-Sahani04</a>,
<a href="https://github.com/xSamiVS" target="_blank"
class="text-blue-400 hover:text-blue-300 transition-colors">@xSamiVS</a>
</span>
</div>
</footer>
@@ -809,7 +880,9 @@
</div>
<script src="js/i18n.js"></script>
<script type="module" src="js/settings.js"></script>
<script type="module" src="js/update.js"></script>
<script src="js/updater.js"></script>
</body>

View File

@@ -4,8 +4,9 @@ const i18n = (() => {
let translations = {};
const availableLanguages = [
{ code: 'en', name: 'English' },
{ code: 'es', name: 'Español' },
{ code: 'pt-BR', name: 'Português (Brasil)' }
{ code: 'es-ES', name: 'Español (España)' },
{ code: 'pt-BR', name: 'Portuguese (Brazil)' },
{ code: 'tr-TR', name: 'Turkish (Turkey)' }
];
// Load single language file

View File

@@ -40,18 +40,6 @@ export function setupInstallation() {
});
}
// Setup installation effects listeners
if (window.electronAPI && window.electronAPI.onInstallationStart) {
window.electronAPI.onInstallationStart(() => {
showInstallationEffects();
});
}
if (window.electronAPI && window.electronAPI.onInstallationEnd) {
window.electronAPI.onInstallationEnd(() => {
hideInstallationEffects();
});
}
}
export async function installGame() {
@@ -60,8 +48,14 @@ export async function installGame() {
const playerName = (installPlayerName ? installPlayerName.value.trim() : '') || 'Player';
const installPath = installPathInput ? installPathInput.value.trim() : '';
const selectedBranchRadio = document.querySelector('input[name="installBranch"]:checked');
const selectedBranch = selectedBranchRadio ? selectedBranchRadio.value : 'release';
console.log(`[Install] Installing game with branch: ${selectedBranch}`);
if (window.LauncherUI) window.LauncherUI.showProgress();
isDownloading = true;
lockInstallForm();
if (installBtn) {
installBtn.disabled = true;
installText.textContent = window.i18n ? window.i18n.t('install.installing') : 'INSTALLING...';
@@ -69,7 +63,7 @@ export async function installGame() {
try {
if (window.electronAPI && window.electronAPI.installGame) {
const result = await window.electronAPI.installGame(playerName, '', installPath);
const result = await window.electronAPI.installGame(playerName, '', installPath, selectedBranch);
if (result.success) {
const successMsg = window.i18n ? window.i18n.t('progress.installationComplete') : 'Installation completed successfully!';
@@ -92,12 +86,7 @@ export async function installGame() {
} catch (error) {
const errorMsg = window.i18n ? window.i18n.t('progress.installationFailed').replace('{error}', error.message) : `Installation failed: ${error.message}`;
// Hide installation effects on error
if (window.hideInstallationEffects) {
window.hideInstallationEffects();
}
// Reset button state on error
// Reset button state and unlock form on error
resetInstallButton();
if (window.LauncherUI) {
@@ -152,6 +141,35 @@ function resetInstallButton() {
installBtn.disabled = false;
installText.textContent = 'INSTALL HYTALE';
}
unlockInstallForm();
}
function lockInstallForm() {
const playerNameInput = document.getElementById('installPlayerName');
const installPathInput = document.getElementById('installPath');
const customCheckbox = document.getElementById('installCustomCheck');
const branchRadios = document.querySelectorAll('input[name="installBranch"]');
const browseBtn = document.querySelector('.browse-btn');
if (playerNameInput) playerNameInput.disabled = true;
if (installPathInput) installPathInput.disabled = true;
if (customCheckbox) customCheckbox.disabled = true;
if (browseBtn) browseBtn.disabled = true;
branchRadios.forEach(radio => radio.disabled = true);
}
function unlockInstallForm() {
const playerNameInput = document.getElementById('installPlayerName');
const installPathInput = document.getElementById('installPath');
const customCheckbox = document.getElementById('installCustomCheck');
const branchRadios = document.querySelectorAll('input[name="installBranch"]');
const browseBtn = document.querySelector('.browse-btn');
if (playerNameInput) playerNameInput.disabled = false;
if (installPathInput) installPathInput.disabled = false;
if (customCheckbox) customCheckbox.disabled = false;
if (browseBtn) browseBtn.disabled = false;
branchRadios.forEach(radio => radio.disabled = false);
}
export async function browseInstallPath() {

View File

@@ -1,5 +1,5 @@
let API_KEY = null;
let API_KEY = "$2a$10$bqk254NMZOWVTzLVJCcxEOmhcyUujKxA5xk.kQCN9q0KNYFJd5b32";
const CURSEFORGE_API = 'https://api.curseforge.com/v1';
const HYTALE_GAME_ID = 70216;
@@ -13,7 +13,6 @@ let modsTotalPages = 1;
export async function initModsManager() {
try {
if (window.electronAPI && window.electronAPI.getEnvVar) {
API_KEY = await window.electronAPI.getEnvVar('CURSEFORGE_API_KEY');
console.log('Loaded API Key:', API_KEY ? 'Yes' : 'No');
}
} catch (err) {
@@ -201,10 +200,15 @@ async function loadBrowseMods() {
browseContainer.innerHTML = `
<div class=\"empty-browse-mods\">
<i class=\"fas fa-key\"></i>
<h4>API Key Required</h4>
<p>CurseForge API key is needed to browse mods</p>
<h4 data-i18n="mods.apiKeyRequired">API Key Required</h4>
<p data-i18n="mods.apiKeyRequiredDesc">CurseForge API key is needed to browse mods</p>
</div>
`;
if (window.i18n) {
const container = modsContainer.querySelector('.empty-browse-mods');
container.querySelector('h4').textContent = window.i18n.t('mods.apiKeyRequired');
container.querySelector('p').textContent = window.i18n.t('mods.apiKeyRequiredDesc');
}
return;
}

View File

@@ -6,7 +6,9 @@ let browseJavaBtn;
let settingsPlayerName;
let discordRPCCheck;
let closeLauncherCheck;
let launcherHwAccelCheck;
let gpuPreferenceRadios;
let gameBranchRadios;
// UUID Management elements
@@ -151,9 +153,9 @@ function showCustomConfirm(message, title, onConfirm, onCancel = null, confirmTe
}
export function initSettings() {
export async function initSettings() {
setupSettingsElements();
loadAllSettings();
await loadAllSettings();
}
function setupSettingsElements() {
@@ -164,7 +166,11 @@ function setupSettingsElements() {
settingsPlayerName = document.getElementById('settingsPlayerName');
discordRPCCheck = document.getElementById('discordRPCCheck');
closeLauncherCheck = document.getElementById('closeLauncherCheck');
launcherHwAccelCheck = document.getElementById('launcherHwAccelCheck');
gpuPreferenceRadios = document.querySelectorAll('input[name="gpuPreference"]');
gameBranchRadios = document.querySelectorAll('input[name="gameBranch"]');
console.log('[Settings] gameBranchRadios found:', gameBranchRadios.length);
// UUID Management elements
@@ -202,6 +208,10 @@ function setupSettingsElements() {
closeLauncherCheck.addEventListener('change', saveCloseLauncher);
}
if (launcherHwAccelCheck) {
launcherHwAccelCheck.addEventListener('change', saveLauncherHwAccel);
}
// UUID event listeners
if (copyUuidBtn) {
@@ -252,6 +262,12 @@ function setupSettingsElements() {
});
});
}
if (gameBranchRadios) {
gameBranchRadios.forEach(radio => {
radio.addEventListener('change', handleBranchChange);
});
}
}
function toggleCustomJava() {
@@ -381,6 +397,35 @@ async function loadCloseLauncher() {
}
}
async function saveLauncherHwAccel() {
try {
if (window.electronAPI && window.electronAPI.saveLauncherHardwareAcceleration && launcherHwAccelCheck) {
const enabled = launcherHwAccelCheck.checked;
await window.electronAPI.saveLauncherHardwareAcceleration(enabled);
const msg = window.i18n ? window.i18n.t('notifications.hwAccelSaved') : 'Setting saved. Please restart the launcher to apply changes.';
showNotification(msg, 'success');
}
} catch (error) {
console.error('Error saving hardware acceleration setting:', error);
const msg = window.i18n ? window.i18n.t('notifications.hwAccelSaveFailed') : 'Failed to save setting';
showNotification(msg, 'error');
}
}
async function loadLauncherHwAccel() {
try {
if (window.electronAPI && window.electronAPI.loadLauncherHardwareAcceleration) {
const enabled = await window.electronAPI.loadLauncherHardwareAcceleration();
if (launcherHwAccelCheck) {
launcherHwAccelCheck.checked = enabled;
}
}
} catch (error) {
console.error('Error loading hardware acceleration setting:', error);
}
}
async function savePlayerName() {
try {
@@ -497,7 +542,9 @@ async function loadAllSettings() {
await loadCurrentUuid();
await loadDiscordRPC();
await loadCloseLauncher();
await loadLauncherHwAccel();
await loadGpuPreference();
await loadVersionBranch();
}
@@ -532,7 +579,8 @@ document.addEventListener('DOMContentLoaded', initSettings);
window.SettingsAPI = {
getCurrentJavaPath,
getCurrentPlayerName
getCurrentPlayerName,
reloadBranch: loadVersionBranch
};
async function loadCurrentUuid() {
@@ -891,4 +939,198 @@ function showNotification(message, type = 'info') {
}
}, 300);
}, 3000);
}// Append this to settings.js for branch management
// === Game Branch Management ===
async function handleBranchChange(event) {
const newBranch = event.target.value;
const currentBranch = await loadVersionBranch();
if (newBranch === currentBranch) {
return; // No change
}
// Confirm branch change
const branchName = window.i18n ?
window.i18n.t(`settings.branch${newBranch === 'pre-release' ? 'PreRelease' : 'Release'}`) :
newBranch;
const message = window.i18n ?
window.i18n.t('settings.branchWarning') :
'Changing branch will download and install a different game version';
showCustomConfirm(
message,
window.i18n ? window.i18n.t('settings.gameBranch') : 'Game Branch',
async () => {
await switchBranch(newBranch);
},
() => {
// Cancel: revert radio selection
loadVersionBranch().then(branch => {
const radioToCheck = document.querySelector(`input[name="gameBranch"][value="${branch}"]`);
if (radioToCheck) {
radioToCheck.checked = true;
}
});
}
);
}
async function switchBranch(newBranch) {
try {
const switchingMsg = window.i18n ?
window.i18n.t('settings.branchSwitching').replace('{branch}', newBranch) :
`Switching to ${newBranch}...`;
showNotification(switchingMsg, 'info');
// Lock play button
const playButton = document.getElementById('playButton');
if (playButton) {
playButton.disabled = true;
playButton.classList.add('disabled');
}
// DON'T save branch yet - wait for installation confirmation
// Suggest reinstalling
setTimeout(() => {
const branchLabel = newBranch === 'release' ?
(window.i18n ? window.i18n.t('install.releaseVersion') : 'Release') :
(window.i18n ? window.i18n.t('install.preReleaseVersion') : 'Pre-Release');
const confirmMsg = window.i18n ?
window.i18n.t('settings.branchInstallConfirm').replace('{branch}', branchLabel) :
`The game will be installed for the ${branchLabel} branch. Continue?`;
showCustomConfirm(
confirmMsg,
window.i18n ? window.i18n.t('settings.installRequired') : 'Installation Required',
async () => {
// Show progress and trigger game installation
if (window.LauncherUI) {
window.LauncherUI.showProgress();
}
try {
const playerName = await window.electronAPI.loadUsername();
const result = await window.electronAPI.installGame(playerName || 'Player', '', '', newBranch);
if (result.success) {
// Save branch ONLY after successful installation
await window.electronAPI.saveVersionBranch(newBranch);
const switchedMsg = window.i18n ?
window.i18n.t('settings.branchSwitched').replace('{branch}', newBranch) :
`Switched to ${newBranch} successfully!`;
const successMsg = window.i18n ?
window.i18n.t('progress.installationComplete') :
'Installation completed successfully!';
showNotification(switchedMsg, 'success');
showNotification(successMsg, 'success');
// Refresh radio buttons to reflect the new branch
await loadVersionBranch();
console.log('[Settings] Radio buttons updated after branch switch');
setTimeout(() => {
if (window.LauncherUI) {
window.LauncherUI.hideProgress();
}
// Unlock play button
const playButton = document.getElementById('playButton');
if (playButton) {
playButton.disabled = false;
playButton.classList.remove('disabled');
}
}, 2000);
} else {
throw new Error(result.error || 'Installation failed');
}
} catch (error) {
console.error('Installation error:', error);
const errorMsg = window.i18n ?
window.i18n.t('progress.installationFailed').replace('{error}', error.message) :
`Installation failed: ${error.message}`;
showNotification(errorMsg, 'error');
if (window.LauncherUI) {
window.LauncherUI.hideProgress();
}
// Revert radio selection to old branch
loadVersionBranch().then(oldBranch => {
const radioToCheck = document.querySelector(`input[name="gameBranch"][value="${oldBranch}"]`);
if (radioToCheck) {
radioToCheck.checked = true;
}
});
// Unlock play button
const playButton = document.getElementById('playButton');
if (playButton) {
playButton.disabled = false;
playButton.classList.remove('disabled');
}
}
},
() => {
// Cancel - unlock play button
const playButton = document.getElementById('playButton');
if (playButton) {
playButton.disabled = false;
playButton.classList.remove('disabled');
}
},
window.i18n ? window.i18n.t('common.install') : 'Install',
window.i18n ? window.i18n.t('common.cancel') : 'Cancel'
);
}, 500);
} catch (error) {
console.error('Error switching branch:', error);
showNotification(`Failed to switch branch: ${error.message}`, 'error');
// Revert radio selection
loadVersionBranch().then(branch => {
const radioToCheck = document.querySelector(`input[name="gameBranch"][value="${branch}"]`);
if (radioToCheck) {
radioToCheck.checked = true;
}
});
}
}
async function loadVersionBranch() {
try {
if (window.electronAPI && window.electronAPI.loadVersionBranch) {
const branch = await window.electronAPI.loadVersionBranch();
console.log('[Settings] Loaded version_branch from config:', branch);
// Use default if branch is null/undefined
const selectedBranch = branch || 'release';
console.log('[Settings] Selected branch:', selectedBranch);
// Update radio buttons
if (gameBranchRadios && gameBranchRadios.length > 0) {
gameBranchRadios.forEach(radio => {
radio.checked = radio.value === selectedBranch;
console.log(`[Settings] Radio ${radio.value}: ${radio.checked ? 'checked' : 'unchecked'}`);
});
} else {
console.warn('[Settings] gameBranchRadios not found or empty');
}
return selectedBranch;
}
return 'release'; // Default
} catch (error) {
console.error('Error loading version branch:', error);
return 'release';
}
}

View File

@@ -6,6 +6,24 @@ let progressText;
let progressPercent;
let progressSpeed;
let progressSize;
let progressErrorContainer;
let progressErrorMessage;
let progressRetryInfo;
let progressRetryBtn;
let progressJRRetryBtn;
let progressPWRRetryBtn;
// Download retry state
let currentDownloadState = {
isDownloading: false,
canRetry: false,
retryData: null,
lastError: null,
errorType: null,
branch: null,
fileName: null,
cacheDir: null
};
function showPage(pageId) {
const pages = document.querySelectorAll('.page');
@@ -13,6 +31,15 @@ function showPage(pageId) {
if (page.id === pageId) {
page.classList.add('active');
page.style.display = '';
// Reload settings when settings page becomes visible
if (pageId === 'settings-page') {
console.log('[UI] Settings page activated, reloading branch...');
// Dynamically import and call loadVersionBranch from settings
if (window.SettingsAPI && window.SettingsAPI.reloadBranch) {
window.SettingsAPI.reloadBranch();
}
}
} else {
page.classList.remove('active');
page.style.display = 'none';
@@ -144,6 +171,12 @@ function hideProgress() {
}
function updateProgress(data) {
// Handle retry state
if (data.retryState) {
currentDownloadState.retryData = data.retryState;
updateRetryState(data.retryState);
}
if (data.message && progressText) {
progressText.textContent = data.message;
}
@@ -162,6 +195,120 @@ function updateProgress(data) {
if (progressSpeed) progressSpeed.textContent = `${speedMB} MB/s`;
if (progressSize) progressSize.textContent = `${downloadedMB} / ${totalMB} MB`;
}
// Handle error states with enhanced categorization
// Don't show error during automatic retries - let the retry message display instead
if ((data.error || (data.message && data.message.includes('failed'))) &&
!(data.retryState && data.retryState.isAutomaticRetry)) {
const errorType = categorizeError(data.message);
console.log('[UI] Showing download error:', { message: data.message, canRetry: data.canRetry, errorType });
showDownloadError(data.message, data.canRetry, errorType, data);
} else if (data.percent === 100) {
hideDownloadError();
} else if (data.retryState && data.retryState.isAutomaticRetry) {
// Hide any existing error during automatic retries
hideDownloadError();
}
}
function updateRetryState(retryState) {
if (!progressRetryInfo) return;
if (retryState.isAutomaticRetry && retryState.automaticStallRetries > 0) {
// Show automatic stall retry count
progressRetryInfo.textContent = `Auto-retry ${retryState.automaticStallRetries}/3`;
progressRetryInfo.style.display = 'block';
progressRetryInfo.style.background = 'rgba(255, 193, 7, 0.2)'; // Light orange background for auto-retries
progressRetryInfo.style.color = '#ff9800'; // Orange text for auto-retries
} else if (retryState.attempts > 1) {
// Show manual retry count
progressRetryInfo.textContent = `Attempt ${retryState.attempts}/${retryState.maxRetries}`;
progressRetryInfo.style.display = 'block';
progressRetryInfo.style.background = ''; // Reset background
progressRetryInfo.style.color = ''; // Reset color
} else {
progressRetryInfo.style.display = 'none';
progressRetryInfo.style.background = ''; // Reset background
progressRetryInfo.style.color = ''; // Reset color
}
}
function showDownloadError(errorMessage, canRetry = true, errorType = 'general', data = null) {
if (!progressErrorContainer || !progressErrorMessage) return;
console.log('[UI] showDownloadError called with:', { errorMessage, canRetry, errorType, data });
console.log('[UI] Data properties:', {
hasData: !!data,
hasRetryData: !!(data && data.retryData),
dataErrorType: data && data.errorType,
dataIsJREError: data && data.retryData && data.retryData.isJREError
});
currentDownloadState.lastError = errorMessage;
currentDownloadState.canRetry = canRetry;
currentDownloadState.errorType = errorType;
// Update retry context if available
if (data && data.retryData) {
currentDownloadState.branch = data.retryData.branch;
currentDownloadState.fileName = data.retryData.fileName;
currentDownloadState.cacheDir = data.retryData.cacheDir;
// Override errorType if specified in data
if (data.errorType) {
currentDownloadState.errorType = data.errorType;
}
}
// Hide all retry buttons first
if (progressRetryBtn) progressRetryBtn.style.display = 'none';
if (progressJRRetryBtn) progressJRRetryBtn.style.display = 'none';
if (progressPWRRetryBtn) progressPWRRetryBtn.style.display = 'none';
// User-friendly error messages
const userMessage = getErrorMessage(errorMessage, errorType);
progressErrorMessage.textContent = userMessage;
progressErrorContainer.style.display = 'block';
// Show appropriate retry button based on error type
if (canRetry) {
if (errorType === 'jre') {
if (progressJRRetryBtn) {
console.log('[UI] Showing JRE retry button');
progressJRRetryBtn.style.display = 'block';
}
} else {
// All other errors use PWR retry button (game download, butler, etc.)
if (progressPWRRetryBtn) {
console.log('[UI] Showing PWR retry button');
progressPWRRetryBtn.style.display = 'block';
}
}
}
// Add visual indicators based on error type
progressErrorContainer.className = `progress-error-container error-${errorType}`;
if (progressOverlay) {
progressOverlay.classList.add('error-state');
}
}
function hideDownloadError() {
if (!progressErrorContainer) return;
// Hide all retry buttons
if (progressRetryBtn) progressRetryBtn.style.display = 'none';
if (progressJRRetryBtn) progressJRRetryBtn.style.display = 'none';
if (progressPWRRetryBtn) progressPWRRetryBtn.style.display = 'none';
progressErrorContainer.style.display = 'none';
currentDownloadState.canRetry = false;
currentDownloadState.lastError = null;
currentDownloadState.errorType = null;
if (progressOverlay) {
progressOverlay.classList.remove('error-state');
}
}
function setupAnimations() {
@@ -478,10 +625,19 @@ function setupUI() {
progressPercent = document.getElementById('progressPercent');
progressSpeed = document.getElementById('progressSpeed');
progressSize = document.getElementById('progressSize');
progressErrorContainer = document.getElementById('progressErrorContainer');
progressErrorMessage = document.getElementById('progressErrorMessage');
progressRetryInfo = document.getElementById('progressRetryInfo');
progressRetryBtn = document.getElementById('progressRetryBtn');
progressJRRetryBtn = document.getElementById('progressJRRetryBtn');
progressPWRRetryBtn = document.getElementById('progressPWRRetryBtn');
// Setup draggable progress bar
setupProgressDrag();
// Setup retry button
setupRetryButton();
lockPlayButton(true);
setTimeout(() => {
@@ -501,6 +657,10 @@ function setupUI() {
setupAnimations();
setupFirstLaunchHandlers();
loadLauncherVersion();
checkGameInstallation().catch(err => {
console.error('Critical error in checkGameInstallation:', err);
lockPlayButton(false);
});
document.body.focus();
}
@@ -520,6 +680,53 @@ async function loadLauncherVersion() {
}
}
// Check game installation status on startup
async function checkGameInstallation() {
try {
console.log('Checking game installation status...');
// Verify electronAPI is available
if (!window.electronAPI || !window.electronAPI.isGameInstalled) {
console.error('electronAPI not available, unlocking play button as fallback');
lockPlayButton(false);
return;
}
// Check if game is installed
const isInstalled = await window.electronAPI.isGameInstalled();
// Load version_client from config
let versionClient = null;
if (window.electronAPI.loadVersionClient) {
versionClient = await window.electronAPI.loadVersionClient();
}
console.log(`Game installed: ${isInstalled}, version_client: ${versionClient}`);
lockPlayButton(false);
// If version_client is null and game is not installed, show install page
if (versionClient === null && !isInstalled) {
console.log('Game not installed and version_client is null, showing install page...');
// Show installation page
const installPage = document.getElementById('install-page');
const launcher = document.getElementById('launcher-container');
const sidebar = document.querySelector('.sidebar');
if (installPage) {
installPage.style.display = 'block';
if (launcher) launcher.style.display = 'none';
if (sidebar) sidebar.style.pointerEvents = 'none';
}
}
} catch (error) {
console.error('Error checking game installation:', error);
// Unlock on error to prevent permanent lock
lockPlayButton(false);
}
}
window.LauncherUI = {
showPage,
setActiveNav,
@@ -530,8 +737,7 @@ window.LauncherUI = {
};
// Make installation effects globally available
window.showInstallationEffects = showInstallationEffects;
window.hideInstallationEffects = hideInstallationEffects;
// Draggable progress bar functionality
function setupProgressDrag() {
@@ -591,21 +797,6 @@ function setupProgressDrag() {
}
}
// Show/hide installation effects
function showInstallationEffects() {
const installationEffects = document.getElementById('installationEffects');
if (installationEffects) {
installationEffects.style.display = 'block';
}
}
function hideInstallationEffects() {
const installationEffects = document.getElementById('installationEffects');
if (installationEffects) {
installationEffects.style.display = 'none';
}
}
// Toggle maximize/restore window function
function toggleMaximize() {
if (window.electronAPI && window.electronAPI.maximizeWindow) {
@@ -613,6 +804,302 @@ function toggleMaximize() {
}
}
// Error categorization and user-friendly messages
function categorizeError(message) {
const msg = message.toLowerCase();
if (msg.includes('network') || msg.includes('connection') || msg.includes('offline')) {
return 'network';
} else if (msg.includes('stalled') || msg.includes('timeout')) {
return 'stall';
} else if (msg.includes('file') || msg.includes('disk')) {
return 'file';
} else if (msg.includes('permission') || msg.includes('access')) {
return 'permission';
} else if (msg.includes('server') || msg.includes('5')) {
return 'server';
} else if (msg.includes('corrupted') || msg.includes('pwr file') || msg.includes('unexpected eof')) {
return 'corruption';
} else if (msg.includes('butler') || msg.includes('patch installation')) {
return 'butler';
} else if (msg.includes('space') || msg.includes('full') || msg.includes('device full')) {
return 'space';
} else if (msg.includes('conflict') || msg.includes('already exists')) {
return 'conflict';
} else if (msg.includes('jre') || msg.includes('java runtime')) {
return 'jre';
} else {
return 'general';
}
}
function getErrorMessage(technicalMessage, errorType) {
// Technical errors go to console, user gets friendly messages
console.error(`Download error [${errorType}]:`, technicalMessage);
switch (errorType) {
case 'network':
return 'Network connection lost. Please check your internet connection and retry.';
case 'stall':
return 'Download stalled due to slow connection. Please retry.';
case 'file':
return 'Unable to save file. Check disk space and permissions. Please retry.';
case 'permission':
return 'Permission denied. Check if launcher has write access. Please retry.';
case 'server':
return 'Server error. Please wait a moment and retry.';
case 'corruption':
return 'Corrupted PWR file detected. File deleted and will retry.';
case 'butler':
return 'Patch installation failed. Please retry.';
case 'space':
return 'Insufficient disk space. Free up space and retry.';
case 'conflict':
return 'Installation directory conflict. Please retry.';
case 'jre':
return 'Java runtime download failed. Please retry.';
default:
return 'Download failed. Please retry.';
}
}
// Connection quality indicator (simplified)
function updateConnectionQuality(quality) {
if (!progressSize) return;
const qualityColors = {
'Good': '#10b981',
'Fair': '#fbbf24',
'Poor': '#f87171'
};
const color = qualityColors[quality] || '#6b7280';
progressSize.style.color = color;
// Add subtle quality indicator
if (progressSize.dataset.quality !== quality) {
progressSize.dataset.quality = quality;
progressSize.style.transition = 'color 0.5s ease';
}
}
// Enhanced retry button setup
function setupRetryButton() {
// Setup JRE retry button
if (progressJRRetryBtn) {
progressJRRetryBtn.addEventListener('click', async () => {
if (!currentDownloadState.canRetry || currentDownloadState.isDownloading) {
return;
}
progressJRRetryBtn.disabled = true;
progressJRRetryBtn.textContent = 'Retrying...';
progressJRRetryBtn.classList.add('retrying');
currentDownloadState.isDownloading = true;
try {
hideDownloadError();
if (progressRetryInfo) {
progressRetryInfo.style.background = '';
progressRetryInfo.style.color = '';
}
if (progressText) {
progressText.textContent = 'Re-downloading Java runtime...';
}
if (!currentDownloadState.retryData || currentDownloadState.errorType !== 'jre') {
currentDownloadState.retryData = {
isJREError: true,
jreUrl: '',
fileName: 'jre.tar.gz',
cacheDir: '',
osName: 'linux',
arch: 'amd64'
};
console.log('[UI] Created default JRE retry data:', currentDownloadState.retryData);
}
if (window.electronAPI && window.electronAPI.retryDownload) {
const result = await window.electronAPI.retryDownload(currentDownloadState.retryData);
if (!result.success) {
throw new Error(result.error || 'JRE retry failed');
}
} else {
console.warn('electronAPI.retryDownload not available, simulating JRE retry...');
await new Promise(resolve => setTimeout(resolve, 2000));
throw new Error('JRE retry API not available');
}
} catch (error) {
console.error('JRE retry failed:', error);
showDownloadError(`JRE retry failed: ${error.message}`, true, 'jre');
} finally {
if (progressJRRetryBtn) {
progressJRRetryBtn.disabled = false;
progressJRRetryBtn.textContent = 'Retry Java Download';
progressJRRetryBtn.classList.remove('retrying');
}
currentDownloadState.isDownloading = false;
}
});
}
// Setup PWR retry button
if (progressPWRRetryBtn) {
progressPWRRetryBtn.addEventListener('click', async () => {
if (!currentDownloadState.canRetry || currentDownloadState.isDownloading) {
return;
}
progressPWRRetryBtn.disabled = true;
progressPWRRetryBtn.textContent = 'Retrying...';
progressPWRRetryBtn.classList.add('retrying');
currentDownloadState.isDownloading = true;
try {
hideDownloadError();
if (progressRetryInfo) {
progressRetryInfo.style.background = '';
progressRetryInfo.style.color = '';
}
if (progressText) {
const contextMessage = getRetryContextMessage();
progressText.textContent = contextMessage;
}
if (!currentDownloadState.retryData || currentDownloadState.errorType === 'jre') {
currentDownloadState.retryData = {
branch: 'release',
fileName: '4.pwr'
};
console.log('[UI] Created default PWR retry data:', currentDownloadState.retryData);
}
if (window.electronAPI && window.electronAPI.retryDownload) {
const result = await window.electronAPI.retryDownload(currentDownloadState.retryData);
if (!result.success) {
throw new Error(result.error || 'Game retry failed');
}
} else {
console.warn('electronAPI.retryDownload not available, simulating PWR retry...');
await new Promise(resolve => setTimeout(resolve, 2000));
throw new Error('Game retry API not available');
}
} catch (error) {
console.error('PWR retry failed:', error);
const errorType = categorizeError(error.message);
showDownloadError(`Game retry failed: ${error.message}`, true, errorType, error);
} finally {
if (progressPWRRetryBtn) {
progressPWRRetryBtn.disabled = false;
progressPWRRetryBtn.textContent = error && error.isJREError ? 'Retry Java Download' : 'Retry Game Download';
progressPWRRetryBtn.classList.remove('retrying');
}
currentDownloadState.isDownloading = false;
}
});
}
// Setup generic retry button (fallback)
if (progressRetryBtn) {
progressRetryBtn.addEventListener('click', async () => {
if (!currentDownloadState.canRetry || currentDownloadState.isDownloading) {
return;
}
progressRetryBtn.disabled = true;
progressRetryBtn.textContent = 'Retrying...';
progressRetryBtn.classList.add('retrying');
currentDownloadState.isDownloading = true;
try {
hideDownloadError();
if (progressRetryInfo) {
progressRetryInfo.style.background = '';
progressRetryInfo.style.color = '';
}
if (progressText) {
const contextMessage = getRetryContextMessage();
progressText.textContent = contextMessage;
}
if (!currentDownloadState.retryData) {
if (currentDownloadState.errorType === 'jre') {
currentDownloadState.retryData = {
isJREError: true,
jreUrl: '',
fileName: 'jre.tar.gz',
cacheDir: '',
osName: 'linux',
arch: 'amd64'
};
} else {
currentDownloadState.retryData = {
branch: 'release',
fileName: '4.pwr'
};
}
console.log('[UI] Created default retry data:', currentDownloadState.retryData);
}
if (window.electronAPI && window.electronAPI.retryDownload) {
const result = await window.electronAPI.retryDownload(currentDownloadState.retryData);
if (!result.success) {
throw new Error(result.error || 'Retry failed');
}
} else {
console.warn('electronAPI.retryDownload not available, simulating retry...');
await new Promise(resolve => setTimeout(resolve, 2000));
throw new Error('Retry API not available');
}
} catch (error) {
console.error('Retry failed:', error);
const errorType = categorizeError(error.message);
showDownloadError(`Retry failed: ${error.message}`, true, errorType);
} finally {
if (progressRetryBtn) {
progressRetryBtn.disabled = false;
progressRetryBtn.textContent = 'Retry Download';
progressRetryBtn.classList.remove('retrying');
}
currentDownloadState.isDownloading = false;
}
});
}
}
function getRetryContextMessage() {
const errorType = currentDownloadState.errorType;
switch (errorType) {
case 'network':
return 'Reconnecting and retrying download...';
case 'stall':
return 'Resuming stalled download...';
case 'server':
return 'Waiting for server and retrying...';
case 'corruption':
return 'Re-downloading corrupted PWR file...';
case 'butler':
return 'Re-attempting patch installation...';
case 'space':
return 'Retrying after clearing disk space...';
case 'permission':
return 'Retrying with corrected permissions...';
case 'conflict':
return 'Retrying after resolving conflicts...';
case 'jre':
return 'Re-downloading Java runtime...';
default:
return 'Initiating retry download...';
}
}
// Make toggleMaximize globally available
window.toggleMaximize = toggleMaximize;

149
GUI/js/updater.js Normal file
View File

@@ -0,0 +1,149 @@
// Launcher Update Manager UI
let updateModal = null;
let downloadProgressBar = null;
function initUpdater() {
// Listen for update events from main process
if (window.electronAPI && window.electronAPI.onUpdateAvailable) {
window.electronAPI.onUpdateAvailable((updateInfo) => {
showUpdateModal(updateInfo);
});
}
if (window.electronAPI && window.electronAPI.onUpdateDownloadProgress) {
window.electronAPI.onUpdateDownloadProgress((progress) => {
updateDownloadProgress(progress);
});
}
if (window.electronAPI && window.electronAPI.onUpdateDownloaded) {
window.electronAPI.onUpdateDownloaded((info) => {
showInstallUpdatePrompt(info);
});
}
}
function showUpdateModal(updateInfo) {
if (updateModal) {
updateModal.remove();
}
updateModal = document.createElement('div');
updateModal.className = 'update-modal-overlay';
updateModal.innerHTML = `
<div class="update-modal">
<div class="update-header">
<i class="fas fa-download"></i>
<h2>Launcher Update Available</h2>
</div>
<div class="update-content">
<p class="update-version">Version ${updateInfo.newVersion} is available!</p>
<p class="current-version">Current version: ${updateInfo.currentVersion}</p>
${updateInfo.releaseNotes ? `<div class="release-notes">${updateInfo.releaseNotes}</div>` : ''}
</div>
<div class="update-progress" style="display: none;">
<div class="progress-bar-container">
<div class="progress-bar" id="updateProgressBar"></div>
</div>
<p class="progress-text" id="updateProgressText">Downloading...</p>
</div>
<div class="update-actions">
<button class="btn-primary" onclick="downloadUpdate()">
<i class="fas fa-download"></i> Download Update
</button>
</div>
</div>
`;
document.body.appendChild(updateModal);
}
async function downloadUpdate() {
const downloadBtn = updateModal.querySelector('.btn-primary');
const progressDiv = updateModal.querySelector('.update-progress');
// Disable button and show progress
downloadBtn.disabled = true;
progressDiv.style.display = 'block';
try {
await window.electronAPI.downloadUpdate();
} catch (error) {
console.error('Failed to download update:', error);
alert('Failed to download update. Please try again later.');
dismissUpdateModal();
}
}
function updateDownloadProgress(progress) {
if (!updateModal) return;
const progressBar = document.getElementById('updateProgressBar');
const progressText = document.getElementById('updateProgressText');
if (progressBar) {
progressBar.style.width = `${progress.percent}%`;
}
if (progressText) {
const mbTransferred = (progress.transferred / 1024 / 1024).toFixed(2);
const mbTotal = (progress.total / 1024 / 1024).toFixed(2);
const speed = (progress.bytesPerSecond / 1024 / 1024).toFixed(2);
progressText.textContent = `Downloading... ${mbTransferred}MB / ${mbTotal}MB (${speed} MB/s)`;
}
}
function showInstallUpdatePrompt(info) {
if (updateModal) {
updateModal.remove();
}
updateModal = document.createElement('div');
updateModal.className = 'update-modal-overlay';
updateModal.innerHTML = `
<div class="update-modal">
<div class="update-header">
<i class="fas fa-check-circle"></i>
<h2>Update Downloaded</h2>
</div>
<div class="update-content">
<p>Version ${info.version} has been downloaded and is ready to install.</p>
<p class="update-note">The launcher will restart to complete the installation.</p>
</div>
<div class="update-actions">
<button class="btn-primary" onclick="installUpdate()">
<i class="fas fa-sync-alt"></i> Restart & Install
</button>
</div>
</div>
`;
document.body.appendChild(updateModal);
}
async function installUpdate() {
try {
await window.electronAPI.installUpdate();
} catch (error) {
console.error('Failed to install update:', error);
}
}
function dismissUpdateModal() {
if (updateModal) {
updateModal.remove();
updateModal = null;
}
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', initUpdater);
// Export functions
window.UpdaterUI = {
showUpdateModal,
dismissUpdateModal,
downloadUpdate,
installUpdate
};

View File

@@ -15,6 +15,9 @@
"title": "FREE TO PLAY LAUNCHER",
"playerName": "Player Name",
"playerNamePlaceholder": "Enter your name",
"gameBranch": "Game Version",
"releaseVersion": "Release (Stable)",
"preReleaseVersion": "Pre-Release (Experimental)",
"customInstallation": "Custom Installation",
"installationFolder": "Installation Folder",
"pathPlaceholder": "Default location",
@@ -54,7 +57,9 @@
"noDescription": "No description available",
"confirmDelete": "Are you sure you want to delete \"{name}\"?",
"confirmDeleteDesc": "This action cannot be undone.",
"confirmDeletion": "Confirm Deletion"
"confirmDeletion": "Confirm Deletion",
"apiKeyRequired": "API Key Required",
"apiKeyRequiredDesc": "CurseForge API key is needed to browse mods"
},
"news": {
"title": "ALL NEWS",
@@ -125,7 +130,18 @@
"logsLoading": "Loading logs...",
"closeLauncher": "Launcher Behavior",
"closeOnStart": "Close Launcher on game start",
"closeOnStartDescription": "Automatically close the launcher after Hytale has launched"
"closeOnStartDescription": "Automatically close the launcher after Hytale has launched",
"hwAccel": "Hardware Acceleration",
"hwAccelDescription": "Enable hardware acceleration for the launcher",
"gameBranch": "Game Branch",
"branchRelease": "Release",
"branchPreRelease": "Pre-Release",
"branchHint": "Switch between stable release and experimental pre-release versions",
"branchWarning": "Changing branch will download and install a different game version",
"branchSwitching": "Switching to {branch}...",
"branchSwitched": "Switched to {branch} successfully!",
"installRequired": "Installation Required",
"branchInstallConfirm": "The game will be installed for the {branch} branch. Continue?"
},
"uuid": {
"modalTitle": "UUID Management",
@@ -157,7 +173,8 @@
"delete": "Delete",
"edit": "Edit",
"loading": "Loading...",
"apply": "Apply"
"apply": "Apply",
"install": "Install"
},
"notifications": {
"gameDataNotFound": "Error: Game data not found",
@@ -192,7 +209,9 @@
"modsDownloadFailed": "Failed to download mod: {error}",
"modsToggleFailed": "Failed to toggle mod: {error}",
"modsDeleteFailed": "Failed to delete mod: {error}",
"modsModNotFound": "Mod information not found"
"modsModNotFound": "Mod information not found",
"hwAccelSaved": "Hardware acceleration setting saved",
"hwAccelSaveFailed": "Failed to save hardware acceleration setting"
},
"confirm": {
"defaultTitle": "Confirm action",

View File

@@ -15,6 +15,9 @@
"title": "LAUNCHER GRATUITO",
"playerName": "Nombre del Jugador",
"playerNamePlaceholder": "Ingresa tu nombre",
"gameBranch": "Versión del Juego",
"releaseVersion": "Lanzamiento (Estable)",
"preReleaseVersion": "Pre-Lanzamiento (Experimental)",
"customInstallation": "Instalación Personalizada",
"installationFolder": "Carpeta de Instalación",
"pathPlaceholder": "Ubicación predeterminada",
@@ -54,7 +57,9 @@
"noDescription": "Sin descripción disponible",
"confirmDelete": "¿Estás seguro de que quieres eliminar \"{name}\"?",
"confirmDeleteDesc": "Esta acción no se puede deshacer.",
"confirmDeletion": "Confirmar eliminación"
"confirmDeletion": "Confirmar eliminación",
"apiKeyRequired": "Clave API Requerida",
"apiKeyRequiredDesc": "Se necesita una clave API de CurseForge para explorar mods"
},
"news": {
"title": "TODAS LAS NOTICIAS",
@@ -125,7 +130,16 @@
"logsLoading": "Cargando registros...",
"closeLauncher": "Comportamiento del Launcher",
"closeOnStart": "Cerrar Launcher al iniciar el juego",
"closeOnStartDescription": "Cierra automáticamente el launcher después de que Hytale se haya iniciado"
"closeOnStartDescription": "Cierra automáticamente el launcher después de que Hytale se haya iniciado",
"gameBranch": "Rama del Juego",
"branchRelease": "Lanzamiento",
"branchPreRelease": "Pre-Lanzamiento",
"branchHint": "Cambia entre la versión estable y la versión experimental de pre-lanzamiento",
"branchWarning": "Cambiar de rama descargará e instalará una versión diferente del juego",
"branchSwitching": "Cambiando a {branch}...",
"branchSwitched": "¡Cambiado a {branch} con éxito!",
"installRequired": "Instalación Requerida",
"branchInstallConfirm": "El juego se instalará para la rama {branch}. ¿Continuar?"
},
"uuid": {
"modalTitle": "Gestión de UUID",
@@ -157,7 +171,8 @@
"delete": "Eliminar",
"edit": "Editar",
"loading": "Cargando...",
"apply": "Aplicar"
"apply": "Aplicar",
"install": "Instalar"
},
"notifications": {
"gameDataNotFound": "Error: No se encontraron datos del juego",

View File

@@ -14,8 +14,9 @@
"install": {
"title": "LANÇADOR JOGO GRATUITO",
"playerName": "Nome do Jogador",
"playerNamePlaceholder": "Digite seu nome",
"customInstallation": "Instalação Personalizada",
"playerNamePlaceholder": "Digite seu nome", "gameBranch": "Versão do Jogo",
"releaseVersion": "Lançamento (Estável)",
"preReleaseVersion": "Pré-Lançamento (Experimental)", "customInstallation": "Instalação Personalizada",
"installationFolder": "Pasta de Instalação",
"pathPlaceholder": "Local padrão",
"browse": "Procurar",
@@ -54,7 +55,9 @@
"noDescription": "Nenhuma descrição disponível",
"confirmDelete": "Tem certeza de que deseja excluir \"{name}\"?",
"confirmDeleteDesc": "Esta ação não pode ser desfeita.",
"confirmDeletion": "Confirmar exclusão"
"confirmDeletion": "Confirmar exclusão",
"apiKeyRequired": "Chave de API Necessária",
"apiKeyRequiredDesc": "Chave de API do CurseForge é necessária para procurar mods"
},
"news": {
"title": "TODAS AS NOTÍCIAS",
@@ -125,7 +128,16 @@
"logsLoading": "Carregando registros...",
"closeLauncher": "Comportamento do Lançador",
"closeOnStart": "Fechar Lançador ao iniciar o jogo",
"closeOnStartDescription": "Fechar automaticamente o lançador após o Hytale ter sido iniciado"
"closeOnStartDescription": "Fechar automaticamente o lançador após o Hytale ter sido iniciado",
"gameBranch": "Versão do Jogo",
"branchRelease": "Lançamento",
"branchPreRelease": "Pré-Lançamento",
"branchHint": "Alterne entre a versão estável e a versão experimental de pré-lançamento",
"branchWarning": "Mudar de versão irá baixar e instalar uma versão diferente do jogo",
"branchSwitching": "Mudando para {branch}...",
"branchSwitched": "Mudado para {branch} com sucesso!",
"installRequired": "Instalação Necessária",
"branchInstallConfirm": "O jogo será instalado para o ramo {branch}. Continuar?"
},
"uuid": {
"modalTitle": "Gerenciamento de UUID",
@@ -158,7 +170,8 @@
"delete": "Excluir",
"edit": "Editar",
"loading": "Carregando...",
"apply": "Aplicar"
"apply": "Aplicar",
"install": "Instalar"
},
"notifications": {
"gameDataNotFound": "Erro: Dados do jogo não encontrados",

246
GUI/locales/tr-TR.json Normal file
View File

@@ -0,0 +1,246 @@
{
"nav": {
"play": "Oyna",
"mods": "Modlar",
"news": "Haberler",
"chat": "Oyuncu Sohbeti",
"settings": "Ayarlar"
},
"header": {
"playersLabel": "Oyuncular:",
"manageProfiles": "Profilleri Yönet",
"defaultProfile": "Varsayılan"
},
"install": {
"title": "ÜCRETSİZ OYNA BAŞLATICI",
"playerName": "Oyuncu Adı",
"playerNamePlaceholder": "Adınızı girin",
"gameBranch": "Oyun Sürümü",
"releaseVersion": "Yayın (Stabil)",
"preReleaseVersion": "Ön-Yayın (Deneysel)",
"customInstallation": "Özel Kurulum",
"installationFolder": "Kurulum Klasörü",
"pathPlaceholder": "Varsayılan konum",
"browse": "Gözat",
"installButton": "HYTALE KURU",
"installing": "KURULUYOR..."
},
"play": {
"ready": "OYNAMAYA HAZIR",
"subtitle": "Hytale'i başlat ve maceraya başla",
"playButton": "HYTALE'YI OYNA",
"latestNews": "SON HABERLER",
"viewAll": "HEPSINI GÖR",
"checking": "KONTROL EDİLİYOR...",
"play": "OYNA"
},
"mods": {
"searchPlaceholder": "Modları ara...",
"myMods": "BENİM MODLARIM",
"previous": "ÖNCEKİ",
"next": "SONRAKİ",
"page": "Sayfa",
"of": "nın",
"modalTitle": "BENİM MODLARIM",
"noModsFound": "Mod Bulunamadı",
"noModsFoundDesc": "Aramanızı ayarlamayı deneyin",
"noModsInstalled": "Hiçbir Mod Kurulu Değil",
"noModsInstalledDesc": "CurseForge'dan modlar ekleyin veya yerel dosyalar içe aktarın",
"view": "GÖR",
"install": "KURU",
"installed": "KURULU",
"enable": "ETKİNLEŞTİR",
"disable": "DEĞİ",
"active": "AKTİF",
"disabled": "DEĞİ",
"delete": "Modı sil",
"noDescription": "Açıklama yok",
"confirmDelete": "\"{name}\" öğesini silmek istediğinizden emin misiniz?",
"confirmDeleteDesc": "Bu işlem geri alınamaz.",
"confirmDeletion": "Silmeyi Onayla",
"apiKeyRequired": "API Anahtarı Gerekli",
"apiKeyRequiredDesc": "Modlara göz atmak için CurseForge API anahtarı gereklidir"
},
"news": {
"title": "TÜM HABERLER",
"readMore": "Daha Fazla Oku"
},
"chat": {
"title": "OYUNCU SOHBETI",
"pickColor": "Renk",
"inputPlaceholder": "Mesajınızı yazın...",
"send": "Gönder",
"online": "çevrimiçi",
"charCounter": "{current}/{max}",
"secureChat": "Güvenli sohbet - Bağlantılar sansürlenir",
"joinChat": "Sohbete Katıl",
"chooseUsername": "Oyuncu Sohbetine katılmak için bir kullanıcı adı seçin",
"username": "Kullanıcı Adı",
"usernamePlaceholder": "Kullanıcı adınızı girin...",
"usernameHint": "3-20 karakter, yalnızca harfler, sayılar, - ve _",
"joinButton": "Sohbete Katıl",
"colorModal": {
"title": "Kullanıcı Adı Rengini Özelleştir",
"chooseSolid": "Düz bir renk seçin:",
"customColor": "Özel renk:",
"preview": "Ön izleme:",
"previewUsername": "Kullanıcı Adı",
"apply": "Rengi Uygula"
}
},
"settings": {
"title": "AYARLAR",
"java": "Java Çalışma Zamanı",
"useCustomJava": "Özel Java Yolunu Kullan",
"javaDescription": "Yüklü Java çalışma zamanını kendi kurulumunuzla geçersiz kılın",
"javaPath": "Java Çalıştırılabilir Yolu",
"javaPathPlaceholder": "Java yolunu seçin...",
"javaBrowse": "Gözat",
"javaHint": "Java kurulum klasörünü seçin (Windows, Mac, Linux destekler)",
"discord": "Discord Entegrasyonu",
"enableRPC": "Discord Rich Presence'ı Etkinleştir",
"discordDescription": "Başlatıcı etkinliğinizi Discord'da gösterin",
"game": "Oyun Seçenekleri",
"playerName": "Oyuncu Adı",
"playerNamePlaceholder": "Oyuncu adınızı girin",
"playerNameHint": "Bu ad oyun içinde kullanılacak (1-16 karakter)",
"openGameLocation": "Oyun Konumunu Aç",
"openGameLocationDesc": "Oyun kurulum klasörünü açın",
"account": "Oyuncu UUID Yönetimi",
"currentUUID": "Geçerli UUID",
"uuidPlaceholder": "UUID yükleniyor...",
"copyUUID": "UUID'yi Kopyala",
"regenerateUUID": "UUID'yi Yeniden Oluştur",
"uuidHint": "Bu kullanıcı adı için benzersiz oyuncu tanımlayıcınız",
"manageUUIDs": "Tüm UUID'leri Yönet",
"manageUUIDsDesc": "Tüm oyuncu UUID'lerini görüntüleyin ve yönetin",
"language": "Dil",
"selectLanguage": "Dil Seçin",
"repairGame": "Oyunu Onarı",
"reinstallGame": "Oyun dosyalarını yeniden kur (veri korur)",
"gpuPreference": "GPU Tercihi",
"gpuHint": "Tercih ettiğiniz GPU'yu seçin (Linux: DRI_PRIME'ı etkiler)",
"gpuAuto": "Otomatik",
"gpuIntegrated": "Entegre",
"gpuDedicated": "Ayrılmış",
"logs": "SİSTEM KAYITLARI",
"logsCopy": "Kopyala",
"logsRefresh": "Yenile",
"logsFolder": "Klasörü Aç",
"logsLoading": "Loglar yükleniyor...",
"closeLauncher": "Başlatıcı Davranışı",
"closeOnStart": "Oyun başlatıldığında Başlatıcıyı Kapat",
"closeOnStartDescription": "Hytale başlatıldıktan sonra başlatıcıyı otomatik olarak kapatın",
"gameBranch": "Oyun Dalı",
"branchRelease": "Yayın",
"branchPreRelease": "Ön-Yayın",
"branchHint": "Stabil yayın ve deneysel ön-yayın sürümleri arasında geçiş yapın",
"branchWarning": "Dalı değiştirmek farklı bir oyun sürümünü indirecek ve kuracaktır",
"branchSwitching": "{branch} sürümüne geçiliyor...",
"branchSwitched": "{branch} sürümüne başarıyla geçildi!",
"installRequired": "Kurulum Gerekli",
"branchInstallConfirm": "Oyun {branch} dalı için kurulacak. Devam et?"
},
"uuid": {
"modalTitle": "UUID Yönetimi",
"currentUserUUID": "Geçerli Kullanıcı UUID",
"allPlayerUUIDs": "Tüm Oyuncu UUID'leri",
"generateNew": "Yeni UUID Oluştur",
"loadingUUIDs": "UUID'ler yükleniyor...",
"setCustomUUID": "Özel UUID Ayarla",
"customPlaceholder": "Özel UUID girin (format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)",
"setUUID": "UUID Ayarla",
"warning": "Uyarı: Özel bir UUID ayarlamak geçerli oyuncu kimliğinizi değiştirecektir",
"copyTooltip": "UUID'yi Kopyala",
"regenerateTooltip": "Yeni UUID Oluştur"
},
"profiles": {
"modalTitle": "Profilleri Yönet",
"newProfilePlaceholder": "Yeni Profil Adı",
"createProfile": "Profil Oluştur"
},
"discord": {
"notificationText": "Discord topluluğumuza katılın!",
"joinButton": "Discord'a Katıl"
},
"common": {
"confirm": "Onayla",
"cancel": "İptal",
"save": "Kaydet",
"close": "Kapat",
"delete": "Sil",
"edit": "Düzenle",
"loading": "Yükleniyor...",
"apply": "Uygula",
"install": "Kur"
},
"notifications": {
"gameDataNotFound": "Hata: Oyun verileri bulunamadı",
"gameUpdatedSuccess": "Oyun başarıyla güncellendi! 🎉",
"updateFailed": "Güncelleme başarısız: {error}",
"updateError": "Güncelleme hatası: {error}",
"discordEnabled": "Discord Rich Presence etkinleştirildi",
"discordDisabled": "Discord Rich Presence devre dışı bırakıldı",
"discordSaveFailed": "Discord ayarı kaydedilemedi",
"playerNameRequired": "Lütfen geçerli bir oyuncu adı girin",
"playerNameSaved": "Oyuncu adı başarıyla kaydedildi",
"playerNameSaveFailed": "Oyuncu adı kaydedilemedi",
"uuidCopied": "UUID panoya kopyalandı!",
"uuidCopyFailed": "UUID kopyalanamadı",
"uuidRegenNotAvailable": "UUID yeniden oluşturma kullanılamıyor",
"uuidRegenFailed": "UUID yeniden oluşturulamadı",
"uuidGenerated": "Yeni UUID başarıyla oluşturuldu!",
"uuidGeneratedShort": "Yeni UUID oluşturuldu!",
"uuidGenerateFailed": "Yeni UUID oluşturulamadı",
"uuidRequired": "Lütfen bir UUID girin",
"uuidInvalidFormat": "Geçersiz UUID formatı",
"uuidSetFailed": "Özel UUID ayarlanamadı",
"uuidSetSuccess": "Özel UUID başarıyla ayarlandı!",
"uuidDeleteFailed": "UUID silinemedi",
"uuidDeleteSuccess": "UUID başarıyla silindi!",
"modsDownloading": "{name} indiriliyor...",
"modsTogglingMod": "Mod değiştiriliyor...",
"modsDeletingMod": "Mod siliniyor...",
"modsLoadingMods": "CurseForge'dan modlar yükleniyor...",
"modsInstalledSuccess": "{name} başarıyla kuruldu! 🎉",
"modsDeletedSuccess": "{name} başarıyla silindi",
"modsDownloadFailed": "Mod indirilemedi: {error}",
"modsToggleFailed": "Mod değiştirilemedi: {error}",
"modsDeleteFailed": "Mod silinemedi: {error}",
"modsModNotFound": "Mod bilgileri bulunamadı"
},
"confirm": {
"defaultTitle": "Eylemi onayla",
"regenerateUuidTitle": "Yeni UUID oluştur",
"regenerateUuidMessage": "Yeni bir UUID oluşturmak istediğinizden emin misiniz? Bu oyuncu kimliğinizi değiştirecektir.",
"regenerateUuidButton": "Oluştur",
"setCustomUuidTitle": "Özel UUID ayarla",
"setCustomUuidMessage": "Bu özel UUID'yi ayarlamak istediğinizden emin misiniz? Bu oyuncu kimliğinizi değiştirecektir.",
"setCustomUuidButton": "UUID Ayarla",
"deleteUuidTitle": "UUID'yi sil",
"deleteUuidMessage": "\"{username}\" için UUID'yi silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
"deleteUuidButton": "Sil",
"uninstallGameTitle": "Oyunu kaldır",
"uninstallGameMessage": "Hytale'yi kaldırmak istediğinizden emin misiniz? Tüm oyun dosyaları silinecektir.",
"uninstallGameButton": "Kaldır"
},
"progress": {
"initializing": "Başlatılıyor...",
"downloading": "İndiriliyor...",
"installing": "Kuruluyur...",
"extracting": "Ayıklanıyor...",
"verifying": "Doğrulanıyor...",
"switchingProfile": "Profil değiştiriliyor...",
"profileSwitched": "Profil değiştirildi!",
"startingGame": "Oyun başlatılıyor...",
"launching": "BAŞLATILIYOR...",
"uninstallingGame": "Oyun kaldırılıyor...",
"gameUninstalled": "Oyun başarıyla kaldırıldı!",
"uninstallFailed": "Kaldırma başarısız: {error}",
"startingUpdate": "Zorunlu oyun güncellemesi başlatılıyor...",
"installationComplete": "Kurulum başarıyla tamamlandı!",
"installationFailed": "Kurulum başarısız: {error}",
"installingGameFiles": "Oyun dosyaları kuruluyor...",
"installComplete": "Kurulum tamamlandı!"
}
}

View File

@@ -662,6 +662,57 @@ body {
box-shadow: none;
}
/* Radio buttons for install page */
.radio-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.radio-label {
display: flex;
align-items: center;
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border: 2px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
}
.radio-label:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(147, 51, 234, 0.5);
}
.radio-label .custom-radio {
position: absolute;
opacity: 0;
cursor: pointer;
}
.radio-label .custom-radio:checked ~ .radio-text {
color: #9333ea;
}
.radio-label:has(.custom-radio:checked) {
background: rgba(147, 51, 234, 0.15);
border-color: #9333ea;
box-shadow: 0 0 20px rgba(147, 51, 234, 0.2);
}
.radio-text {
display: flex;
align-items: center;
color: #d1d5db;
font-weight: 500;
transition: color 0.3s ease;
}
.radio-text i {
margin-right: 0.5rem;
}
.launcher-container {
flex: 1;
display: flex;
@@ -1729,6 +1780,243 @@ body {
}
}
/* Progress Error and Retry Styles */
.progress-error-container {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid rgba(239, 68, 68, 0.3);
animation: errorSlideIn 0.3s ease-out;
}
@keyframes errorSlideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.progress-error-message {
color: #f87171;
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
margin-bottom: 0.5rem;
text-shadow: 0 0 8px rgba(248, 113, 113, 0.4);
line-height: 1.4;
}
.progress-retry-section {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.progress-retry-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.progress-retry-info {
color: #fbbf24;
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
flex: 1;
}
.progress-retry-btn {
background: linear-gradient(135deg, #dc2626, #ef4444);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.3);
white-space: nowrap;
min-width: 120px;
}
.progress-retry-btn:hover {
background: linear-gradient(135deg, #b91c1c, #dc2626);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.4);
}
.progress-retry-btn:active {
transform: translateY(0);
box-shadow: 0 2px 6px rgba(220, 38, 38, 0.3);
}
.progress-retry-btn:disabled {
background: linear-gradient(135deg, #4b5563, #6b7280);
cursor: not-allowed;
transform: none;
box-shadow: none;
opacity: 0.6;
}
/* Progress overlay error state */
.progress-overlay.error-state {
border-color: rgba(239, 68, 68, 0.5);
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.5),
0 0 30px rgba(239, 68, 68, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.progress-overlay.error-state #progressBarFill {
background: linear-gradient(90deg, #dc2626, #ef4444);
animation: errorPulse 1.5s ease-in-out infinite;
}
@keyframes errorPulse {
0%, 100% {
opacity: 0.8;
}
50% {
opacity: 1;
}
}
/* Error type specific styling */
.progress-error-container.error-network {
border-top-color: rgba(59, 130, 246, 0.5);
}
.progress-error-container.error-network .progress-error-message {
color: #60a5fa;
text-shadow: 0 0 8px rgba(96, 165, 250, 0.4);
}
.progress-error-container.error-stall {
border-top-color: rgba(245, 158, 11, 0.5);
}
.progress-error-container.error-stall .progress-error-message {
color: #fbbf24;
text-shadow: 0 0 8px rgba(251, 191, 36, 0.4);
}
.progress-error-container.error-file {
border-top-color: rgba(239, 68, 68, 0.5);
}
.progress-error-container.error-file .progress-error-message {
color: #f87171;
text-shadow: 0 0 8px rgba(248, 113, 113, 0.4);
}
.progress-error-container.error-permission {
border-top-color: rgba(168, 85, 247, 0.5);
}
.progress-error-container.error-permission .progress-error-message {
color: #a855f7;
text-shadow: 0 0 8px rgba(168, 85, 247, 0.4);
}
.progress-error-container.error-server {
border-top-color: rgba(236, 72, 153, 0.5);
}
.progress-error-container.error-server .progress-error-message {
color: #ec4899;
text-shadow: 0 0 8px rgba(236, 72, 153, 0.4);
}
.progress-error-container.error-corruption {
border-top-color: rgba(220, 38, 38, 0.8);
}
.progress-error-container.error-corruption .progress-error-message {
color: #dc2626;
text-shadow: 0 0 8px rgba(220, 38, 38, 0.6);
font-weight: 600;
}
.progress-error-container.error-butler {
border-top-color: rgba(245, 158, 11, 0.5);
}
.progress-error-container.error-butler .progress-error-message {
color: #f59e0b;
text-shadow: 0 0 8px rgba(245, 158, 11, 0.4);
}
.progress-error-container.error-space {
border-top-color: rgba(168, 85, 247, 0.5);
}
.progress-error-container.error-space .progress-error-message {
color: #a855f7;
text-shadow: 0 0 8px rgba(168, 85, 247, 0.4);
}
.progress-error-container.error-conflict {
border-top-color: rgba(6, 182, 212, 0.5);
}
.progress-error-container.error-conflict .progress-error-message {
color: #06b6d4;
text-shadow: 0 0 8px rgba(6, 182, 212, 0.4);
}
/* Connection quality indicators */
.progress-details {
transition: all 0.3s ease;
}
.progress-details #progressSize {
transition: color 0.5s ease;
}
/* Enhanced retry button states */
.progress-retry-btn.retrying {
background: linear-gradient(135deg, #059669, #10b981);
animation: retryingPulse 1s ease-in-out infinite;
}
@keyframes retryingPulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 2px 8px rgba(5, 150, 105, 0.3);
}
50% {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(5, 150, 105, 0.4);
}
}
/* Network status indicator (optional future enhancement) */
.network-status {
position: absolute;
top: 5px;
right: 5px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #10b981;
box-shadow: 0 0 6px rgba(16, 185, 129, 0.6);
}
.network-status.poor {
background: #f87171;
box-shadow: 0 0 6px rgba(248, 113, 113, 0.6);
}
.network-status.fair {
background: #fbbf24;
box-shadow: 0 0 6px rgba(251, 191, 36, 0.6);
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg,
@@ -1795,70 +2083,6 @@ body {
}
/* Installation effects */
.installation-effects {
position: fixed;
top: 0;
left: 80px;
width: calc(100% - 80px);
height: 100%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(10px);
z-index: 40;
pointer-events: auto;
overflow: hidden;
}
.space-effects {
position: absolute;
width: 100%;
height: 100%;
perspective: 1000px;
}
.warp-line {
position: absolute;
width: 2px;
height: 100%;
background: linear-gradient(180deg,
transparent 0%,
rgba(147, 51, 234, 0.8) 50%,
transparent 100%);
box-shadow: 0 0 10px rgba(147, 51, 234, 0.8),
0 0 20px rgba(147, 51, 234, 0.4);
animation: warpSpeed 1.5s linear infinite;
opacity: 0;
}
.warp-line:nth-child(1) { left: 10%; animation-delay: 0s; }
.warp-line:nth-child(2) { left: 25%; animation-delay: 0.2s; }
.warp-line:nth-child(3) { left: 40%; animation-delay: 0.4s; }
.warp-line:nth-child(4) { left: 55%; animation-delay: 0.6s; }
.warp-line:nth-child(5) { left: 70%; animation-delay: 0.8s; }
.warp-line:nth-child(6) { left: 85%; animation-delay: 1s; }
.warp-line:nth-child(7) { left: 15%; animation-delay: 0.3s; }
.warp-line:nth-child(8) { left: 60%; animation-delay: 0.7s; }
@keyframes warpSpeed {
0% {
transform: translateY(-100%) scaleY(0);
opacity: 0;
}
10% {
opacity: 1;
}
50% {
opacity: 1;
transform: translateY(0%) scaleY(1);
}
90% {
opacity: 1;
}
100% {
transform: translateY(100%) scaleY(2);
opacity: 0;
}
}
.mods-manager {
display: flex;
@@ -5767,3 +5991,166 @@ select.settings-input option {
opacity: 1;
}
}
/* Launcher Update Modal Styles */
.update-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100000;
animation: fadeIn 0.3s ease;
}
.update-modal {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border: 2px solid rgba(147, 51, 234, 0.3);
border-radius: 16px;
padding: 2rem;
max-width: 500px;
width: 90%;
box-shadow: 0 20px 60px rgba(147, 51, 234, 0.3);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
transform: translateY(30px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.update-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
color: #9333ea;
}
.update-header i {
font-size: 2rem;
}
.update-header h2 {
margin: 0;
font-size: 1.5rem;
color: #fff;
}
.update-content {
color: #e0e0e0;
margin-bottom: 1.5rem;
}
.update-version {
font-size: 1.2rem;
font-weight: 600;
color: #9333ea;
margin-bottom: 0.5rem;
}
.current-version {
color: #888;
font-size: 0.9rem;
margin-bottom: 1rem;
}
.release-notes {
background: rgba(0, 0, 0, 0.3);
border-left: 3px solid #9333ea;
padding: 1rem;
border-radius: 8px;
max-height: 200px;
overflow-y: auto;
font-size: 0.9rem;
line-height: 1.6;
}
.update-progress {
margin-bottom: 1.5rem;
}
.progress-bar-container {
background: rgba(0, 0, 0, 0.3);
border-radius: 10px;
height: 20px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #9333ea 0%, #7c3aed 100%);
width: 0%;
transition: width 0.3s ease;
border-radius: 10px;
}
.progress-text {
color: #aaa;
font-size: 0.9rem;
text-align: center;
margin: 0;
}
.update-note {
background: rgba(147, 51, 234, 0.1);
border: 1px solid rgba(147, 51, 234, 0.3);
padding: 0.75rem;
border-radius: 8px;
font-size: 0.9rem;
margin-top: 1rem;
}
.update-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
}
.update-actions button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
}
.update-actions .btn-primary {
background: linear-gradient(135deg, #9333ea 0%, #7c3aed 100%);
color: white;
}
.update-actions .btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(147, 51, 234, 0.4);
}
.update-actions .btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: #e0e0e0;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.update-actions .btn-secondary:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.15);
}
.update-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -1,8 +1,7 @@
# Maintainer: Terromur <terromuroz@proton.me>
# Maintainer: Fazri Gading <fazrigading@gmail.com>
pkgname=Hytale-F2P-git
_pkgname=Hytale-F2P
pkgver=2.0.11.r120.gb05aeef
pkgver=2.0.12.r150.gb62ffc1
pkgrel=1
pkgdesc="Hytale-F2P - unofficial Hytale Launcher for free to play with multiplayer support"
arch=('x86_64')
@@ -14,12 +13,15 @@ sha256sums=('SKIP' '46488fada4775d9976d7b7b62f8d1f1f8d9a9a9d8f8aa9af4f2e2153019f
pkgver() {
cd "$_pkgname"
printf "2.0.11.r%s.g%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
version=$(git describe --abbrev=0 --tags --match "v[0-9]*")
commits=$(git rev-list --count HEAD)
hash=$(git rev-parse --short HEAD)
printf "%s.r%s.g%s" "${version#v}" "$commits" "$hash"
}
build() {
cd "$_pkgname"
npm install
npm ci
npm run build:linux
}
@@ -27,5 +29,5 @@ package() {
mkdir -p "$pkgdir/opt/$_pkgname"
cp -r "$_pkgname/dist/linux-unpacked/"* "$pkgdir/opt/$_pkgname"
install -Dm644 "$_pkgname.desktop" "$pkgdir/usr/share/applications/$_pkgname.desktop"
install -Dm644 "$_pkgname/icon.png" "$pkgdir/usr/share/icons/hicolor/512x512/apps/$_pkgname.png"
install -Dm644 "$_pkgname/GUI/icon.png" "$pkgdir/usr/share/icons/hicolor/256x256/apps/$_pkgname.png"
}

310
README.md
View File

@@ -1,19 +1,32 @@
# 🎮 Hytale F2P Launcher | Multiplayer Support [Windows, MacOS, Linux]
<div align="center">
![Version](https://img.shields.io/badge/Version-2.0.2-green?style=for-the-badge)
![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey?style=for-the-badge)
![License](https://img.shields.io/badge/License-Educational-blue?style=for-the-badge)
<header>
<h1>🎮 Hytale F2P Launcher | Cross-Platform Multiplayer 🖥️</h1>
<h3>Available for Windows 🪟, macOS 🍎, and Linux 🐧</h3>
<p><small>An unofficial cross-platform launcher for Hytale with automatic updates and multiplayer support (all OS supported)</small></p>
</header>
**A modern, cross-platform launcher for Hytale with automatic updates and multiplayer support (all OS supported)**
![Version](https://img.shields.io/badge/Version-2.1.0-green?style=for-the-badge)
![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS%20%7C%20Linux-orange?style=for-the-badge)
![License](https://img.shields.io/badge/License-Educational-blue?style=for-the-badge)
[![GitHub stars](https://img.shields.io/github/stars/amiayweb/Hytale-F2P?style=social)](https://github.com/amiayweb/Hytale-F2P/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/amiayweb/Hytale-F2P?style=social)](https://github.com/amiayweb/Hytale-F2P/network/members)
**If you find this project useful, please give it a star!**
**If you find this project useful, please give it a STAR!**
🛑 **Found a problem? Join the Discord: https://discord.gg/gME8rUy3MB** 🛑
### ⚠️ **READ [QUICK START](https://github.com/amiayweb/Hytale-F2P/tree/main?tab=readme-ov-file#-quick-start) before Downloading & Installing the Launcher!** ⚠️
🛑 **Found a problem? Join the Discord and Select #Open-A-Ticket!: https://discord.gg/gME8rUy3MB** 🛑
<p>
If you like the project, <b>feel free to support us via Buy Me a Coffee!</b>
Any support is appreciated and helps keep the project going.
</p>
<a href="https://buymeacoffee.com/hf2p">
<img src="https://media3.giphy.com/media/v1.Y2lkPTc5MGI3NjExem14OW1tanN3eHlyYmR4NW1sYmJkOTZmbmJxejdjZXB6MXY5cW12MSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9cw/TDQOtnWgsBx99cNoyH/giphy.gif" width="120">
</a>
</div>
@@ -21,12 +34,42 @@
## 📸 Screenshots
<div align="center">
![Hytale F2P Launcher](https://i.imgur.com/9iDuzST.png)
![Hytale F2P Mods](https://i.imgur.com/NaareIS.png)
![Hytale F2P News](https://i.imgur.com/n1nEqRS.png)
![Hytale F2P Chat](https://i.imgur.com/Y4hL3sx.png)
<img src="https://i.imgur.com/xW9do3d.png" alt="Hytale F2P Launcher" width="1000">
<details>
<summary><b>View Gallery</b></summary>
<table style="width: 100%; border-spacing: 15px; border-collapse: separate;">
<tr>
<td align="center" style="vertical-align: top; width: 50%;">
<b>Mods Preview</b><br>
<img src="https://i.imgur.com/f8qyIJy.png" alt="Hytale F2P Mods" width="100%">
</td>
<td align="center" style="vertical-align: top; width: 50%;">
<b>Latest News</b><br>
<img src="https://i.imgur.com/qu0HltD.png" alt="Hytale F2P News" width="100%">
</td>
</tr>
<tr>
<td align="center" style="vertical-align: top; width: 50%;">
<b>Social & Chat</b><br>
<img src="https://i.imgur.com/t3GmbfF.png" alt="Hytale F2P Chat" width="100%">
</td>
<td align="center" style="vertical-align: top; width: 50%;">
<b>Settings</b><br>
<img src="https://i.imgur.com/uUD7lDB.png" alt="Hytale F2P Settings" width="100%">
</td>
</tr>
<tr>
<td align="center" style="vertical-align: top; width: 50%;">
<b>In-Game Screenshot - Spawn Point</b><br>
<img src="https://i.imgur.com/X8lNFQ7.png" alt="Hytale F2P In-Game Screenshot-1" width="100%">
</td>
<td align="center" style="vertical-align: top; width: 50%;">
<b>In-Game Screenshot - Gameplay Terrain</b><br>
<img src="https://i.imgur.com/3iRScPa.png" alt="Hytale F2P In-Game Screenshot-2" width="100%">
</td>
</tr>
</table>
</details>
</div>
---
@@ -49,25 +92,189 @@
---
## 🚀 Quick Start
# 🚀 Quick Start
### 📥 Installation
## 🖥️ System Requirements
#### Windows
1. Download the latest `Hytale-F2P.exe` from [**Releases**](https://github.com/amiayweb/Hytale-F2P/releases)
2. Run the installer
3. Launch from desktop or start menu
### 🎮 Hytale Hardware Requirements
#### Linux
See [BUILD.md](BUILD.md) for detailed build instructions or [**Releases**](https://github.com/amiayweb/Hytale-F2P/releases) section.
> [!IMPORTANT]
> Hytale is designed to be accessible while scaling for high-end performance.
> Below are the [official system requirements for the Early Access](https://hytale.com/news/2025/12/hytale-hardware-requirements) release.
#### macOS
See [BUILD.md](BUILD.md) for detailed build instructions or [**Releases**](https://github.com/amiayweb/Hytale-F2P/releases) section.
<div align="center">
#### 🖥️ How to play online on F2P?
See [SERVER.md](SERVER.md)
<table>
<thead>
<tr>
<th>Component</th>
<th>🥉 Minimum (1080p @ 30 FPS)</th>
<th>🥈 Recommended (1080p @ 60 FPS)</th>
<th>🥇 Best (1440p @ 60 FPS)</th>
</tr>
</thead>
<tbody>
<tr>
<td><b>🖥️ OS</b></td>
<td colspan="3" align="center">
Windows 10/11 (64-bit; X64/ARM64) | Linux (x64/ARM64) | macOS (Apple Silicon only)
<br />
<small><i>⚠️ Note: macOS Intel (x86) is not yet supported <a href="#fn1" id="ref1">1</a></sup></i></small>
</td>
</tr>
<tr>
<td><b>⚙️ CPU</b></td>
<td>Intel i5-7500 / Ryzen 3 1200 / Apple M1</td>
<td>Intel i5-10400 / Ryzen 5 3600 / Apple M2</td>
<td>Intel i7-10700K / Ryzen 9 3800X / Apple M3</td>
</tr>
<tr>
<td><b>🧠 RAM</b></td>
<td>8GB (Dedicated) / 12GB (iGPU)</td>
<td>16 GB</td>
<td>32 GB</td>
</tr>
<tr>
<td><b>🎮 GPU</b></td>
<td>GTX 900 / RX 400 / UHD 620</td>
<td>GTX 1060 / RX 580 / Iris Xe</td>
<td>RTX 30 Series / RX 7000 Series</td>
</tr>
<tr>
<td><b>💾 Storage</b></td>
<td>20 GB (SATA SSD)</td>
<td>20 GB (NVMe SSD)</td>
<td>50 GB+ (NVMe SSD)</td>
</tr>
<tr>
<td><b>🌐 Network</b></td>
<td>2 Mbit/s</td>
<td>8 Mbit/s</td>
<td>10+ Mbit/s</td>
</tr>
</tbody>
</table>
</div>
<p id="fn1"><sup>1</sup> Hytale did not provide game files for macOS Intel, yet.</p>
### 🪟 Windows Prequisites
* **Java JDK 25:** Download via [Adoptium](https://adoptium.net/temurin/releases/?version=25) or [Oracle](https://www.oracle.com/java/technologies/downloads/#jdk25-windows)
* **Latest Visual Studio Redist:** Download via [Microsoft Visual C++ Redistributable](https://aka.ms/vc14/vc_redist.x64.exe) or [All-in-One by Techpowerup](https://www.techpowerup.com/download/visual-c-redistributable-runtime-package-all-in-one/)
* **ENABLE MULTIPLAYER:** // TODO MULTIPLAYER GUIDE; FIREWALL GUIDE AND SUCH
### 🐧 Linux Prequisites
> [!WARNING]
> Ubuntu-based Distro like ZorinOS or Pop!_OS or Linux Mint would encounter issues due to UbuntuLTS environment, [check this Discord post](https://discord.com/channels/1462260103951421493/1463662398501027973).
* Make sure you have already installed newest **GPU driver**, consult your distro docs or wiki.
* Install `libpng` package to avoid SDL3_Image error:
* `libpng16-16 libpng-dev` for Ubuntu/Debian-based Distro
* `libpng libpng-devel` for Fedora/RHEL-based Distro
* `libpng` for Arch-based Distro
---
## 📥 Installation
### 🪟 Windows Installation
1. **Prerequisites:** Ensure you have installed all [**Windows Prerequisites**](https://github.com/amiayweb/Hytale-F2P/tree/main?tab=readme-ov-file#-windows-prequisites) listed above.
2. **Download:** Get the latest `Hytale-F2P-Launcher.exe` from the [**Releases**](https://github.com/amiayweb/Hytale-F2P/releases/latest/) page.
3. **SmartScreen Note:** Since the executable is currently unsigned, Windows may show a "Windows protected your PC" popup.
* Click **More info**.
* Click **Run anyway**.
4. **Launch:** Once installed, you can launch the app directly from your Desktop or the Start menu.
---
### 🐧 Linux Installation
1. **Prerequisites:** Ensure you have installed all [**Linux Prerequisites**](https://github.com/amiayweb/Hytale-F2P/tree/main?tab=readme-ov-file#-linux-prequisites) above.
2. **Download:** Choose the package that fits your distribution from the [**Releases**](https://github.com/amiayweb/Hytale-F2P/releases/latest/) page:
* **Universal:** `.AppImage`
* **Arch Linux:** `.pkg.tar.zst`
* **Fedora/RHEL/openSUSE:** `.rpm`
* **Debian/Ubuntu:** `.deb`
3. **Permissions & Execution:**
* **AppImage:** Make the file executable and run it:
```bash
chmod +x Hytale-F2P-Launcher.AppImage
./Hytale-F2P-Launcher.AppImage
```
* **Fedora (dnf):** Install the RPM:
```bash
sudo dnf install ./Hytale-F2P-Launcher.rpm
```
* **Debian/Ubuntu (apt):** Install the DEB:
```bash
sudo apt install ./Hytale-F2P-Launcher.deb
```
* **Arch Linux (pacman):** Install the package using:
```bash
sudo pacman -U /path/to/Hytale-F2P-Launcher.pkg.tar.zst
```
4. **Troubleshooting:**
* **FUSE:** If the AppImage fails to launch on newer distributions, ensure `libfuse2` (or `fuse2` on Arch/Fedora) is installed.
* **Desktop Entry:** After installing via `.rpm`, `.deb`, or `.pkg.tar.zst`, the launcher should automatically appear in your App Library/Grid.
---
### 🍎 macOS Installation
> [!NOTE]
> Apple Silicon Users: If you are on an M1, M2, or M3 Mac, you may be prompted to install Rosetta 2 the first time you run the launcher. This is normal and required for compatibility.
1. **Download:** Get the latest `.dmg` file from the [**Releases**](https://github.com/amiayweb/Hytale-F2P/releases/latest/) page.
2. **Mount:** Double-click the `.dmg` file to open it.
3. **Install:** Drag the **Hytale F2P Launcher** icon into your **Applications** folder.
4. **First Run:** If macOS prevents the app from opening because it is from an "unidentified developer":
* Open **System Settings** > **Privacy & Security**.
* Scroll down to the **Security** section.
* Look for the message regarding "Hytale F2P Launcher" and click **Open Anyway**.
* Authenticate with your password and click **Open**.
#### **Advanced: Manual Installation (.zip)**
The `.zip` version is useful for users who prefer a portable installation or need to bypass specific permission issues.
1. **Extract:** Download and unzip the file to your desired location (e.g., `~/Applications`).
2. **Remove Quarantine:** macOS often "quarantines" apps downloaded via browser. If the app won't open, open **Terminal** and run:
```bash
xattr -rd com.apple.quarantine /path/to/Hytale-F2P-Launcher.app
```
> [!TIP]
> Type the first part of the command, then drag the app icon into the Terminal window to auto-fill the path.
---
# How to Host a Server
## Host your Singleplayer Server (Online-Play Feature)
> [!NOTE]
> You have to play the game to host the server. See Dedicated Server section below if you want to host it without you playing as the host.
1. Open your Singleplayer World
2. Pause the game (Esc) > select Online Play > Turn on `Allow Other Players to Join` > Set password if needed > Press `Save`.
3. Check the status `Connected via STUN` or `Connected via UPnP`.
## Dedicated Server
> [!NOTE]
> If you have already `HytaleServer.jar` in `HytaleF2P/{release/pre-release}/package/game/latest/Server`, you can use it to host local dedicated server.
> [!TIP]
> Use services like Playit.gg, Tailscale, Radmin VPN to share UDP connection if setting up router as an admin is not possible.
> [!WARNING]
> `Hytale-F2P-Server.rar` file is needed to set up a server on non-playing hardware (such as VPS/server hosting).
> [!IMPORTANT]
> See detailed information of setting up a server here: [SERVER.md](SERVER.md)
---
## 🛠️ Building from Source
@@ -76,27 +283,22 @@ See [BUILD.md](BUILD.md) for comprehensive build instructions.
---
## 📌 Versioning Policy
**⚠️ Important: Semantic Versioning Required**
This project follows **strict semantic versioning** with **numerical versions only**:
-**Valid**: `2.0.1`, `2.0.11`, `2.1.0`, `3.0.0`
-**Invalid**: `2.0.2b`, `2.0.2a`, `2.0.1-beta`, `v2.0.2b`
**Format**: `MAJOR.MINOR.PATCH` (e.g., `2.0.11`)
- **MAJOR**: Breaking changes
- **MINOR**: New features (backward compatible)
- **PATCH**: Bug fixes (backward compatible)
**Why?** The auto-update system requires semantic versioning for proper version comparison. Letter suffixes (like `2.0.2b`) are not supported and will cause update detection issues.
---
## 📋 Changelog
### 🆕 v2.1.0
- 🚨 **Auto-Retry Downloads and Auto-Patch Files** —
- ⚡ **Hardware Acceleration** —
- 👨‍💻 **In-App Logging** —
- 🛠️ **Repair Button** — Y
- 🔎 **Browse CurseForge Mods** — Browsing mods now easier with our dedicated CurseForge API Key.
- 🌎 **Fixes and Release New Translation** — Fixed 🇪🇸 🇧🇷 and added more translation for current build. Turkish 🇹🇷 language now added.
<details>
<summary>Click here to see older Changelogs</summary>
### 🆕 v2.0.2b *(Minor Update: Performance & Utilities)*
- 🌎 **Language Translation** — A big welcome for Spanish 🇪🇸 and Portuguese (Brazil) 🇧🇷 players! **Language setting can be found in the bottom part of Settings pane.**
- 💻 **Laptop/Hybrid GPU Performance Issue Fix** — Added automatic GPU detection system and options to choose which GPU will be used for the game, *specifically for Linux users*.
@@ -104,14 +306,15 @@ This project follows **strict semantic versioning** with **numerical versions on
- 🛠️ **Repair Button** — Your game's broken? One button will fix them, go to Settings pane to Repair your game in one-click, **without losing any data**. If doing so did not fix your issue, please report it to us immediately!
- 🐛 **Fixed Bugs** — Fixed issue [#84](https://github.com/amiayweb/Hytale-F2P/issues/84) where mods disappearing when game starts in previous launcher (v2.0.2a).
### 🆕 v2.0.2a *(Minor Update)*
### 🔄 v2.0.2a *(Minor Update)*
- 🧑‍🚀 **Profiles System** — Added proper profile management: create, switch, and delete profiles. Each profile now has its own **isolated mod list**.
- 🔒 **Mod Isolation** — Fixed ModManager so mods are **strictly scoped to the active profile**. Browsing and installing now only affects the selected profile.
- 🚨 **Critical Path Fix** — Resolved a macOS bug where mods were being saved to a Windows path (`~/AppData/Local`) instead of `~/Library/Application Support`. Mods now save to the **correct location** and load properly in-game.
- 🚨 **Critical Path Fix** — Resolved a macOS bug where mods were being saved to a Windows path (`~/AppData/Local`) instead of `~/Library/Application Support`.
- 🛡️ **Stability Improvements** — Added an **auto-sync step before every launch** to ensure the physical mods folder always matches the active profile.
- 🎨 **UI Enhancements** — Added a **profile selector dropdown** and a **profile management modal**.
### 🆕 v2.0.2
### 🔄 v2.0.2
- 🎮 **Discord RPC Integration** - Added Discord Rich Presence with toggle in settings (enabled by default)
- 🌐 **Cross-Platform Multiplayer** - Added multiplayer patch support for Windows, Linux, and macOS
- 🎨 **Chat Improvements** - Simplified chat color system
@@ -156,7 +359,7 @@ This project follows **strict semantic versioning** with **numerical versions on
-**Java Management** - Automatic Java runtime handling
- 🎨 **Modern Interface** - Clean, intuitive design
- 🌟 **First Release** - Core launcher functionality
</details>
---
## 👥 Contributors
@@ -170,7 +373,7 @@ This project follows **strict semantic versioning** with **numerical versions on
</div>
### 🏆 Project Creator
- [**@amiayweb**](https://github.com/amiayweb) - *Lead Developer & Project Creator*
- [**@amiayweb**](https://github.com/amiayweb) - *Lead Developer & Project Creator | Windows*
- [**@Relyz1993**](https://github.com/Relyz1993) - *Server Helper & Second Developer & Project Creator*
### 🌟 Contributors
@@ -181,6 +384,8 @@ This project follows **strict semantic versioning** with **numerical versions on
- [**@chasem-dev**](https://github.com/chasem-dev) - *Issues fixer*
- [**@crimera**](https://github.com/crimera) - *Issues fixer*
- [**@Citeli-py**](https://github.com/Citeli-py) - *Issues fixer*
- [**@Rahul-Sahani04**](https://github.com/Rahul-Sahani04) - *Issues fixer*
- [**@xSamiVS**](https://github.com/xSamiVS) - *Language Translator*
---
@@ -224,7 +429,7 @@ This launcher is created for **educational purposes only**.
🛑 **Takedown Policy** - If Hypixel Studios or Hytale requests removal, this project will be taken down immediately.
❤️ **Support Official** - Please support the official game by purchasing it when available.
❤️ **Support Official** - Please support the official game by **purchasing** it legally when available.
---
@@ -232,7 +437,8 @@ This launcher is created for **educational purposes only**.
**⭐ Star this project if you found it helpful! ⭐**
*Made with ❤️ by [@amiayweb](https://github.com/amiayweb) and the amazing community*
*Made with ❤️ by [@amiayweb](https://github.com/amiayweb) and the legendary contributors with amazing community*
[![Star History Chart](https://api.star-history.com/svg?repos=amiayweb/Hytale-F2P&type=date&legend=top-left)](https://www.star-history.com/#amiayweb/Hytale-F2P&type=date&legend=top-left)
</div>

View File

@@ -188,7 +188,7 @@ Set these before running to customize your server:
| Variable | Default | Description |
|----------|---------|-------------|
| `HYTALE_SERVER_URL` | (placeholder) | URL to download pre-patched server JAR |
| `HYTALE_AUTH_DOMAIN` | `sanasol.ws` | Auth server domain |
| `HYTALE_AUTH_DOMAIN` | `auth.sanasol.ws` | Auth server domain (4-16 chars) |
| `HYTALE_BIND` | `0.0.0.0:5520` | Server IP and port |
| `HYTALE_AUTH_MODE` | `authenticated` | Auth mode (see below) |
| `HYTALE_SERVER_NAME` | `My Hytale Server` | Server display name |
@@ -400,7 +400,7 @@ docker run -d \
--name hytale-server \
-p 5520:5520/udp \
-v ./data:/data \
-e HYTALE_AUTH_DOMAIN=sanasol.ws \
-e HYTALE_AUTH_DOMAIN=auth.sanasol.ws \
-e HYTALE_SERVER_NAME="My Server" \
-e JVM_XMX=8G \
ghcr.io/hybrowse/hytale-server-docker:latest

View File

@@ -15,12 +15,6 @@ class AppUpdater {
}
setupAutoUpdater() {
// Enable dev mode for testing (reads dev-app-update.yml)
// Only enable in development, not in production builds
if (process.env.NODE_ENV === 'development' || !app.isPackaged) {
autoUpdater.forceDevUpdateConfig = true;
console.log('Dev update mode enabled - using dev-app-update.yml');
}
// Configure logger for electron-updater
// Create a compatible logger interface
@@ -176,7 +170,7 @@ class AppUpdater {
console.warn('macOS update error: App may not be code-signed. Auto-update requires code signing.');
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('update-error', {
message: 'Auto-update requires code signing. Please download manually from GitHub.',
message: 'Please download manually from GitHub.',
code: err.code,
isMacSigningError: true,
requiresManualDownload: true,

View File

@@ -4,7 +4,7 @@ const os = require('os');
// Default auth domain - can be overridden by env var or config
const DEFAULT_AUTH_DOMAIN = 'sanasol.ws';
const DEFAULT_AUTH_DOMAIN = 'auth.sanasol.ws';
// Get auth domain from env, config, or default
function getAuthDomain() {
@@ -26,9 +26,10 @@ function getAuthDomain() {
}
// Get full auth server URL
// Domain already includes subdomain (auth.sanasol.ws), so use directly
function getAuthServerUrl() {
const domain = getAuthDomain();
return `https://sessions.${domain}`;
return `https://${domain}`;
}
// Save auth domain to config
@@ -165,6 +166,15 @@ function loadCloseLauncherOnStart() {
return config.closeLauncherOnStart !== undefined ? config.closeLauncherOnStart : false;
}
function saveLauncherHardwareAcceleration(enabled) {
saveConfig({ launcherHardwareAcceleration: !!enabled });
}
function loadLauncherHardwareAcceleration() {
const config = loadConfig();
return config.launcherHardwareAcceleration !== undefined ? config.launcherHardwareAcceleration : true;
}
function saveModsToConfig(mods) {
try {
const config = loadConfig();
@@ -304,6 +314,30 @@ function loadGpuPreference() {
return config.gpuPreference || 'auto';
}
function saveVersionClient(versionClient) {
saveConfig({ version_client: versionClient });
}
function loadVersionClient() {
const config = loadConfig();
return config.version_client !== undefined ? config.version_client : null;
}
function saveVersionBranch(versionBranch) {
const branch = versionBranch || 'release';
if (branch !== 'release' && branch !== 'pre-release') {
console.warn(`Invalid branch "${branch}", defaulting to "release"`);
saveConfig({ version_branch: 'release' });
} else {
saveConfig({ version_branch: branch });
}
}
function loadVersionBranch() {
const config = loadConfig();
return config.version_branch || 'release';
}
module.exports = {
loadConfig,
saveConfig,
@@ -343,5 +377,15 @@ module.exports = {
loadGpuPreference,
// Close Launcher export
saveCloseLauncherOnStart,
loadCloseLauncherOnStart
loadCloseLauncherOnStart,
// Hardware Acceleration functions
saveLauncherHardwareAcceleration,
loadLauncherHardwareAcceleration,
// Version Management exports
saveVersionClient,
loadVersionClient,
saveVersionBranch,
loadVersionBranch
};

View File

@@ -1,6 +1,7 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { loadVersionBranch } = require('./config');
function getAppDir() {
const home = os.homedir();
@@ -48,8 +49,20 @@ function expandHome(inputPath) {
const APP_DIR = DEFAULT_APP_DIR;
const CACHE_DIR = path.join(APP_DIR, 'cache');
const TOOLS_DIR = path.join(APP_DIR, 'butler');
const GAME_DIR = path.join(APP_DIR, 'release', 'package', 'game', 'latest');
const JRE_DIR = path.join(APP_DIR, 'release', 'package', 'jre', 'latest');
// Dynamic GAME_DIR and JRE_DIR based on version_branch from config
function getGameDir() {
const branch = loadVersionBranch();
return path.join(APP_DIR, branch, 'package', 'game', 'latest');
}
function getJreDir() {
const branch = loadVersionBranch();
return path.join(APP_DIR, branch, 'package', 'jre', 'latest');
}
const GAME_DIR = getGameDir();
const JRE_DIR = getJreDir();
const PLAYER_ID_FILE = path.join(APP_DIR, 'player_id.json');
function getClientCandidates(gameLatest) {
@@ -156,7 +169,8 @@ async function getModsPath(customInstallPath = null) {
installPath = getAppDir();
}
const gameLatest = path.join(installPath, 'release', 'package', 'game', 'latest');
const branch = loadVersionBranch();
const gameLatest = path.join(installPath, branch, 'package', 'game', 'latest');
const userDataPath = findUserDataPath(gameLatest);
@@ -195,7 +209,8 @@ function getProfilesDir(customInstallPath = null) {
}
if (!installPath) installPath = getAppDir();
const gameLatest = path.join(installPath, 'release', 'package', 'game', 'latest');
const branch = loadVersionBranch();
const gameLatest = path.join(installPath, branch, 'package', 'game', 'latest');
const userDataPath = findUserDataPath(gameLatest);
const profilesDir = path.join(userDataPath, 'Profiles');
@@ -219,6 +234,8 @@ module.exports = {
TOOLS_DIR,
GAME_DIR,
JRE_DIR,
getGameDir,
getJreDir,
PLAYER_ID_FILE,
getClientCandidates,
findClientPath,

View File

@@ -19,6 +19,12 @@ const {
loadLanguage,
saveCloseLauncherOnStart,
loadCloseLauncherOnStart,
// Hardware Acceleration
saveLauncherHardwareAcceleration,
loadLauncherHardwareAcceleration,
saveModsToConfig,
loadModsFromConfig,
getUuidForUser,
@@ -33,7 +39,12 @@ const {
resetCurrentUserUuid,
// GPU Preference
saveGpuPreference,
loadGpuPreference
loadGpuPreference,
// Version Management
saveVersionClient,
loadVersionClient,
saveVersionBranch,
loadVersionBranch
} = require('./core/config');
const { getResolvedAppDir, getModsPath } = require('./core/paths');
@@ -71,7 +82,6 @@ const {
// Services
const {
getInstalledClientVersion,
getLatestClientVersion
} = require('./services/versionManager');
@@ -130,14 +140,21 @@ module.exports = {
saveCloseLauncherOnStart,
loadCloseLauncherOnStart,
// Hardware Acceleration functions
saveLauncherHardwareAcceleration,
loadLauncherHardwareAcceleration,
// GPU Preference functions
saveGpuPreference,
loadGpuPreference,
detectGpu,
// Version functions
getInstalledClientVersion,
getLatestClientVersion,
saveVersionClient,
loadVersionClient,
saveVersionBranch,
loadVersionBranch,
// News functions
getHytaleNews,

View File

@@ -85,7 +85,7 @@ class Logger {
fs.appendFileSync(this.logFile, message, 'utf8');
} catch (error) {
this.originalConsole.error('Impossible d\'écrire dans le fichier de log:', error.message);
this.originalConsole.error('Unable to write to log file:', error.message);
}
}

View File

@@ -7,9 +7,9 @@ const { spawn } = require('child_process');
const { v4: uuidv4 } = require('uuid');
const { getResolvedAppDir, findClientPath } = require('../core/paths');
const { setupWaylandEnvironment, setupGpuEnvironment } = require('../utils/platformUtils');
const { saveUsername, saveInstallPath, loadJavaPath, getUuidForUser, getAuthServerUrl, getAuthDomain } = require('../core/config');
const { saveUsername, saveInstallPath, loadJavaPath, getUuidForUser, getAuthServerUrl, getAuthDomain, loadVersionBranch, loadVersionClient, saveVersionClient } = require('../core/config');
const { resolveJavaPath, getJavaExec, getBundledJavaPath, detectSystemJava, JAVA_EXECUTABLE } = require('./javaManager');
const { getInstalledClientVersion, getLatestClientVersion } = require('../services/versionManager');
const { getLatestClientVersion } = require('../services/versionManager');
const { updateGameFiles } = require('./gameManager');
const { syncModsForCurrentProfile } = require('./modManager');
@@ -101,10 +101,11 @@ function generateLocalTokens(uuid, name) {
};
}
async function launchGame(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto') {
async function launchGame(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null) {
const branch = branchOverride || loadVersionBranch();
const customAppDir = getResolvedAppDir(installPathOverride);
const customGameDir = path.join(customAppDir, 'release', 'package', 'game', 'latest');
const customJreDir = path.join(customAppDir, 'release', 'package', 'jre', 'latest');
const customGameDir = path.join(customAppDir, branch, 'package', 'game', 'latest');
const customJreDir = path.join(customAppDir, branch, 'package', 'jre', 'latest');
const userDataDir = path.join(customGameDir, 'Client', 'UserData');
const gameLatest = customGameDir;
@@ -151,25 +152,23 @@ async function launchGame(playerName = 'Player', progressCallback, javaPathOverr
const { identityToken, sessionToken } = await fetchAuthTokens(uuid, playerName);
// Patch client and server binaries to use custom auth server (BEFORE signing on macOS)
// FORCE patch on every launch to ensure consistency
const authDomain = getAuthDomain();
if (clientPatcher) {
try {
if (progressCallback) {
progressCallback('Patching game for custom server...', null, null, null, null);
}
console.log(`Patching game binaries for ${authDomain}...`);
console.log(`Force patching game binaries for ${authDomain}...`);
const patchResult = await clientPatcher.ensureClientPatched(gameLatest, (msg, percent) => {
console.log(`[Patcher] ${msg}`);
// console.log(`[Patcher] ${msg}`);
if (progressCallback && msg) {
progressCallback(msg, percent, null, null, null);
}
});
if (patchResult.success) {
if (patchResult.alreadyPatched) {
console.log(`Game already patched for ${authDomain}`);
} else {
console.log(`Game patched successfully (${patchResult.patchCount} total occurrences)`);
if (patchResult.client) {
console.log(` Client: ${patchResult.client.patchCount || 0} occurrences`);
@@ -177,7 +176,6 @@ async function launchGame(playerName = 'Player', progressCallback, javaPathOverr
if (patchResult.server) {
console.log(` Server: ${patchResult.server.patchCount || 0} occurrences`);
}
}
} else {
console.warn('Game patching failed:', patchResult.error);
}
@@ -333,6 +331,7 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
}
});
// Monitor game process status in background
setTimeout(() => {
if (!hasExited) {
console.log('Game appears to be running successfully');
@@ -345,6 +344,7 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
}
}, 3000);
// Return immediately, don't wait for setTimeout
return { success: true, installed: true, launched: true, pid: child.pid };
} catch (spawnError) {
console.error(`Error spawning game process: ${spawnError.message}`);
@@ -355,23 +355,23 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
}
}
async function launchGameWithVersionCheck(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto') {
async function launchGameWithVersionCheck(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null) {
try {
const branch = branchOverride || loadVersionBranch();
if (progressCallback) {
progressCallback('Checking for updates...', 0, null, null, null);
}
const [installedVersion, latestVersion] = await Promise.all([
getInstalledClientVersion(),
getLatestClientVersion()
]);
const installedVersion = loadVersionClient();
const latestVersion = await getLatestClientVersion(branch);
console.log(`Installed version: ${installedVersion}, Latest version: ${latestVersion}`);
console.log(`Installed version: ${installedVersion}, Latest version: ${latestVersion} (branch: ${branch})`);
let needsUpdate = false;
if (installedVersion && latestVersion && installedVersion !== latestVersion) {
if (!installedVersion || installedVersion !== latestVersion) {
needsUpdate = true;
console.log('Version mismatch detected, update required');
console.log('Version mismatch or not installed, update required');
}
if (needsUpdate) {
@@ -380,13 +380,13 @@ async function launchGameWithVersionCheck(playerName = 'Player', progressCallbac
}
const customAppDir = getResolvedAppDir(installPathOverride);
const customGameDir = path.join(customAppDir, 'release', 'package', 'game', 'latest');
const customGameDir = path.join(customAppDir, branch, 'package', 'game', 'latest');
const customToolsDir = path.join(customAppDir, 'butler');
const customCacheDir = path.join(customAppDir, 'cache');
try {
await updateGameFiles(latestVersion, progressCallback, customGameDir, customToolsDir, customCacheDir);
console.log('Game updated successfully, waiting before launch...');
await updateGameFiles(latestVersion, progressCallback, customGameDir, customToolsDir, customCacheDir, branch);
console.log('Game updated successfully, patching will be forced on launch...');
if (progressCallback) {
progressCallback('Preparing game launch...', 90, null, null, null);
@@ -406,13 +406,22 @@ async function launchGameWithVersionCheck(playerName = 'Player', progressCallbac
progressCallback('Launching game...', 80, null, null, null);
}
return await launchGame(playerName, progressCallback, javaPathOverride, installPathOverride, gpuPreference);
const launchResult = await launchGame(playerName, progressCallback, javaPathOverride, installPathOverride, gpuPreference, branch);
// Ensure we always return a result
if (!launchResult) {
console.error('launchGame returned null/undefined, creating fallback response');
return { success: false, error: 'Game launch failed - no response from launcher' };
}
return launchResult;
} catch (error) {
console.error('Error in version check and launch:', error);
if (progressCallback) {
progressCallback(`Error: ${error.message}`, -1, null, null, null);
}
throw error;
// Always return an error response instead of throwing
return { success: false, error: error.message || 'Unknown launch error' };
}
}

View File

@@ -3,14 +3,15 @@ const path = require('path');
const { execFile } = require('child_process');
const { getResolvedAppDir, findClientPath, findUserDataPath, findUserDataRecursive, GAME_DIR, CACHE_DIR, TOOLS_DIR } = require('../core/paths');
const { getOS, getArch } = require('../utils/platformUtils');
const { downloadFile } = require('../utils/fileManager');
const { downloadFile, retryDownload, retryStalledDownload, MAX_AUTOMATIC_STALL_RETRIES } = require('../utils/fileManager');
const { getLatestClientVersion, getInstalledClientVersion } = require('../services/versionManager');
const { installButler } = require('./butlerManager');
const { downloadAndReplaceHomePageUI, downloadAndReplaceLogo } = require('./uiFileManager');
const { saveUsername, saveInstallPath, loadJavaPath, CONFIG_FILE, loadConfig } = require('../core/config');
const { saveUsername, saveInstallPath, loadJavaPath, CONFIG_FILE, loadConfig, loadVersionBranch, saveVersionClient, loadVersionClient } = require('../core/config');
const { resolveJavaPath, detectSystemJava, downloadJRE, getJavaExec, getBundledJavaPath } = require('./javaManager');
const userDataBackup = require('../utils/userDataBackup');
async function downloadPWR(version = 'release', fileName = '4.pwr', progressCallback, cacheDir = CACHE_DIR) {
async function downloadPWR(branch = 'release', fileName = '4.pwr', progressCallback, cacheDir = CACHE_DIR, manualRetry = false) {
const osName = getOS();
const arch = getArch();
@@ -18,24 +19,143 @@ async function downloadPWR(version = 'release', fileName = '4.pwr', progressCall
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}/${version}/0/${fileName}`;
const url = `https://game-patches.hytale.com/patches/${osName}/${arch}/${branch}/0/${fileName}`;
const dest = path.join(cacheDir, `${branch}_${fileName}`);
const dest = path.join(cacheDir, fileName);
if (fs.existsSync(dest)) {
// Check if file exists and validate it
if (fs.existsSync(dest) && !manualRetry) {
console.log('PWR file found in cache:', dest);
return 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;
}
}
console.log('Fetching PWR patch file:', url);
try {
if (manualRetry) {
await retryDownload(url, dest, progressCallback);
} else {
await downloadFile(url, dest, progressCallback);
}
} catch (error) {
// Check for automatic stall retry conditions (only for stall errors, not manual retries)
if (!manualRetry &&
error.message &&
error.message.includes('stalled') &&
error.canRetry !== false && // Explicitly check it's not false
(!error.retryState || error.retryState.automaticStallRetries < MAX_AUTOMATIC_STALL_RETRIES)) {
console.log(`[PWR] Automatic stall retry triggered (${(error.retryState && error.retryState.automaticStallRetries || 0) + 1}/${MAX_AUTOMATIC_STALL_RETRIES})`);
try {
await retryStalledDownload(url, dest, progressCallback, error);
console.log('[PWR] Automatic stall retry successful');
// After successful automatic retry, continue with normal flow - the file should be valid now
const retryStats = fs.statSync(dest);
console.log(`PWR file downloaded (auto-retry), size: ${(retryStats.size / 1024 / 1024).toFixed(2)} MB`);
if (!validatePWRFile(dest)) {
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');
}
} catch (retryError) {
console.error('[PWR] Automatic stall retry failed:', retryError.message);
// Create enhanced error with updated retry state
const enhancedError = new Error(`PWR download failed after automatic retries: ${retryError.message}`);
enhancedError.originalError = retryError;
enhancedError.retryState = retryError.retryState || error.retryState || null;
enhancedError.canRetry = true; // Still allow manual retry
enhancedError.pwrUrl = url;
enhancedError.pwrDest = dest;
enhancedError.branch = branch;
enhancedError.fileName = fileName;
enhancedError.cacheDir = cacheDir;
enhancedError.automaticRetriesExhausted = true;
throw enhancedError;
}
}
// Enhanced error handling for retry UI (non-stall errors or exhausted automatic retries)
const enhancedError = new Error(`PWR download failed: ${error.message}`);
enhancedError.originalError = error;
enhancedError.retryState = error.retryState || null;
enhancedError.canRetry = error.isConnectionLost ? false : (error.canRetry !== false); // Don't allow retry for connection lost
enhancedError.pwrUrl = url;
enhancedError.pwrDest = dest;
enhancedError.branch = branch;
enhancedError.fileName = fileName;
enhancedError.cacheDir = cacheDir;
enhancedError.isConnectionLost = error.isConnectionLost || false;
console.log(`[PWR] Error handling:`, {
message: enhancedError.message,
isConnectionLost: enhancedError.isConnectionLost,
canRetry: enhancedError.canRetry,
retryState: enhancedError.retryState
});
throw enhancedError;
}
// 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)) {
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');
}
console.log('PWR saved to:', dest);
console.log(`[PWR Validation] PWR file validation passed: ${dest}`);
return dest;
}
async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir = TOOLS_DIR) {
// Manual retry function for PWR downloads
async function retryPWRDownload(branch, fileName, progressCallback, cacheDir = CACHE_DIR) {
console.log('Initiating manual PWR retry...');
return await downloadPWR(branch, fileName, progressCallback, cacheDir, true);
}
async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir = TOOLS_DIR, branch = 'release', cacheDir = CACHE_DIR) {
console.log(`[Butler] Starting PWR application with:`);
console.log(`[Butler] - PWR file: ${pwrFile}`);
console.log(`[Butler] - Staging dir: ${path.join(gameDir, 'staging-temp')}`);
console.log(`[Butler] - Game dir: ${gameDir}`);
console.log(`[Butler] - Branch: ${branch}`);
console.log(`[Butler] - Cache dir: ${cacheDir}`);
// Validate PWR file exists and get diagnostic info
if (!pwrFile || typeof pwrFile !== 'string' || !fs.existsSync(pwrFile)) {
throw new Error(`PWR file not found: ${pwrFile || 'undefined'}. Please retry download.`);
}
const pwrStats = fs.statSync(pwrFile);
console.log(`[Butler] PWR file size: ${(pwrStats.size / 1024 / 1024).toFixed(2)} MB`);
console.log(`[Butler] PWR file exists: ${fs.existsSync(pwrFile)}`);
const butlerPath = await installButler(toolsDir);
console.log(`[Butler] Butler path: ${butlerPath}`);
console.log(`[Butler] Butler executable: ${fs.existsSync(butlerPath)}`);
const gameLatest = gameDir;
const stagingDir = path.join(gameLatest, 'staging-temp');
@@ -46,12 +166,11 @@ async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir
return;
}
if (!fs.existsSync(gameLatest)) {
fs.mkdirSync(gameLatest, { recursive: true });
}
if (!fs.existsSync(stagingDir)) {
fs.mkdirSync(stagingDir, { recursive: true });
}
// Validate and prepare directories
validateGameDirectory(gameLatest, stagingDir);
console.log(`[Butler] Game directory validated: ${gameLatest}`);
console.log(`[Butler] Staging directory validated: ${stagingDir}`);
if (progressCallback) {
progressCallback('Installing game patch...', null, null, null, null);
@@ -75,6 +194,8 @@ async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir
gameLatest
];
console.log(`[Butler] Executing command: ${butlerPath} ${args.join(' ')}`);
try {
await new Promise((resolve, reject) => {
const child = execFile(butlerPath, args, {
@@ -82,16 +203,97 @@ async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir
timeout: 600000
}, (error, stdout, stderr) => {
if (error) {
console.error('Butler stderr:', stderr);
console.error('Butler stdout:', stdout);
reject(new Error(`Patch installation failed: ${error.message}${stderr ? '\n' + stderr : ''}`));
console.error('[Butler] stderr:', stderr);
console.error('[Butler] stdout:', stdout);
console.error('[Butler] error code:', error.code);
console.error('[Butler] error signal:', error.signal);
// Enhanced error pattern detection
const errorPatterns = {
'unexpected EOF': {
message: 'Corrupted PWR file detected and deleted. Please try launching the game again.',
shouldDeletePWR: true
},
'permission denied': {
message: 'Permission denied. Check file permissions and try again.',
shouldDeletePWR: false
},
'no space left': {
message: 'Insufficient disk space. Free up space and try again.',
shouldDeletePWR: false
},
'device full': {
message: 'Insufficient disk space. Free up space and try again.',
shouldDeletePWR: false
},
'already exists': {
message: 'Installation directory conflict. Clean directories and retry.',
shouldDeletePWR: false
},
'network error': {
message: 'Network error during patch installation. Please retry.',
shouldDeletePWR: false
},
'connection refused': {
message: 'Connection refused. Check network and retry.',
shouldDeletePWR: false
}
};
let enhancedMessage = `Patch installation failed: ${error.message}${stderr ? '\n' + stderr : ''}`;
let shouldDeletePWR = false;
// Check error patterns
const errorText = (stderr + ' ' + error.message).toLowerCase();
for (const [pattern, config] of Object.entries(errorPatterns)) {
if (errorText.includes(pattern)) {
enhancedMessage = config.message;
shouldDeletePWR = config.shouldDeletePWR;
console.log(`[Butler] Pattern matched: ${pattern}`);
break;
}
}
// Delete corrupted PWR file if needed
if (shouldDeletePWR) {
try {
if (fs.existsSync(pwrFile)) {
fs.unlinkSync(pwrFile);
console.log('[Butler] Corrupted PWR file deleted:', pwrFile);
}
} catch (delErr) {
console.error('[Butler] Failed to delete corrupted PWR file:', delErr);
}
}
// Enhanced error with retry context
const enhancedError = new Error(enhancedMessage);
enhancedError.canRetry = true;
enhancedError.branch = branch;
enhancedError.fileName = path.basename(pwrFile);
enhancedError.cacheDir = cacheDir;
enhancedError.butlerError = true;
enhancedError.errorCode = error.code;
enhancedError.stderr = stderr;
enhancedError.stdout = stdout;
console.log('[Butler] Enhanced error created with retry context');
reject(enhancedError);
} else {
console.log('[Butler] Patch installation completed successfully');
resolve();
}
});
});
} catch (error) {
throw error;
console.error('[Butler] Exception during Butler execution:', error);
const enhancedError = new Error(`Butler execution failed: ${error.message}`);
enhancedError.canRetry = true;
enhancedError.branch = branch;
enhancedError.fileName = path.basename(pwrFile);
enhancedError.cacheDir = cacheDir;
enhancedError.butlerError = true;
throw enhancedError;
}
if (fs.existsSync(stagingDir)) {
@@ -104,13 +306,39 @@ async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir
console.log('Installation complete');
}
async function updateGameFiles(newVersion, progressCallback, gameDir = GAME_DIR, toolsDir = TOOLS_DIR, cacheDir = CACHE_DIR) {
async function updateGameFiles(newVersion, progressCallback, gameDir = GAME_DIR, toolsDir = TOOLS_DIR, cacheDir = CACHE_DIR, branchOverride = null) {
let tempUpdateDir;
let backupPath = null;
const branch = branchOverride || loadVersionBranch();
const installPath = path.dirname(path.dirname(path.dirname(path.dirname(gameDir))));
// Vérifier si on a version_client et version_branch dans config.json
const config = loadConfig();
const hasVersionConfig = !!(config.version_client && config.version_branch);
const oldBranch = config.version_branch || 'release'; // L'ancienne branche pour le backup
console.log(`[UpdateGameFiles] hasVersionConfig: ${hasVersionConfig}`);
console.log(`[UpdateGameFiles] Switching from ${oldBranch} to ${branch}`);
try {
if (progressCallback) {
progressCallback('Updating game files...', 0, null, null, null);
progressCallback('Backing up user data...', 5, null, null, null);
}
console.log(`Updating game files to version: ${newVersion}`);
// Backup UserData AVANT de télécharger/installer (critical for same-branch updates)
try {
console.log(`[UpdateGameFiles] Attempting to backup UserData from old branch: ${oldBranch}`);
backupPath = await userDataBackup.backupUserData(installPath, oldBranch, hasVersionConfig);
if (backupPath) {
console.log(`[UpdateGameFiles] ✓ UserData backed up from ${oldBranch}: ${backupPath}`);
}
} catch (backupError) {
console.warn('[UpdateGameFiles] ✗ UserData backup failed:', backupError.message);
}
if (progressCallback) {
progressCallback('Updating game files...', 10, null, null, null);
}
console.log(`Updating game files to version: ${newVersion} (branch: ${branch})`);
tempUpdateDir = path.join(gameDir, '..', 'temp_update');
@@ -120,48 +348,21 @@ async function updateGameFiles(newVersion, progressCallback, gameDir = GAME_DIR,
fs.mkdirSync(tempUpdateDir, { recursive: true });
if (progressCallback) {
progressCallback('Downloading new game version...', 10, null, null, null);
progressCallback('Downloading new game version...', 20, null, null, null);
}
const pwrFile = await downloadPWR('release', newVersion, progressCallback, cacheDir);
const pwrFile = await downloadPWR(branch, newVersion, progressCallback, cacheDir);
if (progressCallback) {
progressCallback('Extracting new files...', 50, null, null, null);
progressCallback('Extracting new files...', 60, null, null, null);
}
await applyPWR(pwrFile, progressCallback, tempUpdateDir, toolsDir);
await applyPWR(pwrFile, progressCallback, tempUpdateDir, toolsDir, branch, cacheDir);
if (progressCallback) {
progressCallback('Replacing game files...', 80, null, null, null);
}
let userDataBackup = null;
const userDataPath = findUserDataRecursive(gameDir);
if (userDataPath && fs.existsSync(userDataPath)) {
userDataBackup = path.join(gameDir, '..', 'UserData_backup_' + Date.now());
console.log(`Backing up UserData from ${userDataPath} to: ${userDataBackup}`);
function copyRecursive(src, dest) {
const stat = fs.statSync(src);
if (stat.isDirectory()) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
const files = fs.readdirSync(src);
for (const file of files) {
copyRecursive(path.join(src, file), path.join(dest, file));
}
} else {
fs.copyFileSync(src, dest);
}
}
copyRecursive(userDataPath, userDataBackup);
} else {
console.log('No UserData folder found in game directory');
}
if (fs.existsSync(gameDir)) {
console.log('Removing old game files...');
fs.rmSync(gameDir, { recursive: true, force: true });
@@ -175,44 +376,38 @@ async function updateGameFiles(newVersion, progressCallback, gameDir = GAME_DIR,
const logoResult = await downloadAndReplaceLogo(gameDir, progressCallback);
console.log('Logo@2x.png update result after update:', logoResult);
if (userDataBackup && fs.existsSync(userDataBackup)) {
const newUserDataPath = findUserDataPath(gameDir);
const userDataParent = path.dirname(newUserDataPath);
if (!fs.existsSync(userDataParent)) {
fs.mkdirSync(userDataParent, { recursive: true });
// Ensure UserData directory exists
const userDataDir = path.join(gameDir, 'Client', 'UserData');
if (!fs.existsSync(userDataDir)) {
console.log(`[UpdateGameFiles] Creating UserData directory at: ${userDataDir}`);
fs.mkdirSync(userDataDir, { recursive: true });
}
console.log(`Restoring UserData to: ${newUserDataPath}`);
function copyRecursive(src, dest) {
const stat = fs.statSync(src);
if (stat.isDirectory()) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
if (progressCallback) {
progressCallback('Restoring user data...', 90, null, null, null);
}
const files = fs.readdirSync(src);
for (const file of files) {
copyRecursive(path.join(src, file), path.join(dest, file));
// Restore UserData using new system
if (backupPath) {
try {
console.log(`[UpdateGameFiles] Restoring UserData from ${oldBranch} to ${branch}`);
console.log(`[UpdateGameFiles] Source backup: ${backupPath}`);
await userDataBackup.restoreUserData(backupPath, installPath, branch);
await userDataBackup.cleanupBackup(backupPath);
console.log(`[UpdateGameFiles] ✓ UserData migrated successfully from ${oldBranch} to ${branch}`);
} catch (restoreError) {
console.warn('[UpdateGameFiles] ✗ UserData restore failed:', restoreError.message);
}
} else {
fs.copyFileSync(src, dest);
}
}
copyRecursive(userDataBackup, newUserDataPath);
console.log('[UpdateGameFiles] No backup to restore, empty UserData folder created');
}
console.log(`Game files updated successfully to version: ${newVersion}`);
if (userDataBackup && fs.existsSync(userDataBackup)) {
try {
fs.rmSync(userDataBackup, { recursive: true, force: true });
console.log('UserData backup cleaned up');
} catch (cleanupError) {
console.warn('Could not clean up UserData backup:', cleanupError.message);
}
}
// Save the updated version and branch to config
saveVersionClient(newVersion);
const { saveVersionBranch } = require('../core/config');
saveVersionBranch(branch);
console.log('Waiting for file system sync...');
await new Promise(resolve => setTimeout(resolve, 2000));
@@ -225,9 +420,9 @@ async function updateGameFiles(newVersion, progressCallback, gameDir = GAME_DIR,
} catch (error) {
console.error('Error updating game files:', error);
if (userDataBackup && fs.existsSync(userDataBackup)) {
if (backupPath) {
try {
fs.rmSync(userDataBackup, { recursive: true, force: true });
await userDataBackup.cleanupBackup(backupPath);
console.log('UserData backup cleaned up after error');
} catch (cleanupError) {
console.warn('Could not clean up UserData backup:', cleanupError.message);
@@ -242,21 +437,49 @@ async function updateGameFiles(newVersion, progressCallback, gameDir = GAME_DIR,
}
}
function isGameInstalled() {
function isGameInstalled(branchOverride = null) {
const branch = branchOverride || loadVersionBranch();
const appDir = getResolvedAppDir();
const gameDir = path.join(appDir, 'release', 'package', 'game', 'latest');
const gameDir = path.join(appDir, branch, 'package', 'game', 'latest');
const clientPath = findClientPath(gameDir);
return clientPath !== null;
}
async function installGame(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride) {
async function installGame(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride, branchOverride = null) {
console.log(`[InstallGame] branchOverride parameter received: ${branchOverride}`);
const loadedBranch = loadVersionBranch();
console.log(`[InstallGame] loadVersionBranch() returned: ${loadedBranch}`);
const branch = branchOverride || loadedBranch;
console.log(`[InstallGame] Final branch selected: ${branch}`);
const customAppDir = getResolvedAppDir(installPathOverride);
const customCacheDir = path.join(customAppDir, 'cache');
const customToolsDir = path.join(customAppDir, 'butler');
const customGameDir = path.join(customAppDir, 'release', 'package', 'game', 'latest');
const customJreDir = path.join(customAppDir, 'release', 'package', 'jre', 'latest');
const customGameDir = path.join(customAppDir, branch, 'package', 'game', 'latest');
const customJreDir = path.join(customAppDir, branch, 'package', 'jre', 'latest');
const userDataDir = path.join(customGameDir, 'Client', 'UserData');
// Vérifier si on a version_client et version_branch dans config.json
const config = loadConfig();
const hasVersionConfig = !!(config.version_client && config.version_branch);
console.log(`[InstallGame] Configuration detected - version_client: ${config.version_client}, version_branch: ${config.version_branch}`);
console.log(`[InstallGame] hasVersionConfig: ${hasVersionConfig}`);
// Backup UserData AVANT l'installation si nécessaire
let backupPath = null;
if (progressCallback) {
progressCallback('Checking for existing UserData...', 5, null, null, null);
}
try {
console.log(`[InstallGame] Attempting UserData backup (hasVersionConfig: ${hasVersionConfig})...`);
backupPath = await userDataBackup.backupUserData(customAppDir, branch, hasVersionConfig);
if (backupPath) {
console.log(`[InstallGame] ✓ UserData backed up to: ${backupPath}`);
}
} catch (backupError) {
console.warn('[InstallGame] ✗ UserData backup failed:', backupError.message);
}
[customAppDir, customCacheDir, customToolsDir].forEach(dir => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
@@ -297,9 +520,17 @@ async function installGame(playerName = 'Player', progressCallback, javaPathOver
try {
await downloadJRE(progressCallback, customCacheDir, customJreDir);
} catch (error) {
// Don't immediately fall back to system Java for JRE download errors - let user retry
if (error.isJREError) {
console.error('[Install] JRE download failed, allowing user retry:', error.message);
throw error; // Re-throw JRE errors to trigger retry UI
}
// For non-download JRE errors, fall back to system Java
const fallback = await detectSystemJava();
if (fallback) {
javaBin = fallback;
console.log('[Install] Using system Java as fallback');
} else {
throw error;
}
@@ -313,11 +544,36 @@ async function installGame(playerName = 'Player', progressCallback, javaPathOver
if (progressCallback) {
progressCallback('Fetching game files...', null, null, null, null);
}
console.log('Installing game files...');
console.log(`Installing game files for branch: ${branch}...`);
const latestVersion = await getLatestClientVersion();
const pwrFile = await downloadPWR('release', latestVersion, progressCallback, customCacheDir);
await applyPWR(pwrFile, progressCallback, customGameDir, customToolsDir);
const latestVersion = await getLatestClientVersion(branch);
let pwrFile;
try {
pwrFile = await downloadPWR(branch, latestVersion, progressCallback, customCacheDir);
// If downloadPWR returns false, it means the file doesn't exist or is invalid
// We should retry the download with a manual retry flag
if (!pwrFile) {
console.log('[Install] PWR file not found or invalid, attempting retry...');
pwrFile = await retryPWRDownload(branch, latestVersion, progressCallback, customCacheDir);
}
// Double-check we have a valid file path
if (!pwrFile || typeof pwrFile !== 'string') {
throw new Error(`PWR file download failed: received invalid path ${pwrFile}. Please retry download.`);
}
} catch (downloadError) {
console.error('[Install] PWR download failed:', downloadError.message);
throw downloadError; // Re-throw to be handled by the main installGame error handler
}
await applyPWR(pwrFile, progressCallback, customGameDir, customToolsDir, branch, customCacheDir);
// Save the installed version and branch to config
saveVersionClient(latestVersion);
const { saveVersionBranch } = require('../core/config');
saveVersionBranch(branch);
const homeUIResult = await downloadAndReplaceHomePageUI(customGameDir, progressCallback);
console.log('HomePage.ui update result after installation:', homeUIResult);
@@ -325,6 +581,30 @@ async function installGame(playerName = 'Player', progressCallback, javaPathOver
const logoResult = await downloadAndReplaceLogo(customGameDir, progressCallback);
console.log('Logo@2x.png update result after installation:', logoResult);
// Ensure UserData directory exists
if (!fs.existsSync(userDataDir)) {
console.log(`[InstallGame] Creating UserData directory at: ${userDataDir}`);
fs.mkdirSync(userDataDir, { recursive: true });
}
// Restore UserData from backup if exists
if (backupPath) {
if (progressCallback) {
progressCallback('Restoring UserData...', 95, null, null, null);
}
try {
console.log(`[InstallGame] Restoring UserData from: ${backupPath}`);
await userDataBackup.restoreUserData(backupPath, customAppDir, branch);
await userDataBackup.cleanupBackup(backupPath);
console.log('[InstallGame] ✓ UserData restored successfully');
} catch (restoreError) {
console.warn('[InstallGame] ✗ UserData restore failed:', restoreError.message);
}
} else {
console.log('[InstallGame] No backup to restore, empty UserData folder created');
}
if (progressCallback) {
progressCallback('Installation complete', 100, null, null, null);
}
@@ -357,8 +637,9 @@ async function uninstallGame() {
}
}
function checkExistingGameInstallation() {
function checkExistingGameInstallation(branchOverride = null) {
try {
const branch = branchOverride || loadVersionBranch();
const config = loadConfig();
if (!config.installPath || !config.installPath.trim()) {
@@ -366,7 +647,7 @@ function checkExistingGameInstallation() {
}
const installPath = config.installPath.trim();
const gameDir = path.join(installPath, 'HytaleF2P', 'release', 'package', 'game', 'latest');
const gameDir = path.join(installPath, 'HytaleF2P', branch, 'package', 'game', 'latest');
if (!fs.existsSync(gameDir)) {
return null;
@@ -384,7 +665,8 @@ function checkExistingGameInstallation() {
clientPath: clientPath,
userDataPath: userDataPath,
installPath: installPath,
hasUserData: userDataPath && fs.existsSync(userDataPath)
hasUserData: userDataPath && fs.existsSync(userDataPath),
branch: branch
};
} catch (error) {
console.error('Error checking existing game installation:', error);
@@ -392,40 +674,32 @@ function checkExistingGameInstallation() {
}
}
async function repairGame(progressCallback) {
async function repairGame(progressCallback, branchOverride = null) {
const branch = branchOverride || loadVersionBranch();
const appDir = getResolvedAppDir();
const gameDir = path.join(appDir, 'release', 'package', 'game', 'latest');
const gameDir = path.join(appDir, branch, 'package', 'game', 'latest');
const installPath = appDir;
let backupPath = null;
// Vérifier si on a version_client et version_branch dans config.json
const config = loadConfig();
const hasVersionConfig = !!(config.version_client && config.version_branch);
console.log(`[RepairGame] hasVersionConfig: ${hasVersionConfig}`);
// Check if game exists
if (!fs.existsSync(gameDir)) {
throw new Error('Game directory not found. Cannot repair.');
}
// Locate UserData
const userDataPath = findUserDataRecursive(gameDir);
let userDataBackup = null;
if (progressCallback) {
progressCallback('Backing up user data...', 10, null, null, null);
}
// Backup UserData
if (userDataPath && fs.existsSync(userDataPath)) {
userDataBackup = path.join(appDir, 'UserData_backup_repair_' + Date.now());
console.log(`Backing up UserData during repair from ${userDataPath} to ${userDataBackup}`);
// Copy function
function copyRecursive(src, dest) {
const stat = fs.statSync(src);
if (stat.isDirectory()) {
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
fs.readdirSync(src).forEach(child => copyRecursive(path.join(src, child), path.join(dest, child)));
} else {
fs.copyFileSync(src, dest);
}
}
copyRecursive(userDataPath, userDataBackup);
// Backup UserData using new system
try {
backupPath = await userDataBackup.backupUserData(installPath, branch, hasVersionConfig);
} catch (backupError) {
console.warn('UserData backup failed during repair:', backupError.message);
}
if (progressCallback) {
@@ -446,39 +720,21 @@ async function repairGame(progressCallback) {
// Passing null/undefined for overrides to use defaults/saved configs
// installGame calls progressCallback internally
await installGame('Player', progressCallback);
await installGame('Player', progressCallback, null, null, branch);
// Restore UserData
if (userDataBackup && fs.existsSync(userDataBackup)) {
// Restore UserData using new system
if (backupPath) {
if (progressCallback) {
progressCallback('Restoring user data...', 90, null, null, null);
}
// installGame creates: path.join(customGameDir, 'Client', 'UserData')
const newGameDir = path.join(appDir, 'release', 'package', 'game', 'latest');
const newUserDataPath = path.join(newGameDir, 'Client', 'UserData');
if (!fs.existsSync(newUserDataPath)) {
fs.mkdirSync(newUserDataPath, { recursive: true });
try {
await userDataBackup.restoreUserData(backupPath, installPath, branch);
await userDataBackup.cleanupBackup(backupPath);
console.log('UserData restored successfully after repair');
} catch (restoreError) {
console.warn('UserData restore failed after repair:', restoreError.message);
}
console.log(`Restoring UserData to ${newUserDataPath}`);
function copyRecursive(src, dest) {
const stat = fs.statSync(src);
if (stat.isDirectory()) {
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
fs.readdirSync(src).forEach(child => copyRecursive(path.join(src, child), path.join(dest, child)));
} else {
fs.copyFileSync(src, dest);
}
}
copyRecursive(userDataBackup, newUserDataPath);
// Cleanup Backup
console.log('Cleaning up repair backup...');
fs.rmSync(userDataBackup, { recursive: true, force: true });
}
if (progressCallback) {
@@ -488,8 +744,79 @@ async function repairGame(progressCallback) {
return { success: true, repaired: true };
}
// Directory validation and cleanup function
function validateGameDirectory(gameDir, stagingDir) {
try {
// Ensure game directory exists and is writable
if (!fs.existsSync(gameDir)) {
fs.mkdirSync(gameDir, { recursive: true });
console.log(`[Butler] Created game directory: ${gameDir}`);
}
// Test write permissions
const testFile = path.join(gameDir, '.permission_test');
fs.writeFileSync(testFile, 'test');
fs.unlinkSync(testFile);
console.log(`[Butler] Game directory is writable: ${gameDir}`);
// Clean and ensure staging directory
if (fs.existsSync(stagingDir)) {
console.log(`[Butler] Cleaning existing staging directory: ${stagingDir}`);
fs.rmSync(stagingDir, { recursive: true, force: true });
}
fs.mkdirSync(stagingDir, { recursive: true });
console.log(`[Butler] Created clean staging directory: ${stagingDir}`);
// Check disk space (basic check)
const freeSpace = fs.statSync(gameDir);
console.log(`[Butler] Directory validation completed successfully`);
} catch (error) {
throw new Error(`Directory validation failed: ${error.message}. Please check permissions and disk space.`);
}
}
// Enhanced PWR file validation
function validatePWRFile(filePath) {
try {
if (!fs.existsSync(filePath)) {
return false;
}
const stats = fs.statSync(filePath);
const sizeInMB = stats.size / 1024 / 1024;
if (stats.size < 1024 * 1024) {
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`);
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}`);
return true;
} catch (error) {
console.error(`[PWR Validation] Error:`, error.message);
return false;
}
}
module.exports = {
downloadPWR,
retryPWRDownload,
applyPWR,
updateGameFiles,
isGameInstalled,

View File

@@ -9,7 +9,7 @@ const tar = require('tar');
const { expandHome, JRE_DIR } = require('../core/paths');
const { getOS, getArch } = require('../utils/platformUtils');
const { loadConfig } = require('../core/config');
const { downloadFile } = require('../utils/fileManager');
const { downloadFile, retryDownload } = require('../utils/fileManager');
const execFileAsync = promisify(execFile);
const JAVA_EXECUTABLE = 'java' + (process.platform === 'win32' ? '.exe' : '');
@@ -188,6 +188,20 @@ async function getJavaDetection() {
};
}
// Manual retry function for JRE downloads
async function retryJREDownload(url, cacheFile, progressCallback) {
console.log('Initiating manual JRE retry...');
// Ensure cache directory exists before retrying
const cacheDir = path.dirname(cacheFile);
if (!fs.existsSync(cacheDir)) {
console.log('Creating JRE cache directory:', cacheDir);
fs.mkdirSync(cacheDir, { recursive: true });
}
return await retryDownload(url, cacheFile, progressCallback);
}
async function downloadJRE(progressCallback, cacheDir, jreDir = JRE_DIR) {
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
@@ -230,7 +244,40 @@ async function downloadJRE(progressCallback, cacheDir, jreDir = JRE_DIR) {
progressCallback('Fetching Java runtime...', null, null, null, null);
}
console.log('Fetching Java runtime...');
await downloadFile(platform.url, cacheFile, progressCallback);
let jreFile;
try {
jreFile = await downloadFile(platform.url, cacheFile, progressCallback);
// If downloadFile returns false or undefined, it means the download failed
// We should retry the download with a manual retry
if (!jreFile || typeof jreFile !== 'string') {
console.log('[JRE Download] JRE file download failed or incomplete, attempting retry...');
jreFile = await retryJREDownload(platform.url, cacheFile, progressCallback);
}
// Double-check we have a valid file
if (!jreFile || typeof jreFile !== 'string') {
throw new Error(`JRE download failed: received invalid path ${jreFile}. Please retry download.`);
}
} catch (downloadError) {
console.error('[JRE Download] JRE download failed:', downloadError.message);
// Enhance error with retry information for the UI
const enhancedError = new Error(`JRE download failed: ${downloadError.message}`);
enhancedError.originalError = downloadError;
enhancedError.canRetry = downloadError.isConnectionLost ? false : (downloadError.canRetry !== false);
enhancedError.jreUrl = platform.url;
enhancedError.jreDest = cacheFile;
enhancedError.osName = osName;
enhancedError.arch = arch;
enhancedError.fileName = fileName;
enhancedError.cacheDir = cacheDir;
enhancedError.isJREError = true; // Flag to identify JRE errors
enhancedError.isConnectionLost = downloadError.isConnectionLost || false;
throw enhancedError;
}
console.log('Download finished');
}
@@ -359,5 +406,6 @@ module.exports = {
getJavaDetection,
downloadJRE,
extractJRE,
retryJREDownload,
JAVA_EXECUTABLE
};

View File

@@ -6,7 +6,7 @@ const { getModsPath, getProfilesDir } = require('../core/paths');
const { saveModsToConfig, loadModsFromConfig } = require('../core/config');
const profileManager = require('./profileManager');
const API_KEY = process.env.CURSEFORGE_API_KEY;
const API_KEY = "$2a$10$bqk254NMZOWVTzLVJCcxEOmhcyUujKxA5xk.kQCN9q0KNYFJd5b32";
/**
* Get the physical mods path for a specific profile.

View File

@@ -1,6 +1,6 @@
const path = require('path');
const fs = require('fs');
const { markAsLaunched, loadConfig } = require('../core/config');
const { markAsLaunched, loadConfig, saveVersionBranch, saveVersionClient, loadVersionBranch, loadVersionClient } = require('../core/config');
const { checkExistingGameInstallation, updateGameFiles } = require('../managers/gameManager');
const { getInstalledClientVersion, getLatestClientVersion } = require('./versionManager');
@@ -56,6 +56,14 @@ async function handleFirstLaunchCheck(progressCallback) {
try {
const config = loadConfig();
// Initialize version_client if not set (but don't force version_branch)
const currentVersion = loadVersionClient();
if (currentVersion === undefined || currentVersion === null) {
console.log('Initializing version_client to null (will trigger installation)');
saveVersionClient(null);
}
if (config.hasLaunchedBefore === true) {
return { isFirstLaunch: false, needsUpdate: false };
}

View File

@@ -1,9 +1,10 @@
const axios = require('axios');
async function getLatestClientVersion() {
async function getLatestClientVersion(branch = 'release') {
try {
console.log('Fetching latest client version from API...');
console.log(`Fetching latest client version from API (branch: ${branch})...`);
const response = await axios.get('https://files.hytalef2p.com/api/version_client', {
params: { branch },
timeout: 5000,
headers: {
'User-Agent': 'Hytale-F2P-Launcher'
@@ -12,7 +13,7 @@ async function getLatestClientVersion() {
if (response.data && response.data.client_version) {
const version = response.data.client_version;
console.log(`Latest client version: ${version}`);
console.log(`Latest client version for ${branch}: ${version}`);
return version;
} else {
console.log('Warning: Invalid API response, falling back to default version');
@@ -25,32 +26,6 @@ async function getLatestClientVersion() {
}
}
async function getInstalledClientVersion() {
try {
console.log('Fetching installed client version from API...');
const response = await axios.get('https://files.hytalef2p.com/api/clientCheck', {
timeout: 5000,
headers: {
'User-Agent': 'Hytale-F2P-Launcher'
}
});
if (response.data && response.data.client_version) {
const version = response.data.client_version;
console.log(`Installed client version: ${version}`);
return version;
} else {
console.log('Warning: Invalid clientCheck API response');
return null;
}
} catch (error) {
console.error('Error fetching installed client version:', error.message);
console.log('Warning: clientCheck API unavailable');
return null;
}
}
module.exports = {
getLatestClientVersion,
getInstalledClientVersion
getLatestClientVersion
};

View File

@@ -1,73 +0,0 @@
const axios = require('axios');
const UPDATE_CHECK_URL = 'https://files.hytalef2p.com/api/version_launcher';
const CURRENT_VERSION = '2.0.2';
const GITHUB_DOWNLOAD_URL = 'https://github.com/amiayweb/Hytale-F2P/';
class UpdateManager {
constructor() {
this.updateAvailable = false;
this.remoteVersion = null;
}
async checkForUpdates() {
try {
console.log('Checking for updates...');
console.log(`Local version: ${CURRENT_VERSION}`);
const response = await axios.get(UPDATE_CHECK_URL, {
timeout: 5000,
headers: {
'User-Agent': 'Hytale-F2P-Launcher'
}
});
if (response.data && response.data.launcher_version) {
this.remoteVersion = response.data.launcher_version;
console.log(`Remote version: ${this.remoteVersion}`);
if (this.remoteVersion !== CURRENT_VERSION) {
this.updateAvailable = true;
console.log('Update available!');
return {
updateAvailable: true,
currentVersion: CURRENT_VERSION,
newVersion: this.remoteVersion,
downloadUrl: GITHUB_DOWNLOAD_URL
};
} else {
console.log('Launcher is up to date');
return {
updateAvailable: false,
currentVersion: CURRENT_VERSION,
newVersion: this.remoteVersion
};
}
} else {
throw new Error('Invalid API response');
}
} catch (error) {
console.error('Error checking for updates:', error.message);
return {
updateAvailable: false,
error: error.message,
currentVersion: CURRENT_VERSION
};
}
}
getDownloadUrl() {
return GITHUB_DOWNLOAD_URL;
}
getUpdateInfo() {
return {
updateAvailable: this.updateAvailable,
currentVersion: CURRENT_VERSION,
remoteVersion: this.remoteVersion,
downloadUrl: this.getDownloadUrl()
};
}
}
module.exports = UpdateManager;

View File

@@ -2,9 +2,14 @@ const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const AdmZip = require('adm-zip');
const { execSync, spawn } = require('child_process');
const { getJavaExec, getBundledJavaPath } = require('../managers/javaManager');
const { JRE_DIR } = require('../core/paths');
// Domain configuration
const ORIGINAL_DOMAIN = 'hytale.com';
const MIN_DOMAIN_LENGTH = 4;
const MAX_DOMAIN_LENGTH = 16;
function getTargetDomain() {
if (process.env.HYTALE_AUTH_DOMAIN) {
@@ -14,15 +19,22 @@ function getTargetDomain() {
const { getAuthDomain } = require('../core/config');
return getAuthDomain();
} catch (e) {
return 'sanasol.ws';
return 'auth.sanasol.ws';
}
}
const DEFAULT_NEW_DOMAIN = 'sanasol.ws';
const DEFAULT_NEW_DOMAIN = 'auth.sanasol.ws';
/**
* Patches HytaleClient and HytaleServer binaries to replace hytale.com with custom domain
* This allows the game to connect to a custom authentication server
*
* Supports domains from 4 to 16 characters:
* - All F2P traffic routes to single endpoint: https://{domain} (no subdomains)
* - Domains <= 10 chars: Direct replacement, subdomains stripped
* - Domains 11-16 chars: Split mode - first 6 chars replace subdomain prefix, rest replaces domain
*
* Official hytale.com keeps original subdomain behavior (sessions., account-data., etc.)
*/
class ClientPatcher {
constructor() {
@@ -34,14 +46,73 @@ class ClientPatcher {
*/
getNewDomain() {
const domain = getTargetDomain();
if (domain.length !== ORIGINAL_DOMAIN.length) {
console.warn(`Warning: Domain "${domain}" length (${domain.length}) doesn't match original "${ORIGINAL_DOMAIN}" (${ORIGINAL_DOMAIN.length})`);
if (domain.length < MIN_DOMAIN_LENGTH) {
console.warn(`Warning: Domain "${domain}" is too short (min ${MIN_DOMAIN_LENGTH} chars)`);
console.warn(`Using default domain: ${DEFAULT_NEW_DOMAIN}`);
return DEFAULT_NEW_DOMAIN;
}
if (domain.length > MAX_DOMAIN_LENGTH) {
console.warn(`Warning: Domain "${domain}" is too long (max ${MAX_DOMAIN_LENGTH} chars)`);
console.warn(`Using default domain: ${DEFAULT_NEW_DOMAIN}`);
return DEFAULT_NEW_DOMAIN;
}
return domain;
}
/**
* Calculate the domain patching strategy based on length
* @returns {object} Strategy with mainDomain and subdomainPrefix
*/
getDomainStrategy(domain) {
if (domain.length <= 10) {
// Direct replacement - subdomains will be stripped
return {
mode: 'direct',
mainDomain: domain,
subdomainPrefix: '', // Empty = subdomains stripped
description: `Direct replacement: hytale.com -> ${domain}`
};
} else {
// Split mode: first 6 chars become subdomain prefix, rest replaces hytale.com
const prefix = domain.slice(0, 6);
const suffix = domain.slice(6);
return {
mode: 'split',
mainDomain: suffix,
subdomainPrefix: prefix,
description: `Split mode: subdomain prefix="${prefix}", main domain="${suffix}"`
};
}
}
/**
* Convert a string to the length-prefixed byte format used by the client
* Format: [length byte] [00 00 00 padding] [char1] [00] [char2] [00] ... [lastChar]
* Note: No null byte after the last character
*/
stringToLengthPrefixed(str) {
const length = str.length;
const result = Buffer.alloc(4 + length + (length - 1)); // length byte + padding + chars + separators
// Length byte
result[0] = length;
// Padding: 00 00 00
result[1] = 0x00;
result[2] = 0x00;
result[3] = 0x00;
// Characters with null separators (no separator after last char)
let pos = 4;
for (let i = 0; i < length; i++) {
result[pos++] = str.charCodeAt(i);
if (i < length - 1) {
result[pos++] = 0x00;
}
}
return result;
}
/**
* Convert a string to UTF-16LE bytes (how .NET stores strings)
*/
@@ -75,6 +146,30 @@ class ClientPatcher {
return positions;
}
/**
* Replace bytes in buffer - only overwrites the length of new bytes
* Prevents offset corruption by not expanding the replacement
*/
replaceBytes(buffer, oldBytes, newBytes) {
let count = 0;
const result = Buffer.from(buffer);
if (newBytes.length > oldBytes.length) {
console.warn(` Warning: New pattern (${newBytes.length}) longer than old (${oldBytes.length}), skipping`);
return { buffer: result, count: 0 };
}
const positions = this.findAllOccurrences(result, oldBytes);
for (const pos of positions) {
// Only overwrite the length of the new bytes
newBytes.copy(result, pos);
count++;
}
return { buffer: result, count };
}
/**
* UTF-8 domain replacement for Java JAR files.
* Java stores strings in UTF-8 format in the constant pool.
@@ -109,8 +204,6 @@ class ClientPatcher {
const oldUtf16NoLast = this.stringToUtf16LE(oldDomain.slice(0, -1));
const newUtf16NoLast = this.stringToUtf16LE(newDomain.slice(0, -1));
const oldLastChar = this.stringToUtf16LE(oldDomain.slice(-1));
const newLastChar = this.stringToUtf16LE(newDomain.slice(-1));
const oldLastCharByte = oldDomain.charCodeAt(oldDomain.length - 1);
const newLastCharByte = newDomain.charCodeAt(newDomain.length - 1);
@@ -143,6 +236,67 @@ class ClientPatcher {
return { buffer: result, count };
}
/**
* Apply all domain patches using length-prefixed format
* This is the main patching method for variable-length domains
*/
applyDomainPatches(data, domain, protocol = 'https://') {
let result = Buffer.from(data);
let totalCount = 0;
const strategy = this.getDomainStrategy(domain);
console.log(` Patching strategy: ${strategy.description}`);
// 1. Patch telemetry/sentry URL
const oldSentry = 'https://ca900df42fcf57d4dd8401a86ddd7da2@sentry.hytale.com/2';
const newSentry = `${protocol}t@${domain}/2`;
console.log(` Patching sentry: ${oldSentry.slice(0, 30)}... -> ${newSentry}`);
const sentryResult = this.replaceBytes(
result,
this.stringToLengthPrefixed(oldSentry),
this.stringToLengthPrefixed(newSentry)
);
result = sentryResult.buffer;
if (sentryResult.count > 0) {
console.log(` Replaced ${sentryResult.count} sentry occurrence(s)`);
totalCount += sentryResult.count;
}
// 2. Patch main domain (hytale.com -> mainDomain)
console.log(` Patching domain: ${ORIGINAL_DOMAIN} -> ${strategy.mainDomain}`);
const domainResult = this.replaceBytes(
result,
this.stringToLengthPrefixed(ORIGINAL_DOMAIN),
this.stringToLengthPrefixed(strategy.mainDomain)
);
result = domainResult.buffer;
if (domainResult.count > 0) {
console.log(` Replaced ${domainResult.count} domain occurrence(s)`);
totalCount += domainResult.count;
}
// 3. Patch subdomain prefixes
const subdomains = ['https://tools.', 'https://sessions.', 'https://account-data.', 'https://telemetry.'];
const newSubdomainPrefix = protocol + strategy.subdomainPrefix;
for (const sub of subdomains) {
console.log(` Patching subdomain: ${sub} -> ${newSubdomainPrefix}`);
const subResult = this.replaceBytes(
result,
this.stringToLengthPrefixed(sub),
this.stringToLengthPrefixed(newSubdomainPrefix)
);
result = subResult.buffer;
if (subResult.count > 0) {
console.log(` Replaced ${subResult.count} occurrence(s)`);
totalCount += subResult.count;
}
}
return { buffer: result, count: totalCount };
}
/**
* Patch Discord invite URLs from .gg/hytale to .gg/MHkEjepMQ7
*/
@@ -153,6 +307,18 @@ class ClientPatcher {
const oldUrl = '.gg/hytale';
const newUrl = '.gg/MHkEjepMQ7';
// Try length-prefixed format first
const lpResult = this.replaceBytes(
result,
this.stringToLengthPrefixed(oldUrl),
this.stringToLengthPrefixed(newUrl)
);
if (lpResult.count > 0) {
return { buffer: lpResult.buffer, count: lpResult.count };
}
// Fallback to UTF-16LE
const oldUtf16 = this.stringToUtf16LE(oldUrl);
const newUtf16 = this.stringToUtf16LE(newUrl);
@@ -168,17 +334,31 @@ class ClientPatcher {
/**
* Check if the client binary has already been patched
* Also verifies the binary actually contains the patched domain
*/
isPatchedAlready(clientPath) {
const newDomain = this.getNewDomain();
const patchFlagFile = clientPath + this.patchedFlag;
// First check flag file
if (fs.existsSync(patchFlagFile)) {
try {
const flagData = JSON.parse(fs.readFileSync(patchFlagFile, 'utf8'));
if (flagData.targetDomain === newDomain) {
// Verify the binary actually contains the patched domain
const data = fs.readFileSync(clientPath);
const strategy = this.getDomainStrategy(newDomain);
const domainPattern = this.stringToLengthPrefixed(strategy.mainDomain);
if (data.includes(domainPattern)) {
return true;
} else {
console.log(' Flag exists but binary not patched (was updated?), re-patching...');
return false;
}
}
} catch (e) {
// Flag file corrupt or unreadable
}
}
return false;
@@ -189,12 +369,17 @@ class ClientPatcher {
*/
markAsPatched(clientPath) {
const newDomain = this.getNewDomain();
const strategy = this.getDomainStrategy(newDomain);
const patchFlagFile = clientPath + this.patchedFlag;
const flagData = {
patchedAt: new Date().toISOString(),
originalDomain: ORIGINAL_DOMAIN,
targetDomain: newDomain,
patcherVersion: '1.0.0'
patchMode: strategy.mode,
mainDomain: strategy.mainDomain,
subdomainPrefix: strategy.subdomainPrefix,
patcherVersion: '2.0.0',
verified: 'binary_contents'
};
fs.writeFileSync(patchFlagFile, JSON.stringify(flagData, null, 2));
}
@@ -209,6 +394,21 @@ class ClientPatcher {
fs.copyFileSync(clientPath, backupPath);
return backupPath;
}
// Check if current file differs from backup (might have been updated)
const currentSize = fs.statSync(clientPath).size;
const backupSize = fs.statSync(backupPath).size;
if (currentSize !== backupSize) {
// File was updated, create timestamped backup of old backup
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const oldBackupPath = `${clientPath}.original.${timestamp}`;
console.log(` File updated, archiving old backup to ${path.basename(oldBackupPath)}`);
fs.renameSync(backupPath, oldBackupPath);
fs.copyFileSync(clientPath, backupPath);
return backupPath;
}
console.log(' Backup already exists');
return backupPath;
}
@@ -239,9 +439,16 @@ class ClientPatcher {
*/
async patchClient(clientPath, progressCallback) {
const newDomain = this.getNewDomain();
console.log('=== Client Patcher ===');
const strategy = this.getDomainStrategy(newDomain);
console.log('=== Client Patcher v2.0 ===');
console.log(`Target: ${clientPath}`);
console.log(`Replacing: ${ORIGINAL_DOMAIN} -> ${newDomain}`);
console.log(`Domain: ${newDomain} (${newDomain.length} chars)`);
console.log(`Mode: ${strategy.mode}`);
if (strategy.mode === 'split') {
console.log(` Subdomain prefix: ${strategy.subdomainPrefix}`);
console.log(` Main domain: ${strategy.mainDomain}`);
}
if (!fs.existsSync(clientPath)) {
const error = `Client binary not found: ${clientPath}`;
@@ -276,13 +483,24 @@ class ClientPatcher {
progressCallback('Patching domain references...', 50);
}
console.log('Patching domain references...');
const { buffer: patchedData, count } = this.findAndReplaceDomainSmart(data, ORIGINAL_DOMAIN, newDomain);
console.log('Applying domain patches (length-prefixed format)...');
const { buffer: patchedData, count } = this.applyDomainPatches(data, newDomain);
console.log('Patching Discord URLs...');
const { buffer: finalData, count: discordCount } = this.patchDiscordUrl(patchedData);
if (count === 0 && discordCount === 0) {
console.log('No occurrences found - trying legacy UTF-16LE format...');
// Fallback to legacy patching for older binary formats
const legacyResult = this.findAndReplaceDomainSmart(data, ORIGINAL_DOMAIN, strategy.mainDomain);
if (legacyResult.count > 0) {
console.log(`Found ${legacyResult.count} occurrences with legacy format`);
fs.writeFileSync(clientPath, legacyResult.buffer);
this.markAsPatched(clientPath);
return { success: true, patchCount: legacyResult.count, format: 'legacy' };
}
console.log('No occurrences found - binary may already be modified or has different format');
return { success: true, patchCount: 0, warning: 'No occurrences found' };
}
@@ -307,17 +525,18 @@ class ClientPatcher {
}
/**
* Patch the server JAR to use the custom domain
* JAR files are ZIP archives, so we need to extract, patch class files, and repackage
* Patch the server JAR by downloading pre-patched version
* @param {string} serverPath - Path to the HytaleServer.jar
* @param {function} progressCallback - Optional callback for progress updates
* @param {string} javaPath - Path to Java executable (unused, kept for compatibility)
* @returns {object} Result object with success status and details
*/
async patchServer(serverPath, progressCallback) {
async patchServer(serverPath, progressCallback, javaPath = null) {
const newDomain = this.getNewDomain();
console.log('=== Server Patcher ===');
console.log('=== Server Patcher TEMP SYSTEM NEED TO BE FIXED ===');
console.log(`Target: ${serverPath}`);
console.log(`Replacing: ${ORIGINAL_DOMAIN} -> ${newDomain}`);
console.log(`Domain: ${newDomain}`);
if (!fs.existsSync(serverPath)) {
const error = `Server JAR not found: ${serverPath}`;
@@ -325,77 +544,397 @@ class ClientPatcher {
return { success: false, error };
}
if (this.isPatchedAlready(serverPath)) {
// Check if already patched
const patchFlagFile = serverPath + '.dualauth_patched';
if (fs.existsSync(patchFlagFile)) {
try {
const flagData = JSON.parse(fs.readFileSync(patchFlagFile, 'utf8'));
if (flagData.domain === newDomain) {
console.log(`Server already patched for ${newDomain}, skipping`);
if (progressCallback) {
progressCallback('Server already patched', 100);
if (progressCallback) progressCallback('Server already patched', 100);
return { success: true, alreadyPatched: true };
}
} catch (e) {
// Flag file corrupt, re-patch
}
return { success: true, alreadyPatched: true, patchCount: 0 };
}
if (progressCallback) {
progressCallback('Preparing to patch server...', 10);
}
// Create backup
if (progressCallback) progressCallback('Creating backup...', 10);
console.log('Creating backup...');
this.backupClient(serverPath);
if (progressCallback) {
progressCallback('Extracting server JAR...', 20);
// Download pre-patched JAR
if (progressCallback) progressCallback('Downloading patched server JAR...', 30);
console.log('Downloading pre-patched HytaleServer.jar');
try {
const https = require('https');
const url = 'https://pub-027b315ece074e2e891002ca38384792.r2.dev/HytaleServer.jar';
await new Promise((resolve, reject) => {
https.get(url, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
// Follow redirect
https.get(response.headers.location, (redirectResponse) => {
if (redirectResponse.statusCode !== 200) {
reject(new Error(`Failed to download: HTTP ${redirectResponse.statusCode}`));
return;
}
const file = fs.createWriteStream(serverPath);
const totalSize = parseInt(redirectResponse.headers['content-length'], 10);
let downloaded = 0;
redirectResponse.on('data', (chunk) => {
downloaded += chunk.length;
if (progressCallback && totalSize) {
const percent = 30 + Math.floor((downloaded / totalSize) * 60);
progressCallback(`Downloading... ${(downloaded / 1024 / 1024).toFixed(2)} MB`, percent);
}
});
redirectResponse.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
}).on('error', reject);
} else if (response.statusCode === 200) {
const file = fs.createWriteStream(serverPath);
const totalSize = parseInt(response.headers['content-length'], 10);
let downloaded = 0;
response.on('data', (chunk) => {
downloaded += chunk.length;
if (progressCallback && totalSize) {
const percent = 30 + Math.floor((downloaded / totalSize) * 60);
progressCallback(`Downloading... ${(downloaded / 1024 / 1024).toFixed(2)} MB`, percent);
}
});
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
} else {
reject(new Error(`Failed to download: HTTP ${response.statusCode}`));
}
}).on('error', (err) => {
fs.unlink(serverPath, () => {});
reject(err);
});
});
console.log(' Download successful');
// Mark as patched
fs.writeFileSync(patchFlagFile, JSON.stringify({
domain: newDomain,
patchedAt: new Date().toISOString(),
patcher: 'PrePatchedDownload',
source: 'https://pub-027b315ece074e2e891002ca38384792.r2.dev/HytaleServer.jar'
}));
if (progressCallback) progressCallback('Server patching complete', 100);
console.log('=== Server Patching Complete ===');
return { success: true, patchCount: 1 };
} catch (downloadError) {
console.error(`Failed to download patched JAR: ${downloadError.message}`);
// Restore backup on failure
const backupPath = serverPath + '.original';
if (fs.existsSync(backupPath)) {
fs.copyFileSync(backupPath, serverPath);
console.log('Restored backup after download failure');
}
return { success: false, error: `Failed to download patched server: ${downloadError.message}` };
}
}
/**
* Find Java executable - uses bundled JRE first (same as game uses)
* Falls back to system Java if bundled not available
*/
findJava() {
// 1. Try bundled JRE first (comes with the game)
try {
const bundled = getBundledJavaPath(JRE_DIR);
if (bundled && fs.existsSync(bundled)) {
console.log(`Using bundled Java: ${bundled}`);
return bundled;
}
} catch (e) {
// Bundled not available
}
// 2. Try javaManager's getJavaExec (handles all fallbacks)
try {
const javaExec = getJavaExec(JRE_DIR);
if (javaExec && fs.existsSync(javaExec)) {
console.log(`Using Java from javaManager: ${javaExec}`);
return javaExec;
}
} catch (e) {
// Not available
}
// 3. Check JAVA_HOME
if (process.env.JAVA_HOME) {
const javaHome = process.env.JAVA_HOME;
const javaBin = path.join(javaHome, 'bin', process.platform === 'win32' ? 'java.exe' : 'java');
if (fs.existsSync(javaBin)) {
console.log(`Using Java from JAVA_HOME: ${javaBin}`);
return javaBin;
}
}
// 4. Try 'java' from PATH
try {
execSync('java -version 2>&1', { encoding: 'utf8' });
console.log('Using Java from PATH');
return 'java';
} catch (e) {
// Not in PATH
}
return null;
}
/**
* Download DualAuthPatcher from hytale-auth-server if not present
*/
async ensurePatcherDownloaded(patcherDir) {
const patcherJava = path.join(patcherDir, 'DualAuthPatcher.java');
const patcherUrl = 'https://raw.githubusercontent.com/sanasol/hytale-auth-server/master/patcher/DualAuthPatcher.java';
if (!fs.existsSync(patcherDir)) {
fs.mkdirSync(patcherDir, { recursive: true });
}
if (!fs.existsSync(patcherJava)) {
console.log('Downloading DualAuthPatcher from hytale-auth-server...');
try {
const https = require('https');
await new Promise((resolve, reject) => {
const file = fs.createWriteStream(patcherJava);
https.get(patcherUrl, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
// Follow redirect
https.get(response.headers.location, (redirectResponse) => {
redirectResponse.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
}).on('error', reject);
} else {
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
}
}).on('error', (err) => {
fs.unlink(patcherJava, () => {});
reject(err);
});
});
console.log(' Downloaded DualAuthPatcher.java');
} catch (e) {
console.error(` Failed to download DualAuthPatcher: ${e.message}`);
throw e;
}
}
}
/**
* Download ASM libraries if not present
*/
async ensureAsmLibraries(libDir) {
if (!fs.existsSync(libDir)) {
fs.mkdirSync(libDir, { recursive: true });
}
const libs = [
{ name: 'asm-9.6.jar', url: 'https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar' },
{ name: 'asm-tree-9.6.jar', url: 'https://repo1.maven.org/maven2/org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar' },
{ name: 'asm-util-9.6.jar', url: 'https://repo1.maven.org/maven2/org/ow2/asm/asm-util/9.6/asm-util-9.6.jar' }
];
for (const lib of libs) {
const libPath = path.join(libDir, lib.name);
if (!fs.existsSync(libPath)) {
console.log(`Downloading ${lib.name}...`);
try {
const https = require('https');
await new Promise((resolve, reject) => {
const file = fs.createWriteStream(libPath);
https.get(lib.url, (response) => {
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
}).on('error', (err) => {
fs.unlink(libPath, () => {});
reject(err);
});
});
console.log(` Downloaded ${lib.name}`);
} catch (e) {
console.error(` Failed to download ${lib.name}: ${e.message}`);
throw e;
}
}
}
}
/**
* Compile DualAuthPatcher if needed
*/
async compileDualAuthPatcher(java, patcherDir, libDir) {
const patcherClass = path.join(patcherDir, 'DualAuthPatcher.class');
const patcherJava = path.join(patcherDir, 'DualAuthPatcher.java');
// Check if already compiled and up to date
if (fs.existsSync(patcherClass)) {
const classTime = fs.statSync(patcherClass).mtime;
const javaTime = fs.statSync(patcherJava).mtime;
if (classTime > javaTime) {
console.log('DualAuthPatcher already compiled');
return { success: true };
}
}
console.log('Compiling DualAuthPatcher...');
const javac = java.replace(/java(\.exe)?$/, 'javac$1');
const classpath = [
path.join(libDir, 'asm-9.6.jar'),
path.join(libDir, 'asm-tree-9.6.jar'),
path.join(libDir, 'asm-util-9.6.jar')
].join(process.platform === 'win32' ? ';' : ':');
try {
// Fix PATH for packaged Electron apps on Windows
const execOptions = {
stdio: 'pipe',
cwd: patcherDir,
env: { ...process.env }
};
// Add system32 to PATH for Windows to find cmd.exe
if (process.platform === 'win32') {
const systemRoot = process.env.SystemRoot || 'C:\\WINDOWS';
const systemPath = `${systemRoot}\\system32;${systemRoot};${systemRoot}\\System32\\Wbem`;
execOptions.env.PATH = execOptions.env.PATH
? `${systemPath};${execOptions.env.PATH}`
: systemPath;
execOptions.shell = true;
}
execSync(`"${javac}" -cp "${classpath}" -d "${patcherDir}" "${patcherJava}"`, execOptions);
console.log(' Compilation successful');
return { success: true };
} catch (e) {
const error = `Failed to compile DualAuthPatcher: ${e.message}`;
console.error(error);
if (e.stderr) console.error(e.stderr.toString());
return { success: false, error };
}
}
/**
* Run DualAuthPatcher on the server JAR
*/
async runDualAuthPatcher(java, classpath, serverPath, domain) {
return new Promise((resolve) => {
const args = ['-cp', classpath, 'DualAuthPatcher', serverPath];
const env = { ...process.env, HYTALE_AUTH_DOMAIN: domain };
console.log(`Running: java ${args.join(' ')}`);
console.log(` HYTALE_AUTH_DOMAIN=${domain}`);
const proc = spawn(java, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => {
const str = data.toString();
stdout += str;
console.log(str.trim());
});
proc.stderr.on('data', (data) => {
const str = data.toString();
stderr += str;
console.error(str.trim());
});
proc.on('close', (code) => {
if (code === 0) {
resolve({ success: true, stdout });
} else {
resolve({ success: false, error: `Patcher exited with code ${code}: ${stderr}` });
}
});
proc.on('error', (err) => {
resolve({ success: false, error: `Failed to run patcher: ${err.message}` });
});
});
}
/**
* Legacy server patcher (simple domain replacement, no dual auth)
* Use patchServer() for full dual auth support
*/
async patchServerLegacy(serverPath, progressCallback) {
const newDomain = this.getNewDomain();
const strategy = this.getDomainStrategy(newDomain);
console.log('=== Legacy Server Patcher ===');
console.log(`Target: ${serverPath}`);
console.log(`Domain: ${newDomain} (${newDomain.length} chars)`);
if (!fs.existsSync(serverPath)) {
return { success: false, error: `Server JAR not found: ${serverPath}` };
}
if (progressCallback) progressCallback('Patching server...', 20);
console.log('Opening server JAR...');
const zip = new AdmZip(serverPath);
const entries = zip.getEntries();
console.log(`JAR contains ${entries.length} entries`);
if (progressCallback) {
progressCallback('Patching class files...', 40);
}
let totalCount = 0;
const oldUtf8 = this.stringToUtf8(ORIGINAL_DOMAIN);
const newUtf8 = this.stringToUtf8(newDomain);
for (const entry of entries) {
const name = entry.entryName;
if (name.endsWith('.class') || name.endsWith('.properties') ||
name.endsWith('.json') || name.endsWith('.xml') || name.endsWith('.yml')) {
const data = entry.getData();
if (data.includes(oldUtf8)) {
const { buffer: patchedData, count } = this.findAndReplaceDomainUtf8(data, ORIGINAL_DOMAIN, newDomain);
const { buffer: patchedData, count } = this.findAndReplaceDomainUtf8(data, ORIGINAL_DOMAIN, strategy.mainDomain);
if (count > 0) {
zip.updateFile(entry.entryName, patchedData);
console.log(` Patched ${count} occurrences in ${name}`);
totalCount += count;
}
}
}
}
if (totalCount === 0) {
console.log('No occurrences of hytale.com found in server JAR entries');
return { success: true, patchCount: 0, warning: 'No domain occurrences found in JAR' };
}
if (progressCallback) {
progressCallback('Writing patched JAR...', 80);
}
console.log('Writing patched JAR...');
if (totalCount > 0) {
zip.writeZip(serverPath);
this.markAsPatched(serverPath);
if (progressCallback) {
progressCallback('Server patching complete', 100);
}
console.log(`Successfully patched ${totalCount} occurrences in server`);
console.log('=== Server Patching Complete ===');
if (progressCallback) progressCallback('Complete', 100);
return { success: true, patchCount: totalCount };
}
@@ -441,8 +980,9 @@ class ClientPatcher {
* Ensure both client and server are patched before launching
* @param {string} gameDir - Path to the game directory
* @param {function} progressCallback - Optional callback for progress updates
* @param {string} javaPath - Optional path to Java executable for server patching
*/
async ensureClientPatched(gameDir, progressCallback) {
async ensureClientPatched(gameDir, progressCallback, javaPath = null) {
const results = {
client: null,
server: null,
@@ -473,7 +1013,7 @@ class ClientPatcher {
if (progressCallback) {
progressCallback(`Server: ${msg}`, pct ? 50 + pct / 2 : null);
}
});
}, javaPath);
} else {
console.warn('Could not find HytaleServer.jar');
results.server = { success: false, error: 'Server JAR not found' };

View File

@@ -2,151 +2,454 @@ const fs = require('fs');
const path = require('path');
const axios = require('axios');
async function downloadFile(url, dest, progressCallback, maxRetries = 3) {
// Automatic stall retry constants
const MAX_AUTOMATIC_STALL_RETRIES = 3;
const AUTOMATIC_STALL_RETRY_DELAY = 3000; // 3 seconds in milliseconds
// Network monitoring utilities using Node.js built-in methods
function checkNetworkConnection() {
return new Promise((resolve) => {
const { lookup } = require('dns');
const http = require('http');
// Try DNS lookup first (faster) - using callback version
lookup('8.8.8.8', (err) => {
if (err) {
resolve(false);
return;
}
// Try HTTP request to confirm internet connectivity
const req = http.get('http://www.google.com', { timeout: 3000 }, (res) => {
resolve(true);
res.destroy();
});
req.on('error', () => {
resolve(false);
});
req.on('timeout', () => {
req.destroy();
resolve(false);
});
});
});
}
async function downloadFile(url, dest, progressCallback, maxRetries = 5) {
let lastError = null;
let retryState = {
attempts: 0,
maxRetries: maxRetries,
canRetry: true,
lastError: null,
automaticStallRetries: 0,
isAutomaticRetry: false
};
let downloadStalled = false;
let streamCompleted = false;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
retryState.attempts = attempt + 1;
console.log(`Download attempt ${attempt + 1}/${maxRetries} for ${url}`);
if (attempt > 0 && progressCallback) {
progressCallback(`Retry ${attempt}/${maxRetries - 1}...`, null, null, null, null);
await new Promise(resolve => setTimeout(resolve, 2000 * attempt)); // Délai progressif
// Exponential backoff with jitter - longer delays for unstable connections
const baseDelay = 3000;
const exponentialDelay = baseDelay * Math.pow(2, attempt - 1);
const jitter = Math.random() * 2000;
const delay = Math.min(exponentialDelay + jitter, 60000);
progressCallback(`Retry ${attempt}/${maxRetries - 1}...`, null, null, null, null, retryState);
await new Promise(resolve => setTimeout(resolve, delay));
}
// Create AbortController for proper stream control
const controller = new AbortController();
let hasReceivedData = false;
let lastProgressTime = Date.now(); // Initialize before timeout
// Smart overall timeout - only trigger if no progress for extended period
const overallTimeout = setInterval(() => {
const now = Date.now();
const timeSinceLastProgress = now - lastProgressTime;
// Only timeout if no data received for 15 minutes (900 seconds) - for very slow connections
if (timeSinceLastProgress > 900000 && hasReceivedData) {
console.log('Download stalled for 15 minutes, aborting...');
console.log(`Download had progress before stall: ${(downloaded / 1024 / 1024).toFixed(2)} MB`);
controller.abort();
}
}, 60000); // Check every minute
// Check if we can resume existing download
let startByte = 0;
if (fs.existsSync(dest)) {
const existingStats = fs.statSync(dest);
// If file size matches remote size, skip download
if (existingStats.size == fs.statSync(dest).size) {
console.log('File already exists and is complete. Skipping download.');
return { success: true, downloaded: existingStats.size };
}
// Only resume if file exists and is substantial (> 1MB)
if (existingStats.size > 1024 * 1024) {
startByte = existingStats.size;
console.log(`Resuming download from byte ${startByte} (${(existingStats.size / 1024 / 1024).toFixed(2)} MB already downloaded)`);
} else {
// File too small, start fresh
fs.unlinkSync(dest);
console.log('Existing file too small, starting fresh download');
}
}
const headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Accept': '*/*'
};
// Add Range header ONLY if resuming (startByte > 0)
if (startByte > 0) {
headers['Range'] = `bytes=${startByte}-`;
console.log(`Adding Range header: bytes=${startByte}-`);
} else {
console.log('Fresh download, no Range header');
}
const response = await axios({
method: 'GET',
url: url,
responseType: 'stream',
timeout: 60000, // 60 secondes timeout
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': '*/*',
'Accept-Language': 'en-US,en;q=0.9',
'Referer': 'https://launcher.hytale.com/',
'Connection': 'keep-alive'
},
// Configuration Axios pour la robustesse réseau
timeout: 120000, // 120 seconds for slow connections
signal: controller.signal,
headers: headers,
validateStatus: function (status) {
return status >= 200 && status < 300;
return (status >= 200 && status < 300) || status === 206;
},
// Retry configuration
maxRedirects: 5,
// Network resilience
family: 4 // Force IPv4
family: 4
});
const totalSize = parseInt(response.headers['content-length'], 10);
let downloaded = 0;
let lastProgressTime = Date.now();
const contentLength = response.headers['content-length'];
const totalSize = contentLength ? parseInt(contentLength, 10) + startByte : 0;
let downloaded = startByte;
lastProgressTime = Date.now();
const startTime = Date.now();
// Nettoyer le fichier de destination s'il existe
if (fs.existsSync(dest)) {
fs.unlinkSync(dest);
// Check network status before attempting download, in case of known offline state
try {
const isNetworkOnline = await checkNetworkConnection();
if (!isNetworkOnline) {
throw new Error('Network connection unavailable. Please check your connection and retry.');
}
} catch (networkError) {
console.error('[Network] Network check failed, proceeding anyway:', networkError.message);
// Continue with download attempt - network check failure shouldn't block
}
const writer = fs.createWriteStream(dest);
let downloadStalled = false;
const writer = fs.createWriteStream(dest, {
flags: startByte > 0 ? 'a' : 'w', // 'a' for append (resume), 'w' for write (fresh)
start: startByte > 0 ? startByte : 0
});
let streamError = null;
let stalledTimeout = null;
// Reset state for this attempt
downloadStalled = false;
streamCompleted = false;
// Enhanced stream event handling
response.data.on('data', (chunk) => {
downloaded += chunk.length;
const now = Date.now();
hasReceivedData = true; // Mark that we've received data
// Reset stalled timer on data received
// Reset simple stall timer on data received
if (stalledTimeout) {
clearTimeout(stalledTimeout);
}
// Set new stalled timer (30 seconds without data = stalled)
stalledTimeout = setTimeout(() => {
// Set new stall timer (30 seconds without data = stalled)
stalledTimeout = setTimeout(async () => {
console.log('Download stalled - checking network connectivity...');
// Check if network is actually available before retrying
try {
const isNetworkOnline = await checkNetworkConnection();
if (!isNetworkOnline) {
console.log('Network connection lost - stopping download and showing error');
downloadStalled = true;
streamError = new Error('Network connection lost. Please check your internet connection and retry.');
streamError.isConnectionLost = true;
streamError.canRetry = false;
controller.abort();
writer.destroy();
response.data.destroy();
// Immediately reject the promise to prevent hanging
setTimeout(() => promiseReject(streamError), 100);
return;
}
} catch (networkError) {
console.error('Network check failed during stall detection:', networkError.message);
}
console.log('Network available - download stalled due to slow connection, aborting for retry...');
downloadStalled = true;
streamError = new Error('Download stalled due to slow network connection. Please retry.');
controller.abort();
writer.destroy();
response.data.destroy();
// Immediately reject the promise to prevent hanging
setTimeout(() => promiseReject(streamError), 100);
}, 30000);
if (progressCallback && totalSize > 0 && (now - lastProgressTime > 100)) { // Update every 100ms max
const percent = Math.min(100, Math.max(0, (downloaded / totalSize) * 100));
const elapsed = (now - startTime) / 1000;
const speed = elapsed > 0 ? downloaded / elapsed : 0;
progressCallback(null, percent, speed, downloaded, totalSize);
progressCallback(null, percent, speed, downloaded, totalSize, retryState);
lastProgressTime = now;
}
});
// Enhanced stream error handling
response.data.on('error', (error) => {
// Ignore errors if it was intentionally cancelled or already handled
if (downloadStalled || streamCompleted || controller.signal.aborted) {
console.log(`Ignoring stream error after cancellation: ${error.code || error.message}`);
return;
}
if (!streamError) {
streamError = new Error(`Stream error: ${error.code || error.message}. Please retry.`);
// Check for connection lost indicators
if (error.code === 'ERR_NETWORK_CHANGED' ||
error.code === 'ERR_INTERNET_DISCONNECTED' ||
error.code === 'ERR_CONNECTION_LOST') {
streamError.isConnectionLost = true;
streamError.canRetry = false;
}
console.error(`Stream error on attempt ${attempt + 1}:`, error.code || error.message);
}
if (stalledTimeout) {
clearTimeout(stalledTimeout);
}
console.error(`Stream error on attempt ${attempt + 1}:`, error.code || error.message);
if (overallTimeout) {
clearInterval(overallTimeout);
}
writer.destroy();
});
response.data.on('close', () => {
// Only treat as error if not already handled by cancellation and writer didn't complete
if (!streamError && !streamCompleted && !downloadStalled && !controller.signal.aborted) {
// Check if writer actually completed but stream close came first
setTimeout(() => {
if (!streamCompleted) {
streamError = new Error('Stream closed unexpectedly. Please retry.');
console.log('Stream closed unexpectedly on attempt', attempt + 1);
}
}, 500); // Small delay to check if writer completes
}
if (stalledTimeout) {
clearTimeout(stalledTimeout);
}
if (overallTimeout) {
clearInterval(overallTimeout);
}
});
response.data.on('abort', () => {
// Only treat as error if not already handled by stall detection
if (!streamError && !streamCompleted && !downloadStalled) {
streamError = new Error('Download aborted due to network issue. Please retry.');
console.log('Stream aborted on attempt', attempt + 1);
}
if (stalledTimeout) {
clearTimeout(stalledTimeout);
}
});
response.data.pipe(writer);
let promiseReject = null;
await new Promise((resolve, reject) => {
// Store promise reject function for immediate use by stall timeout
promiseReject = reject;
writer.on('finish', () => {
streamCompleted = true;
console.log(`Writer finished on attempt ${attempt + 1}, downloaded: ${(downloaded / 1024 / 1024).toFixed(2)} MB`);
// Clear ALL timeouts to prevent them from firing after completion
if (stalledTimeout) {
clearTimeout(stalledTimeout);
console.log('Cleared stall timeout after writer finished');
}
if (overallTimeout) {
clearInterval(overallTimeout);
console.log('Cleared overall timeout after writer finished');
}
// Download is successful if writer finished - regardless of stream state
if (!downloadStalled) {
console.log(`Download completed successfully on attempt ${attempt + 1}`);
resolve();
} else {
reject(new Error('Download stalled'));
// Don't reject here if we already rejected due to network loss - prevents duplicate rejection
console.log('Writer finished after stall detection, ignoring...');
}
});
writer.on('error', (error) => {
if (stalledTimeout) {
clearTimeout(stalledTimeout);
}
reject(error);
});
response.data.on('error', (error) => {
if (stalledTimeout) {
clearTimeout(stalledTimeout);
}
reject(error);
});
});
// Si on arrive ici, le téléchargement a réussi
// Ignore write errors if stream was intentionally cancelled
if (downloadStalled || controller.signal.aborted) {
console.log(`Ignoring writer error after cancellation: ${error.code || error.message}`);
return;
}
if (!streamError) {
streamError = new Error(`File write error: ${error.code || error.message}. Please retry.`);
console.error(`Writer error on attempt ${attempt + 1}:`, error.code || error.message);
}
if (stalledTimeout) {
clearTimeout(stalledTimeout);
}
if (overallTimeout) {
clearInterval(overallTimeout);
}
reject(streamError);
});
// Handle case where stream ends without finishing writer
response.data.on('end', () => {
if (!streamCompleted && !downloadStalled && !streamError) {
// Give a small delay for writer to finish - this is normal behavior
setTimeout(() => {
if (!streamCompleted) {
console.log('Stream ended but writer not finished - waiting longer...');
// Give more time for writer to finish - this might be slow disk I/O
setTimeout(() => {
if (!streamCompleted) {
streamError = new Error('Download incomplete. Please retry.');
reject(streamError);
}
}, 2000);
}
}, 1000);
}
});
});
return dest;
} catch (error) {
lastError = error;
retryState.lastError = error;
console.error(`Download attempt ${attempt + 1} failed:`, error.code || error.message);
console.error(`Error details:`, {
isConnectionLost: error.isConnectionLost,
canRetry: error.canRetry,
message: error.message,
downloadStalled: downloadStalled,
streamCompleted: streamCompleted
});
// Nettoyer le fichier partiel en cas d'erreur
// Check if download actually completed successfully despite the error
if (fs.existsSync(dest)) {
const stats = fs.statSync(dest);
const sizeInMB = stats.size / 1024 / 1024;
console.log(`File size after error: ${sizeInMB.toFixed(2)} MB`);
// If file is substantial size (> 1.5GB), treat as success and break
if (sizeInMB >= 1500) {
console.log('File appears to be complete despite error, treating as success');
return dest; // Exit the retry loop successfully
}
}
// Enhanced file cleanup with validation
if (fs.existsSync(dest)) {
try {
// HTTP 416 = Range Not Satisfiable, delete corrupted partial file
const isRangeError = error.message && error.message.includes('416');
// Check if file is corrupted (small or invalid) or if error is non-resumable
const partialStats = fs.statSync(dest);
const isResumableError = error.message && (
error.message.includes('stalled') ||
error.message.includes('timeout') ||
error.message.includes('network') ||
error.message.includes('aborted')
);
// Check if download appears to be complete (close to expected PWR size)
const isPossiblyComplete = partialStats.size >= 1500 * 1024 * 1024; // >= 1.5GB
if (isRangeError || partialStats.size < 1024 * 1024 || (!isResumableError && !isPossiblyComplete)) {
// Delete if HTTP 416 OR file is too small OR error is non-resumable AND not possibly complete
const reason = isRangeError ? 'HTTP 416 range error' : (!isResumableError && !isPossiblyComplete ? 'non-resumable error' : 'too small');
console.log(`[Cleanup] Removing file (${reason}): ${(partialStats.size / 1024 / 1024).toFixed(2)} MB`);
fs.unlinkSync(dest);
} else {
// Keep the file for resume on resumable errors or if possibly complete
console.log(`[Resume] Keeping file (${isPossiblyComplete ? 'possibly complete' : 'for resume'}): ${(partialStats.size / 1024 / 1024).toFixed(2)} MB`);
}
} catch (cleanupError) {
console.warn('Could not cleanup partial file:', cleanupError.message);
console.warn('Could not handle partial file:', cleanupError.message);
}
}
// Vérifier si c'est une erreur réseau que l'on peut retry
const retryableErrors = ['ECONNRESET', 'ENOTFOUND', 'ECONNREFUSED', 'ETIMEDOUT', 'ESOCKETTIMEDOUT', 'EPROTO'];
// Expanded retryable error codes for better network detection
const retryableErrors = [
'ECONNRESET', 'ENOTFOUND', 'ECONNREFUSED', 'ETIMEDOUT',
'ESOCKETTIMEDOUT', 'EPROTO', 'ENETDOWN', 'EHOSTUNREACH',
'ECONNABORTED', 'EPIPE', 'ENETRESET', 'EADDRNOTAVAIL',
'ERR_NETWORK', 'ERR_INTERNET_DISCONNECTED', 'ERR_CONNECTION_RESET',
'ERR_CONNECTION_TIMED_OUT', 'ERR_NAME_NOT_RESOLVED', 'ERR_CONNECTION_CLOSED'
];
const isRetryable = retryableErrors.includes(error.code) ||
error.message.includes('timeout') ||
error.message.includes('stalled') ||
error.message.includes('aborted') ||
error.message.includes('network') ||
error.message.includes('connection') ||
error.message.includes('Please retry') ||
error.message.includes('corrupted') ||
error.message.includes('invalid') ||
(error.response && error.response.status >= 500);
if (!isRetryable || attempt === maxRetries - 1) {
console.error(`Non-retryable error or max retries reached: ${error.code || error.message}`);
// Respect error's canRetry property if set
const canRetry = (error.canRetry === false) ? false : isRetryable;
if (!canRetry || attempt === maxRetries - 1) {
// Don't set retryState.canRetry to false for max retries - user should still be able to retry manually
retryState.canRetry = error.canRetry === false ? false : true;
console.error(`Non-retryable error or max retries reached: ${error.code || error.message}`);
break;
}
console.log(`Retryable error detected, will retry in ${2000 * (attempt + 1)}ms...`);
console.log(`Retryable error detected, will retry...`);
}
}
throw new Error(`Download failed after ${maxRetries} attempts. Last error: ${lastError?.code || lastError?.message || 'Unknown error'}`);
// Enhanced error with retry state and user-friendly message
const detailedError = lastError?.code || lastError?.message || 'Unknown error';
const errorMessage = `Download failed after ${maxRetries} attempts. Last error: ${detailedError}. Please retry`;
const enhancedError = new Error(errorMessage);
enhancedError.retryState = retryState;
enhancedError.lastError = lastError;
enhancedError.detailedError = detailedError;
// Allow manual retry unless it's a connection lost error
enhancedError.canRetry = !lastError?.isConnectionLost && lastError?.canRetry !== false;
throw enhancedError;
}
function findHomePageUIPath(gameLatest) {
@@ -205,8 +508,82 @@ function findLogoPath(gameLatest) {
return searchDirectory(gameLatest);
}
// Automatic stall retry function for network stalls
async function retryStalledDownload(url, dest, progressCallback, previousError = null) {
console.log('Automatic stall retry initiated for:', url);
// Wait before retry to allow network recovery
console.log(`Waiting ${AUTOMATIC_STALL_RETRY_DELAY/1000} seconds before automatic retry...`);
await new Promise(resolve => setTimeout(resolve, AUTOMATIC_STALL_RETRY_DELAY));
try {
// Create new retryState for automatic retry
const automaticRetryState = {
attempts: 1,
maxRetries: 1,
canRetry: true,
lastError: null,
automaticStallRetries: (previousError && previousError.retryState) ? previousError.retryState.automaticStallRetries + 1 : 1,
isAutomaticRetry: true
};
// Update progress callback with automatic retry info
if (progressCallback) {
progressCallback(
`Automatic stall retry ${automaticRetryState.automaticStallRetries}/${MAX_AUTOMATIC_STALL_RETRIES}...`,
null, null, null, null, automaticRetryState
);
}
await downloadFile(url, dest, progressCallback, 1);
console.log('Automatic stall retry successful');
} catch (error) {
console.error('Automatic stall retry failed:', error.message);
throw error;
}
}
// Manual retry function for user-initiated retries
async function retryDownload(url, dest, progressCallback, previousError = null) {
console.log('Manual retry initiated for:', url);
// If we have a previous error with retry state, continue from there
let additionalRetries = 3; // Allow 3 additional manual retries
if (previousError && previousError.retryState) {
additionalRetries = Math.max(2, 5 - previousError.retryState.attempts);
}
// Ensure cache directory exists before retrying
const destDir = path.dirname(dest);
if (!fs.existsSync(destDir)) {
console.log('Creating cache directory:', destDir);
fs.mkdirSync(destDir, { recursive: true });
}
// CRITICAL: Delete partial file before manual retry to avoid HTTP 416
if (fs.existsSync(dest)) {
try {
const stats = fs.statSync(dest);
console.log(`[Retry] Deleting partial file before retry: ${(stats.size / 1024 / 1024).toFixed(2)} MB`);
fs.unlinkSync(dest);
} catch (err) {
console.warn('Could not delete partial file:', err.message);
}
}
try {
await downloadFile(url, dest, progressCallback, additionalRetries);
console.log('Manual retry successful');
} catch (error) {
console.error('Manual retry failed:', error.message);
throw error;
}
}
module.exports = {
downloadFile,
retryDownload,
retryStalledDownload,
findHomePageUIPath,
findLogoPath
};

View File

@@ -53,11 +53,7 @@ function setupWaylandEnvironment() {
console.log('Detected Wayland session, configuring environment...');
const envVars = {
SDL_VIDEODRIVER: 'wayland',
GDK_BACKEND: 'wayland',
QT_QPA_PLATFORM: 'wayland',
MOZ_ENABLE_WAYLAND: '1',
_JAVA_AWT_WM_NONREPARENTING: '1'
SDL_VIDEODRIVER: 'wayland'
};
envVars.ELECTRON_OZONE_PLATFORM_HINT = 'wayland';

View File

@@ -0,0 +1,129 @@
const fs = require('fs-extra');
const path = require('path');
/**
* Backup and restore UserData folder during game updates
*/
class UserDataBackup {
/**
* Backup UserData folder to a temporary location
* @param {string} installPath - Base installation path (e.g., C:\Users\...\HytaleF2P)
* @param {string} branch - Branch name (release or pre-release)
* @param {boolean} hasVersionConfig - True if config.json has version_client and version_branch
* @returns {Promise<string|null>} - Path to backup or null if no UserData found
*/
async backupUserData(installPath, branch, hasVersionConfig = true) {
let userDataPath;
// Si on n'a pas de version_client/version_branch dans config.json,
// c'est une ancienne installation, on cherche dans installPath/HytaleF2P/release
if (!hasVersionConfig) {
const oldPath = path.join(installPath, 'HytaleF2P', 'release', 'package', 'game', 'latest', 'Client', 'UserData');
console.log(`[UserDataBackup] No version_client/version_branch detected, searching old installation in: ${oldPath}`);
if (fs.existsSync(oldPath)) {
userDataPath = oldPath;
console.log(`[UserDataBackup] ✓ Old installation found! UserData exists in old location`);
} else {
console.log(`[UserDataBackup] ✗ No old installation found in ${oldPath}`);
userDataPath = path.join(installPath, branch, 'package', 'game', 'latest', 'Client', 'UserData');
}
} else {
// Si on a version_client/version_branch, on cherche dans installPath/HytaleF2P/<branch>
userDataPath = path.join(installPath, branch, 'package', 'game', 'latest', 'Client', 'UserData');
console.log(`[UserDataBackup] Version configured, searching in: ${userDataPath}`);
}
if (!fs.existsSync(userDataPath)) {
console.log(`[UserDataBackup] ✗ No UserData found at ${userDataPath}, backup skipped`);
return null;
}
console.log(`[UserDataBackup] ✓ UserData found at ${userDataPath}`);
const backupPath = path.join(installPath, `UserData_backup_${branch}_${Date.now()}`);
try {
console.log(`[UserDataBackup] Copying from ${userDataPath} to ${backupPath}...`);
await fs.copy(userDataPath, backupPath, {
overwrite: true,
errorOnExist: false
});
console.log('[UserDataBackup] ✓ Backup completed successfully');
return backupPath;
} catch (error) {
console.error('[UserDataBackup] ✗ Erreur lors du backup:', error);
throw new Error(`Failed to backup UserData: ${error.message}`);
}
}
/**
* Restore UserData folder from backup
* @param {string} backupPath - Path to the backup folder
* @param {string} installPath - Base installation path
* @param {string} branch - Branch name (release or pre-release)
* @returns {Promise<boolean>} - True if restored, false otherwise
*/
async restoreUserData(backupPath, installPath, branch) {
if (!backupPath || !fs.existsSync(backupPath)) {
console.log('No backup to restore or backup path does not exist');
return false;
}
const userDataPath = path.join(installPath, branch, 'package', 'game', 'latest', 'Client', 'UserData');
try {
console.log(`Restoring UserData from ${backupPath} to ${userDataPath}`);
// Ensure parent directory exists
const parentDir = path.dirname(userDataPath);
if (!fs.existsSync(parentDir)) {
await fs.ensureDir(parentDir);
}
await fs.copy(backupPath, userDataPath, {
overwrite: true,
errorOnExist: false
});
console.log('UserData restore completed successfully');
return true;
} catch (error) {
console.error('Error restoring UserData:', error);
throw new Error(`Failed to restore UserData: ${error.message}`);
}
}
/**
* Clean up backup folder
* @param {string} backupPath - Path to the backup folder to delete
* @returns {Promise<boolean>} - True if deleted, false otherwise
*/
async cleanupBackup(backupPath) {
if (!backupPath || !fs.existsSync(backupPath)) {
return false;
}
try {
console.log(`Cleaning up backup at ${backupPath}`);
await fs.remove(backupPath);
console.log('Backup cleanup completed');
return true;
} catch (error) {
console.error('Error cleaning up backup:', error);
return false;
}
}
/**
* Check if UserData exists for a specific branch
* @param {string} installPath - Base installation path
* @param {string} branch - Branch name (release or pre-release)
* @returns {boolean} - True if UserData exists
*/
hasUserData(installPath, branch) {
const userDataPath = path.join(installPath, branch, 'package', 'game', 'latest', 'Client', 'UserData');
return fs.existsSync(userDataPath);
}
}
module.exports = new UserDataBackup();

BIN
build/icon.icns Normal file

Binary file not shown.

BIN
build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

359
main.js
View File

@@ -1,10 +1,22 @@
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '.env') });
const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron');
const { autoUpdater } = require('electron-updater');
const fs = require('fs');
const { launchGame, launchGameWithVersionCheck, installGame, saveUsername, loadUsername, saveChatUsername, loadChatUsername, saveChatColor, loadChatColor, saveJavaPath, loadJavaPath, saveInstallPath, loadInstallPath, saveDiscordRPC, loadDiscordRPC, saveLanguage, loadLanguage, saveCloseLauncherOnStart, loadCloseLauncherOnStart, isGameInstalled, uninstallGame, repairGame, getHytaleNews, handleFirstLaunchCheck, proposeGameUpdate, markAsLaunched } = require('./backend/launcher');
const { launchGame, launchGameWithVersionCheck, installGame, saveUsername, loadUsername, saveChatUsername, loadChatUsername, saveChatColor, loadChatColor, saveJavaPath, loadJavaPath, saveInstallPath, loadInstallPath, saveDiscordRPC, loadDiscordRPC, saveLanguage, loadLanguage, saveCloseLauncherOnStart, loadCloseLauncherOnStart, saveLauncherHardwareAcceleration, loadLauncherHardwareAcceleration, isGameInstalled, uninstallGame, repairGame, getHytaleNews, handleFirstLaunchCheck, proposeGameUpdate, markAsLaunched } = require('./backend/launcher');
const { retryPWRDownload } = require('./backend/managers/gameManager');
// Handle Hardware Acceleration
try {
const hwEnabled = loadLauncherHardwareAcceleration();
if (!hwEnabled) {
console.log('Hardware acceleration disabled by user setting');
app.disableHardwareAcceleration();
}
} catch (error) {
console.error('Failed to load hardware acceleration setting:', error);
}
const UpdateManager = require('./backend/updateManager');
const logger = require('./backend/logger');
const profileManager = require('./backend/managers/profileManager');
@@ -26,11 +38,10 @@ if (!gotTheLock) {
}
let mainWindow;
let updateManager;
let discordRPC = null;
// Discord Rich Presence setup
const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID;
const DISCORD_CLIENT_ID = "1462244937868513373";
function initDiscordRPC() {
try {
@@ -82,7 +93,7 @@ function setDiscordActivity() {
}
}
function toggleDiscordRPC(enabled) {
async function toggleDiscordRPC(enabled) {
console.log('Toggling Discord RPC:', enabled);
if (enabled && !discordRPC) {
@@ -92,11 +103,12 @@ function toggleDiscordRPC(enabled) {
try {
console.log('Disconnecting Discord RPC...');
discordRPC.clearActivity();
await new Promise(r => setTimeout(r, 100));
discordRPC.destroy();
discordRPC = null;
console.log('Discord RPC disconnected successfully');
} catch (error) {
console.error('Error disconnecting Discord RPC:', error.message);
} finally {
discordRPC = null;
}
}
@@ -162,12 +174,59 @@ function createWindow() {
// Initialize Discord Rich Presence
initDiscordRPC();
updateManager = new UpdateManager();
setTimeout(async () => {
const updateInfo = await updateManager.checkForUpdates();
if (updateInfo.updateAvailable) {
mainWindow.webContents.send('show-update-popup', updateInfo);
// Configure and initialize electron-updater
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.on('checking-for-update', () => {
console.log('Checking for launcher updates...');
});
autoUpdater.on('update-available', (info) => {
console.log('Update available:', info.version);
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('update-available', {
currentVersion: app.getVersion(),
newVersion: info.version,
releaseNotes: info.releaseNotes,
releaseDate: info.releaseDate
});
}
});
autoUpdater.on('update-not-available', (info) => {
console.log('Launcher is up to date:', info.version);
});
autoUpdater.on('error', (err) => {
console.error('Error in auto-updater:', err);
});
autoUpdater.on('download-progress', (progressObj) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('update-download-progress', {
percent: progressObj.percent,
transferred: progressObj.transferred,
total: progressObj.total,
bytesPerSecond: progressObj.bytesPerSecond
});
}
});
autoUpdater.on('update-downloaded', (info) => {
console.log('Update downloaded:', info.version);
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('update-downloaded', {
version: info.version
});
}
});
// Check for updates after 3 seconds
setTimeout(() => {
autoUpdater.checkForUpdates().catch(err => {
console.log('Failed to check for updates:', err.message);
});
}, 3000);
mainWindow.webContents.on('devtools-opened', () => {
@@ -263,9 +322,9 @@ app.whenReady().then(async () => {
mainWindow.webContents.send('lock-play-button', true);
}
const progressCallback = (message, percent, speed, downloaded, total) => {
const progressCallback = (message, percent, speed, downloaded, total, retryState) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('first-launch-progress', { message, percent, speed, downloaded, total });
mainWindow.webContents.send('first-launch-progress', { message, percent, speed, downloaded, total, retryState });
}
};
@@ -320,25 +379,20 @@ app.whenReady().then(async () => {
}, 3000);
});
function cleanupDiscordRPC() {
if (discordRPC) {
async function cleanupDiscordRPC() {
if (!discordRPC) return;
try {
console.log('Cleaning up Discord RPC...');
discordRPC.clearActivity();
setTimeout(() => {
try {
await new Promise(r => setTimeout(r, 100));
discordRPC.destroy();
} catch (error) {
console.log('Error during final Discord RPC cleanup:', error.message);
}
}, 100);
discordRPC = null;
console.log('Discord RPC cleaned up successfully');
} catch (error) {
console.log('Error cleaning up Discord RPC:', error.message);
} finally {
discordRPC = null;
}
}
}
app.on('before-quit', () => {
console.log('=== LAUNCHER BEFORE QUIT ===');
@@ -347,23 +401,21 @@ app.on('before-quit', () => {
app.on('window-all-closed', () => {
console.log('=== LAUNCHER CLOSING ===');
cleanupDiscordRPC();
app.quit();
});
ipcMain.handle('launch-game', async (event, playerName, javaPath, installPath, gpuPreference) => {
try {
const progressCallback = (message, percent, speed, downloaded, total) => {
const progressCallback = (message, percent, speed, downloaded, total, retryState) => {
if (mainWindow && !mainWindow.isDestroyed()) {
const data = {
message: message || null,
percent: percent !== null && percent !== undefined ? Math.min(100, Math.max(0, percent)) : null,
speed: speed !== null && speed !== undefined ? speed : null,
downloaded: downloaded !== null && downloaded !== undefined ? downloaded : null,
total: total !== null && total !== undefined ? total : null
total: total !== null && total !== undefined ? total : null,
retryState: retryState || null
};
mainWindow.webContents.send('progress-update', data);
}
@@ -397,44 +449,118 @@ ipcMain.handle('launch-game', async (event, playerName, javaPath, installPath, g
}
});
ipcMain.handle('install-game', async (event, playerName, javaPath, installPath) => {
ipcMain.handle('install-game', async (event, playerName, javaPath, installPath, branch) => {
try {
console.log(`[IPC] install-game called with parameters:`);
console.log(` - playerName: ${playerName}`);
console.log(` - javaPath: ${javaPath}`);
console.log(` - installPath: ${installPath}`);
console.log(` - branch: ${branch}`);
console.log(`[IPC] branch type: ${typeof branch}, value: ${JSON.stringify(branch)}`);
// Signal installation start
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('installation-start');
}
const progressCallback = (message, percent, speed, downloaded, total) => {
const progressCallback = (message, percent, speed, downloaded, total, retryState) => {
if (mainWindow && !mainWindow.isDestroyed()) {
const data = {
message: message || null,
percent: percent !== null && percent !== undefined ? Math.min(100, Math.max(0, percent)) : null,
speed: speed !== null && speed !== undefined ? speed : null,
downloaded: downloaded !== null && downloaded !== undefined ? downloaded : null,
total: total !== null && total !== undefined ? total : null
total: total !== null && total !== undefined ? total : null,
retryState: retryState || null
};
mainWindow.webContents.send('progress-update', data);
}
};
const result = await installGame(playerName, progressCallback, javaPath, installPath);
const result = await installGame(playerName, progressCallback, javaPath, installPath, branch);
// Signal installation end
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('installation-end');
}
return result;
// Ensure we always return a result for the IPC handler
const successResponse = result || { success: true };
console.log('[Main] Returning success response for install-game:', successResponse);
return successResponse;
} catch (error) {
console.error('Install error:', error);
// console.error('Install error:', error);
const errorMessage = error.message || error.toString();
// Enhanced error data extraction for both download and Butler errors
let errorData = {
message: errorMessage,
error: true,
canRetry: true, // Default to true, will be overridden by specific error props
retryData: null
};
// Prioritize JRE errors first
if (error.isJREError) {
console.log('[Main] Processing JRE download error with retry context');
errorData.retryData = {
isJREError: true,
jreUrl: error.jreUrl,
fileName: error.fileName,
cacheDir: error.cacheDir,
osName: error.osName,
arch: error.arch
};
// For JRE errors, allow manual retry unless explicitly disabled
errorData.canRetry = error.canRetry !== false;
errorData.errorType = 'jre';
}
// Handle Butler-specific errors
else if (error.butlerError) {
console.log('[Main] Processing Butler error with retry context');
errorData.retryData = {
branch: error.branch || 'release',
fileName: error.fileName || '4.pwr',
cacheDir: error.cacheDir
};
errorData.canRetry = error.canRetry !== false;
}
// Handle PWR download errors
else if (error.branch && error.fileName) {
console.log('[Main] Processing PWR download error with retry context');
errorData.retryData = {
branch: error.branch,
fileName: error.fileName,
cacheDir: error.cacheDir
};
errorData.canRetry = error.canRetry !== false;
}
// Default fallback for other errors
else {
console.log('[Main] Processing generic error, creating default retry data');
errorData.retryData = {
branch: 'release',
fileName: '4.pwr'
};
// For generic errors, assume it's retryable unless specified
errorData.canRetry = error.canRetry !== false;
}
// Send enhanced error info for retry UI
if (mainWindow && !mainWindow.isDestroyed()) {
console.log('[Main] Sending error data to renderer:', errorData);
mainWindow.webContents.send('progress-update', errorData);
}
// Signal installation end on error too
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('installation-end');
}
return { success: false, error: errorMessage };
// Always return a proper response to prevent timeout
const errorResponse = { success: false, error: errorMessage };
console.log('[Main] Returning error response for install-game:', errorResponse);
return errorResponse;
}
});
@@ -510,6 +636,15 @@ ipcMain.handle('load-close-launcher', () => {
return loadCloseLauncherOnStart();
});
ipcMain.handle('save-launcher-hw-accel', (event, enabled) => {
saveLauncherHardwareAcceleration(enabled);
return { success: true };
});
ipcMain.handle('load-launcher-hw-accel', () => {
return loadLauncherHardwareAcceleration();
});
ipcMain.handle('select-install-path', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
@@ -525,14 +660,15 @@ ipcMain.handle('select-install-path', async () => {
ipcMain.handle('accept-first-launch-update', async (event, existingGame) => {
try {
const progressCallback = (message, percent, speed, downloaded, total) => {
const progressCallback = (message, percent, speed, downloaded, total, retryState) => {
if (mainWindow && !mainWindow.isDestroyed()) {
const data = {
message: message || null,
percent: percent !== null && percent !== undefined ? Math.min(100, Math.max(0, percent)) : null,
speed: speed !== null && speed !== undefined ? speed : null,
downloaded: downloaded !== null && downloaded !== undefined ? downloaded : null,
total: total !== null && total !== undefined ? total : null
total: total !== null && total !== undefined ? total : null,
retryState: retryState || null
};
mainWindow.webContents.send('first-launch-progress', data);
}
@@ -574,21 +710,22 @@ ipcMain.handle('uninstall-game', async () => {
try {
await uninstallGame();
} catch (error) {
console.error('Uninstall error:', error);
// console.error('Uninstall error:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('repair-game', async () => {
try {
const progressCallback = (message, percent, speed, downloaded, total) => {
const progressCallback = (message, percent, speed, downloaded, total, retryState) => {
if (mainWindow && !mainWindow.isDestroyed()) {
const data = {
message: message || null,
percent: percent !== null && percent !== undefined ? Math.min(100, Math.max(0, percent)) : null,
speed: speed !== null && speed !== undefined ? speed : null,
downloaded: downloaded !== null && downloaded !== undefined ? downloaded : null,
total: total !== null && total !== undefined ? total : null
total: total !== null && total !== undefined ? total : null,
retryState: retryState || null
};
mainWindow.webContents.send('progress-update', data);
}
@@ -598,7 +735,98 @@ ipcMain.handle('repair-game', async () => {
return result;
} catch (error) {
console.error('Repair error:', error);
return { success: false, error: error.message };
const errorMessage = error.message || error.toString();
return { success: false, error: errorMessage };
}
});
ipcMain.handle('retry-download', async (event, retryData) => {
try {
console.log('[IPC] retry-download called with data:', retryData);
const progressCallback = (message, percent, speed, downloaded, total, retryState) => {
if (mainWindow && !mainWindow.isDestroyed()) {
const data = {
message: message || null,
percent: percent !== null && percent !== undefined ? Math.min(100, Math.max(0, percent)) : null,
speed: speed !== null && speed !== undefined ? speed : null,
downloaded: downloaded !== null && downloaded !== undefined ? downloaded : null,
total: total !== null && total !== undefined ? total : null,
retryState: retryState || null
};
mainWindow.webContents.send('progress-update', data);
}
};
// Handle JRE download retries
if (retryData && retryData.isJREError) {
console.log(`[IPC] Retrying JRE download: jreUrl=${retryData.jreUrl}, fileName=${retryData.fileName}`);
console.log('[IPC] Full JRE retry data:', JSON.stringify(retryData, null, 2));
const { retryJREDownload } = require('./backend/managers/javaManager');
const jreCacheFile = path.join(retryData.cacheDir, retryData.fileName);
await retryJREDownload(retryData.jreUrl, jreCacheFile, progressCallback);
return { success: true };
}
// Handle PWR download retries (default)
if (!retryData || !retryData.branch || !retryData.fileName) {
console.log('[IPC] Invalid retry data, using PWR defaults');
retryData = {
branch: 'release',
fileName: '4.pwr'
};
}
// Extract PWR download info from retryData
const branch = retryData.branch;
const fileName = retryData.fileName;
const cacheDir = retryData.cacheDir;
console.log(`[IPC] Retrying PWR download: branch=${branch}, fileName=${fileName}`);
console.log('[IPC] Full PWR retry data:', JSON.stringify(retryData, null, 2));
// Perform retry with enhanced context
await retryPWRDownload(branch, fileName, progressCallback, cacheDir);
return { success: true };
} catch (error) {
console.error('Retry download error:', error);
const errorMessage = error.message || error.toString();
// Send error update to frontend with context
if (mainWindow && !mainWindow.isDestroyed()) {
const isJreError = retryData?.isJREError;
const errorRetryData = isJreError ?
{
isJREError: true,
jreUrl: retryData?.jreUrl,
fileName: retryData?.fileName,
cacheDir: retryData?.cacheDir,
osName: retryData?.osName,
arch: retryData?.arch
} :
{
branch: retryData?.branch || 'release',
fileName: retryData?.fileName || '4.pwr',
cacheDir: retryData?.cacheDir
};
const data = {
message: errorMessage,
error: true,
canRetry: error.canRetry !== false, // Respect canRetry from the thrown error
retryData: errorRetryData,
errorType: isJreError ? 'jre' : 'general' // Add errorType for the UI
};
mainWindow.webContents.send('progress-update', data);
}
// Always return a proper response to prevent timeout
const errorResponse = { success: false, error: errorMessage };
console.log('[Main] Returning error response for retry-download:', errorResponse);
return errorResponse;
}
});
@@ -624,8 +852,9 @@ ipcMain.handle('open-external', async (event, url) => {
ipcMain.handle('open-game-location', async () => {
try {
const { getResolvedAppDir } = require('./backend/launcher');
const gameDir = path.join(getResolvedAppDir(), 'release', 'package', 'game');
const { getResolvedAppDir, loadVersionBranch } = require('./backend/launcher');
const branch = loadVersionBranch();
const gameDir = path.join(getResolvedAppDir(), branch, 'package', 'game');
if (fs.existsSync(gameDir)) {
await shell.openPath(gameDir);
@@ -823,33 +1052,37 @@ ipcMain.handle('copy-mod-file', async (event, sourcePath, modsPath) => {
}
});
// Electron-updater IPC handlers
ipcMain.handle('check-for-updates', async () => {
try {
return await updateManager.checkForUpdates();
const result = await autoUpdater.checkForUpdates();
return {
updateAvailable: result && result.updateInfo,
currentVersion: app.getVersion(),
updateInfo: result ? result.updateInfo : null
};
} catch (error) {
console.error('Error checking for updates:', error);
return { updateAvailable: false, error: error.message };
}
});
ipcMain.handle('open-download-page', async () => {
ipcMain.handle('download-update', async () => {
try {
await shell.openExternal(updateManager.getDownloadUrl());
setTimeout(() => {
app.quit();
}, 1000);
await autoUpdater.downloadUpdate();
return { success: true };
} catch (error) {
console.error('Error opening download page:', error);
console.error('Error downloading update:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('get-update-info', async () => {
return updateManager.getUpdateInfo();
ipcMain.handle('install-update', () => {
autoUpdater.quitAndInstall(false, true);
});
ipcMain.handle('get-launcher-version', () => {
return app.getVersion();
});
ipcMain.handle('get-gpu-info', () => {
@@ -881,6 +1114,22 @@ ipcMain.handle('get-detected-gpu', () => {
return global.detectedGpu;
});
ipcMain.handle('save-version-branch', (event, branch) => {
const { saveVersionBranch } = require('./backend/launcher');
saveVersionBranch(branch);
return { success: true };
});
ipcMain.handle('load-version-branch', () => {
const { loadVersionBranch } = require('./backend/launcher');
return loadVersionBranch();
});
ipcMain.handle('load-version-client', () => {
const { loadVersionClient } = require('./backend/launcher');
return loadVersionClient();
});
ipcMain.handle('window-close', () => {
app.quit();
});

143
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "hytale-f2p-launcher",
"version": "2.0.11",
"version": "2.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hytale-f2p-launcher",
"version": "2.0.11",
"version": "2.1.0",
"license": "MIT",
"dependencies": {
"adm-zip": "^0.5.10",
@@ -14,6 +14,7 @@
"discord-rpc": "^4.0.1",
"dotenv": "^17.2.3",
"electron-updater": "^6.7.3",
"fs-extra": "^11.3.3",
"tar": "^6.2.1",
"uuid": "^9.0.1"
},
@@ -147,6 +148,21 @@
"global-agent": "^3.0.0"
}
},
"node_modules/@electron/get/node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/@electron/notarize": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz",
@@ -345,34 +361,6 @@
"balanced-match": "^1.0.0"
}
},
"node_modules/@electron/universal/node_modules/fs-extra": {
"version": "11.3.3",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz",
"integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/@electron/universal/node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/@electron/universal/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@@ -389,16 +377,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@electron/universal/node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/@electron/windows-sign": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz",
@@ -421,50 +399,6 @@
"node": ">=14.14"
}
},
"node_modules/@electron/windows-sign/node_modules/fs-extra": {
"version": "11.3.3",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz",
"integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/@electron/windows-sign/node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/@electron/windows-sign/node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
@@ -2612,18 +2546,38 @@
}
},
"node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"dev": true,
"version": "11.3.3",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz",
"integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=6 <7 || >=8"
"node": ">=14.14"
}
},
"node_modules/fs-extra/node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/fs-extra/node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/fs-minipass": {
@@ -3238,9 +3192,9 @@
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"dev": true,
"license": "MIT"
},
@@ -4530,6 +4484,7 @@
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
"deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me",
"license": "ISC",
"dependencies": {
"chownr": "^2.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "hytale-f2p-launcher",
"version": "2.0.11",
"version": "2.1.0",
"description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support",
"homepage": "https://github.com/amiayweb/Hytale-F2P",
"main": "main.js",
@@ -50,6 +50,7 @@
"discord-rpc": "^4.0.1",
"dotenv": "^17.2.3",
"electron-updater": "^6.7.3",
"fs-extra": "^11.3.3",
"tar": "^6.2.1",
"uuid": "^9.0.1"
},

View File

@@ -2,7 +2,7 @@ const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
launchGame: (playerName, javaPath, installPath, gpuPreference) => ipcRenderer.invoke('launch-game', playerName, javaPath, installPath, gpuPreference),
installGame: (playerName, javaPath, installPath) => ipcRenderer.invoke('install-game', playerName, javaPath, installPath),
installGame: (playerName, javaPath, installPath, branch) => ipcRenderer.invoke('install-game', playerName, javaPath, installPath, branch),
closeWindow: () => ipcRenderer.invoke('window-close'),
minimizeWindow: () => ipcRenderer.invoke('window-minimize'),
maximizeWindow: () => ipcRenderer.invoke('window-maximize'),
@@ -23,11 +23,17 @@ contextBridge.exposeInMainWorld('electronAPI', {
loadLanguage: () => ipcRenderer.invoke('load-language'),
saveCloseLauncher: (enabled) => ipcRenderer.invoke('save-close-launcher', enabled),
loadCloseLauncher: () => ipcRenderer.invoke('load-close-launcher'),
// Harwadre Acceleration
saveLauncherHardwareAcceleration: (enabled) => ipcRenderer.invoke('save-launcher-hw-accel', enabled),
loadLauncherHardwareAcceleration: () => ipcRenderer.invoke('load-launcher-hw-accel'),
selectInstallPath: () => ipcRenderer.invoke('select-install-path'),
browseJavaPath: () => ipcRenderer.invoke('browse-java-path'),
isGameInstalled: () => ipcRenderer.invoke('is-game-installed'),
uninstallGame: () => ipcRenderer.invoke('uninstall-game'),
repairGame: () => ipcRenderer.invoke('repair-game'),
retryDownload: (retryData) => ipcRenderer.invoke('retry-download', retryData),
getHytaleNews: () => ipcRenderer.invoke('get-hytale-news'),
openExternal: (url) => ipcRenderer.invoke('open-external', url),
openExternalLink: (url) => ipcRenderer.invoke('openExternalLink', url),
@@ -44,7 +50,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
selectModFiles: () => ipcRenderer.invoke('select-mod-files'),
copyModFile: (sourcePath, modsPath) => ipcRenderer.invoke('copy-mod-file', sourcePath, modsPath),
onProgressUpdate: (callback) => {
ipcRenderer.on('progress-update', (event, data) => callback(data));
ipcRenderer.on('progress-update', (event, data) => {
// Ensure data includes retry state if available
if (data && typeof data === 'object') {
callback(data);
} else {
callback(data);
}
});
},
onProgressComplete: (callback) => {
ipcRenderer.on('progress-complete', () => callback());
@@ -68,6 +81,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
loadGpuPreference: () => ipcRenderer.invoke('load-gpu-preference'),
getDetectedGpu: () => ipcRenderer.invoke('get-detected-gpu'),
saveVersionBranch: (branch) => ipcRenderer.invoke('save-version-branch', branch),
loadVersionBranch: () => ipcRenderer.invoke('load-version-branch'),
loadVersionClient: () => ipcRenderer.invoke('load-version-client'),
acceptFirstLaunchUpdate: (existingGame) => ipcRenderer.invoke('accept-first-launch-update', existingGame),
markAsLaunched: () => ipcRenderer.invoke('mark-as-launched'),
onFirstLaunchUpdate: (callback) => {
@@ -103,5 +120,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
activate: (id) => ipcRenderer.invoke('profile-activate', id),
delete: (id) => ipcRenderer.invoke('profile-delete', id),
update: (id, updates) => ipcRenderer.invoke('profile-update', id, updates)
},
// Launcher Update API
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
downloadUpdate: () => ipcRenderer.invoke('download-update'),
installUpdate: () => ipcRenderer.invoke('install-update'),
getLauncherVersion: () => ipcRenderer.invoke('get-launcher-version'),
onUpdateAvailable: (callback) => {
ipcRenderer.on('update-available', (event, data) => callback(data));
},
onUpdateDownloadProgress: (callback) => {
ipcRenderer.on('update-download-progress', (event, data) => callback(data));
},
onUpdateDownloaded: (callback) => {
ipcRenderer.on('update-downloaded', (event, data) => callback(data));
},
onUpdateError: (callback) => {
ipcRenderer.on('update-error', (event, data) => callback(data));
}
});